pg_reports 0.9.1 → 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 +72 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +126 -80
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
- data/app/views/pg_reports/dashboard/index.html.erb +310 -124
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +39 -8
- data/config/locales/ru.yml +38 -8
- data/config/locales/uk.yml +38 -8
- data/config/routes.rb +1 -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 +17 -9
|
@@ -277,24 +277,28 @@ end</pre>
|
|
|
277
277
|
</div>
|
|
278
278
|
</div>
|
|
279
279
|
|
|
280
|
-
<!-- Query Monitoring Panel:
|
|
281
|
-
application's
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<% unless PgReports.config.standalone %>
|
|
280
|
+
<!-- Query Monitoring Panel: captures from ActiveSupport::Notifications in the
|
|
281
|
+
host application's processes, or by sampling pg_stat_activity in standalone
|
|
282
|
+
mode, where there is no host application. -->
|
|
283
|
+
<% if PgReports.config.query_monitor_available? %>
|
|
285
284
|
<div id="query-monitoring" class="query-monitoring-panel" style="display: none;">
|
|
286
285
|
<div class="query-monitoring-header">
|
|
287
286
|
<div class="query-monitoring-title">
|
|
288
287
|
<span class="monitoring-indicator" id="monitor-indicator"></span>
|
|
289
288
|
<span><%= t("pg_reports.ui.monitoring.query_monitor_title") %></span>
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
<% scope_note = PgReports.config.standalone ? "sampled" : "host" %>
|
|
290
|
+
<span class="query-monitoring-scope" tabindex="0" data-tooltip="<%= t("pg_reports.ui.monitoring.scope_note_long_#{scope_note}") %>">
|
|
291
|
+
<%= t("pg_reports.ui.monitoring.scope_note_short_#{scope_note}") %>
|
|
292
292
|
</span>
|
|
293
293
|
<span class="session-badge" id="session-badge" style="display: none;">
|
|
294
|
-
<%= t("pg_reports.ui.monitoring.
|
|
294
|
+
<%= t("pg_reports.ui.monitoring.auto_stop_label") %> <strong id="monitor-countdown"></strong>
|
|
295
295
|
</span>
|
|
296
296
|
</div>
|
|
297
297
|
<div class="query-monitoring-controls">
|
|
298
|
+
<div class="query-source-switch" id="query-source-switch" role="group" aria-label="<%= t("pg_reports.ui.monitoring.source_switch_label") %>" hidden>
|
|
299
|
+
<button type="button" data-source="notifications" title="<%= t("pg_reports.ui.monitoring.source_notifications_title") %>" onclick="selectQueryMonitorSource('notifications')"><%= t("pg_reports.ui.monitoring.source_notifications") %></button>
|
|
300
|
+
<button type="button" data-source="activity" title="<%= t("pg_reports.ui.monitoring.source_activity_title") %>" onclick="selectQueryMonitorSource('activity')"><%= t("pg_reports.ui.monitoring.source_activity") %></button>
|
|
301
|
+
</div>
|
|
298
302
|
<button class="btn btn-primary" onclick="startQueryMonitoring(this)" id="start-monitor-btn">
|
|
299
303
|
<%= t("pg_reports.ui.actions.start_monitoring") %>
|
|
300
304
|
</button>
|
|
@@ -320,10 +324,28 @@ end</pre>
|
|
|
320
324
|
</div>
|
|
321
325
|
</div>
|
|
322
326
|
|
|
327
|
+
<div class="query-scope-warning" id="query-scope-warning" style="display: none;">
|
|
328
|
+
<%= t("pg_reports.ui.monitoring.scope_process_warning") %>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div class="query-scope-warning" id="query-visibility-warning" style="display: none;"></div>
|
|
332
|
+
<div class="query-scope-warning query-monitor-error" id="query-monitor-error" style="display: none;"></div>
|
|
333
|
+
|
|
323
334
|
<div class="query-feed" id="query-feed">
|
|
324
|
-
<div class="query-feed-empty">
|
|
335
|
+
<div class="query-feed-empty" id="query-feed-empty">
|
|
325
336
|
<%= t("pg_reports.ui.monitoring.feed_empty") %>
|
|
326
337
|
</div>
|
|
338
|
+
<table class="query-feed-table" id="query-feed-table" style="display: none;">
|
|
339
|
+
<thead>
|
|
340
|
+
<tr>
|
|
341
|
+
<th class="col-time"><%= t("pg_reports.ui.monitoring.col_time") %></th>
|
|
342
|
+
<th class="col-duration"><%= t("pg_reports.ui.monitoring.col_duration") %></th>
|
|
343
|
+
<th class="col-source"><%= t("pg_reports.ui.monitoring.col_source") %></th>
|
|
344
|
+
<th class="col-sql"><%= t("pg_reports.ui.monitoring.col_sql") %></th>
|
|
345
|
+
</tr>
|
|
346
|
+
</thead>
|
|
347
|
+
<tbody id="query-feed-body"></tbody>
|
|
348
|
+
</table>
|
|
327
349
|
</div>
|
|
328
350
|
</div>
|
|
329
351
|
<% end %>
|
|
@@ -1380,40 +1402,45 @@ end</pre>
|
|
|
1380
1402
|
const line = lineNumber || 1;
|
|
1381
1403
|
const urls = [];
|
|
1382
1404
|
|
|
1405
|
+
// The path can come from a SQL comment, which anyone with database access
|
|
1406
|
+
// can write, so it is encoded here and never reaches a URL as it was.
|
|
1407
|
+
const urlPath = absolutePath.split('/').map(encodeURIComponent).join('/');
|
|
1408
|
+
const urlParam = encodeURIComponent(absolutePath);
|
|
1409
|
+
|
|
1383
1410
|
// VSCode (WSL Remote format)
|
|
1384
1411
|
urls.push({
|
|
1385
1412
|
name: PG_REPORTS_I18N.settings.ide_vscode_wsl,
|
|
1386
|
-
url: `vscode://vscode-remote/wsl+${wslDistro}${
|
|
1413
|
+
url: `vscode://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
|
|
1387
1414
|
});
|
|
1388
1415
|
|
|
1389
1416
|
// VSCode (direct path)
|
|
1390
1417
|
urls.push({
|
|
1391
1418
|
name: PG_REPORTS_I18N.settings.ide_vscode,
|
|
1392
|
-
url: `vscode://file${
|
|
1419
|
+
url: `vscode://file${urlPath}:${line}`
|
|
1393
1420
|
});
|
|
1394
1421
|
|
|
1395
1422
|
// RubyMine
|
|
1396
1423
|
urls.push({
|
|
1397
1424
|
name: PG_REPORTS_I18N.settings.ide_rubymine,
|
|
1398
|
-
url: `rubymine://open?file=${
|
|
1425
|
+
url: `rubymine://open?file=${urlParam}&line=${line}`
|
|
1399
1426
|
});
|
|
1400
1427
|
|
|
1401
1428
|
// IntelliJ
|
|
1402
1429
|
urls.push({
|
|
1403
1430
|
name: PG_REPORTS_I18N.settings.ide_intellij,
|
|
1404
|
-
url: `idea://open?file=${
|
|
1431
|
+
url: `idea://open?file=${urlParam}&line=${line}`
|
|
1405
1432
|
});
|
|
1406
1433
|
|
|
1407
1434
|
// Cursor (WSL Remote format)
|
|
1408
1435
|
urls.push({
|
|
1409
1436
|
name: PG_REPORTS_I18N.settings.ide_cursor_wsl,
|
|
1410
|
-
url: `cursor://vscode-remote/wsl+${wslDistro}${
|
|
1437
|
+
url: `cursor://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
|
|
1411
1438
|
});
|
|
1412
1439
|
|
|
1413
1440
|
// Cursor (direct path)
|
|
1414
1441
|
urls.push({
|
|
1415
1442
|
name: PG_REPORTS_I18N.settings.ide_cursor,
|
|
1416
|
-
url: `cursor://file${
|
|
1443
|
+
url: `cursor://file${urlPath}:${line}`
|
|
1417
1444
|
});
|
|
1418
1445
|
|
|
1419
1446
|
return urls;
|
|
@@ -1443,11 +1470,13 @@ end</pre>
|
|
|
1443
1470
|
const menu = document.getElementById('ide-menu');
|
|
1444
1471
|
const overlay = document.getElementById('ide-dropdown-overlay');
|
|
1445
1472
|
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1473
|
+
menu.replaceChildren(...ideUrls.map(ide => {
|
|
1474
|
+
const link = document.createElement('a');
|
|
1475
|
+
link.href = ide.url;
|
|
1476
|
+
link.textContent = ide.name;
|
|
1477
|
+
link.addEventListener('click', closeIdeMenu);
|
|
1478
|
+
return link;
|
|
1479
|
+
}));
|
|
1451
1480
|
|
|
1452
1481
|
// Position menu near the clicked element
|
|
1453
1482
|
if (event && event.target) {
|
|
@@ -1472,39 +1501,124 @@ end</pre>
|
|
|
1472
1501
|
|
|
1473
1502
|
const QUERY_MONITOR_CONFIG = {
|
|
1474
1503
|
pollInterval: 2000,
|
|
1475
|
-
|
|
1504
|
+
// Reaching every process takes one flush, so a stopped session settles shortly after
|
|
1505
|
+
settleDelay: 1200,
|
|
1506
|
+
maxQueries: 100
|
|
1476
1507
|
};
|
|
1477
1508
|
|
|
1478
1509
|
let queryMonitorEnabled = false;
|
|
1479
1510
|
let queryMonitorPollTimer = null;
|
|
1511
|
+
let queryMonitorCountdownTimer = null;
|
|
1512
|
+
let queryMonitorExpiresAt = null;
|
|
1480
1513
|
let currentSessionId = null;
|
|
1481
1514
|
let queryCount = 0;
|
|
1515
|
+
let queryLastSeq = 0;
|
|
1516
|
+
let queryMonitorSource = null;
|
|
1517
|
+
const queryFeedData = new WeakMap();
|
|
1518
|
+
|
|
1519
|
+
function readSavedQueryMonitorSource() {
|
|
1520
|
+
try {
|
|
1521
|
+
return localStorage.getItem('pgReportsMonitorSource');
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function selectQueryMonitorSource(source) {
|
|
1528
|
+
queryMonitorSource = source;
|
|
1529
|
+
try {
|
|
1530
|
+
localStorage.setItem('pgReportsMonitorSource', source);
|
|
1531
|
+
} catch (error) {
|
|
1532
|
+
// Only the remembered choice is lost
|
|
1533
|
+
}
|
|
1534
|
+
renderQueryMonitorSource();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function renderQueryMonitorSource() {
|
|
1538
|
+
const chip = document.querySelector('.query-monitoring-scope');
|
|
1539
|
+
const key = queryMonitorSource === 'activity' ? 'sampled' : 'host';
|
|
1540
|
+
chip.textContent = PG_REPORTS_I18N.monitoring[`scope_note_short_${key}`];
|
|
1541
|
+
chip.dataset.tooltip = PG_REPORTS_I18N.monitoring[`scope_note_long_${key}`];
|
|
1542
|
+
|
|
1543
|
+
document.querySelectorAll('#query-source-switch button').forEach(button => {
|
|
1544
|
+
button.classList.toggle('active', button.dataset.source === queryMonitorSource);
|
|
1545
|
+
button.disabled = queryMonitorEnabled;
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function applyQueryMonitorSources(data) {
|
|
1550
|
+
const available = data.available_sources || [];
|
|
1551
|
+
document.getElementById('query-source-switch').hidden = available.length < 2;
|
|
1552
|
+
|
|
1553
|
+
const saved = readSavedQueryMonitorSource();
|
|
1554
|
+
// A running session decides; otherwise the viewer's last choice, if still on offer
|
|
1555
|
+
queryMonitorSource = data.enabled || !available.includes(saved) ? data.source : saved;
|
|
1556
|
+
renderQueryMonitorSource();
|
|
1557
|
+
}
|
|
1482
1558
|
|
|
1483
1559
|
function initQueryMonitoring() {
|
|
1484
1560
|
const panel = document.getElementById('query-monitoring');
|
|
1485
1561
|
if (!panel) return;
|
|
1486
1562
|
|
|
1563
|
+
document.getElementById('query-feed-body').addEventListener('click', handleQueryFeedClick);
|
|
1564
|
+
|
|
1487
1565
|
// Check initial status
|
|
1488
1566
|
checkQueryMonitorStatus();
|
|
1489
1567
|
}
|
|
1490
1568
|
|
|
1569
|
+
function applyQueryMonitorStatus(data) {
|
|
1570
|
+
if (data.max_queries) QUERY_MONITOR_CONFIG.maxQueries = data.max_queries;
|
|
1571
|
+
if (typeof data.query_count === 'number') queryCount = data.query_count;
|
|
1572
|
+
|
|
1573
|
+
document.getElementById('query-count').textContent = queryCount;
|
|
1574
|
+
document.getElementById('query-scope-warning').style.display = data.scope === 'process' ? 'block' : 'none';
|
|
1575
|
+
|
|
1576
|
+
const visibilityWarning = document.getElementById('query-visibility-warning');
|
|
1577
|
+
visibilityWarning.style.display = data.hidden_sessions > 0 ? 'block' : 'none';
|
|
1578
|
+
visibilityWarning.textContent = pgReportsFormat(PG_REPORTS_I18N.monitoring.hidden_sessions_warning, { count: data.hidden_sessions });
|
|
1579
|
+
|
|
1580
|
+
queryMonitorExpiresAt = data.enabled && typeof data.expires_in === 'number'
|
|
1581
|
+
? Date.now() + data.expires_in * 1000
|
|
1582
|
+
: null;
|
|
1583
|
+
renderQueryMonitorCountdown();
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function renderQueryMonitorError(message) {
|
|
1587
|
+
const element = document.getElementById('query-monitor-error');
|
|
1588
|
+
element.style.display = message ? 'block' : 'none';
|
|
1589
|
+
element.textContent = message ? `${PG_REPORTS_I18N.errors.query_monitoring_error}: ${message}` : '';
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function renderQueryMonitorCountdown() {
|
|
1593
|
+
const element = document.getElementById('monitor-countdown');
|
|
1594
|
+
if (!element || queryMonitorExpiresAt === null) return;
|
|
1595
|
+
|
|
1596
|
+
const seconds = Math.max(0, Math.ceil((queryMonitorExpiresAt - Date.now()) / 1000));
|
|
1597
|
+
element.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1491
1600
|
async function checkQueryMonitorStatus() {
|
|
1492
1601
|
try {
|
|
1493
1602
|
const response = await fetch(`${pgReportsRoot}/query_monitor/status`);
|
|
1494
1603
|
const data = await response.json();
|
|
1495
1604
|
|
|
1496
1605
|
queryHistoryAvailable = data.history_available === true;
|
|
1606
|
+
applyQueryMonitorStatus(data);
|
|
1607
|
+
renderQueryMonitorError(data.success ? data.capture_error : data.error);
|
|
1608
|
+
queryMonitorEnabled = data.success && data.enabled;
|
|
1609
|
+
applyQueryMonitorSources(data);
|
|
1497
1610
|
|
|
1498
1611
|
if (data.success && data.enabled) {
|
|
1499
1612
|
queryMonitorEnabled = true;
|
|
1500
1613
|
currentSessionId = data.session_id;
|
|
1501
|
-
queryCount = data.query_count;
|
|
1502
1614
|
updateQueryMonitorUI(true);
|
|
1503
1615
|
startQueryMonitorPolling();
|
|
1504
1616
|
} else {
|
|
1505
1617
|
// Monitoring not active
|
|
1506
1618
|
queryMonitorEnabled = false;
|
|
1507
1619
|
updateQueryMonitorUI(false);
|
|
1620
|
+
// The last session's queries outlive it, so a reload does not lose them
|
|
1621
|
+
if (queryCount > 0) fetchQueryFeed();
|
|
1508
1622
|
}
|
|
1509
1623
|
} catch (error) {
|
|
1510
1624
|
console.error('Failed to check query monitor status:', error);
|
|
@@ -1560,7 +1674,8 @@ end</pre>
|
|
|
1560
1674
|
headers: {
|
|
1561
1675
|
'Content-Type': 'application/json',
|
|
1562
1676
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
|
1563
|
-
}
|
|
1677
|
+
},
|
|
1678
|
+
body: JSON.stringify({ source: queryMonitorSource })
|
|
1564
1679
|
});
|
|
1565
1680
|
|
|
1566
1681
|
const data = await response.json();
|
|
@@ -1568,11 +1683,13 @@ end</pre>
|
|
|
1568
1683
|
if (data.success) {
|
|
1569
1684
|
queryMonitorEnabled = true;
|
|
1570
1685
|
currentSessionId = data.session_id;
|
|
1686
|
+
clearQueryFeed();
|
|
1571
1687
|
updateQueryMonitorUI(true);
|
|
1688
|
+
renderQueryMonitorSource();
|
|
1572
1689
|
startQueryMonitorPolling();
|
|
1573
1690
|
showToast(data.message);
|
|
1574
1691
|
} else {
|
|
1575
|
-
showToast(data.message || PG_REPORTS_I18N.errors.start_monitoring_failed, 'error');
|
|
1692
|
+
showToast(data.message || data.error || PG_REPORTS_I18N.errors.start_monitoring_failed, 'error');
|
|
1576
1693
|
button.disabled = false;
|
|
1577
1694
|
button.innerHTML = PG_REPORTS_I18N.actions.start_monitoring;
|
|
1578
1695
|
}
|
|
@@ -1599,11 +1716,7 @@ end</pre>
|
|
|
1599
1716
|
const data = await response.json();
|
|
1600
1717
|
|
|
1601
1718
|
if (data.success) {
|
|
1602
|
-
|
|
1603
|
-
currentSessionId = null;
|
|
1604
|
-
stopQueryMonitorPolling();
|
|
1605
|
-
|
|
1606
|
-
updateQueryMonitorUI(false);
|
|
1719
|
+
endQueryMonitoringSession();
|
|
1607
1720
|
showToast(data.message);
|
|
1608
1721
|
} else {
|
|
1609
1722
|
showToast(data.message || PG_REPORTS_I18N.errors.stop_monitoring_failed, 'error');
|
|
@@ -1624,9 +1737,7 @@ end</pre>
|
|
|
1624
1737
|
const stopBtn = document.getElementById('stop-monitor-btn');
|
|
1625
1738
|
const loadHistoryBtn = document.getElementById('load-history-btn');
|
|
1626
1739
|
const sessionBadge = document.getElementById('session-badge');
|
|
1627
|
-
const sessionIdEl = document.getElementById('session-id');
|
|
1628
1740
|
const downloadDropdown = document.getElementById('monitor-download-dropdown');
|
|
1629
|
-
const feed = document.getElementById('query-feed');
|
|
1630
1741
|
|
|
1631
1742
|
panel.style.display = 'block';
|
|
1632
1743
|
|
|
@@ -1638,7 +1749,6 @@ end</pre>
|
|
|
1638
1749
|
stopBtn.innerHTML = PG_REPORTS_I18N.actions.stop_monitoring;
|
|
1639
1750
|
loadHistoryBtn.style.display = 'none'; // Hide when monitoring active
|
|
1640
1751
|
sessionBadge.style.display = 'inline-block';
|
|
1641
|
-
sessionIdEl.textContent = currentSessionId ? currentSessionId.substring(0, 8) : '';
|
|
1642
1752
|
downloadDropdown.style.display = 'inline-block';
|
|
1643
1753
|
} else {
|
|
1644
1754
|
indicator.classList.remove('active');
|
|
@@ -1651,14 +1761,10 @@ end</pre>
|
|
|
1651
1761
|
sessionBadge.style.display = 'none';
|
|
1652
1762
|
|
|
1653
1763
|
// Keep results visible after stopping, only hide download if no queries
|
|
1654
|
-
|
|
1655
|
-
downloadDropdown.style.display = hasQueries ? 'inline-block' : 'none';
|
|
1656
|
-
|
|
1657
|
-
// Only clear feed if no queries captured
|
|
1658
|
-
if (!hasQueries) {
|
|
1659
|
-
feed.innerHTML = '<div class="query-feed-empty">' + PG_REPORTS_I18N.monitoring.feed_empty + '</div>';
|
|
1660
|
-
}
|
|
1764
|
+
downloadDropdown.style.display = queryCount > 0 ? 'inline-block' : 'none';
|
|
1661
1765
|
}
|
|
1766
|
+
|
|
1767
|
+
renderQueryFeedEmptyState(enabled);
|
|
1662
1768
|
}
|
|
1663
1769
|
|
|
1664
1770
|
function toggleMonitorDownloadMenu(event) {
|
|
@@ -1697,126 +1803,206 @@ end</pre>
|
|
|
1697
1803
|
});
|
|
1698
1804
|
|
|
1699
1805
|
function startQueryMonitorPolling() {
|
|
1700
|
-
|
|
1806
|
+
stopQueryMonitorPolling();
|
|
1701
1807
|
|
|
1702
1808
|
fetchQueryFeed(); // Immediate fetch
|
|
1703
1809
|
queryMonitorPollTimer = setInterval(fetchQueryFeed, QUERY_MONITOR_CONFIG.pollInterval);
|
|
1810
|
+
queryMonitorCountdownTimer = setInterval(renderQueryMonitorCountdown, 1000);
|
|
1704
1811
|
}
|
|
1705
1812
|
|
|
1706
1813
|
function stopQueryMonitorPolling() {
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1814
|
+
clearInterval(queryMonitorPollTimer);
|
|
1815
|
+
clearInterval(queryMonitorCountdownTimer);
|
|
1816
|
+
queryMonitorPollTimer = null;
|
|
1817
|
+
queryMonitorCountdownTimer = null;
|
|
1711
1818
|
}
|
|
1712
1819
|
|
|
1713
|
-
|
|
1714
|
-
|
|
1820
|
+
// Shared by the Stop button and a session that ended on its own. Rows stay.
|
|
1821
|
+
function endQueryMonitoringSession() {
|
|
1822
|
+
queryMonitorEnabled = false;
|
|
1823
|
+
stopQueryMonitorPolling();
|
|
1824
|
+
updateQueryMonitorUI(false);
|
|
1825
|
+
renderQueryMonitorSource();
|
|
1826
|
+
setTimeout(fetchQueryFeed, QUERY_MONITOR_CONFIG.settleDelay);
|
|
1827
|
+
}
|
|
1715
1828
|
|
|
1829
|
+
async function fetchQueryFeed() {
|
|
1716
1830
|
try {
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1831
|
+
const params = new URLSearchParams({
|
|
1832
|
+
limit: QUERY_MONITOR_CONFIG.maxQueries,
|
|
1833
|
+
since: queryLastSeq,
|
|
1834
|
+
session_id: currentSessionId || ''
|
|
1835
|
+
});
|
|
1836
|
+
const response = await fetch(`${pgReportsRoot}/query_monitor/feed?${params}`);
|
|
1720
1837
|
const data = await response.json();
|
|
1721
1838
|
|
|
1722
|
-
if (data.success
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
const errorMsg = data.message || data.error || PG_REPORTS_I18N.errors.query_monitoring_error;
|
|
1727
|
-
showToast(errorMsg, 'error');
|
|
1839
|
+
if (!data.success) {
|
|
1840
|
+
renderQueryMonitorError(data.error);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1728
1843
|
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1844
|
+
renderQueryMonitorError(data.capture_error);
|
|
1845
|
+
const wasEnabled = queryMonitorEnabled;
|
|
1846
|
+
|
|
1847
|
+
// Someone restarted monitoring: the sequence numbers belong to a new session
|
|
1848
|
+
if (data.enabled && data.session_id !== currentSessionId) {
|
|
1849
|
+
currentSessionId = data.session_id;
|
|
1850
|
+
queryMonitorEnabled = true;
|
|
1851
|
+
clearQueryFeed();
|
|
1852
|
+
applyQueryMonitorSources(data);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
applyQueryMonitorStatus(data);
|
|
1856
|
+
appendQueries(data.queries);
|
|
1857
|
+
queryLastSeq = Math.max(queryLastSeq, data.last_seq);
|
|
1858
|
+
|
|
1859
|
+
if (wasEnabled && !data.enabled) {
|
|
1860
|
+
showToast(PG_REPORTS_I18N.monitoring.session_ended);
|
|
1861
|
+
endQueryMonitoringSession();
|
|
1862
|
+
} else if (!queryMonitorEnabled) {
|
|
1863
|
+
// Enough rows arrived after the stop to change what can be downloaded
|
|
1733
1864
|
updateQueryMonitorUI(false);
|
|
1734
1865
|
}
|
|
1735
1866
|
} catch (error) {
|
|
1736
1867
|
console.error('Failed to fetch query feed:', error);
|
|
1737
|
-
showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
|
|
1738
1868
|
}
|
|
1739
1869
|
}
|
|
1740
1870
|
|
|
1871
|
+
function clearQueryFeed() {
|
|
1872
|
+
document.getElementById('query-feed-body').replaceChildren();
|
|
1873
|
+
queryLastSeq = 0;
|
|
1874
|
+
queryCount = 0;
|
|
1875
|
+
document.getElementById('query-count').textContent = '0';
|
|
1876
|
+
renderQueryFeedEmptyState(queryMonitorEnabled);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function renderQueryFeedEmptyState(enabled) {
|
|
1880
|
+
const hasRows = document.getElementById('query-feed-body').children.length > 0;
|
|
1881
|
+
const empty = document.getElementById('query-feed-empty');
|
|
1882
|
+
|
|
1883
|
+
document.getElementById('query-feed-table').style.display = hasRows ? 'table' : 'none';
|
|
1884
|
+
empty.style.display = hasRows ? 'none' : 'block';
|
|
1885
|
+
empty.textContent = enabled ? PG_REPORTS_I18N.monitoring.feed_no_queries : PG_REPORTS_I18N.monitoring.feed_empty;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Replaces the table with a finished list, such as the history from the log file
|
|
1741
1889
|
function renderQueryFeed(queries) {
|
|
1890
|
+
clearQueryFeed();
|
|
1891
|
+
appendQueries(queries);
|
|
1892
|
+
queryCount = queries.length;
|
|
1893
|
+
document.getElementById('query-count').textContent = queryCount;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Rows are only added, never redrawn, so an expanded query and the reader's
|
|
1897
|
+
// scroll position survive every poll. `queries` arrive oldest first.
|
|
1898
|
+
function appendQueries(queries) {
|
|
1899
|
+
if (queries.length === 0) return;
|
|
1900
|
+
|
|
1742
1901
|
const feed = document.getElementById('query-feed');
|
|
1902
|
+
const body = document.getElementById('query-feed-body');
|
|
1903
|
+
const heightBefore = body.offsetHeight;
|
|
1743
1904
|
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1905
|
+
queries.forEach(query => body.prepend(buildQueryRow(query)));
|
|
1906
|
+
|
|
1907
|
+
let rows = body.querySelectorAll('.query-row').length;
|
|
1908
|
+
while (rows > QUERY_MONITOR_CONFIG.maxQueries) {
|
|
1909
|
+
if (body.lastElementChild.classList.contains('query-row')) rows--;
|
|
1910
|
+
body.lastElementChild.remove();
|
|
1749
1911
|
}
|
|
1750
1912
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1913
|
+
renderQueryFeedEmptyState(queryMonitorEnabled);
|
|
1914
|
+
|
|
1915
|
+
// Someone reading further down keeps their place as rows arrive on top
|
|
1916
|
+
if (feed.scrollTop > 0) feed.scrollTop += body.offsetHeight - heightBefore;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function buildQueryCell(className, text) {
|
|
1920
|
+
const cell = document.createElement('td');
|
|
1921
|
+
cell.className = className;
|
|
1922
|
+
cell.textContent = text;
|
|
1923
|
+
return cell;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function buildQuerySourceLink(query) {
|
|
1927
|
+
const location = query.source_location;
|
|
1928
|
+
if (!location || !location.file) return null;
|
|
1753
1929
|
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1930
|
+
const link = document.createElement('a');
|
|
1931
|
+
link.href = '#';
|
|
1932
|
+
link.className = 'query-source-link';
|
|
1933
|
+
link.dataset.file = location.file;
|
|
1934
|
+
link.dataset.line = location.line;
|
|
1935
|
+
link.title = `${location.file}:${location.line}`;
|
|
1936
|
+
// The tail of the path is what tells two rows apart in a narrow column
|
|
1937
|
+
link.textContent = `${location.file.split('/').slice(-2).join('/')}:${location.line}`;
|
|
1938
|
+
return link;
|
|
1757
1939
|
}
|
|
1758
1940
|
|
|
1759
|
-
function
|
|
1760
|
-
const
|
|
1941
|
+
function buildQueryRow(query) {
|
|
1942
|
+
const row = document.createElement('tr');
|
|
1943
|
+
row.className = 'query-row';
|
|
1944
|
+
queryFeedData.set(row, query);
|
|
1945
|
+
|
|
1946
|
+
const duration = Number(query.duration_ms) || 0;
|
|
1761
1947
|
const durationClass = duration < 10 ? 'fast' : duration > 100 ? 'slow' : '';
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1948
|
+
|
|
1949
|
+
row.append(
|
|
1950
|
+
buildQueryCell('col-time', new Date(query.timestamp).toLocaleTimeString()),
|
|
1951
|
+
buildQueryCell(`col-duration query-duration ${durationClass}`, duration.toFixed(1)),
|
|
1952
|
+
buildQueryCell('col-source', ''),
|
|
1953
|
+
buildQueryCell('col-sql', String(query.sql || '').replace(/\s+/g, ' ').trim())
|
|
1954
|
+
);
|
|
1955
|
+
|
|
1956
|
+
const link = buildQuerySourceLink(query);
|
|
1957
|
+
const sourceCell = row.querySelector('.col-source');
|
|
1958
|
+
if (link) {
|
|
1959
|
+
sourceCell.append(link);
|
|
1960
|
+
} else {
|
|
1961
|
+
sourceCell.textContent = query.source || query.name || PG_REPORTS_I18N.monitoring.unknown_source;
|
|
1772
1962
|
}
|
|
1773
1963
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
</div>
|
|
1797
|
-
<div class="query-sql query-sql-expanded" id="${queryId}-sql-full" style="display: none;">
|
|
1798
|
-
${escapeHtml(fullSql)}
|
|
1799
|
-
</div>
|
|
1800
|
-
</div>
|
|
1801
|
-
</div>
|
|
1802
|
-
`;
|
|
1964
|
+
return row;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function buildQueryDetail(query) {
|
|
1968
|
+
const row = document.createElement('tr');
|
|
1969
|
+
row.className = 'query-row-detail';
|
|
1970
|
+
|
|
1971
|
+
const cell = document.createElement('td');
|
|
1972
|
+
cell.colSpan = 4;
|
|
1973
|
+
|
|
1974
|
+
const link = buildQuerySourceLink(query);
|
|
1975
|
+
if (link) {
|
|
1976
|
+
link.textContent = link.title;
|
|
1977
|
+
cell.append(link);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const sql = document.createElement('pre');
|
|
1981
|
+
sql.textContent = String(query.sql || '').trim();
|
|
1982
|
+
cell.append(sql);
|
|
1983
|
+
|
|
1984
|
+
row.append(cell);
|
|
1985
|
+
return row;
|
|
1803
1986
|
}
|
|
1804
1987
|
|
|
1805
|
-
function
|
|
1806
|
-
const
|
|
1807
|
-
|
|
1808
|
-
|
|
1988
|
+
function handleQueryFeedClick(event) {
|
|
1989
|
+
const link = event.target.closest('.query-source-link');
|
|
1990
|
+
if (link) {
|
|
1991
|
+
openInIDE(link.dataset.file, Number(link.dataset.line), event);
|
|
1992
|
+
event.preventDefault();
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
const row = event.target.closest('.query-row');
|
|
1997
|
+
if (!row || String(window.getSelection())) return;
|
|
1809
1998
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
iconEl.textContent = '▼';
|
|
1999
|
+
const detail = row.nextElementSibling;
|
|
2000
|
+
if (detail && detail.classList.contains('query-row-detail')) {
|
|
2001
|
+
detail.remove();
|
|
2002
|
+
row.classList.remove('expanded');
|
|
1815
2003
|
} else {
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
expandedEl.style.display = 'block';
|
|
1819
|
-
iconEl.textContent = '▲';
|
|
2004
|
+
row.after(buildQueryDetail(queryFeedData.get(row)));
|
|
2005
|
+
row.classList.add('expanded');
|
|
1820
2006
|
}
|
|
1821
2007
|
}
|
|
1822
2008
|
|
data/bin/pg_reports
CHANGED
|
@@ -46,7 +46,7 @@ banner = <<~BANNER
|
|
|
46
46
|
|
|
47
47
|
Configuration is layered, lowest to highest precedence:
|
|
48
48
|
PG_REPORTS_* env vars < config file < the flags below.
|
|
49
|
-
A config file (--config,
|
|
49
|
+
A config file (--config PATH, never loaded unless you name it) is
|
|
50
50
|
plain Ruby calling `PgReports.configure` — the way to set every option that
|
|
51
51
|
has no flag (thresholds, Telegram, Grafana, dashboard_auth, …).
|
|
52
52
|
|
|
@@ -60,7 +60,7 @@ parser = OptionParser.new do |o|
|
|
|
60
60
|
o.on("-m", "--mount PATH", "Path to mount the dashboard at (default: /)") { |v| options[:mount] = v }
|
|
61
61
|
o.on("-d", "--database-url URL", "PostgreSQL connection URL") { |v| options[:database_url] = v }
|
|
62
62
|
o.on("-s", "--server NAME", "Web server to use (e.g. puma, webrick)") { |v| options[:server] = v }
|
|
63
|
-
o.on("-c", "--config PATH", "Ruby config file to load
|
|
63
|
+
o.on("-c", "--config PATH", "Ruby config file to load") { |v| options[:config_file] = v }
|
|
64
64
|
o.on("--[no-]allow-raw-query-execution", "Allow running raw SQL / EXPLAIN ANALYZE from the dashboard") { |v| overrides[:allow_raw_query_execution] = v }
|
|
65
65
|
o.on("--[no-]allow-migration-creation", "Allow the dashboard to write migration files to db/migrate/") { |v| overrides[:allow_migration_creation] = v }
|
|
66
66
|
o.on("--[no-]external-fonts", "Load Google Fonts in the dashboard (off by default)") { |v| overrides[:load_external_fonts] = v }
|