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
@@ -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
- permitted = params.require(:source).permit(
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>