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.
- checksums.yaml +4 -4
- data/.claude/commands/rails-audit.md +77 -0
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +2 -2
- data/Gemfile.lock +7 -20
- data/RAILS_AUDIT.md +424 -0
- data/VERSION +1 -1
- data/app/assets/builds/source_monitor/application.css +4 -24
- data/app/assets/builds/source_monitor/application.js +57 -89
- data/app/assets/builds/source_monitor/application.js.map +4 -4
- data/app/assets/javascripts/source_monitor/application.js +3 -6
- data/app/assets/javascripts/source_monitor/controllers/dropdown_controller.js +6 -86
- data/app/assets/javascripts/source_monitor/controllers/filter_submit_controller.js +13 -0
- data/app/assets/javascripts/source_monitor/controllers/modal_controller.js +56 -0
- data/app/assets/javascripts/source_monitor/controllers/notification_controller.js +3 -13
- data/app/components/source_monitor/application_component.rb +10 -0
- data/app/components/source_monitor/filter_dropdown_component.rb +62 -0
- data/app/components/source_monitor/icon_component.rb +140 -0
- data/app/components/source_monitor/status_badge_component.html.erb +8 -0
- data/app/components/source_monitor/status_badge_component.rb +96 -0
- data/app/controllers/concerns/source_monitor/sanitizes_search_params.rb +4 -0
- data/app/controllers/concerns/source_monitor/set_source.rb +13 -0
- data/app/controllers/source_monitor/application_controller.rb +17 -0
- data/app/controllers/source_monitor/bulk_scrape_enablements_controller.rb +6 -10
- data/app/controllers/source_monitor/dashboard_controller.rb +5 -1
- data/app/controllers/source_monitor/import_history_dismissals_controller.rb +1 -1
- data/app/controllers/source_monitor/import_sessions_controller.rb +30 -9
- data/app/controllers/source_monitor/item_scrapes_controller.rb +70 -0
- data/app/controllers/source_monitor/items_controller.rb +2 -69
- data/app/controllers/source_monitor/source_bulk_scrapes_controller.rb +1 -4
- data/app/controllers/source_monitor/source_favicon_fetches_controller.rb +2 -12
- data/app/controllers/source_monitor/source_fetches_controller.rb +1 -6
- data/app/controllers/source_monitor/source_health_checks_controller.rb +9 -16
- data/app/controllers/source_monitor/source_health_resets_controller.rb +1 -6
- data/app/controllers/source_monitor/source_retries_controller.rb +1 -6
- data/app/controllers/source_monitor/source_scrape_tests_controller.rb +2 -4
- data/app/controllers/source_monitor/source_turbo_responses.rb +1 -3
- data/app/controllers/source_monitor/sources_controller.rb +15 -20
- data/app/helpers/source_monitor/application_helper.rb +15 -31
- data/app/helpers/source_monitor/health_badge_helper.rb +8 -0
- data/app/jobs/source_monitor/download_content_images_job.rb +1 -59
- data/app/jobs/source_monitor/favicon_fetch_job.rb +1 -58
- data/app/jobs/source_monitor/fetch_feed_job.rb +2 -52
- data/app/jobs/source_monitor/import_opml_job.rb +6 -145
- data/app/jobs/source_monitor/import_session_health_check_job.rb +15 -76
- data/app/jobs/source_monitor/item_cleanup_job.rb +5 -0
- data/app/jobs/source_monitor/log_cleanup_job.rb +13 -2
- data/app/jobs/source_monitor/schedule_fetches_job.rb +8 -0
- data/app/jobs/source_monitor/scrape_item_job.rb +6 -52
- data/app/jobs/source_monitor/source_health_check_job.rb +1 -72
- data/app/models/concerns/source_monitor/loggable.rb +12 -0
- data/app/models/source_monitor/fetch_log.rb +0 -8
- data/app/models/source_monitor/health_check_log.rb +0 -8
- data/app/models/source_monitor/import_history.rb +14 -0
- data/app/models/source_monitor/import_session.rb +2 -0
- data/app/models/source_monitor/item.rb +15 -0
- data/app/models/source_monitor/item_content.rb +4 -3
- data/app/models/source_monitor/scrape_log.rb +4 -6
- data/app/models/source_monitor/source.rb +28 -19
- data/app/presenters/source_monitor/base_presenter.rb +19 -0
- data/app/presenters/source_monitor/source_details_presenter.rb +61 -0
- data/app/presenters/source_monitor/sources_filter_presenter.rb +61 -0
- data/app/views/source_monitor/dashboard/_recent_activity.html.erb +3 -3
- data/app/views/source_monitor/dashboard/_stat_card.html.erb +2 -1
- data/app/views/source_monitor/dashboard/_stats.html.erb +5 -7
- data/app/views/source_monitor/items/_details.html.erb +11 -14
- data/app/views/source_monitor/items/index.html.erb +10 -35
- data/app/views/source_monitor/logs/index.html.erb +20 -41
- data/app/views/source_monitor/shared/_form_errors.html.erb +14 -0
- data/app/views/source_monitor/source_scrape_tests/_result.html.erb +1 -29
- data/app/views/source_monitor/source_scrape_tests/_result_content.html.erb +33 -0
- data/app/views/source_monitor/source_scrape_tests/show.html.erb +1 -29
- data/app/views/source_monitor/sources/_bulk_scrape_enable_modal.html.erb +2 -2
- data/app/views/source_monitor/sources/_bulk_scrape_modal.html.erb +7 -5
- data/app/views/source_monitor/sources/_details.html.erb +24 -52
- data/app/views/source_monitor/sources/_health_status_badge.html.erb +4 -6
- data/app/views/source_monitor/sources/_row.html.erb +7 -18
- data/app/views/source_monitor/sources/edit.html.erb +1 -10
- data/app/views/source_monitor/sources/index.html.erb +26 -46
- data/app/views/source_monitor/sources/new.html.erb +1 -10
- data/config/routes.rb +1 -1
- data/db/migrate/20260313120000_add_composite_indexes_to_log_tables.rb +14 -0
- data/db/migrate/20260314120000_align_health_status_default.rb +11 -0
- data/lib/source_monitor/analytics/sources_index_metrics.rb +15 -0
- data/lib/source_monitor/dashboard/queries/recent_activity_query.rb +10 -4
- data/lib/source_monitor/dashboard/turbo_broadcaster.rb +21 -5
- data/lib/source_monitor/favicons/fetcher.rb +86 -0
- data/lib/source_monitor/fetching/cloudflare_bypass.rb +14 -5
- data/lib/source_monitor/fetching/completion/event_publisher.rb +12 -0
- data/lib/source_monitor/fetching/completion/follow_up_handler.rb +15 -2
- data/lib/source_monitor/fetching/completion/retention_handler.rb +11 -3
- data/lib/source_monitor/fetching/feed_fetcher.rb +2 -21
- data/lib/source_monitor/fetching/fetch_runner.rb +12 -3
- data/lib/source_monitor/fetching/retry_orchestrator.rb +102 -0
- data/lib/source_monitor/fetching/stalled_fetch_reconciler.rb +9 -0
- data/lib/source_monitor/health/source_health_check_orchestrator.rb +95 -0
- data/lib/source_monitor/health.rb +1 -0
- data/lib/source_monitor/images/downloader.rb +6 -7
- data/lib/source_monitor/images/processor.rb +98 -0
- data/lib/source_monitor/import_sessions/health_check_updater.rb +95 -0
- data/lib/source_monitor/import_sessions/opml_importer.rb +163 -0
- data/lib/source_monitor/items/item_creator.rb +0 -21
- data/lib/source_monitor/logs/query.rb +20 -0
- data/lib/source_monitor/queries/scrape_candidates_query.rb +30 -0
- data/lib/source_monitor/queries.rb +7 -0
- data/lib/source_monitor/scheduler.rb +5 -0
- data/lib/source_monitor/scraping/bulk_result_presenter.rb +11 -8
- data/lib/source_monitor/scraping/runner.rb +52 -0
- data/lib/source_monitor/scraping/scheduler.rb +5 -0
- data/lib/source_monitor/scraping/state.rb +4 -2
- data/lib/source_monitor/security/parameter_sanitizer.rb +7 -0
- data/lib/source_monitor/version.rb +1 -1
- data/lib/source_monitor.rb +7 -0
- data/source_monitor.gemspec +1 -0
- 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
|
|
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.
|
|
12
|
-
this.
|
|
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
|
-
|
|
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 (
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,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
|
|
@@ -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
|
|