rails_pulse 0.1.2 → 0.1.4
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 +66 -20
- data/Rakefile +169 -86
- 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 +49 -10
- data/app/controllers/rails_pulse/requests_controller.rb +46 -20
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
- data/app/helpers/rails_pulse/chart_helper.rb +16 -8
- data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
- 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 +249 -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/dashboard/tables/slow_queries.rb +1 -1
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
- data/app/models/rails_pulse/requests/tables/index.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
- data/app/models/rails_pulse/routes/tables/index.rb +4 -2
- data/app/models/rails_pulse/summary.rb +7 -7
- 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 +154 -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 +28 -5
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- 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 +2 -2
- 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 +117 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
- data/app/views/rails_pulse/queries/_table.html.erb +4 -8
- 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 +31 -18
- data/app/views/rails_pulse/requests/index.html.erb +55 -50
- data/app/views/rails_pulse/requests/show.html.erb +0 -2
- data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
- data/app/views/rails_pulse/routes/_table.html.erb +4 -10
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +6 -8
- data/config/initializers/rails_charts_csp_patch.rb +32 -40
- data/config/routes.rb +5 -1
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
- data/db/rails_pulse_schema.rb +10 -1
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
- data/lib/generators/rails_pulse/install_generator.rb +75 -18
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
- data/lib/rails_pulse/engine.rb +21 -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 +25 -6
- 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
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -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
|
|
@@ -177,6 +192,8 @@ export default class extends Controller {
|
|
177
192
|
updatePaginationLimit() {
|
178
193
|
// Update or set the limit param in the browser so if the user refreshes the page,
|
179
194
|
// the limit will be preserved.
|
195
|
+
if (!this.hasPaginationLimitTarget) return;
|
196
|
+
|
180
197
|
const url = new URL(window.location.href);
|
181
198
|
const currentParams = new URLSearchParams(url.search);
|
182
199
|
const limit = this.paginationLimitTarget.value;
|
@@ -189,15 +206,15 @@ export default class extends Controller {
|
|
189
206
|
sendTurboFrameRequest(data) {
|
190
207
|
const now = Date.now();
|
191
208
|
const timeSinceLastRequest = now - this.lastTurboFrameRequestAt;
|
192
|
-
|
209
|
+
|
193
210
|
// Store the latest data for potential delayed execution
|
194
211
|
this.pendingRequestData = data;
|
195
|
-
|
212
|
+
|
196
213
|
// Clear any existing timeout
|
197
214
|
if (this.pendingRequestTimeout) {
|
198
215
|
clearTimeout(this.pendingRequestTimeout);
|
199
216
|
}
|
200
|
-
|
217
|
+
|
201
218
|
// If enough time has passed since last request, execute immediately
|
202
219
|
if (timeSinceLastRequest >= 1000) {
|
203
220
|
this.executeTurboFrameRequest(data);
|
@@ -215,10 +232,10 @@ export default class extends Controller {
|
|
215
232
|
executeTurboFrameRequest(data) {
|
216
233
|
this.lastTurboFrameRequestAt = Date.now();
|
217
234
|
|
218
|
-
// Start with the current page's URL
|
235
|
+
// Start with the current page's URL to preserve all existing parameters including sort
|
219
236
|
const url = new URL(window.location.href);
|
220
237
|
|
221
|
-
// Preserve existing URL parameters
|
238
|
+
// Preserve existing URL parameters (including sort parameters like q[s])
|
222
239
|
const currentParams = new URLSearchParams(url.search);
|
223
240
|
|
224
241
|
const startTimestamp = data.xAxis[0];
|
@@ -229,7 +246,9 @@ export default class extends Controller {
|
|
229
246
|
currentParams.set('zoom_end_time', endTimestamp);
|
230
247
|
|
231
248
|
// Set the limit param based on the value in the pagination selector
|
232
|
-
|
249
|
+
if (this.hasPaginationLimitTarget) {
|
250
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
251
|
+
}
|
233
252
|
|
234
253
|
// Update the URL's search parameters
|
235
254
|
url.search = currentParams.toString();
|
@@ -237,8 +256,9 @@ export default class extends Controller {
|
|
237
256
|
fetch(url, {
|
238
257
|
method: 'GET',
|
239
258
|
headers: {
|
240
|
-
'Accept': 'text/html
|
241
|
-
'Turbo-Frame': this.
|
259
|
+
'Accept': 'text/vnd.turbo-stream.html, text/html',
|
260
|
+
'Turbo-Frame': this.indexTableTarget.id,
|
261
|
+
'X-Requested-With': 'XMLHttpRequest'
|
242
262
|
}
|
243
263
|
})
|
244
264
|
.then(response => {
|
@@ -311,4 +331,222 @@ export default class extends Controller {
|
|
311
331
|
targetFrame.innerHTML = html;
|
312
332
|
}
|
313
333
|
}
|
334
|
+
|
335
|
+
handleColumnClick(params) {
|
336
|
+
const clickedIndex = params.dataIndex;
|
337
|
+
|
338
|
+
// If clicking the same column that's already selected, deselect all
|
339
|
+
if (this.selectedColumnIndex === clickedIndex) {
|
340
|
+
this.resetColumnColors();
|
341
|
+
this.selectedColumnIndex = null;
|
342
|
+
this.sendColumnDeselectionRequest();
|
343
|
+
} else {
|
344
|
+
// Select the clicked column and gray out others
|
345
|
+
this.highlightColumn(clickedIndex);
|
346
|
+
this.selectedColumnIndex = clickedIndex;
|
347
|
+
this.sendColumnSelectionRequest(clickedIndex);
|
348
|
+
}
|
349
|
+
}
|
350
|
+
|
351
|
+
highlightColumn(selectedIndex) {
|
352
|
+
try {
|
353
|
+
const option = this.chart.getOption();
|
354
|
+
if (!option.series || !option.series[0] || !option.series[0].data) {
|
355
|
+
return;
|
356
|
+
}
|
357
|
+
|
358
|
+
const seriesData = option.series[0].data;
|
359
|
+
|
360
|
+
// Instead of changing data structure, modify the series itemStyle
|
361
|
+
const currentOption = this.chart.getOption();
|
362
|
+
|
363
|
+
// Get the default color from the chart theme
|
364
|
+
const defaultColor = currentOption.color?.[0] || '#5470c6'; // ECharts default blue
|
365
|
+
|
366
|
+
this.chart.setOption({
|
367
|
+
series: [{
|
368
|
+
data: seriesData, // Keep original data format for tooltips
|
369
|
+
itemStyle: {
|
370
|
+
color: (params) => {
|
371
|
+
return params.dataIndex === selectedIndex ? defaultColor : '#cccccc';
|
372
|
+
}
|
373
|
+
}
|
374
|
+
}]
|
375
|
+
});
|
376
|
+
} catch (error) {
|
377
|
+
console.error('Error highlighting column:', error);
|
378
|
+
}
|
379
|
+
}
|
380
|
+
|
381
|
+
storeOriginalSeriesOption() {
|
382
|
+
try {
|
383
|
+
const option = this.chart.getOption();
|
384
|
+
if (option.series && option.series[0]) {
|
385
|
+
// Deep clone the original series configuration to restore later
|
386
|
+
const originalSeries = JSON.parse(JSON.stringify(option.series[0]));
|
387
|
+
|
388
|
+
// Ensure we don't store any column selection modifications
|
389
|
+
// Remove any custom itemStyle that might have color functions
|
390
|
+
if (originalSeries.itemStyle && typeof originalSeries.itemStyle.color === 'function') {
|
391
|
+
delete originalSeries.itemStyle.color;
|
392
|
+
}
|
393
|
+
|
394
|
+
this.originalSeriesOption = originalSeries;
|
395
|
+
}
|
396
|
+
} catch (error) {
|
397
|
+
console.error('Error storing original series option:', error);
|
398
|
+
}
|
399
|
+
}
|
400
|
+
|
401
|
+
resetColumnColors() {
|
402
|
+
try {
|
403
|
+
if (!this.originalSeriesOption) {
|
404
|
+
console.warn('No original series option stored, cannot reset properly');
|
405
|
+
return;
|
406
|
+
}
|
407
|
+
|
408
|
+
const option = this.chart.getOption();
|
409
|
+
const seriesData = option.series[0].data;
|
410
|
+
|
411
|
+
// Restore original series configuration but keep current data
|
412
|
+
const restoredOption = {
|
413
|
+
...this.originalSeriesOption,
|
414
|
+
data: seriesData // Keep current data to preserve tooltips
|
415
|
+
};
|
416
|
+
|
417
|
+
// Explicitly set color back to default yellow theme color
|
418
|
+
if (!restoredOption.itemStyle) {
|
419
|
+
restoredOption.itemStyle = {};
|
420
|
+
}
|
421
|
+
restoredOption.itemStyle.color = '#ffc91f'; // Default yellow from railspulse theme
|
422
|
+
|
423
|
+
this.chart.setOption({
|
424
|
+
series: [restoredOption]
|
425
|
+
}, false); // Use replace mode to ensure clean state
|
426
|
+
} catch (error) {
|
427
|
+
console.error('Error resetting column colors:', error);
|
428
|
+
}
|
429
|
+
}
|
430
|
+
|
431
|
+
sendColumnSelectionRequest(columnIndex) {
|
432
|
+
// Get the timestamp for the selected column
|
433
|
+
const option = this.chart.getOption();
|
434
|
+
const xAxisData = option.xAxis[0].data;
|
435
|
+
const selectedTimestamp = xAxisData[columnIndex];
|
436
|
+
|
437
|
+
if (!selectedTimestamp) {
|
438
|
+
console.error('Could not find timestamp for column index:', columnIndex);
|
439
|
+
return;
|
440
|
+
}
|
441
|
+
|
442
|
+
// Build the request URL with column selection parameter, preserving all existing params including sort
|
443
|
+
const url = new URL(window.location.href);
|
444
|
+
const currentParams = new URLSearchParams(url.search);
|
445
|
+
|
446
|
+
// Keep all existing parameters (including sort like q[s]) and add column selection parameter
|
447
|
+
currentParams.set('selected_column_time', selectedTimestamp);
|
448
|
+
|
449
|
+
// Preserve pagination limit
|
450
|
+
if (this.hasPaginationLimitTarget) {
|
451
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
452
|
+
}
|
453
|
+
|
454
|
+
url.search = currentParams.toString();
|
455
|
+
|
456
|
+
// Update browser URL to persist column selection
|
457
|
+
window.history.replaceState({}, '', url);
|
458
|
+
|
459
|
+
// Send the turbo frame request
|
460
|
+
this.executeTurboFrameRequestForColumn(url);
|
461
|
+
}
|
462
|
+
|
463
|
+
sendColumnDeselectionRequest() {
|
464
|
+
// Build the request URL without column selection parameter, preserving all other params including sort
|
465
|
+
const url = new URL(window.location.href);
|
466
|
+
const currentParams = new URLSearchParams(url.search);
|
467
|
+
|
468
|
+
// Remove only the column selection parameter, keep all others (including sort like q[s])
|
469
|
+
currentParams.delete('selected_column_time');
|
470
|
+
|
471
|
+
// Preserve pagination limit
|
472
|
+
if (this.hasPaginationLimitTarget) {
|
473
|
+
currentParams.set('limit', this.paginationLimitTarget.value);
|
474
|
+
}
|
475
|
+
|
476
|
+
url.search = currentParams.toString();
|
477
|
+
|
478
|
+
// Update browser URL to remove column selection
|
479
|
+
window.history.replaceState({}, '', url);
|
480
|
+
|
481
|
+
// Send the turbo frame request to restore default/zoom view
|
482
|
+
this.executeTurboFrameRequestForColumn(url);
|
483
|
+
}
|
484
|
+
|
485
|
+
executeTurboFrameRequestForColumn(url) {
|
486
|
+
fetch(url, {
|
487
|
+
method: 'GET',
|
488
|
+
headers: {
|
489
|
+
'Accept': 'text/vnd.turbo-stream.html, text/html',
|
490
|
+
'Turbo-Frame': this.indexTableTarget.id,
|
491
|
+
'X-Requested-With': 'XMLHttpRequest'
|
492
|
+
}
|
493
|
+
})
|
494
|
+
.then(response => {
|
495
|
+
return response.text();
|
496
|
+
})
|
497
|
+
.then(html => {
|
498
|
+
// Find the turbo-frame in the document using the target
|
499
|
+
const frame = this.indexTableTarget;
|
500
|
+
if (frame) {
|
501
|
+
// Parse the response HTML
|
502
|
+
const parser = new DOMParser();
|
503
|
+
const doc = parser.parseFromString(html, 'text/html');
|
504
|
+
|
505
|
+
// Find the turbo-frame in the response using the frame's ID
|
506
|
+
const responseFrame = doc.querySelector(`turbo-frame#${frame.id}`);
|
507
|
+
if (responseFrame) {
|
508
|
+
// CSP-safe content replacement using DOM methods
|
509
|
+
this.replaceFrameContent(frame, responseFrame);
|
510
|
+
} else {
|
511
|
+
// Fallback: parse the entire HTML response
|
512
|
+
this.replaceFrameContentFromHTML(frame, html);
|
513
|
+
}
|
514
|
+
}
|
515
|
+
})
|
516
|
+
.catch(error => console.error('[IndexController] Column selection fetch error:', error));
|
517
|
+
}
|
518
|
+
|
519
|
+
initializeColumnSelectionFromUrl() {
|
520
|
+
// Check if there's a selected_column_time parameter in the URL
|
521
|
+
const urlParams = new URLSearchParams(window.location.search);
|
522
|
+
const selectedColumnTime = urlParams.get('selected_column_time');
|
523
|
+
|
524
|
+
if (selectedColumnTime) {
|
525
|
+
// Find the column index that matches this timestamp
|
526
|
+
const option = this.chart.getOption();
|
527
|
+
if (!option.xAxis || !option.xAxis[0] || !option.xAxis[0].data) {
|
528
|
+
return;
|
529
|
+
}
|
530
|
+
|
531
|
+
const xAxisData = option.xAxis[0].data;
|
532
|
+
|
533
|
+
// Try exact match first
|
534
|
+
let columnIndex = xAxisData.findIndex(timestamp => timestamp.toString() === selectedColumnTime);
|
535
|
+
|
536
|
+
// If no exact match, try converting to numbers and comparing
|
537
|
+
if (columnIndex === -1) {
|
538
|
+
const selectedTimeNumber = parseInt(selectedColumnTime);
|
539
|
+
columnIndex = xAxisData.findIndex(timestamp => parseInt(timestamp) === selectedTimeNumber);
|
540
|
+
}
|
541
|
+
|
542
|
+
if (columnIndex !== -1) {
|
543
|
+
// Set the selected column index and apply visual styling
|
544
|
+
this.selectedColumnIndex = columnIndex;
|
545
|
+
// Use requestAnimationFrame to ensure ECharts is ready
|
546
|
+
requestAnimationFrame(() => {
|
547
|
+
this.highlightColumn(columnIndex);
|
548
|
+
});
|
549
|
+
}
|
550
|
+
}
|
551
|
+
}
|
314
552
|
}
|
@@ -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
|
+
}
|
@@ -32,7 +32,7 @@ module RailsPulse
|
|
32
32
|
{
|
33
33
|
query_text: truncate_query(record.normalized_sql),
|
34
34
|
query_id: record.query_id,
|
35
|
-
query_link:
|
35
|
+
query_link: RailsPulse::Engine.routes.url_helpers.query_path(record.query_id),
|
36
36
|
average_time: record.avg_duration.to_f.round(0),
|
37
37
|
request_count: record.request_count,
|
38
38
|
last_request: time_ago_in_words(record.last_seen)
|
@@ -33,7 +33,7 @@ module RailsPulse
|
|
33
33
|
{
|
34
34
|
route_path: record.path,
|
35
35
|
route_id: record.route_id,
|
36
|
-
route_link:
|
36
|
+
route_link: RailsPulse::Engine.routes.url_helpers.route_path(record.route_id),
|
37
37
|
average_time: record.avg_duration.to_f.round(0),
|
38
38
|
request_count: record.request_count,
|
39
39
|
last_request: time_ago_in_words(record.last_seen)
|
@@ -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
|
{
|
@@ -64,7 +64,7 @@ module RailsPulse
|
|
64
64
|
context: "queries",
|
65
65
|
title: "Average Query Time",
|
66
66
|
summary: "#{average_query_time} ms",
|
67
|
-
|
67
|
+
chart_data: sparkline_data,
|
68
68
|
trend_icon: trend_icon,
|
69
69
|
trend_amount: trend_amount,
|
70
70
|
trend_text: "Compared to last week"
|
@@ -10,10 +10,20 @@ module RailsPulse
|
|
10
10
|
last_7_days = 7.days.ago.beginning_of_day
|
11
11
|
previous_7_days = 14.days.ago.beginning_of_day
|
12
12
|
|
13
|
+
# Get the most common period type for this query, or fall back to "day"
|
14
|
+
period_type = if @query
|
15
|
+
RailsPulse::Summary.where(
|
16
|
+
summarizable_type: "RailsPulse::Query",
|
17
|
+
summarizable_id: @query.id
|
18
|
+
).group(:period_type).count.max_by(&:last)&.first || "day"
|
19
|
+
else
|
20
|
+
"day"
|
21
|
+
end
|
22
|
+
|
13
23
|
# Single query to get all count metrics with conditional aggregation
|
14
24
|
base_query = RailsPulse::Summary.where(
|
15
25
|
summarizable_type: "RailsPulse::Query",
|
16
|
-
period_type:
|
26
|
+
period_type: period_type,
|
17
27
|
period_start: 2.weeks.ago.beginning_of_day..Time.current
|
18
28
|
)
|
19
29
|
base_query = base_query.where(summarizable_id: @query.id) if @query
|
@@ -33,26 +43,60 @@ module RailsPulse
|
|
33
43
|
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
|
34
44
|
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
|
35
45
|
|
36
|
-
#
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
46
|
+
# Sparkline data with zero-filled periods over the last 14 days
|
47
|
+
if period_type == "day"
|
48
|
+
grouped_data = base_query
|
49
|
+
.group_by_day(:period_start, time_zone: "UTC")
|
50
|
+
.sum(:count)
|
51
|
+
|
52
|
+
start_period = 2.weeks.ago.beginning_of_day.to_date
|
53
|
+
end_period = Time.current.to_date
|
54
|
+
|
55
|
+
sparkline_data = {}
|
56
|
+
(start_period..end_period).each do |day|
|
57
|
+
total = grouped_data[day] || 0
|
58
|
+
label = day.strftime("%b %-d")
|
59
|
+
sparkline_data[label] = { value: total }
|
44
60
|
end
|
61
|
+
else
|
62
|
+
# For hourly data, group by day for sparkline display
|
63
|
+
grouped_data = base_query
|
64
|
+
.group("DATE(period_start)")
|
65
|
+
.sum(:count)
|
66
|
+
|
67
|
+
start_period = 2.weeks.ago.beginning_of_day.to_date
|
68
|
+
end_period = Time.current.to_date
|
69
|
+
|
70
|
+
sparkline_data = {}
|
71
|
+
(start_period..end_period).each do |day|
|
72
|
+
date_key = day.strftime("%Y-%m-%d")
|
73
|
+
total = grouped_data[date_key] || 0
|
74
|
+
label = day.strftime("%b %-d")
|
75
|
+
sparkline_data[label] = { value: total }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Calculate appropriate rate display based on frequency
|
80
|
+
total_minutes = 2.weeks / 1.minute.to_f
|
81
|
+
executions_per_minute = total_execution_count.to_f / total_minutes
|
45
82
|
|
46
|
-
#
|
47
|
-
|
48
|
-
|
83
|
+
# Choose appropriate time unit for display
|
84
|
+
if executions_per_minute >= 1
|
85
|
+
summary = "#{executions_per_minute.round(2)} / min"
|
86
|
+
elsif executions_per_minute * 60 >= 1
|
87
|
+
executions_per_hour = executions_per_minute * 60
|
88
|
+
summary = "#{executions_per_hour.round(2)} / hour"
|
89
|
+
else
|
90
|
+
executions_per_day = executions_per_minute * 60 * 24
|
91
|
+
summary = "#{executions_per_day.round(2)} / day"
|
92
|
+
end
|
49
93
|
|
50
94
|
{
|
51
95
|
id: "execution_rate",
|
52
96
|
context: "queries",
|
53
97
|
title: "Execution Rate",
|
54
|
-
summary:
|
55
|
-
|
98
|
+
summary: summary,
|
99
|
+
chart_data: sparkline_data,
|
56
100
|
trend_icon: trend_icon,
|
57
101
|
trend_amount: trend_amount,
|
58
102
|
trend_text: "Compared to last week"
|
@@ -33,22 +33,27 @@ 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",
|
48
53
|
context: "queries",
|
49
54
|
title: "95th Percentile Query Time",
|
50
55
|
summary: "#{p95_query_time} ms",
|
51
|
-
|
56
|
+
chart_data: sparkline_data,
|
52
57
|
trend_icon: trend_icon,
|
53
58
|
trend_amount: trend_amount,
|
54
59
|
trend_text: "Compared to last week"
|
@@ -12,13 +12,9 @@ module RailsPulse
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def to_rails_chart
|
15
|
-
|
16
|
-
|
17
|
-
period_type: @period_type
|
18
|
-
)
|
19
|
-
|
20
|
-
summaries = summaries.where(summarizable_id: @query.id) if @query
|
21
|
-
summaries = summaries
|
15
|
+
# The ransack query already contains the correct filters, just add period_type
|
16
|
+
summaries = @ransack_query.result(distinct: false)
|
17
|
+
.where(period_type: @period_type)
|
22
18
|
.group(:period_start)
|
23
19
|
.having("AVG(avg_duration) > ?", @start_duration || 0)
|
24
20
|
.average(:avg_duration)
|