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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +79 -177
  3. data/Rakefile +77 -2
  4. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  5. data/app/assets/images/rails_pulse/request.png +0 -0
  6. data/app/assets/stylesheets/rails_pulse/application.css +28 -17
  7. data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
  8. data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
  9. data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
  10. data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
  11. data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
  12. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
  13. data/app/controllers/concerns/chart_table_concern.rb +21 -4
  14. data/app/controllers/concerns/response_range_concern.rb +6 -3
  15. data/app/controllers/concerns/time_range_concern.rb +5 -10
  16. data/app/controllers/concerns/zoom_range_concern.rb +32 -1
  17. data/app/controllers/rails_pulse/application_controller.rb +13 -5
  18. data/app/controllers/rails_pulse/dashboard_controller.rb +12 -0
  19. data/app/controllers/rails_pulse/queries_controller.rb +111 -51
  20. data/app/controllers/rails_pulse/requests_controller.rb +37 -12
  21. data/app/controllers/rails_pulse/routes_controller.rb +98 -24
  22. data/app/helpers/rails_pulse/application_helper.rb +0 -1
  23. data/app/helpers/rails_pulse/chart_formatters.rb +3 -3
  24. data/app/helpers/rails_pulse/chart_helper.rb +21 -9
  25. data/app/helpers/rails_pulse/status_helper.rb +10 -4
  26. data/app/javascript/rails_pulse/application.js +34 -3
  27. data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
  28. data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
  29. data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
  30. data/app/javascript/rails_pulse/controllers/index_controller.js +353 -39
  31. data/app/javascript/rails_pulse/controllers/pagination_controller.js +17 -27
  32. data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
  33. data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
  34. data/app/jobs/rails_pulse/backfill_summaries_job.rb +41 -0
  35. data/app/jobs/rails_pulse/summary_job.rb +53 -0
  36. data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +28 -7
  37. data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +18 -22
  38. data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +18 -7
  39. data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +34 -41
  40. data/app/models/rails_pulse/operation.rb +1 -1
  41. data/app/models/rails_pulse/queries/cards/average_query_times.rb +49 -25
  42. data/app/models/rails_pulse/queries/cards/execution_rate.rb +40 -28
  43. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +37 -43
  44. data/app/models/rails_pulse/queries/charts/average_query_times.rb +23 -97
  45. data/app/models/rails_pulse/queries/tables/index.rb +74 -0
  46. data/app/models/rails_pulse/query.rb +47 -0
  47. data/app/models/rails_pulse/requests/charts/average_response_times.rb +23 -84
  48. data/app/models/rails_pulse/route.rb +1 -6
  49. data/app/models/rails_pulse/routes/cards/average_response_times.rb +45 -25
  50. data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +43 -45
  51. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +36 -44
  52. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +37 -27
  53. data/app/models/rails_pulse/routes/charts/average_response_times.rb +23 -100
  54. data/app/models/rails_pulse/routes/tables/index.rb +57 -40
  55. data/app/models/rails_pulse/summary.rb +143 -0
  56. data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
  57. data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
  58. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
  59. data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
  60. data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
  61. data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +146 -0
  62. data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
  63. data/app/services/rails_pulse/query_analysis_service.rb +125 -0
  64. data/app/services/rails_pulse/summary_service.rb +199 -0
  65. data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
  66. data/app/views/layouts/rails_pulse/application.html.erb +4 -6
  67. data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
  68. data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
  69. data/app/views/rails_pulse/components/_empty_state.html.erb +11 -0
  70. data/app/views/rails_pulse/components/_metric_card.html.erb +37 -28
  71. data/app/views/rails_pulse/components/_panel.html.erb +1 -1
  72. data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
  73. data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
  74. data/app/views/rails_pulse/dashboard/index.html.erb +55 -37
  75. data/app/views/rails_pulse/operations/show.html.erb +17 -15
  76. data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
  77. data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
  78. data/app/views/rails_pulse/queries/_analysis_results.html.erb +87 -0
  79. data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
  80. data/app/views/rails_pulse/queries/_show_table.html.erb +2 -2
  81. data/app/views/rails_pulse/queries/_table.html.erb +11 -13
  82. data/app/views/rails_pulse/queries/index.html.erb +32 -28
  83. data/app/views/rails_pulse/queries/show.html.erb +45 -34
  84. data/app/views/rails_pulse/requests/_operations.html.erb +38 -45
  85. data/app/views/rails_pulse/requests/_table.html.erb +3 -3
  86. data/app/views/rails_pulse/requests/index.html.erb +33 -28
  87. data/app/views/rails_pulse/routes/_table.html.erb +14 -14
  88. data/app/views/rails_pulse/routes/index.html.erb +34 -29
  89. data/app/views/rails_pulse/routes/show.html.erb +43 -36
  90. data/config/initializers/rails_pulse.rb +0 -12
  91. data/config/routes.rb +5 -1
  92. data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +54 -0
  93. data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
  94. data/db/rails_pulse_schema.rb +130 -0
  95. data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
  96. data/lib/generators/rails_pulse/install_generator.rb +94 -4
  97. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +60 -0
  98. data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +22 -0
  99. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
  100. data/lib/generators/rails_pulse/templates/rails_pulse.rb +0 -12
  101. data/lib/generators/rails_pulse/upgrade_generator.rb +225 -0
  102. data/lib/rails_pulse/configuration.rb +0 -11
  103. data/lib/rails_pulse/engine.rb +0 -1
  104. data/lib/rails_pulse/version.rb +1 -1
  105. data/lib/tasks/rails_pulse.rake +77 -0
  106. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  107. data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
  108. data/public/rails-pulse-assets/rails-pulse.js +53 -53
  109. data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
  110. data/public/rails-pulse-assets/search.svg +43 -0
  111. metadata +48 -14
  112. data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
  113. data/app/assets/images/rails_pulse/routes.png +0 -0
  114. data/app/controllers/rails_pulse/caches_controller.rb +0 -115
  115. data/app/helpers/rails_pulse/cached_component_helper.rb +0 -73
  116. data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
  117. data/app/models/rails_pulse/component_cache_key.rb +0 -33
  118. data/app/views/rails_pulse/caches/show.html.erb +0 -9
  119. data/db/migrate/20250227235904_create_routes.rb +0 -12
  120. data/db/migrate/20250227235915_create_requests.rb +0 -19
  121. data/db/migrate/20250228000000_create_queries.rb +0 -14
  122. data/db/migrate/20250228000056_create_operations.rb +0 -24
  123. 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"] // The chart element to be monitored
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 a property to track the last request time
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
- document.addEventListener('chart:initialized', this.handleChartInitialized);
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]) { this.setup(); }
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:initialized', this.handleChartInitialized);
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.chartId === this.chartIdValue) { this.setup(); }
49
+ if (event.detail.containerId === this.chartIdValue) {
50
+ this.setup();
51
+ }
38
52
  }
39
53
 
40
54
  setup() {
41
- if (this.setupDone) return; // Prevent multiple setups
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
- if (!this.chart) return;
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 = () => { this.visibleData = this.getVisibleData(); };
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 = () => { this.handleZoomChange(); };
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', () => { this.handleZoomChange(); });
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 = () => { this.handleZoomChange(); };
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
- 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;
128
+ try {
129
+ const currentOption = this.chart.getOption();
78
130
 
79
- const startValue = dataZoom.startValue;
80
- const endValue = dataZoom.endValue;
131
+ if (!currentOption.dataZoom || currentOption.dataZoom.length === 0) {
132
+ return { xAxis: [], series: [] };
133
+ }
81
134
 
82
- return {
83
- xAxis: xAxisData.slice(startValue, endValue + 1),
84
- series: seriesData.slice(startValue, endValue + 1)
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
- if (newVisibleData.xAxis.join() !== this.visibleData.xAxis.join()) {
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
- // 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.
203
+ // Improved debouncing with guaranteed final request
130
204
  sendTurboFrameRequest(data) {
131
205
  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;
206
+ const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
135
207
 
136
- // Start with the current page's URL
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
- url.searchParams.set('limit', this.paginationLimitTarget.value);
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; turbo-frame',
159
- 'Turbo-Frame': this.chartIdValue
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 => response.text()) // Get the raw HTML 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('Error:', 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 via AJAX and reload the page to reflect changes
15
- async updateLimit() {
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
- try {
22
- // Send AJAX request to update server session using Rails.ajax
23
- const response = await fetch(this.urlValue, {
24
- method: 'PATCH',
25
- headers: {
26
- 'Content-Type': 'application/json',
27
- 'X-CSRF-Token': this.getCSRFToken()
28
- },
29
- body: JSON.stringify({ limit: limit })
30
- })
31
-
32
- if (response.ok) {
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: "bottom" } }
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
+ }