rails_pulse 0.1.2 → 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 +10 -4
- 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 -5
- 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/zoom_range_concern.rb +31 -0
- data/app/controllers/rails_pulse/application_controller.rb +5 -1
- data/app/controllers/rails_pulse/queries_controller.rb +46 -1
- data/app/controllers/rails_pulse/requests_controller.rb +14 -1
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/chart_helper.rb +15 -7
- 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 +241 -11
- 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/models/rails_pulse/queries/cards/average_query_times.rb +19 -19
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +13 -8
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +13 -8
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +17 -19
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +13 -8
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +13 -8
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +13 -8
- 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/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +0 -2
- 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 +1 -1
- data/app/views/rails_pulse/components/_metric_card.html.erb +27 -4
- 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 +1 -1
- 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 +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +1 -1
- data/app/views/rails_pulse/queries/index.html.erb +48 -51
- data/app/views/rails_pulse/queries/show.html.erb +56 -52
- data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
- data/app/views/rails_pulse/requests/_table.html.erb +3 -1
- data/app/views/rails_pulse/requests/index.html.erb +48 -51
- data/app/views/rails_pulse/routes/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +4 -4
- data/config/routes.rb +5 -1
- data/db/migrate/20250916031656_add_analysis_to_rails_pulse_queries.rb +13 -0
- data/db/rails_pulse_schema.rb +9 -0
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +65 -0
- data/lib/generators/rails_pulse/install_generator.rb +71 -18
- 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/upgrade_generator.rb +225 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +27 -8
- 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
- metadata +23 -5
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
@@ -0,0 +1,58 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
toggle(event) {
|
5
|
+
// Delegate clicks from tbody to the nearest row
|
6
|
+
const triggerRow = event.target.closest('tr')
|
7
|
+
if (!triggerRow || triggerRow.closest('tbody') !== this.element) return
|
8
|
+
|
9
|
+
// Ignore clicks on the details row itself
|
10
|
+
if (triggerRow.classList.contains('operation-details-row')) return
|
11
|
+
|
12
|
+
// Do not toggle when clicking the final Actions column
|
13
|
+
const clickedCell = event.target.closest('td,th')
|
14
|
+
if (clickedCell && clickedCell.parentElement === triggerRow) {
|
15
|
+
const isLastCell = clickedCell.cellIndex === (triggerRow.cells.length - 1)
|
16
|
+
if (isLastCell) return
|
17
|
+
}
|
18
|
+
|
19
|
+
event.preventDefault()
|
20
|
+
event.stopPropagation()
|
21
|
+
|
22
|
+
const detailsRow = triggerRow.nextElementSibling
|
23
|
+
if (!detailsRow || detailsRow.tagName !== 'TR' || !detailsRow.classList.contains('operation-details-row')) return
|
24
|
+
|
25
|
+
const isHidden = detailsRow.classList.contains('hidden')
|
26
|
+
if (isHidden) {
|
27
|
+
this.expand(triggerRow, detailsRow)
|
28
|
+
} else {
|
29
|
+
this.collapse(triggerRow, detailsRow)
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
expand(triggerRow, detailsRow) {
|
34
|
+
detailsRow.classList.remove('hidden')
|
35
|
+
|
36
|
+
// Rotate chevron to point down
|
37
|
+
const chevron = triggerRow.querySelector('.chevron')
|
38
|
+
if (chevron) chevron.style.transform = 'rotate(90deg)'
|
39
|
+
|
40
|
+
triggerRow.classList.add('expanded')
|
41
|
+
|
42
|
+
// Lazy load operation details once
|
43
|
+
const frame = detailsRow.querySelector('turbo-frame')
|
44
|
+
if (frame && !frame.getAttribute('src')) {
|
45
|
+
const url = frame.dataset.operationUrl
|
46
|
+
if (url) frame.setAttribute('src', url)
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
collapse(triggerRow, detailsRow) {
|
51
|
+
detailsRow.classList.add('hidden')
|
52
|
+
|
53
|
+
const chevron = triggerRow.querySelector('.chevron')
|
54
|
+
if (chevron) chevron.style.transform = 'rotate(0deg)'
|
55
|
+
|
56
|
+
triggerRow.classList.remove('expanded')
|
57
|
+
}
|
58
|
+
}
|
@@ -11,6 +11,8 @@ export default class extends Controller {
|
|
11
11
|
lastTurboFrameRequestAt = 0;
|
12
12
|
pendingRequestTimeout = null;
|
13
13
|
pendingRequestData = null;
|
14
|
+
selectedColumnIndex = null;
|
15
|
+
originalSeriesOption = null;
|
14
16
|
|
15
17
|
connect() {
|
16
18
|
// Listen for the custom event 'chart:initialized' to set up the chart.
|
@@ -61,16 +63,20 @@ export default class extends Controller {
|
|
61
63
|
} catch (e) {
|
62
64
|
hasTarget = false;
|
63
65
|
}
|
64
|
-
|
66
|
+
|
65
67
|
// Get the chart element which the RailsCharts library has created
|
66
68
|
this.chart = window.RailsCharts.charts[this.chartIdValue];
|
67
|
-
|
69
|
+
|
68
70
|
// Only proceed if we have BOTH the DOM target and the chart object
|
69
71
|
if (!hasTarget || !this.chart) {
|
70
72
|
return;
|
71
73
|
}
|
72
74
|
|
73
75
|
this.visibleData = this.getVisibleData();
|
76
|
+
|
77
|
+
// Store the original series configuration BEFORE any modifications
|
78
|
+
this.storeOriginalSeriesOption();
|
79
|
+
|
74
80
|
this.setupChartEventListeners();
|
75
81
|
this.setupDone = true;
|
76
82
|
|
@@ -78,6 +84,10 @@ export default class extends Controller {
|
|
78
84
|
if (hasTarget) {
|
79
85
|
document.getElementById(this.chartIdValue)?.setAttribute('data-chart-rendered', 'true');
|
80
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();
|
81
91
|
}
|
82
92
|
|
83
93
|
// Add some event listeners to the chart so we can track the zoom changes
|
@@ -104,6 +114,11 @@ export default class extends Controller {
|
|
104
114
|
this.handleZoomChange();
|
105
115
|
};
|
106
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
|
+
});
|
107
122
|
}
|
108
123
|
|
109
124
|
// This returns the visible data from the chart based on the current zoom level.
|
@@ -166,7 +181,7 @@ export default class extends Controller {
|
|
166
181
|
const startTimestamp = data.xAxis[0];
|
167
182
|
const endTimestamp = data.xAxis[data.xAxis.length - 1];
|
168
183
|
|
169
|
-
// Update zoom parameters in URL
|
184
|
+
// Update zoom parameters in URL while preserving all other parameters including sort
|
170
185
|
currentParams.set('zoom_start_time', startTimestamp);
|
171
186
|
currentParams.set('zoom_end_time', endTimestamp);
|
172
187
|
|
@@ -189,15 +204,15 @@ export default class extends Controller {
|
|
189
204
|
sendTurboFrameRequest(data) {
|
190
205
|
const now = Date.now();
|
191
206
|
const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
|
192
|
-
|
207
|
+
|
193
208
|
// Store the latest data for potential delayed execution
|
194
209
|
this.pendingRequestData = data;
|
195
|
-
|
210
|
+
|
196
211
|
// Clear any existing timeout
|
197
212
|
if (this.pendingRequestTimeout) {
|
198
213
|
clearTimeout(this.pendingRequestTimeout);
|
199
214
|
}
|
200
|
-
|
215
|
+
|
201
216
|
// If enough time has passed since last request, execute immediately
|
202
217
|
if (timeSinceLastRequest >= 1000) {
|
203
218
|
this.executeTurboFrameRequest(data);
|
@@ -215,10 +230,10 @@ export default class extends Controller {
|
|
215
230
|
executeTurboFrameRequest(data) {
|
216
231
|
this.lastTurboFrameRequestAt = Date.now();
|
217
232
|
|
218
|
-
// Start with the current page's URL
|
233
|
+
// Start with the current page's URL to preserve all existing parameters including sort
|
219
234
|
const url = new URL(window.location.href);
|
220
235
|
|
221
|
-
// Preserve existing URL parameters
|
236
|
+
// Preserve existing URL parameters (including sort parameters like q[s])
|
222
237
|
const currentParams = new URLSearchParams(url.search);
|
223
238
|
|
224
239
|
const startTimestamp = data.xAxis[0];
|
@@ -229,7 +244,7 @@ export default class extends Controller {
|
|
229
244
|
currentParams.set('zoom_end_time', endTimestamp);
|
230
245
|
|
231
246
|
// Set the limit param based on the value in the pagination selector
|
232
|
-
|
247
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
233
248
|
|
234
249
|
// Update the URL's search parameters
|
235
250
|
url.search = currentParams.toString();
|
@@ -237,8 +252,9 @@ export default class extends Controller {
|
|
237
252
|
fetch(url, {
|
238
253
|
method: 'GET',
|
239
254
|
headers: {
|
240
|
-
'Accept': 'text/html
|
241
|
-
'Turbo-Frame': this.
|
255
|
+
'Accept': 'text/vnd.turbo-stream.html, text/html',
|
256
|
+
'Turbo-Frame': this.indexTableTarget.id,
|
257
|
+
'X-Requested-With': 'XMLHttpRequest'
|
242
258
|
}
|
243
259
|
})
|
244
260
|
.then(response => {
|
@@ -311,4 +327,218 @@ export default class extends Controller {
|
|
311
327
|
targetFrame.innerHTML = html;
|
312
328
|
}
|
313
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
|
+
}
|
314
544
|
}
|
@@ -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
|
+
}
|
@@ -36,27 +36,27 @@ module RailsPulse
|
|
36
36
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
|
37
37
|
trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
|
38
38
|
|
39
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
39
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
40
|
+
# Use Groupdate to get grouped sums and compute weighted averages per day
|
41
|
+
grouped_weighted = base_query
|
42
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
43
|
+
.sum(Arel.sql("avg_duration * count"))
|
44
44
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
else
|
49
|
-
sparkline_data[formatted_date] = {
|
50
|
-
total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
|
51
|
-
total_count: (summary.count || 0)
|
52
|
-
}
|
53
|
-
end
|
54
|
-
end
|
45
|
+
grouped_counts = base_query
|
46
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
47
|
+
.sum(:count)
|
55
48
|
|
56
|
-
#
|
57
|
-
|
58
|
-
|
59
|
-
|
49
|
+
# Build a continuous 14-day range, fill missing days with 0
|
50
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
51
|
+
end_day = Time.current.to_date
|
52
|
+
|
53
|
+
sparkline_data = {}
|
54
|
+
(start_day..end_day).each do |day|
|
55
|
+
weighted_sum = grouped_weighted[day] || 0
|
56
|
+
count_sum = grouped_counts[day] || 0
|
57
|
+
avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
|
58
|
+
label = day.strftime("%b %-d")
|
59
|
+
sparkline_data[label] = { value: avg }
|
60
60
|
end
|
61
61
|
|
62
62
|
{
|
@@ -33,15 +33,20 @@ module RailsPulse
|
|
33
33
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
|
34
34
|
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
|
35
35
|
|
36
|
-
#
|
37
|
-
|
38
|
-
.
|
36
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
37
|
+
grouped_daily = base_query
|
38
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
39
39
|
.sum(:count)
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
40
|
+
|
41
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
42
|
+
end_day = Time.current.to_date
|
43
|
+
|
44
|
+
sparkline_data = {}
|
45
|
+
(start_day..end_day).each do |day|
|
46
|
+
total = grouped_daily[day] || 0
|
47
|
+
label = day.strftime("%b %-d")
|
48
|
+
sparkline_data[label] = { value: total }
|
49
|
+
end
|
45
50
|
|
46
51
|
# Calculate average executions per minute over 2-week period
|
47
52
|
total_minutes = 2.weeks / 1.minute
|
@@ -33,15 +33,20 @@ module RailsPulse
|
|
33
33
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_p95 < previous_period_p95 ? "trending-down" : "trending-up"
|
34
34
|
trend_amount = previous_period_p95.zero? ? "0%" : "#{percentage}%"
|
35
35
|
|
36
|
-
#
|
37
|
-
|
38
|
-
.
|
36
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
37
|
+
grouped_daily = base_query
|
38
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
39
39
|
.average(:p95_duration)
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
40
|
+
|
41
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
42
|
+
end_day = Time.current.to_date
|
43
|
+
|
44
|
+
sparkline_data = {}
|
45
|
+
(start_day..end_day).each do |day|
|
46
|
+
avg = grouped_daily[day]&.round(0) || 0
|
47
|
+
label = day.strftime("%b %-d")
|
48
|
+
sparkline_data[label] = { value: avg }
|
49
|
+
end
|
45
50
|
|
46
51
|
{
|
47
52
|
id: "percentile_query_times",
|
@@ -9,6 +9,13 @@ module RailsPulse
|
|
9
9
|
# Validations
|
10
10
|
validates :normalized_sql, presence: true, uniqueness: true
|
11
11
|
|
12
|
+
# JSON serialization for analysis columns
|
13
|
+
serialize :issues, type: Array, coder: JSON
|
14
|
+
serialize :metadata, type: Hash, coder: JSON
|
15
|
+
serialize :query_stats, type: Hash, coder: JSON
|
16
|
+
serialize :backtrace_analysis, type: Hash, coder: JSON
|
17
|
+
serialize :suggestions, type: Array, coder: JSON
|
18
|
+
|
12
19
|
def self.ransackable_attributes(auth_object = nil)
|
13
20
|
%w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
|
14
21
|
end
|
@@ -52,6 +59,45 @@ module RailsPulse
|
|
52
59
|
Arel.sql("MAX(rails_pulse_operations.occurred_at)")
|
53
60
|
end
|
54
61
|
|
62
|
+
# Analysis helper methods
|
63
|
+
def analyzed?
|
64
|
+
analyzed_at.present?
|
65
|
+
end
|
66
|
+
|
67
|
+
def has_recent_operations?
|
68
|
+
operations.where("occurred_at > ?", 48.hours.ago).exists?
|
69
|
+
end
|
70
|
+
|
71
|
+
def needs_reanalysis?
|
72
|
+
return true unless analyzed?
|
73
|
+
|
74
|
+
# Check if there are new operations since analysis
|
75
|
+
last_operation_time = operations.maximum(:occurred_at)
|
76
|
+
return false unless last_operation_time
|
77
|
+
|
78
|
+
last_operation_time > analyzed_at
|
79
|
+
end
|
80
|
+
|
81
|
+
def analysis_status
|
82
|
+
return "not_analyzed" unless analyzed?
|
83
|
+
return "needs_update" if needs_reanalysis?
|
84
|
+
"current"
|
85
|
+
end
|
86
|
+
|
87
|
+
def issues_by_severity
|
88
|
+
return {} unless analyzed? && issues.present?
|
89
|
+
|
90
|
+
issues.group_by { |issue| issue["severity"] || "unknown" }
|
91
|
+
end
|
92
|
+
|
93
|
+
def critical_issues_count
|
94
|
+
issues_by_severity["critical"]&.count || 0
|
95
|
+
end
|
96
|
+
|
97
|
+
def warning_issues_count
|
98
|
+
issues_by_severity["warning"]&.count || 0
|
99
|
+
end
|
100
|
+
|
55
101
|
def to_s
|
56
102
|
id
|
57
103
|
end
|
@@ -36,27 +36,25 @@ module RailsPulse
|
|
36
36
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
|
37
37
|
trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
|
38
38
|
|
39
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
formatted_date = week_start.strftime("%b %-d")
|
39
|
+
# Sparkline data by day with zero-filled days over the last 14 days
|
40
|
+
grouped_weighted = base_query
|
41
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
42
|
+
.sum(Arel.sql("avg_duration * count"))
|
44
43
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
else
|
49
|
-
sparkline_data[formatted_date] = {
|
50
|
-
total_weighted: (summary.avg_duration || 0) * (summary.count || 0),
|
51
|
-
total_count: (summary.count || 0)
|
52
|
-
}
|
53
|
-
end
|
54
|
-
end
|
44
|
+
grouped_counts = base_query
|
45
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
46
|
+
.sum(:count)
|
55
47
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
48
|
+
start_day = 2.weeks.ago.beginning_of_day.to_date
|
49
|
+
end_day = Time.current.to_date
|
50
|
+
|
51
|
+
sparkline_data = {}
|
52
|
+
(start_day..end_day).each do |day|
|
53
|
+
weighted_sum = grouped_weighted[day] || 0
|
54
|
+
count_sum = grouped_counts[day] || 0
|
55
|
+
avg = count_sum > 0 ? (weighted_sum.to_f / count_sum).round(0) : 0
|
56
|
+
label = day.strftime("%b %-d")
|
57
|
+
sparkline_data[label] = { value: avg }
|
60
58
|
end
|
61
59
|
|
62
60
|
{
|