rails_pulse 0.1.1 → 0.1.3
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/README.md +79 -177
- data/Rakefile +77 -2
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -17
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- data/app/controllers/concerns/chart_table_concern.rb +21 -4
- data/app/controllers/concerns/response_range_concern.rb +6 -3
- data/app/controllers/concerns/time_range_concern.rb +5 -10
- data/app/controllers/concerns/zoom_range_concern.rb +32 -1
- data/app/controllers/rails_pulse/application_controller.rb +13 -5
- data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
- data/app/controllers/rails_pulse/queries_controller.rb +111 -51
- data/app/controllers/rails_pulse/requests_controller.rb +37 -12
- data/app/controllers/rails_pulse/routes_controller.rb +98 -24
- data/app/helpers/rails_pulse/application_helper.rb +0 -1
- data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
- data/app/helpers/rails_pulse/chart_helper.rb +21 -9
- data/app/helpers/rails_pulse/status_helper.rb +10 -4
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
- data/app/jobs/rails_pulse/summary_job.rb +53 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
- data/app/models/rails_pulse/operation.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
- data/app/models/rails_pulse/queries/tables/index.rb +74 -0
- data/app/models/rails_pulse/query.rb +47 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
- data/app/models/rails_pulse/route.rb +1 -6
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
- data/app/models/rails_pulse/routes/tables/index.rb +57 -40
- data/app/models/rails_pulse/summary.rb +143 -0
- data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/services/rails_pulse/summary_service.rb +199 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +4 -6
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
- data/app/views/rails_pulse/queries/_table.html.erb +11 -13
- data/app/views/rails_pulse/queries/index.html.erb +32 -28
- data/app/views/rails_pulse/queries/show.html.erb +45 -34
- data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
- data/app/views/rails_pulse/requests/_table.html.erb +3 -3
- data/app/views/rails_pulse/requests/index.html.erb +33 -28
- data/app/views/rails_pulse/routes/_table.html.erb +14 -14
- data/app/views/rails_pulse/routes/index.html.erb +34 -29
- data/app/views/rails_pulse/routes/show.html.erb +43 -36
- data/config/initializers/rails_pulse.rb +0 -12
- data/config/routes.rb +5 -1
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +130 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +94 -4
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
- data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/configuration.rb +0 -11
- data/lib/rails_pulse/engine.rb +0 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +77 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- data/public/rails-pulse-assets/search.svg +43 -0
- metadata +48 -14
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/controllers/rails_pulse/caches_controller.rb +0 -115
- data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
- data/app/models/rails_pulse/component_cache_key.rb +0 -33
- data/app/views/rails_pulse/caches/show.html.erb +0 -9
- data/db/migrate/20250227235904_create_routes.rb +0 -12
- data/db/migrate/20250227235915_create_requests.rb +0 -19
- data/db/migrate/20250228000000_create_queries.rb +0 -14
- data/db/migrate/20250228000056_create_operations.rb +0 -24
- data/lib/rails_pulse/migration.rb +0 -29
@@ -1,88 +1,161 @@
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
2
2
|
|
3
3
|
export default class extends Controller {
|
4
|
-
static targets = ["chart", "paginationLimit", "indexTable"]
|
4
|
+
static targets = ["chart", "paginationLimit", "indexTable"]
|
5
5
|
|
6
6
|
static values = {
|
7
7
|
chartId: String // The ID of the chart to be monitored
|
8
8
|
}
|
9
9
|
|
10
|
-
// Add
|
10
|
+
// Add properties for improved debouncing
|
11
11
|
lastTurboFrameRequestAt = 0;
|
12
|
+
pendingRequestTimeout = null;
|
13
|
+
pendingRequestData = null;
|
14
|
+
selectedColumnIndex = null;
|
15
|
+
originalSeriesOption = null;
|
12
16
|
|
13
17
|
connect() {
|
14
18
|
// Listen for the custom event 'chart:initialized' to set up the chart.
|
15
19
|
// This event is sent from the RailsCharts library when the chart is ready.
|
16
20
|
this.handleChartInitialized = this.onChartInitialized.bind(this);
|
17
|
-
|
21
|
+
|
22
|
+
document.addEventListener('chart:rendered', this.handleChartInitialized);
|
18
23
|
|
19
24
|
// If the chart is already initialized (e.g., on back navigation), set up immediately
|
20
|
-
if (window.RailsCharts?.charts?.[this.chartIdValue]) {
|
25
|
+
if (window.RailsCharts?.charts?.[this.chartIdValue]) {
|
26
|
+
this.setup();
|
27
|
+
}
|
21
28
|
}
|
22
29
|
|
23
30
|
disconnect() {
|
24
31
|
// Remove the event listener from RailsCharts when the controller is disconnected
|
25
|
-
document.removeEventListener('chart:
|
32
|
+
document.removeEventListener('chart:rendered', this.handleChartInitialized);
|
26
33
|
|
27
34
|
// Remove chart event listeners if they exist
|
28
|
-
if (this.chartTarget) {
|
35
|
+
if (this.hasChartTarget && this.chartTarget) {
|
29
36
|
this.chartTarget.removeEventListener('mousedown', this.handleChartMouseDown);
|
30
37
|
this.chartTarget.removeEventListener('mouseup', this.handleChartMouseUp);
|
31
38
|
}
|
32
39
|
document.removeEventListener('mouseup', this.handleDocumentMouseUp);
|
40
|
+
|
41
|
+
// Clear any pending timeout
|
42
|
+
if (this.pendingRequestTimeout) {
|
43
|
+
clearTimeout(this.pendingRequestTimeout);
|
44
|
+
}
|
33
45
|
}
|
34
46
|
|
35
47
|
// After the chart is initialized, set up the event listeners and data tracking
|
36
48
|
onChartInitialized(event) {
|
37
|
-
if (event.detail.
|
49
|
+
if (event.detail.containerId === this.chartIdValue) {
|
50
|
+
this.setup();
|
51
|
+
}
|
38
52
|
}
|
39
53
|
|
40
54
|
setup() {
|
41
|
-
if (this.setupDone)
|
55
|
+
if (this.setupDone) {
|
56
|
+
return; // Prevent multiple setups
|
57
|
+
}
|
58
|
+
|
59
|
+
// We need both the chart target in DOM and the chart object from RailsCharts
|
60
|
+
let hasTarget = false;
|
61
|
+
try {
|
62
|
+
hasTarget = !!this.chartTarget;
|
63
|
+
} catch (e) {
|
64
|
+
hasTarget = false;
|
65
|
+
}
|
42
66
|
|
43
67
|
// Get the chart element which the RailsCharts library has created
|
44
68
|
this.chart = window.RailsCharts.charts[this.chartIdValue];
|
45
|
-
|
69
|
+
|
70
|
+
// Only proceed if we have BOTH the DOM target and the chart object
|
71
|
+
if (!hasTarget || !this.chart) {
|
72
|
+
return;
|
73
|
+
}
|
46
74
|
|
47
75
|
this.visibleData = this.getVisibleData();
|
76
|
+
|
77
|
+
// Store the original series configuration BEFORE any modifications
|
78
|
+
this.storeOriginalSeriesOption();
|
79
|
+
|
48
80
|
this.setupChartEventListeners();
|
49
81
|
this.setupDone = true;
|
82
|
+
|
83
|
+
// Mark the chart as fully rendered for testing
|
84
|
+
if (hasTarget) {
|
85
|
+
document.getElementById(this.chartIdValue)?.setAttribute('data-chart-rendered', 'true');
|
86
|
+
}
|
87
|
+
|
88
|
+
// Initialize column selection from URL parameters after chart is fully ready
|
89
|
+
// This must come AFTER storing the original option to avoid storing modified state
|
90
|
+
this.initializeColumnSelectionFromUrl();
|
50
91
|
}
|
51
92
|
|
52
93
|
// Add some event listeners to the chart so we can track the zoom changes
|
53
94
|
setupChartEventListeners() {
|
54
95
|
// When clicking on the chart, we want to store the current visible data so we can compare it later
|
55
|
-
this.handleChartMouseDown = () => {
|
96
|
+
this.handleChartMouseDown = () => {
|
97
|
+
this.visibleData = this.getVisibleData();
|
98
|
+
};
|
56
99
|
this.chartTarget.addEventListener('mousedown', this.handleChartMouseDown);
|
57
100
|
|
58
101
|
// When releasing the mouse button, we want to check if the visible data has changed
|
59
|
-
this.handleChartMouseUp = () => {
|
102
|
+
this.handleChartMouseUp = () => {
|
103
|
+
this.handleZoomChange();
|
104
|
+
};
|
60
105
|
this.chartTarget.addEventListener('mouseup', this.handleChartMouseUp);
|
61
106
|
|
62
107
|
// When the chart is zoomed, we want to check if the visible data has changed
|
63
|
-
this.chart.on('datazoom', () => {
|
108
|
+
this.chart.on('datazoom', () => {
|
109
|
+
this.handleZoomChange();
|
110
|
+
});
|
64
111
|
|
65
112
|
// When releasing the mouse button outside the chart, we want to check if the visible data has changed
|
66
|
-
this.handleDocumentMouseUp = () => {
|
113
|
+
this.handleDocumentMouseUp = () => {
|
114
|
+
this.handleZoomChange();
|
115
|
+
};
|
67
116
|
document.addEventListener('mouseup', this.handleDocumentMouseUp);
|
117
|
+
|
118
|
+
// Add click event handler for bar chart columns
|
119
|
+
this.chart.on('click', (params) => {
|
120
|
+
this.handleColumnClick(params);
|
121
|
+
});
|
68
122
|
}
|
69
123
|
|
70
124
|
// This returns the visible data from the chart based on the current zoom level.
|
71
125
|
// The xAxis data and series data are sliced based on the start and end values of the dataZoom component.
|
72
126
|
// The series data will contain the actual data points that are visible in the chart.
|
73
127
|
getVisibleData() {
|
74
|
-
|
75
|
-
|
76
|
-
const xAxisData = currentOption.xAxis[0].data;
|
77
|
-
const seriesData = currentOption.series[0].data;
|
128
|
+
try {
|
129
|
+
const currentOption = this.chart.getOption();
|
78
130
|
|
79
|
-
|
80
|
-
|
131
|
+
if (!currentOption.dataZoom || currentOption.dataZoom.length === 0) {
|
132
|
+
return { xAxis: [], series: [] };
|
133
|
+
}
|
81
134
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
135
|
+
// Try to find the correct dataZoom component
|
136
|
+
let dataZoom = currentOption.dataZoom[1] || currentOption.dataZoom[0];
|
137
|
+
|
138
|
+
if (!currentOption.xAxis || !currentOption.xAxis[0] || !currentOption.xAxis[0].data) {
|
139
|
+
return { xAxis: [], series: [] };
|
140
|
+
}
|
141
|
+
|
142
|
+
if (!currentOption.series || !currentOption.series[0] || !currentOption.series[0].data) {
|
143
|
+
return { xAxis: [], series: [] };
|
144
|
+
}
|
145
|
+
|
146
|
+
const xAxisData = currentOption.xAxis[0].data;
|
147
|
+
const seriesData = currentOption.series[0].data;
|
148
|
+
|
149
|
+
const startValue = dataZoom.startValue || 0;
|
150
|
+
const endValue = dataZoom.endValue || xAxisData.length - 1;
|
151
|
+
|
152
|
+
return {
|
153
|
+
xAxis: xAxisData.slice(startValue, endValue + 1),
|
154
|
+
series: seriesData.slice(startValue, endValue + 1)
|
155
|
+
};
|
156
|
+
} catch (error) {
|
157
|
+
return { xAxis: [], series: [] };
|
158
|
+
}
|
86
159
|
}
|
87
160
|
|
88
161
|
// When the zoom level changes, we want to check if the visible data has changed
|
@@ -90,7 +163,10 @@ export default class extends Controller {
|
|
90
163
|
// we can update the table with the new data that is visible in the chart.
|
91
164
|
handleZoomChange() {
|
92
165
|
const newVisibleData = this.getVisibleData();
|
93
|
-
|
166
|
+
const newDataString = newVisibleData.xAxis.join();
|
167
|
+
const currentDataString = this.visibleData.xAxis.join();
|
168
|
+
|
169
|
+
if (newDataString !== currentDataString) {
|
94
170
|
this.visibleData = newVisibleData;
|
95
171
|
this.updateUrlWithZoomParams(newVisibleData);
|
96
172
|
this.sendTurboFrameRequest(newVisibleData);
|
@@ -105,7 +181,7 @@ export default class extends Controller {
|
|
105
181
|
const startTimestamp = data.xAxis[0];
|
106
182
|
const endTimestamp = data.xAxis[data.xAxis.length - 1];
|
107
183
|
|
108
|
-
// Update zoom parameters in URL
|
184
|
+
// Update zoom parameters in URL while preserving all other parameters including sort
|
109
185
|
currentParams.set('zoom_start_time', startTimestamp);
|
110
186
|
currentParams.set('zoom_end_time', endTimestamp);
|
111
187
|
|
@@ -124,19 +200,40 @@ export default class extends Controller {
|
|
124
200
|
window.history.replaceState({}, '', url);
|
125
201
|
}
|
126
202
|
|
127
|
-
//
|
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.
|
203
|
+
// Improved debouncing with guaranteed final request
|
130
204
|
sendTurboFrameRequest(data) {
|
131
205
|
const now = Date.now();
|
132
|
-
|
133
|
-
if (now - this.lastTurboFrameRequestAt < 1000) { return; }
|
134
|
-
this.lastTurboFrameRequestAt = now;
|
206
|
+
const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
|
135
207
|
|
136
|
-
//
|
208
|
+
// Store the latest data for potential delayed execution
|
209
|
+
this.pendingRequestData = data;
|
210
|
+
|
211
|
+
// Clear any existing timeout
|
212
|
+
if (this.pendingRequestTimeout) {
|
213
|
+
clearTimeout(this.pendingRequestTimeout);
|
214
|
+
}
|
215
|
+
|
216
|
+
// If enough time has passed since last request, execute immediately
|
217
|
+
if (timeSinceLastRequest >= 1000) {
|
218
|
+
this.executeTurboFrameRequest(data);
|
219
|
+
} else {
|
220
|
+
// Otherwise, schedule execution for later to ensure final request goes through
|
221
|
+
const remainingTime = 1000 - timeSinceLastRequest;
|
222
|
+
this.pendingRequestTimeout = setTimeout(() => {
|
223
|
+
this.executeTurboFrameRequest(this.pendingRequestData);
|
224
|
+
this.pendingRequestTimeout = null;
|
225
|
+
}, remainingTime);
|
226
|
+
}
|
227
|
+
}
|
228
|
+
|
229
|
+
// Execute the actual AJAX request
|
230
|
+
executeTurboFrameRequest(data) {
|
231
|
+
this.lastTurboFrameRequestAt = Date.now();
|
232
|
+
|
233
|
+
// Start with the current page's URL to preserve all existing parameters including sort
|
137
234
|
const url = new URL(window.location.href);
|
138
235
|
|
139
|
-
// Preserve existing URL parameters
|
236
|
+
// Preserve existing URL parameters (including sort parameters like q[s])
|
140
237
|
const currentParams = new URLSearchParams(url.search);
|
141
238
|
|
142
239
|
const startTimestamp = data.xAxis[0];
|
@@ -147,7 +244,7 @@ export default class extends Controller {
|
|
147
244
|
currentParams.set('zoom_end_time', endTimestamp);
|
148
245
|
|
149
246
|
// Set the limit param based on the value in the pagination selector
|
150
|
-
|
247
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
151
248
|
|
152
249
|
// Update the URL's search parameters
|
153
250
|
url.search = currentParams.toString();
|
@@ -155,11 +252,14 @@ export default class extends Controller {
|
|
155
252
|
fetch(url, {
|
156
253
|
method: 'GET',
|
157
254
|
headers: {
|
158
|
-
'Accept': 'text/html
|
159
|
-
'Turbo-Frame': this.
|
255
|
+
'Accept': 'text/vnd.turbo-stream.html, text/html',
|
256
|
+
'Turbo-Frame': this.indexTableTarget.id,
|
257
|
+
'X-Requested-With': 'XMLHttpRequest'
|
160
258
|
}
|
161
259
|
})
|
162
|
-
.then(response =>
|
260
|
+
.then(response => {
|
261
|
+
return response.text();
|
262
|
+
})
|
163
263
|
.then(html => {
|
164
264
|
// Find the turbo-frame in the document using the target
|
165
265
|
const frame = this.indexTableTarget;
|
@@ -179,7 +279,7 @@ export default class extends Controller {
|
|
179
279
|
}
|
180
280
|
}
|
181
281
|
})
|
182
|
-
.catch(error => console.error('
|
282
|
+
.catch(error => console.error('[IndexController] Fetch error:', error));
|
183
283
|
}
|
184
284
|
|
185
285
|
// CSP-safe method to replace frame content using DOM methods
|
@@ -209,7 +309,7 @@ export default class extends Controller {
|
|
209
309
|
// Parse HTML safely
|
210
310
|
const parser = new DOMParser();
|
211
311
|
const doc = parser.parseFromString(html, 'text/html');
|
212
|
-
|
312
|
+
|
213
313
|
// Clear existing content
|
214
314
|
while (targetFrame.firstChild) {
|
215
315
|
targetFrame.removeChild(targetFrame.firstChild);
|
@@ -227,4 +327,218 @@ export default class extends Controller {
|
|
227
327
|
targetFrame.innerHTML = html;
|
228
328
|
}
|
229
329
|
}
|
330
|
+
|
331
|
+
handleColumnClick(params) {
|
332
|
+
const clickedIndex = params.dataIndex;
|
333
|
+
|
334
|
+
// If clicking the same column that's already selected, deselect all
|
335
|
+
if (this.selectedColumnIndex === clickedIndex) {
|
336
|
+
this.resetColumnColors();
|
337
|
+
this.selectedColumnIndex = null;
|
338
|
+
this.sendColumnDeselectionRequest();
|
339
|
+
} else {
|
340
|
+
// Select the clicked column and gray out others
|
341
|
+
this.highlightColumn(clickedIndex);
|
342
|
+
this.selectedColumnIndex = clickedIndex;
|
343
|
+
this.sendColumnSelectionRequest(clickedIndex);
|
344
|
+
}
|
345
|
+
}
|
346
|
+
|
347
|
+
highlightColumn(selectedIndex) {
|
348
|
+
try {
|
349
|
+
const option = this.chart.getOption();
|
350
|
+
if (!option.series || !option.series[0] || !option.series[0].data) {
|
351
|
+
return;
|
352
|
+
}
|
353
|
+
|
354
|
+
const seriesData = option.series[0].data;
|
355
|
+
|
356
|
+
// Instead of changing data structure, modify the series itemStyle
|
357
|
+
const currentOption = this.chart.getOption();
|
358
|
+
|
359
|
+
// Get the default color from the chart theme
|
360
|
+
const defaultColor = currentOption.color?.[0] || '#5470c6'; // ECharts default blue
|
361
|
+
|
362
|
+
this.chart.setOption({
|
363
|
+
series: [{
|
364
|
+
data: seriesData, // Keep original data format for tooltips
|
365
|
+
itemStyle: {
|
366
|
+
color: (params) => {
|
367
|
+
return params.dataIndex === selectedIndex ? defaultColor : '#cccccc';
|
368
|
+
}
|
369
|
+
}
|
370
|
+
}]
|
371
|
+
});
|
372
|
+
} catch (error) {
|
373
|
+
console.error('Error highlighting column:', error);
|
374
|
+
}
|
375
|
+
}
|
376
|
+
|
377
|
+
storeOriginalSeriesOption() {
|
378
|
+
try {
|
379
|
+
const option = this.chart.getOption();
|
380
|
+
if (option.series && option.series[0]) {
|
381
|
+
// Deep clone the original series configuration to restore later
|
382
|
+
const originalSeries = JSON.parse(JSON.stringify(option.series[0]));
|
383
|
+
|
384
|
+
// Ensure we don't store any column selection modifications
|
385
|
+
// Remove any custom itemStyle that might have color functions
|
386
|
+
if (originalSeries.itemStyle && typeof originalSeries.itemStyle.color === 'function') {
|
387
|
+
delete originalSeries.itemStyle.color;
|
388
|
+
}
|
389
|
+
|
390
|
+
this.originalSeriesOption = originalSeries;
|
391
|
+
}
|
392
|
+
} catch (error) {
|
393
|
+
console.error('Error storing original series option:', error);
|
394
|
+
}
|
395
|
+
}
|
396
|
+
|
397
|
+
resetColumnColors() {
|
398
|
+
try {
|
399
|
+
if (!this.originalSeriesOption) {
|
400
|
+
console.warn('No original series option stored, cannot reset properly');
|
401
|
+
return;
|
402
|
+
}
|
403
|
+
|
404
|
+
const option = this.chart.getOption();
|
405
|
+
const seriesData = option.series[0].data;
|
406
|
+
|
407
|
+
// Restore original series configuration but keep current data
|
408
|
+
const restoredOption = {
|
409
|
+
...this.originalSeriesOption,
|
410
|
+
data: seriesData // Keep current data to preserve tooltips
|
411
|
+
};
|
412
|
+
|
413
|
+
// Explicitly set color back to default yellow theme color
|
414
|
+
if (!restoredOption.itemStyle) {
|
415
|
+
restoredOption.itemStyle = {};
|
416
|
+
}
|
417
|
+
restoredOption.itemStyle.color = '#ffc91f'; // Default yellow from railspulse theme
|
418
|
+
|
419
|
+
this.chart.setOption({
|
420
|
+
series: [restoredOption]
|
421
|
+
}, false); // Use replace mode to ensure clean state
|
422
|
+
} catch (error) {
|
423
|
+
console.error('Error resetting column colors:', error);
|
424
|
+
}
|
425
|
+
}
|
426
|
+
|
427
|
+
sendColumnSelectionRequest(columnIndex) {
|
428
|
+
// Get the timestamp for the selected column
|
429
|
+
const option = this.chart.getOption();
|
430
|
+
const xAxisData = option.xAxis[0].data;
|
431
|
+
const selectedTimestamp = xAxisData[columnIndex];
|
432
|
+
|
433
|
+
if (!selectedTimestamp) {
|
434
|
+
console.error('Could not find timestamp for column index:', columnIndex);
|
435
|
+
return;
|
436
|
+
}
|
437
|
+
|
438
|
+
// Build the request URL with column selection parameter, preserving all existing params including sort
|
439
|
+
const url = new URL(window.location.href);
|
440
|
+
const currentParams = new URLSearchParams(url.search);
|
441
|
+
|
442
|
+
// Keep all existing parameters (including sort like q[s]) and add column selection parameter
|
443
|
+
currentParams.set('selected_column_time', selectedTimestamp);
|
444
|
+
|
445
|
+
// Preserve pagination limit
|
446
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
447
|
+
|
448
|
+
url.search = currentParams.toString();
|
449
|
+
|
450
|
+
// Update browser URL to persist column selection
|
451
|
+
window.history.replaceState({}, '', url);
|
452
|
+
|
453
|
+
// Send the turbo frame request
|
454
|
+
this.executeTurboFrameRequestForColumn(url);
|
455
|
+
}
|
456
|
+
|
457
|
+
sendColumnDeselectionRequest() {
|
458
|
+
// Build the request URL without column selection parameter, preserving all other params including sort
|
459
|
+
const url = new URL(window.location.href);
|
460
|
+
const currentParams = new URLSearchParams(url.search);
|
461
|
+
|
462
|
+
// Remove only the column selection parameter, keep all others (including sort like q[s])
|
463
|
+
currentParams.delete('selected_column_time');
|
464
|
+
|
465
|
+
// Preserve pagination limit
|
466
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
467
|
+
|
468
|
+
url.search = currentParams.toString();
|
469
|
+
|
470
|
+
// Update browser URL to remove column selection
|
471
|
+
window.history.replaceState({}, '', url);
|
472
|
+
|
473
|
+
// Send the turbo frame request to restore default/zoom view
|
474
|
+
this.executeTurboFrameRequestForColumn(url);
|
475
|
+
}
|
476
|
+
|
477
|
+
executeTurboFrameRequestForColumn(url) {
|
478
|
+
fetch(url, {
|
479
|
+
method: 'GET',
|
480
|
+
headers: {
|
481
|
+
'Accept': 'text/vnd.turbo-stream.html, text/html',
|
482
|
+
'Turbo-Frame': this.indexTableTarget.id,
|
483
|
+
'X-Requested-With': 'XMLHttpRequest'
|
484
|
+
}
|
485
|
+
})
|
486
|
+
.then(response => {
|
487
|
+
return response.text();
|
488
|
+
})
|
489
|
+
.then(html => {
|
490
|
+
// Find the turbo-frame in the document using the target
|
491
|
+
const frame = this.indexTableTarget;
|
492
|
+
if (frame) {
|
493
|
+
// Parse the response HTML
|
494
|
+
const parser = new DOMParser();
|
495
|
+
const doc = parser.parseFromString(html, 'text/html');
|
496
|
+
|
497
|
+
// Find the turbo-frame in the response using the frame's ID
|
498
|
+
const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
|
499
|
+
if (responseFrame) {
|
500
|
+
// CSP-safe content replacement using DOM methods
|
501
|
+
this.replaceFrameContent(frame, responseFrame);
|
502
|
+
} else {
|
503
|
+
// Fallback: parse the entire HTML response
|
504
|
+
this.replaceFrameContentFromHTML(frame, html);
|
505
|
+
}
|
506
|
+
}
|
507
|
+
})
|
508
|
+
.catch(error => console.error('[IndexController] Column selection fetch error:', error));
|
509
|
+
}
|
510
|
+
|
511
|
+
initializeColumnSelectionFromUrl() {
|
512
|
+
// Check if there's a selected_column_time parameter in the URL
|
513
|
+
const urlParams = new URLSearchParams(window.location.search);
|
514
|
+
const selectedColumnTime = urlParams.get('selected_column_time');
|
515
|
+
|
516
|
+
if (selectedColumnTime) {
|
517
|
+
// Find the column index that matches this timestamp
|
518
|
+
const option = this.chart.getOption();
|
519
|
+
if (!option.xAxis || !option.xAxis[0] || !option.xAxis[0].data) {
|
520
|
+
return;
|
521
|
+
}
|
522
|
+
|
523
|
+
const xAxisData = option.xAxis[0].data;
|
524
|
+
|
525
|
+
// Try exact match first
|
526
|
+
let columnIndex = xAxisData.findIndex(timestamp => timestamp.toString() === selectedColumnTime);
|
527
|
+
|
528
|
+
// If no exact match, try converting to numbers and comparing
|
529
|
+
if (columnIndex === -1) {
|
530
|
+
const selectedTimeNumber = parseInt(selectedColumnTime);
|
531
|
+
columnIndex = xAxisData.findIndex(timestamp => parseInt(timestamp) === selectedTimeNumber);
|
532
|
+
}
|
533
|
+
|
534
|
+
if (columnIndex !== -1) {
|
535
|
+
// Set the selected column index and apply visual styling
|
536
|
+
this.selectedColumnIndex = columnIndex;
|
537
|
+
// Use requestAnimationFrame to ensure ECharts is ready
|
538
|
+
requestAnimationFrame(() => {
|
539
|
+
this.highlightColumn(columnIndex);
|
540
|
+
});
|
541
|
+
}
|
542
|
+
}
|
543
|
+
}
|
230
544
|
}
|
@@ -11,33 +11,25 @@ export default class extends Controller {
|
|
11
11
|
this.restorePaginationLimit()
|
12
12
|
}
|
13
13
|
|
14
|
-
// Update pagination limit
|
15
|
-
|
14
|
+
// Update pagination limit and refresh the turbo frame
|
15
|
+
updateLimit() {
|
16
16
|
const limit = this.limitTarget.value
|
17
17
|
|
18
|
-
// Save to session storage
|
18
|
+
// Save to session storage only - no server request needed
|
19
19
|
sessionStorage.setItem(this.storageKeyValue, limit)
|
20
20
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
// Reload the page to reflect the new pagination limit
|
34
|
-
// This preserves all current URL parameters including Ransack search params
|
35
|
-
window.location.reload()
|
36
|
-
} else {
|
37
|
-
throw new Error(`HTTP error! status: ${response.status}`)
|
38
|
-
}
|
39
|
-
} catch (error) {
|
40
|
-
console.error('Error updating pagination limit:', error)
|
21
|
+
// Find the closest turbo frame and reload it to apply new pagination
|
22
|
+
const turboFrame = this.element.closest('turbo-frame')
|
23
|
+
if (turboFrame) {
|
24
|
+
// Add the limit as a URL parameter so server picks it up
|
25
|
+
const currentUrl = new URL(window.location)
|
26
|
+
currentUrl.searchParams.set('limit', limit)
|
27
|
+
turboFrame.src = currentUrl.pathname + currentUrl.search
|
28
|
+
} else {
|
29
|
+
// Fallback to page reload if not within a turbo frame
|
30
|
+
const currentUrl = new URL(window.location)
|
31
|
+
currentUrl.searchParams.set('limit', limit)
|
32
|
+
window.location.href = currentUrl.pathname + currentUrl.search
|
41
33
|
}
|
42
34
|
}
|
43
35
|
|
@@ -60,10 +52,8 @@ export default class extends Controller {
|
|
60
52
|
// Only set if the current value is different (prevents unnecessary DOM updates)
|
61
53
|
if (this.limitTarget.value !== savedLimit) {
|
62
54
|
this.limitTarget.value = savedLimit
|
63
|
-
|
64
|
-
// Trigger a change event to ensure any other listeners are notified
|
65
|
-
this.limitTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
55
|
+
// Don't trigger change event when restoring from session - prevents infinite loops
|
66
56
|
}
|
67
57
|
}
|
68
58
|
}
|
69
|
-
}
|
59
|
+
}
|
@@ -1,9 +1,33 @@
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
2
2
|
import { computePosition, flip, shift, offset, autoUpdate } from "@floating-ui/dom"
|
3
3
|
|
4
|
+
/**
|
5
|
+
* Popover Stimulus Controller
|
6
|
+
*
|
7
|
+
* Usage:
|
8
|
+
* <div data-controller="popover" data-popover-placement-value="top">
|
9
|
+
* <button data-popover-target="button" data-action="click->popover#toggle">Toggle</button>
|
10
|
+
* <div data-popover-target="menu" popover>Menu content</div>
|
11
|
+
* </div>
|
12
|
+
*
|
13
|
+
* Targets:
|
14
|
+
* - button: The element that triggers the popover
|
15
|
+
* - menu: The popover content element
|
16
|
+
*
|
17
|
+
* Values:
|
18
|
+
* - placement: Controls popover positioning (default: "top")
|
19
|
+
* Valid values: "top", "top-start", "top-end", "bottom", "bottom-start",
|
20
|
+
* "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end"
|
21
|
+
*
|
22
|
+
* Features:
|
23
|
+
* - Auto-positioning with collision detection
|
24
|
+
* - Lazy loading of operation details via Turbo frames
|
25
|
+
* - CSP-compliant styling using CSS custom properties
|
26
|
+
*/
|
27
|
+
|
4
28
|
export default class extends Controller {
|
5
29
|
static targets = [ "button", "menu" ]
|
6
|
-
static values = { placement: { type: String, default: "
|
30
|
+
static values = { placement: { type: String, default: "top" } }
|
7
31
|
|
8
32
|
#showTimer = null
|
9
33
|
#hideTimer = null
|
@@ -64,16 +88,16 @@ export default class extends Controller {
|
|
64
88
|
// Check if this popover has operation details to load
|
65
89
|
const operationUrl = this.menuTarget.dataset.operationUrl
|
66
90
|
if (!operationUrl) return
|
67
|
-
|
91
|
+
|
68
92
|
// Find the turbo frame inside the popover
|
69
93
|
const turboFrame = this.menuTarget.querySelector('turbo-frame')
|
70
94
|
if (!turboFrame) return
|
71
|
-
|
95
|
+
|
72
96
|
// Only load if not already loaded (check if still shows loading content)
|
73
97
|
// Use CSP-safe method to check for loading content
|
74
98
|
const hasLoadingContent = this.hasLoadingContent(turboFrame)
|
75
99
|
if (!hasLoadingContent) return
|
76
|
-
|
100
|
+
|
77
101
|
// Set the src attribute to trigger the turbo frame loading
|
78
102
|
turboFrame.src = operationUrl
|
79
103
|
}
|
@@ -0,0 +1,14 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
updateUrl(event) {
|
5
|
+
// Get the href from the clicked link
|
6
|
+
const link = event.currentTarget;
|
7
|
+
const href = link.getAttribute('href');
|
8
|
+
|
9
|
+
if (href) {
|
10
|
+
// Update the browser URL to match the sort link
|
11
|
+
window.history.replaceState({}, '', href);
|
12
|
+
}
|
13
|
+
}
|
14
|
+
}
|