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.
- checksums.yaml +4 -4
- data/.claude/commands/rails-audit.md +77 -0
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +2 -2
- data/Gemfile.lock +7 -20
- data/RAILS_AUDIT.md +424 -0
- data/VERSION +1 -1
- data/app/assets/builds/source_monitor/application.css +4 -24
- data/app/assets/builds/source_monitor/application.js +57 -89
- data/app/assets/builds/source_monitor/application.js.map +4 -4
- data/app/assets/javascripts/source_monitor/application.js +3 -6
- data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +6 -86
- data/app/assets/javascripts/source_monitor/controllers/filter_submit_controller.js +13 -0
- data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +3 -13
- data/app/components/source_monitor/application_component.rb +10 -0
- data/app/components/source_monitor/filter_dropdown_component.rb +62 -0
- data/app/components/source_monitor/icon_component.rb +140 -0
- data/app/components/source_monitor/status_badge_component.html.erb +8 -0
- data/app/components/source_monitor/status_badge_component.rb +96 -0
- data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +4 -0
- data/app/controllers/concerns/source_monitor/set_source.rb +13 -0
- data/app/controllers/source_monitor/application_controller.rb +17 -0
- data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +6 -10
- data/app/controllers/source_monitor/dashboard_controller.rb +5 -1
- data/app/controllers/source_monitor/import_history_dismissals_controller.rb +1 -1
- data/app/controllers/source_monitor/import_sessions_controller.rb +30 -9
- data/app/controllers/source_monitor/item_scrapes_controller.rb +70 -0
- data/app/controllers/source_monitor/items_controller.rb +2 -69
- data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +1 -4
- data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +2 -12
- data/app/controllers/source_monitor/source_fetches_controller.rb +1 -6
- data/app/controllers/source_monitor/source_health_checks_controller.rb +9 -16
- data/app/controllers/source_monitor/source_health_resets_controller.rb +1 -6
- data/app/controllers/source_monitor/source_retries_controller.rb +1 -6
- data/app/controllers/source_monitor/source_scrape_tests_controller.rb +2 -4
- data/app/controllers/source_monitor/source_turbo_responses.rb +1 -3
- data/app/controllers/source_monitor/sources_controller.rb +15 -20
- data/app/helpers/source_monitor/application_helper.rb +15 -31
- data/app/helpers/source_monitor/health_badge_helper.rb +8 -0
- data/app/jobs/source_monitor/download_content_images_job.rb +1 -59
- data/app/jobs/source_monitor/favicon_fetch_job.rb +1 -58
- data/app/jobs/source_monitor/fetch_feed_job.rb +2 -52
- data/app/jobs/source_monitor/import_opml_job.rb +6 -145
- data/app/jobs/source_monitor/import_session_health_check_job.rb +15 -76
- data/app/jobs/source_monitor/item_cleanup_job.rb +5 -0
- data/app/jobs/source_monitor/log_cleanup_job.rb +13 -2
- data/app/jobs/source_monitor/schedule_fetches_job.rb +8 -0
- data/app/jobs/source_monitor/scrape_item_job.rb +6 -52
- data/app/jobs/source_monitor/source_health_check_job.rb +1 -72
- data/app/models/concerns/source_monitor/loggable.rb +12 -0
- data/app/models/source_monitor/fetch_log.rb +0 -8
- data/app/models/source_monitor/health_check_log.rb +0 -8
- data/app/models/source_monitor/import_history.rb +14 -0
- data/app/models/source_monitor/import_session.rb +2 -0
- data/app/models/source_monitor/item.rb +15 -0
- data/app/models/source_monitor/item_content.rb +4 -3
- data/app/models/source_monitor/scrape_log.rb +4 -6
- data/app/models/source_monitor/source.rb +28 -19
- data/app/presenters/source_monitor/base_presenter.rb +19 -0
- data/app/presenters/source_monitor/source_details_presenter.rb +61 -0
- data/app/presenters/source_monitor/sources_filter_presenter.rb +61 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +3 -3
- data/app/views/source_monitor/dashboard/_stat_card.html.erb +2 -1
- data/app/views/source_monitor/dashboard/_stats.html.erb +5 -7
- data/app/views/source_monitor/items/_details.html.erb +11 -14
- data/app/views/source_monitor/items/index.html.erb +10 -35
- data/app/views/source_monitor/logs/index.html.erb +20 -41
- data/app/views/source_monitor/shared/_form_errors.html.erb +14 -0
- data/app/views/source_monitor/source_scrape_tests/_result.html.erb +1 -29
- data/app/views/source_monitor/source_scrape_tests/_result_content.html.erb +33 -0
- data/app/views/source_monitor/source_scrape_tests/show.html.erb +1 -29
- data/app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erb +2 -2
- data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +7 -5
- data/app/views/source_monitor/sources/_details.html.erb +24 -52
- data/app/views/source_monitor/sources/_health_status_badge.html.erb +4 -6
- data/app/views/source_monitor/sources/_row.html.erb +7 -18
- data/app/views/source_monitor/sources/edit.html.erb +1 -10
- data/app/views/source_monitor/sources/index.html.erb +26 -46
- data/app/views/source_monitor/sources/new.html.erb +1 -10
- data/config/routes.rb +1 -1
- data/db/migrate/20260313120000_add_composite_indexes_to_log_tables.rb +14 -0
- data/db/migrate/20260314120000_align_health_status_default.rb +11 -0
- data/lib/source_monitor/analytics/sources_index_metrics.rb +15 -0
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +10 -4
- data/lib/source_monitor/dashboard/turbo_broadcaster.rb +21 -5
- data/lib/source_monitor/favicons/fetcher.rb +86 -0
- data/lib/source_monitor/fetching/cloudflare_bypass.rb +14 -5
- data/lib/source_monitor/fetching/completion/event_publisher.rb +12 -0
- data/lib/source_monitor/fetching/completion/follow_up_handler.rb +15 -2
- data/lib/source_monitor/fetching/completion/retention_handler.rb +11 -3
- data/lib/source_monitor/fetching/feed_fetcher.rb +2 -21
- data/lib/source_monitor/fetching/fetch_runner.rb +12 -3
- data/lib/source_monitor/fetching/retry_orchestrator.rb +102 -0
- data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +9 -0
- data/lib/source_monitor/health/source_health_check_orchestrator.rb +95 -0
- data/lib/source_monitor/health.rb +1 -0
- data/lib/source_monitor/images/downloader.rb +6 -7
- data/lib/source_monitor/images/processor.rb +98 -0
- data/lib/source_monitor/import_sessions/health_check_updater.rb +95 -0
- data/lib/source_monitor/import_sessions/opml_importer.rb +163 -0
- data/lib/source_monitor/items/item_creator.rb +0 -21
- data/lib/source_monitor/logs/query.rb +20 -0
- data/lib/source_monitor/queries/scrape_candidates_query.rb +30 -0
- data/lib/source_monitor/queries.rb +7 -0
- data/lib/source_monitor/scheduler.rb +5 -0
- data/lib/source_monitor/scraping/bulk_result_presenter.rb +11 -8
- data/lib/source_monitor/scraping/runner.rb +52 -0
- data/lib/source_monitor/scraping/scheduler.rb +5 -0
- data/lib/source_monitor/scraping/state.rb +4 -2
- data/lib/source_monitor/security/parameter_sanitizer.rb +7 -0
- data/lib/source_monitor/version.rb +1 -1
- data/lib/source_monitor.rb +7 -0
- data/source_monitor.gemspec +1 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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(
|
|
155
|
-
.average("
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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",
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 =
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
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
|
-
<%
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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>
|