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,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
|
|
5
|
+
module SourceMonitor
|
|
6
|
+
module ModelExtensions
|
|
7
|
+
class << self
|
|
8
|
+
def register(model_class, key)
|
|
9
|
+
key = key.to_sym
|
|
10
|
+
registry[key] ||= []
|
|
11
|
+
entry = registry[key].find { |registered| registered.model_class == model_class }
|
|
12
|
+
|
|
13
|
+
unless entry
|
|
14
|
+
entry = RegisteredModel.new(model_class, base_table_name_for(model_class), key)
|
|
15
|
+
registry[key] << entry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
apply_to(entry)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reload!
|
|
22
|
+
registry.each do |key, models|
|
|
23
|
+
models.each { |entry| apply_to(entry) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
RegisteredModel = Struct.new(:model_class, :base_table, :key)
|
|
30
|
+
|
|
31
|
+
def registry
|
|
32
|
+
@registry ||= {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def apply_to(entry)
|
|
36
|
+
definition = SourceMonitor.config.models.for(entry.key)
|
|
37
|
+
|
|
38
|
+
assign_table_name(entry)
|
|
39
|
+
apply_concerns(entry.model_class, definition)
|
|
40
|
+
apply_validations(entry.model_class, definition)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def assign_table_name(entry)
|
|
44
|
+
model_class = entry.model_class
|
|
45
|
+
desired = "#{SourceMonitor.table_name_prefix}#{entry.base_table}"
|
|
46
|
+
model_class.table_name = desired
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def apply_concerns(model_class, definition)
|
|
50
|
+
applied = model_class.instance_variable_get(:@_source_monitor_extension_concerns) || []
|
|
51
|
+
|
|
52
|
+
definition.each_concern do |signature, mod|
|
|
53
|
+
next if applied.include?(signature)
|
|
54
|
+
|
|
55
|
+
model_class.include(mod) unless model_class < mod
|
|
56
|
+
applied << signature
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
model_class.instance_variable_set(:@_source_monitor_extension_concerns, applied)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def apply_validations(model_class, definition)
|
|
63
|
+
remove_extension_validations(model_class)
|
|
64
|
+
|
|
65
|
+
applied_signatures = []
|
|
66
|
+
applied_filters = []
|
|
67
|
+
|
|
68
|
+
definition.validations.each do |validation|
|
|
69
|
+
signature = validation.signature
|
|
70
|
+
next if applied_signatures.include?(signature)
|
|
71
|
+
|
|
72
|
+
if validation.symbol?
|
|
73
|
+
model_class.validate(validation.handler, **validation.options)
|
|
74
|
+
applied_filters << validation.handler
|
|
75
|
+
else
|
|
76
|
+
handler = validation.handler
|
|
77
|
+
callback = proc { |record| handler.call(record) }
|
|
78
|
+
model_class.validate(**validation.options, &callback)
|
|
79
|
+
applied_filters << callback
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
applied_signatures << signature
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
model_class.instance_variable_set(:@_source_monitor_extension_validations, applied_signatures)
|
|
86
|
+
model_class.instance_variable_set(:@_source_monitor_extension_validation_filters, applied_filters)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def base_table_name_for(model_class)
|
|
90
|
+
model_class.name.demodulize.tableize
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def remove_extension_validations(model_class)
|
|
94
|
+
filters = model_class.instance_variable_get(:@_source_monitor_extension_validation_filters)
|
|
95
|
+
return unless filters&.any?
|
|
96
|
+
|
|
97
|
+
callbacks = model_class._validate_callbacks
|
|
98
|
+
return unless callbacks
|
|
99
|
+
|
|
100
|
+
callbacks.to_a.each do |callback|
|
|
101
|
+
callbacks.delete(callback) if filters.include?(callback.filter)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
model_class.instance_variable_set(:@_source_monitor_extension_validations, [])
|
|
105
|
+
model_class.instance_variable_set(:@_source_monitor_extension_validation_filters, [])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/hash_with_indifferent_access"
|
|
5
|
+
|
|
6
|
+
module SourceMonitor
|
|
7
|
+
module Models
|
|
8
|
+
module Sanitizable
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
class_attribute :sanitized_string_attributes, instance_writer: false, default: []
|
|
13
|
+
class_attribute :sanitized_hash_attributes, instance_writer: false, default: []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def sanitizes_string_attributes(*attributes)
|
|
18
|
+
configure_sanitization_callback
|
|
19
|
+
self.sanitized_string_attributes += attributes.map(&:to_sym)
|
|
20
|
+
self.sanitized_string_attributes.uniq!
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def sanitizes_hash_attributes(*attributes)
|
|
24
|
+
configure_sanitization_callback
|
|
25
|
+
self.sanitized_hash_attributes += attributes.map(&:to_sym)
|
|
26
|
+
self.sanitized_hash_attributes.uniq!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def configure_sanitization_callback
|
|
32
|
+
return if @_source_monitor_sanitization_callback_defined
|
|
33
|
+
|
|
34
|
+
before_validation :sanitize_model_attributes
|
|
35
|
+
@_source_monitor_sanitization_callback_defined = true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def sanitize_model_attributes
|
|
42
|
+
sanitizer = SourceMonitor::Security::ParameterSanitizer
|
|
43
|
+
|
|
44
|
+
self.class.sanitized_string_attributes.each do |attribute|
|
|
45
|
+
value = self[attribute]
|
|
46
|
+
next unless value.is_a?(String)
|
|
47
|
+
|
|
48
|
+
self[attribute] = sanitizer.sanitize(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
self.class.sanitized_hash_attributes.each do |attribute|
|
|
52
|
+
value = self[attribute] || {}
|
|
53
|
+
sanitized = sanitizer.sanitize(value)
|
|
54
|
+
self[attribute] = if sanitized.is_a?(Hash)
|
|
55
|
+
to_indifferent_hash(sanitized)
|
|
56
|
+
else
|
|
57
|
+
ActiveSupport::HashWithIndifferentAccess.new
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_indifferent_hash(value)
|
|
63
|
+
case value
|
|
64
|
+
when Hash
|
|
65
|
+
value.each_with_object(ActiveSupport::HashWithIndifferentAccess.new) do |(key, val), memo|
|
|
66
|
+
memo[key] = to_indifferent_hash(val)
|
|
67
|
+
end
|
|
68
|
+
when Array
|
|
69
|
+
value.map { |element| to_indifferent_hash(element) }
|
|
70
|
+
else
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SourceMonitor
|
|
8
|
+
module Models
|
|
9
|
+
module UrlNormalizable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
class_attribute :normalized_url_attributes, instance_writer: false, default: []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class_methods do
|
|
17
|
+
def normalizes_urls(*attributes)
|
|
18
|
+
return if attributes.empty?
|
|
19
|
+
|
|
20
|
+
before_validation :normalize_configured_urls
|
|
21
|
+
self.normalized_url_attributes += attributes.map(&:to_sym)
|
|
22
|
+
self.normalized_url_attributes.uniq!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validates_url_format(*attributes)
|
|
26
|
+
attributes.each do |attribute|
|
|
27
|
+
validate_method = :"validate_#{attribute}_format"
|
|
28
|
+
|
|
29
|
+
define_method validate_method do
|
|
30
|
+
return if self[attribute].blank?
|
|
31
|
+
|
|
32
|
+
errors.add(attribute, "must be a valid HTTP(S) URL") if url_invalid?(attribute)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
validate validate_method
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def url_invalid?(attribute)
|
|
41
|
+
invalid_urls[attribute.to_sym]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def normalize_configured_urls
|
|
47
|
+
normalized_url_attributes.each do |attribute|
|
|
48
|
+
normalize_single_url(attribute)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize_single_url(attribute)
|
|
53
|
+
raw_value = self[attribute]
|
|
54
|
+
invalid_urls[attribute] = false
|
|
55
|
+
|
|
56
|
+
normalized = normalize_url_value(raw_value)
|
|
57
|
+
self[attribute] = normalized
|
|
58
|
+
rescue URI::InvalidURIError
|
|
59
|
+
invalid_urls[attribute] = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_url_value(value)
|
|
63
|
+
return nil if value.blank?
|
|
64
|
+
|
|
65
|
+
uri = URI.parse(value.to_s.strip)
|
|
66
|
+
raise URI::InvalidURIError if uri.scheme.blank? || uri.host.blank?
|
|
67
|
+
|
|
68
|
+
scheme = uri.scheme.downcase
|
|
69
|
+
raise URI::InvalidURIError unless %w[http https].include?(scheme)
|
|
70
|
+
|
|
71
|
+
uri.scheme = scheme
|
|
72
|
+
uri.host = uri.host.downcase
|
|
73
|
+
uri.path = "/" if uri.path.blank?
|
|
74
|
+
uri.fragment = nil
|
|
75
|
+
|
|
76
|
+
uri.to_s
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def invalid_urls
|
|
80
|
+
@_source_monitor_invalid_urls ||= Hash.new(false)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SourceMonitor
|
|
4
|
+
module Pagination
|
|
5
|
+
Result = Struct.new(
|
|
6
|
+
:records,
|
|
7
|
+
:page,
|
|
8
|
+
:per_page,
|
|
9
|
+
:has_next_page,
|
|
10
|
+
:has_previous_page,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
) do
|
|
13
|
+
def has_next_page?
|
|
14
|
+
!!has_next_page
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def has_previous_page?
|
|
18
|
+
!!has_previous_page
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def next_page
|
|
22
|
+
return nil unless has_next_page
|
|
23
|
+
|
|
24
|
+
page + 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def previous_page
|
|
28
|
+
return nil unless has_previous_page
|
|
29
|
+
|
|
30
|
+
[ page - 1, 1 ].max
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Paginator
|
|
35
|
+
DEFAULT_PER_PAGE = 25
|
|
36
|
+
|
|
37
|
+
def initialize(scope:, page: 1, per_page: DEFAULT_PER_PAGE)
|
|
38
|
+
@scope = scope
|
|
39
|
+
@page = normalize_page(page)
|
|
40
|
+
@per_page = normalize_per_page(per_page)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def paginate
|
|
44
|
+
paginated_records = fetch_records
|
|
45
|
+
has_next_page = paginated_records.length > per_page
|
|
46
|
+
|
|
47
|
+
Result.new(
|
|
48
|
+
records: paginated_records.first(per_page),
|
|
49
|
+
page: page,
|
|
50
|
+
per_page: per_page,
|
|
51
|
+
has_next_page: has_next_page,
|
|
52
|
+
has_previous_page: page > 1
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :scope, :page, :per_page
|
|
59
|
+
|
|
60
|
+
def fetch_records
|
|
61
|
+
offset = (page - 1) * per_page
|
|
62
|
+
|
|
63
|
+
relation = scope.is_a?(ActiveRecord::Relation) ? scope : Array(scope)
|
|
64
|
+
|
|
65
|
+
if relation.is_a?(Array)
|
|
66
|
+
relation.slice(offset, per_page + 1) || []
|
|
67
|
+
else
|
|
68
|
+
relation.offset(offset).limit(per_page + 1).to_a
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_page(value)
|
|
73
|
+
number = value.to_i
|
|
74
|
+
number = 1 if number <= 0
|
|
75
|
+
number
|
|
76
|
+
rescue StandardError
|
|
77
|
+
1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_per_page(value)
|
|
81
|
+
number = value.to_i
|
|
82
|
+
return DEFAULT_PER_PAGE if number <= 0
|
|
83
|
+
|
|
84
|
+
[ number, 100 ].min
|
|
85
|
+
rescue StandardError
|
|
86
|
+
DEFAULT_PER_PAGE
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
4
|
+
require "active_support/core_ext/hash/keys"
|
|
5
|
+
|
|
6
|
+
module SourceMonitor
|
|
7
|
+
module Realtime
|
|
8
|
+
module Adapter
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
FALLBACK_ADAPTERS = %i[async test].freeze
|
|
12
|
+
|
|
13
|
+
def configure!
|
|
14
|
+
return unless action_cable_available?
|
|
15
|
+
|
|
16
|
+
desired_adapter = SourceMonitor.config.realtime.adapter
|
|
17
|
+
return unless desired_adapter
|
|
18
|
+
|
|
19
|
+
ensure_dependency!(desired_adapter)
|
|
20
|
+
|
|
21
|
+
existing_config = current_config
|
|
22
|
+
existing_adapter = extract_adapter(existing_config)
|
|
23
|
+
|
|
24
|
+
if should_replace_adapter?(existing_adapter, desired_adapter)
|
|
25
|
+
apply_configuration(SourceMonitor.config.realtime.action_cable_config)
|
|
26
|
+
elsif same_adapter?(existing_adapter, desired_adapter)
|
|
27
|
+
merge_defaults(existing_config, SourceMonitor.config.realtime.action_cable_config)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def action_cable_available?
|
|
34
|
+
defined?(ActionCable) && ActionCable.respond_to?(:server)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def current_config
|
|
38
|
+
ActionCable.server.config.cable || {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_adapter(config)
|
|
42
|
+
adapter = config.is_a?(Hash) ? config[:adapter] || config["adapter"] : nil
|
|
43
|
+
adapter&.to_sym
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def should_replace_adapter?(existing_adapter, desired_adapter)
|
|
47
|
+
return true if existing_adapter.nil?
|
|
48
|
+
return true if FALLBACK_ADAPTERS.include?(existing_adapter)
|
|
49
|
+
return true if desired_adapter && existing_adapter != desired_adapter
|
|
50
|
+
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def same_adapter?(existing_adapter, desired_adapter)
|
|
55
|
+
existing_adapter && desired_adapter && existing_adapter == desired_adapter
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def apply_configuration(raw_config)
|
|
59
|
+
ActionCable.server.config.cable = normalize_config(raw_config)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def merge_defaults(existing_config, raw_config)
|
|
63
|
+
defaults = normalize_config(raw_config)
|
|
64
|
+
existing = normalize_config(existing_config)
|
|
65
|
+
|
|
66
|
+
merged = defaults.merge(existing)
|
|
67
|
+
ActionCable.server.config.cable = merged
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalize_config(config)
|
|
71
|
+
hash = config.to_h.deep_symbolize_keys
|
|
72
|
+
hash[:adapter] = hash[:adapter].to_s if hash.key?(:adapter)
|
|
73
|
+
hash.with_indifferent_access
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ensure_dependency!(adapter)
|
|
77
|
+
case adapter.to_sym
|
|
78
|
+
when :solid_cable
|
|
79
|
+
require "solid_cable"
|
|
80
|
+
when :redis
|
|
81
|
+
require "redis"
|
|
82
|
+
end
|
|
83
|
+
rescue LoadError => error
|
|
84
|
+
raise_missing_dependency_error(adapter, error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def raise_missing_dependency_error(adapter, error)
|
|
88
|
+
message = <<~ERROR.squish
|
|
89
|
+
SourceMonitor realtime adapter #{adapter.inspect} could not be loaded: #{error.class}: #{error.message}.
|
|
90
|
+
Ensure the corresponding gem is available or configure `SourceMonitor.config.realtime.adapter` with a supported backend.
|
|
91
|
+
ERROR
|
|
92
|
+
|
|
93
|
+
raise LoadError, message
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
|
|
5
|
+
module SourceMonitor
|
|
6
|
+
module Realtime
|
|
7
|
+
module Broadcaster
|
|
8
|
+
extend self
|
|
9
|
+
extend ActionView::RecordIdentifier
|
|
10
|
+
|
|
11
|
+
SOURCE_INDEX_STREAM = "source_monitor_sources"
|
|
12
|
+
NOTIFICATION_STREAM = "source_monitor_notifications"
|
|
13
|
+
|
|
14
|
+
def setup!
|
|
15
|
+
return unless turbo_available?
|
|
16
|
+
return if @setup
|
|
17
|
+
|
|
18
|
+
register_callback(:after_fetch_completed, fetch_callback)
|
|
19
|
+
register_callback(:after_item_scraped, item_callback)
|
|
20
|
+
|
|
21
|
+
@setup = true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_callback
|
|
25
|
+
@fetch_callback ||= lambda { |event| handle_fetch_completed(event) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def item_callback
|
|
29
|
+
@item_callback ||= lambda { |event| handle_item_scraped(event) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def broadcast_source(source)
|
|
33
|
+
return unless turbo_available?
|
|
34
|
+
source = reload_record(source)
|
|
35
|
+
return unless source
|
|
36
|
+
|
|
37
|
+
broadcast_source_row(source)
|
|
38
|
+
broadcast_source_show(source)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def broadcast_item(item)
|
|
42
|
+
return unless turbo_available?
|
|
43
|
+
item = reload_record(item)
|
|
44
|
+
return unless item
|
|
45
|
+
|
|
46
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
47
|
+
item,
|
|
48
|
+
:details,
|
|
49
|
+
target: dom_id(item, :details),
|
|
50
|
+
html: render_html = SourceMonitor::ItemsController.render(
|
|
51
|
+
partial: "source_monitor/items/details_wrapper",
|
|
52
|
+
locals: { item: item }
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
log_info(
|
|
56
|
+
"broadcast_item",
|
|
57
|
+
item_id: item.id,
|
|
58
|
+
stream: item_stream_identifier(item),
|
|
59
|
+
status: item.scrape_status,
|
|
60
|
+
contains_scraped_label: render_html.include?("Scraped")
|
|
61
|
+
)
|
|
62
|
+
rescue StandardError => error
|
|
63
|
+
log_error("item broadcast", error)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def broadcast_toast(message:, level: :info, title: nil, delay_ms: 5000)
|
|
67
|
+
return unless turbo_available?
|
|
68
|
+
return if message.blank?
|
|
69
|
+
|
|
70
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
71
|
+
NOTIFICATION_STREAM,
|
|
72
|
+
target: NOTIFICATION_STREAM,
|
|
73
|
+
html: SourceMonitor::ApplicationController.render(
|
|
74
|
+
partial: "source_monitor/shared/toast",
|
|
75
|
+
locals: {
|
|
76
|
+
message: message,
|
|
77
|
+
level: level,
|
|
78
|
+
title: title,
|
|
79
|
+
delay_ms: delay_ms
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
rescue StandardError => error
|
|
84
|
+
log_error("toast broadcast", error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def handle_fetch_completed(event)
|
|
90
|
+
source = event&.source
|
|
91
|
+
return unless source
|
|
92
|
+
|
|
93
|
+
broadcast_source(source)
|
|
94
|
+
broadcast_fetch_toast(event)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_item_scraped(event)
|
|
98
|
+
item = event&.item
|
|
99
|
+
return unless item
|
|
100
|
+
|
|
101
|
+
broadcast_item(item)
|
|
102
|
+
broadcast_source(event&.source || item.source)
|
|
103
|
+
broadcast_item_toast(event)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def broadcast_fetch_toast(event)
|
|
107
|
+
return unless event
|
|
108
|
+
source = event.source
|
|
109
|
+
status = event.status.to_s
|
|
110
|
+
|
|
111
|
+
case status
|
|
112
|
+
when "fetched"
|
|
113
|
+
processing = event.result&.item_processing
|
|
114
|
+
created = processing&.created.to_i
|
|
115
|
+
updated = processing&.updated.to_i
|
|
116
|
+
broadcast_toast(
|
|
117
|
+
message: "Fetched #{source.name} (#{created} created, #{updated} updated).",
|
|
118
|
+
level: :success
|
|
119
|
+
)
|
|
120
|
+
when "not_modified"
|
|
121
|
+
broadcast_toast(
|
|
122
|
+
message: "#{source.name} is up to date.",
|
|
123
|
+
level: :info
|
|
124
|
+
)
|
|
125
|
+
when "failed"
|
|
126
|
+
error_message = event.result&.error&.message ||
|
|
127
|
+
source.last_error ||
|
|
128
|
+
"Fetch failed"
|
|
129
|
+
broadcast_toast(
|
|
130
|
+
message: "Fetch failed for #{source.name}: #{error_message}",
|
|
131
|
+
level: :error,
|
|
132
|
+
delay_ms: 6000
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def broadcast_item_toast(event)
|
|
138
|
+
return unless event
|
|
139
|
+
item = event.item
|
|
140
|
+
source = event.source
|
|
141
|
+
title = item&.title.presence || "Feed item"
|
|
142
|
+
|
|
143
|
+
if event.status.to_s == "failed"
|
|
144
|
+
message = "Scrape failed for #{title}"
|
|
145
|
+
message += " (#{source.name})" if source
|
|
146
|
+
broadcast_toast(message:, level: :error, delay_ms: 6000)
|
|
147
|
+
else
|
|
148
|
+
message = "Scrape completed for #{title}"
|
|
149
|
+
message += " (#{source.name})" if source
|
|
150
|
+
broadcast_toast(message:, level: :success)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def broadcast_source_row(source)
|
|
155
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
156
|
+
SOURCE_INDEX_STREAM,
|
|
157
|
+
target: dom_id(source, :row),
|
|
158
|
+
html: SourceMonitor::SourcesController.render(
|
|
159
|
+
partial: "source_monitor/sources/row",
|
|
160
|
+
locals: {
|
|
161
|
+
source: source,
|
|
162
|
+
activity_rate: SourceMonitor::Analytics::SourceActivityRates.rate_for(source)
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
rescue StandardError => error
|
|
167
|
+
log_error("source row broadcast", error)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def broadcast_source_show(source)
|
|
171
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
172
|
+
source,
|
|
173
|
+
:details,
|
|
174
|
+
target: dom_id(source, :details),
|
|
175
|
+
html: SourceMonitor::SourcesController.render(
|
|
176
|
+
partial: "source_monitor/sources/details_wrapper",
|
|
177
|
+
locals: { source: source }
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
log_info(
|
|
181
|
+
"broadcast_source_show",
|
|
182
|
+
source_id: source.id,
|
|
183
|
+
stream: source_stream_identifier(source),
|
|
184
|
+
status: source.fetch_status
|
|
185
|
+
)
|
|
186
|
+
rescue StandardError => error
|
|
187
|
+
log_error("source show broadcast", error)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reload_record(record)
|
|
191
|
+
return unless record
|
|
192
|
+
|
|
193
|
+
record.reload
|
|
194
|
+
rescue StandardError
|
|
195
|
+
record
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def log_info(stage, **extra)
|
|
199
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
200
|
+
|
|
201
|
+
payload = {
|
|
202
|
+
stage: "SourceMonitor::Realtime::Broadcaster##{stage}"
|
|
203
|
+
}.merge(extra.compact)
|
|
204
|
+
Rails.logger.info("[SourceMonitor::ManualScrape] #{payload.to_json}")
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def item_stream_identifier(item)
|
|
210
|
+
Turbo::StreamsChannel.signed_stream_name([ item, :details ]) rescue nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def source_stream_identifier(source)
|
|
214
|
+
Turbo::StreamsChannel.signed_stream_name([ source, :details ]) rescue nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def turbo_available?
|
|
218
|
+
defined?(Turbo::StreamsChannel)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def register_callback(name, callback)
|
|
222
|
+
callbacks = SourceMonitor.config.events.callbacks_for(name)
|
|
223
|
+
return if callbacks.include?(callback)
|
|
224
|
+
|
|
225
|
+
SourceMonitor.config.events.public_send(name, callback)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def log_error(context, error)
|
|
229
|
+
Rails.logger.error(
|
|
230
|
+
"[SourceMonitor] Realtime #{context} failed: #{error.class}: #{error.message}"
|
|
231
|
+
) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
232
|
+
rescue StandardError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "source_monitor/realtime/adapter"
|
|
4
|
+
require "source_monitor/realtime/broadcaster"
|
|
5
|
+
|
|
6
|
+
module SourceMonitor
|
|
7
|
+
module Realtime
|
|
8
|
+
class << self
|
|
9
|
+
def setup!
|
|
10
|
+
SourceMonitor::Realtime::Adapter.configure!
|
|
11
|
+
SourceMonitor::Realtime::Broadcaster.setup!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
delegate :broadcast_source, :broadcast_item, :broadcast_toast, to: SourceMonitor::Realtime::Broadcaster
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|