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.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +16 -0
  3. data/.rubocop.yml +12 -0
  4. data/.ruby-version +1 -0
  5. data/AGENTS.md +132 -0
  6. data/CHANGELOG.md +66 -0
  7. data/CONTRIBUTING.md +31 -0
  8. data/Gemfile +30 -0
  9. data/Gemfile.lock +411 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +108 -0
  12. data/Rakefile +8 -0
  13. data/app/assets/builds/.keep +0 -0
  14. data/app/assets/config/source_monitor_manifest.js +4 -0
  15. data/app/assets/images/source_monitor/.keep +0 -0
  16. data/app/assets/javascripts/source_monitor/application.js +20 -0
  17. data/app/assets/javascripts/source_monitor/controllers/async_submit_controller.js +36 -0
  18. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +109 -0
  19. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  20. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +53 -0
  21. data/app/assets/javascripts/source_monitor/turbo_actions.js +13 -0
  22. data/app/assets/stylesheets/source_monitor/application.tailwind.css +13 -0
  23. data/app/assets/svgs/source_monitor/.keep +0 -0
  24. data/app/controllers/concerns/.keep +0 -0
  25. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +81 -0
  26. data/app/controllers/source_monitor/application_controller.rb +62 -0
  27. data/app/controllers/source_monitor/dashboard_controller.rb +27 -0
  28. data/app/controllers/source_monitor/fetch_logs_controller.rb +9 -0
  29. data/app/controllers/source_monitor/health_controller.rb +10 -0
  30. data/app/controllers/source_monitor/items_controller.rb +116 -0
  31. data/app/controllers/source_monitor/logs_controller.rb +15 -0
  32. data/app/controllers/source_monitor/scrape_logs_controller.rb +9 -0
  33. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +35 -0
  34. data/app/controllers/source_monitor/source_fetches_controller.rb +22 -0
  35. data/app/controllers/source_monitor/source_health_checks_controller.rb +34 -0
  36. data/app/controllers/source_monitor/source_health_resets_controller.rb +27 -0
  37. data/app/controllers/source_monitor/source_retries_controller.rb +22 -0
  38. data/app/controllers/source_monitor/source_turbo_responses.rb +115 -0
  39. data/app/controllers/source_monitor/sources_controller.rb +179 -0
  40. data/app/helpers/source_monitor/application_helper.rb +327 -0
  41. data/app/jobs/source_monitor/application_job.rb +13 -0
  42. data/app/jobs/source_monitor/fetch_feed_job.rb +117 -0
  43. data/app/jobs/source_monitor/item_cleanup_job.rb +48 -0
  44. data/app/jobs/source_monitor/log_cleanup_job.rb +47 -0
  45. data/app/jobs/source_monitor/schedule_fetches_job.rb +29 -0
  46. data/app/jobs/source_monitor/scrape_item_job.rb +47 -0
  47. data/app/jobs/source_monitor/source_health_check_job.rb +77 -0
  48. data/app/mailers/source_monitor/application_mailer.rb +17 -0
  49. data/app/models/concerns/.keep +0 -0
  50. data/app/models/concerns/source_monitor/loggable.rb +18 -0
  51. data/app/models/source_monitor/application_record.rb +5 -0
  52. data/app/models/source_monitor/fetch_log.rb +31 -0
  53. data/app/models/source_monitor/health_check_log.rb +28 -0
  54. data/app/models/source_monitor/item.rb +102 -0
  55. data/app/models/source_monitor/item_content.rb +11 -0
  56. data/app/models/source_monitor/log_entry.rb +56 -0
  57. data/app/models/source_monitor/scrape_log.rb +31 -0
  58. data/app/models/source_monitor/source.rb +115 -0
  59. data/app/views/layouts/source_monitor/application.html.erb +54 -0
  60. data/app/views/source_monitor/dashboard/_fetch_schedule.html.erb +90 -0
  61. data/app/views/source_monitor/dashboard/_job_metrics.html.erb +82 -0
  62. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +39 -0
  63. data/app/views/source_monitor/dashboard/_stat_card.html.erb +6 -0
  64. data/app/views/source_monitor/dashboard/_stats.html.erb +9 -0
  65. data/app/views/source_monitor/dashboard/index.html.erb +48 -0
  66. data/app/views/source_monitor/fetch_logs/show.html.erb +90 -0
  67. data/app/views/source_monitor/items/_details.html.erb +234 -0
  68. data/app/views/source_monitor/items/_details_wrapper.html.erb +3 -0
  69. data/app/views/source_monitor/items/index.html.erb +147 -0
  70. data/app/views/source_monitor/items/show.html.erb +3 -0
  71. data/app/views/source_monitor/logs/index.html.erb +208 -0
  72. data/app/views/source_monitor/scrape_logs/show.html.erb +73 -0
  73. data/app/views/source_monitor/shared/_toast.html.erb +34 -0
  74. data/app/views/source_monitor/sources/_bulk_scrape_form.html.erb +64 -0
  75. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +53 -0
  76. data/app/views/source_monitor/sources/_details.html.erb +302 -0
  77. data/app/views/source_monitor/sources/_details_wrapper.html.erb +3 -0
  78. data/app/views/source_monitor/sources/_empty_state_row.html.erb +5 -0
  79. data/app/views/source_monitor/sources/_fetch_interval_heatmap.html.erb +46 -0
  80. data/app/views/source_monitor/sources/_form.html.erb +143 -0
  81. data/app/views/source_monitor/sources/_health_status_badge.html.erb +46 -0
  82. data/app/views/source_monitor/sources/_row.html.erb +102 -0
  83. data/app/views/source_monitor/sources/edit.html.erb +28 -0
  84. data/app/views/source_monitor/sources/index.html.erb +153 -0
  85. data/app/views/source_monitor/sources/new.html.erb +22 -0
  86. data/app/views/source_monitor/sources/show.html.erb +3 -0
  87. data/config/coverage_baseline.json +2010 -0
  88. data/config/initializers/feedjira.rb +19 -0
  89. data/config/routes.rb +18 -0
  90. data/config/tailwind.config.js +17 -0
  91. data/db/migrate/20241008120000_create_source_monitor_sources.rb +40 -0
  92. data/db/migrate/20241008121000_create_source_monitor_items.rb +44 -0
  93. data/db/migrate/20241008122000_create_source_monitor_fetch_logs.rb +32 -0
  94. data/db/migrate/20241008123000_create_source_monitor_scrape_logs.rb +25 -0
  95. data/db/migrate/20251008183000_change_fetch_interval_to_minutes.rb +23 -0
  96. data/db/migrate/20251009090000_create_source_monitor_item_contents.rb +38 -0
  97. data/db/migrate/20251009103000_add_feed_content_readability_to_sources.rb +5 -0
  98. data/db/migrate/20251010090000_add_adaptive_fetching_toggle_to_sources.rb +7 -0
  99. data/db/migrate/20251010123000_add_deleted_at_to_source_monitor_items.rb +8 -0
  100. data/db/migrate/20251010153000_add_type_to_source_monitor_sources.rb +8 -0
  101. data/db/migrate/20251010154500_add_fetch_status_to_source_monitor_sources.rb +9 -0
  102. data/db/migrate/20251010160000_create_solid_cable_messages.rb +16 -0
  103. data/db/migrate/20251011090000_add_fetch_retry_state_to_sources.rb +14 -0
  104. data/db/migrate/20251012090000_add_health_fields_to_sources.rb +17 -0
  105. data/db/migrate/20251012100000_optimize_source_monitor_database_performance.rb +13 -0
  106. data/db/migrate/20251014064947_add_not_null_constraints_to_items.rb +30 -0
  107. data/db/migrate/20251014171659_add_performance_indexes.rb +29 -0
  108. data/db/migrate/20251014172525_add_fetch_status_check_constraint.rb +18 -0
  109. data/db/migrate/20251015100000_create_source_monitor_log_entries.rb +89 -0
  110. data/db/migrate/20251022100000_create_source_monitor_health_check_logs.rb +22 -0
  111. data/db/migrate/20251108120116_refresh_fetch_status_constraint.rb +29 -0
  112. data/docs/configuration.md +170 -0
  113. data/docs/deployment.md +63 -0
  114. data/docs/gh-cli-workflow.md +44 -0
  115. data/docs/installation.md +144 -0
  116. data/docs/troubleshooting.md +76 -0
  117. data/eslint.config.mjs +27 -0
  118. data/lib/generators/source_monitor/install/install_generator.rb +59 -0
  119. data/lib/generators/source_monitor/install/templates/source_monitor.rb.tt +155 -0
  120. data/lib/source_monitor/analytics/source_activity_rates.rb +53 -0
  121. data/lib/source_monitor/analytics/source_fetch_interval_distribution.rb +57 -0
  122. data/lib/source_monitor/analytics/sources_index_metrics.rb +92 -0
  123. data/lib/source_monitor/assets/bundler.rb +49 -0
  124. data/lib/source_monitor/assets.rb +6 -0
  125. data/lib/source_monitor/configuration.rb +654 -0
  126. data/lib/source_monitor/dashboard/queries.rb +356 -0
  127. data/lib/source_monitor/dashboard/quick_action.rb +7 -0
  128. data/lib/source_monitor/dashboard/quick_actions_presenter.rb +26 -0
  129. data/lib/source_monitor/dashboard/recent_activity.rb +30 -0
  130. data/lib/source_monitor/dashboard/recent_activity_presenter.rb +77 -0
  131. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +87 -0
  132. data/lib/source_monitor/dashboard/upcoming_fetch_schedule.rb +126 -0
  133. data/lib/source_monitor/engine.rb +107 -0
  134. data/lib/source_monitor/events.rb +110 -0
  135. data/lib/source_monitor/feedjira_extensions.rb +103 -0
  136. data/lib/source_monitor/fetching/advisory_lock.rb +54 -0
  137. data/lib/source_monitor/fetching/completion/event_publisher.rb +22 -0
  138. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +37 -0
  139. data/lib/source_monitor/fetching/completion/retention_handler.rb +30 -0
  140. data/lib/source_monitor/fetching/feed_fetcher.rb +627 -0
  141. data/lib/source_monitor/fetching/fetch_error.rb +88 -0
  142. data/lib/source_monitor/fetching/fetch_runner.rb +142 -0
  143. data/lib/source_monitor/fetching/retry_policy.rb +85 -0
  144. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +146 -0
  145. data/lib/source_monitor/health/source_health_check.rb +100 -0
  146. data/lib/source_monitor/health/source_health_monitor.rb +210 -0
  147. data/lib/source_monitor/health/source_health_reset.rb +68 -0
  148. data/lib/source_monitor/health.rb +46 -0
  149. data/lib/source_monitor/http.rb +85 -0
  150. data/lib/source_monitor/instrumentation.rb +52 -0
  151. data/lib/source_monitor/items/item_creator.rb +601 -0
  152. data/lib/source_monitor/items/retention_pruner.rb +146 -0
  153. data/lib/source_monitor/items/retention_strategies/destroy.rb +26 -0
  154. data/lib/source_monitor/items/retention_strategies/soft_delete.rb +50 -0
  155. data/lib/source_monitor/items/retention_strategies.rb +9 -0
  156. data/lib/source_monitor/jobs/cleanup_options.rb +85 -0
  157. data/lib/source_monitor/jobs/fetch_failure_subscriber.rb +129 -0
  158. data/lib/source_monitor/jobs/solid_queue_metrics.rb +199 -0
  159. data/lib/source_monitor/jobs/visibility.rb +133 -0
  160. data/lib/source_monitor/logs/entry_sync.rb +69 -0
  161. data/lib/source_monitor/logs/filter_set.rb +163 -0
  162. data/lib/source_monitor/logs/query.rb +81 -0
  163. data/lib/source_monitor/logs/table_presenter.rb +161 -0
  164. data/lib/source_monitor/metrics.rb +77 -0
  165. data/lib/source_monitor/model_extensions.rb +109 -0
  166. data/lib/source_monitor/models/sanitizable.rb +76 -0
  167. data/lib/source_monitor/models/url_normalizable.rb +84 -0
  168. data/lib/source_monitor/pagination/paginator.rb +90 -0
  169. data/lib/source_monitor/realtime/adapter.rb +97 -0
  170. data/lib/source_monitor/realtime/broadcaster.rb +237 -0
  171. data/lib/source_monitor/realtime.rb +17 -0
  172. data/lib/source_monitor/release/changelog.rb +59 -0
  173. data/lib/source_monitor/release/runner.rb +73 -0
  174. data/lib/source_monitor/scheduler.rb +82 -0
  175. data/lib/source_monitor/scrapers/base.rb +105 -0
  176. data/lib/source_monitor/scrapers/fetchers/http_fetcher.rb +97 -0
  177. data/lib/source_monitor/scrapers/parsers/readability_parser.rb +101 -0
  178. data/lib/source_monitor/scrapers/readability.rb +156 -0
  179. data/lib/source_monitor/scraping/bulk_result_presenter.rb +85 -0
  180. data/lib/source_monitor/scraping/bulk_source_scraper.rb +233 -0
  181. data/lib/source_monitor/scraping/enqueuer.rb +125 -0
  182. data/lib/source_monitor/scraping/item_scraper/adapter_resolver.rb +44 -0
  183. data/lib/source_monitor/scraping/item_scraper/persistence.rb +189 -0
  184. data/lib/source_monitor/scraping/item_scraper.rb +84 -0
  185. data/lib/source_monitor/scraping/scheduler.rb +43 -0
  186. data/lib/source_monitor/scraping/state.rb +79 -0
  187. data/lib/source_monitor/security/authentication.rb +85 -0
  188. data/lib/source_monitor/security/parameter_sanitizer.rb +42 -0
  189. data/lib/source_monitor/sources/turbo_stream_presenter.rb +54 -0
  190. data/lib/source_monitor/turbo_streams/stream_responder.rb +95 -0
  191. data/lib/source_monitor/version.rb +3 -0
  192. data/lib/source_monitor.rb +149 -0
  193. data/lib/tasks/recover_stalled_fetches.rake +16 -0
  194. data/lib/tasks/source_monitor_assets.rake +28 -0
  195. data/lib/tasks/source_monitor_tasks.rake +29 -0
  196. data/lib/tasks/test_smoke.rake +12 -0
  197. data/package-lock.json +3997 -0
  198. data/package.json +29 -0
  199. data/postcss.config.js +6 -0
  200. data/source_monitor.gemspec +46 -0
  201. data/stylelint.config.js +12 -0
  202. 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