source_monitor 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/rails-audit.md +77 -0
  3. data/CHANGELOG.md +50 -0
  4. data/CLAUDE.md +2 -2
  5. data/Gemfile.lock +7 -20
  6. data/RAILS_AUDIT.md +424 -0
  7. data/VERSION +1 -1
  8. data/app/assets/builds/source_monitor/application.css +4 -24
  9. data/app/assets/builds/source_monitor/application.js +57 -89
  10. data/app/assets/builds/source_monitor/application.js.map +4 -4
  11. data/app/assets/javascripts/source_monitor/application.js +3 -6
  12. data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +6 -86
  13. data/app/assets/javascripts/source_monitor/controllers/filter_submit_controller.js +13 -0
  14. data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
  15. data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +3 -13
  16. data/app/components/source_monitor/application_component.rb +10 -0
  17. data/app/components/source_monitor/filter_dropdown_component.rb +62 -0
  18. data/app/components/source_monitor/icon_component.rb +140 -0
  19. data/app/components/source_monitor/status_badge_component.html.erb +8 -0
  20. data/app/components/source_monitor/status_badge_component.rb +96 -0
  21. data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +4 -0
  22. data/app/controllers/concerns/source_monitor/set_source.rb +13 -0
  23. data/app/controllers/source_monitor/application_controller.rb +17 -0
  24. data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +6 -10
  25. data/app/controllers/source_monitor/dashboard_controller.rb +5 -1
  26. data/app/controllers/source_monitor/import_history_dismissals_controller.rb +1 -1
  27. data/app/controllers/source_monitor/import_sessions_controller.rb +30 -9
  28. data/app/controllers/source_monitor/item_scrapes_controller.rb +70 -0
  29. data/app/controllers/source_monitor/items_controller.rb +2 -69
  30. data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +1 -4
  31. data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +2 -12
  32. data/app/controllers/source_monitor/source_fetches_controller.rb +1 -6
  33. data/app/controllers/source_monitor/source_health_checks_controller.rb +9 -16
  34. data/app/controllers/source_monitor/source_health_resets_controller.rb +1 -6
  35. data/app/controllers/source_monitor/source_retries_controller.rb +1 -6
  36. data/app/controllers/source_monitor/source_scrape_tests_controller.rb +2 -4
  37. data/app/controllers/source_monitor/source_turbo_responses.rb +1 -3
  38. data/app/controllers/source_monitor/sources_controller.rb +15 -20
  39. data/app/helpers/source_monitor/application_helper.rb +15 -31
  40. data/app/helpers/source_monitor/health_badge_helper.rb +8 -0
  41. data/app/jobs/source_monitor/download_content_images_job.rb +1 -59
  42. data/app/jobs/source_monitor/favicon_fetch_job.rb +1 -58
  43. data/app/jobs/source_monitor/fetch_feed_job.rb +2 -52
  44. data/app/jobs/source_monitor/import_opml_job.rb +6 -145
  45. data/app/jobs/source_monitor/import_session_health_check_job.rb +15 -76
  46. data/app/jobs/source_monitor/item_cleanup_job.rb +5 -0
  47. data/app/jobs/source_monitor/log_cleanup_job.rb +13 -2
  48. data/app/jobs/source_monitor/schedule_fetches_job.rb +8 -0
  49. data/app/jobs/source_monitor/scrape_item_job.rb +6 -52
  50. data/app/jobs/source_monitor/source_health_check_job.rb +1 -72
  51. data/app/models/concerns/source_monitor/loggable.rb +12 -0
  52. data/app/models/source_monitor/fetch_log.rb +0 -8
  53. data/app/models/source_monitor/health_check_log.rb +0 -8
  54. data/app/models/source_monitor/import_history.rb +14 -0
  55. data/app/models/source_monitor/import_session.rb +2 -0
  56. data/app/models/source_monitor/item.rb +15 -0
  57. data/app/models/source_monitor/item_content.rb +4 -3
  58. data/app/models/source_monitor/scrape_log.rb +4 -6
  59. data/app/models/source_monitor/source.rb +28 -19
  60. data/app/presenters/source_monitor/base_presenter.rb +19 -0
  61. data/app/presenters/source_monitor/source_details_presenter.rb +61 -0
  62. data/app/presenters/source_monitor/sources_filter_presenter.rb +61 -0
  63. data/app/views/source_monitor/dashboard/_recent_activity.html.erb +3 -3
  64. data/app/views/source_monitor/dashboard/_stat_card.html.erb +2 -1
  65. data/app/views/source_monitor/dashboard/_stats.html.erb +5 -7
  66. data/app/views/source_monitor/items/_details.html.erb +11 -14
  67. data/app/views/source_monitor/items/index.html.erb +10 -35
  68. data/app/views/source_monitor/logs/index.html.erb +20 -41
  69. data/app/views/source_monitor/shared/_form_errors.html.erb +14 -0
  70. data/app/views/source_monitor/source_scrape_tests/_result.html.erb +1 -29
  71. data/app/views/source_monitor/source_scrape_tests/_result_content.html.erb +33 -0
  72. data/app/views/source_monitor/source_scrape_tests/show.html.erb +1 -29
  73. data/app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erb +2 -2
  74. data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +7 -5
  75. data/app/views/source_monitor/sources/_details.html.erb +24 -52
  76. data/app/views/source_monitor/sources/_health_status_badge.html.erb +4 -6
  77. data/app/views/source_monitor/sources/_row.html.erb +7 -18
  78. data/app/views/source_monitor/sources/edit.html.erb +1 -10
  79. data/app/views/source_monitor/sources/index.html.erb +26 -46
  80. data/app/views/source_monitor/sources/new.html.erb +1 -10
  81. data/config/routes.rb +1 -1
  82. data/db/migrate/20260313120000_add_composite_indexes_to_log_tables.rb +14 -0
  83. data/db/migrate/20260314120000_align_health_status_default.rb +11 -0
  84. data/lib/source_monitor/analytics/sources_index_metrics.rb +15 -0
  85. data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +10 -4
  86. data/lib/source_monitor/dashboard/turbo_broadcaster.rb +21 -5
  87. data/lib/source_monitor/favicons/fetcher.rb +86 -0
  88. data/lib/source_monitor/fetching/cloudflare_bypass.rb +14 -5
  89. data/lib/source_monitor/fetching/completion/event_publisher.rb +12 -0
  90. data/lib/source_monitor/fetching/completion/follow_up_handler.rb +15 -2
  91. data/lib/source_monitor/fetching/completion/retention_handler.rb +11 -3
  92. data/lib/source_monitor/fetching/feed_fetcher.rb +2 -21
  93. data/lib/source_monitor/fetching/fetch_runner.rb +12 -3
  94. data/lib/source_monitor/fetching/retry_orchestrator.rb +102 -0
  95. data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +9 -0
  96. data/lib/source_monitor/health/source_health_check_orchestrator.rb +95 -0
  97. data/lib/source_monitor/health.rb +1 -0
  98. data/lib/source_monitor/images/downloader.rb +6 -7
  99. data/lib/source_monitor/images/processor.rb +98 -0
  100. data/lib/source_monitor/import_sessions/health_check_updater.rb +95 -0
  101. data/lib/source_monitor/import_sessions/opml_importer.rb +163 -0
  102. data/lib/source_monitor/items/item_creator.rb +0 -21
  103. data/lib/source_monitor/logs/query.rb +20 -0
  104. data/lib/source_monitor/queries/scrape_candidates_query.rb +30 -0
  105. data/lib/source_monitor/queries.rb +7 -0
  106. data/lib/source_monitor/scheduler.rb +5 -0
  107. data/lib/source_monitor/scraping/bulk_result_presenter.rb +11 -8
  108. data/lib/source_monitor/scraping/runner.rb +52 -0
  109. data/lib/source_monitor/scraping/scheduler.rb +5 -0
  110. data/lib/source_monitor/scraping/state.rb +4 -2
  111. data/lib/source_monitor/security/parameter_sanitizer.rb +7 -0
  112. data/lib/source_monitor/version.rb +1 -1
  113. data/lib/source_monitor.rb +7 -0
  114. data/source_monitor.gemspec +1 -0
  115. metadata +47 -1
@@ -6,14 +6,10 @@ import DropdownController from "./controllers/dropdown_controller";
6
6
  import ModalController from "./controllers/modal_controller";
7
7
  import ConfirmNavigationController from "./controllers/confirm_navigation_controller";
8
8
  import SelectAllController from "./controllers/select_all_controller";
9
+ import FilterSubmitController from "./controllers/filter_submit_controller";
9
10
  import "./turbo_actions";
10
11
 
11
- const existingApplication = window.SourceMonitorStimulus;
12
- const application = existingApplication || Application.start();
13
-
14
- if (!existingApplication) {
15
- window.SourceMonitorStimulus = application;
16
- }
12
+ const application = Application.start();
17
13
 
18
14
  application.register("notification", NotificationController);
19
15
  application.register("notification-container", NotificationContainerController);
@@ -22,5 +18,6 @@ application.register("dropdown", DropdownController);
22
18
  application.register("modal", ModalController);
23
19
  application.register("confirm-navigation", ConfirmNavigationController);
24
20
  application.register("select-all", SelectAllController);
21
+ application.register("filter-submit", FilterSubmitController);
25
22
 
26
23
  export default application;
@@ -3,107 +3,27 @@ import { Controller } from "@hotwired/stimulus";
3
3
  export default class extends Controller {
4
4
  static targets = ["menu"];
5
5
  static values = {
6
- transitionModule: { type: String, default: "stimulus-use" },
7
6
  hiddenClass: { type: String, default: "hidden" }
8
7
  };
9
8
 
10
9
  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
- });
10
+ this._hideOnClickOutside = this.hide.bind(this);
11
+ document.addEventListener("click", this._hideOnClickOutside);
26
12
  }
27
13
 
28
14
  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
- }
15
+ document.removeEventListener("click", this._hideOnClickOutside);
58
16
  }
59
17
 
60
18
  toggle(event) {
61
- if (this.transitionEnabled && typeof this.toggleTransition === "function") {
62
- this.toggleTransition();
63
- } else {
64
- this.toggleVisibility();
65
- }
19
+ if (event) event.stopPropagation();
20
+ if (!this.hasMenuTarget) return;
21
+ this.menuTarget.classList.toggle(this.hiddenClassValue);
66
22
  }
67
23
 
68
24
  hide(event) {
69
25
  if (!this.hasMenuTarget) return;
70
26
  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
27
  this.menuTarget.classList.add(this.hiddenClassValue);
91
28
  }
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
29
  }
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Submits the parent form when a filter dropdown value changes.
4
+ // Replaces inline `onchange="this.form.requestSubmit()"` handlers.
5
+ //
6
+ // Usage:
7
+ // <select data-action="change->filter-submit#submit">
8
+ export default class extends Controller {
9
+ submit(event) {
10
+ const form = event.target.closest("form");
11
+ if (form) form.requestSubmit();
12
+ }
13
+ }
@@ -1,3 +1,4 @@
1
+ /* global Node, requestAnimationFrame */
1
2
  import { Controller } from "@hotwired/stimulus";
2
3
 
3
4
  export default class extends Controller {
@@ -7,6 +8,7 @@ export default class extends Controller {
7
8
 
8
9
  connect() {
9
10
  this.handleEscape = this.handleEscape.bind(this);
11
+ this._inertElements = [];
10
12
  if (this.autoOpenValue) {
11
13
  this.open();
12
14
  }
@@ -27,6 +29,12 @@ export default class extends Controller {
27
29
 
28
30
  document.body.classList.add("overflow-hidden");
29
31
  document.addEventListener("keydown", this.handleEscape);
32
+
33
+ // Focus trap: set inert on sibling elements so Tab stays inside the modal
34
+ this._setInert(true);
35
+
36
+ // Move focus to the first focusable element inside the modal
37
+ this._focusFirstElement();
30
38
  }
31
39
 
32
40
  close(event) {
@@ -38,6 +46,8 @@ export default class extends Controller {
38
46
  this.panelTarget.classList.remove(this.openClass);
39
47
  }
40
48
 
49
+ // Remove inert from background elements
50
+ this._setInert(false);
41
51
  this.teardown();
42
52
 
43
53
  if (this.removeOnCloseValue) {
@@ -61,4 +71,50 @@ export default class extends Controller {
61
71
  document.body.classList.remove("overflow-hidden");
62
72
  document.removeEventListener("keydown", this.handleEscape);
63
73
  }
74
+
75
+ // -- Private helpers --
76
+
77
+ _setInert(inert) {
78
+ if (inert) {
79
+ // Find the modal panel's topmost parent that is a direct child of body,
80
+ // then mark all its siblings as inert
81
+ const modalRoot = this._findModalRoot();
82
+ if (!modalRoot) return;
83
+
84
+ this._inertElements = [];
85
+ for (const sibling of document.body.children) {
86
+ if (sibling === modalRoot || sibling === this.element) continue;
87
+ if (sibling.nodeType !== Node.ELEMENT_NODE) continue;
88
+ if (!sibling.hasAttribute("inert")) {
89
+ sibling.setAttribute("inert", "");
90
+ this._inertElements.push(sibling);
91
+ }
92
+ }
93
+ } else {
94
+ for (const el of this._inertElements) {
95
+ el.removeAttribute("inert");
96
+ }
97
+ this._inertElements = [];
98
+ }
99
+ }
100
+
101
+ _findModalRoot() {
102
+ // Walk up from the panel target to find the element that is a direct child of body
103
+ let el = this.hasPanelTarget ? this.panelTarget : this.element;
104
+ while (el && el.parentElement !== document.body) {
105
+ el = el.parentElement;
106
+ }
107
+ return el;
108
+ }
109
+
110
+ _focusFirstElement() {
111
+ // Defer to next frame so the panel is visible
112
+ requestAnimationFrame(() => {
113
+ if (!this.hasPanelTarget) return;
114
+ const focusable = this.panelTarget.querySelector(
115
+ 'button, [href], input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
116
+ );
117
+ if (focusable) focusable.focus();
118
+ });
119
+ }
64
120
  }
@@ -6,12 +6,7 @@ export default class extends Controller {
6
6
  };
7
7
 
8
8
  connect() {
9
- if (!window.SourceMonitorControllers) {
10
- window.SourceMonitorControllers = {};
11
- }
12
-
13
9
  this.clearTimeout();
14
- this.registerController();
15
10
  this.applyLevelDelay();
16
11
  this.startTimer();
17
12
  }
@@ -26,20 +21,15 @@ export default class extends Controller {
26
21
  this.dismiss();
27
22
  }
28
23
 
29
- registerController() {
30
- window.SourceMonitorControllers.notification = this;
31
- }
32
-
33
24
  startTimer() {
34
25
  if (this.delayValue <= 0) return;
35
26
  this.timeoutId = window.setTimeout(() => this.dismiss(), this.delayValue);
36
27
  }
37
28
 
38
29
  applyLevelDelay() {
39
- const level = this.element.dataset.level;
40
- if (level === "error" && this.delayValue === 5000) {
41
- this.delayValue = 10000;
42
- }
30
+ // Error delay is set server-side via TOAST_DURATION_ERROR (6000ms) in
31
+ // ApplicationController and passed as data-notification-delay-value.
32
+ // No client-side override needed.
43
33
  }
44
34
 
45
35
  dismiss() {
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component"
4
+
5
+ module SourceMonitor
6
+ class ApplicationComponent < ViewComponent::Base
7
+ include ActionView::Helpers::TagHelper
8
+ include ActionView::Helpers::FormTagHelper
9
+ end
10
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ # Renders a labeled filter dropdown with auto-submit behavior.
5
+ # Used across sources, items, and logs index views to provide
6
+ # consistent filter select styling and behavior.
7
+ class FilterDropdownComponent < ApplicationComponent
8
+ SELECT_CLASSES = "rounded-md border border-slate-200 bg-white px-2 py-2 text-sm " \
9
+ "text-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
10
+ LABEL_CLASSES = "block text-xs font-medium text-slate-500 mb-1"
11
+
12
+ # @param label [String] visible label text for the dropdown
13
+ # @param param_name [Symbol, String] the form parameter name
14
+ # @param options [Array<Array>] array of [label, value] pairs for the select
15
+ # @param selected_value [String, nil] currently selected value
16
+ # @param form [ActionView::Helpers::FormBuilder, nil] optional form builder for Ransack integration
17
+ def initialize(label:, param_name:, options:, selected_value: nil, form: nil)
18
+ @label = label
19
+ @param_name = param_name
20
+ @options = options
21
+ @selected_value = selected_value.to_s
22
+ @form = form
23
+ end
24
+
25
+ def call
26
+ content_tag(:div) do
27
+ safe_join([ render_label, render_select ])
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def render_label
34
+ if @form
35
+ @form.label(@param_name, @label, class: LABEL_CLASSES)
36
+ else
37
+ label_tag(@param_name, @label, class: LABEL_CLASSES)
38
+ end
39
+ end
40
+
41
+ def render_select
42
+ stimulus_attrs = { action: "change->filter-submit#submit" }
43
+
44
+ if @form
45
+ @form.select(
46
+ @param_name,
47
+ options_for_select(@options, @selected_value),
48
+ {},
49
+ class: SELECT_CLASSES,
50
+ data: stimulus_attrs
51
+ )
52
+ else
53
+ select_tag(
54
+ @param_name,
55
+ options_for_select(@options, @selected_value),
56
+ class: SELECT_CLASSES,
57
+ data: stimulus_attrs
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ class IconComponent < ApplicationComponent
5
+ # SVG path data for each registered icon. Each entry is an array of hashes
6
+ # with :d (path data) and optional per-path attributes like :fill, :stroke, etc.
7
+ ICONS = {
8
+ menu_dots: {
9
+ view_box: "0 0 20 20",
10
+ fill: "none",
11
+ stroke: "currentColor",
12
+ stroke_width: "1.5",
13
+ paths: [
14
+ { d: "M10.343 3.94a.75.75 0 0 0-1.093-.332l-.822.548a2.25 2.25 0 0 1-2.287.014l-.856-.506a.75.75 0 0 0-1.087.63l.03.988a2.25 2.25 0 0 1-.639 1.668l-.715.715a.75.75 0 0 0 0 1.06l.715.715a2.25 2.25 0 0 1 .639 1.668l-.03.988a.75.75 0 0 0 1.087.63l.856-.506a2.25 2.25 0 0 1 2.287.014l.822.548a.75.75 0 0 0 1.093-.332l.38-.926a2.25 2.25 0 0 1 1.451-1.297l.964-.258a.75.75 0 0 0 .534-.72v-.946a.75.75 0 0 0-.534-.72l-.964-.258a2.25 2.25 0 0 1-1.45-1.297l-.381-.926Z",
15
+ stroke_linecap: "round", stroke_linejoin: "round" },
16
+ { d: "M12 10a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z",
17
+ stroke_linecap: "round", stroke_linejoin: "round" }
18
+ ]
19
+ },
20
+ refresh: {
21
+ view_box: "0 0 24 24",
22
+ fill: "none",
23
+ stroke: "currentColor",
24
+ stroke_width: "1.5",
25
+ paths: [
26
+ { d: "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99",
27
+ stroke_linecap: "round", stroke_linejoin: "round" }
28
+ ]
29
+ },
30
+ chevron_down: {
31
+ view_box: "0 0 20 20",
32
+ fill: "currentColor",
33
+ paths: [
34
+ { d: "M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06z",
35
+ fill_rule: "evenodd", clip_rule: "evenodd" }
36
+ ]
37
+ },
38
+ external_link: {
39
+ view_box: "0 0 24 24",
40
+ fill: "none",
41
+ stroke: "currentColor",
42
+ stroke_width: "2",
43
+ paths: [
44
+ { d: "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25",
45
+ stroke_linecap: "round", stroke_linejoin: "round" }
46
+ ]
47
+ },
48
+ spinner: {
49
+ view_box: "0 0 24 24",
50
+ fill: "none",
51
+ spinner: true,
52
+ elements: [
53
+ { type: :circle, class: "opacity-25", cx: "12", cy: "12", r: "10",
54
+ stroke: "currentColor", stroke_width: "4" },
55
+ { type: :path, class: "opacity-75", fill: "currentColor",
56
+ d: "M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" }
57
+ ]
58
+ }
59
+ }.freeze
60
+
61
+ SIZE_CLASSES = {
62
+ sm: "h-4 w-4",
63
+ md: "h-5 w-5",
64
+ lg: "h-6 w-6"
65
+ }.freeze
66
+
67
+ def initialize(name, size: :md, css_class: nil)
68
+ @name = name.to_sym
69
+ @size = size&.to_sym
70
+ @css_class = css_class
71
+ end
72
+
73
+ def call
74
+ icon = ICONS[@name]
75
+ return "".html_safe unless icon
76
+
77
+ size_cls = @size ? SIZE_CLASSES.fetch(@size, SIZE_CLASSES[:md]) : nil
78
+ classes = [ size_cls, @css_class ].compact.join(" ")
79
+
80
+ if icon[:spinner]
81
+ render_spinner(icon, classes)
82
+ else
83
+ render_standard(icon, classes)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def render_standard(icon, classes)
90
+ svg_attrs = {
91
+ class: classes,
92
+ xmlns: "http://www.w3.org/2000/svg",
93
+ viewBox: icon[:view_box],
94
+ fill: icon[:fill] || "none",
95
+ aria: { hidden: "true" }
96
+ }
97
+ svg_attrs[:stroke] = icon[:stroke] if icon[:stroke]
98
+ svg_attrs[:stroke_width] = icon[:stroke_width] if icon[:stroke_width]
99
+
100
+ tag.svg(**svg_attrs) do
101
+ safe_join(icon[:paths].map { |path_data| render_path(path_data) })
102
+ end
103
+ end
104
+
105
+ def render_path(path_data)
106
+ attrs = { d: path_data[:d] }
107
+ attrs[:stroke_linecap] = path_data[:stroke_linecap] if path_data[:stroke_linecap]
108
+ attrs[:stroke_linejoin] = path_data[:stroke_linejoin] if path_data[:stroke_linejoin]
109
+ attrs[:fill_rule] = path_data[:fill_rule] if path_data[:fill_rule]
110
+ attrs[:clip_rule] = path_data[:clip_rule] if path_data[:clip_rule]
111
+ attrs[:fill] = path_data[:fill] if path_data[:fill]
112
+ tag.path(**attrs)
113
+ end
114
+
115
+ def render_spinner(icon, classes)
116
+ tag.svg(
117
+ class: classes,
118
+ xmlns: "http://www.w3.org/2000/svg",
119
+ fill: "none",
120
+ viewBox: icon[:view_box],
121
+ aria: { hidden: "true" }
122
+ ) do
123
+ safe_join(icon[:elements].map { |el| render_element(el) })
124
+ end
125
+ end
126
+
127
+ def render_element(el)
128
+ case el[:type]
129
+ when :circle
130
+ tag.circle(
131
+ class: el[:class],
132
+ cx: el[:cx], cy: el[:cy], r: el[:r],
133
+ stroke: el[:stroke], stroke_width: el[:stroke_width]
134
+ )
135
+ when :path
136
+ tag.path(class: el[:class], fill: el[:fill], d: el[:d])
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,8 @@
1
+ <span class="<%= badge_classes %>"
2
+ data-testid="<%= data_attributes[:testid] %>"
3
+ data-status="<%= data_attributes[:status] %>">
4
+ <% if show_spinner? %>
5
+ <%= render SourceMonitor::IconComponent.new(:spinner, size: nil, css_class: "mr-1 #{spinner_size_class} animate-spin text-blue-500") %>
6
+ <% end %>
7
+ <%= display_label %>
8
+ </span>
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ # Renders a consistent status badge with color-coded styling and optional spinner.
5
+ # Replaces 12+ duplicated badge markup patterns across views.
6
+ #
7
+ # Usage:
8
+ # render StatusBadgeComponent.new(status: "working")
9
+ # render StatusBadgeComponent.new(status: :fetching, size: :sm)
10
+ # render StatusBadgeComponent.new(status: "success", label: "Completed")
11
+ class StatusBadgeComponent < ApplicationComponent
12
+ STATUS_STYLES = {
13
+ # Health statuses
14
+ "working" => { classes: "bg-green-100 text-green-700", label: "Working" },
15
+ "active" => { classes: "bg-green-100 text-green-700", label: "Active" },
16
+ "improving" => { classes: "bg-sky-100 text-sky-700", label: "Improving" },
17
+ # Success statuses
18
+ "success" => { classes: "bg-green-100 text-green-700", label: "Success" },
19
+ "completed" => { classes: "bg-green-100 text-green-700", label: "Completed" },
20
+ # Failure statuses
21
+ "failing" => { classes: "bg-rose-100 text-rose-700", label: "Failing" },
22
+ "failed" => { classes: "bg-rose-100 text-rose-700", label: "Failed" },
23
+ "error" => { classes: "bg-rose-100 text-rose-700", label: "Error" },
24
+ # Warning statuses
25
+ "declining" => { classes: "bg-yellow-100 text-yellow-700", label: "Declining" },
26
+ "warning" => { classes: "bg-amber-100 text-amber-700", label: "Warning" },
27
+ "partial" => { classes: "bg-amber-100 text-amber-700", label: "Partial" },
28
+ # Pending/queued statuses
29
+ "queued" => { classes: "bg-amber-100 text-amber-700", label: "Queued" },
30
+ "pending" => { classes: "bg-amber-100 text-amber-700", label: "Pending" },
31
+ # Processing statuses (show spinner)
32
+ "fetching" => { classes: "bg-blue-100 text-blue-700", label: "Processing", spinner: true },
33
+ "processing" => { classes: "bg-blue-100 text-blue-700", label: "Processing", spinner: true },
34
+ # Inactive statuses
35
+ "idle" => { classes: "bg-slate-100 text-slate-600", label: "Idle" },
36
+ "disabled" => { classes: "bg-slate-200 text-slate-600", label: "Disabled" },
37
+ "paused" => { classes: "bg-amber-100 text-amber-700", label: "Paused" },
38
+ "blocked" => { classes: "bg-rose-100 text-rose-700", label: "Blocked" }
39
+ }.freeze
40
+
41
+ SPINNER_STATUSES = %w[fetching processing].freeze
42
+
43
+ SIZE_CLASSES = {
44
+ sm: "px-2 py-0.5 text-[10px]",
45
+ md: "px-3 py-1 text-xs",
46
+ lg: "px-4 py-1.5 text-sm"
47
+ }.freeze
48
+
49
+ DEFAULT_CLASSES = "bg-slate-100 text-slate-600"
50
+
51
+ # @param status [String, Symbol] the status to display
52
+ # @param label [String, nil] override the default label for the status
53
+ # @param size [Symbol] :sm, :md, or :lg (default: :md)
54
+ # @param show_spinner [Boolean] whether to show spinner for processing statuses (default: true)
55
+ # @param data [Hash] additional data attributes for the badge element
56
+ def initialize(status:, label: nil, size: :md, show_spinner: true, data: {})
57
+ @status = status.to_s
58
+ @label = label
59
+ @size = size.to_sym
60
+ @show_spinner = show_spinner
61
+ @data = data
62
+ end
63
+
64
+ private
65
+
66
+ def style_config
67
+ STATUS_STYLES.fetch(@status) { { classes: DEFAULT_CLASSES, label: @status.humanize } }
68
+ end
69
+
70
+ def badge_classes
71
+ color_classes = style_config[:classes]
72
+ size_classes = SIZE_CLASSES.fetch(@size, SIZE_CLASSES[:md])
73
+ "inline-flex items-center rounded-full font-semibold #{size_classes} #{color_classes}"
74
+ end
75
+
76
+ def display_label
77
+ @label || style_config[:label]
78
+ end
79
+
80
+ def show_spinner?
81
+ @show_spinner && (style_config[:spinner] || SPINNER_STATUSES.include?(@status))
82
+ end
83
+
84
+ def spinner_size_class
85
+ case @size
86
+ when :sm then "h-3 w-3"
87
+ when :lg then "h-4.5 w-4.5"
88
+ else "h-3.5 w-3.5"
89
+ end
90
+ end
91
+
92
+ def data_attributes
93
+ { testid: "status-badge", status: @status }.merge(@data)
94
+ end
95
+ end
96
+ end
@@ -41,6 +41,10 @@ module SourceMonitor
41
41
  raw = params[:q]
42
42
  return {} unless raw
43
43
 
44
+ # Ransack requires a plain Hash, not ActionController::Parameters. Using
45
+ # to_unsafe_h here is safe because the values are immediately passed through
46
+ # ParameterSanitizer.sanitize which applies an explicit allowlist, then
47
+ # stripped and blank-filtered below before reaching Ransack.
44
48
  hash =
45
49
  if raw.respond_to?(:to_unsafe_h)
46
50
  raw.to_unsafe_h
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SourceMonitor
4
+ module SetSource
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def set_source
10
+ @source = Source.find(params[:source_id])
11
+ end
12
+ end
13
+ end
@@ -10,8 +10,23 @@ module SourceMonitor
10
10
  helper_method :source_monitor_current_user, :source_monitor_user_signed_in?
11
11
  after_action :broadcast_flash_toasts
12
12
 
13
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
14
+
13
15
  private
14
16
 
17
+ def record_not_found
18
+ respond_to do |format|
19
+ format.html { render plain: "Record not found", status: :not_found }
20
+ format.turbo_stream do
21
+ render turbo_stream: turbo_stream.append("flash",
22
+ partial: "source_monitor/shared/toast",
23
+ locals: { message: "Record not found", level: :error }),
24
+ status: :not_found
25
+ end
26
+ format.json { render json: { error: "Record not found" }, status: :not_found }
27
+ end
28
+ end
29
+
15
30
  FLASH_LEVELS = {
16
31
  notice: :success,
17
32
  alert: :error,
@@ -20,6 +35,8 @@ module SourceMonitor
20
35
  warning: :warning
21
36
  }.freeze
22
37
 
38
+ # Toast display durations in milliseconds. These values are passed to the
39
+ # Stimulus notification_controller via data-notification-delay-value.
23
40
  TOAST_DURATION_DEFAULT = 5000
24
41
  TOAST_DURATION_ERROR = 6000
25
42