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
@@ -8,6 +8,7 @@
8
8
 
9
9
  <% base_params = @filter_params.except(:page) %>
10
10
 
11
+ <%= turbo_frame_tag "source_monitor_logs" do %>
11
12
  <div class="flex flex-wrap items-center gap-3">
12
13
  <div class="flex overflow-hidden rounded-md border border-slate-200">
13
14
  <% status_options = [
@@ -24,7 +25,8 @@
24
25
  "px-4 py-2 text-sm font-medium border-slate-200",
25
26
  active ? "bg-slate-800 text-white" : "bg-white text-slate-600 hover:bg-slate-50",
26
27
  value.present? ? "border-l" : ""
27
- ].join(" ") %>
28
+ ].join(" "),
29
+ data: { turbo_frame: "source_monitor_logs" } %>
28
30
  <% end %>
29
31
  </div>
30
32
 
@@ -44,12 +46,13 @@
44
46
  "px-4 py-2 text-sm font-medium border-slate-200",
45
47
  active ? "bg-slate-800 text-white" : "bg-white text-slate-600 hover:bg-slate-50",
46
48
  value.present? ? "border-l" : ""
47
- ].join(" ") %>
49
+ ].join(" "),
50
+ data: { turbo_frame: "source_monitor_logs" } %>
48
51
  <% end %>
49
52
  </div>
50
53
  </div>
51
54
 
52
- <%= form_with url: source_monitor.logs_path, method: :get, local: true, html: { class: "rounded-lg border border-slate-200 bg-white p-4 shadow-sm" } do |form| %>
55
+ <%= form_with url: source_monitor.logs_path, method: :get, html: { class: "rounded-lg border border-slate-200 bg-white p-4 shadow-sm", data: { turbo_frame: "source_monitor_logs" } } do |form| %>
53
56
  <%= form.hidden_field :status, value: @filter_set.status %>
54
57
  <%= form.hidden_field :log_type, value: @filter_set.log_type %>
55
58
 
@@ -62,12 +65,10 @@
62
65
  class: "mt-1 rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200" %>
63
66
  </div>
64
67
 
65
- <div class="flex flex-col">
66
- <%= form.label :timeframe, "Timeframe", class: "text-xs font-semibold uppercase tracking-wide text-slate-500" %>
67
- <%= form.select :timeframe,
68
- options_for_select([["All time", nil], ["Last 24 hours", "24h"], ["Last 7 days", "7d"], ["Last 30 days", "30d"]], @filter_set.timeframe),
69
- {}, class: "mt-1 rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200" %>
70
- </div>
68
+ <%= render SourceMonitor::FilterDropdownComponent.new(
69
+ label: "Timeframe", param_name: :timeframe,
70
+ options: [["All time", ""], ["Last 24 hours", "24h"], ["Last 7 days", "7d"], ["Last 30 days", "30d"]],
71
+ selected_value: @filter_set.timeframe, form: form) %>
71
72
 
72
73
  <div class="flex flex-col">
73
74
  <%= form.label :started_after, "Started after", class: "text-xs font-semibold uppercase tracking-wide text-slate-500" %>
@@ -100,7 +101,7 @@
100
101
 
101
102
  <div class="mt-4 flex flex-wrap items-center gap-3">
102
103
  <%= form.submit "Search", class: "inline-flex items-center rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
103
- <%= link_to "Clear", source_monitor.logs_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
104
+ <%= link_to "Clear", source_monitor.logs_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500", data: { turbo_frame: "source_monitor_logs" } %>
104
105
  </div>
105
106
  <% end %>
106
107
 
@@ -155,8 +156,9 @@
155
156
  <span class="font-medium"><%= row.http_summary %></span>
156
157
  </td>
157
158
  <td class="px-6 py-4 text-sm">
158
- <% status_classes = row.success? ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>
159
- <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= status_classes %>"><%= row.status_label %></span>
159
+ <%= render SourceMonitor::StatusBadgeComponent.new(
160
+ status: row.success? ? "success" : "failed",
161
+ label: row.status_label) %>
160
162
  <% if row.error_message.present? %>
161
163
  <p class="mt-2 text-xs text-slate-500"><%= row.error_message %></p>
162
164
  <% end %>
@@ -185,33 +187,10 @@
185
187
  </table>
186
188
  </div>
187
189
 
188
- <div class="flex flex-wrap items-center justify-between gap-3">
189
- <div class="text-xs text-slate-500" data-page-indicator="<%= @query_result.page %>">
190
- Page <%= @query_result.page %>
191
- </div>
192
- <div class="flex gap-2">
193
- <% prev_page = @query_result.has_previous_page ? @query_result.page - 1 : @query_result.page %>
194
- <% next_page = @query_result.has_next_page ? @query_result.page + 1 : @query_result.page %>
195
- <% prev_params = @filter_params.merge(page: prev_page).compact %>
196
- <% next_params = @filter_params.merge(page: next_page).compact %>
197
- <%= link_to "Previous",
198
- source_monitor.logs_path(prev_params),
199
- class: [
200
- "inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium",
201
- @query_result.has_previous_page ? "border-slate-300 text-slate-700 hover:bg-slate-50" : "border-slate-200 text-slate-300 cursor-not-allowed"
202
- ].join(" "),
203
- aria: { disabled: !@query_result.has_previous_page } %>
204
- <%= link_to "Next",
205
- source_monitor.logs_path(next_params),
206
- class: [
207
- "inline-flex items-center rounded-md border px-3 py-1 text-sm font-medium",
208
- @query_result.has_next_page ? "border-slate-300 text-slate-700 hover:bg-slate-50" : "border-slate-200 text-slate-300 cursor-not-allowed"
209
- ].join(" "),
210
- aria: { disabled: !@query_result.has_next_page } %>
211
- </div>
212
- </div>
213
-
214
- <p class="text-xs text-slate-500">
215
- Showing up to <%= @query_result.per_page %> logs per page.
216
- </p>
190
+ <%= render "source_monitor/shared/pagination",
191
+ paginator_result: @query_result,
192
+ base_path: source_monitor.logs_path,
193
+ extra_params: @filter_params.except(:page),
194
+ turbo_frame: "source_monitor_logs" %>
195
+ <% end %>
217
196
  </div>
@@ -0,0 +1,14 @@
1
+ <%# Shared form validation error display
2
+ Locals:
3
+ record - ActiveRecord model instance with potential errors
4
+ %>
5
+ <% if record.errors.any? %>
6
+ <div class="mt-4 rounded border border-red-300 bg-red-50 p-4">
7
+ <h2 class="font-medium text-red-700">Please fix the following:</h2>
8
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-red-700">
9
+ <% record.errors.full_messages.each do |message| %>
10
+ <li><%= message %></li>
11
+ <% end %>
12
+ </ul>
13
+ </div>
14
+ <% end %>
@@ -28,35 +28,7 @@
28
28
  </div>
29
29
 
30
30
  <div class="px-6 py-5">
31
- <div class="grid grid-cols-2 gap-6">
32
- <div>
33
- <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Feed Word Count</dt>
34
- <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= test_result[:feed_word_count] || "N/A" %></dd>
35
- </div>
36
- <div>
37
- <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Scraped Word Count</dt>
38
- <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= test_result[:scraped_word_count] || "N/A" %></dd>
39
- </div>
40
- </div>
41
-
42
- <% if test_result[:improvement] && test_result[:improvement] != 0 %>
43
- <div class="mt-4">
44
- <% color = test_result[:improvement] > 0 ? "text-green-600" : "text-amber-600" %>
45
- <span class="text-sm font-medium <%= color %>">
46
- <%= test_result[:improvement] > 0 ? "+" : "" %><%= test_result[:improvement] %>% word count change
47
- </span>
48
- </div>
49
- <% end %>
50
-
51
- <% if test_result[:scrape_result]&.success? %>
52
- <div class="mt-4 rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
53
- Scrape successful. Enabling scraping for this source would capture more content.
54
- </div>
55
- <% else %>
56
- <div class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">
57
- Scrape had issues: <%= test_result[:scrape_result]&.message || "Unknown error" %>
58
- </div>
59
- <% end %>
31
+ <%= render "source_monitor/source_scrape_tests/result_content", test_result: test_result %>
60
32
  </div>
61
33
 
62
34
  <div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
@@ -0,0 +1,33 @@
1
+ <%# Shared scrape test result content used by both show.html.erb and _result.html.erb
2
+ Locals:
3
+ test_result - Hash with :feed_word_count, :scraped_word_count, :improvement, :scrape_result
4
+ %>
5
+ <div class="grid grid-cols-2 gap-6">
6
+ <div>
7
+ <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Feed Word Count</dt>
8
+ <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= test_result[:feed_word_count] || "N/A" %></dd>
9
+ </div>
10
+ <div>
11
+ <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Scraped Word Count</dt>
12
+ <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= test_result[:scraped_word_count] || "N/A" %></dd>
13
+ </div>
14
+ </div>
15
+
16
+ <% if test_result[:improvement] && test_result[:improvement] != 0 %>
17
+ <div class="mt-4">
18
+ <% color = test_result[:improvement] > 0 ? "text-green-600" : "text-amber-600" %>
19
+ <span class="text-sm font-medium <%= color %>">
20
+ <%= test_result[:improvement] > 0 ? "+" : "" %><%= test_result[:improvement] %>% word count change
21
+ </span>
22
+ </div>
23
+ <% end %>
24
+
25
+ <% if test_result[:scrape_result]&.success? %>
26
+ <div class="mt-4 rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
27
+ Scrape successful. Enabling scraping for this source would capture more content.
28
+ </div>
29
+ <% else %>
30
+ <div class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">
31
+ Scrape had issues: <%= test_result[:scrape_result]&.message || "Unknown error" %>
32
+ </div>
33
+ <% end %>
@@ -13,35 +13,7 @@
13
13
  </p>
14
14
  </div>
15
15
  <div class="px-6 py-5">
16
- <div class="grid grid-cols-2 gap-6">
17
- <div>
18
- <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Feed Word Count</dt>
19
- <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= @test_result[:feed_word_count] || "N/A" %></dd>
20
- </div>
21
- <div>
22
- <dt class="text-xs font-medium uppercase tracking-wide text-slate-500">Scraped Word Count</dt>
23
- <dd class="mt-1 text-2xl font-semibold text-slate-900"><%= @test_result[:scraped_word_count] || "N/A" %></dd>
24
- </div>
25
- </div>
26
-
27
- <% if @test_result[:improvement] && @test_result[:improvement] != 0 %>
28
- <div class="mt-4">
29
- <% color = @test_result[:improvement] > 0 ? "text-green-600" : "text-amber-600" %>
30
- <span class="text-sm font-medium <%= color %>">
31
- <%= @test_result[:improvement] > 0 ? "+" : "" %><%= @test_result[:improvement] %>% word count change
32
- </span>
33
- </div>
34
- <% end %>
35
-
36
- <% if @test_result[:scrape_result]&.success? %>
37
- <div class="mt-4 rounded-md bg-green-50 px-3 py-2 text-sm text-green-700">
38
- Scrape successful. Enabling scraping for this source would capture more content.
39
- </div>
40
- <% else %>
41
- <div class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">
42
- Scrape had issues: <%= @test_result[:scrape_result]&.message || "Unknown error" %>
43
- </div>
44
- <% end %>
16
+ <%= render "source_monitor/source_scrape_tests/result_content", test_result: @test_result %>
45
17
  </div>
46
18
 
47
19
  <% unless @source.scraping_enabled? %>
@@ -1,8 +1,8 @@
1
1
  <div data-controller="modal" class="relative">
2
- <div data-modal-target="panel" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" data-action="click->modal#backdrop">
2
+ <div data-modal-target="panel" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50" data-action="click->modal#backdrop" role="dialog" aria-modal="true" aria-labelledby="bulk-scrape-enable-modal-heading">
3
3
  <div class="w-full max-w-md rounded-lg bg-white shadow-xl" data-action="click->modal#stop">
4
4
  <div class="border-b border-slate-200 px-6 py-4">
5
- <h3 class="text-lg font-semibold text-slate-900">Enable Scraping</h3>
5
+ <h3 id="bulk-scrape-enable-modal-heading" class="text-lg font-semibold text-slate-900">Enable Scraping</h3>
6
6
  </div>
7
7
  <div class="px-6 py-4">
8
8
  <p class="text-sm text-slate-700">
@@ -1,10 +1,9 @@
1
1
  <% selected = (local_assigns[:selected] || :current).to_sym %>
2
2
  <% preview_limit = local_assigns[:preview_limit] || SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT %>
3
- <% preview_items_relation = SourceMonitor::Item.where(source_id: source.id)
3
+ <% preview_items = local_assigns[:preview_items] || @items&.to_a || SourceMonitor::Item.where(source_id: source.id)
4
4
  .order(Arel.sql("published_at DESC NULLS LAST, created_at DESC"))
5
- .limit(preview_limit) %>
6
- <% preview_items = preview_items_relation.to_a %>
7
- <% counts = SourceMonitor::Scraping::BulkSourceScraper.selection_counts(
5
+ .limit(preview_limit).to_a %>
6
+ <% counts = local_assigns[:counts] || SourceMonitor::Scraping::BulkSourceScraper.selection_counts(
8
7
  source: source,
9
8
  preview_items: preview_items,
10
9
  preview_limit: preview_limit
@@ -24,12 +23,15 @@
24
23
  data-modal-target="panel"
25
24
  class="hidden fixed inset-0 z-50 items-center justify-center"
26
25
  data-testid="bulk-scrape-modal"
26
+ role="dialog"
27
+ aria-modal="true"
28
+ aria-labelledby="bulk-scrape-modal-heading"
27
29
  >
28
30
  <div class="absolute inset-0 bg-slate-900/40" data-action="click->modal#close"></div>
29
31
  <div class="relative z-10 w-full max-w-xl overflow-hidden rounded-lg bg-white shadow-xl">
30
32
  <div class="flex items-start justify-between border-b border-slate-200 px-6 py-4">
31
33
  <div>
32
- <h2 class="text-lg font-semibold text-slate-900">Bulk Scrape Items</h2>
34
+ <h2 id="bulk-scrape-modal-heading" class="text-lg font-semibold text-slate-900">Bulk Scrape Items</h2>
33
35
  <p class="mt-1 text-xs text-slate-500">Queue scraping jobs for this source without leaving the page.</p>
34
36
  </div>
35
37
  <button
@@ -1,9 +1,9 @@
1
1
  <% source = local_assigns.fetch(:source) %>
2
- <% recent_fetch_logs = source.fetch_logs.order(started_at: :desc).limit(5) %>
3
- <% recent_scrape_logs = source.scrape_logs.order(started_at: :desc).limit(5) %>
2
+ <% presenter = SourceMonitor::SourceDetailsPresenter.new(source) %>
3
+ <% recent_fetch_logs = @recent_fetch_logs || source.fetch_logs.order(started_at: :desc).limit(5) %>
4
+ <% recent_scrape_logs = @recent_scrape_logs || source.scrape_logs.order(started_at: :desc).limit(5) %>
4
5
  <% preview_limit = SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT %>
5
- <% items = source.items.recent.includes(:item_content).limit(preview_limit) %>
6
- <% fetch_status = async_status_badge(source.fetch_status) %>
6
+ <% items = @items || source.items.recent.includes(:item_content).limit(preview_limit) %>
7
7
  <% health_status_override = local_assigns[:health_status_override] %>
8
8
 
9
9
  <div class="space-y-8">
@@ -28,15 +28,12 @@
28
28
  action: "turbo:submit-start->async-submit#start turbo:submit-end->async-submit#finish"
29
29
  }
30
30
  } do %>
31
- <svg class="h-4 w-4 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
32
- <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
33
- </svg>
31
+ <%= render SourceMonitor::IconComponent.new(:refresh, size: :sm, css_class: "text-slate-500") %>
34
32
  <% end %>
35
33
  <% end %>
36
34
  </div>
37
- <span data-testid="fetch-status-badge" class="mt-2 inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= fetch_status[:classes] %>">
38
- <%= loading_spinner_svg if fetch_status[:show_spinner] %>
39
- <%= fetch_status[:label] %>
35
+ <span class="mt-2" data-testid="fetch-status-badge">
36
+ <%= render SourceMonitor::StatusBadgeComponent.new(status: source.fetch_status) %>
40
37
  </span>
41
38
  </div>
42
39
  <div>
@@ -174,34 +171,11 @@
174
171
  <h2 class="text-lg font-medium">Source Details</h2>
175
172
  </div>
176
173
  <dl class="divide-y divide-slate-100">
177
- <% interval_hours = number_with_precision(source.fetch_interval_minutes / 60.0, precision: 2)
178
- circuit_state =
179
- if source.fetch_circuit_open?
180
- until_time = source.fetch_circuit_until&.strftime("%b %d, %Y %H:%M %Z") || "unknown"
181
- "Open until #{until_time}"
182
- else
183
- "Closed"
184
- end
185
-
186
- details = {
187
- "Website" => (source.website_url.present? ? external_link_to(source.website_url, source.website_url, class: "text-slate-900 hover:text-blue-500") : "\u2014"),
188
- "Fetch interval" => "#{source.fetch_interval_minutes} minutes (~#{interval_hours} hours)",
189
- "Adaptive interval" => source.adaptive_fetching_enabled? ? "Auto" : "Fixed",
190
- "Scraper" => source.scraper_adapter,
191
- "Feed content" => source.feed_content_readability_enabled? ? "Readability" : "Raw",
192
- "Active" => source.active? ? "Yes" : "No",
193
- "Scraping" => source.scraping_enabled? ? "Enabled" : "Disabled",
194
- "Auto scrape" => source.auto_scrape? ? "Enabled" : "Disabled",
195
- "Requires JS" => source.requires_javascript? ? "Yes" : "No",
196
- "Failure count" => source.failure_count,
197
- "Retry attempt" => source.fetch_retry_attempt,
198
- "Circuit state" => circuit_state,
199
- "Last error" => source.last_error.presence || "None",
200
- "Items count" => source.items_count,
201
- "Retention days" => source.items_retention_days || "—",
202
- "Max items" => source.max_items || "—"
203
- } %>
204
- <% details.each do |label, value| %>
174
+ <div class="flex items-center justify-between px-5 py-3">
175
+ <dt class="text-sm font-medium text-slate-600">Website</dt>
176
+ <dd class="text-sm text-slate-900"><%= source.website_url.present? ? external_link_to(source.website_url, source.website_url, class: "text-slate-900 hover:text-blue-500") : "\u2014" %></dd>
177
+ </div>
178
+ <% presenter.details_hash.each do |label, value| %>
205
179
  <div class="flex items-center justify-between px-5 py-3">
206
180
  <dt class="text-sm font-medium text-slate-600"><%= label %></dt>
207
181
  <dd class="text-sm text-slate-900"><%= value %></dd>
@@ -246,9 +220,10 @@
246
220
  <div class="px-5 py-3 text-sm text-slate-700">
247
221
  <div class="flex items-center justify-between">
248
222
  <span>Started <%= log.started_at&.strftime("%b %d, %H:%M") || "Unknown" %></span>
249
- <span class="ml-4 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold <%= log.success? ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>">
250
- <%= log.success? ? "Success" : "Failure" %>
251
- </span>
223
+ <%= render SourceMonitor::StatusBadgeComponent.new(
224
+ status: log.success? ? "success" : "failed",
225
+ label: log.success? ? "Success" : "Failure",
226
+ size: :sm) %>
252
227
  </div>
253
228
  <p class="mt-1 text-xs text-slate-500"><%= log.items_created %> created · <%= log.items_updated %> updated · <%= log.items_failed %> failed</p>
254
229
  </div>
@@ -269,9 +244,10 @@
269
244
  <div class="px-5 py-3 text-sm text-slate-700">
270
245
  <div class="flex items-center justify-between">
271
246
  <span>Started <%= log.started_at&.strftime("%b %d, %H:%M") || "Unknown" %></span>
272
- <span class="ml-4 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold <%= log.success? ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>">
273
- <%= log.success? ? "Success" : "Failure" %>
274
- </span>
247
+ <%= render SourceMonitor::StatusBadgeComponent.new(
248
+ status: log.success? ? "success" : "failed",
249
+ label: log.success? ? "Success" : "Failure",
250
+ size: :sm) %>
275
251
  </div>
276
252
  <p class="mt-1 text-xs text-slate-500"><%= log.scraper_adapter || "—" %></p>
277
253
  </div>
@@ -327,14 +303,10 @@
327
303
  </td>
328
304
  <td class="px-5 py-4 text-xs">
329
305
  <% scrape_badge = item_scrape_status_badge(item: item, source: source) %>
330
- <span
331
- class="inline-flex items-center gap-1 rounded-full px-3 py-1 font-semibold <%= scrape_badge[:classes] %>"
332
- data-testid="item-scrape-status-badge"
333
- data-status="<%= scrape_badge[:status] %>"
334
- >
335
- <%= loading_spinner_svg(css_class: "h-3.5 w-3.5 animate-spin text-blue-500") if scrape_badge[:show_spinner] %>
336
- <%= scrape_badge[:label] %>
337
- </span>
306
+ <%= render SourceMonitor::StatusBadgeComponent.new(
307
+ status: scrape_badge[:status],
308
+ label: scrape_badge[:label],
309
+ data: { testid: "item-scrape-status-badge" }) %>
338
310
  </td>
339
311
  <td class="px-5 py-4 text-xs text-slate-500">
340
312
  <%= item.item_content&.feed_word_count || "\u2014" %>
@@ -5,18 +5,16 @@
5
5
  <% interactive = interactive_health_status?(source, override: override) && actions.any? %>
6
6
 
7
7
  <% if interactive %>
8
- <div data-controller="dropdown" class="relative inline-block text-left" data-testid="source-health-menu">
8
+ <div data-controller="dropdown" class="relative inline-block text-left" data-testid="source-health-menu-<%= dom_id(source) %>">
9
9
  <button type="button"
10
10
  class="inline-flex w-full items-center justify-center rounded-full px-3 py-1 text-xs font-semibold <%= badge[:classes] %>"
11
- data-action="dropdown#toggle click@window->dropdown#hide"
12
- data-testid="source-health-menu-toggle">
11
+ data-action="dropdown#toggle"
12
+ data-testid="source-health-menu-toggle-<%= dom_id(source) %>">
13
13
  <% if badge[:show_spinner] %>
14
14
  <%= loading_spinner_svg(css_class: "mr-1 h-3.5 w-3.5 animate-spin text-blue-500") %>
15
15
  <% end %>
16
16
  <span><%= badge[:label] %></span>
17
- <svg class="ml-2 h-3 w-3 text-slate-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
18
- <path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06z" clip-rule="evenodd" />
19
- </svg>
17
+ <%= render SourceMonitor::IconComponent.new(:chevron_down, size: :sm, css_class: "ml-2 text-slate-500") %>
20
18
  </button>
21
19
  <div data-dropdown-target="menu" class="absolute z-20 mt-2 w-80 origin-top-left rounded-md border border-slate-200 bg-white shadow-lg hidden">
22
20
  <div class="py-2">
@@ -4,12 +4,7 @@
4
4
  <% scrape_candidates = local_assigns[:scrape_candidate_ids] || Set.new %>
5
5
  <% activity_rate = rate_map.fetch(source.id, 0.0) %>
6
6
  <% health_status_override = local_assigns[:health_status_override] %>
7
- <% health_status = if !source.active?
8
- { label: "Paused", classes: "bg-amber-100 text-amber-700", show_spinner: false }
9
- else
10
- source_health_badge(source, override: health_status_override)
11
- end %>
12
- <% fetch_status = async_status_badge(source.fetch_status) %>
7
+ <% health_status = source_health_badge(source, override: health_status_override) unless !source.active? %>
13
8
  <% search_params = local_assigns[:search_params] || {} %>
14
9
  <% delete_query =
15
10
  if search_params.respond_to?(:to_unsafe_h)
@@ -21,7 +16,7 @@
21
16
  else
22
17
  {}
23
18
  end %>
24
- <% delete_query = delete_query.respond_to?(:compact_blank) ? delete_query.compact_blank : delete_query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? } if delete_query.present? %>
19
+ <% delete_query = compact_blank_hash(delete_query) if delete_query.present? %>
25
20
  <% delete_path = delete_query.present? ? source_monitor.source_path(source, q: delete_query) : source_monitor.source_path(source) %>
26
21
 
27
22
  <tr id="<%= dom_id(source, :row) %>" class="hover:bg-slate-50">
@@ -72,7 +67,7 @@
72
67
  source: source,
73
68
  health_status_override: health_status_override %>
74
69
  <% else %>
75
- <span class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= health_status[:classes] %>"><%= health_status[:label] %></span>
70
+ <%= render SourceMonitor::StatusBadgeComponent.new(status: "paused") %>
76
71
  <% end %>
77
72
  <% if source.rolling_success_rate.present? %>
78
73
  <span class="text-[11px] text-slate-500">Success Rate: <%= number_to_percentage(source.rolling_success_rate * 100, precision: 0) %></span>
@@ -81,13 +76,10 @@
81
76
  </td>
82
77
  <td class="px-6 py-4">
83
78
  <div class="flex flex-col gap-1 text-xs">
84
- <span class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= fetch_status[:classes] %>">
85
- <%= loading_spinner_svg(css_class: "mr-1 h-3.5 w-3.5 animate-spin text-blue-500") if fetch_status[:show_spinner] %>
86
- <%= fetch_status[:label] %>
79
+ <%= render SourceMonitor::StatusBadgeComponent.new(status: source.fetch_status) %>
87
80
  <% if source.fetch_status == "fetching" && source.last_fetch_started_at.present? %>
88
81
  <span class="ml-2 font-normal text-[10px] text-slate-500">(since <%= source.last_fetch_started_at.strftime("%H:%M:%S") %>)</span>
89
82
  <% end %>
90
- </span>
91
83
  <span class="text-[11px] text-slate-500">(<%= number_with_precision(1440.0 / source.fetch_interval_minutes, precision: 1) %>x / day)</span>
92
84
  </div>
93
85
  </td>
@@ -106,15 +98,12 @@
106
98
  <%= source.last_fetched_at ? source.last_fetched_at.strftime("%b %d, %H:%M") : "Never" %>
107
99
  </td>
108
100
  <td class="px-6 py-4 text-right text-sm">
109
- <div data-controller="dropdown" class="relative inline-block text-left">
101
+ <div data-controller="dropdown" class="relative inline-block text-left" data-testid="source-actions-<%= dom_id(source) %>">
110
102
  <button type="button"
111
103
  class="inline-flex items-center rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
112
- data-action="dropdown#toggle click@window->dropdown#hide"
104
+ data-action="dropdown#toggle"
113
105
  aria-label="Source actions">
114
- <svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
115
- <path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94a.75.75 0 0 0-1.093-.332l-.822.548a2.25 2.25 0 0 1-2.287.014l-.856-.506a.75.75 0 0 0-1.087.63l.03.988a2.25 2.25 0 0 1-.639 1.668l-.715.715a.75.75 0 0 0 0 1.06l.715.715a2.25 2.25 0 0 1 .639 1.668l-.03.988a.75.75 0 0 0 1.087.63l.856-.506a2.25 2.25 0 0 1 2.287.014l.822.548a.75.75 0 0 0 1.093-.332l.38-.926a2.25 2.25 0 0 1 1.451-1.297l.964-.258a.75.75 0 0 0 .534-.72v-.946a.75.75 0 0 0-.534-.72l-.964-.258a2.25 2.25 0 0 1-1.45-1.297l-.381-.926Z" />
116
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" />
117
- </svg>
106
+ <%= render SourceMonitor::IconComponent.new(:menu_dots) %>
118
107
  </button>
119
108
  <div data-dropdown-target="menu" class="absolute right-0 z-10 mt-2 w-36 origin-top-right rounded-md border border-slate-200 bg-white shadow-lg transition hidden">
120
109
  <div class="py-1 text-sm text-slate-700">
@@ -1,16 +1,7 @@
1
1
  <div class="mx-auto max-w-2xl py-10">
2
2
  <h1 class="text-3xl font-semibold">Edit Source</h1>
3
3
 
4
- <% if @source.errors.any? %>
5
- <div class="mt-4 rounded border border-red-300 bg-red-50 p-4">
6
- <h2 class="font-medium text-red-700">Please fix the following:</h2>
7
- <ul class="mt-2 list-disc space-y-1 pl-5 text-red-700">
8
- <% @source.errors.full_messages.each do |message| %>
9
- <li><%= message %></li>
10
- <% end %>
11
- </ul>
12
- </div>
13
- <% end %>
4
+ <%= render "source_monitor/shared/form_errors", record: @source %>
14
5
 
15
6
  <div class="mt-6">
16
7
  <%= render "form", source: @source %>
@@ -28,45 +28,37 @@
28
28
  </div>
29
29
  </div>
30
30
  <div class="flex flex-wrap items-end gap-2">
31
- <div>
32
- <%= form.label :active_eq, "Status", class: "block text-xs font-medium text-slate-500 mb-1" %>
33
- <%= form.select :active_eq, options_for_select([["All Statuses", ""], ["Active", "true"], ["Paused", "false"]], @search_params["active_eq"].to_s), {}, class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500", onchange: "this.form.requestSubmit()" %>
34
- </div>
35
- <div>
36
- <%= form.label :health_status_eq, "Health", class: "block text-xs font-medium text-slate-500 mb-1" %>
37
- <%= form.select :health_status_eq, options_for_select([["All Health", ""], ["Working", "working"], ["Declining", "declining"], ["Improving", "improving"], ["Failing", "failing"]], @search_params["health_status_eq"].to_s), {}, class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500", onchange: "this.form.requestSubmit()" %>
38
- </div>
39
- <div>
40
- <%= form.label :feed_format_eq, "Format", class: "block text-xs font-medium text-slate-500 mb-1" %>
41
- <%= form.select :feed_format_eq, options_for_select([["All Formats", ""], ["RSS", "rss"], ["Atom", "atom"], ["JSON", "json"]], @search_params["feed_format_eq"].to_s), {}, class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500", onchange: "this.form.requestSubmit()" %>
42
- </div>
43
- <div>
44
- <% adapter_options = SourceMonitor::Source.distinct.where.not(scraper_adapter: [nil, ""]).order(:scraper_adapter).pluck(:scraper_adapter) %>
45
- <%= form.label :scraper_adapter_eq, "Adapter", class: "block text-xs font-medium text-slate-500 mb-1" %>
46
- <%= form.select :scraper_adapter_eq, options_for_select([["All Adapters", ""]] + adapter_options.map { |a| [a.titleize, a] }, @search_params["scraper_adapter_eq"].to_s), {}, class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500", onchange: "this.form.requestSubmit()" %>
47
- </div>
48
- <div>
49
- <%= form.label :scraping_enabled_eq, "Scrape", class: "block text-xs font-medium text-slate-500 mb-1" %>
50
- <%= form.select :scraping_enabled_eq, options_for_select([["All Sources", ""], ["Scraping Enabled", "true"], ["Scraping Disabled", "false"], ["Recommendations", "recommend"]], @search_params["scraping_enabled_eq"].to_s), {}, class: "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500", onchange: "this.form.requestSubmit()" %>
51
- </div>
31
+ <%= render SourceMonitor::FilterDropdownComponent.new(
32
+ label: "Status", param_name: :active_eq,
33
+ options: [["All Statuses", ""], ["Active", "true"], ["Paused", "false"]],
34
+ selected_value: @search_params["active_eq"].to_s, form: form) %>
35
+ <%= render SourceMonitor::FilterDropdownComponent.new(
36
+ label: "Health", param_name: :health_status_eq,
37
+ options: [["All Health", ""], ["Working", "working"], ["Declining", "declining"], ["Improving", "improving"], ["Failing", "failing"]],
38
+ selected_value: @search_params["health_status_eq"].to_s, form: form) %>
39
+ <%= render SourceMonitor::FilterDropdownComponent.new(
40
+ label: "Format", param_name: :feed_format_eq,
41
+ options: [["All Formats", ""], ["RSS", "rss"], ["Atom", "atom"], ["JSON", "json"]],
42
+ selected_value: @search_params["feed_format_eq"].to_s, form: form) %>
43
+ <%= render SourceMonitor::FilterDropdownComponent.new(
44
+ label: "Adapter", param_name: :scraper_adapter_eq,
45
+ options: [["All Adapters", ""]] + @filter_presenter.adapter_options.map { |a| [a.titleize, a] },
46
+ selected_value: @search_params["scraper_adapter_eq"].to_s, form: form) %>
47
+ <%= render SourceMonitor::FilterDropdownComponent.new(
48
+ label: "Scrape", param_name: :scraping_enabled_eq,
49
+ options: [["All Sources", ""], ["Scraping Enabled", "true"], ["Scraping Disabled", "false"], ["Recommendations", "recommend"]],
50
+ selected_value: @search_params["scraping_enabled_eq"].to_s, form: form) %>
52
51
  </div>
53
52
  <% end %>
54
53
 
55
54
  <div class="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
56
55
  <%= turbo_frame_tag "source_monitor_sources_table" do %>
57
- <% dropdown_filter_keys = %w[active_eq health_status_eq feed_format_eq scraper_adapter_eq scraping_enabled_eq avg_feed_words_lt] %>
58
- <% active_dropdown_filters = dropdown_filter_keys.select { |k| @search_params[k].present? } %>
59
- <% has_any_filter = @search_term.present? || @fetch_interval_filter.present? || active_dropdown_filters.any? %>
60
- <% if has_any_filter %>
56
+ <% if @filter_presenter.has_any_filter? %>
61
57
  <div class="rounded-t-lg border-b border-blue-100 bg-blue-50 px-4 py-3 text-xs text-blue-700">
62
58
  <% if @search_term.present? %>
63
59
  <% clear_search_query = @search_params.dup %>
64
60
  <% clear_search_query.delete("name_or_feed_url_or_website_url_cont") %>
65
- <% clear_search_query = if clear_search_query.respond_to?(:compact_blank)
66
- clear_search_query.compact_blank
67
- else
68
- clear_search_query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
69
- end %>
61
+ <% clear_search_query = compact_blank_hash(clear_search_query) %>
70
62
  <% clear_search_path = clear_search_query.empty? ? source_monitor.sources_path : source_monitor.sources_path(q: clear_search_query) %>
71
63
  <div>
72
64
  Showing results for "<%= @search_term %>".
@@ -84,28 +76,16 @@
84
76
  </div>
85
77
  <% end %>
86
78
 
87
- <% if active_dropdown_filters.any? %>
79
+ <% if @filter_presenter.active_filter_keys.any? %>
88
80
  <div class="mt-1 flex flex-wrap items-center gap-2">
89
81
  <span>Filtered by</span>
90
- <% filter_labels = {
91
- "active_eq" => @search_params["active_eq"] == "true" ? "Status: Active" : "Status: Paused",
92
- "health_status_eq" => "Health: #{@search_params['health_status_eq']&.titleize}",
93
- "feed_format_eq" => "Format: #{@search_params['feed_format_eq']&.upcase}",
94
- "scraper_adapter_eq" => "Adapter: #{@search_params['scraper_adapter_eq']&.titleize}",
95
- "scraping_enabled_eq" => @search_params["scraping_enabled_eq"] == "true" ? "Scraping: Enabled" : "Scraping: Disabled",
96
- "avg_feed_words_lt" => "Avg Feed Words: < #{@search_params['avg_feed_words_lt']}"
97
- } %>
98
- <% active_dropdown_filters.each do |filter_key| %>
82
+ <% @filter_presenter.active_filter_keys.each do |filter_key| %>
99
83
  <% clear_query = @search_params.dup %>
100
84
  <% clear_query.delete(filter_key) %>
101
- <% clear_query = if clear_query.respond_to?(:compact_blank)
102
- clear_query.compact_blank
103
- else
104
- clear_query.reject { |_k, v| v.respond_to?(:blank?) ? v.blank? : v.nil? }
105
- end %>
85
+ <% clear_query = compact_blank_hash(clear_query) %>
106
86
  <% clear_path = clear_query.empty? ? source_monitor.sources_path : source_monitor.sources_path(q: clear_query) %>
107
87
  <span class="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
108
- <%= filter_labels[filter_key] %>
88
+ <%= @filter_presenter.filter_labels[filter_key] %>
109
89
  <%= link_to "×", clear_path, class: "ml-0.5 font-bold text-blue-500 hover:text-blue-700", data: { turbo_frame: "source_monitor_sources_table" } %>
110
90
  </span>
111
91
  <% end %>