pg_reports 0.9.0 → 1.0.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 +82 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +222 -93
- data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
- data/app/views/pg_reports/dashboard/index.html.erb +346 -238
- data/app/views/pg_reports/dashboard/show.html.erb +1 -1
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +45 -11
- data/config/locales/ru.yml +44 -11
- data/config/locales/uk.yml +44 -11
- data/config/routes.rb +2 -0
- data/lib/pg_reports/access_policy.rb +39 -0
- data/lib/pg_reports/compatibility.rb +10 -2
- data/lib/pg_reports/configuration.rb +33 -2
- data/lib/pg_reports/connection/error_translator.rb +21 -0
- data/lib/pg_reports/connection/target.rb +43 -10
- data/lib/pg_reports/csv_cell.rb +20 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
- data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
- data/lib/pg_reports/definitions/connections/locks.yml +1 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
- data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
- data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
- data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
- data/lib/pg_reports/drop_index_migration.rb +56 -0
- data/lib/pg_reports/engine.rb +4 -3
- data/lib/pg_reports/executor.rb +46 -9
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +478 -192
- data/lib/pg_reports/rate_limiter.rb +63 -0
- data/lib/pg_reports/report.rb +1 -1
- data/lib/pg_reports/report_definition.rb +11 -1
- data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
- data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
- data/lib/pg_reports/sql/connections/locks.sql +1 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
- data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
- data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
- data/lib/pg_reports/sql_redactor.rb +32 -0
- data/lib/pg_reports/standalone.rb +83 -20
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +7 -2
- metadata +18 -9
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
const svgIcon = (name, cls) => `<svg class="icon ${cls || ''}"><use href="#i-${name}"></use></svg>`;
|
|
3
3
|
let currentReportData = null;
|
|
4
|
+
// Rows in their rendered order. Inline handlers carry an index into this, never
|
|
5
|
+
// the row itself: report data comes from the database, and any encoding of it
|
|
6
|
+
// into an attribute that JS re-parses is one decoding step from being code.
|
|
7
|
+
let renderedRows = [];
|
|
4
8
|
let currentProblemExplanations = {};
|
|
5
9
|
const category = '<%= @category %>';
|
|
6
10
|
const reportKey = '<%= @report_key %>';
|
|
7
|
-
const
|
|
8
|
-
const isDevelopment = railsEnv === 'development';
|
|
11
|
+
const allowMigrationCreation = <%= PgReports.config.allow_migration_creation ? true : false %>;
|
|
9
12
|
const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
|
|
10
13
|
let syncingScroll = false;
|
|
11
14
|
let currentSort = { column: null, direction: 'asc' };
|
|
@@ -118,6 +121,8 @@
|
|
|
118
121
|
const tableBody = document.getElementById('results-body');
|
|
119
122
|
if (!tableBody) return;
|
|
120
123
|
|
|
124
|
+
renderedRows = data;
|
|
125
|
+
|
|
121
126
|
let rowsHtml = '';
|
|
122
127
|
|
|
123
128
|
data.forEach((row, idx) => {
|
|
@@ -130,7 +135,7 @@
|
|
|
130
135
|
rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
|
|
131
136
|
const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
|
|
132
137
|
const indicatorIcon = svgIcon('alert');
|
|
133
|
-
problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation();
|
|
138
|
+
problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showRowProblems(${idx})">${indicatorIcon}</span>`;
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
// Data row
|
|
@@ -347,6 +352,14 @@
|
|
|
347
352
|
return { level: maxLevel, problems };
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
function showRowProblems(rowIndex) {
|
|
356
|
+
const row = renderedRows[rowIndex];
|
|
357
|
+
if (!row) return;
|
|
358
|
+
|
|
359
|
+
const problemInfo = getRowProblemLevel(row, currentReportData.thresholds, currentReportData.problem_fields);
|
|
360
|
+
showProblemModal(problemInfo.problems, row);
|
|
361
|
+
}
|
|
362
|
+
|
|
350
363
|
// Show problem modal
|
|
351
364
|
function showProblemModal(problems, row) {
|
|
352
365
|
const modal = document.getElementById('problem-modal');
|
|
@@ -536,39 +549,44 @@
|
|
|
536
549
|
const line = lineNumber || 1;
|
|
537
550
|
const urls = [];
|
|
538
551
|
|
|
552
|
+
// The path can come from a SQL comment, which anyone with database access
|
|
553
|
+
// can write, so it is encoded here and never reaches a URL as it was.
|
|
554
|
+
const urlPath = absolutePath.split('/').map(encodeURIComponent).join('/');
|
|
555
|
+
const urlParam = encodeURIComponent(absolutePath);
|
|
556
|
+
|
|
539
557
|
// VSCode (WSL Remote format)
|
|
540
558
|
urls.push({
|
|
541
559
|
name: 'VS Code (WSL)',
|
|
542
|
-
url: `vscode://vscode-remote/wsl+${wslDistro}${
|
|
560
|
+
url: `vscode://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
|
|
543
561
|
});
|
|
544
562
|
|
|
545
563
|
// VSCode (direct path - for native Linux or Windows)
|
|
546
564
|
urls.push({
|
|
547
565
|
name: 'VS Code',
|
|
548
|
-
url: `vscode://file${
|
|
566
|
+
url: `vscode://file${urlPath}:${line}`
|
|
549
567
|
});
|
|
550
568
|
|
|
551
569
|
// JetBrains (RubyMine, IntelliJ, etc.)
|
|
552
570
|
urls.push({
|
|
553
571
|
name: 'RubyMine',
|
|
554
|
-
url: `x-mine://open?file=${
|
|
572
|
+
url: `x-mine://open?file=${urlParam}&line=${line}`
|
|
555
573
|
});
|
|
556
574
|
|
|
557
575
|
urls.push({
|
|
558
576
|
name: 'IntelliJ',
|
|
559
|
-
url: `idea://open?file=${
|
|
577
|
+
url: `idea://open?file=${urlParam}&line=${line}`
|
|
560
578
|
});
|
|
561
579
|
|
|
562
580
|
// Cursor (WSL Remote format)
|
|
563
581
|
urls.push({
|
|
564
582
|
name: 'Cursor (WSL)',
|
|
565
|
-
url: `cursor://vscode-remote/wsl+${wslDistro}${
|
|
583
|
+
url: `cursor://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
|
|
566
584
|
});
|
|
567
585
|
|
|
568
586
|
// Cursor (direct path)
|
|
569
587
|
urls.push({
|
|
570
588
|
name: 'Cursor',
|
|
571
|
-
url: `cursor://file${
|
|
589
|
+
url: `cursor://file${urlPath}:${line}`
|
|
572
590
|
});
|
|
573
591
|
|
|
574
592
|
return urls;
|
|
@@ -603,7 +621,7 @@
|
|
|
603
621
|
if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
|
|
604
622
|
const ideUrl = ideUrls[ideKeyMap[defaultIde]];
|
|
605
623
|
if (ideUrl) {
|
|
606
|
-
return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
|
|
624
|
+
return `<a class="source-badge clickable" href="${escapeHtmlAttr(ideUrl.url)}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
|
|
607
625
|
}
|
|
608
626
|
}
|
|
609
627
|
|
|
@@ -617,7 +635,7 @@
|
|
|
617
635
|
`;
|
|
618
636
|
|
|
619
637
|
for (const ide of ideUrls) {
|
|
620
|
-
dropdownHtml += `<a href="${ide.url}">${ide.name}</a>`;
|
|
638
|
+
dropdownHtml += `<a href="${escapeHtmlAttr(ide.url)}">${escapeHtml(ide.name)}</a>`;
|
|
621
639
|
}
|
|
622
640
|
|
|
623
641
|
dropdownHtml += '</div></div>';
|
|
@@ -713,10 +731,9 @@
|
|
|
713
731
|
|
|
714
732
|
// Action buttons
|
|
715
733
|
const isSaved = isRecordSaved(rowId);
|
|
716
|
-
const rowJson = JSON.stringify(row).replace(/"/g, '"');
|
|
717
734
|
|
|
718
735
|
html += `<div class="detail-actions">`;
|
|
719
|
-
html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation();
|
|
736
|
+
html += `<button class="btn-save ${isSaved ? 'saved' : ''}" data-row-id="${rowId}" onclick="event.stopPropagation(); toggleSaveRow(${rowIndex}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
|
|
720
737
|
|
|
721
738
|
if (hasQuery) {
|
|
722
739
|
// Only show EXPLAIN ANALYZE for SELECT queries
|
|
@@ -731,11 +748,8 @@
|
|
|
731
748
|
|
|
732
749
|
// Show migration button for index reports (only in development)
|
|
733
750
|
if (hasIndexName && (category === 'indexes')) {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const schemaName = row.schema_name || row.schemaname || 'public';
|
|
737
|
-
if (isDevelopment) {
|
|
738
|
-
html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">${svgIcon('trash')}Generate Migration</button>`;
|
|
751
|
+
if (allowMigrationCreation) {
|
|
752
|
+
html += `<button class="btn-migration" onclick="event.stopPropagation(); showRowMigration(${rowIndex})">${svgIcon('trash')}Generate Migration</button>`;
|
|
739
753
|
} else {
|
|
740
754
|
html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" >${svgIcon('trash')}Generate Migration (Development Only)</button>`;
|
|
741
755
|
}
|
|
@@ -827,7 +841,7 @@
|
|
|
827
841
|
// Build table header with sortable columns
|
|
828
842
|
if (tableHead) {
|
|
829
843
|
tableHead.innerHTML = '<tr>' + data.columns.map(col =>
|
|
830
|
-
`<th class="sortable" data-column="${
|
|
844
|
+
`<th class="sortable" data-column="${escapeHtmlAttr(col)}" onclick="handleSortClick(this.dataset.column)">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
|
|
831
845
|
).join('') + '</tr>';
|
|
832
846
|
}
|
|
833
847
|
|
|
@@ -854,7 +868,7 @@
|
|
|
854
868
|
document.addEventListener('keydown', function(e) {
|
|
855
869
|
if (e.key === 'Escape') {
|
|
856
870
|
closeProblemModal();
|
|
857
|
-
|
|
871
|
+
closeSettingsModal();
|
|
858
872
|
closeExplainModal();
|
|
859
873
|
closeMigrationModal();
|
|
860
874
|
}
|
|
@@ -867,44 +881,6 @@
|
|
|
867
881
|
}
|
|
868
882
|
});
|
|
869
883
|
|
|
870
|
-
// IDE Settings functions
|
|
871
|
-
function showIdeSettingsModal() {
|
|
872
|
-
const modal = document.getElementById('ide-settings-modal');
|
|
873
|
-
modal.style.display = 'flex';
|
|
874
|
-
loadIdeSettingsState();
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function closeIdeSettingsModal() {
|
|
878
|
-
document.getElementById('ide-settings-modal').style.display = 'none';
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function setDefaultIde(ideKey) {
|
|
882
|
-
if (ideKey) {
|
|
883
|
-
localStorage.setItem('pgReportsDefaultIde', ideKey);
|
|
884
|
-
} else {
|
|
885
|
-
localStorage.removeItem('pgReportsDefaultIde');
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function getDefaultIde() {
|
|
890
|
-
return localStorage.getItem('pgReportsDefaultIde') || '';
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
function loadIdeSettingsState() {
|
|
894
|
-
const currentIde = getDefaultIde();
|
|
895
|
-
const radios = document.querySelectorAll('input[name="default-ide"]');
|
|
896
|
-
radios.forEach(radio => {
|
|
897
|
-
radio.checked = (radio.value === currentIde);
|
|
898
|
-
});
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Close IDE settings modal on backdrop click
|
|
902
|
-
document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
|
|
903
|
-
if (e.target === this) {
|
|
904
|
-
closeIdeSettingsModal();
|
|
905
|
-
}
|
|
906
|
-
});
|
|
907
|
-
|
|
908
884
|
// ==========================================
|
|
909
885
|
// SAVED RECORDS FUNCTIONALITY
|
|
910
886
|
// ==========================================
|
|
@@ -951,6 +927,11 @@
|
|
|
951
927
|
return saved.some(r => r.id === rowId);
|
|
952
928
|
}
|
|
953
929
|
|
|
930
|
+
function toggleSaveRow(rowIndex, btn) {
|
|
931
|
+
const row = renderedRows[rowIndex];
|
|
932
|
+
if (row) toggleSaveRecord(generateRowId(row), row, btn);
|
|
933
|
+
}
|
|
934
|
+
|
|
954
935
|
function toggleSaveRecord(rowId, row, btn) {
|
|
955
936
|
const saved = getSavedRecords();
|
|
956
937
|
const existingIdx = saved.findIndex(r => r.id === rowId);
|
|
@@ -984,7 +965,7 @@
|
|
|
984
965
|
renderSavedRecords();
|
|
985
966
|
|
|
986
967
|
// Update button in table if visible
|
|
987
|
-
const btn = document.querySelector(`.btn-save[
|
|
968
|
+
const btn = document.querySelector(`.btn-save[data-row-id="${CSS.escape(rowId)}"]`);
|
|
988
969
|
if (btn) {
|
|
989
970
|
btn.classList.remove('saved');
|
|
990
971
|
btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
|
|
@@ -1035,7 +1016,7 @@
|
|
|
1035
1016
|
<div class="saved-record-header">
|
|
1036
1017
|
<span class="saved-record-time">${PG_REPORTS_I18N.saved.saved_at_prefix} ${savedTime}</span>
|
|
1037
1018
|
<span class="saved-record-expand-hint">${PG_REPORTS_I18N.saved.click_to_expand}</span>
|
|
1038
|
-
<button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord(
|
|
1019
|
+
<button class="saved-record-remove" data-record-id="${escapeHtmlAttr(record.id)}" onclick="event.stopPropagation(); removeSavedRecord(this.dataset.recordId)" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
|
|
1039
1020
|
</div>
|
|
1040
1021
|
<div class="saved-record-data">
|
|
1041
1022
|
`;
|
|
@@ -1350,7 +1331,7 @@
|
|
|
1350
1331
|
const indentPx = line.indent_level * 20;
|
|
1351
1332
|
html += '<span class="explain-line-content ' + nodeColorClass + '" style="padding-left: ' + indentPx + 'px"';
|
|
1352
1333
|
if (tooltip) {
|
|
1353
|
-
html += ' title="' +
|
|
1334
|
+
html += ' title="' + escapeHtmlAttr(tooltip) + '"';
|
|
1354
1335
|
}
|
|
1355
1336
|
html += '>';
|
|
1356
1337
|
|
|
@@ -1376,7 +1357,7 @@
|
|
|
1376
1357
|
// Problem indicator
|
|
1377
1358
|
if (lineProblem) {
|
|
1378
1359
|
const problemIcon = svgIcon('alert');
|
|
1379
|
-
html += '<span class="explain-line-problem-indicator" title="' +
|
|
1360
|
+
html += '<span class="explain-line-problem-indicator" title="' + escapeHtmlAttr(lineProblem.message) + '">' + problemIcon + '</span>';
|
|
1380
1361
|
}
|
|
1381
1362
|
|
|
1382
1363
|
html += '</div>';
|
|
@@ -1542,49 +1523,38 @@
|
|
|
1542
1523
|
|
|
1543
1524
|
let currentMigrationData = null;
|
|
1544
1525
|
|
|
1545
|
-
function
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
:
|
|
1553
|
-
|
|
1554
|
-
const migrationCode = `# frozen_string_literal: true
|
|
1555
|
-
|
|
1556
|
-
class ${className} < ActiveRecord::Migration[7.0]
|
|
1557
|
-
def change
|
|
1558
|
-
remove_index :${tableName}, name: :${indexName}, if_exists: true
|
|
1559
|
-
end
|
|
1560
|
-
end
|
|
1561
|
-
`;
|
|
1562
|
-
|
|
1563
|
-
currentMigrationData = {
|
|
1564
|
-
fileName: `${timestamp}_${migrationName}.rb`,
|
|
1565
|
-
code: migrationCode,
|
|
1566
|
-
indexName,
|
|
1567
|
-
tableName,
|
|
1568
|
-
schemaName
|
|
1526
|
+
async function showRowMigration(rowIndex) {
|
|
1527
|
+
const row = renderedRows[rowIndex];
|
|
1528
|
+
if (!row) return;
|
|
1529
|
+
|
|
1530
|
+
// The server builds the migration from the index it finds in the database,
|
|
1531
|
+
// so all it is told is which one.
|
|
1532
|
+
const target = {
|
|
1533
|
+
index_name: row.index_name,
|
|
1534
|
+
schema: row.schema || row.schema_name || row.schemaname || ''
|
|
1569
1535
|
};
|
|
1570
1536
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
createBtn.style.cursor = 'pointer';
|
|
1586
|
-
createBtn.title = '';
|
|
1537
|
+
try {
|
|
1538
|
+
const response = await fetch(`${pgReportsRoot}/migration_preview`, {
|
|
1539
|
+
method: 'POST',
|
|
1540
|
+
headers: {
|
|
1541
|
+
'Content-Type': 'application/json',
|
|
1542
|
+
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
1543
|
+
},
|
|
1544
|
+
body: JSON.stringify(target)
|
|
1545
|
+
});
|
|
1546
|
+
const data = await response.json();
|
|
1547
|
+
|
|
1548
|
+
if (!data.success) {
|
|
1549
|
+
showToast(data.error || PG_REPORTS_I18N.errors.create_migration_failed, 'error');
|
|
1550
|
+
return;
|
|
1587
1551
|
}
|
|
1552
|
+
|
|
1553
|
+
currentMigrationData = { ...target, code: data.code };
|
|
1554
|
+
document.getElementById('migration-code').textContent = data.code;
|
|
1555
|
+
document.getElementById('migration-modal').style.display = 'flex';
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
|
|
1588
1558
|
}
|
|
1589
1559
|
}
|
|
1590
1560
|
|
|
@@ -1609,7 +1579,7 @@ end
|
|
|
1609
1579
|
if (!currentMigrationData) return;
|
|
1610
1580
|
|
|
1611
1581
|
// Security check
|
|
1612
|
-
if (!
|
|
1582
|
+
if (!allowMigrationCreation) {
|
|
1613
1583
|
const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
|
|
1614
1584
|
showToast(message, 'error');
|
|
1615
1585
|
|
|
@@ -1631,8 +1601,8 @@ end
|
|
|
1631
1601
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
1632
1602
|
},
|
|
1633
1603
|
body: JSON.stringify({
|
|
1634
|
-
|
|
1635
|
-
|
|
1604
|
+
index_name: currentMigrationData.index_name,
|
|
1605
|
+
schema: currentMigrationData.schema
|
|
1636
1606
|
})
|
|
1637
1607
|
});
|
|
1638
1608
|
|