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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "monitor"
5
+
6
+ module SourceMonitor
7
+ module Jobs
8
+ module Visibility
9
+ module_function
10
+
11
+ def setup!
12
+ return if @subscribed || !SourceMonitor.config.job_metrics_enabled
13
+
14
+ subscribe_enqueue
15
+ subscribe_perform_start
16
+ subscribe_perform
17
+
18
+ @subscribed = true
19
+ end
20
+
21
+ def adapter_name
22
+ ActiveJob::Base.queue_adapter_name
23
+ end
24
+
25
+ def queue_depth(queue_name)
26
+ state_for(queue_name)[:depth]
27
+ end
28
+
29
+ def last_enqueued_at(queue_name)
30
+ state_for(queue_name)[:last_enqueued_at]
31
+ end
32
+
33
+ def last_started_at(queue_name)
34
+ state_for(queue_name)[:last_started_at]
35
+ end
36
+
37
+ def last_finished_at(queue_name)
38
+ state_for(queue_name)[:last_finished_at]
39
+ end
40
+
41
+ def reset!
42
+ synchronize do
43
+ @queue_state = nil
44
+ end
45
+ end
46
+
47
+ def state_snapshot
48
+ synchronize do
49
+ queue_state.transform_values(&:dup)
50
+ end
51
+ end
52
+
53
+ def trackable_job?(job)
54
+ job.class.name.start_with?("SourceMonitor::")
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ def queue_identifier(job)
60
+ (job.queue_name || SourceMonitor.config.fetch_queue_name).to_s
61
+ end
62
+
63
+ def queue_state
64
+ @queue_state ||= Hash.new do |hash, key|
65
+ hash[key] = { depth: 0, last_enqueued_at: nil, last_started_at: nil, last_finished_at: nil }
66
+ end
67
+ end
68
+
69
+ def state_for(name)
70
+ synchronize do
71
+ queue_state[name.to_s]
72
+ end
73
+ end
74
+
75
+ def synchronize(&block)
76
+ monitor.synchronize(&block)
77
+ end
78
+
79
+ def monitor
80
+ @monitor ||= Monitor.new
81
+ end
82
+
83
+ def subscribe_enqueue
84
+ ActiveSupport::Notifications.subscribe("enqueue.active_job") do |_event, _start, _finish, _id, payload|
85
+ job = payload[:job]
86
+ next unless trackable_job?(job)
87
+
88
+ queue_name = queue_identifier(job)
89
+
90
+ synchronize do
91
+ state = queue_state[queue_name]
92
+ state[:depth] += 1
93
+ state[:last_enqueued_at] = Time.current
94
+ SourceMonitor::Metrics.gauge("jobs_queue_depth_#{queue_name}", state[:depth])
95
+ SourceMonitor::Metrics.gauge("jobs_last_enqueued_at_#{queue_name}", state[:last_enqueued_at])
96
+ end
97
+ end
98
+ end
99
+
100
+ def subscribe_perform_start
101
+ ActiveSupport::Notifications.subscribe("perform_start.active_job") do |_event, _start, _finish, _id, payload|
102
+ job = payload[:job]
103
+ next unless trackable_job?(job)
104
+
105
+ queue_name = queue_identifier(job)
106
+
107
+ synchronize do
108
+ state = queue_state[queue_name]
109
+ state[:depth] = [ state[:depth] - 1, 0 ].max
110
+ state[:last_started_at] = Time.current
111
+ SourceMonitor::Metrics.gauge("jobs_queue_depth_#{queue_name}", state[:depth])
112
+ SourceMonitor::Metrics.gauge("jobs_last_started_at_#{queue_name}", state[:last_started_at])
113
+ end
114
+ end
115
+ end
116
+
117
+ def subscribe_perform
118
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |_event, _start, _finish, _id, payload|
119
+ job = payload[:job]
120
+ next unless trackable_job?(job)
121
+
122
+ queue_name = queue_identifier(job)
123
+
124
+ synchronize do
125
+ state = queue_state[queue_name]
126
+ state[:last_finished_at] = Time.current
127
+ SourceMonitor::Metrics.gauge("jobs_last_finished_at_#{queue_name}", state[:last_finished_at])
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Logs
5
+ class EntrySync
6
+ def self.call(loggable)
7
+ new(loggable).call
8
+ end
9
+
10
+ def initialize(loggable)
11
+ @loggable = loggable
12
+ end
13
+
14
+ def call
15
+ return unless loggable&.persisted?
16
+ return unless loggable.respond_to?(:log_entry)
17
+
18
+ entry = loggable.log_entry || loggable.build_log_entry
19
+ entry.assign_attributes(entry_attributes)
20
+ entry.save!
21
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
22
+ Rails.logger&.error("[SourceMonitor::Logs::EntrySync] Failed to sync log entry for #{loggable.class.name}##{loggable.id}")
23
+ nil
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :loggable
29
+
30
+ def entry_attributes
31
+ {
32
+ source: loggable.source,
33
+ item: extract_item,
34
+ success: boolean_success,
35
+ started_at: loggable.started_at,
36
+ completed_at: loggable.respond_to?(:completed_at) ? loggable.completed_at : nil,
37
+ http_status: safe_attribute(:http_status),
38
+ duration_ms: safe_attribute(:duration_ms),
39
+ items_created: safe_attribute(:items_created),
40
+ items_updated: safe_attribute(:items_updated),
41
+ items_failed: safe_attribute(:items_failed),
42
+ scraper_adapter: safe_attribute(:scraper_adapter),
43
+ content_length: safe_attribute(:content_length),
44
+ error_class: safe_attribute(:error_class),
45
+ error_message: safe_attribute(:error_message)
46
+ }
47
+ end
48
+
49
+ def extract_item
50
+ return nil unless loggable.respond_to?(:item)
51
+
52
+ loggable.item
53
+ end
54
+
55
+ def safe_attribute(attribute)
56
+ loggable.respond_to?(attribute) ? loggable.public_send(attribute) : nil
57
+ end
58
+
59
+ def boolean_success
60
+ return false unless loggable.respond_to?(:success)
61
+
62
+ value = loggable.public_send(:success)
63
+ return false if value.nil?
64
+
65
+ ActiveModel::Type::Boolean.new.cast(value)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Logs
5
+ class FilterSet
6
+ STATUS_MAP = {
7
+ "success" => true,
8
+ "failed" => false
9
+ }.freeze
10
+
11
+ LOG_TYPE_MAP = {
12
+ "fetch" => "SourceMonitor::FetchLog",
13
+ "scrape" => "SourceMonitor::ScrapeLog",
14
+ "health_check" => "SourceMonitor::HealthCheckLog"
15
+ }.freeze
16
+
17
+ TIMEFRAME_MAP = {
18
+ "24h" => 24.hours,
19
+ "7d" => 7.days,
20
+ "30d" => 30.days
21
+ }.freeze
22
+
23
+ MAX_PER_PAGE = 100
24
+ DEFAULT_PER_PAGE = 25
25
+
26
+ attr_reader :raw_params
27
+
28
+ def initialize(params:)
29
+ @raw_params = params || {}
30
+ end
31
+
32
+ def status
33
+ @status ||= begin
34
+ value = sanitize_string(raw_params[:status])
35
+ STATUS_MAP.key?(value) ? value : nil
36
+ end
37
+ end
38
+
39
+ def success_flag
40
+ STATUS_MAP[status]
41
+ end
42
+
43
+ def log_type
44
+ @log_type ||= begin
45
+ value = sanitize_string(raw_params[:log_type])
46
+ LOG_TYPE_MAP.key?(value) ? value : nil
47
+ end
48
+ end
49
+
50
+ def loggable_type
51
+ LOG_TYPE_MAP[log_type]
52
+ end
53
+
54
+ def timeframe
55
+ @timeframe ||= begin
56
+ value = sanitize_string(raw_params[:timeframe])
57
+ TIMEFRAME_MAP.key?(value) ? value : nil
58
+ end
59
+ end
60
+
61
+ def timeframe_start
62
+ return nil unless timeframe
63
+
64
+ current_time - TIMEFRAME_MAP.fetch(timeframe)
65
+ end
66
+
67
+ def started_after
68
+ @started_after ||= parse_time_param(raw_params[:started_after])
69
+ end
70
+
71
+ def started_before
72
+ @started_before ||= parse_time_param(raw_params[:started_before])
73
+ end
74
+
75
+ def effective_started_after
76
+ [ timeframe_start, started_after ].compact.max
77
+ end
78
+
79
+ def source_id
80
+ @source_id ||= integer_param(raw_params[:source_id])
81
+ end
82
+
83
+ def item_id
84
+ @item_id ||= integer_param(raw_params[:item_id])
85
+ end
86
+
87
+ def search
88
+ @search ||= begin
89
+ value = sanitize_string(raw_params[:search])
90
+ value.presence
91
+ end
92
+ end
93
+
94
+ def page
95
+ @page ||= begin
96
+ integer = integer_param(raw_params[:page])
97
+ integer.present? && integer.positive? ? integer : 1
98
+ end
99
+ end
100
+
101
+ def per_page
102
+ @per_page ||= begin
103
+ integer = integer_param(raw_params[:per_page])
104
+ return DEFAULT_PER_PAGE unless integer.present?
105
+
106
+ integer = DEFAULT_PER_PAGE if integer <= 0
107
+ [ integer, MAX_PER_PAGE ].min
108
+ end
109
+ end
110
+
111
+ def to_params
112
+ {
113
+ status: status,
114
+ log_type: log_type,
115
+ timeframe: timeframe,
116
+ started_after: started_after&.iso8601,
117
+ started_before: started_before&.iso8601,
118
+ source_id: source_id,
119
+ item_id: item_id,
120
+ search: search,
121
+ page: page,
122
+ per_page: per_page
123
+ }.compact
124
+ end
125
+
126
+ private
127
+
128
+ def sanitize_string(value)
129
+ return "" if value.nil?
130
+
131
+ SourceMonitor::Security::ParameterSanitizer.sanitize(value.to_s)
132
+ end
133
+
134
+ def integer_param(value)
135
+ return nil if value.nil?
136
+
137
+ sanitized = sanitize_string(value)
138
+ return nil unless sanitized.match?(/\A\d+\z/)
139
+
140
+ sanitized.to_i
141
+ end
142
+
143
+ def parse_time_param(value)
144
+ return nil if value.nil?
145
+
146
+ sanitized = sanitize_string(value)
147
+ return nil if sanitized.blank?
148
+
149
+ Time.iso8601(sanitized)
150
+ rescue ArgumentError
151
+ begin
152
+ Time.zone.parse(sanitized)
153
+ rescue ArgumentError, TypeError
154
+ nil
155
+ end
156
+ end
157
+
158
+ def current_time
159
+ @current_time ||= Time.zone.now
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Logs
5
+ class Query
6
+ Result = Struct.new(
7
+ :entries,
8
+ :page,
9
+ :per_page,
10
+ :has_next_page,
11
+ :has_previous_page,
12
+ :filter_set,
13
+ keyword_init: true
14
+ ) do
15
+ def has_next_page?
16
+ !!self[:has_next_page]
17
+ end
18
+
19
+ def has_previous_page?
20
+ !!self[:has_previous_page]
21
+ end
22
+ end
23
+
24
+ def initialize(params:)
25
+ @filter_set = SourceMonitor::Logs::FilterSet.new(params:)
26
+ end
27
+
28
+ def call
29
+ pagination_result = SourceMonitor::Pagination::Paginator.new(
30
+ scope: filtered_scope,
31
+ page: filter_set.page,
32
+ per_page: filter_set.per_page
33
+ ).paginate
34
+
35
+ Result.new(
36
+ entries: pagination_result.records,
37
+ page: pagination_result.page,
38
+ per_page: pagination_result.per_page,
39
+ has_next_page: pagination_result.has_next_page?,
40
+ has_previous_page: pagination_result.has_previous_page?,
41
+ filter_set:
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :filter_set
48
+
49
+ def filtered_scope
50
+ scope = SourceMonitor::LogEntry.includes(:source, :item, :loggable).recent
51
+ scope = scope.where(success: filter_set.success_flag) unless filter_set.success_flag.nil?
52
+ scope = scope.where(loggable_type: filter_set.loggable_type) if filter_set.loggable_type
53
+ scope = scope.where(source_id: filter_set.source_id) if filter_set.source_id
54
+ scope = scope.where(item_id: filter_set.item_id) if filter_set.item_id
55
+ scope = scope.where("sourcemon_log_entries.started_at >= ?", filter_set.effective_started_after) if filter_set.effective_started_after
56
+ scope = scope.where("sourcemon_log_entries.started_at <= ?", filter_set.started_before) if filter_set.started_before
57
+ scope = apply_search(scope, filter_set.search) if filter_set.search
58
+ scope.order(started_at: :desc, id: :desc)
59
+ end
60
+
61
+ def apply_search(scope, term)
62
+ normalized = "%#{term.to_s.downcase}%"
63
+
64
+ scope.
65
+ left_outer_joins(:source).
66
+ left_outer_joins(:item).
67
+ where(
68
+ <<~SQL.squish,
69
+ (LOWER(sourcemon_log_entries.error_message) LIKE :query) OR
70
+ (LOWER(sourcemon_log_entries.error_class) LIKE :query) OR
71
+ (CAST(sourcemon_log_entries.http_status AS TEXT) LIKE :query) OR
72
+ (LOWER(sourcemon_log_entries.scraper_adapter) LIKE :query) OR
73
+ (LOWER(sourcemon_sources.name) LIKE :query) OR
74
+ (LOWER(sourcemon_items.title) LIKE :query)
75
+ SQL
76
+ query: normalized
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Logs
5
+ class TablePresenter
6
+ class Row
7
+ def initialize(entry, url_helpers)
8
+ @entry = entry
9
+ @url_helpers = url_helpers
10
+ end
11
+
12
+ def dom_id
13
+ "#{type_slug}-#{entry.loggable_id}"
14
+ end
15
+
16
+ def type_label
17
+ if fetch?
18
+ "Fetch"
19
+ elsif scrape?
20
+ "Scrape"
21
+ else
22
+ "Health Check"
23
+ end
24
+ end
25
+
26
+ def type_variant
27
+ if fetch?
28
+ :fetch
29
+ elsif scrape?
30
+ :scrape
31
+ else
32
+ :health_check
33
+ end
34
+ end
35
+
36
+ def status_label
37
+ entry.success? ? "Success" : "Failure"
38
+ end
39
+
40
+ def status_variant
41
+ entry.success? ? :success : :failure
42
+ end
43
+
44
+ def started_at
45
+ entry.started_at
46
+ end
47
+
48
+ def primary_label
49
+ if scrape?
50
+ entry.item&.title.presence || "(untitled)"
51
+ else
52
+ entry.source&.name
53
+ end
54
+ end
55
+
56
+ def primary_path
57
+ if scrape? && entry.item
58
+ url_helpers.item_path(entry.item)
59
+ elsif entry.source
60
+ url_helpers.source_path(entry.source)
61
+ end
62
+ end
63
+
64
+ def source_label
65
+ entry.source&.name
66
+ end
67
+
68
+ def source_path
69
+ url_helpers.source_path(entry.source) if entry.source
70
+ end
71
+
72
+ def http_summary
73
+ if fetch?
74
+ entry.http_status.present? ? entry.http_status.to_s : "—"
75
+ elsif scrape?
76
+ parts = []
77
+ parts << entry.http_status.to_s if entry.http_status
78
+ parts << entry.scraper_adapter if entry.scraper_adapter.present?
79
+ parts.compact.join(" · ").presence || "—"
80
+ else
81
+ entry.http_status.present? ? entry.http_status.to_s : "—"
82
+ end
83
+ end
84
+
85
+ def metrics_summary
86
+ if fetch?
87
+ "+#{entry.items_created.to_i} / ~#{entry.items_updated.to_i} / ✕#{entry.items_failed.to_i}"
88
+ else
89
+ entry.duration_ms.present? ? "#{entry.duration_ms} ms" : "—"
90
+ end
91
+ end
92
+
93
+ def detail_path
94
+ case entry.loggable
95
+ when SourceMonitor::FetchLog
96
+ url_helpers.fetch_log_path(entry.loggable)
97
+ when SourceMonitor::ScrapeLog
98
+ url_helpers.scrape_log_path(entry.loggable)
99
+ else
100
+ nil
101
+ end
102
+ end
103
+
104
+ def adapter
105
+ entry.scraper_adapter
106
+ end
107
+
108
+ def success?
109
+ entry.success?
110
+ end
111
+
112
+ def failure?
113
+ !success?
114
+ end
115
+
116
+ def error_message
117
+ entry.error_message
118
+ end
119
+
120
+ def type_slug
121
+ if fetch?
122
+ "fetch"
123
+ elsif scrape?
124
+ "scrape"
125
+ else
126
+ "health-check"
127
+ end
128
+ end
129
+
130
+ def fetch?
131
+ entry.fetch?
132
+ end
133
+
134
+ def scrape?
135
+ entry.scrape?
136
+ end
137
+
138
+ def health_check?
139
+ entry.health_check?
140
+ end
141
+
142
+ private
143
+
144
+ attr_reader :entry, :url_helpers
145
+ end
146
+
147
+ def initialize(entries:, url_helpers:)
148
+ @entries = entries
149
+ @url_helpers = url_helpers
150
+ end
151
+
152
+ def rows
153
+ entries.map { |entry| Row.new(entry, url_helpers) }
154
+ end
155
+
156
+ private
157
+
158
+ attr_reader :entries, :url_helpers
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module SourceMonitor
6
+ module Metrics
7
+ module_function
8
+
9
+ def increment(name, value = 1)
10
+ counters[name.to_s] += value
11
+ end
12
+
13
+ def gauge(name, value)
14
+ gauges[name.to_s] = value
15
+ end
16
+
17
+ def counter(name)
18
+ counters[name.to_s]
19
+ end
20
+
21
+ def gauge_value(name)
22
+ gauges[name.to_s]
23
+ end
24
+
25
+ def snapshot
26
+ { counters: counters.dup, gauges: gauges.dup }
27
+ end
28
+
29
+ def reset!
30
+ @counters = Hash.new(0)
31
+ @gauges = {}
32
+ end
33
+
34
+ def setup_subscribers!
35
+ return if defined?(@subscribed) && @subscribed
36
+
37
+ ActiveSupport::Notifications.subscribe("source_monitor.fetch.start") do |_name, _start, _finish, _id, payload|
38
+ increment(:fetch_started_total)
39
+ gauge(:last_fetch_source_id, payload[:source_id]) if payload.key?(:source_id)
40
+ end
41
+
42
+ ActiveSupport::Notifications.subscribe("source_monitor.fetch.finish") do |_name, start, finish, _id, payload|
43
+ increment(:fetch_finished_total)
44
+ success = payload.fetch(:success, true)
45
+ if success
46
+ increment(:fetch_success_total)
47
+ else
48
+ increment(:fetch_failure_total)
49
+ end
50
+
51
+ duration_ms = payload[:duration_ms] || ((finish - start) * 1000.0).round(2)
52
+ gauge(:last_fetch_duration_ms, duration_ms)
53
+ end
54
+
55
+ ActiveSupport::Notifications.subscribe("source_monitor.scheduler.run") do |_name, start, finish, _id, payload|
56
+ enqueued = payload[:enqueued_count].to_i
57
+ duration_ms = payload[:duration_ms] || ((finish - start) * 1000.0).round(2)
58
+
59
+ increment(:scheduler_runs_total)
60
+ increment(:scheduler_sources_enqueued_total, enqueued)
61
+ gauge(:scheduler_last_enqueued_count, enqueued)
62
+ gauge(:scheduler_last_duration_ms, duration_ms)
63
+ gauge(:scheduler_last_run_at_epoch, finish.to_f)
64
+ end
65
+
66
+ @subscribed = true
67
+ end
68
+
69
+ def counters
70
+ @counters ||= Hash.new(0)
71
+ end
72
+
73
+ def gauges
74
+ @gauges ||= {}
75
+ end
76
+ end
77
+ end