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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SourceMonitor engine configuration.
4
+ #
5
+ # These values default to conservative settings that work for most hosts.
6
+ # Tweak them here instead of monkey-patching the engine so upgrades remain easy.
7
+ SourceMonitor.configure do |config|
8
+ # Namespace used when deriving queue names and instrumentation keys. If your
9
+ # host app already prefixes queues (via ActiveJob.queue_name_prefix), this
10
+ # string is automatically combined with that prefix.
11
+ config.queue_namespace = "source_monitor"
12
+
13
+ # Dedicated queue names for fetching and scraping jobs. Solid Queue will use
14
+ # these names when dispatching work; ensure they match entries in
15
+ # config/solid_queue.yml (or your chosen Active Job backend).
16
+ config.fetch_queue_name = "#{config.queue_namespace}_fetch"
17
+ config.scrape_queue_name = "#{config.queue_namespace}_scrape"
18
+
19
+ # Recommended worker concurrency for each queue when using Solid Queue.
20
+ # Adjust to fit the workload and infrastructure available in the host app.
21
+ config.fetch_queue_concurrency = 2
22
+ config.scrape_queue_concurrency = 2
23
+
24
+ # Solid Queue executes recurring "command" tasks via a job class. Override
25
+ # this when host apps need additional context or instrumentation around
26
+ # recurring commands.
27
+ # config.recurring_command_job_class = "MyRecurringCommandJob"
28
+
29
+ # SourceMonitor assumes Solid Queue handles persistence. Run
30
+ # `bin/rails solid_queue:install` (dedicated queue database) or copy the
31
+ # engine's Solid Queue migration into your app so Mission Control and the
32
+ # dashboard can surface live queue metrics.
33
+
34
+ # Toggle SourceMonitor's lightweight queue visibility layer. When enabled (the
35
+ # default), the dashboard shows queue depth and last-run timestamps sourced
36
+ # from ActiveSupport::Notifications.
37
+ config.job_metrics_enabled = true
38
+
39
+ # Mission Control integration is optional. Flip this to true to surface an
40
+ # "Open Mission Control" link on the SourceMonitor dashboard.
41
+ config.mission_control_enabled = false
42
+
43
+ # Provide a String path ("/mission_control"), a route helper
44
+ # (-> { Rails.application.routes.url_helpers.mission_control_jobs_path }),
45
+ # or nil if you prefer not to link directly. This is only referenced when
46
+ # mission_control_enabled is true. Ensure the host routes mount Mission
47
+ # Control when supplying a path, for example:
48
+ # # Gemfile: gem "mission_control-jobs"
49
+ # # config/routes.rb:
50
+ # # mount MissionControl::Jobs::Engine, at: "/mission_control"
51
+ # # config.mission_control_dashboard_path = "/mission_control"
52
+ config.mission_control_dashboard_path = nil
53
+
54
+ # ---- Authentication -----------------------------------------------------
55
+ # Hook SourceMonitor into your host application's authentication stack. Each
56
+ # handler can be a Symbol (invoked on the controller) or a callable that will
57
+ # receive the controller instance.
58
+ # config.authentication.authenticate_with :authenticate_admin!
59
+ # config.authentication.authorize_with -> { authorize!(:manage, :source_monitor) }
60
+ # config.authentication.current_user_method = :current_admin
61
+ # config.authentication.user_signed_in_method = :admin_signed_in?
62
+
63
+ # ---- HTTP client -------------------------------------------------------
64
+ # Tune the Faraday client SourceMonitor uses for fetches/scrapes.
65
+ config.http.timeout = 15
66
+ config.http.open_timeout = 5
67
+ config.http.max_redirects = 5
68
+ # config.http.proxy = ENV["SOURCE_MONITOR_HTTP_PROXY"]
69
+ # config.http.retry_max = 4
70
+ # config.http.retry_interval = 0.5
71
+ # config.http.retry_backoff_factor = 2
72
+ # config.http.retry_statuses = [429, 500, 502, 503, 504]
73
+ # Merge extra default headers (User-Agent defaults to SourceMonitor/version).
74
+ # config.http.headers = { "X-Request-ID" => -> { SecureRandom.uuid } }
75
+
76
+ # ---- Adaptive fetch scheduling ----------------------------------------
77
+ # Control how quickly sources speed up or back off when adaptive fetching
78
+ # is enabled. Times are in minutes; factors must be positive numbers.
79
+ # config.fetching.min_interval_minutes = 5 # lower bound (default: 5 minutes)
80
+ # config.fetching.max_interval_minutes = 1440 # upper bound (default: 24 hours)
81
+ # config.fetching.increase_factor = 1.25 # multiplier when no new items
82
+ # config.fetching.decrease_factor = 0.75 # multiplier when new items arrive
83
+ # config.fetching.failure_increase_factor = 1.5 # multiplier on errors/timeouts
84
+ # config.fetching.jitter_percent = 0.1 # random jitter (0 disables jitter)
85
+
86
+ # ---- Source health monitoring ---------------------------------------
87
+ # Tune how many fetches SourceMonitor evaluates when determining health
88
+ # status, as well as thresholds for warnings and automatic pauses.
89
+ config.health.window_size = 20
90
+ config.health.healthy_threshold = 0.8
91
+ config.health.warning_threshold = 0.5
92
+ config.health.auto_pause_threshold = 0.2
93
+ config.health.auto_resume_threshold = 0.6
94
+ config.health.auto_pause_cooldown_minutes = 60
95
+
96
+ # ---- Scraper adapters --------------------------------------------------
97
+ # Register additional scraper adapters or override built-ins. Adapters must
98
+ # inherit from SourceMonitor::Scrapers::Base.
99
+ # config.scrapers.register(:custom, "MyApp::Scrapers::CustomAdapter")
100
+
101
+ # ---- Retention defaults ------------------------------------------------
102
+ # Sources inherit these values when they leave retention fields blank.
103
+ config.retention.items_retention_days = nil
104
+ config.retention.max_items = nil
105
+ # config.retention.strategy = :destroy # or :soft_delete
106
+
107
+ # ---- Scraping controls -------------------------------------------------
108
+ # Limit how many scrapes can be in-flight per source and cap the size of
109
+ # bulk enqueue operations. Set to nil to disable a limit.
110
+ # config.scraping.max_in_flight_per_source = 25
111
+ # config.scraping.max_bulk_batch_size = 100
112
+
113
+ # ---- Event callbacks ---------------------------------------------------
114
+ # Integrate with host workflows by responding to engine events. Handlers
115
+ # receive a single event object with helpful context. For example:
116
+ #
117
+ # config.events.after_item_created do |event|
118
+ # NewItemNotifier.publish(event.item, source: event.source)
119
+ # end
120
+ #
121
+ # config.events.after_fetch_completed do |event|
122
+ # Rails.logger.info "Fetch for #{event.source.name} finished with #{event.status}"
123
+ # end
124
+ #
125
+ # Register item processors to run after each entry is processed. These are
126
+ # ideal for lightweight normalization or denormalized writes.
127
+ # config.events.register_item_processor ->(context) { ItemIndexer.index(context.item) }
128
+
129
+ # ---- Model extensions --------------------------------------------------
130
+ # Host applications can extend SourceMonitor models without monkey patches.
131
+ # Table names default to "sourcemon_*"; override when multi-tenancy or
132
+ # legacy naming requires a different prefix.
133
+ # config.models.table_name_prefix = "sourcemon_"
134
+ #
135
+ # Include extension concerns to add associations, scopes, or helper methods.
136
+ # config.models.source.include_concern "MyApp::SourceMonitor::SourceExtensions"
137
+ #
138
+ # Register custom validations using a method name or a callable. Both forms
139
+ # run within the model instance context, so you can reuse helpers defined in
140
+ # your concerns.
141
+ # config.models.source.validate :enforce_custom_rules
142
+ # config.models.source.validate ->(record) { record.errors.add(:base, "custom error") }
143
+
144
+ # ---- Realtime adapter -------------------------------------------------
145
+ # Choose the Action Cable backend powering Turbo Streams. Solid Cable keeps
146
+ # everything in the primary database so Redis is no longer required. Switch
147
+ # to :redis if the host already runs a Redis cluster.
148
+ config.realtime.adapter = :solid_cable
149
+ # config.realtime.redis_url = ENV.fetch("SOURCE_MONITOR_REDIS_URL", nil)
150
+ # config.realtime.solid_cable.polling_interval = "0.05.seconds"
151
+ # config.realtime.solid_cable.message_retention = "12.hours"
152
+ # config.realtime.solid_cable.connects_to = {
153
+ # database: { writing: :cable }
154
+ # }
155
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Analytics
5
+ class SourceActivityRates
6
+ DEFAULT_LOOKBACK = 14.days
7
+
8
+ def initialize(scope: SourceMonitor::Source.all, lookback: DEFAULT_LOOKBACK, now: Time.current)
9
+ @scope = scope
10
+ @lookback = lookback
11
+ @now = now
12
+ end
13
+
14
+ def per_source_rates
15
+ return {} if source_ids.empty?
16
+
17
+ counts = SourceMonitor::Item
18
+ .where(source_id: source_ids)
19
+ .where("created_at >= ?", window_start)
20
+ .group(:source_id)
21
+ .count
22
+
23
+ days = [ lookback.in_days, 1 ].max
24
+
25
+ counts.transform_values { |count| count.to_f / days }.tap do |results|
26
+ source_ids.each { |source_id| results[source_id] ||= 0.0 }
27
+ end
28
+ end
29
+
30
+ def self.rate_for(source, lookback: DEFAULT_LOOKBACK, now: Time.current)
31
+ return 0.0 unless source
32
+
33
+ new(scope: Array(source), lookback:, now:).per_source_rates[source.id] || 0.0
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :scope, :lookback, :now
39
+
40
+ def source_ids
41
+ @source_ids ||= if scope.respond_to?(:pluck)
42
+ scope.pluck(:id)
43
+ else
44
+ Array(scope).map { |record| record.respond_to?(:id) ? record.id : record }
45
+ end
46
+ end
47
+
48
+ def window_start
49
+ now - lookback
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Analytics
5
+ class SourceFetchIntervalDistribution
6
+ Bucket = Struct.new(:label, :min, :max, :count, keyword_init: true)
7
+
8
+ BUCKETS = [
9
+ { label: "5-30 min", min: 5, max: 30 },
10
+ { label: "30-60 min", min: 30, max: 60 },
11
+ { label: "60-120 min", min: 60, max: 120 },
12
+ { label: "120-240 min", min: 120, max: 240 },
13
+ { label: "240-480 min", min: 240, max: 480 },
14
+ { label: "480+ min", min: 480, max: nil }
15
+ ].freeze
16
+
17
+ def initialize(scope: SourceMonitor::Source.all)
18
+ @scope = scope
19
+ end
20
+
21
+ def buckets
22
+ @buckets ||= build_buckets
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :scope
28
+
29
+ def build_buckets
30
+ values = scope.pluck(:fetch_interval_minutes).compact
31
+ counts = Hash.new(0)
32
+
33
+ values.each do |value|
34
+ bucket = bucket_for(value)
35
+ counts[bucket_key(bucket)] += 1
36
+ end
37
+
38
+ BUCKETS.map do |definition|
39
+ bucket = definition.merge(count: counts[bucket_key(definition)] || 0)
40
+ Bucket.new(**bucket)
41
+ end
42
+ end
43
+
44
+ def bucket_for(value)
45
+ BUCKETS.find do |definition|
46
+ min = definition[:min] || 0
47
+ max = definition[:max]
48
+ value >= min && (max.nil? || value < max)
49
+ end || BUCKETS.first
50
+ end
51
+
52
+ def bucket_key(definition)
53
+ [ definition[:min], definition[:max] ]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Analytics
5
+ class SourcesIndexMetrics
6
+ FETCH_INTERVAL_KEYS = %w[
7
+ fetch_interval_minutes_gteq
8
+ fetch_interval_minutes_lt
9
+ fetch_interval_minutes_lteq
10
+ ].freeze
11
+
12
+ attr_reader :search_params
13
+
14
+ def initialize(base_scope:, result_scope:, search_params:, lookback: SourceActivityRates::DEFAULT_LOOKBACK, now: Time.current)
15
+ @base_scope = base_scope
16
+ @result_scope = result_scope
17
+ @search_params = (search_params || {}).dup
18
+ @lookback = lookback
19
+ @now = now
20
+ end
21
+
22
+ def fetch_interval_distribution
23
+ @fetch_interval_distribution ||= SourceFetchIntervalDistribution.new(scope: distribution_scope).buckets
24
+ end
25
+
26
+ def selected_fetch_interval_bucket
27
+ filter = fetch_interval_filter
28
+ return if filter.blank?
29
+
30
+ fetch_interval_distribution.find do |bucket|
31
+ min_match = filter[:min].present? ? filter[:min].to_i == bucket.min.to_i : bucket.min.nil?
32
+ max_match = if bucket.max.nil?
33
+ filter[:max].nil?
34
+ else
35
+ filter[:max].present? && filter[:max].to_i == bucket.max.to_i
36
+ end
37
+
38
+ min_match && max_match
39
+ end
40
+ end
41
+
42
+ def item_activity_rates
43
+ @item_activity_rates ||= SourceActivityRates.new(scope: result_scope, lookback:, now:).per_source_rates
44
+ end
45
+
46
+ def fetch_interval_filter
47
+ min = integer_param(search_params["fetch_interval_minutes_gteq"])
48
+ max = integer_param(search_params["fetch_interval_minutes_lt"]) || integer_param(search_params["fetch_interval_minutes_lteq"])
49
+ return if min.nil? && max.nil?
50
+
51
+ { min:, max: }
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :base_scope, :result_scope, :lookback, :now
57
+
58
+ def distribution_scope
59
+ @distribution_scope ||= begin
60
+ filtered_params = search_params.except(*FETCH_INTERVAL_KEYS)
61
+
62
+ if filtered_params.present? && base_scope.respond_to?(:ransack)
63
+ base_scope.ransack(filtered_params).result
64
+ else
65
+ base_scope
66
+ end
67
+ end
68
+ end
69
+
70
+ def integer_param(value)
71
+ return if value.blank?
72
+
73
+ sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(value.to_s)
74
+ cleaned = sanitized.strip
75
+ return if cleaned.blank?
76
+
77
+ Integer(cleaned)
78
+ rescue ArgumentError, TypeError
79
+ nil
80
+ end
81
+
82
+ def distribution_source_ids
83
+ scope = distribution_scope
84
+ if scope.respond_to?(:pluck)
85
+ scope.pluck(:id)
86
+ else
87
+ Array(scope).map { |record| record.respond_to?(:id) ? record.id : record }
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,49 @@
1
+ module SourceMonitor
2
+ module Assets
3
+ module Bundler
4
+ MissingBuildError = Class.new(StandardError)
5
+
6
+ module_function
7
+
8
+ def build!
9
+ run_script!("build")
10
+ end
11
+
12
+ def build_css!
13
+ run_script!("build:css")
14
+ end
15
+
16
+ def build_js!
17
+ run_script!("build:js")
18
+ end
19
+
20
+ def verify!
21
+ missing = build_artifacts.reject(&:exist?)
22
+
23
+ if missing.any?
24
+ relative_paths = missing.map { |path| path.relative_path_from(engine_root) }
25
+ raise MissingBuildError,
26
+ "SourceMonitor asset build artifacts missing: #{relative_paths.join(', ')}. Run `npm run build` in the engine root to regenerate."
27
+ end
28
+
29
+ true
30
+ end
31
+
32
+ def build_artifacts
33
+ [
34
+ engine_root.join("app/assets/builds/source_monitor/application.css"),
35
+ engine_root.join("app/assets/builds/source_monitor/application.js")
36
+ ]
37
+ end
38
+
39
+ def run_script!(script)
40
+ command = [ "npm", "run", script ]
41
+ system({ "BUNDLE_GEMFILE" => engine_root.join("Gemfile").to_s }, *command, chdir: engine_root.to_s, exception: true)
42
+ end
43
+
44
+ def engine_root
45
+ SourceMonitor::Engine.root
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,6 @@
1
+ module SourceMonitor
2
+ module Assets
3
+ end
4
+ end
5
+
6
+ require "source_monitor/assets/bundler"