source_monitor 0.1.1
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 +7 -0
- data/.gitignore +16 -0
- data/.rubocop.yml +12 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +132 -0
- data/CHANGELOG.md +66 -0
- data/CONTRIBUTING.md +31 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +411 -0
- data/MIT-LICENSE +20 -0
- data/README.md +108 -0
- data/Rakefile +8 -0
- data/app/assets/builds/.keep +0 -0
- data/app/assets/config/source_monitor_manifest.js +4 -0
- data/app/assets/images/source_monitor/.keep +0 -0
- data/app/assets/javascripts/source_monitor/application.js +20 -0
- data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
- data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
- data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
- data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
- data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
- data/app/assets/svgs/source_monitor/.keep +0 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
- data/app/controllers/source_monitor/application_controller.rb +62 -0
- data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
- data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/health_controller.rb +10 -0
- data/app/controllers/source_monitor/items_controller.rb +116 -0
- data/app/controllers/source_monitor/logs_controller.rb +15 -0
- data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
- data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
- data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
- data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
- data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
- data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
- data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
- data/app/controllers/source_monitor/sources_controller.rb +179 -0
- data/app/helpers/source_monitor/application_helper.rb +327 -0
- data/app/jobs/source_monitor/application_job.rb +13 -0
- data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
- data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
- data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
- data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
- data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
- data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
- data/app/mailers/source_monitor/application_mailer.rb +17 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/source_monitor/loggable.rb +18 -0
- data/app/models/source_monitor/application_record.rb +5 -0
- data/app/models/source_monitor/fetch_log.rb +31 -0
- data/app/models/source_monitor/health_check_log.rb +28 -0
- data/app/models/source_monitor/item.rb +102 -0
- data/app/models/source_monitor/item_content.rb +11 -0
- data/app/models/source_monitor/log_entry.rb +56 -0
- data/app/models/source_monitor/scrape_log.rb +31 -0
- data/app/models/source_monitor/source.rb +115 -0
- data/app/views/layouts/source_monitor/application.html.erb +54 -0
- data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
- data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
- data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
- data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
- data/app/views/source_monitor/dashboard/index.html.erb +48 -0
- data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
- data/app/views/source_monitor/items/_details.html.erb +234 -0
- data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/items/index.html.erb +147 -0
- data/app/views/source_monitor/items/show.html.erb +3 -0
- data/app/views/source_monitor/logs/index.html.erb +208 -0
- data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
- data/app/views/source_monitor/shared/_toast.html.erb +34 -0
- data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
- data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
- data/app/views/source_monitor/sources/_details.html.erb +302 -0
- data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
- data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
- data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
- data/app/views/source_monitor/sources/_form.html.erb +143 -0
- data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
- data/app/views/source_monitor/sources/_row.html.erb +102 -0
- data/app/views/source_monitor/sources/edit.html.erb +28 -0
- data/app/views/source_monitor/sources/index.html.erb +153 -0
- data/app/views/source_monitor/sources/new.html.erb +22 -0
- data/app/views/source_monitor/sources/show.html.erb +3 -0
- data/config/coverage_baseline.json +2010 -0
- data/config/initializers/feedjira.rb +19 -0
- data/config/routes.rb +18 -0
- data/config/tailwind.config.js +17 -0
- data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
- data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
- data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
- data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
- data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
- data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
- data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
- data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
- data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
- data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
- data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
- data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
- data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
- data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
- data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
- data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
- data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
- data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
- data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
- data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
- data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
- data/docs/configuration.md +170 -0
- data/docs/deployment.md +63 -0
- data/docs/gh-cli-workflow.md +44 -0
- data/docs/installation.md +144 -0
- data/docs/troubleshooting.md +76 -0
- data/eslint.config.mjs +27 -0
- data/lib/generators/source_monitor/install/install_generator.rb +59 -0
- data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
- data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
- data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
- data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
- data/lib/source_monitor/assets/bundler.rb +49 -0
- data/lib/source_monitor/assets.rb +6 -0
- data/lib/source_monitor/configuration.rb +654 -0
- data/lib/source_monitor/dashboard/queries.rb +356 -0
- data/lib/source_monitor/dashboard/quick_action.rb +7 -0
- data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
- data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
- data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
- data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
- data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
- data/lib/source_monitor/engine.rb +107 -0
- data/lib/source_monitor/events.rb +110 -0
- data/lib/source_monitor/feedjira_extensions.rb +103 -0
- data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
- data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
- data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
- data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
- data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
- data/lib/source_monitor/fetching/fetch_error.rb +88 -0
- data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
- data/lib/source_monitor/fetching/retry_policy.rb +85 -0
- data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
- data/lib/source_monitor/health/source_health_check.rb +100 -0
- data/lib/source_monitor/health/source_health_monitor.rb +210 -0
- data/lib/source_monitor/health/source_health_reset.rb +68 -0
- data/lib/source_monitor/health.rb +46 -0
- data/lib/source_monitor/http.rb +85 -0
- data/lib/source_monitor/instrumentation.rb +52 -0
- data/lib/source_monitor/items/item_creator.rb +601 -0
- data/lib/source_monitor/items/retention_pruner.rb +146 -0
- data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
- data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
- data/lib/source_monitor/items/retention_strategies.rb +9 -0
- data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
- data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
- data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
- data/lib/source_monitor/jobs/visibility.rb +133 -0
- data/lib/source_monitor/logs/entry_sync.rb +69 -0
- data/lib/source_monitor/logs/filter_set.rb +163 -0
- data/lib/source_monitor/logs/query.rb +81 -0
- data/lib/source_monitor/logs/table_presenter.rb +161 -0
- data/lib/source_monitor/metrics.rb +77 -0
- data/lib/source_monitor/model_extensions.rb +109 -0
- data/lib/source_monitor/models/sanitizable.rb +76 -0
- data/lib/source_monitor/models/url_normalizable.rb +84 -0
- data/lib/source_monitor/pagination/paginator.rb +90 -0
- data/lib/source_monitor/realtime/adapter.rb +97 -0
- data/lib/source_monitor/realtime/broadcaster.rb +237 -0
- data/lib/source_monitor/realtime.rb +17 -0
- data/lib/source_monitor/release/changelog.rb +59 -0
- data/lib/source_monitor/release/runner.rb +73 -0
- data/lib/source_monitor/scheduler.rb +82 -0
- data/lib/source_monitor/scrapers/base.rb +105 -0
- data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
- data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
- data/lib/source_monitor/scrapers/readability.rb +156 -0
- data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
- data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
- data/lib/source_monitor/scraping/enqueuer.rb +125 -0
- data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
- data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
- data/lib/source_monitor/scraping/item_scraper.rb +84 -0
- data/lib/source_monitor/scraping/scheduler.rb +43 -0
- data/lib/source_monitor/scraping/state.rb +79 -0
- data/lib/source_monitor/security/authentication.rb +85 -0
- data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
- data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
- data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
- data/lib/source_monitor/version.rb +3 -0
- data/lib/source_monitor.rb +149 -0
- data/lib/tasks/recover_stalled_fetches.rake +16 -0
- data/lib/tasks/source_monitor_assets.rake +28 -0
- data/lib/tasks/source_monitor_tasks.rake +29 -0
- data/lib/tasks/test_smoke.rake +12 -0
- data/package-lock.json +3997 -0
- data/package.json +29 -0
- data/postcss.config.js +6 -0
- data/source_monitor.gemspec +46 -0
- data/stylelint.config.js +12 -0
- metadata +469 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class SourceHealthResetsController < ApplicationController
|
|
5
|
+
include SourceMonitor::SourceTurboResponses
|
|
6
|
+
|
|
7
|
+
before_action :set_source
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
SourceMonitor::Health::SourceHealthReset.call(source: @source)
|
|
11
|
+
SourceMonitor::Realtime.broadcast_source(@source)
|
|
12
|
+
|
|
13
|
+
render_fetch_enqueue_response(
|
|
14
|
+
"Health state reset",
|
|
15
|
+
toast_level: :success
|
|
16
|
+
)
|
|
17
|
+
rescue StandardError => error
|
|
18
|
+
handle_fetch_failure(error, prefix: "Health reset")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def set_source
|
|
24
|
+
@source = Source.find(params[:source_id])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
class SourceRetriesController < ApplicationController
|
|
5
|
+
include SourceMonitor::SourceTurboResponses
|
|
6
|
+
|
|
7
|
+
before_action :set_source
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
SourceMonitor::Fetching::FetchRunner.enqueue(@source.id, force: true)
|
|
11
|
+
render_fetch_enqueue_response("Retry has been forced and will run shortly.")
|
|
12
|
+
rescue StandardError => error
|
|
13
|
+
handle_fetch_failure(error)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def set_source
|
|
19
|
+
@source = Source.find(params[:source_id])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
module SourceTurboResponses
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
include ActionView::RecordIdentifier
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def render_fetch_enqueue_response(message, toast_level: :info, health_status_override: nil)
|
|
14
|
+
@source.reload
|
|
15
|
+
respond_to do |format|
|
|
16
|
+
format.turbo_stream do
|
|
17
|
+
responder = SourceMonitor::TurboStreams::StreamResponder.new
|
|
18
|
+
|
|
19
|
+
responder.replace_details(
|
|
20
|
+
@source,
|
|
21
|
+
partial: "source_monitor/sources/details_wrapper",
|
|
22
|
+
locals: { source: @source, health_status_override: health_status_override }
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
responder.replace_row(
|
|
26
|
+
@source,
|
|
27
|
+
partial: "source_monitor/sources/row",
|
|
28
|
+
locals: {
|
|
29
|
+
source: @source,
|
|
30
|
+
item_activity_rates: { @source.id => SourceMonitor::Analytics::SourceActivityRates.rate_for(@source) },
|
|
31
|
+
health_status_override: health_status_override
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
responder.toast(message:, level: toast_level, delay_ms: toast_delay_for(toast_level))
|
|
36
|
+
|
|
37
|
+
render turbo_stream: responder.render(view_context)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
format.html do
|
|
41
|
+
redirect_to source_monitor.source_path(@source), notice: message
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_fetch_failure(error, prefix: "Fetch")
|
|
47
|
+
error_message = "#{prefix} could not be enqueued: #{error.message}"
|
|
48
|
+
|
|
49
|
+
respond_to do |format|
|
|
50
|
+
format.turbo_stream do
|
|
51
|
+
responder = SourceMonitor::TurboStreams::StreamResponder.new
|
|
52
|
+
responder.toast(message: error_message, level: :error, delay_ms: toast_delay_for(:error))
|
|
53
|
+
|
|
54
|
+
render turbo_stream: responder.render(view_context), status: :unprocessable_entity
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
format.html do
|
|
58
|
+
redirect_to source_monitor.source_path(@source), alert: error_message
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def respond_to_bulk_scrape(result)
|
|
64
|
+
@source.reload
|
|
65
|
+
@bulk_scrape_selection = result.selection
|
|
66
|
+
payload = bulk_scrape_flash_payload(result)
|
|
67
|
+
status = result.error? ? :unprocessable_entity : :ok
|
|
68
|
+
|
|
69
|
+
respond_to do |format|
|
|
70
|
+
format.turbo_stream do
|
|
71
|
+
responder = SourceMonitor::TurboStreams::StreamResponder.new
|
|
72
|
+
|
|
73
|
+
responder.replace_details(
|
|
74
|
+
@source,
|
|
75
|
+
partial: "source_monitor/sources/details_wrapper",
|
|
76
|
+
locals: { source: @source }
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
responder.replace_row(
|
|
80
|
+
@source,
|
|
81
|
+
partial: "source_monitor/sources/row",
|
|
82
|
+
locals: {
|
|
83
|
+
source: @source,
|
|
84
|
+
item_activity_rates: { @source.id => SourceMonitor::Analytics::SourceActivityRates.rate_for(@source) }
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if payload[:message].present?
|
|
89
|
+
responder.toast(
|
|
90
|
+
message: payload[:message],
|
|
91
|
+
level: payload[:level],
|
|
92
|
+
delay_ms: toast_delay_for(payload[:level])
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
render turbo_stream: responder.render(view_context), status: status
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
format.html do
|
|
100
|
+
if payload[:message].present?
|
|
101
|
+
redirect_to source_monitor.source_path(@source), flash: { payload[:flash_key] => payload[:message] }
|
|
102
|
+
else
|
|
103
|
+
redirect_to source_monitor.source_path(@source)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def bulk_scrape_flash_payload(result)
|
|
110
|
+
pluralizer = ->(count, word) { view_context.pluralize(count, word) }
|
|
111
|
+
presenter = SourceMonitor::Scraping::BulkResultPresenter.new(result:, pluralizer:)
|
|
112
|
+
presenter.to_flash_payload
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "source_monitor/sources/turbo_stream_presenter"
|
|
4
|
+
require "source_monitor/scraping/bulk_result_presenter"
|
|
5
|
+
|
|
6
|
+
module SourceMonitor
|
|
7
|
+
class SourcesController < ApplicationController
|
|
8
|
+
include ActionView::RecordIdentifier
|
|
9
|
+
include SourceMonitor::SanitizesSearchParams
|
|
10
|
+
|
|
11
|
+
searchable_with scope: -> { Source.all }, default_sorts: [ "created_at desc" ]
|
|
12
|
+
|
|
13
|
+
ITEMS_PREVIEW_LIMIT = SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT
|
|
14
|
+
|
|
15
|
+
before_action :set_source, only: %i[show edit update destroy]
|
|
16
|
+
|
|
17
|
+
SEARCH_FIELD = :name_or_feed_url_or_website_url_cont
|
|
18
|
+
|
|
19
|
+
def index
|
|
20
|
+
@search_params = sanitized_search_params
|
|
21
|
+
@q = build_search_query
|
|
22
|
+
|
|
23
|
+
@sources = @q.result
|
|
24
|
+
|
|
25
|
+
@search_term = @search_params[SEARCH_FIELD.to_s].to_s.strip
|
|
26
|
+
@search_field = SEARCH_FIELD
|
|
27
|
+
|
|
28
|
+
metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(
|
|
29
|
+
base_scope: Source.all,
|
|
30
|
+
result_scope: @sources,
|
|
31
|
+
search_params: @search_params
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@fetch_interval_distribution = metrics.fetch_interval_distribution
|
|
35
|
+
@fetch_interval_filter = metrics.fetch_interval_filter
|
|
36
|
+
@selected_fetch_interval_bucket = metrics.selected_fetch_interval_bucket
|
|
37
|
+
@item_activity_rates = metrics.item_activity_rates
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show
|
|
41
|
+
@recent_fetch_logs = @source.fetch_logs.order(started_at: :desc).limit(5)
|
|
42
|
+
@recent_scrape_logs = @source.scrape_logs.order(started_at: :desc).limit(5)
|
|
43
|
+
@items = @source.items.recent.limit(ITEMS_PREVIEW_LIMIT)
|
|
44
|
+
@bulk_scrape_selection = :current
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def new
|
|
48
|
+
@source = Source.new(default_attributes)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create
|
|
52
|
+
@source = Source.new(source_params)
|
|
53
|
+
|
|
54
|
+
if @source.save
|
|
55
|
+
redirect_to source_monitor.source_path(@source), notice: "Source created successfully"
|
|
56
|
+
else
|
|
57
|
+
render :new, status: :unprocessable_entity
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def edit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update
|
|
65
|
+
if @source.update(source_params)
|
|
66
|
+
redirect_to source_monitor.source_path(@source), notice: "Source updated successfully"
|
|
67
|
+
else
|
|
68
|
+
render :edit, status: :unprocessable_entity
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def destroy
|
|
73
|
+
search_params = sanitized_search_params
|
|
74
|
+
@source.destroy
|
|
75
|
+
message = "Source deleted"
|
|
76
|
+
|
|
77
|
+
respond_to do |format|
|
|
78
|
+
format.turbo_stream do
|
|
79
|
+
query = build_search_query(params: search_params)
|
|
80
|
+
|
|
81
|
+
metrics = SourceMonitor::Analytics::SourcesIndexMetrics.new(
|
|
82
|
+
base_scope: Source.all,
|
|
83
|
+
result_scope: query.result,
|
|
84
|
+
search_params:
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
redirect_location = safe_redirect_path(params[:redirect_to])
|
|
88
|
+
|
|
89
|
+
responder = SourceMonitor::TurboStreams::StreamResponder.new
|
|
90
|
+
presenter = SourceMonitor::Sources::TurboStreamPresenter.new(source: @source, responder:)
|
|
91
|
+
|
|
92
|
+
presenter.render_deletion(
|
|
93
|
+
metrics:,
|
|
94
|
+
query:,
|
|
95
|
+
search_params:,
|
|
96
|
+
redirect_location:
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
responder.toast(message:, level: :success)
|
|
100
|
+
|
|
101
|
+
render turbo_stream: responder.render(view_context)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
format.html do
|
|
105
|
+
redirect_to source_monitor.sources_path, notice: message
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fetch
|
|
111
|
+
SourceMonitor::Fetching::FetchRunner.enqueue(@source.id)
|
|
112
|
+
render_fetch_enqueue_response("Fetch has been enqueued and will run shortly.")
|
|
113
|
+
rescue StandardError => error
|
|
114
|
+
handle_fetch_failure(error)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def retry
|
|
118
|
+
SourceMonitor::Fetching::FetchRunner.enqueue(@source.id, force: true)
|
|
119
|
+
render_fetch_enqueue_response("Retry has been forced and will run shortly.")
|
|
120
|
+
rescue StandardError => error
|
|
121
|
+
handle_fetch_failure(error)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def set_source
|
|
127
|
+
@source = Source.find(params[:id])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
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
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
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)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def safe_redirect_path(raw_value)
|
|
173
|
+
return if raw_value.blank?
|
|
174
|
+
|
|
175
|
+
sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(raw_value.to_s)
|
|
176
|
+
sanitized.start_with?("/") ? sanitized : nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
module SourceMonitor
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
def source_monitor_stylesheet_bundle_tag
|
|
4
|
+
stylesheet_link_tag("source_monitor/application", "data-turbo-track": "reload")
|
|
5
|
+
rescue StandardError => error
|
|
6
|
+
log_source_monitor_asset_error(:stylesheet, error)
|
|
7
|
+
nil
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source_monitor_javascript_bundle_tag
|
|
11
|
+
javascript_include_tag("source_monitor/application", "data-turbo-track": "reload", type: "module")
|
|
12
|
+
rescue StandardError => error
|
|
13
|
+
log_source_monitor_asset_error(:javascript, error)
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def heatmap_bucket_classes(count, max_count)
|
|
18
|
+
return "bg-slate-100 text-slate-500" if max_count.to_i.zero? || count.to_i.zero?
|
|
19
|
+
|
|
20
|
+
ratio = count.to_f / max_count
|
|
21
|
+
|
|
22
|
+
case ratio
|
|
23
|
+
when 0...0.25
|
|
24
|
+
"bg-blue-100 text-blue-800"
|
|
25
|
+
when 0.25...0.5
|
|
26
|
+
"bg-blue-200 text-blue-900"
|
|
27
|
+
when 0.5...0.75
|
|
28
|
+
"bg-blue-400 text-white"
|
|
29
|
+
else
|
|
30
|
+
"bg-blue-600 text-white"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fetch_interval_bucket_path(bucket, search_params, selected: false)
|
|
35
|
+
query = fetch_interval_bucket_query(bucket, search_params, selected: selected)
|
|
36
|
+
route_helpers = SourceMonitor::Engine.routes.url_helpers
|
|
37
|
+
|
|
38
|
+
query.empty? ? route_helpers.sources_path : route_helpers.sources_path(q: query)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch_interval_bucket_query(bucket, search_params, selected: false)
|
|
42
|
+
base = (search_params || {}).dup
|
|
43
|
+
base = base.except("fetch_interval_minutes_gteq", "fetch_interval_minutes_lt", "fetch_interval_minutes_lteq")
|
|
44
|
+
|
|
45
|
+
query = if selected
|
|
46
|
+
base
|
|
47
|
+
else
|
|
48
|
+
updated = base.dup
|
|
49
|
+
updated["fetch_interval_minutes_gteq"] = bucket.min.to_i.to_s if bucket.respond_to?(:min) && bucket.min
|
|
50
|
+
|
|
51
|
+
if bucket.respond_to?(:max) && bucket.max
|
|
52
|
+
updated["fetch_interval_minutes_lt"] = bucket.max.to_i.to_s
|
|
53
|
+
else
|
|
54
|
+
updated.delete("fetch_interval_minutes_lt")
|
|
55
|
+
updated.delete("fetch_interval_minutes_lteq")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
updated
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if query.respond_to?(:compact_blank)
|
|
62
|
+
query.compact_blank
|
|
63
|
+
else
|
|
64
|
+
query.reject { |_key, value| value.respond_to?(:blank?) ? value.blank? : value.nil? }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch_interval_filter_label(bucket, filter)
|
|
69
|
+
return bucket.label if bucket&.respond_to?(:label)
|
|
70
|
+
return unless filter
|
|
71
|
+
|
|
72
|
+
min = filter[:min]
|
|
73
|
+
max = filter[:max]
|
|
74
|
+
|
|
75
|
+
if min && max
|
|
76
|
+
"#{min}-#{max} min"
|
|
77
|
+
elsif min
|
|
78
|
+
"#{min}+ min"
|
|
79
|
+
else
|
|
80
|
+
"Any interval"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_schedule_window_label(group)
|
|
85
|
+
start_time = group.respond_to?(:window_start) ? group.window_start : nil
|
|
86
|
+
end_time = group.respond_to?(:window_end) ? group.window_end : nil
|
|
87
|
+
|
|
88
|
+
return unless start_time || end_time
|
|
89
|
+
|
|
90
|
+
if start_time && end_time
|
|
91
|
+
"#{format_schedule_time(start_time)} – #{format_schedule_time(end_time)}"
|
|
92
|
+
elsif start_time
|
|
93
|
+
"After #{format_schedule_time(start_time)}"
|
|
94
|
+
else
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_schedule_time(time)
|
|
100
|
+
return unless time
|
|
101
|
+
|
|
102
|
+
l(time.in_time_zone, format: :short)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def human_fetch_interval(minutes)
|
|
106
|
+
return "—" if minutes.blank?
|
|
107
|
+
|
|
108
|
+
total_minutes = minutes.to_i
|
|
109
|
+
hours, remaining = total_minutes.divmod(60)
|
|
110
|
+
parts = []
|
|
111
|
+
parts << "#{hours}h" if hours.positive?
|
|
112
|
+
parts << "#{remaining}m" if remaining.positive? || parts.empty?
|
|
113
|
+
parts.join(" ")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Unified status badge helper for both fetch and scrape operations
|
|
117
|
+
ITEM_SCRAPE_STATUS_LABELS = {
|
|
118
|
+
"pending" => "Pending",
|
|
119
|
+
"processing" => "Processing",
|
|
120
|
+
"success" => "Scraped",
|
|
121
|
+
"failed" => "Failed",
|
|
122
|
+
"partial" => "Partial",
|
|
123
|
+
"disabled" => "Disabled",
|
|
124
|
+
"idle" => "Not scraped"
|
|
125
|
+
}.freeze
|
|
126
|
+
|
|
127
|
+
# Maps asynchronous workflow states to badge styling/labels shared across the
|
|
128
|
+
# engine. Item scraping builds on these core states, reusing the same colors
|
|
129
|
+
# so the UI stays consistent across sources, items, and job dashboards.
|
|
130
|
+
def async_status_badge(status, show_spinner: true)
|
|
131
|
+
status_str = status.to_s
|
|
132
|
+
|
|
133
|
+
label, classes, spinner = case status_str
|
|
134
|
+
when "queued"
|
|
135
|
+
[ "Queued", "bg-amber-100 text-amber-700", show_spinner ]
|
|
136
|
+
when "pending"
|
|
137
|
+
[ "Pending", "bg-amber-100 text-amber-700", show_spinner ]
|
|
138
|
+
when "fetching", "processing"
|
|
139
|
+
[ "Processing", "bg-blue-100 text-blue-700", show_spinner ]
|
|
140
|
+
when "success"
|
|
141
|
+
[ "Completed", "bg-green-100 text-green-700", false ]
|
|
142
|
+
when "failed"
|
|
143
|
+
[ "Failed", "bg-rose-100 text-rose-700", false ]
|
|
144
|
+
when "partial"
|
|
145
|
+
[ "Partial", "bg-amber-100 text-amber-700", false ]
|
|
146
|
+
when "disabled"
|
|
147
|
+
[ "Disabled", "bg-slate-200 text-slate-600", false ]
|
|
148
|
+
when "idle"
|
|
149
|
+
[ "Idle", "bg-slate-100 text-slate-600", false ]
|
|
150
|
+
else
|
|
151
|
+
[ "Ready", "bg-slate-100 text-slate-600", false ]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{ label: label, classes: classes, show_spinner: spinner }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns a normalized badge payload for the source show/item pages. The
|
|
158
|
+
# status derives from the item's recorded scrape_status, falls back to the
|
|
159
|
+
# source configuration, and always lands inside the known status set:
|
|
160
|
+
# pending, processing, success, failed, partial, disabled, or idle.
|
|
161
|
+
def item_scrape_status_badge(item:, source: nil, show_spinner: true)
|
|
162
|
+
status = derive_item_scrape_status(item:, source: source)
|
|
163
|
+
base_badge = async_status_badge(status, show_spinner: show_spinner)
|
|
164
|
+
label = ITEM_SCRAPE_STATUS_LABELS.fetch(status) { base_badge[:label] }
|
|
165
|
+
spinner = base_badge[:show_spinner] && %w[pending processing].include?(status)
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
status: status,
|
|
169
|
+
label: label,
|
|
170
|
+
classes: base_badge[:classes],
|
|
171
|
+
show_spinner: spinner
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Legacy helper for backwards compatibility
|
|
176
|
+
def fetch_status_badge_classes(status)
|
|
177
|
+
async_status_badge(status)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Helper to render the loading spinner SVG
|
|
181
|
+
def loading_spinner_svg(css_class: "mr-1 h-4 w-4 animate-spin text-blue-500")
|
|
182
|
+
tag.svg(
|
|
183
|
+
class: css_class,
|
|
184
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
185
|
+
fill: "none",
|
|
186
|
+
viewBox: "0 0 24 24",
|
|
187
|
+
aria: { hidden: "true" }
|
|
188
|
+
) do
|
|
189
|
+
concat tag.circle(class: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", stroke_width: "4")
|
|
190
|
+
concat tag.path(class: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def source_health_badge(source, override: nil)
|
|
195
|
+
return override if override.present?
|
|
196
|
+
|
|
197
|
+
status = source&.health_status.presence || "healthy"
|
|
198
|
+
|
|
199
|
+
mapping = {
|
|
200
|
+
"healthy" => { label: "Healthy", classes: "bg-green-100 text-green-700", show_spinner: false },
|
|
201
|
+
"warning" => { label: "Needs Attention", classes: "bg-amber-100 text-amber-700", show_spinner: false },
|
|
202
|
+
"critical" => { label: "Failing", classes: "bg-rose-100 text-rose-700", show_spinner: false },
|
|
203
|
+
"declining" => { label: "Declining", classes: "bg-orange-100 text-orange-700", show_spinner: false },
|
|
204
|
+
"improving" => { label: "Improving", classes: "bg-sky-100 text-sky-700", show_spinner: false },
|
|
205
|
+
"auto_paused" => { label: "Auto-Paused", classes: "bg-amber-100 text-amber-700", show_spinner: false },
|
|
206
|
+
"unknown" => { label: "Unknown", classes: "bg-slate-100 text-slate-600", show_spinner: false }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
mapping.fetch(status) { mapping.fetch("unknown") }.merge(status: status)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def source_health_actions(source)
|
|
213
|
+
status = source&.health_status.presence || "healthy"
|
|
214
|
+
helpers = SourceMonitor::Engine.routes.url_helpers
|
|
215
|
+
|
|
216
|
+
case status
|
|
217
|
+
when "critical", "declining"
|
|
218
|
+
[
|
|
219
|
+
{
|
|
220
|
+
key: :full_fetch,
|
|
221
|
+
label: "Queue Full Fetch",
|
|
222
|
+
description: "Runs the full fetch pipeline immediately and updates items if the feed responds.",
|
|
223
|
+
path: helpers.source_fetch_path(source),
|
|
224
|
+
method: :post,
|
|
225
|
+
data: { testid: "source-health-action-full_fetch" }
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
key: :health_check,
|
|
229
|
+
label: "Run Health Check",
|
|
230
|
+
description: "Sends a single request to confirm the feed is reachable without modifying stored items.",
|
|
231
|
+
path: helpers.source_health_check_path(source),
|
|
232
|
+
method: :post,
|
|
233
|
+
data: { testid: "source-health-action-health_check" }
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
when "auto_paused"
|
|
237
|
+
[
|
|
238
|
+
{
|
|
239
|
+
key: :reset,
|
|
240
|
+
label: "Reset to Active Status",
|
|
241
|
+
description: "Clears the pause window, failure counters, and schedules the next fetch using the configured interval.",
|
|
242
|
+
path: helpers.source_health_reset_path(source),
|
|
243
|
+
method: :post,
|
|
244
|
+
data: { testid: "source-health-action-reset" }
|
|
245
|
+
}
|
|
246
|
+
]
|
|
247
|
+
else
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def interactive_health_status?(source, override: nil)
|
|
253
|
+
return false if override.present?
|
|
254
|
+
|
|
255
|
+
%w[critical declining auto_paused].include?(source&.health_status.presence)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def table_sort_direction(search_object, attribute)
|
|
259
|
+
return unless search_object.respond_to?(:sorts)
|
|
260
|
+
|
|
261
|
+
sort = search_object.sorts.detect { |s| s && s.name == attribute.to_s }
|
|
262
|
+
sort&.dir
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def table_sort_arrow(search_object, attribute, default: nil)
|
|
266
|
+
direction = table_sort_direction(search_object, attribute) || default&.to_s
|
|
267
|
+
|
|
268
|
+
case direction
|
|
269
|
+
when "asc"
|
|
270
|
+
"▲"
|
|
271
|
+
when "desc"
|
|
272
|
+
"▼"
|
|
273
|
+
else
|
|
274
|
+
"↕"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def table_sort_aria(search_object, attribute)
|
|
279
|
+
direction = table_sort_direction(search_object, attribute)
|
|
280
|
+
|
|
281
|
+
case direction
|
|
282
|
+
when "asc"
|
|
283
|
+
"ascending"
|
|
284
|
+
when "desc"
|
|
285
|
+
"descending"
|
|
286
|
+
else
|
|
287
|
+
"none"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def table_sort_link(search_object, attribute, label, frame:, default_order:, secondary: [], html_options: {})
|
|
292
|
+
sort_targets = [ attribute, *Array(secondary) ]
|
|
293
|
+
options = {
|
|
294
|
+
default_order: default_order,
|
|
295
|
+
hide_indicator: true
|
|
296
|
+
}.merge(html_options)
|
|
297
|
+
|
|
298
|
+
options[:data] = (options[:data] || {}).merge(turbo_frame: frame)
|
|
299
|
+
options[:data][:turbo_action] ||= "advance"
|
|
300
|
+
|
|
301
|
+
sort_link(search_object, attribute, sort_targets, options) do
|
|
302
|
+
tag.span(label, class: "inline-flex items-center gap-1")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
def derive_item_scrape_status(item:, source: nil)
|
|
309
|
+
return "idle" unless item
|
|
310
|
+
|
|
311
|
+
status = item.scrape_status.to_s.presence
|
|
312
|
+
return status if status.present?
|
|
313
|
+
|
|
314
|
+
source ||= item.source
|
|
315
|
+
return "disabled" if source&.scraping_enabled? == false
|
|
316
|
+
return "success" if item.scraped_at.present?
|
|
317
|
+
|
|
318
|
+
"idle"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def log_source_monitor_asset_error(kind, error)
|
|
322
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
323
|
+
|
|
324
|
+
Rails.logger.debug("[SourceMonitor] Skipping #{kind} bundle include: #{error.message}")
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module SourceMonitor
|
|
2
|
+
parent_job = defined?(::ApplicationJob) ? ::ApplicationJob : ActiveJob::Base
|
|
3
|
+
|
|
4
|
+
class ApplicationJob < parent_job
|
|
5
|
+
class << self
|
|
6
|
+
# Specify a queue name using SourceMonitor's configuration, ensuring
|
|
7
|
+
# we respect host application prefixes and overrides.
|
|
8
|
+
def source_monitor_queue(role)
|
|
9
|
+
queue_as SourceMonitor.queue_name(role)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|