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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +1 -1
- data/app/assets/javascripts/source_monitor/application.js +4 -0
- data/app/assets/javascripts/source_monitor/controllers/confirm_navigation_controller.js +49 -0
- data/app/assets/javascripts/source_monitor/controllers/select_all_controller.js +36 -0
- data/app/controllers/source_monitor/import_sessions_controller.rb +791 -0
- data/app/controllers/source_monitor/sources_controller.rb +5 -36
- data/app/helpers/source_monitor/application_helper.rb +17 -0
- data/app/jobs/source_monitor/import_opml_job.rb +150 -0
- data/app/jobs/source_monitor/import_session_health_check_job.rb +93 -0
- data/app/models/source_monitor/import_history.rb +35 -0
- data/app/models/source_monitor/import_session.rb +34 -0
- data/app/views/source_monitor/import_sessions/_header.html.erb +12 -0
- data/app/views/source_monitor/import_sessions/_sidebar.html.erb +23 -0
- data/app/views/source_monitor/import_sessions/health_check/_progress.html.erb +20 -0
- data/app/views/source_monitor/import_sessions/health_check/_row.html.erb +44 -0
- data/app/views/source_monitor/import_sessions/show.html.erb +15 -0
- data/app/views/source_monitor/import_sessions/show.turbo_stream.erb +1 -0
- data/app/views/source_monitor/import_sessions/steps/_configure.html.erb +53 -0
- data/app/views/source_monitor/import_sessions/steps/_confirm.html.erb +121 -0
- data/app/views/source_monitor/import_sessions/steps/_health_check.html.erb +82 -0
- data/app/views/source_monitor/import_sessions/steps/_navigation.html.erb +29 -0
- data/app/views/source_monitor/import_sessions/steps/_preview.html.erb +172 -0
- data/app/views/source_monitor/import_sessions/steps/_upload.html.erb +42 -0
- data/app/views/source_monitor/sources/_form.html.erb +8 -138
- data/app/views/source_monitor/sources/_form_fields.html.erb +142 -0
- data/app/views/source_monitor/sources/_import_history_panel.html.erb +53 -0
- data/app/views/source_monitor/sources/index.html.erb +7 -1
- data/config/coverage_baseline.json +91 -15
- data/config/routes.rb +6 -0
- data/db/migrate/20251124090000_create_import_sessions.rb +18 -0
- data/db/migrate/20251124153000_add_health_fields_to_import_sessions.rb +14 -0
- data/db/migrate/20251125094500_create_import_histories.rb +19 -0
- data/lib/source_monitor/health/import_source_health_check.rb +55 -0
- data/lib/source_monitor/health.rb +1 -0
- data/lib/source_monitor/import_sessions/entry_normalizer.rb +30 -0
- data/lib/source_monitor/import_sessions/health_check_broadcaster.rb +103 -0
- data/lib/source_monitor/sources/params.rb +52 -0
- data/lib/source_monitor/version.rb +1 -1
- data/tasks/completed/codebase_audit_2025.md +1396 -0
- data/tasks/completed/engine-asset-configuration.md +203 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-product-brief.md +58 -0
- data/tasks/completed/opml-import-wizard/opml-import-wizard-tech-brief.md +75 -0
- data/tasks/completed/opml-import-wizard/task-01/instructions.md +81 -0
- data/tasks/completed/opml-import-wizard/task-01/requirements.md +19 -0
- data/tasks/completed/opml-import-wizard/task-02/instructions.md +83 -0
- data/tasks/completed/opml-import-wizard/task-02/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-03/instructions.md +58 -0
- data/tasks/completed/opml-import-wizard/task-03/requirements.md +18 -0
- data/tasks/completed/opml-import-wizard/task-04/instructions.md +84 -0
- data/tasks/completed/opml-import-wizard/task-04/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-05/instructions.md +50 -0
- data/tasks/completed/opml-import-wizard/task-05/requirements.md +17 -0
- data/tasks/completed/opml-import-wizard/task-06/instructions.md +92 -0
- data/tasks/completed/opml-import-wizard/task-06/requirements.md +21 -0
- data/tasks/completed/phase_17_01_complexity_audit_2025-10-12.md +62 -0
- data/tasks/completed/phase_17_02_complexity_findings_2025-10-12.md +74 -0
- data/tasks/completed/phase_17_03_refactor_plan_2025-10-12.md +37 -0
- data/tasks/completed/phase_21_01_log_consolidation_2025-10-15.md +30 -0
- data/tasks/completed/release_checklist.md +23 -0
- data/tasks/completed/routes_refactor_evaluation.md +109 -0
- data/tasks/completed/source_monitor_rename_plan.md +70 -0
- data/tasks/completed/tasks.md +952 -0
- data/tasks/ideas.md +10 -0
- metadata +56 -3
- /data/tasks/{prd-setup-workflow-streamlining.md → completed/prd-setup-workflow-streamlining.md} +0 -0
- /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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
132
|
+
45,
|
|
133
|
+
49,
|
|
134
|
+
58,
|
|
82
135
|
66,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
136
|
+
67,
|
|
137
|
+
69,
|
|
138
|
+
106,
|
|
86
139
|
112,
|
|
87
|
-
|
|
88
|
-
|
|
140
|
+
113,
|
|
141
|
+
115,
|
|
89
142
|
119,
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|