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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "source_monitor/sources/turbo_stream_presenter"
|
|
4
4
|
require "source_monitor/scraping/bulk_result_presenter"
|
|
5
|
+
require "source_monitor/sources/params"
|
|
5
6
|
|
|
6
7
|
module SourceMonitor
|
|
7
8
|
class SourcesController < ApplicationController
|
|
@@ -31,6 +32,8 @@ module SourceMonitor
|
|
|
31
32
|
search_params: @search_params
|
|
32
33
|
)
|
|
33
34
|
|
|
35
|
+
@recent_import_histories = SourceMonitor::ImportHistory.recent_for(source_monitor_current_user&.id).limit(5)
|
|
36
|
+
|
|
34
37
|
@fetch_interval_distribution = metrics.fetch_interval_distribution
|
|
35
38
|
@fetch_interval_filter = metrics.fetch_interval_filter
|
|
36
39
|
@selected_fetch_interval_bucket = metrics.selected_fetch_interval_bucket
|
|
@@ -128,45 +131,11 @@ module SourceMonitor
|
|
|
128
131
|
end
|
|
129
132
|
|
|
130
133
|
def default_attributes
|
|
131
|
-
|
|
132
|
-
active: true,
|
|
133
|
-
scraping_enabled: false,
|
|
134
|
-
auto_scrape: false,
|
|
135
|
-
requires_javascript: false,
|
|
136
|
-
feed_content_readability_enabled: false,
|
|
137
|
-
fetch_interval_minutes: 360,
|
|
138
|
-
adaptive_fetching_enabled: true,
|
|
139
|
-
scraper_adapter: "readability"
|
|
140
|
-
}
|
|
134
|
+
SourceMonitor::Sources::Params.default_attributes
|
|
141
135
|
end
|
|
142
136
|
|
|
143
137
|
def source_params
|
|
144
|
-
|
|
145
|
-
:name,
|
|
146
|
-
:feed_url,
|
|
147
|
-
:website_url,
|
|
148
|
-
:fetch_interval_minutes,
|
|
149
|
-
:active,
|
|
150
|
-
:auto_scrape,
|
|
151
|
-
:scraping_enabled,
|
|
152
|
-
:requires_javascript,
|
|
153
|
-
:feed_content_readability_enabled,
|
|
154
|
-
:scraper_adapter,
|
|
155
|
-
:items_retention_days,
|
|
156
|
-
:max_items,
|
|
157
|
-
:adaptive_fetching_enabled,
|
|
158
|
-
:health_auto_pause_threshold,
|
|
159
|
-
scrape_settings: [
|
|
160
|
-
:include_plain_text,
|
|
161
|
-
:timeout,
|
|
162
|
-
:javascript_enabled,
|
|
163
|
-
{ selectors: %i[content title],
|
|
164
|
-
http: [],
|
|
165
|
-
readability: [] }
|
|
166
|
-
]
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
SourceMonitor::Security::ParameterSanitizer.sanitize(permitted.to_h)
|
|
138
|
+
SourceMonitor::Sources::Params.sanitize(params)
|
|
170
139
|
end
|
|
171
140
|
|
|
172
141
|
def safe_redirect_path(raw_value)
|
|
@@ -303,6 +303,23 @@ module SourceMonitor
|
|
|
303
303
|
end
|
|
304
304
|
end
|
|
305
305
|
|
|
306
|
+
def formatted_setting_value(value)
|
|
307
|
+
case value
|
|
308
|
+
when TrueClass
|
|
309
|
+
"Enabled"
|
|
310
|
+
when FalseClass
|
|
311
|
+
"Disabled"
|
|
312
|
+
when Hash
|
|
313
|
+
value.to_json
|
|
314
|
+
when Array
|
|
315
|
+
value.join(", ")
|
|
316
|
+
when NilClass
|
|
317
|
+
"—"
|
|
318
|
+
else
|
|
319
|
+
value
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
306
323
|
private
|
|
307
324
|
|
|
308
325
|
def derive_item_scrape_status(item:, source: nil)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "source_monitor/import_sessions/entry_normalizer"
|
|
5
|
+
require "source_monitor/realtime/broadcaster"
|
|
6
|
+
require "source_monitor/sources/params"
|
|
7
|
+
|
|
8
|
+
module SourceMonitor
|
|
9
|
+
class ImportOpmlJob < ApplicationJob
|
|
10
|
+
source_monitor_queue :fetch
|
|
11
|
+
|
|
12
|
+
discard_on ActiveJob::DeserializationError
|
|
13
|
+
|
|
14
|
+
def perform(import_session_id, import_history_id)
|
|
15
|
+
@import_session = SourceMonitor::ImportSession.find_by(id: import_session_id)
|
|
16
|
+
@import_history = SourceMonitor::ImportHistory.find_by(id: import_history_id)
|
|
17
|
+
return unless import_session && import_history
|
|
18
|
+
|
|
19
|
+
import_history.update_columns(started_at: Time.current) unless import_history.started_at
|
|
20
|
+
|
|
21
|
+
processed = Set.new
|
|
22
|
+
|
|
23
|
+
selected_entries.each do |entry|
|
|
24
|
+
process_entry(entry, processed)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
import_history.update!(
|
|
28
|
+
imported_sources: imported_sources,
|
|
29
|
+
failed_sources: failed_sources,
|
|
30
|
+
skipped_duplicates: skipped_duplicates,
|
|
31
|
+
bulk_settings: import_session.bulk_settings.presence || {},
|
|
32
|
+
completed_at: Time.current
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
broadcast_completion(import_history)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :import_session, :import_history
|
|
41
|
+
|
|
42
|
+
def selected_entries
|
|
43
|
+
ids = Array(import_session.selected_source_ids).map(&:to_s)
|
|
44
|
+
|
|
45
|
+
Array(import_session.parsed_sources)
|
|
46
|
+
.map { |entry| SourceMonitor::ImportSessions::EntryNormalizer.normalize(entry) }
|
|
47
|
+
.select { |entry| ids.include?(entry[:id]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def process_entry(entry, processed)
|
|
51
|
+
feed_url = entry[:feed_url].to_s
|
|
52
|
+
|
|
53
|
+
if feed_url.blank?
|
|
54
|
+
failed_sources << failure_payload(feed_url, "MissingFeedURL", "Feed URL is missing")
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
normalized_url = feed_url.downcase
|
|
59
|
+
|
|
60
|
+
if processed.include?(normalized_url)
|
|
61
|
+
skipped_duplicates << skipped_payload(feed_url, "duplicate in import selection")
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if duplicate_source?(normalized_url)
|
|
66
|
+
skipped_duplicates << skipped_payload(feed_url, "already exists")
|
|
67
|
+
processed << normalized_url
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
source = SourceMonitor::Source.new(build_attributes(entry))
|
|
72
|
+
|
|
73
|
+
if source.save
|
|
74
|
+
imported_sources << { id: source.id, feed_url: source.feed_url, name: source.name }
|
|
75
|
+
processed << normalized_url
|
|
76
|
+
else
|
|
77
|
+
failed_sources << failure_payload(feed_url, "ValidationFailed", source.errors.full_messages.to_sentence)
|
|
78
|
+
end
|
|
79
|
+
rescue ActiveRecord::RecordNotUnique
|
|
80
|
+
skipped_duplicates << skipped_payload(feed_url, "already exists")
|
|
81
|
+
processed << normalized_url
|
|
82
|
+
rescue StandardError => error
|
|
83
|
+
failed_sources << failure_payload(feed_url, error.class.name, error.message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def duplicate_source?(normalized_feed_url)
|
|
87
|
+
SourceMonitor::Source.where("LOWER(feed_url) = ?", normalized_feed_url).exists?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_attributes(entry)
|
|
91
|
+
defaults = SourceMonitor::Sources::Params.default_attributes.deep_dup
|
|
92
|
+
settings = SourceMonitor::Security::ParameterSanitizer.sanitize(import_session.bulk_settings.presence || {})
|
|
93
|
+
settings = settings.deep_symbolize_keys
|
|
94
|
+
|
|
95
|
+
defaults.merge(settings).merge(identity_attributes(entry))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def identity_attributes(entry)
|
|
99
|
+
{
|
|
100
|
+
name: entry[:title].presence || entry[:feed_url],
|
|
101
|
+
feed_url: entry[:feed_url],
|
|
102
|
+
website_url: entry[:website_url]
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def imported_sources
|
|
107
|
+
@imported_sources ||= []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def failed_sources
|
|
111
|
+
@failed_sources ||= []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def skipped_duplicates
|
|
115
|
+
@skipped_duplicates ||= []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def failure_payload(feed_url, error_class, message)
|
|
119
|
+
{
|
|
120
|
+
feed_url: feed_url,
|
|
121
|
+
error_class: error_class,
|
|
122
|
+
error_message: message
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def skipped_payload(feed_url, reason)
|
|
127
|
+
{
|
|
128
|
+
feed_url: feed_url,
|
|
129
|
+
reason: reason
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def broadcast_completion(history)
|
|
134
|
+
return unless defined?(Turbo::StreamsChannel)
|
|
135
|
+
|
|
136
|
+
histories = SourceMonitor::ImportHistory.recent_for(history.user_id).limit(5)
|
|
137
|
+
|
|
138
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
139
|
+
SourceMonitor::Realtime::Broadcaster::SOURCE_INDEX_STREAM,
|
|
140
|
+
target: "source_monitor_import_history_panel",
|
|
141
|
+
html: SourceMonitor::SourcesController.render(
|
|
142
|
+
partial: "source_monitor/sources/import_history_panel",
|
|
143
|
+
locals: { import_histories: histories }
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
rescue StandardError => error
|
|
147
|
+
Rails.logger.error("[SourceMonitor::ImportOpmlJob] broadcast failed: #{error.class}: #{error.message}") if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class ImportSessionHealthCheckJob < ApplicationJob
|
|
5
|
+
source_monitor_queue :fetch
|
|
6
|
+
|
|
7
|
+
require "source_monitor/health/import_source_health_check"
|
|
8
|
+
require "source_monitor/import_sessions/entry_normalizer"
|
|
9
|
+
require "source_monitor/import_sessions/health_check_broadcaster"
|
|
10
|
+
|
|
11
|
+
discard_on ActiveJob::DeserializationError
|
|
12
|
+
|
|
13
|
+
def perform(import_session_id, entry_id)
|
|
14
|
+
import_session = SourceMonitor::ImportSession.find_by(id: import_session_id)
|
|
15
|
+
return unless import_session
|
|
16
|
+
return unless active_for?(import_session)
|
|
17
|
+
|
|
18
|
+
result = perform_health_check(import_session, entry_id)
|
|
19
|
+
return unless result
|
|
20
|
+
|
|
21
|
+
updated_entry = nil
|
|
22
|
+
|
|
23
|
+
import_session.with_lock do
|
|
24
|
+
import_session.reload
|
|
25
|
+
return unless active_for?(import_session)
|
|
26
|
+
|
|
27
|
+
entries = Array(import_session.parsed_sources).map(&:to_h)
|
|
28
|
+
index = entries.index { |candidate| entry_id_for(candidate) == entry_id.to_s }
|
|
29
|
+
return unless index
|
|
30
|
+
|
|
31
|
+
entries[index] = entries[index].merge(
|
|
32
|
+
"health_status" => result.status,
|
|
33
|
+
"health_error" => result.error_message
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
selected_ids = Array(import_session.selected_source_ids).map(&:to_s)
|
|
37
|
+
selected_ids -= [ entry_id.to_s ] if result.status == "unhealthy"
|
|
38
|
+
|
|
39
|
+
attrs = {
|
|
40
|
+
parsed_sources: entries,
|
|
41
|
+
selected_source_ids: selected_ids,
|
|
42
|
+
health_check_completed_at: completion_time(entries, import_session.health_check_targets)
|
|
43
|
+
}.compact
|
|
44
|
+
|
|
45
|
+
import_session.update!(attrs)
|
|
46
|
+
normalized_entry = SourceMonitor::ImportSessions::EntryNormalizer.normalize(entries[index])
|
|
47
|
+
updated_entry = normalized_entry.merge(selected: selected_ids.include?(entry_id.to_s))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
broadcaster = SourceMonitor::ImportSessions::HealthCheckBroadcaster.new(import_session)
|
|
51
|
+
broadcaster.broadcast_row(updated_entry) if updated_entry
|
|
52
|
+
broadcaster.broadcast_progress
|
|
53
|
+
rescue StandardError => error
|
|
54
|
+
Rails.logger.error(
|
|
55
|
+
"[SourceMonitor::ImportSessionHealthCheckJob] #{error.class}: #{error.message}"
|
|
56
|
+
) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def active_for?(import_session)
|
|
62
|
+
import_session.current_step == "health_check" && import_session.health_checks_active?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def perform_health_check(import_session, entry_id)
|
|
66
|
+
entry = find_entry(import_session, entry_id)
|
|
67
|
+
return unless entry
|
|
68
|
+
|
|
69
|
+
SourceMonitor::Health::ImportSourceHealthCheck.new(feed_url: entry_feed_url(entry)).call
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_entry(import_session, entry_id)
|
|
73
|
+
Array(import_session.parsed_sources).find { |entry| entry_id_for(entry) == entry_id.to_s }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def entry_id_for(entry)
|
|
77
|
+
entry.to_h["id"].presence || entry.to_h[:id].presence || entry.to_h["feed_url"].to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def entry_feed_url(entry)
|
|
81
|
+
entry.to_h["feed_url"] || entry.to_h[:feed_url]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def completion_time(entries, targets)
|
|
85
|
+
normalized = Array(entries).map { |entry| SourceMonitor::ImportSessions::EntryNormalizer.normalize(entry) }
|
|
86
|
+
filtered = normalized.select { |entry| targets.include?(entry[:id]) }
|
|
87
|
+
return nil if filtered.empty?
|
|
88
|
+
|
|
89
|
+
completed = filtered.count { |entry| %w[healthy unhealthy].include?(entry[:health_status].to_s) }
|
|
90
|
+
completed >= filtered.size ? Time.current : nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class ImportHistory < ApplicationRecord
|
|
5
|
+
validates :user_id, presence: true
|
|
6
|
+
|
|
7
|
+
scope :recent_for, lambda { |user_id|
|
|
8
|
+
scope = order(created_at: :desc)
|
|
9
|
+
scope = scope.where(user_id: user_id) if user_id
|
|
10
|
+
scope
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
def imported_count
|
|
14
|
+
Array(imported_sources).size
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def failed_count
|
|
18
|
+
Array(failed_sources).size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def skipped_count
|
|
22
|
+
Array(skipped_duplicates).size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def completed?
|
|
26
|
+
completed_at.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def duration_ms
|
|
30
|
+
return unless started_at && completed_at
|
|
31
|
+
|
|
32
|
+
((completed_at - started_at) * 1000).round
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class ImportSession < ApplicationRecord
|
|
5
|
+
STEP_ORDER = %w[upload preview health_check configure confirm].freeze
|
|
6
|
+
|
|
7
|
+
validates :current_step, inclusion: { in: STEP_ORDER }
|
|
8
|
+
validates :user_id, presence: true
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def default_step
|
|
12
|
+
STEP_ORDER.first
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def health_stream_name
|
|
17
|
+
"source_monitor_import_session_#{id}_health"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def health_check_targets
|
|
21
|
+
Array(health_check_target_ids).map(&:to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def next_step
|
|
25
|
+
index = STEP_ORDER.index(current_step)
|
|
26
|
+
STEP_ORDER[index + 1] if index && index < STEP_ORDER.length - 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def previous_step
|
|
30
|
+
index = STEP_ORDER.index(current_step)
|
|
31
|
+
STEP_ORDER[index - 1] if index&.positive?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div id="import_session_header" class="flex flex-wrap items-center justify-between gap-3">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="text-sm font-semibold uppercase tracking-wide text-slate-500">OPML Import</p>
|
|
4
|
+
<h1 class="text-3xl font-semibold text-slate-900">Import Wizard</h1>
|
|
5
|
+
<p class="mt-1 text-sm text-slate-500">Step <%= current_step.titleize %> · progress is saved to your session.</p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="flex items-center gap-3">
|
|
8
|
+
<%= link_to "Cancel", source_monitor.import_session_path(import_session),
|
|
9
|
+
data: { turbo_method: :delete, action: "confirm-navigation#disable" },
|
|
10
|
+
class: "inline-flex items-center rounded-md border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<nav id="import_session_sidebar" aria-label="Import steps" class="rounded-lg border border-slate-200 bg-white shadow-sm">
|
|
2
|
+
<ol class="divide-y divide-slate-100" role="list">
|
|
3
|
+
<% wizard_steps.each do |step| %>
|
|
4
|
+
<% active = step == current_step %>
|
|
5
|
+
<li>
|
|
6
|
+
<%= link_to source_monitor.step_import_session_path(import_session, step: step),
|
|
7
|
+
class: [
|
|
8
|
+
"flex items-center justify-between px-4 py-3 text-sm font-medium transition-colors",
|
|
9
|
+
active ? "bg-blue-50 text-blue-700" : "text-slate-700 hover:bg-slate-50"
|
|
10
|
+
].join(" "),
|
|
11
|
+
aria: { current: (active ? "page" : nil) } do %>
|
|
12
|
+
<span class="flex items-center gap-2">
|
|
13
|
+
<span class="h-2 w-2 rounded-full <%= active ? 'bg-blue-600' : 'bg-slate-300' %>" aria-hidden="true"></span>
|
|
14
|
+
<span><%= step.titleize %></span>
|
|
15
|
+
</span>
|
|
16
|
+
<% if active %>
|
|
17
|
+
<span class="text-xs font-semibold uppercase text-blue-600">Current</span>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</li>
|
|
21
|
+
<% end %>
|
|
22
|
+
</ol>
|
|
23
|
+
</nav>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<% progress ||= { completed: 0, total: 0, pending: 0, active: false, done: false } %>
|
|
2
|
+
<% progress_id = "import_session_#{import_session.id}_health_progress" %>
|
|
3
|
+
|
|
4
|
+
<div id="<%= progress_id %>" class="inline-flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700">
|
|
5
|
+
<div class="flex items-center gap-2">
|
|
6
|
+
<% if progress[:done] %>
|
|
7
|
+
<span class="inline-block h-2 w-2 rounded-full bg-green-500" aria-hidden="true"></span>
|
|
8
|
+
<span>Health checks complete</span>
|
|
9
|
+
<% elsif progress[:active] %>
|
|
10
|
+
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" aria-hidden="true"></span>
|
|
11
|
+
<span>Running health checks…</span>
|
|
12
|
+
<% else %>
|
|
13
|
+
<span class="inline-block h-2 w-2 rounded-full bg-slate-400" aria-hidden="true"></span>
|
|
14
|
+
<span>Health checks paused</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
18
|
+
<%= progress[:completed] %> / <%= progress[:total] %> completed
|
|
19
|
+
</span>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<% row_id = "import_session_#{import_session.id}_health_row_#{entry[:id]}" %>
|
|
2
|
+
<tr id="<%= row_id %>" class="<%= entry[:health_status] == 'unhealthy' ? 'bg-rose-50/40' : '' %>">
|
|
3
|
+
<td class="px-4 py-3 text-center align-middle">
|
|
4
|
+
<%= check_box_tag "import_session[selected_source_ids][]",
|
|
5
|
+
entry[:id],
|
|
6
|
+
entry[:selected],
|
|
7
|
+
data: { select_all_target: "item", action: "select-all#toggleItem" },
|
|
8
|
+
class: "h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500" %>
|
|
9
|
+
</td>
|
|
10
|
+
<td class="px-4 py-3 align-top">
|
|
11
|
+
<div class="max-w-xl break-words font-medium text-slate-900"><%= entry[:feed_url] %></div>
|
|
12
|
+
<% if entry[:website_url].present? %>
|
|
13
|
+
<div class="mt-1 text-xs text-blue-700 truncate"><%= entry[:website_url] %></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</td>
|
|
16
|
+
<td class="px-4 py-3 align-top">
|
|
17
|
+
<div class="text-sm font-medium text-slate-900"><%= entry[:title].presence || "(No title)" %></div>
|
|
18
|
+
</td>
|
|
19
|
+
<td class="px-4 py-3 align-top">
|
|
20
|
+
<% status = entry[:health_status].presence || "pending" %>
|
|
21
|
+
<% case status %>
|
|
22
|
+
<% when "healthy" %>
|
|
23
|
+
<span class="inline-flex items-center gap-2 rounded-full bg-green-100 px-2 py-1 text-xs font-semibold text-green-800">
|
|
24
|
+
<span class="inline-block h-2 w-2 rounded-full bg-green-500" aria-hidden="true"></span>
|
|
25
|
+
Healthy
|
|
26
|
+
</span>
|
|
27
|
+
<% when "unhealthy" %>
|
|
28
|
+
<div class="space-y-1">
|
|
29
|
+
<span class="inline-flex items-center gap-2 rounded-full bg-rose-100 px-2 py-1 text-xs font-semibold text-rose-800">
|
|
30
|
+
<span class="inline-block h-2 w-2 rounded-full bg-rose-500" aria-hidden="true"></span>
|
|
31
|
+
Unhealthy
|
|
32
|
+
</span>
|
|
33
|
+
<% if entry[:health_error].present? %>
|
|
34
|
+
<div class="text-xs text-rose-700">Reason: <%= entry[:health_error] %></div>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
<% else %>
|
|
38
|
+
<span class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-2 py-1 text-xs font-semibold text-blue-800">
|
|
39
|
+
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" aria-hidden="true"></span>
|
|
40
|
+
Checking…
|
|
41
|
+
</span>
|
|
42
|
+
<% end %>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% content_for :title, "Import OPML" %>
|
|
2
|
+
|
|
3
|
+
<div class="space-y-6" data-controller="confirm-navigation" data-confirm-navigation-message-value="You have an in-progress import. Are you sure you want to leave this wizard?">
|
|
4
|
+
<%= render "source_monitor/import_sessions/header", import_session: @import_session, current_step: @current_step %>
|
|
5
|
+
|
|
6
|
+
<div class="grid gap-6 lg:grid-cols-4">
|
|
7
|
+
<%= render "source_monitor/import_sessions/sidebar", import_session: @import_session, current_step: @current_step, wizard_steps: @wizard_steps %>
|
|
8
|
+
|
|
9
|
+
<div class="lg:col-span-3">
|
|
10
|
+
<%= turbo_frame_tag "import_session_step" do %>
|
|
11
|
+
<%= render "source_monitor/import_sessions/steps/#{@current_step}", import_session: @import_session %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= turbo_stream.redirect_to source_monitor.step_import_session_path(@import_session, step: @current_step) %>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% bulk_source = local_assigns[:bulk_source] || @bulk_source %>
|
|
2
|
+
<% selected_count = Array(import_session.selected_source_ids).size %>
|
|
3
|
+
|
|
4
|
+
<div class="space-y-6">
|
|
5
|
+
<div class="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
|
6
|
+
<h2 class="text-lg font-semibold text-slate-900">Configure settings</h2>
|
|
7
|
+
<p class="mt-1 text-sm text-slate-600">
|
|
8
|
+
Apply the following settings to the <%= selected_count %> feeds you're importing.
|
|
9
|
+
</p>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if bulk_source&.errors&.any? %>
|
|
13
|
+
<div class="rounded border border-red-300 bg-red-50 p-4">
|
|
14
|
+
<h3 class="font-medium text-red-700">Please fix the following:</h3>
|
|
15
|
+
<ul class="mt-2 list-disc space-y-1 pl-5 text-red-700">
|
|
16
|
+
<% bulk_source.errors.full_messages.each do |message| %>
|
|
17
|
+
<li><%= message %></li>
|
|
18
|
+
<% end %>
|
|
19
|
+
</ul>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
|
|
23
|
+
<div class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
|
24
|
+
<%= form_with model: bulk_source,
|
|
25
|
+
url: source_monitor.step_import_session_path(import_session, step: "configure"),
|
|
26
|
+
method: :patch,
|
|
27
|
+
data: { turbo: false, action: "confirm-navigation#disable" },
|
|
28
|
+
html: { class: "space-y-6" } do |form| %>
|
|
29
|
+
<%# Hide identity fields for bulk apply; we already capture per-feed details from OPML %>
|
|
30
|
+
<%= render "source_monitor/sources/form_fields", form: form, include_identity_fields: false %>
|
|
31
|
+
|
|
32
|
+
<div class="flex items-center justify-between gap-3">
|
|
33
|
+
<div>
|
|
34
|
+
<%= button_tag "Back",
|
|
35
|
+
type: :submit,
|
|
36
|
+
name: "import_session[next_step]",
|
|
37
|
+
value: "health_check",
|
|
38
|
+
class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="flex items-center gap-3">
|
|
41
|
+
<%= link_to "Cancel", source_monitor.import_session_path(import_session),
|
|
42
|
+
data: { turbo_method: :delete, action: "confirm-navigation#disable" },
|
|
43
|
+
class: "inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" %>
|
|
44
|
+
<%= button_tag "Continue",
|
|
45
|
+
type: :submit,
|
|
46
|
+
name: "import_session[next_step]",
|
|
47
|
+
value: "confirm",
|
|
48
|
+
class: "inline-flex items-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500" %>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|