pg_reports 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +57 -6
- data/app/views/layouts/pg_reports/application.html.erb +616 -201
- data/app/views/pg_reports/dashboard/_database_selector.html.erb +2 -1
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +10 -7
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +100 -51
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +392 -476
- data/app/views/pg_reports/dashboard/index.html.erb +190 -388
- data/app/views/pg_reports/dashboard/show.html.erb +13 -11
- data/config/brakeman.ignore +24 -1
- data/config/locales/en.yml +53 -48
- data/config/locales/ru.yml +53 -48
- data/config/locales/uk.yml +53 -48
- data/lib/pg_reports/connection/error_translator.rb +43 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +10 -0
- data/lib/pg_reports/explain_analyzer.rb +0 -3
- data/lib/pg_reports/query_monitor.rb +15 -3
- data/lib/pg_reports/version.rb +1 -1
- metadata +1 -1
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
</span>
|
|
35
35
|
<% elsif @database_error %>
|
|
36
36
|
<span class="db-selector-error" title="<%= @database_error[:detail] %>">
|
|
37
|
-
|
|
37
|
+
<svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>
|
|
38
|
+
<%= @database_error[:title] %>
|
|
38
39
|
</span>
|
|
39
40
|
<% end %>
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
<div id="explain-params-inputs"></div>
|
|
72
72
|
</div>
|
|
73
73
|
<div class="explain-actions">
|
|
74
|
-
<button class="btn btn-
|
|
74
|
+
<button class="btn btn-primary" onclick="executeExplainAnalyze()" id="btn-explain">
|
|
75
75
|
<%= t("pg_reports.ui.actions.explain_analyze") %>
|
|
76
76
|
</button>
|
|
77
77
|
<button class="btn btn-secondary" onclick="executeQuery()" id="btn-execute">
|
|
@@ -94,12 +94,15 @@
|
|
|
94
94
|
<button class="modal-close" onclick="closeMigrationModal()">×</button>
|
|
95
95
|
</div>
|
|
96
96
|
<div class="modal-body" id="migration-modal-body">
|
|
97
|
-
<div class="
|
|
98
|
-
<
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
97
|
+
<div class="callout callout-warning">
|
|
98
|
+
<svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>
|
|
99
|
+
<div>
|
|
100
|
+
<strong><%= t("pg_reports.ui.documentation.threshold_warning_label").chomp(":") %></strong>
|
|
101
|
+
<p>
|
|
102
|
+
<%= t("pg_reports.ui.modals.migration_warning") %>
|
|
103
|
+
<strong><%= t("pg_reports.ui.modals.migration_warning_dev_only") %></strong>
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
103
106
|
</div>
|
|
104
107
|
<p class="settings-label"><%= t("pg_reports.ui.modals.migration_subtitle") %></p>
|
|
105
108
|
<div id="migration-code" class="migration-code"></div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
const svgIcon = (name, cls) => `<svg class="icon ${cls || ''}"><use href="#i-${name}"></use></svg>`;
|
|
2
3
|
let currentReportData = null;
|
|
3
4
|
let currentProblemExplanations = {};
|
|
4
5
|
const category = '<%= @category %>';
|
|
@@ -18,8 +19,10 @@
|
|
|
18
19
|
|
|
19
20
|
if (!topScrollWrapper || !topScrollContent || !tableWrapper || !table) return;
|
|
20
21
|
|
|
21
|
-
// Check if table overflows
|
|
22
|
-
|
|
22
|
+
// Check if table overflows. On touch there is no visible scrollbar to
|
|
23
|
+
// drag, so the proxy would only be an empty strip above the table.
|
|
24
|
+
const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
|
|
25
|
+
if (table.scrollWidth > tableWrapper.clientWidth && !coarsePointer) {
|
|
23
26
|
topScrollWrapper.style.display = 'block';
|
|
24
27
|
topScrollContent.style.width = table.scrollWidth + 'px';
|
|
25
28
|
|
|
@@ -126,7 +129,7 @@
|
|
|
126
129
|
if (problemInfo && problemInfo.level) {
|
|
127
130
|
rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
|
|
128
131
|
const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
|
|
129
|
-
const indicatorIcon =
|
|
132
|
+
const indicatorIcon = svgIcon('alert');
|
|
130
133
|
problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showProblemModal(${JSON.stringify(problemInfo.problems).replace(/"/g, '"')}, ${JSON.stringify(row).replace(/"/g, '"')})">${indicatorIcon}</span>`;
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -157,6 +160,8 @@
|
|
|
157
160
|
|
|
158
161
|
tableBody.innerHTML = rowsHtml;
|
|
159
162
|
|
|
163
|
+
markClippedSources(tableBody);
|
|
164
|
+
|
|
160
165
|
// Re-setup top scrollbar
|
|
161
166
|
setTimeout(setupTopScrollbar, 0);
|
|
162
167
|
}
|
|
@@ -409,11 +414,16 @@
|
|
|
409
414
|
let lineNumber = null;
|
|
410
415
|
let methodName = null;
|
|
411
416
|
|
|
412
|
-
// Try to match file:line pattern
|
|
413
|
-
|
|
417
|
+
// Try to match file:line pattern. Deliberately no extension allow-list —
|
|
418
|
+
// source locations legitimately end in .rake, .jbuilder, .ru, .haml, or in
|
|
419
|
+
// no extension at all (Rakefile:5), and anything the list missed silently
|
|
420
|
+
// lost its shortening and its IDE link. Lazy up to the first ":<line>" that
|
|
421
|
+
// ends the token, so a trailing ":in `method'" or a Windows drive letter
|
|
422
|
+
// ("C:\app\x.rb:12") doesn't derail it.
|
|
423
|
+
const fileLineMatch = source.match(/^(.+?):(\d+)(?=$|[:\s])/);
|
|
414
424
|
if (fileLineMatch) {
|
|
415
425
|
filePath = fileLineMatch[1];
|
|
416
|
-
lineNumber = parseInt(fileLineMatch[
|
|
426
|
+
lineNumber = parseInt(fileLineMatch[2], 10);
|
|
417
427
|
}
|
|
418
428
|
|
|
419
429
|
// Try to match Controller#action pattern (e.g., PostsController#index)
|
|
@@ -449,6 +459,70 @@
|
|
|
449
459
|
const railsRootPath = '<%= Rails.root.to_s %>';
|
|
450
460
|
const wslDistro = 'Ubuntu';
|
|
451
461
|
|
|
462
|
+
// Prefix to strip when displaying a source path. Only meaningful when the
|
|
463
|
+
// engine is mounted in a host app — in standalone mode Rails.root is a
|
|
464
|
+
// throwaway tmpdir that no query's source location will ever sit under.
|
|
465
|
+
const sourceRootPath = <%== (PgReports.config.standalone ? nil : Rails.root.to_s).to_json %>;
|
|
466
|
+
|
|
467
|
+
// The badge shows the shortest path that still identifies the line. The full
|
|
468
|
+
// value stays in the tooltip, the expanded row and the exports; IDE links are
|
|
469
|
+
// built from the unshortened path.
|
|
470
|
+
function shortenSourcePath(filePath) {
|
|
471
|
+
if (!filePath) return filePath;
|
|
472
|
+
|
|
473
|
+
if (sourceRootPath && filePath.startsWith(sourceRootPath + '/')) {
|
|
474
|
+
return filePath.slice(sourceRootPath.length + 1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// A bundled gem: everything before the gem's own directory is install noise.
|
|
478
|
+
// The greedy prefix is deliberate — a gem home nests "/gems/" twice
|
|
479
|
+
// ("…/lib/ruby/gems/3.4.0/gems/activerecord-8.1.3/…") and only the last one
|
|
480
|
+
// starts the gem's own path.
|
|
481
|
+
const gem = filePath.match(/^.*\/gems\/([^/]+\/.+)$/);
|
|
482
|
+
if (gem) return gem[1];
|
|
483
|
+
|
|
484
|
+
return filePath;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Split so the directory can be clipped while the file name and line — the part
|
|
488
|
+
// you actually navigate by — stay readable. The directory is clipped at its
|
|
489
|
+
// *start*: see .source-dir in the styles, and markClippedSources below for the
|
|
490
|
+
// leading ellipsis.
|
|
491
|
+
function sourceBadgeLabel(parsed) {
|
|
492
|
+
if (!parsed.filePath) {
|
|
493
|
+
return `<span class="source-file"><span>${escapeHtml(parsed.original)}</span></span>`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const shortened = shortenSourcePath(parsed.filePath);
|
|
497
|
+
const line = parsed.lineNumber ? ':' + parsed.lineNumber : '';
|
|
498
|
+
const cut = shortened.lastIndexOf('/');
|
|
499
|
+
|
|
500
|
+
const ellipsis = '<span class="source-ellipsis" aria-hidden="true">…</span>';
|
|
501
|
+
const file = (text) => `<span class="source-file"><span>${escapeHtml(text)}</span></span>`;
|
|
502
|
+
|
|
503
|
+
if (cut < 0) return ellipsis + file(shortened + line);
|
|
504
|
+
|
|
505
|
+
return ellipsis +
|
|
506
|
+
`<span class="source-dir"><span>${escapeHtml(shortened.slice(0, cut))}</span></span>` +
|
|
507
|
+
file(shortened.slice(cut) + line);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Whether a part actually overflows can only be known after layout, and the
|
|
511
|
+
// leading ellipsis must not appear when nothing is cut. Measured on the inner
|
|
512
|
+
// span, not on its container: the overflow here runs off the *start* edge, and
|
|
513
|
+
// scrollWidth only ever reports overflow past the end edge.
|
|
514
|
+
function markClippedSources(root) {
|
|
515
|
+
(root || document).querySelectorAll('.source-badge').forEach(badge => {
|
|
516
|
+
const parts = badge.querySelectorAll('.source-dir, .source-file');
|
|
517
|
+
const clipped = Array.prototype.some.call(parts, part => {
|
|
518
|
+
const inner = part.firstElementChild;
|
|
519
|
+
return inner && inner.getBoundingClientRect().width > part.clientWidth + 1;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
badge.classList.toggle('is-clipped', clipped);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
452
526
|
// Generate IDE URLs
|
|
453
527
|
function generateIdeUrls(filePath, lineNumber) {
|
|
454
528
|
if (!filePath) return [];
|
|
@@ -521,7 +595,7 @@
|
|
|
521
595
|
const ideUrls = generateIdeUrls(parsed.filePath, parsed.lineNumber);
|
|
522
596
|
|
|
523
597
|
if (ideUrls.length === 0) {
|
|
524
|
-
return `<span class="source-badge" title="${
|
|
598
|
+
return `<span class="source-badge" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</span>`;
|
|
525
599
|
}
|
|
526
600
|
|
|
527
601
|
// Check if user has a default IDE set
|
|
@@ -529,7 +603,7 @@
|
|
|
529
603
|
if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
|
|
530
604
|
const ideUrl = ideUrls[ideKeyMap[defaultIde]];
|
|
531
605
|
if (ideUrl) {
|
|
532
|
-
return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${
|
|
606
|
+
return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
|
|
533
607
|
}
|
|
534
608
|
}
|
|
535
609
|
|
|
@@ -538,7 +612,7 @@
|
|
|
538
612
|
|
|
539
613
|
let dropdownHtml = `
|
|
540
614
|
<div class="ide-dropdown">
|
|
541
|
-
<span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${
|
|
615
|
+
<span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</span>
|
|
542
616
|
<div class="ide-dropdown-menu" id="${dropdownId}">
|
|
543
617
|
`;
|
|
544
618
|
|
|
@@ -642,7 +716,7 @@
|
|
|
642
716
|
const rowJson = JSON.stringify(row).replace(/"/g, '"');
|
|
643
717
|
|
|
644
718
|
html += `<div class="detail-actions">`;
|
|
645
|
-
html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${isSaved ?
|
|
719
|
+
html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
|
|
646
720
|
|
|
647
721
|
if (hasQuery) {
|
|
648
722
|
// Only show EXPLAIN ANALYZE for SELECT queries
|
|
@@ -651,7 +725,7 @@
|
|
|
651
725
|
// Use data attribute to pass query hash for security
|
|
652
726
|
const queryBase64 = btoa(unescape(encodeURIComponent(row.query)));
|
|
653
727
|
const queryHash = row.query_hash || '';
|
|
654
|
-
html += `<button class="btn-explain" data-query-b64="${queryBase64}" data-query-hash="${queryHash}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)"
|
|
728
|
+
html += `<button class="btn-explain" data-query-b64="${queryBase64}" data-query-hash="${queryHash}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">${svgIcon('activity')}EXPLAIN ANALYZE</button>`;
|
|
655
729
|
}
|
|
656
730
|
}
|
|
657
731
|
|
|
@@ -661,9 +735,9 @@
|
|
|
661
735
|
const tableName = row.table_name || row.tablename || '';
|
|
662
736
|
const schemaName = row.schema_name || row.schemaname || 'public';
|
|
663
737
|
if (isDevelopment) {
|
|
664
|
-
html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')"
|
|
738
|
+
html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">${svgIcon('trash')}Generate Migration</button>`;
|
|
665
739
|
} else {
|
|
666
|
-
html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment"
|
|
740
|
+
html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" >${svgIcon('trash')}Generate Migration (Development Only)</button>`;
|
|
667
741
|
}
|
|
668
742
|
}
|
|
669
743
|
|
|
@@ -1158,15 +1232,15 @@
|
|
|
1158
1232
|
if (data.summary && data.summary.total_problems > 0) {
|
|
1159
1233
|
html += '<div class="explain-summary explain-summary-' + data.summary.status + '">';
|
|
1160
1234
|
html += '<div class="explain-summary-header">';
|
|
1161
|
-
html += '
|
|
1235
|
+
html += svgIcon(data.summary.status === 'good' ? 'check' : 'alert', 'explain-summary-icon');
|
|
1162
1236
|
html += '<span class="explain-summary-title">' + escapeHtml(data.summary.status_text) + '</span>';
|
|
1163
1237
|
html += '</div>';
|
|
1164
1238
|
html += '<div class="explain-summary-stats">';
|
|
1165
1239
|
if (data.summary.critical_problems > 0) {
|
|
1166
|
-
html += '<span class="explain-summary-stat critical"
|
|
1240
|
+
html += '<span class="explain-summary-stat critical">' + svgIcon('alert') + data.summary.critical_problems + ' critical</span>';
|
|
1167
1241
|
}
|
|
1168
1242
|
if (data.summary.warnings > 0) {
|
|
1169
|
-
html += '<span class="explain-summary-stat warning"
|
|
1243
|
+
html += '<span class="explain-summary-stat warning">' + svgIcon('alert') + data.summary.warnings + ' warnings</span>';
|
|
1170
1244
|
}
|
|
1171
1245
|
html += '</div>';
|
|
1172
1246
|
html += '</div>';
|
|
@@ -1212,7 +1286,7 @@
|
|
|
1212
1286
|
|
|
1213
1287
|
data.problems.forEach(problem => {
|
|
1214
1288
|
const severityClass = 'problem-' + problem.severity;
|
|
1215
|
-
const severityIcon =
|
|
1289
|
+
const severityIcon = svgIcon(problem.severity === 'info' ? 'info' : 'alert');
|
|
1216
1290
|
|
|
1217
1291
|
html += '<div class="explain-problem ' + severityClass + '">';
|
|
1218
1292
|
html += '<div class="explain-problem-header">';
|
|
@@ -1226,7 +1300,7 @@
|
|
|
1226
1300
|
html += '<div class="explain-problem-details">' + escapeHtml(problem.details) + '</div>';
|
|
1227
1301
|
}
|
|
1228
1302
|
if (problem.recommendation) {
|
|
1229
|
-
html += '<div class="explain-problem-recommendation"
|
|
1303
|
+
html += '<div class="explain-problem-recommendation">' + svgIcon('bulb') + escapeHtml(problem.recommendation) + '</div>';
|
|
1230
1304
|
}
|
|
1231
1305
|
html += '</div>';
|
|
1232
1306
|
});
|
|
@@ -1301,7 +1375,7 @@
|
|
|
1301
1375
|
|
|
1302
1376
|
// Problem indicator
|
|
1303
1377
|
if (lineProblem) {
|
|
1304
|
-
const problemIcon =
|
|
1378
|
+
const problemIcon = svgIcon('alert');
|
|
1305
1379
|
html += '<span class="explain-line-problem-indicator" title="' + escapeHtml(lineProblem.message) + '">' + problemIcon + '</span>';
|
|
1306
1380
|
}
|
|
1307
1381
|
|
|
@@ -1338,15 +1412,7 @@
|
|
|
1338
1412
|
// Security check
|
|
1339
1413
|
if (!allowRawQueryExecution) {
|
|
1340
1414
|
showToast(PG_REPORTS_I18N.errors.explain_disabled_toast, 'error');
|
|
1341
|
-
content.innerHTML =
|
|
1342
|
-
<strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
|
|
1343
|
-
${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
|
|
1344
|
-
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
|
1345
|
-
PgReports.configure do |config|<br>
|
|
1346
|
-
config.allow_raw_query_execution = true<br>
|
|
1347
|
-
end
|
|
1348
|
-
</code>
|
|
1349
|
-
</div>`;
|
|
1415
|
+
content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
|
|
1350
1416
|
return;
|
|
1351
1417
|
}
|
|
1352
1418
|
|
|
@@ -1387,15 +1453,7 @@ end
|
|
|
1387
1453
|
// Security check
|
|
1388
1454
|
if (!allowRawQueryExecution) {
|
|
1389
1455
|
showToast(PG_REPORTS_I18N.errors.execute_disabled_toast, 'error');
|
|
1390
|
-
content.innerHTML =
|
|
1391
|
-
<strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
|
|
1392
|
-
${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
|
|
1393
|
-
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
|
1394
|
-
PgReports.configure do |config|<br>
|
|
1395
|
-
config.allow_raw_query_execution = true<br>
|
|
1396
|
-
end
|
|
1397
|
-
</code>
|
|
1398
|
-
</div>`;
|
|
1456
|
+
content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
|
|
1399
1457
|
return;
|
|
1400
1458
|
}
|
|
1401
1459
|
|
|
@@ -1552,24 +1610,15 @@ end
|
|
|
1552
1610
|
|
|
1553
1611
|
// Security check
|
|
1554
1612
|
if (!allowRawQueryExecution) {
|
|
1555
|
-
const message =
|
|
1613
|
+
const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
|
|
1556
1614
|
showToast(message, 'error');
|
|
1557
1615
|
|
|
1558
1616
|
const modalBody = document.getElementById('migration-modal-body');
|
|
1559
1617
|
if (modalBody) {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
<strong>⚠️ Migration creation is disabled</strong><br><br>
|
|
1565
|
-
To enable this feature, add to your configuration:<br>
|
|
1566
|
-
<code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
|
|
1567
|
-
PgReports.configure do |config|<br>
|
|
1568
|
-
config.allow_raw_query_execution = true<br>
|
|
1569
|
-
end
|
|
1570
|
-
</code>
|
|
1571
|
-
`;
|
|
1572
|
-
modalBody.appendChild(errorDiv);
|
|
1618
|
+
modalBody.insertAdjacentHTML(
|
|
1619
|
+
'beforeend',
|
|
1620
|
+
pgReportsDisabledNotice(PG_REPORTS_I18N.modals.migration_disabled_title)
|
|
1621
|
+
);
|
|
1573
1622
|
}
|
|
1574
1623
|
return;
|
|
1575
1624
|
}
|