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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceHealthResetsController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ before_action :set_source
8
+
9
+ def create
10
+ SourceMonitor::Health::SourceHealthReset.call(source: @source)
11
+ SourceMonitor::Realtime.broadcast_source(@source)
12
+
13
+ render_fetch_enqueue_response(
14
+ "Health state reset",
15
+ toast_level: :success
16
+ )
17
+ rescue StandardError => error
18
+ handle_fetch_failure(error, prefix: "Health reset")
19
+ end
20
+
21
+ private
22
+
23
+ def set_source
24
+ @source = Source.find(params[:source_id])
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceRetriesController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ before_action :set_source
8
+
9
+ def create
10
+ SourceMonitor::Fetching::FetchRunner.enqueue(@source.id, force: true)
11
+ render_fetch_enqueue_response("Retry has been forced and will run shortly.")
12
+ rescue StandardError => error
13
+ handle_fetch_failure(error)
14
+ end
15
+
16
+ private
17
+
18
+ def set_source
19
+ @source = Source.find(params[:source_id])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module SourceTurboResponses
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ include ActionView::RecordIdentifier
9
+ end
10
+
11
+ private
12
+
13
+ def render_fetch_enqueue_response(message, toast_level: :info, health_status_override: nil)
14
+ @source.reload
15
+ respond_to do |format|
16
+ format.turbo_stream do
17
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
18
+
19
+ responder.replace_details(
20
+ @source,
21
+ partial: "source_monitor/sources/details_wrapper",
22
+ locals: { source: @source, health_status_override: health_status_override }
23
+ )
24
+
25
+ responder.replace_row(
26
+ @source,
27
+ partial: "source_monitor/sources/row",
28
+ locals: {
29
+ source: @source,
30
+ item_activity_rates: { @source.id => SourceMonitor::Analytics::SourceActivityRates.rate_for(@source) },
31
+ health_status_override: health_status_override
32
+ }
33
+ )
34
+
35
+ responder.toast(message:, level: toast_level, delay_ms: toast_delay_for(toast_level))
36
+
37
+ render turbo_stream: responder.render(view_context)
38
+ end
39
+
40
+ format.html do
41
+ redirect_to source_monitor.source_path(@source), notice: message
42
+ end
43
+ end
44
+ end
45
+
46
+ def handle_fetch_failure(error, prefix: "Fetch")
47
+ error_message = "#{prefix} could not be enqueued: #{error.message}"
48
+
49
+ respond_to do |format|
50
+ format.turbo_stream do
51
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
52
+ responder.toast(message: error_message, level: :error, delay_ms: toast_delay_for(:error))
53
+
54
+ render turbo_stream: responder.render(view_context), status: :unprocessable_entity
55
+ end
56
+
57
+ format.html do
58
+ redirect_to source_monitor.source_path(@source), alert: error_message
59
+ end
60
+ end
61
+ end
62
+
63
+ def respond_to_bulk_scrape(result)
64
+ @source.reload
65
+ @bulk_scrape_selection = result.selection
66
+ payload = bulk_scrape_flash_payload(result)
67
+ status = result.error? ? :unprocessable_entity : :ok
68
+
69
+ respond_to do |format|
70
+ format.turbo_stream do
71
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
72
+
73
+ responder.replace_details(
74
+ @source,
75
+ partial: "source_monitor/sources/details_wrapper",
76
+ locals: { source: @source }
77
+ )
78
+
79
+ responder.replace_row(
80
+ @source,
81
+ partial: "source_monitor/sources/row",
82
+ locals: {
83
+ source: @source,
84
+ item_activity_rates: { @source.id => SourceMonitor::Analytics::SourceActivityRates.rate_for(@source) }
85
+ }
86
+ )
87
+
88
+ if payload[:message].present?
89
+ responder.toast(
90
+ message: payload[:message],
91
+ level: payload[:level],
92
+ delay_ms: toast_delay_for(payload[:level])
93
+ )
94
+ end
95
+
96
+ render turbo_stream: responder.render(view_context), status: status
97
+ end
98
+
99
+ format.html do
100
+ if payload[:message].present?
101
+ redirect_to source_monitor.source_path(@source), flash: { payload[:flash_key] => payload[:message] }
102
+ else
103
+ redirect_to source_monitor.source_path(@source)
104
+ end
105
+ end
106
+ end
107
+ end
108
+
109
+ def bulk_scrape_flash_payload(result)
110
+ pluralizer = ->(count, word) { view_context.pluralize(count, word) }
111
+ presenter = SourceMonitor::Scraping::BulkResultPresenter.new(result:, pluralizer:)
112
+ presenter.to_flash_payload
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "source_monitor/sources/turbo_stream_presenter"
4
+ require "source_monitor/scraping/bulk_result_presenter"
5
+
6
+ module SourceMonitor
7
+ class SourcesController < ApplicationController
8
+ include ActionView::RecordIdentifier
9
+ include SourceMonitor::SanitizesSearchParams
10
+
11
+ searchable_with scope: -> { Source.all }, default_sorts: [ "created_at desc" ]
12
+
13
+ ITEMS_PREVIEW_LIMIT = SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT
14
+
15
+ before_action :set_source, only: %i[show edit update destroy]
16
+
17
+ SEARCH_FIELD = :name_or_feed_url_or_website_url_cont
18
+
19
+ def index
20
+ @search_params = sanitized_search_params
21
+ @q = build_search_query
22
+
23
+ @sources = @q.result
24
+
25
+ @search_term = @search_params[SEARCH_FIELD.to_s].to_s.strip
26
+ @search_field = SEARCH_FIELD
27
+
28
+ metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(
29
+ base_scope: Source.all,
30
+ result_scope: @sources,
31
+ search_params: @search_params
32
+ )
33
+
34
+ @fetch_interval_distribution = metrics.fetch_interval_distribution
35
+ @fetch_interval_filter = metrics.fetch_interval_filter
36
+ @selected_fetch_interval_bucket = metrics.selected_fetch_interval_bucket
37
+ @item_activity_rates = metrics.item_activity_rates
38
+ end
39
+
40
+ def show
41
+ @recent_fetch_logs = @source.fetch_logs.order(started_at: :desc).limit(5)
42
+ @recent_scrape_logs = @source.scrape_logs.order(started_at: :desc).limit(5)
43
+ @items = @source.items.recent.limit(ITEMS_PREVIEW_LIMIT)
44
+ @bulk_scrape_selection = :current
45
+ end
46
+
47
+ def new
48
+ @source = Source.new(default_attributes)
49
+ end
50
+
51
+ def create
52
+ @source = Source.new(source_params)
53
+
54
+ if @source.save
55
+ redirect_to source_monitor.source_path(@source), notice: "Source created successfully"
56
+ else
57
+ render :new, status: :unprocessable_entity
58
+ end
59
+ end
60
+
61
+ def edit
62
+ end
63
+
64
+ def update
65
+ if @source.update(source_params)
66
+ redirect_to source_monitor.source_path(@source), notice: "Source updated successfully"
67
+ else
68
+ render :edit, status: :unprocessable_entity
69
+ end
70
+ end
71
+
72
+ def destroy
73
+ search_params = sanitized_search_params
74
+ @source.destroy
75
+ message = "Source deleted"
76
+
77
+ respond_to do |format|
78
+ format.turbo_stream do
79
+ query = build_search_query(params: search_params)
80
+
81
+ metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(
82
+ base_scope: Source.all,
83
+ result_scope: query.result,
84
+ search_params:
85
+ )
86
+
87
+ redirect_location = safe_redirect_path(params[:redirect_to])
88
+
89
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
90
+ presenter = SourceMonitor::Sources::TurboStreamPresenter.new(source: @source, responder:)
91
+
92
+ presenter.render_deletion(
93
+ metrics:,
94
+ query:,
95
+ search_params:,
96
+ redirect_location:
97
+ )
98
+
99
+ responder.toast(message:, level: :success)
100
+
101
+ render turbo_stream: responder.render(view_context)
102
+ end
103
+
104
+ format.html do
105
+ redirect_to source_monitor.sources_path, notice: message
106
+ end
107
+ end
108
+ end
109
+
110
+ def fetch
111
+ SourceMonitor::Fetching::FetchRunner.enqueue(@source.id)
112
+ render_fetch_enqueue_response("Fetch has been enqueued and will run shortly.")
113
+ rescue StandardError => error
114
+ handle_fetch_failure(error)
115
+ end
116
+
117
+ def retry
118
+ SourceMonitor::Fetching::FetchRunner.enqueue(@source.id, force: true)
119
+ render_fetch_enqueue_response("Retry has been forced and will run shortly.")
120
+ rescue StandardError => error
121
+ handle_fetch_failure(error)
122
+ end
123
+
124
+ private
125
+
126
+ def set_source
127
+ @source = Source.find(params[:id])
128
+ end
129
+
130
+ def default_attributes
131
+ {
132
+ active: true,
133
+ scraping_enabled: false,
134
+ auto_scrape: false,
135
+ requires_javascript: false,
136
+ feed_content_readability_enabled: false,
137
+ fetch_interval_minutes: 360,
138
+ adaptive_fetching_enabled: true,
139
+ scraper_adapter: "readability"
140
+ }
141
+ end
142
+
143
+ def source_params
144
+ permitted = params.require(:source).permit(
145
+ :name,
146
+ :feed_url,
147
+ :website_url,
148
+ :fetch_interval_minutes,
149
+ :active,
150
+ :auto_scrape,
151
+ :scraping_enabled,
152
+ :requires_javascript,
153
+ :feed_content_readability_enabled,
154
+ :scraper_adapter,
155
+ :items_retention_days,
156
+ :max_items,
157
+ :adaptive_fetching_enabled,
158
+ :health_auto_pause_threshold,
159
+ scrape_settings: [
160
+ :include_plain_text,
161
+ :timeout,
162
+ :javascript_enabled,
163
+ { selectors: %i[content title],
164
+ http: [],
165
+ readability: [] }
166
+ ]
167
+ )
168
+
169
+ SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h)
170
+ end
171
+
172
+ def safe_redirect_path(raw_value)
173
+ return if raw_value.blank?
174
+
175
+ sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(raw_value.to_s)
176
+ sanitized.start_with?("/") ? sanitized : nil
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,327 @@
1
+ module SourceMonitor
2
+ module ApplicationHelper
3
+ def source_monitor_stylesheet_bundle_tag
4
+ stylesheet_link_tag("source_monitor/application", "data-turbo-track": "reload")
5
+ rescue StandardError => error
6
+ log_source_monitor_asset_error(:stylesheet, error)
7
+ nil
8
+ end
9
+
10
+ def source_monitor_javascript_bundle_tag
11
+ javascript_include_tag("source_monitor/application", "data-turbo-track": "reload", type: "module")
12
+ rescue StandardError => error
13
+ log_source_monitor_asset_error(:javascript, error)
14
+ nil
15
+ end
16
+
17
+ def heatmap_bucket_classes(count, max_count)
18
+ return "bg-slate-100 text-slate-500" if max_count.to_i.zero? || count.to_i.zero?
19
+
20
+ ratio = count.to_f / max_count
21
+
22
+ case ratio
23
+ when 0...0.25
24
+ "bg-blue-100 text-blue-800"
25
+ when 0.25...0.5
26
+ "bg-blue-200 text-blue-900"
27
+ when 0.5...0.75
28
+ "bg-blue-400 text-white"
29
+ else
30
+ "bg-blue-600 text-white"
31
+ end
32
+ end
33
+
34
+ def fetch_interval_bucket_path(bucket, search_params, selected: false)
35
+ query = fetch_interval_bucket_query(bucket, search_params, selected: selected)
36
+ route_helpers = SourceMonitor::Engine.routes.url_helpers
37
+
38
+ query.empty? ? route_helpers.sources_path : route_helpers.sources_path(q: query)
39
+ end
40
+
41
+ def fetch_interval_bucket_query(bucket, search_params, selected: false)
42
+ base = (search_params || {}).dup
43
+ base = base.except("fetch_interval_minutes_gteq", "fetch_interval_minutes_lt", "fetch_interval_minutes_lteq")
44
+
45
+ query = if selected
46
+ base
47
+ else
48
+ updated = base.dup
49
+ updated["fetch_interval_minutes_gteq"] = bucket.min.to_i.to_s if bucket.respond_to?(:min) && bucket.min
50
+
51
+ if bucket.respond_to?(:max) && bucket.max
52
+ updated["fetch_interval_minutes_lt"] = bucket.max.to_i.to_s
53
+ else
54
+ updated.delete("fetch_interval_minutes_lt")
55
+ updated.delete("fetch_interval_minutes_lteq")
56
+ end
57
+
58
+ updated
59
+ end
60
+
61
+ if query.respond_to?(:compact_blank)
62
+ query.compact_blank
63
+ else
64
+ query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
65
+ end
66
+ end
67
+
68
+ def fetch_interval_filter_label(bucket, filter)
69
+ return bucket.label if bucket&.respond_to?(:label)
70
+ return unless filter
71
+
72
+ min = filter[:min]
73
+ max = filter[:max]
74
+
75
+ if min && max
76
+ "#{min}-#{max} min"
77
+ elsif min
78
+ "#{min}+ min"
79
+ else
80
+ "Any interval"
81
+ end
82
+ end
83
+
84
+ def fetch_schedule_window_label(group)
85
+ start_time = group.respond_to?(:window_start) ? group.window_start : nil
86
+ end_time = group.respond_to?(:window_end) ? group.window_end : nil
87
+
88
+ return unless start_time || end_time
89
+
90
+ if start_time && end_time
91
+ "#{format_schedule_time(start_time)} – #{format_schedule_time(end_time)}"
92
+ elsif start_time
93
+ "After #{format_schedule_time(start_time)}"
94
+ else
95
+ nil
96
+ end
97
+ end
98
+
99
+ def format_schedule_time(time)
100
+ return unless time
101
+
102
+ l(time.in_time_zone, format: :short)
103
+ end
104
+
105
+ def human_fetch_interval(minutes)
106
+ return "—" if minutes.blank?
107
+
108
+ total_minutes = minutes.to_i
109
+ hours, remaining = total_minutes.divmod(60)
110
+ parts = []
111
+ parts << "#{hours}h" if hours.positive?
112
+ parts << "#{remaining}m" if remaining.positive? || parts.empty?
113
+ parts.join(" ")
114
+ end
115
+
116
+ # Unified status badge helper for both fetch and scrape operations
117
+ ITEM_SCRAPE_STATUS_LABELS = {
118
+ "pending" => "Pending",
119
+ "processing" => "Processing",
120
+ "success" => "Scraped",
121
+ "failed" => "Failed",
122
+ "partial" => "Partial",
123
+ "disabled" => "Disabled",
124
+ "idle" => "Not scraped"
125
+ }.freeze
126
+
127
+ # Maps asynchronous workflow states to badge styling/labels shared across the
128
+ # engine. Item scraping builds on these core states, reusing the same colors
129
+ # so the UI stays consistent across sources, items, and job dashboards.
130
+ def async_status_badge(status, show_spinner: true)
131
+ status_str = status.to_s
132
+
133
+ label, classes, spinner = case status_str
134
+ when "queued"
135
+ [ "Queued", "bg-amber-100 text-amber-700", show_spinner ]
136
+ when "pending"
137
+ [ "Pending", "bg-amber-100 text-amber-700", show_spinner ]
138
+ when "fetching", "processing"
139
+ [ "Processing", "bg-blue-100 text-blue-700", show_spinner ]
140
+ when "success"
141
+ [ "Completed", "bg-green-100 text-green-700", false ]
142
+ when "failed"
143
+ [ "Failed", "bg-rose-100 text-rose-700", false ]
144
+ when "partial"
145
+ [ "Partial", "bg-amber-100 text-amber-700", false ]
146
+ when "disabled"
147
+ [ "Disabled", "bg-slate-200 text-slate-600", false ]
148
+ when "idle"
149
+ [ "Idle", "bg-slate-100 text-slate-600", false ]
150
+ else
151
+ [ "Ready", "bg-slate-100 text-slate-600", false ]
152
+ end
153
+
154
+ { label: label, classes: classes, show_spinner: spinner }
155
+ end
156
+
157
+ # Returns a normalized badge payload for the source show/item pages. The
158
+ # status derives from the item's recorded scrape_status, falls back to the
159
+ # source configuration, and always lands inside the known status set:
160
+ # pending, processing, success, failed, partial, disabled, or idle.
161
+ def item_scrape_status_badge(item:, source: nil, show_spinner: true)
162
+ status = derive_item_scrape_status(item:, source: source)
163
+ base_badge = async_status_badge(status, show_spinner: show_spinner)
164
+ label = ITEM_SCRAPE_STATUS_LABELS.fetch(status) { base_badge[:label] }
165
+ spinner = base_badge[:show_spinner] && %w[pending processing].include?(status)
166
+
167
+ {
168
+ status: status,
169
+ label: label,
170
+ classes: base_badge[:classes],
171
+ show_spinner: spinner
172
+ }
173
+ end
174
+
175
+ # Legacy helper for backwards compatibility
176
+ def fetch_status_badge_classes(status)
177
+ async_status_badge(status)
178
+ end
179
+
180
+ # Helper to render the loading spinner SVG
181
+ def loading_spinner_svg(css_class: "mr-1 h-4 w-4 animate-spin text-blue-500")
182
+ tag.svg(
183
+ class: css_class,
184
+ xmlns: "http://www.w3.org/2000/svg",
185
+ fill: "none",
186
+ viewBox: "0 0 24 24",
187
+ aria: { hidden: "true" }
188
+ ) do
189
+ concat tag.circle(class: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", stroke_width: "4")
190
+ concat tag.path(class: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z")
191
+ end
192
+ end
193
+
194
+ def source_health_badge(source, override: nil)
195
+ return override if override.present?
196
+
197
+ status = source&.health_status.presence || "healthy"
198
+
199
+ mapping = {
200
+ "healthy" => { label: "Healthy", classes: "bg-green-100 text-green-700", show_spinner: false },
201
+ "warning" => { label: "Needs Attention", classes: "bg-amber-100 text-amber-700", show_spinner: false },
202
+ "critical" => { label: "Failing", classes: "bg-rose-100 text-rose-700", show_spinner: false },
203
+ "declining" => { label: "Declining", classes: "bg-orange-100 text-orange-700", show_spinner: false },
204
+ "improving" => { label: "Improving", classes: "bg-sky-100 text-sky-700", show_spinner: false },
205
+ "auto_paused" => { label: "Auto-Paused", classes: "bg-amber-100 text-amber-700", show_spinner: false },
206
+ "unknown" => { label: "Unknown", classes: "bg-slate-100 text-slate-600", show_spinner: false }
207
+ }
208
+
209
+ mapping.fetch(status) { mapping.fetch("unknown") }.merge(status: status)
210
+ end
211
+
212
+ def source_health_actions(source)
213
+ status = source&.health_status.presence || "healthy"
214
+ helpers = SourceMonitor::Engine.routes.url_helpers
215
+
216
+ case status
217
+ when "critical", "declining"
218
+ [
219
+ {
220
+ key: :full_fetch,
221
+ label: "Queue Full Fetch",
222
+ description: "Runs the full fetch pipeline immediately and updates items if the feed responds.",
223
+ path: helpers.source_fetch_path(source),
224
+ method: :post,
225
+ data: { testid: "source-health-action-full_fetch" }
226
+ },
227
+ {
228
+ key: :health_check,
229
+ label: "Run Health Check",
230
+ description: "Sends a single request to confirm the feed is reachable without modifying stored items.",
231
+ path: helpers.source_health_check_path(source),
232
+ method: :post,
233
+ data: { testid: "source-health-action-health_check" }
234
+ }
235
+ ]
236
+ when "auto_paused"
237
+ [
238
+ {
239
+ key: :reset,
240
+ label: "Reset to Active Status",
241
+ description: "Clears the pause window, failure counters, and schedules the next fetch using the configured interval.",
242
+ path: helpers.source_health_reset_path(source),
243
+ method: :post,
244
+ data: { testid: "source-health-action-reset" }
245
+ }
246
+ ]
247
+ else
248
+ []
249
+ end
250
+ end
251
+
252
+ def interactive_health_status?(source, override: nil)
253
+ return false if override.present?
254
+
255
+ %w[critical declining auto_paused].include?(source&.health_status.presence)
256
+ end
257
+
258
+ def table_sort_direction(search_object, attribute)
259
+ return unless search_object.respond_to?(:sorts)
260
+
261
+ sort = search_object.sorts.detect { |s| s && s.name == attribute.to_s }
262
+ sort&.dir
263
+ end
264
+
265
+ def table_sort_arrow(search_object, attribute, default: nil)
266
+ direction = table_sort_direction(search_object, attribute) || default&.to_s
267
+
268
+ case direction
269
+ when "asc"
270
+ "▲"
271
+ when "desc"
272
+ "▼"
273
+ else
274
+ "↕"
275
+ end
276
+ end
277
+
278
+ def table_sort_aria(search_object, attribute)
279
+ direction = table_sort_direction(search_object, attribute)
280
+
281
+ case direction
282
+ when "asc"
283
+ "ascending"
284
+ when "desc"
285
+ "descending"
286
+ else
287
+ "none"
288
+ end
289
+ end
290
+
291
+ def table_sort_link(search_object, attribute, label, frame:, default_order:, secondary: [], html_options: {})
292
+ sort_targets = [ attribute, *Array(secondary) ]
293
+ options = {
294
+ default_order: default_order,
295
+ hide_indicator: true
296
+ }.merge(html_options)
297
+
298
+ options[:data] = (options[:data] || {}).merge(turbo_frame: frame)
299
+ options[:data][:turbo_action] ||= "advance"
300
+
301
+ sort_link(search_object, attribute, sort_targets, options) do
302
+ tag.span(label, class: "inline-flex items-center gap-1")
303
+ end
304
+ end
305
+
306
+ private
307
+
308
+ def derive_item_scrape_status(item:, source: nil)
309
+ return "idle" unless item
310
+
311
+ status = item.scrape_status.to_s.presence
312
+ return status if status.present?
313
+
314
+ source ||= item.source
315
+ return "disabled" if source&.scraping_enabled? == false
316
+ return "success" if item.scraped_at.present?
317
+
318
+ "idle"
319
+ end
320
+
321
+ def log_source_monitor_asset_error(kind, error)
322
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
323
+
324
+ Rails.logger.debug("[SourceMonitor] Skipping #{kind} bundle include: #{error.message}")
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,13 @@
1
+ module SourceMonitor
2
+ parent_job = defined?(::ApplicationJob) ? ::ApplicationJob : ActiveJob::Base
3
+
4
+ class ApplicationJob < parent_job
5
+ class << self
6
+ # Specify a queue name using SourceMonitor's configuration, ensuring
7
+ # we respect host application prefixes and overrides.
8
+ def source_monitor_queue(role)
9
+ queue_as SourceMonitor.queue_name(role)
10
+ end
11
+ end
12
+ end
13
+ end