source_monitor 0.11.1 → 0.12.0

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/rails-audit.md +77 -0
  3. data/CHANGELOG.md +50 -0
  4. data/CLAUDE.md +2 -2
  5. data/Gemfile.lock +7 -20
  6. data/RAILS_AUDIT.md +424 -0
  7. data/VERSION +1 -1
  8. data/app/assets/builds/source_monitor/application.css +4 -24
  9. data/app/assets/builds/source_monitor/application.js +57 -89
  10. data/app/assets/builds/source_monitor/application.js.map +4 -4
  11. data/app/assets/javascripts/source_monitor/application.js +3 -6
  12. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +6 -86
  13. data/app/assets/javascripts/source_monitor/controllers/filter_submit_controller.js +13 -0
  14. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  15. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +3 -13
  16. data/app/components/source_monitor/application_component.rb +10 -0
  17. data/app/components/source_monitor/filter_dropdown_component.rb +62 -0
  18. data/app/components/source_monitor/icon_component.rb +140 -0
  19. data/app/components/source_monitor/status_badge_component.html.erb +8 -0
  20. data/app/components/source_monitor/status_badge_component.rb +96 -0
  21. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +4 -0
  22. data/app/controllers/concerns/source_monitor/set_source.rb +13 -0
  23. data/app/controllers/source_monitor/application_controller.rb +17 -0
  24. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +6 -10
  25. data/app/controllers/source_monitor/dashboard_controller.rb +5 -1
  26. data/app/controllers/source_monitor/import_history_dismissals_controller.rb +1 -1
  27. data/app/controllers/source_monitor/import_sessions_controller.rb +30 -9
  28. data/app/controllers/source_monitor/item_scrapes_controller.rb +70 -0
  29. data/app/controllers/source_monitor/items_controller.rb +2 -69
  30. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +1 -4
  31. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +2 -12
  32. data/app/controllers/source_monitor/source_fetches_controller.rb +1 -6
  33. data/app/controllers/source_monitor/source_health_checks_controller.rb +9 -16
  34. data/app/controllers/source_monitor/source_health_resets_controller.rb +1 -6
  35. data/app/controllers/source_monitor/source_retries_controller.rb +1 -6
  36. data/app/controllers/source_monitor/source_scrape_tests_controller.rb +2 -4
  37. data/app/controllers/source_monitor/source_turbo_responses.rb +1 -3
  38. data/app/controllers/source_monitor/sources_controller.rb +15 -20
  39. data/app/helpers/source_monitor/application_helper.rb +15 -31
  40. data/app/helpers/source_monitor/health_badge_helper.rb +8 -0
  41. data/app/jobs/source_monitor/download_content_images_job.rb +1 -59
  42. data/app/jobs/source_monitor/favicon_fetch_job.rb +1 -58
  43. data/app/jobs/source_monitor/fetch_feed_job.rb +2 -52
  44. data/app/jobs/source_monitor/import_opml_job.rb +6 -145
  45. data/app/jobs/source_monitor/import_session_health_check_job.rb +15 -76
  46. data/app/jobs/source_monitor/item_cleanup_job.rb +5 -0
  47. data/app/jobs/source_monitor/log_cleanup_job.rb +13 -2
  48. data/app/jobs/source_monitor/schedule_fetches_job.rb +8 -0
  49. data/app/jobs/source_monitor/scrape_item_job.rb +6 -52
  50. data/app/jobs/source_monitor/source_health_check_job.rb +1 -72
  51. data/app/models/concerns/source_monitor/loggable.rb +12 -0
  52. data/app/models/source_monitor/fetch_log.rb +0 -8
  53. data/app/models/source_monitor/health_check_log.rb +0 -8
  54. data/app/models/source_monitor/import_history.rb +14 -0
  55. data/app/models/source_monitor/import_session.rb +2 -0
  56. data/app/models/source_monitor/item.rb +15 -0
  57. data/app/models/source_monitor/item_content.rb +4 -3
  58. data/app/models/source_monitor/scrape_log.rb +4 -6
  59. data/app/models/source_monitor/source.rb +28 -19
  60. data/app/presenters/source_monitor/base_presenter.rb +19 -0
  61. data/app/presenters/source_monitor/source_details_presenter.rb +61 -0
  62. data/app/presenters/source_monitor/sources_filter_presenter.rb +61 -0
  63. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +3 -3
  64. data/app/views/source_monitor/dashboard/_stat_card.html.erb +2 -1
  65. data/app/views/source_monitor/dashboard/_stats.html.erb +5 -7
  66. data/app/views/source_monitor/items/_details.html.erb +11 -14
  67. data/app/views/source_monitor/items/index.html.erb +10 -35
  68. data/app/views/source_monitor/logs/index.html.erb +20 -41
  69. data/app/views/source_monitor/shared/_form_errors.html.erb +14 -0
  70. data/app/views/source_monitor/source_scrape_tests/_result.html.erb +1 -29
  71. data/app/views/source_monitor/source_scrape_tests/_result_content.html.erb +33 -0
  72. data/app/views/source_monitor/source_scrape_tests/show.html.erb +1 -29
  73. data/app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erb +2 -2
  74. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +7 -5
  75. data/app/views/source_monitor/sources/_details.html.erb +24 -52
  76. data/app/views/source_monitor/sources/_health_status_badge.html.erb +4 -6
  77. data/app/views/source_monitor/sources/_row.html.erb +7 -18
  78. data/app/views/source_monitor/sources/edit.html.erb +1 -10
  79. data/app/views/source_monitor/sources/index.html.erb +26 -46
  80. data/app/views/source_monitor/sources/new.html.erb +1 -10
  81. data/config/routes.rb +1 -1
  82. data/db/migrate/20260313120000_add_composite_indexes_to_log_tables.rb +14 -0
  83. data/db/migrate/20260314120000_align_health_status_default.rb +11 -0
  84. data/lib/source_monitor/analytics/sources_index_metrics.rb +15 -0
  85. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +10 -4
  86. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +21 -5
  87. data/lib/source_monitor/favicons/fetcher.rb +86 -0
  88. data/lib/source_monitor/fetching/cloudflare_bypass.rb +14 -5
  89. data/lib/source_monitor/fetching/completion/event_publisher.rb +12 -0
  90. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +15 -2
  91. data/lib/source_monitor/fetching/completion/retention_handler.rb +11 -3
  92. data/lib/source_monitor/fetching/feed_fetcher.rb +2 -21
  93. data/lib/source_monitor/fetching/fetch_runner.rb +12 -3
  94. data/lib/source_monitor/fetching/retry_orchestrator.rb +102 -0
  95. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +9 -0
  96. data/lib/source_monitor/health/source_health_check_orchestrator.rb +95 -0
  97. data/lib/source_monitor/health.rb +1 -0
  98. data/lib/source_monitor/images/downloader.rb +6 -7
  99. data/lib/source_monitor/images/processor.rb +98 -0
  100. data/lib/source_monitor/import_sessions/health_check_updater.rb +95 -0
  101. data/lib/source_monitor/import_sessions/opml_importer.rb +163 -0
  102. data/lib/source_monitor/items/item_creator.rb +0 -21
  103. data/lib/source_monitor/logs/query.rb +20 -0
  104. data/lib/source_monitor/queries/scrape_candidates_query.rb +30 -0
  105. data/lib/source_monitor/queries.rb +7 -0
  106. data/lib/source_monitor/scheduler.rb +5 -0
  107. data/lib/source_monitor/scraping/bulk_result_presenter.rb +11 -8
  108. data/lib/source_monitor/scraping/runner.rb +52 -0
  109. data/lib/source_monitor/scraping/scheduler.rb +5 -0
  110. data/lib/source_monitor/scraping/state.rb +4 -2
  111. data/lib/source_monitor/security/parameter_sanitizer.rb +7 -0
  112. data/lib/source_monitor/version.rb +1 -1
  113. data/lib/source_monitor.rb +7 -0
  114. data/source_monitor.gemspec +1 -0
  115. metadata +47 -1
@@ -3,20 +3,14 @@
3
3
  module SourceMonitor
4
4
  class BulkScrapeEnablementsController < ApplicationController
5
5
  def create
6
- source_ids = Array(params.dig(:bulk_scrape_enablement, :source_ids)).map(&:to_i).reject(&:zero?)
6
+ source_ids = enablement_params[:source_ids]
7
7
 
8
8
  if source_ids.empty?
9
9
  handle_empty_selection
10
10
  return
11
11
  end
12
12
 
13
- sources = Source.where(id: source_ids, scraping_enabled: false)
14
- updated_count = sources.update_all(
15
- scraping_enabled: true,
16
- auto_scrape: true,
17
- scraper_adapter: default_adapter,
18
- updated_at: Time.current
19
- )
13
+ updated_count = Source.enable_scraping!(source_ids)
20
14
 
21
15
  respond_to do |format|
22
16
  format.turbo_stream do
@@ -37,8 +31,10 @@ module SourceMonitor
37
31
 
38
32
  private
39
33
 
40
- def default_adapter
41
- "readability"
34
+ def enablement_params
35
+ raw_ids = Array(params.dig(:bulk_scrape_enablement, :source_ids))
36
+ ids = raw_ids.map(&:to_i).reject(&:zero?)
37
+ { source_ids: ids }
42
38
  end
43
39
 
44
40
  def handle_empty_selection
@@ -30,7 +30,11 @@ module SourceMonitor
30
30
  private
31
31
 
32
32
  def schedule_pages_params
33
- params.fetch(:schedule_pages, {}).permit!.to_h
33
+ raw = params.fetch(:schedule_pages, {})
34
+ return {} unless raw.respond_to?(:permit)
35
+
36
+ permitted_keys = raw.keys.select { |k| k.to_s.match?(/\Apage_\d+\z/) }
37
+ raw.permit(*permitted_keys).to_h
34
38
  end
35
39
  end
36
40
  end
@@ -3,7 +3,7 @@
3
3
  module SourceMonitor
4
4
  class ImportHistoryDismissalsController < ApplicationController
5
5
  def create
6
- import_history = ImportHistory.find(params[:import_history_id])
6
+ import_history = ImportHistory.where(user_id: source_monitor_current_user&.id).find(params[:import_history_id])
7
7
  import_history.update!(dismissed_at: Time.current)
8
8
 
9
9
  respond_to do |format|
@@ -12,11 +12,30 @@ module SourceMonitor
12
12
  include SourceMonitor::ImportSessions::HealthCheckManagement
13
13
  include SourceMonitor::ImportSessions::BulkConfiguration
14
14
 
15
+ STEP_HANDLERS = {
16
+ "upload" => :handle_upload_step,
17
+ "preview" => :handle_preview_step,
18
+ "health_check" => :handle_health_check_step,
19
+ "configure" => :handle_configure_step,
20
+ "confirm" => :handle_confirm_step
21
+ }.freeze
22
+
23
+ STEP_CONTEXTS = {
24
+ "preview" => :prepare_preview_context,
25
+ "health_check" => :prepare_health_check_context,
26
+ "configure" => :prepare_configure_context,
27
+ "confirm" => :prepare_confirm_context
28
+ }.freeze
29
+
15
30
  before_action :ensure_current_user!
16
31
  before_action :set_import_session, only: %i[show update destroy]
17
32
  before_action :authorize_import_session!, only: %i[show update destroy]
18
33
  before_action :set_wizard_step, only: %i[show update]
19
34
 
35
+ # The OPML import wizard requires a persisted ImportSession record to track
36
+ # state across steps (file upload, preview, health check, configure, confirm).
37
+ # Visiting "new" immediately creates a session and redirects to the first step,
38
+ # so there is no separate form -- the wizard IS the form.
20
39
  def new
21
40
  create
22
41
  end
@@ -31,20 +50,15 @@ module SourceMonitor
31
50
  end
32
51
 
33
52
  def show
34
- prepare_preview_context if @current_step == "preview"
35
- prepare_health_check_context if @current_step == "health_check"
36
- prepare_configure_context if @current_step == "configure"
37
- prepare_confirm_context if @current_step == "confirm"
53
+ context_method = STEP_CONTEXTS[@current_step]
54
+ send(context_method) if context_method
38
55
  persist_step!
39
56
  render :show
40
57
  end
41
58
 
42
59
  def update
43
- return handle_upload_step if @current_step == "upload"
44
- return handle_preview_step if @current_step == "preview"
45
- return handle_health_check_step if @current_step == "health_check"
46
- return handle_configure_step if @current_step == "configure"
47
- return handle_confirm_step if @current_step == "confirm"
60
+ handler = STEP_HANDLERS[@current_step]
61
+ return send(handler) if handler
48
62
 
49
63
  @import_session.update!(session_attributes)
50
64
  @current_step = target_step
@@ -245,6 +259,13 @@ module SourceMonitor
245
259
  return @fallback_user_id
246
260
  end
247
261
 
262
+ # Only create guest users in development/test. An engine should never
263
+ # create records in host-app tables in production.
264
+ unless Rails.env.local?
265
+ @fallback_user_id = nil
266
+ return @fallback_user_id
267
+ end
268
+
248
269
  @fallback_user_id = create_guest_user&.id
249
270
  rescue StandardError
250
271
  @fallback_user_id = nil
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class ItemScrapesController < ApplicationController
5
+ include ActionView::RecordIdentifier
6
+
7
+ before_action :set_item
8
+
9
+ def create
10
+ log_manual_scrape("controller:start", extra: { format: request.format })
11
+
12
+ enqueue_result = SourceMonitor::Scraping::Enqueuer.enqueue(item: @item, reason: :manual)
13
+ log_manual_scrape("controller:enqueue_result", extra: { status: enqueue_result.status, message: enqueue_result.message })
14
+ flash_key, flash_message = scrape_flash_payload(enqueue_result)
15
+ status = enqueue_result.failure? ? :unprocessable_entity : :ok
16
+
17
+ respond_to do |format|
18
+ format.turbo_stream do
19
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
20
+
21
+ if enqueue_result.enqueued? || enqueue_result.already_enqueued?
22
+ @item.reload
23
+ responder.replace_details(
24
+ @item,
25
+ partial: "source_monitor/items/details_wrapper",
26
+ locals: { item: @item }
27
+ )
28
+ end
29
+
30
+ if flash_message
31
+ level = flash_key == :notice ? :info : :error
32
+ responder.toast(message: flash_message, level:, delay_ms: toast_delay_for(level))
33
+ end
34
+
35
+ render turbo_stream: responder.render(view_context), status: status
36
+ end
37
+
38
+ format.html do
39
+ if flash_key && flash_message
40
+ redirect_to source_monitor.item_path(@item), flash: { flash_key => flash_message }
41
+ else
42
+ redirect_to source_monitor.item_path(@item)
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def set_item
51
+ @item = Item.active.includes(:source, :item_content).find(params[:item_id])
52
+ end
53
+
54
+ def scrape_flash_payload(result)
55
+ case result.status
56
+ when :enqueued
57
+ [ :notice, "Scrape has been enqueued and will run shortly." ]
58
+ when :already_enqueued
59
+ [ :notice, result.message ]
60
+ else
61
+ [ :alert, result.message || "Unable to enqueue scrape for this item." ]
62
+ end
63
+ end
64
+
65
+ def log_manual_scrape(stage, extra: {})
66
+ payload = { stage:, item_id: @item&.id }.merge(extra.compact)
67
+ Rails.logger.info("[SourceMonitor::ManualScrape] #{payload.to_json}")
68
+ end
69
+ end
70
+ end
@@ -10,7 +10,7 @@ module SourceMonitor
10
10
  PER_PAGE = 25
11
11
  SEARCH_FIELD = :title_or_summary_or_url_or_source_name_cont
12
12
 
13
- before_action :set_item, only: %i[show scrape]
13
+ before_action :set_item, only: :show
14
14
  before_action :load_scrape_context, only: :show
15
15
 
16
16
  def index
@@ -24,6 +24,7 @@ module SourceMonitor
24
24
  per_page: PER_PAGE
25
25
  ).paginate
26
26
 
27
+ @paginator = paginator
27
28
  @items = paginator.records
28
29
  @page = paginator.page
29
30
  @has_next_page = paginator.has_next_page
@@ -36,54 +37,6 @@ module SourceMonitor
36
37
  def show
37
38
  end
38
39
 
39
- # TODO: Extract to ItemScrapesController (CRUD-only convention).
40
- # Deferred to avoid view/route churn in a cleanup phase.
41
- def scrape
42
- log_manual_scrape("controller:start", item: @item, extra: { format: request.format })
43
-
44
- enqueue_result = SourceMonitor::Scraping::Enqueuer.enqueue(item: @item, reason: :manual)
45
- log_manual_scrape(
46
- "controller:enqueue_result",
47
- item: @item,
48
- extra: { status: enqueue_result.status, message: enqueue_result.message }
49
- )
50
- flash_key, flash_message = scrape_flash_payload(enqueue_result)
51
- status = enqueue_result.failure? ? :unprocessable_entity : :ok
52
-
53
- respond_to do |format|
54
- format.turbo_stream do
55
- log_manual_scrape("controller:respond_turbo", item: @item, extra: { status: status })
56
-
57
- responder = SourceMonitor::TurboStreams::StreamResponder.new
58
-
59
- if enqueue_result.enqueued? || enqueue_result.already_enqueued?
60
- @item.reload
61
- responder.replace_details(
62
- @item,
63
- partial: "source_monitor/items/details_wrapper",
64
- locals: { item: @item }
65
- )
66
- end
67
-
68
- if flash_message
69
- level = flash_key == :notice ? :info : :error
70
- responder.toast(message: flash_message, level:, delay_ms: toast_delay_for(level))
71
- end
72
-
73
- render turbo_stream: responder.render(view_context), status: status
74
- end
75
-
76
- format.html do
77
- log_manual_scrape("controller:respond_html", item: @item)
78
- if flash_key && flash_message
79
- redirect_to source_monitor.item_path(@item), flash: { flash_key => flash_message }
80
- else
81
- redirect_to source_monitor.item_path(@item)
82
- end
83
- end
84
- end
85
- end
86
-
87
40
  private
88
41
 
89
42
  def set_item
@@ -94,25 +47,5 @@ module SourceMonitor
94
47
  @recent_scrape_logs = @item.scrape_logs.order(started_at: :desc).limit(5)
95
48
  @latest_scrape_log = @recent_scrape_logs.first
96
49
  end
97
-
98
- def scrape_flash_payload(result)
99
- case result.status
100
- when :enqueued
101
- [ :notice, "Scrape has been enqueued and will run shortly." ]
102
- when :already_enqueued
103
- [ :notice, result.message ]
104
- else
105
- [ :alert, result.message || "Unable to enqueue scrape for this item." ]
106
- end
107
- end
108
-
109
- def log_manual_scrape(stage, item:, extra: {})
110
- return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
111
-
112
- payload = { stage:, item_id: item&.id }.merge(extra.compact)
113
- Rails.logger.info("[SourceMonitor::ManualScrape] #{payload.to_json}")
114
- rescue StandardError
115
- nil
116
- end
117
50
  end
118
51
  end
@@ -3,6 +3,7 @@
3
3
  module SourceMonitor
4
4
  class SourceBulkScrapesController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
6
7
 
7
8
  ITEMS_PREVIEW_LIMIT = SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT
8
9
 
@@ -24,10 +25,6 @@ module SourceMonitor
24
25
 
25
26
  private
26
27
 
27
- def set_source
28
- @source = Source.find(params[:source_id])
29
- end
30
-
31
28
  def bulk_scrape_params
32
29
  params.fetch(:bulk_scrape, {}).permit(:selection)
33
30
  end
@@ -3,6 +3,7 @@
3
3
  module SourceMonitor
4
4
  class SourceFaviconFetchesController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
6
7
 
7
8
  before_action :set_source
8
9
 
@@ -16,23 +17,12 @@ module SourceMonitor
16
17
  end
17
18
 
18
19
  # Clear cooldown so the job doesn't skip this attempt
19
- clear_favicon_cooldown(@source)
20
+ @source.clear_favicon_cooldown!
20
21
 
21
22
  SourceMonitor::FaviconFetchJob.perform_later(@source.id)
22
23
  render_fetch_enqueue_response("Favicon fetch has been enqueued.")
23
24
  rescue StandardError => error
24
25
  handle_fetch_failure(error, prefix: "Favicon fetch")
25
26
  end
26
-
27
- private
28
-
29
- def set_source
30
- @source = Source.find(params[:source_id])
31
- end
32
-
33
- def clear_favicon_cooldown(source)
34
- metadata = (source.metadata || {}).except("favicon_last_attempted_at")
35
- source.update_column(:metadata, metadata)
36
- end
37
27
  end
38
28
  end
@@ -3,6 +3,7 @@
3
3
  module SourceMonitor
4
4
  class SourceFetchesController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
6
7
 
7
8
  before_action :set_source
8
9
 
@@ -12,11 +13,5 @@ module SourceMonitor
12
13
  rescue StandardError => error
13
14
  handle_fetch_failure(error)
14
15
  end
15
-
16
- private
17
-
18
- def set_source
19
- @source = Source.find(params[:source_id])
20
- end
21
16
  end
22
17
  end
@@ -3,6 +3,14 @@
3
3
  module SourceMonitor
4
4
  class SourceHealthChecksController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
7
+
8
+ PROCESSING_BADGE = {
9
+ label: "Processing",
10
+ classes: "bg-blue-100 text-blue-700",
11
+ show_spinner: true,
12
+ status: "processing"
13
+ }.freeze
6
14
 
7
15
  before_action :set_source
8
16
 
@@ -10,25 +18,10 @@ module SourceMonitor
10
18
  SourceMonitor::SourceHealthCheckJob.perform_later(@source.id)
11
19
  render_fetch_enqueue_response(
12
20
  "Health check enqueued",
13
- health_status_override: processing_badge
21
+ health_status_override: PROCESSING_BADGE
14
22
  )
15
23
  rescue StandardError => error
16
24
  handle_fetch_failure(error, prefix: "Health check")
17
25
  end
18
-
19
- private
20
-
21
- def set_source
22
- @source = Source.find(params[:source_id])
23
- end
24
-
25
- def processing_badge
26
- {
27
- label: "Processing",
28
- classes: "bg-blue-100 text-blue-700",
29
- show_spinner: true,
30
- status: "processing"
31
- }
32
- end
33
26
  end
34
27
  end
@@ -3,6 +3,7 @@
3
3
  module SourceMonitor
4
4
  class SourceHealthResetsController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
6
7
 
7
8
  before_action :set_source
8
9
 
@@ -17,11 +18,5 @@ module SourceMonitor
17
18
  rescue StandardError => error
18
19
  handle_fetch_failure(error, prefix: "Health reset")
19
20
  end
20
-
21
- private
22
-
23
- def set_source
24
- @source = Source.find(params[:source_id])
25
- end
26
21
  end
27
22
  end
@@ -3,6 +3,7 @@
3
3
  module SourceMonitor
4
4
  class SourceRetriesController < ApplicationController
5
5
  include SourceMonitor::SourceTurboResponses
6
+ include SourceMonitor::SetSource
6
7
 
7
8
  before_action :set_source
8
9
 
@@ -20,11 +21,5 @@ module SourceMonitor
20
21
  rescue StandardError => error
21
22
  handle_fetch_failure(error)
22
23
  end
23
-
24
- private
25
-
26
- def set_source
27
- @source = Source.find(params[:source_id])
28
- end
29
24
  end
30
25
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SourceMonitor
4
4
  class SourceScrapeTestsController < ApplicationController
5
+ include SourceMonitor::SetSource
6
+
5
7
  before_action :set_source
6
8
 
7
9
  def create
@@ -38,10 +40,6 @@ module SourceMonitor
38
40
 
39
41
  private
40
42
 
41
- def set_source
42
- @source = Source.find(params[:source_id])
43
- end
44
-
45
43
  def pick_test_item
46
44
  @source.items
47
45
  .joins(:item_content)
@@ -107,9 +107,7 @@ module SourceMonitor
107
107
  end
108
108
 
109
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
110
+ SourceMonitor::Scraping::BulkResultPresenter.new(result:).to_flash_payload
113
111
  end
114
112
  end
115
113
  end
@@ -48,20 +48,22 @@ module SourceMonitor
48
48
  @item_activity_rates = metrics.item_activity_rates
49
49
 
50
50
  source_ids = @sources.map(&:id)
51
- if source_ids.any?
52
- base = ItemContent.joins(:item).where(sourcemon_items: { source_id: source_ids })
53
- @avg_feed_word_counts = base.where.not(feed_word_count: nil)
54
- .group("sourcemon_items.source_id")
55
- .average(:feed_word_count)
56
- @avg_scraped_word_counts = base.where.not(scraped_word_count: nil)
57
- .group("sourcemon_items.source_id")
58
- .average(:scraped_word_count)
59
- else
60
- @avg_feed_word_counts = {}
61
- @avg_scraped_word_counts = {}
62
- end
51
+ word_counts = metrics.word_count_averages(source_ids)
52
+ @avg_feed_word_counts = word_counts[:feed]
53
+ @avg_scraped_word_counts = word_counts[:scraped]
63
54
 
64
55
  @scrape_candidate_ids = compute_scrape_candidate_ids
56
+
57
+ # Row partial preload requirements (V3): item_activity_rates,
58
+ # avg_feed_word_counts, avg_scraped_word_counts are pre-computed above
59
+ # and passed as locals to avoid N+1 queries in _row.html.erb.
60
+ adapter_options = Source.distinct.where.not(scraper_adapter: [ nil, "" ]).order(:scraper_adapter).pluck(:scraper_adapter)
61
+ @filter_presenter = SourceMonitor::SourcesFilterPresenter.new(
62
+ search_params: @search_params,
63
+ search_term: @search_term,
64
+ fetch_interval_filter: @fetch_interval_filter,
65
+ adapter_options: adapter_options
66
+ )
65
67
  end
66
68
 
67
69
  def show
@@ -131,7 +133,7 @@ module SourceMonitor
131
133
  search_params:
132
134
  )
133
135
 
134
- redirect_location = safe_redirect_path(params[:redirect_to])
136
+ redirect_location = SourceMonitor::Security::ParameterSanitizer.safe_redirect_path(params[:redirect_to])
135
137
 
136
138
  responder = SourceMonitor::TurboStreams::StreamResponder.new
137
139
  presenter = SourceMonitor::Sources::TurboStreamPresenter.new(source: @source, responder:)
@@ -168,13 +170,6 @@ module SourceMonitor
168
170
  SourceMonitor::Sources::Params.sanitize(params)
169
171
  end
170
172
 
171
- def safe_redirect_path(raw_value)
172
- return if raw_value.blank?
173
-
174
- sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(raw_value.to_s)
175
- sanitized.start_with?("/") ? sanitized : nil
176
- end
177
-
178
173
  def handle_destroy_failure(search_params, error_message)
179
174
  respond_to do |format|
180
175
  format.turbo_stream do
@@ -35,6 +35,16 @@ module SourceMonitor
35
35
  end
36
36
  end
37
37
 
38
+ def compact_blank_hash(hash)
39
+ return {} if hash.blank?
40
+
41
+ if hash.respond_to?(:compact_blank)
42
+ hash.compact_blank
43
+ else
44
+ hash.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
45
+ end
46
+ end
47
+
38
48
  def fetch_interval_bucket_path(bucket, search_params, selected: false)
39
49
  query = fetch_interval_bucket_query(bucket, search_params, selected: selected)
40
50
  route_helpers = SourceMonitor::Engine.routes.url_helpers
@@ -62,11 +72,7 @@ module SourceMonitor
62
72
  updated
63
73
  end
64
74
 
65
- if query.respond_to?(:compact_blank)
66
- query.compact_blank
67
- else
68
- query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
69
- end
75
+ compact_blank_hash(query)
70
76
  end
71
77
 
72
78
  def fetch_interval_filter_label(bucket, filter)
@@ -181,18 +187,10 @@ module SourceMonitor
181
187
  async_status_badge(status)
182
188
  end
183
189
 
184
- # Helper to render the loading spinner SVG
190
+ # Helper to render the loading spinner SVG via IconComponent.
191
+ # Accepts a custom css_class to override the default spinner styling.
185
192
  def loading_spinner_svg(css_class: "mr-1 h-4 w-4 animate-spin text-blue-500")
186
- tag.svg(
187
- class: css_class,
188
- xmlns: "http://www.w3.org/2000/svg",
189
- fill: "none",
190
- viewBox: "0 0 24 24",
191
- aria: { hidden: "true" }
192
- ) do
193
- concat tag.circle(class: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", stroke_width: "4")
194
- concat tag.path(class: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z")
195
- end
193
+ render SourceMonitor::IconComponent.new(:spinner, size: nil, css_class: css_class)
196
194
  end
197
195
 
198
196
  def formatted_setting_value(value)
@@ -276,21 +274,7 @@ module SourceMonitor
276
274
  private
277
275
 
278
276
  def external_link_icon
279
- tag.svg(
280
- class: "inline-block h-3 w-3 text-slate-400",
281
- xmlns: "http://www.w3.org/2000/svg",
282
- fill: "none",
283
- viewBox: "0 0 24 24",
284
- stroke_width: "2",
285
- stroke: "currentColor",
286
- aria: { hidden: "true" }
287
- ) do
288
- tag.path(
289
- stroke_linecap: "round",
290
- stroke_linejoin: "round",
291
- d: "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
292
- )
293
- end
277
+ render SourceMonitor::IconComponent.new(:external_link, size: nil, css_class: "inline-block h-3 w-3 text-slate-400")
294
278
  end
295
279
 
296
280
  def derive_item_scrape_status(item:, source: nil)
@@ -40,6 +40,14 @@ module SourceMonitor
40
40
  path: helpers.source_health_check_path(source),
41
41
  method: :post,
42
42
  data: { testid: "source-health-action-health_check" }
43
+ },
44
+ {
45
+ key: :reset,
46
+ label: "Reset to Active Status",
47
+ description: "Clears failure count, backoff timers, and auto-pause state so the source resumes normal fetching.",
48
+ path: helpers.source_health_reset_path(source),
49
+ method: :post,
50
+ data: { testid: "source-health-action-reset" }
43
51
  }
44
52
  ]
45
53
  else