source_monitor 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rubocop.yml +12 -0
  4. data/.ruby-version +1 -0
  5. data/AGENTS.md +132 -0
  6. data/CHANGELOG.md +66 -0
  7. data/CONTRIBUTING.md +31 -0
  8. data/Gemfile +30 -0
  9. data/Gemfile.lock +411 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +108 -0
  12. data/Rakefile +8 -0
  13. data/app/assets/builds/.keep +0 -0
  14. data/app/assets/config/source_monitor_manifest.js +4 -0
  15. data/app/assets/images/source_monitor/.keep +0 -0
  16. data/app/assets/javascripts/source_monitor/application.js +20 -0
  17. data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
  18. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
  19. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
  21. data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
  22. data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
  23. data/app/assets/svgs/source_monitor/.keep +0 -0
  24. data/app/controllers/concerns/.keep +0 -0
  25. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
  26. data/app/controllers/source_monitor/application_controller.rb +62 -0
  27. data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
  28. data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
  29. data/app/controllers/source_monitor/health_controller.rb +10 -0
  30. data/app/controllers/source_monitor/items_controller.rb +116 -0
  31. data/app/controllers/source_monitor/logs_controller.rb +15 -0
  32. data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
  33. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
  34. data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
  35. data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
  36. data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
  37. data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
  38. data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
  39. data/app/controllers/source_monitor/sources_controller.rb +179 -0
  40. data/app/helpers/source_monitor/application_helper.rb +327 -0
  41. data/app/jobs/source_monitor/application_job.rb +13 -0
  42. data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
  43. data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
  44. data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
  45. data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
  46. data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
  47. data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
  48. data/app/mailers/source_monitor/application_mailer.rb +17 -0
  49. data/app/models/concerns/.keep +0 -0
  50. data/app/models/concerns/source_monitor/loggable.rb +18 -0
  51. data/app/models/source_monitor/application_record.rb +5 -0
  52. data/app/models/source_monitor/fetch_log.rb +31 -0
  53. data/app/models/source_monitor/health_check_log.rb +28 -0
  54. data/app/models/source_monitor/item.rb +102 -0
  55. data/app/models/source_monitor/item_content.rb +11 -0
  56. data/app/models/source_monitor/log_entry.rb +56 -0
  57. data/app/models/source_monitor/scrape_log.rb +31 -0
  58. data/app/models/source_monitor/source.rb +115 -0
  59. data/app/views/layouts/source_monitor/application.html.erb +54 -0
  60. data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
  61. data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
  62. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
  63. data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
  64. data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
  65. data/app/views/source_monitor/dashboard/index.html.erb +48 -0
  66. data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
  67. data/app/views/source_monitor/items/_details.html.erb +234 -0
  68. data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
  69. data/app/views/source_monitor/items/index.html.erb +147 -0
  70. data/app/views/source_monitor/items/show.html.erb +3 -0
  71. data/app/views/source_monitor/logs/index.html.erb +208 -0
  72. data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
  73. data/app/views/source_monitor/shared/_toast.html.erb +34 -0
  74. data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
  75. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
  76. data/app/views/source_monitor/sources/_details.html.erb +302 -0
  77. data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
  78. data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
  79. data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
  80. data/app/views/source_monitor/sources/_form.html.erb +143 -0
  81. data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
  82. data/app/views/source_monitor/sources/_row.html.erb +102 -0
  83. data/app/views/source_monitor/sources/edit.html.erb +28 -0
  84. data/app/views/source_monitor/sources/index.html.erb +153 -0
  85. data/app/views/source_monitor/sources/new.html.erb +22 -0
  86. data/app/views/source_monitor/sources/show.html.erb +3 -0
  87. data/config/coverage_baseline.json +2010 -0
  88. data/config/initializers/feedjira.rb +19 -0
  89. data/config/routes.rb +18 -0
  90. data/config/tailwind.config.js +17 -0
  91. data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
  92. data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
  93. data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
  94. data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
  95. data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
  96. data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
  97. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
  98. data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
  99. data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
  100. data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
  101. data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
  102. data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
  103. data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
  104. data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
  105. data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
  106. data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
  107. data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
  108. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
  109. data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
  110. data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
  111. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
  112. data/docs/configuration.md +170 -0
  113. data/docs/deployment.md +63 -0
  114. data/docs/gh-cli-workflow.md +44 -0
  115. data/docs/installation.md +144 -0
  116. data/docs/troubleshooting.md +76 -0
  117. data/eslint.config.mjs +27 -0
  118. data/lib/generators/source_monitor/install/install_generator.rb +59 -0
  119. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
  120. data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
  121. data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
  122. data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
  123. data/lib/source_monitor/assets/bundler.rb +49 -0
  124. data/lib/source_monitor/assets.rb +6 -0
  125. data/lib/source_monitor/configuration.rb +654 -0
  126. data/lib/source_monitor/dashboard/queries.rb +356 -0
  127. data/lib/source_monitor/dashboard/quick_action.rb +7 -0
  128. data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
  129. data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
  130. data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
  131. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
  132. data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
  133. data/lib/source_monitor/engine.rb +107 -0
  134. data/lib/source_monitor/events.rb +110 -0
  135. data/lib/source_monitor/feedjira_extensions.rb +103 -0
  136. data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
  137. data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
  138. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
  139. data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
  140. data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
  141. data/lib/source_monitor/fetching/fetch_error.rb +88 -0
  142. data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
  143. data/lib/source_monitor/fetching/retry_policy.rb +85 -0
  144. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
  145. data/lib/source_monitor/health/source_health_check.rb +100 -0
  146. data/lib/source_monitor/health/source_health_monitor.rb +210 -0
  147. data/lib/source_monitor/health/source_health_reset.rb +68 -0
  148. data/lib/source_monitor/health.rb +46 -0
  149. data/lib/source_monitor/http.rb +85 -0
  150. data/lib/source_monitor/instrumentation.rb +52 -0
  151. data/lib/source_monitor/items/item_creator.rb +601 -0
  152. data/lib/source_monitor/items/retention_pruner.rb +146 -0
  153. data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
  154. data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
  155. data/lib/source_monitor/items/retention_strategies.rb +9 -0
  156. data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
  157. data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
  158. data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
  159. data/lib/source_monitor/jobs/visibility.rb +133 -0
  160. data/lib/source_monitor/logs/entry_sync.rb +69 -0
  161. data/lib/source_monitor/logs/filter_set.rb +163 -0
  162. data/lib/source_monitor/logs/query.rb +81 -0
  163. data/lib/source_monitor/logs/table_presenter.rb +161 -0
  164. data/lib/source_monitor/metrics.rb +77 -0
  165. data/lib/source_monitor/model_extensions.rb +109 -0
  166. data/lib/source_monitor/models/sanitizable.rb +76 -0
  167. data/lib/source_monitor/models/url_normalizable.rb +84 -0
  168. data/lib/source_monitor/pagination/paginator.rb +90 -0
  169. data/lib/source_monitor/realtime/adapter.rb +97 -0
  170. data/lib/source_monitor/realtime/broadcaster.rb +237 -0
  171. data/lib/source_monitor/realtime.rb +17 -0
  172. data/lib/source_monitor/release/changelog.rb +59 -0
  173. data/lib/source_monitor/release/runner.rb +73 -0
  174. data/lib/source_monitor/scheduler.rb +82 -0
  175. data/lib/source_monitor/scrapers/base.rb +105 -0
  176. data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
  177. data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
  178. data/lib/source_monitor/scrapers/readability.rb +156 -0
  179. data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
  180. data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
  181. data/lib/source_monitor/scraping/enqueuer.rb +125 -0
  182. data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
  183. data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
  184. data/lib/source_monitor/scraping/item_scraper.rb +84 -0
  185. data/lib/source_monitor/scraping/scheduler.rb +43 -0
  186. data/lib/source_monitor/scraping/state.rb +79 -0
  187. data/lib/source_monitor/security/authentication.rb +85 -0
  188. data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
  189. data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
  190. data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
  191. data/lib/source_monitor/version.rb +3 -0
  192. data/lib/source_monitor.rb +149 -0
  193. data/lib/tasks/recover_stalled_fetches.rake +16 -0
  194. data/lib/tasks/source_monitor_assets.rake +28 -0
  195. data/lib/tasks/source_monitor_tasks.rake +29 -0
  196. data/lib/tasks/test_smoke.rake +12 -0
  197. data/package-lock.json +3997 -0
  198. data/package.json +29 -0
  199. data/postcss.config.js +6 -0
  200. data/source_monitor.gemspec +46 -0
  201. data/stylelint.config.js +12 -0
  202. metadata +469 -0
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "source_monitor/dashboard/upcoming_fetch_schedule"
5
+
6
+ module SourceMonitor
7
+ module Dashboard
8
+ class Queries
9
+ def initialize(reference_time: Time.current)
10
+ @reference_time = reference_time
11
+ @cache = Cache.new
12
+ end
13
+
14
+ def stats
15
+ cache.fetch(:stats) do
16
+ measure(:stats) do
17
+ StatsQuery.new(reference_time:).call
18
+ end
19
+ end
20
+ end
21
+
22
+ def recent_activity(limit: DEFAULT_RECENT_ACTIVITY_LIMIT)
23
+ cache.fetch([ :recent_activity, limit ]) do
24
+ measure(:recent_activity, limit:) do
25
+ RecentActivityQuery.new(limit:).call
26
+ end
27
+ end
28
+ end
29
+
30
+ def quick_actions
31
+ QUICK_ACTIONS
32
+ end
33
+
34
+ def job_metrics(queue_names: queue_name_map.values)
35
+ measure(:job_metrics, queue_names:) do
36
+ summaries = SourceMonitor::Jobs::SolidQueueMetrics.call(queue_names:)
37
+
38
+ queue_name_map.map do |role, queue_name|
39
+ summary = summaries[queue_name.to_s] || SourceMonitor::Jobs::SolidQueueMetrics::QueueSummary.new(
40
+ queue_name: queue_name.to_s,
41
+ ready_count: 0,
42
+ scheduled_count: 0,
43
+ failed_count: 0,
44
+ recurring_count: 0,
45
+ paused: false,
46
+ last_enqueued_at: nil,
47
+ last_started_at: nil,
48
+ last_finished_at: nil,
49
+ available: false
50
+ )
51
+
52
+ {
53
+ role: role,
54
+ queue_name: queue_name,
55
+ summary: summary
56
+ }
57
+ end
58
+ end
59
+ end
60
+
61
+ def upcoming_fetch_schedule
62
+ cache.fetch(:upcoming_fetch_schedule) do
63
+ measure(:upcoming_fetch_schedule) do
64
+ SourceMonitor::Dashboard::UpcomingFetchSchedule.new(scope: SourceMonitor::Source.active)
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ DEFAULT_RECENT_ACTIVITY_LIMIT = 8
72
+
73
+ attr_reader :reference_time, :cache
74
+
75
+ def measure(name, metadata = {})
76
+ started_at = monotonic_time
77
+ result = yield
78
+ duration_ms = ((monotonic_time - started_at) * 1000.0).round(2)
79
+ recorded_at = Time.current
80
+
81
+ payload = metadata.merge(duration_ms:, recorded_at:)
82
+ ActiveSupport::Notifications.instrument("source_monitor.dashboard.#{name}", payload)
83
+ record_metrics(name, result, duration_ms:, recorded_at:, metadata:)
84
+
85
+ result
86
+ end
87
+
88
+ def record_metrics(name, result, duration_ms:, recorded_at:, metadata:)
89
+ SourceMonitor::Metrics.gauge(:"dashboard_#{name}_duration_ms", duration_ms)
90
+ SourceMonitor::Metrics.gauge(:"dashboard_#{name}_last_run_at_epoch", recorded_at.to_f)
91
+
92
+ case name
93
+ when :stats
94
+ record_stats_metrics(result)
95
+ when :recent_activity
96
+ SourceMonitor::Metrics.gauge(:dashboard_recent_activity_events_count, result.size)
97
+ SourceMonitor::Metrics.gauge(:dashboard_recent_activity_limit, metadata[:limit]) if metadata[:limit]
98
+ when :job_metrics
99
+ SourceMonitor::Metrics.gauge(:dashboard_job_metrics_queue_count, result.size)
100
+ when :upcoming_fetch_schedule
101
+ SourceMonitor::Metrics.gauge(:dashboard_fetch_schedule_group_count, result.groups.size)
102
+ end
103
+ end
104
+
105
+ def record_stats_metrics(stats)
106
+ SourceMonitor::Metrics.gauge(:dashboard_stats_total_sources, stats[:total_sources])
107
+ SourceMonitor::Metrics.gauge(:dashboard_stats_active_sources, stats[:active_sources])
108
+ SourceMonitor::Metrics.gauge(:dashboard_stats_failed_sources, stats[:failed_sources])
109
+ SourceMonitor::Metrics.gauge(:dashboard_stats_total_items, stats[:total_items])
110
+ SourceMonitor::Metrics.gauge(:dashboard_stats_fetches_today, stats[:fetches_today])
111
+ end
112
+
113
+ def queue_name_map
114
+ @queue_name_map ||= {
115
+ fetch: SourceMonitor.queue_name(:fetch),
116
+ scrape: SourceMonitor.queue_name(:scrape)
117
+ }
118
+ end
119
+
120
+ def monotonic_time
121
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+ end
123
+
124
+ class Cache
125
+ def initialize
126
+ @store = {}
127
+ end
128
+
129
+ def fetch(key)
130
+ if store.key?(key)
131
+ store.fetch(key)
132
+ else
133
+ store[key] = yield
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ attr_reader :store
140
+ end
141
+
142
+ class StatsQuery
143
+ def initialize(reference_time:)
144
+ @reference_time = reference_time
145
+ end
146
+
147
+ def call
148
+ {
149
+ total_sources: integer_value(source_counts["total_sources"]),
150
+ active_sources: integer_value(source_counts["active_sources"]),
151
+ failed_sources: integer_value(source_counts["failed_sources"]),
152
+ total_items: total_items_count,
153
+ fetches_today: fetches_today_count
154
+ }
155
+ end
156
+
157
+ private
158
+
159
+ attr_reader :reference_time
160
+
161
+ def source_counts
162
+ @source_counts ||= begin
163
+ SourceMonitor::Source.connection.exec_query(source_counts_sql).first || {}
164
+ end
165
+ end
166
+
167
+ def total_items_count
168
+ SourceMonitor::Item.connection.select_value(total_items_sql).to_i
169
+ end
170
+
171
+ def fetches_today_count
172
+ SourceMonitor::FetchLog.where("started_at >= ?", start_of_day).count
173
+ end
174
+
175
+ def source_counts_sql
176
+ <<~SQL.squish
177
+ SELECT
178
+ COUNT(*) AS total_sources,
179
+ SUM(CASE WHEN active THEN 1 ELSE 0 END) AS active_sources,
180
+ SUM(CASE WHEN (#{failure_condition}) THEN 1 ELSE 0 END) AS failed_sources
181
+ FROM #{SourceMonitor::Source.quoted_table_name}
182
+ SQL
183
+ end
184
+
185
+ def failure_condition
186
+ [
187
+ "#{SourceMonitor::Source.quoted_table_name}.failure_count > 0",
188
+ "#{SourceMonitor::Source.quoted_table_name}.last_error IS NOT NULL",
189
+ "#{SourceMonitor::Source.quoted_table_name}.last_error_at IS NOT NULL"
190
+ ].join(" OR ")
191
+ end
192
+
193
+ def total_items_sql
194
+ "SELECT COUNT(*) FROM #{SourceMonitor::Item.quoted_table_name}"
195
+ end
196
+
197
+ def start_of_day
198
+ reference_time.in_time_zone.beginning_of_day
199
+ end
200
+
201
+ def integer_value(value)
202
+ value.to_i
203
+ end
204
+ end
205
+
206
+ class RecentActivityQuery
207
+ EVENT_TYPE_FETCH = "fetch_log"
208
+ EVENT_TYPE_SCRAPE = "scrape_log"
209
+ EVENT_TYPE_ITEM = "item"
210
+
211
+ def initialize(limit:)
212
+ @limit = limit
213
+ end
214
+
215
+ def call
216
+ rows = connection.exec_query(sanitized_sql)
217
+ rows.map { |row| build_event(row) }
218
+ end
219
+
220
+ private
221
+
222
+ attr_reader :limit
223
+
224
+ def connection
225
+ ActiveRecord::Base.connection
226
+ end
227
+
228
+ def build_event(row)
229
+ SourceMonitor::Dashboard::RecentActivity::Event.new(
230
+ type: row["resource_type"].to_sym,
231
+ id: row["resource_id"],
232
+ occurred_at: row["occurred_at"],
233
+ success: row["success_flag"].to_i == 1,
234
+ items_created: row["items_created"],
235
+ items_updated: row["items_updated"],
236
+ scraper_adapter: row["scraper_adapter"],
237
+ item_title: row["item_title"],
238
+ item_url: row["item_url"],
239
+ source_name: row["source_name"],
240
+ source_id: row["source_id"]
241
+ )
242
+ end
243
+
244
+ def sanitized_sql
245
+ ActiveRecord::Base.send(:sanitize_sql_array, [ unified_sql_template, limit ])
246
+ end
247
+
248
+ def unified_sql_template
249
+ <<~SQL
250
+ SELECT resource_type,
251
+ resource_id,
252
+ occurred_at,
253
+ success_flag,
254
+ items_created,
255
+ items_updated,
256
+ scraper_adapter,
257
+ item_title,
258
+ item_url,
259
+ source_name,
260
+ source_id
261
+ FROM (
262
+ #{fetch_log_sql}
263
+ UNION ALL
264
+ #{scrape_log_sql}
265
+ UNION ALL
266
+ #{item_sql}
267
+ ) AS dashboard_events
268
+ WHERE occurred_at IS NOT NULL
269
+ ORDER BY occurred_at DESC
270
+ LIMIT ?
271
+ SQL
272
+ end
273
+
274
+ def fetch_log_sql
275
+ <<~SQL
276
+ SELECT
277
+ '#{EVENT_TYPE_FETCH}' AS resource_type,
278
+ #{SourceMonitor::FetchLog.quoted_table_name}.id AS resource_id,
279
+ #{SourceMonitor::FetchLog.quoted_table_name}.started_at AS occurred_at,
280
+ CASE WHEN #{SourceMonitor::FetchLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
281
+ #{SourceMonitor::FetchLog.quoted_table_name}.items_created AS items_created,
282
+ #{SourceMonitor::FetchLog.quoted_table_name}.items_updated AS items_updated,
283
+ NULL AS scraper_adapter,
284
+ NULL AS item_title,
285
+ NULL AS item_url,
286
+ NULL AS source_name,
287
+ #{SourceMonitor::FetchLog.quoted_table_name}.source_id AS source_id
288
+ FROM #{SourceMonitor::FetchLog.quoted_table_name}
289
+ SQL
290
+ end
291
+
292
+ def scrape_log_sql
293
+ <<~SQL
294
+ SELECT
295
+ '#{EVENT_TYPE_SCRAPE}' AS resource_type,
296
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.id AS resource_id,
297
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.started_at AS occurred_at,
298
+ CASE WHEN #{SourceMonitor::ScrapeLog.quoted_table_name}.success THEN 1 ELSE 0 END AS success_flag,
299
+ NULL AS items_created,
300
+ NULL AS items_updated,
301
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.scraper_adapter AS scraper_adapter,
302
+ NULL AS item_title,
303
+ NULL AS item_url,
304
+ #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
305
+ #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id AS source_id
306
+ FROM #{SourceMonitor::ScrapeLog.quoted_table_name}
307
+ LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
308
+ ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::ScrapeLog.quoted_table_name}.source_id
309
+ SQL
310
+ end
311
+
312
+ def item_sql
313
+ <<~SQL
314
+ SELECT
315
+ '#{EVENT_TYPE_ITEM}' AS resource_type,
316
+ #{SourceMonitor::Item.quoted_table_name}.id AS resource_id,
317
+ #{SourceMonitor::Item.quoted_table_name}.created_at AS occurred_at,
318
+ 1 AS success_flag,
319
+ NULL AS items_created,
320
+ NULL AS items_updated,
321
+ NULL AS scraper_adapter,
322
+ #{SourceMonitor::Item.quoted_table_name}.title AS item_title,
323
+ #{SourceMonitor::Item.quoted_table_name}.url AS item_url,
324
+ #{SourceMonitor::Source.quoted_table_name}.#{quoted_source_name} AS source_name,
325
+ #{SourceMonitor::Item.quoted_table_name}.source_id AS source_id
326
+ FROM #{SourceMonitor::Item.quoted_table_name}
327
+ LEFT JOIN #{SourceMonitor::Source.quoted_table_name}
328
+ ON #{SourceMonitor::Source.quoted_table_name}.id = #{SourceMonitor::Item.quoted_table_name}.source_id
329
+ SQL
330
+ end
331
+
332
+ def quoted_source_name
333
+ ActiveRecord::Base.connection.quote_column_name("name")
334
+ end
335
+ end
336
+
337
+ QUICK_ACTIONS = [
338
+ SourceMonitor::Dashboard::QuickAction.new(
339
+ label: "Add Source",
340
+ description: "Create a new feed source",
341
+ route_name: :new_source_path
342
+ ).freeze,
343
+ SourceMonitor::Dashboard::QuickAction.new(
344
+ label: "View Sources",
345
+ description: "Manage existing sources",
346
+ route_name: :sources_path
347
+ ).freeze,
348
+ SourceMonitor::Dashboard::QuickAction.new(
349
+ label: "Check Health",
350
+ description: "Verify engine status",
351
+ route_name: :health_path
352
+ ).freeze
353
+ ].freeze
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ QuickAction = Struct.new(:label, :description, :route_name, keyword_init: true)
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ class QuickActionsPresenter
6
+ def initialize(actions, url_helpers:)
7
+ @actions = actions
8
+ @url_helpers = url_helpers
9
+ end
10
+
11
+ def to_a
12
+ actions.map do |action|
13
+ {
14
+ label: action.label,
15
+ description: action.description,
16
+ path: url_helpers.public_send(action.route_name)
17
+ }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :actions, :url_helpers
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ module RecentActivity
6
+ Event = Struct.new(
7
+ :type,
8
+ :id,
9
+ :occurred_at,
10
+ :success,
11
+ :items_created,
12
+ :items_updated,
13
+ :scraper_adapter,
14
+ :item_title,
15
+ :item_url,
16
+ :source_name,
17
+ :source_id,
18
+ keyword_init: true
19
+ ) do
20
+ def type
21
+ self[:type]&.to_sym
22
+ end
23
+
24
+ def success?
25
+ !!self[:success]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ class RecentActivityPresenter
6
+ def initialize(events, url_helpers:)
7
+ @events = events
8
+ @url_helpers = url_helpers
9
+ end
10
+
11
+ def to_a
12
+ events.map { |event| build_view_model(event) }
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :events, :url_helpers
18
+
19
+ def build_view_model(event)
20
+ case event.type
21
+ when :fetch_log
22
+ fetch_event(event)
23
+ when :scrape_log
24
+ scrape_event(event)
25
+ when :item
26
+ item_event(event)
27
+ else
28
+ fallback_event(event)
29
+ end
30
+ end
31
+
32
+ def fetch_event(event)
33
+ {
34
+ label: "Fetch ##{event.id}",
35
+ description: "#{event.items_created.to_i} created / #{event.items_updated.to_i} updated",
36
+ status: event.success? ? :success : :failure,
37
+ type: :fetch,
38
+ time: event.occurred_at,
39
+ path: url_helpers.fetch_log_path(event.id)
40
+ }
41
+ end
42
+
43
+ def scrape_event(event)
44
+ {
45
+ label: "Scrape ##{event.id}",
46
+ description: (event.scraper_adapter.presence || "Scraper"),
47
+ status: event.success? ? :success : :failure,
48
+ type: :scrape,
49
+ time: event.occurred_at,
50
+ path: url_helpers.scrape_log_path(event.id)
51
+ }
52
+ end
53
+
54
+ def item_event(event)
55
+ {
56
+ label: event.item_title.presence || "New Item",
57
+ description: event.source_name.presence || event.item_url.presence || "New feed item",
58
+ status: :success,
59
+ type: :item,
60
+ time: event.occurred_at,
61
+ path: url_helpers.item_path(event.id)
62
+ }
63
+ end
64
+
65
+ def fallback_event(event)
66
+ {
67
+ label: "Event ##{event.id}",
68
+ description: event.source_name.presence || "No additional details recorded.",
69
+ status: event.success? ? :success : :failure,
70
+ type: event.type || :unknown,
71
+ time: event.occurred_at,
72
+ path: nil
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Dashboard
5
+ module TurboBroadcaster
6
+ STREAM_NAME = "source_monitor_dashboard"
7
+
8
+ module_function
9
+
10
+ def setup!
11
+ return unless turbo_streams_available?
12
+
13
+ register_callback(:after_fetch_completed, fetch_callback)
14
+ register_callback(:after_item_created, item_callback)
15
+ end
16
+
17
+ def fetch_callback
18
+ @fetch_callback ||= lambda { |_event| broadcast_dashboard_updates }
19
+ end
20
+
21
+ def item_callback
22
+ @item_callback ||= lambda { |_event| broadcast_dashboard_updates }
23
+ end
24
+
25
+ def broadcast_dashboard_updates
26
+ return unless turbo_streams_available?
27
+
28
+ queries = SourceMonitor::Dashboard::Queries.new
29
+ url_helpers = SourceMonitor::Engine.routes.url_helpers
30
+
31
+ Turbo::StreamsChannel.broadcast_replace_to(
32
+ STREAM_NAME,
33
+ target: "source_monitor_dashboard_stats",
34
+ html: render_partial("source_monitor/dashboard/stats", stats: queries.stats)
35
+ )
36
+
37
+ Turbo::StreamsChannel.broadcast_replace_to(
38
+ STREAM_NAME,
39
+ target: "source_monitor_dashboard_recent_activity",
40
+ html: render_partial(
41
+ "source_monitor/dashboard/recent_activity",
42
+ recent_activity: SourceMonitor::Dashboard::RecentActivityPresenter.new(
43
+ queries.recent_activity,
44
+ url_helpers:
45
+ ).to_a
46
+ )
47
+ )
48
+
49
+ fetch_schedule = queries.upcoming_fetch_schedule
50
+ Turbo::StreamsChannel.broadcast_replace_to(
51
+ STREAM_NAME,
52
+ target: "source_monitor_dashboard_fetch_schedule",
53
+ html: render_partial(
54
+ "source_monitor/dashboard/fetch_schedule",
55
+ groups: fetch_schedule.groups,
56
+ reference_time: fetch_schedule.reference_time
57
+ )
58
+ )
59
+ rescue StandardError => error
60
+ Rails.logger.error(
61
+ "[SourceMonitor] Turbo stream broadcast failed: #{error.class}: #{error.message}"
62
+ )
63
+ end
64
+
65
+ def turbo_streams_available?
66
+ defined?(Turbo::StreamsChannel)
67
+ end
68
+ private_class_method :turbo_streams_available?
69
+
70
+ def render_partial(partial, locals)
71
+ SourceMonitor::DashboardController.render(
72
+ partial:,
73
+ locals:
74
+ )
75
+ end
76
+ private_class_method :render_partial
77
+
78
+ def register_callback(name, callback)
79
+ callbacks = SourceMonitor.config.events.callbacks_for(name)
80
+ return if callbacks.include?(callback)
81
+
82
+ SourceMonitor.config.events.public_send(name, callback)
83
+ end
84
+ private_class_method :register_callback
85
+ end
86
+ end
87
+ end