source_monitor 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/rails-audit.md +77 -0
  3. data/CHANGELOG.md +50 -0
  4. data/CLAUDE.md +2 -2
  5. data/Gemfile.lock +7 -20
  6. data/RAILS_AUDIT.md +424 -0
  7. data/VERSION +1 -1
  8. data/app/assets/builds/source_monitor/application.css +4 -24
  9. data/app/assets/builds/source_monitor/application.js +57 -89
  10. data/app/assets/builds/source_monitor/application.js.map +4 -4
  11. data/app/assets/javascripts/source_monitor/application.js +3 -6
  12. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +6 -86
  13. data/app/assets/javascripts/source_monitor/controllers/filter_submit_controller.js +13 -0
  14. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  15. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +3 -13
  16. data/app/components/source_monitor/application_component.rb +10 -0
  17. data/app/components/source_monitor/filter_dropdown_component.rb +62 -0
  18. data/app/components/source_monitor/icon_component.rb +140 -0
  19. data/app/components/source_monitor/status_badge_component.html.erb +8 -0
  20. data/app/components/source_monitor/status_badge_component.rb +96 -0
  21. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +4 -0
  22. data/app/controllers/concerns/source_monitor/set_source.rb +13 -0
  23. data/app/controllers/source_monitor/application_controller.rb +17 -0
  24. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +6 -10
  25. data/app/controllers/source_monitor/dashboard_controller.rb +5 -1
  26. data/app/controllers/source_monitor/import_history_dismissals_controller.rb +1 -1
  27. data/app/controllers/source_monitor/import_sessions_controller.rb +30 -9
  28. data/app/controllers/source_monitor/item_scrapes_controller.rb +70 -0
  29. data/app/controllers/source_monitor/items_controller.rb +2 -69
  30. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +1 -4
  31. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +2 -12
  32. data/app/controllers/source_monitor/source_fetches_controller.rb +1 -6
  33. data/app/controllers/source_monitor/source_health_checks_controller.rb +9 -16
  34. data/app/controllers/source_monitor/source_health_resets_controller.rb +1 -6
  35. data/app/controllers/source_monitor/source_retries_controller.rb +1 -6
  36. data/app/controllers/source_monitor/source_scrape_tests_controller.rb +2 -4
  37. data/app/controllers/source_monitor/source_turbo_responses.rb +1 -3
  38. data/app/controllers/source_monitor/sources_controller.rb +15 -20
  39. data/app/helpers/source_monitor/application_helper.rb +15 -31
  40. data/app/helpers/source_monitor/health_badge_helper.rb +8 -0
  41. data/app/jobs/source_monitor/download_content_images_job.rb +1 -59
  42. data/app/jobs/source_monitor/favicon_fetch_job.rb +1 -58
  43. data/app/jobs/source_monitor/fetch_feed_job.rb +2 -52
  44. data/app/jobs/source_monitor/import_opml_job.rb +6 -145
  45. data/app/jobs/source_monitor/import_session_health_check_job.rb +15 -76
  46. data/app/jobs/source_monitor/item_cleanup_job.rb +5 -0
  47. data/app/jobs/source_monitor/log_cleanup_job.rb +13 -2
  48. data/app/jobs/source_monitor/schedule_fetches_job.rb +8 -0
  49. data/app/jobs/source_monitor/scrape_item_job.rb +6 -52
  50. data/app/jobs/source_monitor/source_health_check_job.rb +1 -72
  51. data/app/models/concerns/source_monitor/loggable.rb +12 -0
  52. data/app/models/source_monitor/fetch_log.rb +0 -8
  53. data/app/models/source_monitor/health_check_log.rb +0 -8
  54. data/app/models/source_monitor/import_history.rb +14 -0
  55. data/app/models/source_monitor/import_session.rb +2 -0
  56. data/app/models/source_monitor/item.rb +15 -0
  57. data/app/models/source_monitor/item_content.rb +4 -3
  58. data/app/models/source_monitor/scrape_log.rb +4 -6
  59. data/app/models/source_monitor/source.rb +28 -19
  60. data/app/presenters/source_monitor/base_presenter.rb +19 -0
  61. data/app/presenters/source_monitor/source_details_presenter.rb +61 -0
  62. data/app/presenters/source_monitor/sources_filter_presenter.rb +61 -0
  63. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +3 -3
  64. data/app/views/source_monitor/dashboard/_stat_card.html.erb +2 -1
  65. data/app/views/source_monitor/dashboard/_stats.html.erb +5 -7
  66. data/app/views/source_monitor/items/_details.html.erb +11 -14
  67. data/app/views/source_monitor/items/index.html.erb +10 -35
  68. data/app/views/source_monitor/logs/index.html.erb +20 -41
  69. data/app/views/source_monitor/shared/_form_errors.html.erb +14 -0
  70. data/app/views/source_monitor/source_scrape_tests/_result.html.erb +1 -29
  71. data/app/views/source_monitor/source_scrape_tests/_result_content.html.erb +33 -0
  72. data/app/views/source_monitor/source_scrape_tests/show.html.erb +1 -29
  73. data/app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erb +2 -2
  74. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +7 -5
  75. data/app/views/source_monitor/sources/_details.html.erb +24 -52
  76. data/app/views/source_monitor/sources/_health_status_badge.html.erb +4 -6
  77. data/app/views/source_monitor/sources/_row.html.erb +7 -18
  78. data/app/views/source_monitor/sources/edit.html.erb +1 -10
  79. data/app/views/source_monitor/sources/index.html.erb +26 -46
  80. data/app/views/source_monitor/sources/new.html.erb +1 -10
  81. data/config/routes.rb +1 -1
  82. data/db/migrate/20260313120000_add_composite_indexes_to_log_tables.rb +14 -0
  83. data/db/migrate/20260314120000_align_health_status_default.rb +11 -0
  84. data/lib/source_monitor/analytics/sources_index_metrics.rb +15 -0
  85. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +10 -4
  86. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +21 -5
  87. data/lib/source_monitor/favicons/fetcher.rb +86 -0
  88. data/lib/source_monitor/fetching/cloudflare_bypass.rb +14 -5
  89. data/lib/source_monitor/fetching/completion/event_publisher.rb +12 -0
  90. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +15 -2
  91. data/lib/source_monitor/fetching/completion/retention_handler.rb +11 -3
  92. data/lib/source_monitor/fetching/feed_fetcher.rb +2 -21
  93. data/lib/source_monitor/fetching/fetch_runner.rb +12 -3
  94. data/lib/source_monitor/fetching/retry_orchestrator.rb +102 -0
  95. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +9 -0
  96. data/lib/source_monitor/health/source_health_check_orchestrator.rb +95 -0
  97. data/lib/source_monitor/health.rb +1 -0
  98. data/lib/source_monitor/images/downloader.rb +6 -7
  99. data/lib/source_monitor/images/processor.rb +98 -0
  100. data/lib/source_monitor/import_sessions/health_check_updater.rb +95 -0
  101. data/lib/source_monitor/import_sessions/opml_importer.rb +163 -0
  102. data/lib/source_monitor/items/item_creator.rb +0 -21
  103. data/lib/source_monitor/logs/query.rb +20 -0
  104. data/lib/source_monitor/queries/scrape_candidates_query.rb +30 -0
  105. data/lib/source_monitor/queries.rb +7 -0
  106. data/lib/source_monitor/scheduler.rb +5 -0
  107. data/lib/source_monitor/scraping/bulk_result_presenter.rb +11 -8
  108. data/lib/source_monitor/scraping/runner.rb +52 -0
  109. data/lib/source_monitor/scraping/scheduler.rb +5 -0
  110. data/lib/source_monitor/scraping/state.rb +4 -2
  111. data/lib/source_monitor/security/parameter_sanitizer.rb +7 -0
  112. data/lib/source_monitor/version.rb +1 -1
  113. data/lib/source_monitor.rb +7 -0
  114. data/source_monitor.gemspec +1 -0
  115. metadata +47 -1
@@ -13,6 +13,18 @@ module SourceMonitor
13
13
  scope :recent, -> { order(started_at: :desc) }
14
14
  scope :successful, -> { where(success: true) }
15
15
  scope :failed, -> { where(success: false) }
16
+ scope :since, ->(date) { where(arel_table[:started_at].gteq(date)) }
17
+ scope :before, ->(date) { where(arel_table[:started_at].lteq(date)) }
18
+ scope :today, -> { since(Time.current.beginning_of_day) }
19
+ scope :by_date_range, ->(start_date, end_date) { since(start_date).before(end_date) }
20
+
21
+ after_save :sync_log_entry
22
+ end
23
+
24
+ private
25
+
26
+ def sync_log_entry
27
+ SourceMonitor::Logs::EntrySync.call(self)
16
28
  end
17
29
  end
18
30
  end
@@ -23,13 +23,5 @@ module SourceMonitor
23
23
  scope :by_category, ->(category) { where(error_category: category) }
24
24
 
25
25
  SourceMonitor::ModelExtensions.register(self, :fetch_log)
26
-
27
- after_save :sync_log_entry
28
-
29
- private
30
-
31
- def sync_log_entry
32
- SourceMonitor::Logs::EntrySync.call(self)
33
- end
34
26
  end
35
27
  end
@@ -16,13 +16,5 @@ module SourceMonitor
16
16
  validates :source, presence: true
17
17
 
18
18
  SourceMonitor::ModelExtensions.register(self, :health_check_log)
19
-
20
- after_save :sync_log_entry
21
-
22
- private
23
-
24
- def sync_log_entry
25
- SourceMonitor::Logs::EntrySync.call(self)
26
- end
27
19
  end
28
20
  end
@@ -2,7 +2,13 @@
2
2
 
3
3
  module SourceMonitor
4
4
  class ImportHistory < ApplicationRecord
5
+ attribute :imported_sources, default: -> { [] }
6
+ attribute :failed_sources, default: -> { [] }
7
+ attribute :skipped_duplicates, default: -> { [] }
8
+ attribute :bulk_settings, default: -> { {} }
9
+
5
10
  validates :user_id, presence: true
11
+ validate :completed_at_after_started_at
6
12
 
7
13
  scope :not_dismissed, -> { where(dismissed_at: nil) }
8
14
 
@@ -33,5 +39,13 @@ module SourceMonitor
33
39
 
34
40
  ((completed_at - started_at) * 1000).round
35
41
  end
42
+
43
+ private
44
+
45
+ def completed_at_after_started_at
46
+ return if completed_at.blank? || started_at.blank?
47
+
48
+ errors.add(:completed_at, "must be after started_at") if completed_at < started_at
49
+ end
36
50
  end
37
51
  end
@@ -4,6 +4,8 @@ module SourceMonitor
4
4
  class ImportSession < ApplicationRecord
5
5
  STEP_ORDER = %w[upload preview health_check configure confirm].freeze
6
6
 
7
+ scope :in_step, ->(step) { where(current_step: step) }
8
+
7
9
  validates :current_step, inclusion: { in: STEP_ORDER }
8
10
  validates :user_id, presence: true
9
11
 
@@ -24,6 +24,8 @@ module SourceMonitor
24
24
  validates :content_fingerprint, uniqueness: { scope: :source_id }, allow_blank: true
25
25
  validates :url, presence: true
26
26
 
27
+ after_create_commit :ensure_feed_content_record, if: -> { content.present? }
28
+
27
29
  scope :recent, -> { active.order(Arel.sql("published_at DESC NULLS LAST, created_at DESC")) }
28
30
  scope :published, -> { active.where.not(published_at: nil) }
29
31
  scope :pending_scrape, -> { active.where(scraped_at: nil) }
@@ -80,6 +82,19 @@ module SourceMonitor
80
82
  end
81
83
  end
82
84
 
85
+ def restore!
86
+ return unless deleted?
87
+
88
+ self.class.transaction do
89
+ update_columns(
90
+ deleted_at: nil,
91
+ updated_at: Time.current
92
+ )
93
+
94
+ SourceMonitor::Source.increment_counter(:items_count, source_id) if source_id
95
+ end
96
+ end
97
+
83
98
  private
84
99
 
85
100
  # Item content lives in a separate row that we only create when rich content exists.
@@ -8,6 +8,8 @@ module SourceMonitor
8
8
 
9
9
  has_many_attached :images if defined?(ActiveStorage)
10
10
 
11
+ delegate :content, to: :item, prefix: :feed, allow_nil: true
12
+
11
13
  before_save :compute_word_counts
12
14
 
13
15
  SourceMonitor::ModelExtensions.register(self, :item_content)
@@ -30,11 +32,10 @@ module SourceMonitor
30
32
  end
31
33
 
32
34
  def compute_feed_word_count
33
- content = item&.content
34
- if content.blank?
35
+ if feed_content.blank?
35
36
  self.feed_word_count = nil
36
37
  else
37
- stripped = ActionView::Base.full_sanitizer.sanitize(content)
38
+ stripped = ActionView::Base.full_sanitizer.sanitize(feed_content)
38
39
  self.feed_word_count = stripped.present? ? stripped.split.size : nil
39
40
  end
40
41
  end
@@ -8,14 +8,16 @@ module SourceMonitor
8
8
  belongs_to :source, class_name: "SourceMonitor::Source", inverse_of: :scrape_logs
9
9
  has_one :log_entry, as: :loggable, class_name: "SourceMonitor::LogEntry", inverse_of: :loggable, dependent: :destroy
10
10
 
11
+ scope :by_source, ->(source) { where(source: source) }
12
+ scope :by_status, ->(success) { where(success: success) }
13
+ scope :by_item, ->(item) { where(item: item) }
14
+
11
15
  validates :item, :source, presence: true
12
16
  validates :content_length, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
13
17
  validate :source_matches_item
14
18
 
15
19
  SourceMonitor::ModelExtensions.register(self, :scrape_log)
16
20
 
17
- after_save :sync_log_entry
18
-
19
21
  private
20
22
 
21
23
  def source_matches_item
@@ -23,9 +25,5 @@ module SourceMonitor
23
25
 
24
26
  errors.add(:source, "must match item source") if item.source_id != source_id
25
27
  end
26
-
27
- def sync_log_entry
28
- SourceMonitor::Logs::EntrySync.call(self)
29
- end
30
28
  end
31
29
  end
@@ -11,6 +11,7 @@ module SourceMonitor
11
11
  has_one_attached :favicon if defined?(ActiveStorage)
12
12
 
13
13
  FETCH_STATUS_VALUES = %w[idle queued fetching failed].freeze
14
+ HEALTH_STATUS_VALUES = %w[working declining improving failing].freeze
14
15
 
15
16
  has_many :all_items, class_name: "SourceMonitor::Item", inverse_of: :source, dependent: :destroy
16
17
  has_many :items, -> { active }, class_name: "SourceMonitor::Item", inverse_of: :source
@@ -28,6 +29,8 @@ module SourceMonitor
28
29
  where(failure.or(error_present).or(error_time_present))
29
30
  }
30
31
  scope :healthy, -> { active.where(failure_count: 0, last_error: nil, last_error_at: nil) }
32
+ scope :scraping_enabled, -> { where(scraping_enabled: true) }
33
+ scope :scraping_disabled, -> { where(scraping_enabled: false) }
31
34
 
32
35
  # Use Rails attribute API for default values instead of after_initialize callbacks
33
36
  attribute :scrape_settings, default: -> { {} }
@@ -48,6 +51,7 @@ module SourceMonitor
48
51
  validates :items_retention_days, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 }
49
52
  validates :max_items, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 }
50
53
  validates :fetch_status, inclusion: { in: FETCH_STATUS_VALUES }
54
+ validates :health_status, inclusion: { in: HEALTH_STATUS_VALUES }
51
55
  validates :fetch_retry_attempt, numericality: { greater_than_or_equal_to: 0, only_integer: true }
52
56
 
53
57
  validate :health_auto_pause_threshold_within_bounds
@@ -62,21 +66,20 @@ module SourceMonitor
62
66
  end
63
67
 
64
68
  def scrape_candidates(threshold: SourceMonitor.config.scraping.scrape_recommendation_threshold)
65
- threshold_value = threshold.to_i
66
- return none if threshold_value <= 0
67
-
68
- active
69
- .where(scraping_enabled: false)
70
- .where(
71
- "#{table_name}.id IN (
72
- SELECT i.source_id
73
- FROM #{Item.table_name} i
74
- INNER JOIN #{ItemContent.table_name} ic ON ic.item_id = i.id
75
- WHERE ic.feed_word_count IS NOT NULL
76
- GROUP BY i.source_id
77
- HAVING AVG(ic.feed_word_count) < ?
78
- )", threshold_value
79
- )
69
+ SourceMonitor::Queries::ScrapeCandidatesQuery.new(threshold:).call
70
+ end
71
+
72
+ # Bulk-enable scraping for sources that don't already have it enabled.
73
+ # Returns the number of records updated.
74
+ def enable_scraping!(ids)
75
+ default_adapter = column_defaults["scraper_adapter"] || "readability"
76
+
77
+ where(id: ids, scraping_enabled: false).update_all(
78
+ scraping_enabled: true,
79
+ auto_scrape: true,
80
+ scraper_adapter: default_adapter,
81
+ updated_at: Time.current
82
+ )
80
83
  end
81
84
 
82
85
  def ransackable_attributes(_auth_object = nil)
@@ -145,14 +148,20 @@ module SourceMonitor
145
148
 
146
149
  def reset_items_counter!
147
150
  # Recalculate items_count from actual active (non-deleted) items
148
- actual_count = items.count
149
- update_columns(items_count: actual_count)
151
+ # Use reset_counters which is the Rails-native way to fix counter caches
152
+ self.class.reset_counters(id, :items)
153
+ reload
154
+ end
155
+
156
+ def clear_favicon_cooldown!
157
+ metadata_without_cooldown = (metadata || {}).except("favicon_last_attempted_at")
158
+ update_column(:metadata, metadata_without_cooldown)
150
159
  end
151
160
 
152
161
  def avg_word_count
153
162
  items.joins(:item_content)
154
- .where.not(sourcemon_item_contents: { scraped_word_count: nil })
155
- .average("sourcemon_item_contents.scraped_word_count")
163
+ .where.not(ItemContent.table_name => { scraped_word_count: nil })
164
+ .average("#{ItemContent.table_name}.scraped_word_count")
156
165
  &.round
157
166
  end
158
167
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class BasePresenter < SimpleDelegator
5
+ include ActionView::Helpers::NumberHelper
6
+ include ActionView::Helpers::DateHelper
7
+ include ActionView::Helpers::TextHelper
8
+
9
+ def initialize(model)
10
+ super(model)
11
+ end
12
+
13
+ def model
14
+ __getobj__
15
+ end
16
+
17
+ alias_method :object, :model
18
+ end
19
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceDetailsPresenter < BasePresenter
5
+ DATE_FORMAT = "%b %d, %Y %H:%M %Z"
6
+
7
+ def fetch_interval_display
8
+ hours = number_with_precision(fetch_interval_minutes / 60.0, precision: 2)
9
+ "#{fetch_interval_minutes} minutes (~#{hours} hours)"
10
+ end
11
+
12
+ def circuit_state_label
13
+ if fetch_circuit_open?
14
+ until_time = fetch_circuit_until&.strftime(DATE_FORMAT) || "unknown"
15
+ "Open until #{until_time}"
16
+ else
17
+ "Closed"
18
+ end
19
+ end
20
+
21
+ def adaptive_interval_label
22
+ adaptive_fetching_enabled? ? "Auto" : "Fixed"
23
+ end
24
+
25
+ def formatted_next_fetch_at
26
+ format_timestamp(next_fetch_at)
27
+ end
28
+
29
+ def formatted_last_fetched_at
30
+ format_timestamp(last_fetched_at)
31
+ end
32
+
33
+ def details_hash
34
+ {
35
+ "Fetch interval" => fetch_interval_display,
36
+ "Adaptive interval" => adaptive_interval_label,
37
+ "Scraper" => scraper_adapter,
38
+ "Feed content" => feed_content_readability_enabled? ? "Readability" : "Raw",
39
+ "Active" => active? ? "Yes" : "No",
40
+ "Scraping" => scraping_enabled? ? "Enabled" : "Disabled",
41
+ "Auto scrape" => auto_scrape? ? "Enabled" : "Disabled",
42
+ "Requires JS" => requires_javascript? ? "Yes" : "No",
43
+ "Failure count" => failure_count,
44
+ "Retry attempt" => fetch_retry_attempt,
45
+ "Circuit state" => circuit_state_label,
46
+ "Last error" => last_error.presence || "None",
47
+ "Items count" => items_count,
48
+ "Retention days" => items_retention_days || "\u2014",
49
+ "Max items" => max_items || "\u2014"
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def format_timestamp(time)
56
+ return "\u2014" if time.nil?
57
+
58
+ time.strftime(DATE_FORMAT)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ # Encapsulates filter state logic for the sources index view.
5
+ # Extracts filter detection, label generation, and active-filter
6
+ # tracking from the template into a testable object.
7
+ class SourcesFilterPresenter
8
+ DROPDOWN_FILTER_KEYS = %w[
9
+ active_eq
10
+ health_status_eq
11
+ feed_format_eq
12
+ scraper_adapter_eq
13
+ scraping_enabled_eq
14
+ avg_feed_words_lt
15
+ ].freeze
16
+
17
+ attr_reader :search_params, :search_term, :fetch_interval_filter, :adapter_options
18
+
19
+ # @param search_params [Hash] sanitized Ransack search params
20
+ # @param search_term [String] current text search term
21
+ # @param fetch_interval_filter [Hash, nil] active fetch interval filter
22
+ # @param adapter_options [Array<String>] distinct scraper adapter values
23
+ def initialize(search_params:, search_term:, fetch_interval_filter:, adapter_options: [])
24
+ @search_params = search_params || {}
25
+ @search_term = search_term.to_s
26
+ @fetch_interval_filter = fetch_interval_filter
27
+ @adapter_options = adapter_options
28
+ end
29
+
30
+ def has_any_filter?
31
+ @search_term.present? || @fetch_interval_filter.present? || active_filter_keys.any?
32
+ end
33
+
34
+ # Returns the subset of DROPDOWN_FILTER_KEYS that have present values
35
+ def active_filter_keys
36
+ DROPDOWN_FILTER_KEYS.select { |k| @search_params[k].present? }
37
+ end
38
+
39
+ # Returns a hash mapping active filter keys to human-readable labels
40
+ def filter_labels
41
+ {
42
+ "active_eq" => status_label,
43
+ "health_status_eq" => "Health: #{@search_params['health_status_eq']&.titleize}",
44
+ "feed_format_eq" => "Format: #{@search_params['feed_format_eq']&.upcase}",
45
+ "scraper_adapter_eq" => "Adapter: #{@search_params['scraper_adapter_eq']&.titleize}",
46
+ "scraping_enabled_eq" => scraping_label,
47
+ "avg_feed_words_lt" => "Avg Feed Words: < #{@search_params['avg_feed_words_lt']}"
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def status_label
54
+ @search_params["active_eq"] == "true" ? "Status: Active" : "Status: Paused"
55
+ end
56
+
57
+ def scraping_label
58
+ @search_params["scraping_enabled_eq"] == "true" ? "Scraping: Enabled" : "Scraping: Disabled"
59
+ end
60
+ end
61
+ end
@@ -32,9 +32,9 @@
32
32
  <% end %>
33
33
  </div>
34
34
  <div class="text-right">
35
- <span class="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold <%= event[:status] == :success ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>">
36
- <%= event[:status] == :success ? "Success" : "Failure" %>
37
- </span>
35
+ <%= render SourceMonitor::StatusBadgeComponent.new(
36
+ status: event[:status] == :success ? "success" : "failed",
37
+ label: event[:status] == :success ? "Success" : "Failure") %>
38
38
  <div class="mt-1 text-xs text-slate-400">
39
39
  <%= event[:time]&.strftime("%b %d, %H:%M") || "Unknown" %>
40
40
  </div>
@@ -1,5 +1,6 @@
1
1
  <% value = (stat_card[:value] || 0) %>
2
- <div class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
2
+ <% card_id = stat_card[:key] ? "source_monitor_stat_#{stat_card[:key]}" : nil %>
3
+ <div id="<%= card_id %>" class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
3
4
  <dt class="text-xs font-medium uppercase tracking-wide text-slate-500"><%= stat_card[:label] %></dt>
4
5
  <dd class="mt-2 text-3xl font-semibold text-slate-900"><%= value %></dd>
5
6
  <p class="mt-1 text-xs text-slate-500"><%= stat_card[:caption] %></p>
@@ -1,12 +1,10 @@
1
1
  <div id="source_monitor_dashboard_stats">
2
2
  <div class="grid gap-5 sm:grid-cols-2 xl:grid-cols-5">
3
- <%= render partial: "stat_card", collection: [
4
- { label: "Sources", value: stats[:total_sources], caption: "Total registered" },
5
- { label: "Active", value: stats[:active_sources], caption: "Fetching on schedule" },
6
- { label: "Failures", value: stats[:failed_sources], caption: "Require attention" },
7
- { label: "Items", value: stats[:total_items], caption: "Stored entries" },
8
- { label: "Fetches Today", value: stats[:fetches_today], caption: "Completed runs" }
9
- ], locals: { stats: stats } %>
3
+ <%= render partial: "stat_card", locals: { stat_card: { key: "total_sources", label: "Sources", value: stats[:total_sources], caption: "Total registered" } } %>
4
+ <%= render partial: "stat_card", locals: { stat_card: { key: "active_sources", label: "Active", value: stats[:active_sources], caption: "Fetching on schedule" } } %>
5
+ <%= render partial: "stat_card", locals: { stat_card: { key: "failed_sources", label: "Failures", value: stats[:failed_sources], caption: "Require attention" } } %>
6
+ <%= render partial: "stat_card", locals: { stat_card: { key: "total_items", label: "Items", value: stats[:total_items], caption: "Stored entries" } } %>
7
+ <%= render partial: "stat_card", locals: { stat_card: { key: "fetches_today", label: "Fetches Today", value: stats[:fetches_today], caption: "Completed runs" } } %>
10
8
  </div>
11
9
 
12
10
  <% if stats[:health_distribution]&.values&.any? { |v| v > 0 } %>
@@ -3,22 +3,18 @@
3
3
  <% manual_scrape_disabled = !source&.scraping_enabled? %>
4
4
  <% scrape_badge = item_scrape_status_badge(item: item, source: source) %>
5
5
  <% inflight_scrape = %w[pending processing].include?(scrape_badge[:status]) %>
6
- <% recent_scrape_logs = item.scrape_logs.order(started_at: :desc).limit(5) %>
7
- <% latest_scrape_log = recent_scrape_logs.first %>
6
+ <% recent_scrape_logs = local_assigns[:recent_scrape_logs] || @recent_scrape_logs || [] %>
7
+ <% latest_scrape_log = local_assigns[:latest_scrape_log] || @latest_scrape_log || recent_scrape_logs.first %>
8
8
 
9
9
  <div class="space-y-8">
10
10
  <div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
11
11
  <div>
12
12
  <h1 class="text-3xl font-semibold text-slate-900"><%= item.title.presence || "(untitled)" %></h1>
13
13
  <div class="mt-2 flex flex-wrap items-center gap-2 text-xs">
14
- <span
15
- data-testid="scrape-status-badge"
16
- data-status="<%= scrape_badge[:status] %>"
17
- class="inline-flex items-center gap-1 rounded-full px-3 py-1 font-semibold <%= scrape_badge[:classes] %>"
18
- >
19
- <%= loading_spinner_svg if scrape_badge[:show_spinner] %>
20
- <%= scrape_badge[:label] %>
21
- </span>
14
+ <%= render SourceMonitor::StatusBadgeComponent.new(
15
+ status: scrape_badge[:status],
16
+ label: scrape_badge[:label],
17
+ data: { testid: "scrape-status-badge" }) %>
22
18
  <% if item.scraped_at.present? %>
23
19
  <span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-slate-600">
24
20
  Last scraped <%= time_ago_in_words(item.scraped_at) %> ago
@@ -110,9 +106,10 @@
110
106
  <% recent_scrape_logs.first(3).each do |log| %>
111
107
  <li class="flex items-center justify-between">
112
108
  <span><%= log.started_at&.strftime("%b %d, %H:%M") || "Unknown" %></span>
113
- <span class="ml-3 inline-flex items-center rounded-full px-2 py-0.5 font-semibold <%= log.success? ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700" %>">
114
- <%= log.success? ? "Success" : "Failure" %>
115
- </span>
109
+ <%= render SourceMonitor::StatusBadgeComponent.new(
110
+ status: log.success? ? "success" : "failed",
111
+ label: log.success? ? "Success" : "Failure",
112
+ size: :sm) %>
116
113
  </li>
117
114
  <% end %>
118
115
  </ul>
@@ -162,7 +159,7 @@
162
159
  <%= link_to "View Original", item.url, target: "_blank", rel: "noopener", class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white shadow hover:bg-blue-500" %>
163
160
  <% end %>
164
161
  <%= button_to "Manual Scrape",
165
- source_monitor.scrape_item_path(item),
162
+ source_monitor.item_scrape_path(item),
166
163
  method: :post,
167
164
  class: [
168
165
  "inline-flex items-center rounded-md px-3 py-2 text-sm font-medium shadow",
@@ -100,18 +100,11 @@
100
100
  <% end %>
101
101
  </td>
102
102
  <td class="px-6 py-4 text-xs">
103
- <% status_label, status_classes =
104
- case item.scrape_status
105
- when "success"
106
- ["Scraped", "bg-green-100 text-green-700"]
107
- when "failed"
108
- ["Failed", "bg-rose-100 text-rose-700"]
109
- when "pending"
110
- ["Pending", "bg-amber-100 text-amber-700"]
111
- else
112
- ["Not Scraped", "bg-slate-100 text-slate-600"]
113
- end %>
114
- <span class="inline-flex items-center rounded-full px-3 py-1 font-semibold <%= status_classes %>"><%= status_label %></span>
103
+ <% badge = item_scrape_status_badge(item: item, source: @source, show_spinner: false) %>
104
+ <%= render SourceMonitor::StatusBadgeComponent.new(
105
+ status: badge[:status],
106
+ label: badge[:label],
107
+ show_spinner: false) %>
115
108
  </td>
116
109
  <td class="px-6 py-4 text-xs text-slate-500">
117
110
  <%= item.item_content&.feed_word_count || "\u2014" %>
@@ -131,29 +124,11 @@
131
124
  <% end %>
132
125
  </tbody>
133
126
  </table>
134
- <div class="flex flex-col items-center gap-3 border-t border-slate-200 px-6 py-4 sm:flex-row sm:justify-between">
135
- <div class="text-xs text-slate-500">
136
- Page <%= @page %>
137
- </div>
138
- <div class="flex gap-2">
139
- <% prev_params = { page: @page - 1 } %>
140
- <% prev_params[:q] = @search_params if @search_params.present? %>
141
- <% next_params = { page: @page + 1 } %>
142
- <% next_params[:q] = @search_params if @search_params.present? %>
143
-
144
- <% if @has_previous_page %>
145
- <%= link_to "Previous", source_monitor.items_path(prev_params), class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50", data: { turbo_frame: "source_monitor_items_table" } %>
146
- <% else %>
147
- <span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Previous</span>
148
- <% end %>
149
-
150
- <% if @has_next_page %>
151
- <%= link_to "Next", source_monitor.items_path(next_params), class: "inline-flex items-center rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50", data: { turbo_frame: "source_monitor_items_table" } %>
152
- <% else %>
153
- <span class="inline-flex items-center rounded-md border border-slate-200 px-3 py-2 text-sm font-medium text-slate-300">Next</span>
154
- <% end %>
155
- </div>
156
- </div>
127
+ <%= render "source_monitor/shared/pagination",
128
+ paginator_result: @paginator,
129
+ base_path: source_monitor.items_path,
130
+ extra_params: @search_params.present? ? { q: @search_params } : {},
131
+ turbo_frame: "source_monitor_items_table" %>
157
132
  <% end %>
158
133
  </div>
159
134
  </div>