source_monitor 0.1.3 → 0.2.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Gemfile.lock +1 -1
  4. data/app/assets/javascripts/source_monitor/application.js +4 -0
  5. data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
  6. data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
  7. data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
  8. data/app/controllers/source_monitor/sources_controller.rb +5 -36
  9. data/app/helpers/source_monitor/application_helper.rb +17 -0
  10. data/app/jobs/source_monitor/import_opml_job.rb +150 -0
  11. data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
  12. data/app/models/source_monitor/import_history.rb +35 -0
  13. data/app/models/source_monitor/import_session.rb +34 -0
  14. data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
  15. data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
  16. data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
  17. data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
  18. data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
  19. data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
  20. data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
  21. data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
  22. data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
  23. data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
  24. data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
  25. data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
  26. data/app/views/source_monitor/sources/_form.html.erb +8 -138
  27. data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
  28. data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
  29. data/app/views/source_monitor/sources/index.html.erb +7 -1
  30. data/config/coverage_baseline.json +91 -15
  31. data/config/routes.rb +6 -0
  32. data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
  33. data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
  34. data/db/migrate/20251125094500_create_import_histories.rb +19 -0
  35. data/lib/source_monitor/health/import_source_health_check.rb +55 -0
  36. data/lib/source_monitor/health.rb +1 -0
  37. data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
  38. data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
  39. data/lib/source_monitor/sources/params.rb +52 -0
  40. data/lib/source_monitor/version.rb +1 -1
  41. data/tasks/completed/codebase_audit_2025.md +1396 -0
  42. data/tasks/completed/engine-asset-configuration.md +203 -0
  43. data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
  44. data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
  45. data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
  46. data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
  47. data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
  48. data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
  49. data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
  50. data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
  51. data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
  52. data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
  53. data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
  54. data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
  55. data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
  56. data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
  57. data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
  58. data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
  59. data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
  60. data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
  61. data/tasks/completed/release_checklist.md +23 -0
  62. data/tasks/completed/routes_refactor_evaluation.md +109 -0
  63. data/tasks/completed/source_monitor_rename_plan.md +70 -0
  64. data/tasks/completed/tasks.md +952 -0
  65. data/tasks/ideas.md +10 -0
  66. metadata +56 -3
  67. /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
  68. /data/tasks/{tasks-setup-workflow-streamlining.md → completed/tasks-setup-workflow-streamlining.md} +0 -0
@@ -0,0 +1,142 @@
1
+ <% source = form.object %>
2
+ <% include_identity_fields = local_assigns.fetch(:include_identity_fields, true) %>
3
+
4
+ <% if include_identity_fields %>
5
+ <div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
6
+ <div>
7
+ <%= form.label :name, class: "block text-sm font-medium text-slate-800" %>
8
+ <%= form.text_field :name, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= form.label :feed_url, class: "block text-sm font-medium text-slate-800" %>
13
+ <%= form.url_field :feed_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
14
+ </div>
15
+
16
+ <div>
17
+ <%= form.label :website_url, class: "block text-sm font-medium text-slate-800" %>
18
+ <%= form.url_field :website_url, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
19
+ </div>
20
+
21
+ <label class="inline-flex items-center space-x-2">
22
+ <%= form.check_box :active %>
23
+ <span>Active</span>
24
+ </label>
25
+ </div>
26
+ <% end %>
27
+
28
+ <div class="space-y-4 rounded-lg border border-slate-200 bg-white px-5 py-5 shadow-sm">
29
+ <div>
30
+ <%= form.label :fetch_interval_minutes, "Fetch interval (minutes)", class: "block text-sm font-medium text-slate-800" %>
31
+ <%= form.number_field :fetch_interval_minutes, min: 1, step: 1, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
32
+ <p class="mt-1 text-xs text-slate-500">Minimum 5 minutes recommended.</p>
33
+ </div>
34
+
35
+ <label class="flex items-start space-x-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
36
+ <%= form.check_box :adaptive_fetching_enabled, class: "mt-1" %>
37
+ <span>
38
+ <span class="block text-sm font-medium text-slate-800">Automatically adjust fetch interval</span>
39
+ <span class="mt-1 block text-xs text-slate-500">
40
+ When enabled, SourceMonitor lengthens or shortens the interval based on feed activity and failures. Disable to keep the interval fixed at the value above.
41
+ </span>
42
+ </span>
43
+ </label>
44
+
45
+ <div>
46
+ <%= form.label :health_auto_pause_threshold, "Auto-pause threshold", class: "block text-sm font-medium text-slate-800" %>
47
+ <%= form.number_field :health_auto_pause_threshold, min: 0, max: 1, step: 0.05, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
48
+ <p class="mt-1 text-xs text-slate-500">
49
+ Optional override for this source. When the rolling success rate falls below this fraction (defaults to <%= SourceMonitor.config.health.auto_pause_threshold %>), the source is temporarily auto-paused.
50
+ </p>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
55
+ <div class="border-b border-slate-200 px-5 py-4">
56
+ <h2 class="text-lg font-medium">Retention</h2>
57
+ <p class="mt-1 text-xs text-slate-500">
58
+ Choose how long SourceMonitor keeps items for this source. Pruning runs after every fetch so the UI always reflects active retention rules.
59
+ </p>
60
+ </div>
61
+ <div class="grid gap-4 px-5 py-5 sm:grid-cols-2">
62
+ <div>
63
+ <%= form.label :items_retention_days, "Retention window (days)", class: "block text-sm font-medium text-slate-800" %>
64
+ <%= form.number_field :items_retention_days, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
65
+ <p class="mt-1 text-xs text-slate-500">
66
+ Leave blank to keep historical items indefinitely. Items older than the configured window are removed automatically.
67
+ </p>
68
+ </div>
69
+
70
+ <div>
71
+ <%= form.label :max_items, "Maximum stored items", class: "block text-sm font-medium text-slate-800" %>
72
+ <%= form.number_field :max_items, min: 0, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
73
+ <p class="mt-1 text-xs text-slate-500">
74
+ Optional hard cap. SourceMonitor keeps the newest items and prunes the rest after each fetch.
75
+ </p>
76
+ </div>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
81
+ <div class="border-b border-slate-200 px-5 py-4">
82
+ <h2 class="text-lg font-medium">Feed Content Processing</h2>
83
+ <p class="mt-1 text-xs text-slate-500">Control whether feed-supplied content is cleaned with Readability before storing items.</p>
84
+ </div>
85
+ <div class="space-y-4 px-5 py-5">
86
+ <label class="inline-flex items-center space-x-2">
87
+ <%= form.check_box :feed_content_readability_enabled %>
88
+ <span>Process feed content with Readability</span>
89
+ </label>
90
+ <p class="text-xs text-slate-500">
91
+ When enabled, the raw feed HTML is passed through Readability. Scraping configuration below remains independent, so you can mix-and-match feed content and scraping strategies.
92
+ </p>
93
+ </div>
94
+ </div>
95
+
96
+ <% scrape_settings = (source.scrape_settings || {}).with_indifferent_access %>
97
+ <% selectors = scrape_settings[:selectors] || {} %>
98
+
99
+ <div class="rounded-lg border border-slate-200 bg-white shadow-sm">
100
+ <div class="border-b border-slate-200 px-5 py-4">
101
+ <h2 class="text-lg font-medium">Scraping Configuration</h2>
102
+ <p class="mt-1 text-xs text-slate-500">Control how content extraction runs for this source.</p>
103
+ </div>
104
+ <div class="space-y-5 px-5 py-5">
105
+ <div class="grid gap-3 sm:grid-cols-2">
106
+ <label class="inline-flex items-center space-x-2">
107
+ <%= form.check_box :scraping_enabled %>
108
+ <span>Scraping enabled</span>
109
+ </label>
110
+
111
+ <label class="inline-flex items-center space-x-2">
112
+ <%= form.check_box :auto_scrape %>
113
+ <span>Auto scrape after fetch</span>
114
+ </label>
115
+
116
+ <label class="inline-flex items-center space-x-2">
117
+ <%= form.check_box :requires_javascript %>
118
+ <span>Requires JavaScript</span>
119
+ </label>
120
+ </div>
121
+
122
+ <div>
123
+ <%= form.label :scraper_adapter, "Scraper adapter", class: "block text-sm font-medium text-slate-800" %>
124
+ <%= form.select :scraper_adapter, [["Readability", "readability"]], {}, class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2" %>
125
+ <p class="mt-1 text-xs text-slate-500">Additional adapters can be configured in future phases.</p>
126
+ </div>
127
+
128
+ <div class="grid gap-4 sm:grid-cols-2">
129
+ <div>
130
+ <label for="source_scrape_settings_selectors_content" class="block text-sm font-medium text-slate-800">Content CSS selector</label>
131
+ <%= text_field_tag "source[scrape_settings][selectors][content]", selectors[:content], id: "source_scrape_settings_selectors_content", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: ".article-body" %>
132
+ <p class="mt-1 text-xs text-slate-500">Optional. Overrides Readability extraction target.</p>
133
+ </div>
134
+
135
+ <div>
136
+ <label for="source_scrape_settings_selectors_title" class="block text-sm font-medium text-slate-800">Title CSS selector</label>
137
+ <%= text_field_tag "source[scrape_settings][selectors][title]", selectors[:title], id: "source_scrape_settings_selectors_title", class: "mt-1 block w-full rounded border border-slate-300 px-3 py-2", placeholder: "h1.article-title" %>
138
+ <p class="mt-1 text-xs text-slate-500">Optional. Leave blank to use feed-provided title.</p>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </div>
@@ -0,0 +1,53 @@
1
+ <% latest = import_histories&.first %>
2
+ <div id="source_monitor_import_history_panel">
3
+ <% if latest.present? %>
4
+ <div class="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 shadow-sm">
5
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
6
+ <div>
7
+ <p class="text-xs font-semibold uppercase tracking-wide text-blue-700">Recent OPML import</p>
8
+ <h3 class="text-lg font-semibold text-slate-900">
9
+ Imported <%= latest.imported_count %> • Skipped <%= latest.skipped_count %> • Failed <%= latest.failed_count %>
10
+ </h3>
11
+ <p class="text-sm text-slate-700">
12
+ Completed <%= time_ago_in_words(latest.completed_at || latest.created_at) %> ago.
13
+ </p>
14
+ </div>
15
+ <% if latest.completed? && latest.duration_ms %>
16
+ <div class="text-right text-sm text-slate-600">
17
+ <p>Duration: <%= latest.duration_ms %> ms</p>
18
+ </div>
19
+ <% end %>
20
+ </div>
21
+
22
+ <% if latest.failed_sources.present? %>
23
+ <div class="mt-3 overflow-x-auto rounded-md border border-rose-100 bg-white">
24
+ <table class="min-w-full divide-y divide-rose-50 text-left text-sm">
25
+ <thead class="bg-rose-50 text-xs font-semibold uppercase tracking-wide text-rose-700">
26
+ <tr>
27
+ <th scope="col" class="px-3 py-2">Feed URL</th>
28
+ <th scope="col" class="px-3 py-2">Error</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody class="divide-y divide-rose-50 text-rose-800">
32
+ <% Array(latest.failed_sources).each do |failure| %>
33
+ <tr>
34
+ <td class="px-3 py-2 align-top font-mono text-xs text-slate-800 break-all"><%= failure["feed_url"] || failure[:feed_url] %></td>
35
+ <td class="px-3 py-2 align-top text-sm">
36
+ <span class="font-semibold"><%= failure["error_class"] || failure[:error_class] %>:</span>
37
+ <%= failure["error_message"] || failure[:error_message] %>
38
+ </td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+ </div>
44
+ <% elsif latest.skipped_count.positive? %>
45
+ <p class="mt-2 text-sm text-slate-700">Skipped duplicates: <%= latest.skipped_count %>.</p>
46
+ <% end %>
47
+ </div>
48
+ <% else %>
49
+ <div class="rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
50
+ No recent OPML imports.
51
+ </div>
52
+ <% end %>
53
+ </div>
@@ -13,10 +13,16 @@
13
13
  <%= form.submit "Search", class: "rounded-r-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500" %>
14
14
  </div>
15
15
  <% end %>
16
- <%= link_to "New Source", source_monitor.new_source_path, class: "inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
16
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
17
+ <%= link_to "New Source", source_monitor.new_source_path, class: "inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
18
+ <%= link_to "Import OPML", source_monitor.new_import_session_path,
19
+ class: "inline-flex items-center justify-center rounded-md border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50" %>
20
+ </div>
17
21
  </div>
18
22
  </div>
19
23
 
24
+ <%= render "source_monitor/sources/import_history_panel", import_histories: @recent_import_histories %>
25
+
20
26
  <%= render "source_monitor/sources/fetch_interval_heatmap",
21
27
  fetch_interval_distribution: @fetch_interval_distribution,
22
28
  selected_bucket: @selected_fetch_interval_bucket,
@@ -9,7 +9,6 @@
9
9
  77
10
10
  ],
11
11
  "app/controllers/source_monitor/application_controller.rb": [
12
- 33,
13
12
  37
14
13
  ],
15
14
  "app/controllers/source_monitor/fetch_logs_controller.rb": [
@@ -21,6 +20,61 @@
21
20
  8,
22
21
  9
23
22
  ],
23
+ "app/controllers/source_monitor/import_sessions_controller.rb": [
24
+ 28,
25
+ 33,
26
+ 50,
27
+ 51,
28
+ 52,
29
+ 54,
30
+ 76,
31
+ 77,
32
+ 91,
33
+ 92,
34
+ 93,
35
+ 95,
36
+ 97,
37
+ 101,
38
+ 102,
39
+ 103,
40
+ 104,
41
+ 341,
42
+ 347,
43
+ 349,
44
+ 357,
45
+ 359,
46
+ 360,
47
+ 361,
48
+ 364,
49
+ 365,
50
+ 366,
51
+ 367,
52
+ 370,
53
+ 372,
54
+ 376,
55
+ 378,
56
+ 379,
57
+ 380,
58
+ 382,
59
+ 383,
60
+ 387,
61
+ 391,
62
+ 393,
63
+ 395,
64
+ 397,
65
+ 399,
66
+ 401,
67
+ 463,
68
+ 520,
69
+ 532,
70
+ 536,
71
+ 547,
72
+ 575,
73
+ 593,
74
+ 594,
75
+ 721,
76
+ 723
77
+ ],
24
78
  "app/controllers/source_monitor/items_controller.rb": [
25
79
  40,
26
80
  42,
@@ -72,23 +126,23 @@
72
126
  103
73
127
  ],
74
128
  "app/controllers/source_monitor/sources_controller.rb": [
75
- 41,
76
129
  42,
77
130
  43,
78
131
  44,
79
- 48,
80
- 57,
81
- 65,
132
+ 45,
133
+ 49,
134
+ 58,
82
135
  66,
83
- 68,
84
- 105,
85
- 111,
136
+ 67,
137
+ 69,
138
+ 106,
86
139
  112,
87
- 114,
88
- 118,
140
+ 113,
141
+ 115,
89
142
  119,
90
- 121,
91
- 131
143
+ 120,
144
+ 122,
145
+ 132
92
146
  ],
93
147
  "app/helpers/source_monitor/application_helper.rb": [
94
148
  64,
@@ -107,6 +161,9 @@
107
161
  95,
108
162
  114
109
163
  ],
164
+ "app/jobs/source_monitor/import_session_health_check_job.rb": [
165
+ 56
166
+ ],
110
167
  "app/jobs/source_monitor/item_cleanup_job.rb": [
111
168
  40
112
169
  ],
@@ -889,12 +946,31 @@
889
946
  81
890
947
  ],
891
948
  "lib/source_monitor/health.rb": [
892
- 22,
893
- 35,
949
+ 23,
894
950
  36,
895
951
  37,
952
+ 38,
953
+ 40,
954
+ 43
955
+ ],
956
+ "lib/source_monitor/health/import_source_health_check.rb": [
957
+ 9,
958
+ 10,
959
+ 14,
960
+ 16,
961
+ 17,
962
+ 18,
963
+ 20,
964
+ 21,
965
+ 22,
966
+ 26,
967
+ 34,
968
+ 38,
896
969
  39,
897
- 42
970
+ 41,
971
+ 45,
972
+ 49,
973
+ 51
898
974
  ],
899
975
  "lib/source_monitor/health/source_health_check.rb": [
900
976
  43
data/config/routes.rb CHANGED
@@ -5,6 +5,12 @@ SourceMonitor::Engine.routes.draw do
5
5
  resources :logs, only: :index
6
6
  resources :fetch_logs, only: :show
7
7
  resources :scrape_logs, only: :show
8
+ resources :import_sessions, path: "import_opml", only: %i[new create show update destroy] do
9
+ member do
10
+ get "steps/:step", action: :show, as: :step
11
+ patch "steps/:step", action: :update
12
+ end
13
+ end
8
14
  resources :items, only: %i[index show] do
9
15
  post :scrape, on: :member
10
16
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateImportSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :"#{SourceMonitor.table_name_prefix}import_sessions" do |t|
6
+ t.references :user, null: false, foreign_key: true
7
+ t.jsonb :opml_file_metadata, null: false, default: {}
8
+ t.jsonb :parsed_sources, null: false, default: []
9
+ t.jsonb :selected_source_ids, null: false, default: []
10
+ t.jsonb :bulk_settings, null: false, default: {}
11
+ t.string :current_step, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :"#{SourceMonitor.table_name_prefix}import_sessions", :current_step
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddHealthFieldsToImportSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ change_table :"#{SourceMonitor.table_name_prefix}import_sessions" do |t|
6
+ t.boolean :health_checks_active, null: false, default: false
7
+ t.jsonb :health_check_target_ids, null: false, default: []
8
+ t.datetime :health_check_started_at
9
+ t.datetime :health_check_completed_at
10
+ end
11
+
12
+ add_index :"#{SourceMonitor.table_name_prefix}import_sessions", :health_checks_active
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateImportHistories < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :"#{SourceMonitor.table_name_prefix}import_histories" do |t|
6
+ t.references :user, null: false, foreign_key: true
7
+ t.jsonb :imported_sources, null: false, default: []
8
+ t.jsonb :failed_sources, null: false, default: []
9
+ t.jsonb :skipped_duplicates, null: false, default: []
10
+ t.jsonb :bulk_settings, null: false, default: {}
11
+ t.datetime :started_at
12
+ t.datetime :completed_at
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :"#{SourceMonitor.table_name_prefix}import_histories", :created_at
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Health
5
+ class ImportSourceHealthCheck
6
+ Result = Struct.new(:status, :error_message, :http_status, keyword_init: true)
7
+
8
+ def initialize(feed_url:, client: nil)
9
+ @feed_url = feed_url
10
+ @client = client
11
+ end
12
+
13
+ def call
14
+ return Result.new(status: "unhealthy", error_message: "Missing feed URL", http_status: nil) if feed_url.blank?
15
+
16
+ response = connection.get(feed_url)
17
+ status_code = response_status(response)
18
+ healthy = healthy_status?(status_code)
19
+
20
+ Result.new(
21
+ status: healthy ? "healthy" : "unhealthy",
22
+ error_message: healthy ? nil : error_for_status(status_code),
23
+ http_status: status_code
24
+ )
25
+ rescue StandardError => error
26
+ Result.new(status: "unhealthy", error_message: error.message, http_status: response_status(error))
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :feed_url, :client
32
+
33
+ def connection
34
+ @connection ||= (client || SourceMonitor::HTTP.client(headers: {}, retry_requests: false))
35
+ end
36
+
37
+ def response_status(response)
38
+ return response.status if response.respond_to?(:status)
39
+ return response.response[:status] if response.respond_to?(:response) && response.response.is_a?(Hash)
40
+
41
+ nil
42
+ end
43
+
44
+ def healthy_status?(status)
45
+ status.present? && status.to_i.between?(200, 399)
46
+ end
47
+
48
+ def error_for_status(status)
49
+ return "Request failed" if status.blank?
50
+
51
+ "HTTP #{status}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -3,6 +3,7 @@
3
3
  require "source_monitor/health/source_health_monitor"
4
4
  require "source_monitor/health/source_health_reset"
5
5
  require "source_monitor/health/source_health_check"
6
+ require "source_monitor/health/import_source_health_check"
6
7
 
7
8
  module SourceMonitor
8
9
  module Health
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module ImportSessions
5
+ module EntryNormalizer
6
+ module_function
7
+
8
+ def normalize(entry)
9
+ entry = entry.to_h
10
+
11
+ {
12
+ id: string_for(entry[:id] || entry["id"] || entry[:feed_url] || entry["feed_url"]),
13
+ feed_url: entry[:feed_url].presence || entry["feed_url"].presence,
14
+ title: entry[:title].presence || entry["title"].presence,
15
+ website_url: entry[:website_url].presence || entry["website_url"].presence,
16
+ status: entry[:status].presence || entry["status"].presence || "valid",
17
+ error: entry[:error].presence || entry["error"].presence,
18
+ raw_outline_index: entry[:raw_outline_index] || entry["raw_outline_index"],
19
+ health_status: entry[:health_status].presence || entry["health_status"].presence,
20
+ health_error: entry[:health_error].presence || entry["health_error"].presence
21
+ }
22
+ end
23
+
24
+ def string_for(value)
25
+ value&.to_s
26
+ end
27
+ private_class_method :string_for
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module ImportSessions
5
+ class HealthCheckBroadcaster
6
+ include ActionView::RecordIdentifier
7
+
8
+ require "source_monitor/import_sessions/entry_normalizer"
9
+
10
+ def initialize(import_session)
11
+ @import_session = import_session
12
+ end
13
+
14
+ def stream_name
15
+ import_session.health_stream_name
16
+ end
17
+
18
+ def broadcast_row(entry)
19
+ return unless turbo_available?
20
+ return unless entry
21
+
22
+ Turbo::StreamsChannel.broadcast_replace_to(
23
+ stream_name,
24
+ target: row_target(entry),
25
+ html: render_row(entry)
26
+ )
27
+ end
28
+
29
+ def broadcast_progress
30
+ return unless turbo_available?
31
+
32
+ Turbo::StreamsChannel.broadcast_replace_to(
33
+ stream_name,
34
+ target: progress_target,
35
+ html: render_progress
36
+ )
37
+ end
38
+
39
+ def progress_data
40
+ entries = health_entries
41
+ total = import_session.health_check_targets.size
42
+ completed = entries.count { |entry| %w[healthy unhealthy].include?(entry[:health_status].to_s) }
43
+
44
+ {
45
+ completed: completed,
46
+ total: total,
47
+ pending: [ total - completed, 0 ].max,
48
+ active: import_session.health_checks_active?,
49
+ done: total.positive? && completed >= total
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :import_session
56
+
57
+ def row_target(entry)
58
+ "import_session_#{import_session.id}_health_row_#{entry_id(entry)}"
59
+ end
60
+
61
+ def progress_target
62
+ "import_session_#{import_session.id}_health_progress"
63
+ end
64
+
65
+ def entry_id(entry)
66
+ entry[:id] || entry["id"]
67
+ end
68
+
69
+ def render_row(entry)
70
+ SourceMonitor::ImportSessionsController.render(
71
+ partial: "source_monitor/import_sessions/health_check/row",
72
+ locals: { import_session:, entry: entry_with_selection(entry) }
73
+ )
74
+ end
75
+
76
+ def render_progress
77
+ SourceMonitor::ImportSessionsController.render(
78
+ partial: "source_monitor/import_sessions/health_check/progress",
79
+ locals: { import_session:, progress: progress_data }
80
+ )
81
+ end
82
+
83
+ def entry_with_selection(entry)
84
+ selected_ids = Array(import_session.selected_source_ids).map(&:to_s)
85
+ normalized = entry.is_a?(Hash) ? entry.symbolize_keys : entry
86
+ normalized.merge(selected: selected_ids.include?(entry_id(entry).to_s))
87
+ end
88
+
89
+ def health_entries
90
+ targets = import_session.health_check_targets
91
+ selected_ids = Array(import_session.selected_source_ids).map(&:to_s)
92
+
93
+ Array(import_session.parsed_sources).map { |entry| SourceMonitor::ImportSessions::EntryNormalizer.normalize(entry) }
94
+ .select { |entry| targets.include?(entry[:id]) }
95
+ .map { |entry| entry.merge(selected: selected_ids.include?(entry[:id])) }
96
+ end
97
+
98
+ def turbo_available?
99
+ defined?(Turbo::StreamsChannel)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module Sources
5
+ module Params
6
+ module_function
7
+
8
+ def permitted_attributes
9
+ [
10
+ :name,
11
+ :feed_url,
12
+ :website_url,
13
+ :fetch_interval_minutes,
14
+ :active,
15
+ :auto_scrape,
16
+ :scraping_enabled,
17
+ :requires_javascript,
18
+ :feed_content_readability_enabled,
19
+ :scraper_adapter,
20
+ :items_retention_days,
21
+ :max_items,
22
+ :adaptive_fetching_enabled,
23
+ :health_auto_pause_threshold,
24
+ { scrape_settings: [
25
+ :include_plain_text,
26
+ :timeout,
27
+ :javascript_enabled,
28
+ { selectors: %i[content title], http: [], readability: [] }
29
+ ] }
30
+ ]
31
+ end
32
+
33
+ def default_attributes
34
+ {
35
+ active: true,
36
+ scraping_enabled: false,
37
+ auto_scrape: false,
38
+ requires_javascript: false,
39
+ feed_content_readability_enabled: false,
40
+ fetch_interval_minutes: 360,
41
+ adaptive_fetching_enabled: true,
42
+ scraper_adapter: "readability"
43
+ }
44
+ end
45
+
46
+ def sanitize(params)
47
+ permitted = params.require(:source).permit(*permitted_attributes)
48
+ SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h)
49
+ end
50
+ end
51
+ end
52
+ end