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
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu"];
5
+ static values = {
6
+ transitionModule: { type: String, default: "stimulus-use" },
7
+ hiddenClass: { type: String, default: "hidden" }
8
+ };
9
+
10
+ connect() {
11
+ this.element.dataset.dropdownState = "initializing";
12
+ this.transitionEnabled = false;
13
+ if (typeof this.toggleTransition !== "function") {
14
+ this.toggleTransition = this.toggleVisibility.bind(this);
15
+ }
16
+
17
+ if (typeof this.leave !== "function") {
18
+ this.leave = this.hideMenu.bind(this);
19
+ }
20
+
21
+ this.loadTransitions()
22
+ .catch(() => null)
23
+ .finally(() => {
24
+ this.element.dataset.dropdownState = "ready";
25
+ });
26
+ }
27
+
28
+ disconnect() {
29
+ delete this.element.dataset.dropdownState;
30
+ }
31
+
32
+ // Dynamic import provides progressive enhancement: smooth transitions when stimulus-use
33
+ // is available, graceful fallback to CSS class toggling when not. This complexity is
34
+ // justified as it allows the engine to work without requiring stimulus-use as a dependency.
35
+ // Evaluated for simplification in Phase 20.05.07 - Decision: Keep current implementation.
36
+ async loadTransitions() {
37
+ if (!this.hasMenuTarget || this.transitionModuleValue === "") {
38
+ this.logFallback();
39
+ return;
40
+ }
41
+
42
+ try {
43
+ const module = await import(this.transitionModuleValue);
44
+ const useTransition = module?.useTransition || module?.default?.useTransition;
45
+
46
+ if (typeof useTransition === "function") {
47
+ useTransition(this, {
48
+ element: this.menuTarget,
49
+ hiddenClass: this.hiddenClassValue
50
+ });
51
+ this.transitionEnabled = true;
52
+ } else {
53
+ this.logFallback();
54
+ }
55
+ } catch (error) {
56
+ this.logFallback(error);
57
+ }
58
+ }
59
+
60
+ toggle(event) {
61
+ if (this.transitionEnabled && typeof this.toggleTransition === "function") {
62
+ this.toggleTransition();
63
+ } else {
64
+ this.toggleVisibility();
65
+ }
66
+ }
67
+
68
+ hide(event) {
69
+ if (!this.hasMenuTarget) return;
70
+ if (event && this.element.contains(event.target)) return;
71
+
72
+ if (this.transitionEnabled && typeof this.leave === "function") {
73
+ this.leave();
74
+ } else {
75
+ this.hideMenu();
76
+ }
77
+ }
78
+
79
+ toggleVisibility() {
80
+ this.isOpen() ? this.hideMenu() : this.showMenu();
81
+ }
82
+
83
+ showMenu() {
84
+ if (!this.hasMenuTarget) return;
85
+ this.menuTarget.classList.remove(this.hiddenClassValue);
86
+ }
87
+
88
+ hideMenu() {
89
+ if (!this.hasMenuTarget) return;
90
+ this.menuTarget.classList.add(this.hiddenClassValue);
91
+ }
92
+
93
+ isOpen() {
94
+ return this.hasMenuTarget && !this.menuTarget.classList.contains(this.hiddenClassValue);
95
+ }
96
+
97
+ logFallback(error = null) {
98
+ this.transitionEnabled = false;
99
+ if (!this._fallbackLogged && typeof window !== "undefined" && window.console) {
100
+ window.console.warn(
101
+ "SourceMonitor dropdown transitions unavailable; using CSS class toggling instead."
102
+ );
103
+ if (error && typeof window.console.debug === "function") {
104
+ window.console.debug(error);
105
+ }
106
+ }
107
+ this._fallbackLogged = true;
108
+ }
109
+ }
@@ -0,0 +1,56 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"];
5
+ static classes = ["open"];
6
+
7
+ connect() {
8
+ this.handleEscape = this.handleEscape.bind(this);
9
+ }
10
+
11
+ disconnect() {
12
+ this.teardown();
13
+ }
14
+
15
+ open(event) {
16
+ if (event) event.preventDefault();
17
+ if (!this.hasPanelTarget) return;
18
+
19
+ this.panelTarget.classList.remove("hidden");
20
+ if (this.hasOpenClass) {
21
+ this.panelTarget.classList.add(this.openClass);
22
+ }
23
+
24
+ document.body.classList.add("overflow-hidden");
25
+ document.addEventListener("keydown", this.handleEscape);
26
+ }
27
+
28
+ close(event) {
29
+ if (event) event.preventDefault();
30
+ if (!this.hasPanelTarget) return;
31
+
32
+ this.panelTarget.classList.add("hidden");
33
+ if (this.hasOpenClass) {
34
+ this.panelTarget.classList.remove(this.openClass);
35
+ }
36
+
37
+ this.teardown();
38
+ }
39
+
40
+ backdrop(event) {
41
+ if (event.target === event.currentTarget) {
42
+ this.close(event);
43
+ }
44
+ }
45
+
46
+ handleEscape(event) {
47
+ if (event.key === "Escape") {
48
+ this.close(event);
49
+ }
50
+ }
51
+
52
+ teardown() {
53
+ document.body.classList.remove("overflow-hidden");
54
+ document.removeEventListener("keydown", this.handleEscape);
55
+ }
56
+ }
@@ -0,0 +1,53 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ delay: { default: 5000, type: Number }
6
+ };
7
+
8
+ connect() {
9
+ if (!window.SourceMonitorControllers) {
10
+ window.SourceMonitorControllers = {};
11
+ }
12
+
13
+ this.clearTimeout();
14
+ this.registerController();
15
+ this.startTimer();
16
+ }
17
+
18
+ disconnect() {
19
+ this.clearTimeout();
20
+ }
21
+
22
+ hide(event) {
23
+ if (event) event.preventDefault();
24
+ this.clearTimeout();
25
+ this.dismiss();
26
+ }
27
+
28
+ registerController() {
29
+ window.SourceMonitorControllers.notification = this;
30
+ }
31
+
32
+ startTimer() {
33
+ if (this.delayValue <= 0) return;
34
+ this.timeoutId = window.setTimeout(() => this.dismiss(), this.delayValue);
35
+ }
36
+
37
+ dismiss() {
38
+ if (!this.element) return;
39
+ this.element.classList.add("opacity-0", "translate-y-2");
40
+ window.setTimeout(() => {
41
+ if (this.element && this.element.remove) {
42
+ this.element.remove();
43
+ }
44
+ }, 200);
45
+ }
46
+
47
+ clearTimeout() {
48
+ if (!this.timeoutId) return;
49
+
50
+ window.clearTimeout(this.timeoutId);
51
+ this.timeoutId = null;
52
+ }
53
+ }
@@ -0,0 +1,13 @@
1
+ // Custom Turbo Stream action for client-side redirects
2
+ // Usage: responder.redirect(url, action: "advance")
3
+ // Note: Turbo is available globally via turbo_include_tags in the layout
4
+ if (window.Turbo && window.Turbo.StreamActions) {
5
+ window.Turbo.StreamActions.redirect = function () {
6
+ const url = this.getAttribute("url");
7
+ const visitAction = this.getAttribute("visit-action") || "advance";
8
+
9
+ if (url) {
10
+ window.Turbo.visit(url, { action: visitAction });
11
+ }
12
+ };
13
+ }
@@ -0,0 +1,13 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ .fm-admin {
7
+ @apply min-h-screen bg-slate-50 text-slate-900;
8
+ }
9
+
10
+ .fm-admin a {
11
+ @apply text-blue-600 hover:text-blue-500;
12
+ }
13
+ }
File without changes
File without changes
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module SanitizesSearchParams
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :_search_scope, instance_writer: false
9
+ class_attribute :_default_search_sorts, instance_writer: false, default: [ "created_at desc" ]
10
+ end
11
+
12
+ class_methods do
13
+ def searchable_with(scope:, default_sorts: [ "created_at desc" ])
14
+ self._search_scope = scope
15
+ self._default_search_sorts = default_sorts
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def build_search_query(scope = nil, params: sanitized_search_params)
22
+ base_scope = scope || search_scope
23
+ query = base_scope.ransack(params)
24
+ query.sorts = default_search_sorts if query.sorts.blank?
25
+ query
26
+ end
27
+
28
+ def search_scope
29
+ if _search_scope.respond_to?(:call)
30
+ instance_exec(&_search_scope)
31
+ else
32
+ _search_scope
33
+ end
34
+ end
35
+
36
+ def default_search_sorts
37
+ _default_search_sorts
38
+ end
39
+
40
+ def sanitized_search_params
41
+ raw = params[:q]
42
+ return {} unless raw
43
+
44
+ hash =
45
+ if raw.respond_to?(:to_unsafe_h)
46
+ raw.to_unsafe_h
47
+ elsif raw.respond_to?(:to_h)
48
+ raw.to_h
49
+ elsif raw.is_a?(Hash)
50
+ raw
51
+ else
52
+ {}
53
+ end
54
+
55
+ sanitized = SourceMonitor::Security::ParameterSanitizer.sanitize(hash)
56
+
57
+ sanitized_params = sanitized.each_with_object({}) do |(key, value), memo|
58
+ next if value.nil?
59
+
60
+ cleaned_value = value.is_a?(String) ? value.strip : value
61
+ next if cleaned_value.respond_to?(:blank?) ? cleaned_value.blank? : cleaned_value.nil?
62
+
63
+ memo[key.to_s] = cleaned_value
64
+ end
65
+
66
+ assign_sanitized_params(sanitized_params)
67
+
68
+ sanitized_params
69
+ end
70
+
71
+ def assign_sanitized_params(sanitized_params)
72
+ return unless respond_to?(:params) && params
73
+
74
+ if params.is_a?(ActionController::Parameters)
75
+ params[:q] = ActionController::Parameters.new(sanitized_params)
76
+ else
77
+ params[:q] = sanitized_params
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,62 @@
1
+ module SourceMonitor
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception, prepend: true
4
+
5
+ before_action :authenticate_source_monitor_user
6
+ before_action :authorize_source_monitor_access
7
+
8
+ helper_method :source_monitor_current_user, :source_monitor_user_signed_in?
9
+ after_action :broadcast_flash_toasts
10
+
11
+ private
12
+
13
+ FLASH_LEVELS = {
14
+ notice: :success,
15
+ alert: :error,
16
+ error: :error,
17
+ success: :success,
18
+ warning: :warning
19
+ }.freeze
20
+
21
+ TOAST_DURATION_DEFAULT = 5000
22
+ TOAST_DURATION_ERROR = 6000
23
+
24
+ def authenticate_source_monitor_user
25
+ SourceMonitor::Security::Authentication.authenticate!(self)
26
+ end
27
+
28
+ def authorize_source_monitor_access
29
+ SourceMonitor::Security::Authentication.authorize!(self)
30
+ end
31
+
32
+ def source_monitor_current_user
33
+ SourceMonitor::Security::Authentication.current_user(self)
34
+ end
35
+
36
+ def source_monitor_user_signed_in?
37
+ SourceMonitor::Security::Authentication.user_signed_in?(self)
38
+ end
39
+
40
+ def toast_delay_for(level)
41
+ level.to_sym == :error ? TOAST_DURATION_ERROR : TOAST_DURATION_DEFAULT
42
+ end
43
+
44
+ def broadcast_flash_toasts
45
+ return if flash.empty?
46
+ return unless request.format.html? || request.format.turbo_stream?
47
+
48
+ flash.each do |key, message|
49
+ next if message.blank?
50
+
51
+ Array(message).each do |msg|
52
+ SourceMonitor::Realtime.broadcast_toast(
53
+ message: msg,
54
+ level: FLASH_LEVELS[key.to_sym] || :info
55
+ )
56
+ end
57
+ end
58
+ ensure
59
+ flash.discard
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class DashboardController < ApplicationController
5
+ def index
6
+ queries = SourceMonitor::Dashboard::Queries.new
7
+ url_helpers = SourceMonitor::Engine.routes.url_helpers
8
+
9
+ @stats = queries.stats
10
+ @recent_activity = SourceMonitor::Dashboard::RecentActivityPresenter.new(
11
+ queries.recent_activity,
12
+ url_helpers:
13
+ ).to_a
14
+ @quick_actions = SourceMonitor::Dashboard::QuickActionsPresenter.new(
15
+ queries.quick_actions,
16
+ url_helpers:
17
+ ).to_a
18
+ @job_adapter = SourceMonitor::Jobs::Visibility.adapter_name
19
+ @job_metrics = queries.job_metrics
20
+ fetch_schedule = queries.upcoming_fetch_schedule
21
+ @fetch_schedule_groups = fetch_schedule.groups
22
+ @fetch_schedule_reference_time = fetch_schedule.reference_time
23
+ @mission_control_enabled = SourceMonitor.mission_control_enabled?
24
+ @mission_control_dashboard_path = SourceMonitor.mission_control_dashboard_path
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class FetchLogsController < ApplicationController
5
+ def show
6
+ @log = FetchLog.includes(:source).find(params[:id])
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module SourceMonitor
2
+ class HealthController < ApplicationController
3
+ def show
4
+ render json: {
5
+ status: "ok",
6
+ metrics: SourceMonitor::Metrics.snapshot
7
+ }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class ItemsController < ApplicationController
5
+ include ActionView::RecordIdentifier
6
+ include SourceMonitor::SanitizesSearchParams
7
+
8
+ searchable_with scope: -> { Item.active.includes(:source) }, default_sorts: [ "published_at desc", "created_at desc" ]
9
+
10
+ PER_PAGE = 25
11
+ SEARCH_FIELD = :title_or_summary_or_url_or_source_name_cont
12
+
13
+ before_action :set_item, only: %i[show scrape]
14
+ before_action :load_scrape_context, only: :show
15
+
16
+ def index
17
+ @search_params = sanitized_search_params
18
+ @q = build_search_query
19
+
20
+ scope = @q.result(distinct: true)
21
+ paginator = SourceMonitor::Pagination::Paginator.new(
22
+ scope:,
23
+ page: params[:page],
24
+ per_page: PER_PAGE
25
+ ).paginate
26
+
27
+ @items = paginator.records
28
+ @page = paginator.page
29
+ @has_next_page = paginator.has_next_page
30
+ @has_previous_page = paginator.has_previous_page
31
+
32
+ @search_term = @search_params[SEARCH_FIELD.to_s].to_s.strip
33
+ @search_field = SEARCH_FIELD
34
+ end
35
+
36
+ def show
37
+ end
38
+
39
+ def scrape
40
+ log_manual_scrape("controller:start", item: @item, extra: { format: request.format })
41
+
42
+ enqueue_result = SourceMonitor::Scraping::Enqueuer.enqueue(item: @item, reason: :manual)
43
+ log_manual_scrape(
44
+ "controller:enqueue_result",
45
+ item: @item,
46
+ extra: { status: enqueue_result.status, message: enqueue_result.message }
47
+ )
48
+ flash_key, flash_message = scrape_flash_payload(enqueue_result)
49
+ status = enqueue_result.failure? ? :unprocessable_entity : :ok
50
+
51
+ respond_to do |format|
52
+ format.turbo_stream do
53
+ log_manual_scrape("controller:respond_turbo", item: @item, extra: { status: status })
54
+
55
+ responder = SourceMonitor::TurboStreams::StreamResponder.new
56
+
57
+ if enqueue_result.enqueued? || enqueue_result.already_enqueued?
58
+ @item.reload
59
+ responder.replace_details(
60
+ @item,
61
+ partial: "source_monitor/items/details_wrapper",
62
+ locals: { item: @item }
63
+ )
64
+ end
65
+
66
+ if flash_message
67
+ level = flash_key == :notice ? :info : :error
68
+ responder.toast(message: flash_message, level:, delay_ms: toast_delay_for(level))
69
+ end
70
+
71
+ render turbo_stream: responder.render(view_context), status: status
72
+ end
73
+
74
+ format.html do
75
+ log_manual_scrape("controller:respond_html", item: @item)
76
+ if flash_key && flash_message
77
+ redirect_to source_monitor.item_path(@item), flash: { flash_key => flash_message }
78
+ else
79
+ redirect_to source_monitor.item_path(@item)
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def set_item
88
+ @item = Item.active.includes(:source, :item_content).find(params[:id])
89
+ end
90
+
91
+ def load_scrape_context
92
+ @recent_scrape_logs = @item.scrape_logs.order(started_at: :desc).limit(5)
93
+ @latest_scrape_log = @recent_scrape_logs.first
94
+ end
95
+
96
+ def scrape_flash_payload(result)
97
+ case result.status
98
+ when :enqueued
99
+ [ :notice, "Scrape has been enqueued and will run shortly." ]
100
+ when :already_enqueued
101
+ [ :notice, result.message ]
102
+ else
103
+ [ :alert, result.message || "Unable to enqueue scrape for this item." ]
104
+ end
105
+ end
106
+
107
+ def log_manual_scrape(stage, item:, extra: {})
108
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
109
+
110
+ payload = { stage:, item_id: item&.id }.merge(extra.compact)
111
+ Rails.logger.info("[SourceMonitor::ManualScrape] #{payload.to_json}")
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class LogsController < ApplicationController
5
+ def index
6
+ @query_result = SourceMonitor::Logs::Query.new(params: params).call
7
+ @filter_set = @query_result.filter_set
8
+ @filter_params = @filter_set.to_params.symbolize_keys
9
+ @rows = SourceMonitor::Logs::TablePresenter.new(
10
+ entries: @query_result.entries,
11
+ url_helpers: SourceMonitor::Engine.routes.url_helpers
12
+ ).rows
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class ScrapeLogsController < ApplicationController
5
+ def show
6
+ @log = ScrapeLog.includes(:item, :source).find(params[:id])
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceBulkScrapesController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ ITEMS_PREVIEW_LIMIT = SourceMonitor::Scraping::BulkSourceScraper::DEFAULT_PREVIEW_LIMIT
8
+
9
+ before_action :set_source
10
+
11
+ def create
12
+ selection = bulk_scrape_params[:selection]
13
+ normalized_selection = SourceMonitor::Scraping::BulkSourceScraper.normalize_selection(selection) || :current
14
+ @bulk_scrape_selection = normalized_selection
15
+
16
+ result = SourceMonitor::Scraping::BulkSourceScraper.new(
17
+ source: @source,
18
+ selection: normalized_selection,
19
+ preview_limit: ITEMS_PREVIEW_LIMIT
20
+ ).call
21
+
22
+ respond_to_bulk_scrape(result)
23
+ end
24
+
25
+ private
26
+
27
+ def set_source
28
+ @source = Source.find(params[:source_id])
29
+ end
30
+
31
+ def bulk_scrape_params
32
+ params.fetch(:bulk_scrape, {}).permit(:selection)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceFetchesController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ before_action :set_source
8
+
9
+ def create
10
+ SourceMonitor::Fetching::FetchRunner.enqueue(@source.id)
11
+ render_fetch_enqueue_response("Fetch has been enqueued 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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class SourceHealthChecksController < ApplicationController
5
+ include SourceMonitor::SourceTurboResponses
6
+
7
+ before_action :set_source
8
+
9
+ def create
10
+ SourceMonitor::SourceHealthCheckJob.perform_later(@source.id)
11
+ render_fetch_enqueue_response(
12
+ "Health check enqueued",
13
+ health_status_override: processing_badge
14
+ )
15
+ rescue StandardError => error
16
+ handle_fetch_failure(error, prefix: "Health check")
17
+ end
18
+
19
+ private
20
+
21
+ def set_source
22
+ @source = Source.find(params[:source_id])
23
+ end
24
+
25
+ def processing_badge
26
+ {
27
+ label: "Processing",
28
+ classes: "bg-blue-100 text-blue-700",
29
+ show_spinner: true,
30
+ status: "processing"
31
+ }
32
+ end
33
+ end
34
+ end