rails_pulse 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +638 -0
- data/Rakefile +207 -0
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/menu.svg +1 -0
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +102 -0
- data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
- data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
- data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
- data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
- data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
- data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
- data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
- data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
- data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
- data/app/controllers/concerns/chart_table_concern.rb +82 -0
- data/app/controllers/concerns/response_range_concern.rb +24 -0
- data/app/controllers/concerns/time_range_concern.rb +67 -0
- data/app/controllers/concerns/zoom_range_concern.rb +40 -0
- data/app/controllers/rails_pulse/application_controller.rb +67 -0
- data/app/controllers/rails_pulse/assets_controller.rb +33 -0
- data/app/controllers/rails_pulse/caches_controller.rb +115 -0
- data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
- data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
- data/app/controllers/rails_pulse/operations_controller.rb +219 -0
- data/app/controllers/rails_pulse/queries_controller.rb +121 -0
- data/app/controllers/rails_pulse/requests_controller.rb +69 -0
- data/app/controllers/rails_pulse/routes_controller.rb +99 -0
- data/app/helpers/rails_pulse/application_helper.rb +111 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
- data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
- data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
- data/app/helpers/rails_pulse/chart_helper.rb +140 -0
- data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
- data/app/helpers/rails_pulse/status_helper.rb +279 -0
- data/app/helpers/rails_pulse/table_helper.rb +54 -0
- data/app/javascript/rails_pulse/application.js +119 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
- data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
- data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
- data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
- data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
- data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
- data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
- data/app/javascript/rails_pulse/theme.js +416 -0
- data/app/jobs/rails_pulse/application_job.rb +4 -0
- data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
- data/app/mailers/rails_pulse/application_mailer.rb +6 -0
- data/app/models/rails_pulse/application_record.rb +7 -0
- data/app/models/rails_pulse/component_cache_key.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
- data/app/models/rails_pulse/operation.rb +87 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
- data/app/models/rails_pulse/query.rb +58 -0
- data/app/models/rails_pulse/request.rb +64 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
- data/app/models/rails_pulse/route.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
- data/app/models/rails_pulse/routes/tables/index.rb +63 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
- data/app/views/layouts/rails_pulse/application.html.erb +72 -0
- data/app/views/rails_pulse/caches/show.html.erb +9 -0
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
- data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
- data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
- data/app/views/rails_pulse/components/_panel.html.erb +56 -0
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
- data/app/views/rails_pulse/components/_table.html.erb +50 -0
- data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
- data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
- data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
- data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
- data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
- data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
- data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
- data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
- data/app/views/rails_pulse/operations/show.html.erb +79 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
- data/app/views/rails_pulse/queries/_table.html.erb +31 -0
- data/app/views/rails_pulse/queries/index.html.erb +64 -0
- data/app/views/rails_pulse/queries/show.html.erb +86 -0
- data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
- data/app/views/rails_pulse/requests/_table.html.erb +31 -0
- data/app/views/rails_pulse/requests/index.html.erb +64 -0
- data/app/views/rails_pulse/requests/show.html.erb +44 -0
- data/app/views/rails_pulse/routes/_table.html.erb +29 -0
- data/app/views/rails_pulse/routes/index.html.erb +65 -0
- data/app/views/rails_pulse/routes/show.html.erb +67 -0
- data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
- data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
- data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
- data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
- data/config/importmap.rb +12 -0
- data/config/initializers/rails_charts_csp_patch.rb +83 -0
- data/config/initializers/rails_pulse.rb +198 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20250227235904_create_routes.rb +12 -0
- data/db/migrate/20250227235915_create_requests.rb +19 -0
- data/db/migrate/20250228000000_create_queries.rb +14 -0
- data/db/migrate/20250228000056_create_operations.rb +24 -0
- data/lib/generators/rails_pulse/install_generator.rb +17 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
- data/lib/rails_pulse/cleanup_service.rb +212 -0
- data/lib/rails_pulse/configuration.rb +176 -0
- data/lib/rails_pulse/engine.rb +88 -0
- data/lib/rails_pulse/middleware/asset_server.rb +84 -0
- data/lib/rails_pulse/middleware/request_collector.rb +120 -0
- data/lib/rails_pulse/migration.rb +29 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
- data/lib/rails_pulse/version.rb +3 -0
- data/lib/rails_pulse.rb +38 -0
- data/lib/tasks/rails_pulse_tasks.rake +138 -0
- data/public/rails-pulse-assets/csp-test.js +110 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -0
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
- data/public/rails-pulse-assets/rails-pulse.js +183 -0
- data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
- metadata +339 -0
@@ -0,0 +1,119 @@
|
|
1
|
+
import * as echarts from "echarts";
|
2
|
+
import "./theme";
|
3
|
+
import * as Turbo from "@hotwired/turbo";
|
4
|
+
import { Application } from "@hotwired/stimulus";
|
5
|
+
|
6
|
+
// CSS Zero Controllers
|
7
|
+
import ContextMenuController from "./controllers/context_menu_controller";
|
8
|
+
import DialogController from "./controllers/dialog_controller";
|
9
|
+
import MenuController from "./controllers/menu_controller";
|
10
|
+
import PopoverController from "./controllers/popover_controller";
|
11
|
+
import FormController from "./controllers/form_controller";
|
12
|
+
|
13
|
+
// Rails Pulse Controllers
|
14
|
+
import IndexController from "./controllers/index_controller";
|
15
|
+
import ColorSchemeController from "./controllers/color_scheme_controller";
|
16
|
+
import PaginationController from "./controllers/pagination_controller";
|
17
|
+
import TimezoneController from "./controllers/timezone_controller";
|
18
|
+
import IconController from "./controllers/icon_controller";
|
19
|
+
import ExpandableRowController from "./controllers/expandable_row_controller";
|
20
|
+
|
21
|
+
const application = Application.start();
|
22
|
+
|
23
|
+
// Configure Stimulus application
|
24
|
+
application.debug = false;
|
25
|
+
window.Stimulus = application;
|
26
|
+
|
27
|
+
// Make ECharts available globally for rails_charts gem
|
28
|
+
window.echarts = echarts;
|
29
|
+
|
30
|
+
// Make Turbo available globally
|
31
|
+
window.Turbo = Turbo;
|
32
|
+
|
33
|
+
application.register("rails-pulse--context-menu", ContextMenuController);
|
34
|
+
application.register("rails-pulse--dialog", DialogController);
|
35
|
+
application.register("rails-pulse--menu", MenuController);
|
36
|
+
application.register("rails-pulse--popover", PopoverController);
|
37
|
+
application.register("rails-pulse--form", FormController);
|
38
|
+
|
39
|
+
application.register("rails-pulse--index", IndexController);
|
40
|
+
application.register("rails-pulse--color-scheme", ColorSchemeController);
|
41
|
+
application.register("rails-pulse--pagination", PaginationController);
|
42
|
+
application.register("rails-pulse--timezone", TimezoneController);
|
43
|
+
application.register("rails-pulse--icon", IconController);
|
44
|
+
application.register("rails-pulse--expandable-row", ExpandableRowController);
|
45
|
+
|
46
|
+
// Ensure Turbo Frames are loaded after page load
|
47
|
+
document.addEventListener('DOMContentLoaded', () => {
|
48
|
+
// Force Turbo to process any frames with src attributes
|
49
|
+
const frames = document.querySelectorAll('turbo-frame[src]:not([complete])');
|
50
|
+
frames.forEach(frame => {
|
51
|
+
// Trigger frame loading by temporarily removing and re-adding src
|
52
|
+
const src = frame.getAttribute('src');
|
53
|
+
if (src) {
|
54
|
+
frame.removeAttribute('src');
|
55
|
+
setTimeout(() => frame.setAttribute('src', src), 10);
|
56
|
+
}
|
57
|
+
});
|
58
|
+
});
|
59
|
+
|
60
|
+
// Also handle frames that are added dynamically
|
61
|
+
const observer = new MutationObserver((mutations) => {
|
62
|
+
mutations.forEach((mutation) => {
|
63
|
+
if (mutation.type === 'childList') {
|
64
|
+
mutation.addedNodes.forEach((node) => {
|
65
|
+
if (node.nodeType === 1 && node.tagName === 'TURBO-FRAME' && node.hasAttribute('src') && !node.hasAttribute('complete')) {
|
66
|
+
const src = node.getAttribute('src');
|
67
|
+
if (src) {
|
68
|
+
node.removeAttribute('src');
|
69
|
+
setTimeout(() => node.setAttribute('src', src), 10);
|
70
|
+
}
|
71
|
+
}
|
72
|
+
});
|
73
|
+
}
|
74
|
+
});
|
75
|
+
});
|
76
|
+
|
77
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
78
|
+
|
79
|
+
// Register ECharts theme for Rails Pulse
|
80
|
+
echarts.registerTheme('railspulse', {
|
81
|
+
"color": ["#ffc91f", "#ffde66", "#fbedbf"],
|
82
|
+
"backgroundColor": "rgba(255,255,255,0)",
|
83
|
+
"textStyle": {},
|
84
|
+
"title": { "textStyle": { "color": "#666666" } },
|
85
|
+
"line": { "lineStyle": { "width": "3" }, "symbolSize": "8" },
|
86
|
+
"bar": { "itemStyle": { "barBorderWidth": 0 } }
|
87
|
+
});
|
88
|
+
|
89
|
+
// Chart resize functionality (moved from inline script for CSP compliance)
|
90
|
+
window.addEventListener('resize', function() {
|
91
|
+
if (window.RailsCharts && window.RailsCharts.charts) {
|
92
|
+
Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
|
93
|
+
window.RailsCharts.charts[chartID].resize();
|
94
|
+
});
|
95
|
+
}
|
96
|
+
});
|
97
|
+
|
98
|
+
// Global function to initialize Rails Charts in any container.
|
99
|
+
// This is needed as we render Rails Charts in Turbo Frames.
|
100
|
+
window.initializeChartsInContainer = function(containerId) {
|
101
|
+
requestAnimationFrame(() => {
|
102
|
+
const container = containerId ? document.getElementById(containerId) : document;
|
103
|
+
const scripts = container.querySelectorAll('script');
|
104
|
+
scripts.forEach(script => {
|
105
|
+
const content = script.textContent;
|
106
|
+
const match = content.match(/function\s+(init_rails_charts_[a-f0-9]+)/);
|
107
|
+
if (match && window[match[1]]) {
|
108
|
+
window[match[1]]();
|
109
|
+
}
|
110
|
+
});
|
111
|
+
});
|
112
|
+
};
|
113
|
+
|
114
|
+
// Export for global access
|
115
|
+
window.RailsPulse = {
|
116
|
+
application,
|
117
|
+
version: "1.0.0"
|
118
|
+
};
|
119
|
+
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
connect() {
|
5
|
+
this.storageKey = "color-scheme"
|
6
|
+
this.html = document.documentElement
|
7
|
+
const saved = localStorage.getItem(this.storageKey)
|
8
|
+
if (saved) {
|
9
|
+
this.html.setAttribute("data-color-scheme", saved)
|
10
|
+
}
|
11
|
+
}
|
12
|
+
|
13
|
+
toggle(event) {
|
14
|
+
event.preventDefault()
|
15
|
+
const current = this.html.getAttribute("data-color-scheme") === "dark" ? "light" : "dark"
|
16
|
+
console.log("Toggling color scheme to", current)
|
17
|
+
this.html.setAttribute("data-color-scheme", current)
|
18
|
+
localStorage.setItem(this.storageKey, current)
|
19
|
+
}
|
20
|
+
}
|
@@ -0,0 +1,16 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = [ "menu" ]
|
5
|
+
|
6
|
+
show(event) {
|
7
|
+
// Use CSS custom properties instead of inline styles for CSP safety
|
8
|
+
this.menuTarget.style.setProperty('--context-menu-x', `${event.clientX - 5}px`)
|
9
|
+
this.menuTarget.style.setProperty('--context-menu-y', `${event.clientY - 5}px`)
|
10
|
+
|
11
|
+
// Add CSS class to apply positioning via CSS custom properties
|
12
|
+
this.menuTarget.classList.add('positioned')
|
13
|
+
|
14
|
+
setTimeout(() => this.menuTarget.showPopover(), 150)
|
15
|
+
}
|
16
|
+
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = [ "menu" ]
|
5
|
+
|
6
|
+
show() {
|
7
|
+
this.menuTarget.show()
|
8
|
+
}
|
9
|
+
|
10
|
+
showModal() {
|
11
|
+
this.menuTarget.showModal()
|
12
|
+
}
|
13
|
+
|
14
|
+
close() {
|
15
|
+
this.menuTarget.close()
|
16
|
+
}
|
17
|
+
|
18
|
+
closeOnClickOutside({ target }) {
|
19
|
+
target.nodeName === "DIALOG" && this.close()
|
20
|
+
}
|
21
|
+
}
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["trigger", "details", "chevron"]
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
// Ensure details row is initially hidden
|
8
|
+
this.detailsTarget.classList.add("hidden")
|
9
|
+
this.loaded = false
|
10
|
+
}
|
11
|
+
|
12
|
+
toggle(event) {
|
13
|
+
event.preventDefault()
|
14
|
+
event.stopPropagation()
|
15
|
+
|
16
|
+
const isExpanded = !this.detailsTarget.classList.contains("hidden")
|
17
|
+
console.log('Toggle clicked, currently expanded:', isExpanded)
|
18
|
+
|
19
|
+
console.log('isExpanded', isExpanded)
|
20
|
+
if (isExpanded) {
|
21
|
+
console.log('Collapsing...')
|
22
|
+
this.collapse()
|
23
|
+
} else {
|
24
|
+
console.log('Expanding...')
|
25
|
+
this.expand()
|
26
|
+
}
|
27
|
+
}
|
28
|
+
|
29
|
+
expand() {
|
30
|
+
// Show details row
|
31
|
+
this.detailsTarget.classList.remove("hidden")
|
32
|
+
|
33
|
+
// Rotate chevron to point down
|
34
|
+
this.chevronTarget.style.transform = "rotate(90deg)"
|
35
|
+
|
36
|
+
// Add expanded state class to trigger row
|
37
|
+
this.triggerTarget.classList.add("expanded")
|
38
|
+
|
39
|
+
// Load content lazily on first expansion
|
40
|
+
if (!this.loaded) {
|
41
|
+
this.loadOperationDetails()
|
42
|
+
this.loaded = true
|
43
|
+
}
|
44
|
+
}
|
45
|
+
|
46
|
+
loadOperationDetails() {
|
47
|
+
// Find the turbo frame and set its src to trigger loading
|
48
|
+
const turboFrame = this.detailsTarget.querySelector('turbo-frame')
|
49
|
+
if (turboFrame) {
|
50
|
+
const operationUrl = turboFrame.dataset.operationUrl
|
51
|
+
if (operationUrl) {
|
52
|
+
turboFrame.src = operationUrl
|
53
|
+
}
|
54
|
+
}
|
55
|
+
}
|
56
|
+
|
57
|
+
collapse() {
|
58
|
+
// Hide details row
|
59
|
+
this.detailsTarget.classList.add("hidden")
|
60
|
+
|
61
|
+
// Rotate chevron back to point right
|
62
|
+
this.chevronTarget.style.transform = "rotate(0deg)"
|
63
|
+
|
64
|
+
// Remove expanded state class from trigger row
|
65
|
+
this.triggerTarget.classList.remove("expanded")
|
66
|
+
}
|
67
|
+
}
|
@@ -0,0 +1,39 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = [ "cancel" ]
|
5
|
+
|
6
|
+
initialize() {
|
7
|
+
// Simple debounce implementation for asset independence
|
8
|
+
this.search = this.debounce(this.search.bind(this), 500)
|
9
|
+
}
|
10
|
+
|
11
|
+
// Simple debounce implementation (replaces lodash dependency)
|
12
|
+
debounce(func, wait) {
|
13
|
+
let timeout
|
14
|
+
return function executedFunction(...args) {
|
15
|
+
const later = () => {
|
16
|
+
clearTimeout(timeout)
|
17
|
+
func(...args)
|
18
|
+
}
|
19
|
+
clearTimeout(timeout)
|
20
|
+
timeout = setTimeout(later, wait)
|
21
|
+
}
|
22
|
+
}
|
23
|
+
|
24
|
+
submit() {
|
25
|
+
this.element.requestSubmit()
|
26
|
+
}
|
27
|
+
|
28
|
+
search() {
|
29
|
+
this.element.requestSubmit()
|
30
|
+
}
|
31
|
+
|
32
|
+
cancel() {
|
33
|
+
this.cancelTarget?.click()
|
34
|
+
}
|
35
|
+
|
36
|
+
preventAttachment(event) {
|
37
|
+
event.preventDefault()
|
38
|
+
}
|
39
|
+
}
|
@@ -0,0 +1,170 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
// CSP-Safe Icon Controller for Rails Pulse
|
4
|
+
// Integrates with window.RailsPulseIcons for pre-compiled icon access
|
5
|
+
export default class extends Controller {
|
6
|
+
static values = {
|
7
|
+
name: String, // Icon name (e.g., "menu", "chevron-right")
|
8
|
+
width: String, // Icon width (default: "24")
|
9
|
+
height: String, // Icon height (default: "24")
|
10
|
+
strokeWidth: String // Stroke width (default: "2")
|
11
|
+
}
|
12
|
+
|
13
|
+
static classes = ["loading", "error", "loaded"]
|
14
|
+
|
15
|
+
connect() {
|
16
|
+
this.renderIcon()
|
17
|
+
}
|
18
|
+
|
19
|
+
// Called when icon name changes
|
20
|
+
nameValueChanged() {
|
21
|
+
this.renderIcon()
|
22
|
+
}
|
23
|
+
|
24
|
+
// CSP-safe icon rendering using DOM methods
|
25
|
+
renderIcon() {
|
26
|
+
// Clear any existing content
|
27
|
+
this.clearIcon()
|
28
|
+
|
29
|
+
// Add loading state
|
30
|
+
this.element.classList.add(...this.loadingClasses)
|
31
|
+
this.element.classList.remove(...this.errorClasses, ...this.loadedClasses)
|
32
|
+
|
33
|
+
// Get icon name
|
34
|
+
const iconName = this.nameValue
|
35
|
+
if (!iconName) {
|
36
|
+
this.handleError(`Icon name is required`)
|
37
|
+
return
|
38
|
+
}
|
39
|
+
|
40
|
+
// Check if RailsPulseIcons is available
|
41
|
+
if (!window.RailsPulseIcons) {
|
42
|
+
this.handleError(`RailsPulseIcons not loaded`)
|
43
|
+
return
|
44
|
+
}
|
45
|
+
|
46
|
+
// Get icon SVG content
|
47
|
+
const svgContent = window.RailsPulseIcons.get(iconName)
|
48
|
+
if (!svgContent) {
|
49
|
+
this.handleError(`Icon '${iconName}' not found`)
|
50
|
+
return
|
51
|
+
}
|
52
|
+
|
53
|
+
// Create SVG element using CSP-safe DOM methods
|
54
|
+
try {
|
55
|
+
const svg = this.createSVGElement(svgContent)
|
56
|
+
this.element.appendChild(svg)
|
57
|
+
|
58
|
+
// Update state to loaded
|
59
|
+
this.element.classList.remove(...this.loadingClasses, ...this.errorClasses)
|
60
|
+
this.element.classList.add(...this.loadedClasses)
|
61
|
+
|
62
|
+
// Set aria-label for accessibility
|
63
|
+
this.element.setAttribute('aria-label', `${iconName} icon`)
|
64
|
+
|
65
|
+
} catch (error) {
|
66
|
+
this.handleError(`Failed to render icon '${iconName}': ${error.message}`)
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
// Create SVG element using CSP-safe DOM methods
|
71
|
+
createSVGElement(svgContent) {
|
72
|
+
// Create SVG element with proper namespace
|
73
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
74
|
+
|
75
|
+
// Set SVG attributes
|
76
|
+
svg.setAttribute('width', this.widthValue || '24')
|
77
|
+
svg.setAttribute('height', this.heightValue || '24')
|
78
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
79
|
+
svg.setAttribute('fill', 'none')
|
80
|
+
svg.setAttribute('stroke', 'currentColor')
|
81
|
+
svg.setAttribute('stroke-width', this.strokeWidthValue || '2')
|
82
|
+
svg.setAttribute('stroke-linecap', 'round')
|
83
|
+
svg.setAttribute('stroke-linejoin', 'round')
|
84
|
+
|
85
|
+
// Use the RailsPulseIcons render method for CSP-safe injection
|
86
|
+
if (window.RailsPulseIcons.render) {
|
87
|
+
// Clear the element and let RailsPulseIcons handle the rendering
|
88
|
+
const tempDiv = document.createElement('div')
|
89
|
+
const success = window.RailsPulseIcons.render(this.nameValue, tempDiv, {
|
90
|
+
width: this.widthValue || '24',
|
91
|
+
height: this.heightValue || '24'
|
92
|
+
})
|
93
|
+
|
94
|
+
if (success && tempDiv.firstChild) {
|
95
|
+
return tempDiv.firstChild
|
96
|
+
}
|
97
|
+
}
|
98
|
+
|
99
|
+
// Fallback: manually parse SVG content using DOMParser
|
100
|
+
const parser = new DOMParser()
|
101
|
+
const svgDoc = parser.parseFromString(
|
102
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${this.widthValue || '24'}" height="${this.heightValue || '24'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${this.strokeWidthValue || '2'}" stroke-linecap="round" stroke-linejoin="round">${svgContent}</svg>`,
|
103
|
+
'image/svg+xml'
|
104
|
+
)
|
105
|
+
|
106
|
+
const parsedSvg = svgDoc.documentElement
|
107
|
+
if (parsedSvg.nodeName === 'parsererror') {
|
108
|
+
throw new Error('Invalid SVG content')
|
109
|
+
}
|
110
|
+
|
111
|
+
// Import the node into the current document
|
112
|
+
return document.importNode(parsedSvg, true)
|
113
|
+
}
|
114
|
+
|
115
|
+
// Clear icon content safely
|
116
|
+
clearIcon() {
|
117
|
+
// Use DOM methods to clear content (CSP-safe)
|
118
|
+
while (this.element.firstChild) {
|
119
|
+
this.element.removeChild(this.element.firstChild)
|
120
|
+
}
|
121
|
+
}
|
122
|
+
|
123
|
+
// Handle icon loading errors
|
124
|
+
handleError(message) {
|
125
|
+
console.warn(`[Rails Pulse Icon Controller] ${message}`)
|
126
|
+
|
127
|
+
// Clear any existing content
|
128
|
+
this.clearIcon()
|
129
|
+
|
130
|
+
// Update state to error
|
131
|
+
this.element.classList.remove(...this.loadingClasses, ...this.loadedClasses)
|
132
|
+
this.element.classList.add(...this.errorClasses)
|
133
|
+
|
134
|
+
// Create fallback placeholder using CSP-safe methods
|
135
|
+
this.createErrorPlaceholder()
|
136
|
+
|
137
|
+
// Set aria-label for accessibility
|
138
|
+
this.element.setAttribute('aria-label', 'Icon not available')
|
139
|
+
}
|
140
|
+
|
141
|
+
// Create error placeholder using CSP-safe DOM methods
|
142
|
+
createErrorPlaceholder() {
|
143
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
144
|
+
svg.setAttribute('width', this.widthValue || '24')
|
145
|
+
svg.setAttribute('height', this.heightValue || '24')
|
146
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
147
|
+
svg.setAttribute('fill', 'currentColor')
|
148
|
+
svg.setAttribute('opacity', '0.3')
|
149
|
+
|
150
|
+
// Create a simple rectangle as placeholder
|
151
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
152
|
+
rect.setAttribute('width', '20')
|
153
|
+
rect.setAttribute('height', '20')
|
154
|
+
rect.setAttribute('x', '2')
|
155
|
+
rect.setAttribute('y', '2')
|
156
|
+
rect.setAttribute('rx', '2')
|
157
|
+
|
158
|
+
svg.appendChild(rect)
|
159
|
+
this.element.appendChild(svg)
|
160
|
+
}
|
161
|
+
|
162
|
+
// Debug method to list available icons
|
163
|
+
listAvailableIcons() {
|
164
|
+
if (window.RailsPulseIcons?.list) {
|
165
|
+
console.log('Available icons:', window.RailsPulseIcons.list())
|
166
|
+
} else {
|
167
|
+
console.warn('RailsPulseIcons not loaded or list method not available')
|
168
|
+
}
|
169
|
+
}
|
170
|
+
}
|
@@ -0,0 +1,230 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["chart", "paginationLimit", "indexTable"] // The chart element to be monitored
|
5
|
+
|
6
|
+
static values = {
|
7
|
+
chartId: String // The ID of the chart to be monitored
|
8
|
+
}
|
9
|
+
|
10
|
+
// Add a property to track the last request time
|
11
|
+
lastTurboFrameRequestAt = 0;
|
12
|
+
|
13
|
+
connect() {
|
14
|
+
// Listen for the custom event 'chart:initialized' to set up the chart.
|
15
|
+
// This event is sent from the RailsCharts library when the chart is ready.
|
16
|
+
this.handleChartInitialized = this.onChartInitialized.bind(this);
|
17
|
+
document.addEventListener('chart:initialized', this.handleChartInitialized);
|
18
|
+
|
19
|
+
// If the chart is already initialized (e.g., on back navigation), set up immediately
|
20
|
+
if (window.RailsCharts?.charts?.[this.chartIdValue]) { this.setup(); }
|
21
|
+
}
|
22
|
+
|
23
|
+
disconnect() {
|
24
|
+
// Remove the event listener from RailsCharts when the controller is disconnected
|
25
|
+
document.removeEventListener('chart:initialized', this.handleChartInitialized);
|
26
|
+
|
27
|
+
// Remove chart event listeners if they exist
|
28
|
+
if (this.chartTarget) {
|
29
|
+
this.chartTarget.removeEventListener('mousedown', this.handleChartMouseDown);
|
30
|
+
this.chartTarget.removeEventListener('mouseup', this.handleChartMouseUp);
|
31
|
+
}
|
32
|
+
document.removeEventListener('mouseup', this.handleDocumentMouseUp);
|
33
|
+
}
|
34
|
+
|
35
|
+
// After the chart is initialized, set up the event listeners and data tracking
|
36
|
+
onChartInitialized(event) {
|
37
|
+
if (event.detail.chartId === this.chartIdValue) { this.setup(); }
|
38
|
+
}
|
39
|
+
|
40
|
+
setup() {
|
41
|
+
if (this.setupDone) return; // Prevent multiple setups
|
42
|
+
|
43
|
+
// Get the chart element which the RailsCharts library has created
|
44
|
+
this.chart = window.RailsCharts.charts[this.chartIdValue];
|
45
|
+
if (!this.chart) return;
|
46
|
+
|
47
|
+
this.visibleData = this.getVisibleData();
|
48
|
+
this.setupChartEventListeners();
|
49
|
+
this.setupDone = true;
|
50
|
+
}
|
51
|
+
|
52
|
+
// Add some event listeners to the chart so we can track the zoom changes
|
53
|
+
setupChartEventListeners() {
|
54
|
+
// When clicking on the chart, we want to store the current visible data so we can compare it later
|
55
|
+
this.handleChartMouseDown = () => { this.visibleData = this.getVisibleData(); };
|
56
|
+
this.chartTarget.addEventListener('mousedown', this.handleChartMouseDown);
|
57
|
+
|
58
|
+
// When releasing the mouse button, we want to check if the visible data has changed
|
59
|
+
this.handleChartMouseUp = () => { this.handleZoomChange(); };
|
60
|
+
this.chartTarget.addEventListener('mouseup', this.handleChartMouseUp);
|
61
|
+
|
62
|
+
// When the chart is zoomed, we want to check if the visible data has changed
|
63
|
+
this.chart.on('datazoom', () => { this.handleZoomChange(); });
|
64
|
+
|
65
|
+
// When releasing the mouse button outside the chart, we want to check if the visible data has changed
|
66
|
+
this.handleDocumentMouseUp = () => { this.handleZoomChange(); };
|
67
|
+
document.addEventListener('mouseup', this.handleDocumentMouseUp);
|
68
|
+
}
|
69
|
+
|
70
|
+
// This returns the visible data from the chart based on the current zoom level.
|
71
|
+
// The xAxis data and series data are sliced based on the start and end values of the dataZoom component.
|
72
|
+
// The series data will contain the actual data points that are visible in the chart.
|
73
|
+
getVisibleData() {
|
74
|
+
const currentOption = this.chart.getOption();
|
75
|
+
const dataZoom = currentOption.dataZoom[1];
|
76
|
+
const xAxisData = currentOption.xAxis[0].data;
|
77
|
+
const seriesData = currentOption.series[0].data;
|
78
|
+
|
79
|
+
const startValue = dataZoom.startValue;
|
80
|
+
const endValue = dataZoom.endValue;
|
81
|
+
|
82
|
+
return {
|
83
|
+
xAxis: xAxisData.slice(startValue, endValue + 1),
|
84
|
+
series: seriesData.slice(startValue, endValue + 1)
|
85
|
+
};
|
86
|
+
}
|
87
|
+
|
88
|
+
// When the zoom level changes, we want to check if the visible data has changed
|
89
|
+
// If it has, we want to send a request to the server with the new visible data so
|
90
|
+
// we can update the table with the new data that is visible in the chart.
|
91
|
+
handleZoomChange() {
|
92
|
+
const newVisibleData = this.getVisibleData();
|
93
|
+
if (newVisibleData.xAxis.join() !== this.visibleData.xAxis.join()) {
|
94
|
+
this.visibleData = newVisibleData;
|
95
|
+
this.updateUrlWithZoomParams(newVisibleData);
|
96
|
+
this.sendTurboFrameRequest(newVisibleData);
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
// Update the browser URL with zoom parameters so they persist on page refresh
|
101
|
+
updateUrlWithZoomParams(data) {
|
102
|
+
const url = new URL(window.location.href);
|
103
|
+
const currentParams = new URLSearchParams(url.search);
|
104
|
+
|
105
|
+
const startTimestamp = data.xAxis[0];
|
106
|
+
const endTimestamp = data.xAxis[data.xAxis.length - 1];
|
107
|
+
|
108
|
+
// Update zoom parameters in URL
|
109
|
+
currentParams.set('zoom_start_time', startTimestamp);
|
110
|
+
currentParams.set('zoom_end_time', endTimestamp);
|
111
|
+
|
112
|
+
url.search = currentParams.toString();
|
113
|
+
window.history.replaceState({}, '', url);
|
114
|
+
}
|
115
|
+
|
116
|
+
updatePaginationLimit() {
|
117
|
+
// Update or set the limit param in the browser so if the user refreshes the page,
|
118
|
+
// the limit will be preserved.
|
119
|
+
const url = new URL(window.location.href);
|
120
|
+
const currentParams = new URLSearchParams(url.search);
|
121
|
+
const limit = this.paginationLimitTarget.value;
|
122
|
+
currentParams.set('limit', limit);
|
123
|
+
url.search = currentParams.toString();
|
124
|
+
window.history.replaceState({}, '', url);
|
125
|
+
}
|
126
|
+
|
127
|
+
// After the zoom level changes, we want to send a request to the server with the new visible data.
|
128
|
+
// The server will then return the full page HTML with the updated table data wrapped in a turbo-frame.
|
129
|
+
// We will then replace the innerHTML of the turbo-frame with the new HTML.
|
130
|
+
sendTurboFrameRequest(data) {
|
131
|
+
const now = Date.now();
|
132
|
+
// If less than 1 second since last request, ignore this call
|
133
|
+
if (now - this.lastTurboFrameRequestAt < 1000) { return; }
|
134
|
+
this.lastTurboFrameRequestAt = now;
|
135
|
+
|
136
|
+
// Start with the current page's URL
|
137
|
+
const url = new URL(window.location.href);
|
138
|
+
|
139
|
+
// Preserve existing URL parameters
|
140
|
+
const currentParams = new URLSearchParams(url.search);
|
141
|
+
|
142
|
+
const startTimestamp = data.xAxis[0];
|
143
|
+
const endTimestamp = data.xAxis[data.xAxis.length - 1];
|
144
|
+
|
145
|
+
// Add or update the zoom occurred_at parameters for table filtering
|
146
|
+
currentParams.set('zoom_start_time', startTimestamp);
|
147
|
+
currentParams.set('zoom_end_time', endTimestamp);
|
148
|
+
|
149
|
+
// Set the limit param based on the value in the pagination selector
|
150
|
+
url.searchParams.set('limit', this.paginationLimitTarget.value);
|
151
|
+
|
152
|
+
// Update the URL's search parameters
|
153
|
+
url.search = currentParams.toString();
|
154
|
+
|
155
|
+
fetch(url, {
|
156
|
+
method: 'GET',
|
157
|
+
headers: {
|
158
|
+
'Accept': 'text/html; turbo-frame',
|
159
|
+
'Turbo-Frame': this.chartIdValue
|
160
|
+
}
|
161
|
+
})
|
162
|
+
.then(response => response.text()) // Get the raw HTML response
|
163
|
+
.then(html => {
|
164
|
+
// Find the turbo-frame in the document using the target
|
165
|
+
const frame = this.indexTableTarget;
|
166
|
+
if (frame) {
|
167
|
+
// Parse the response HTML
|
168
|
+
const parser = new DOMParser();
|
169
|
+
const doc = parser.parseFromString(html, 'text/html');
|
170
|
+
|
171
|
+
// Find the turbo-frame in the response using the frame's ID
|
172
|
+
const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
|
173
|
+
if (responseFrame) {
|
174
|
+
// CSP-safe content replacement using DOM methods
|
175
|
+
this.replaceFrameContent(frame, responseFrame);
|
176
|
+
} else {
|
177
|
+
// Fallback: parse the entire HTML response
|
178
|
+
this.replaceFrameContentFromHTML(frame, html);
|
179
|
+
}
|
180
|
+
}
|
181
|
+
})
|
182
|
+
.catch(error => console.error('Error:', error));
|
183
|
+
}
|
184
|
+
|
185
|
+
// CSP-safe method to replace frame content using DOM methods
|
186
|
+
replaceFrameContent(targetFrame, sourceFrame) {
|
187
|
+
try {
|
188
|
+
// Clear existing content using DOM methods
|
189
|
+
while (targetFrame.firstChild) {
|
190
|
+
targetFrame.removeChild(targetFrame.firstChild);
|
191
|
+
}
|
192
|
+
|
193
|
+
// Clone and append all child nodes from source frame
|
194
|
+
const children = Array.from(sourceFrame.childNodes);
|
195
|
+
children.forEach(child => {
|
196
|
+
const clonedChild = child.cloneNode(true);
|
197
|
+
targetFrame.appendChild(clonedChild);
|
198
|
+
});
|
199
|
+
} catch (error) {
|
200
|
+
console.error('Error replacing frame content:', error);
|
201
|
+
// Fallback to innerHTML as last resort (not ideal for CSP)
|
202
|
+
targetFrame.innerHTML = sourceFrame.innerHTML;
|
203
|
+
}
|
204
|
+
}
|
205
|
+
|
206
|
+
// CSP-safe fallback method for parsing raw HTML
|
207
|
+
replaceFrameContentFromHTML(targetFrame, html) {
|
208
|
+
try {
|
209
|
+
// Parse HTML safely
|
210
|
+
const parser = new DOMParser();
|
211
|
+
const doc = parser.parseFromString(html, 'text/html');
|
212
|
+
|
213
|
+
// Clear existing content
|
214
|
+
while (targetFrame.firstChild) {
|
215
|
+
targetFrame.removeChild(targetFrame.firstChild);
|
216
|
+
}
|
217
|
+
|
218
|
+
// If the HTML contains a single root element, use its children
|
219
|
+
const bodyChildren = Array.from(doc.body.childNodes);
|
220
|
+
bodyChildren.forEach(child => {
|
221
|
+
const clonedChild = child.cloneNode(true);
|
222
|
+
targetFrame.appendChild(clonedChild);
|
223
|
+
});
|
224
|
+
} catch (error) {
|
225
|
+
console.error('Error parsing HTML content:', error);
|
226
|
+
// Last resort fallback
|
227
|
+
targetFrame.innerHTML = html;
|
228
|
+
}
|
229
|
+
}
|
230
|
+
}
|