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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +126 -80
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
  8. data/app/views/pg_reports/dashboard/index.html.erb +310 -124
  9. data/bin/pg_reports +2 -2
  10. data/config/brakeman.ignore +10 -56
  11. data/config/locales/en.yml +39 -8
  12. data/config/locales/ru.yml +38 -8
  13. data/config/locales/uk.yml +38 -8
  14. data/config/routes.rb +1 -0
  15. data/lib/pg_reports/access_policy.rb +39 -0
  16. data/lib/pg_reports/compatibility.rb +10 -2
  17. data/lib/pg_reports/configuration.rb +33 -2
  18. data/lib/pg_reports/connection/error_translator.rb +21 -0
  19. data/lib/pg_reports/connection/target.rb +43 -10
  20. data/lib/pg_reports/csv_cell.rb +20 -0
  21. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  22. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  23. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  24. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  25. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  26. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  27. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  28. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  29. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  30. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  31. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  32. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  33. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  34. data/lib/pg_reports/drop_index_migration.rb +56 -0
  35. data/lib/pg_reports/engine.rb +4 -3
  36. data/lib/pg_reports/executor.rb +46 -9
  37. data/lib/pg_reports/modules/queries.rb +1 -0
  38. data/lib/pg_reports/query_monitor.rb +478 -192
  39. data/lib/pg_reports/rate_limiter.rb +63 -0
  40. data/lib/pg_reports/report.rb +1 -1
  41. data/lib/pg_reports/report_definition.rb +11 -1
  42. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  43. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  44. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  45. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  46. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  47. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  48. data/lib/pg_reports/sql_redactor.rb +32 -0
  49. data/lib/pg_reports/standalone.rb +83 -20
  50. data/lib/pg_reports/version.rb +1 -1
  51. data/lib/pg_reports.rb +7 -2
  52. metadata +17 -9
@@ -277,24 +277,28 @@ end</pre>
277
277
  </div>
278
278
  </div>
279
279
 
280
- <!-- Query Monitoring Panel: taps ActiveSupport::Notifications in the host
281
- application's process. There is no host app in standalone mode, so the
282
- panel is hidden entirely rather than showing a monitor with nothing to
283
- monitor. -->
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
- <span class="query-monitoring-scope" tabindex="0" data-tooltip="<%= t("pg_reports.ui.monitoring.scope_note_long", default: "Query Monitor subscribes to ActiveSupport::Notifications in the host application's process. It captures every SQL the host app runs, on whichever database the host app is connected to. The dashboard's database selector does not change this scope.") %>">
291
- <%= t("pg_reports.ui.monitoring.scope_note_short", default: "scope: host application") %>
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.session_label") %> <strong id="session-id"></strong>
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}${absolutePath}:${line}`
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${absolutePath}:${line}`
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=${absolutePath}&line=${line}`
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=${absolutePath}&line=${line}`
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}${absolutePath}:${line}`
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${absolutePath}:${line}`
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
- let menuHtml = '';
1447
- ideUrls.forEach(ide => {
1448
- menuHtml += `<a href="${ide.url}" onclick="closeIdeMenu()">${ide.name}</a>`;
1449
- });
1450
- menu.innerHTML = menuHtml;
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
- maxQueries: 50
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
- queryMonitorEnabled = false;
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
- const hasQueries = queryCount > 0;
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
- if (queryMonitorPollTimer) clearInterval(queryMonitorPollTimer);
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
- if (queryMonitorPollTimer) {
1708
- clearInterval(queryMonitorPollTimer);
1709
- queryMonitorPollTimer = null;
1710
- }
1814
+ clearInterval(queryMonitorPollTimer);
1815
+ clearInterval(queryMonitorCountdownTimer);
1816
+ queryMonitorPollTimer = null;
1817
+ queryMonitorCountdownTimer = null;
1711
1818
  }
1712
1819
 
1713
- async function fetchQueryFeed() {
1714
- if (!queryMonitorEnabled) return;
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 response = await fetch(
1718
- `${pgReportsRoot}/query_monitor/feed?limit=${QUERY_MONITOR_CONFIG.maxQueries}&session_id=${currentSessionId || ''}`
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 && data.queries) {
1723
- renderQueryFeed(data.queries);
1724
- } else if (!data.success) {
1725
- // Server returned an error (e.g., "Monitoring not active")
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
- // Stop polling and update UI since monitoring is not active
1730
- queryMonitorEnabled = false;
1731
- currentSessionId = null;
1732
- stopQueryMonitorPolling();
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
- if (queries.length === 0) {
1745
- feed.innerHTML = '<div class="query-feed-empty">' + PG_REPORTS_I18N.monitoring.feed_no_queries + '</div>';
1746
- queryCount = 0;
1747
- document.getElementById('query-count').textContent = '0';
1748
- return;
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
- queryCount = queries.length;
1752
- document.getElementById('query-count').textContent = queryCount;
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
- // Render queries in reverse chronological order
1755
- const html = queries.slice().reverse().map(query => renderQueryItem(query)).join('');
1756
- feed.innerHTML = html;
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 renderQueryItem(query) {
1760
- const duration = query.duration_ms;
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
- const timestamp = new Date(query.timestamp).toLocaleTimeString();
1763
- const queryId = `query-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1764
-
1765
- let sourceInfo = PG_REPORTS_I18N.monitoring.unknown_source;
1766
- if (query.source_location && query.source_location.file) {
1767
- const file = query.source_location.file;
1768
- const line = query.source_location.line;
1769
- sourceInfo = `<a href="#" onclick="openInIDE('${escapeHtml(file)}', ${line}, event); return false;">
1770
- ${escapeHtml(file)}:${line}
1771
- </a>`;
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
- // Prepare SQL: collapse to single line and truncate
1775
- const fullSql = query.sql.trim();
1776
- const collapsedSql = fullSql.replace(/\s+/g, ' ').trim();
1777
- const truncatedSql = collapsedSql.length > 100
1778
- ? collapsedSql.substring(0, 100) + '...'
1779
- : collapsedSql;
1780
-
1781
- return `
1782
- <div class="query-item">
1783
- <div class="query-header">
1784
- <div class="query-meta">
1785
- <span class="query-timestamp">${timestamp}</span>
1786
- <span class="query-duration ${durationClass}">${duration.toFixed(2)}ms</span>
1787
- <span class="query-source">${sourceInfo}</span>
1788
- </div>
1789
- <button class="query-expand-btn" onclick="toggleQueryExpand('${queryId}')" title="${PG_REPORTS_I18N.monitoring.expand_collapse_title}">
1790
- <span id="${queryId}-icon">▼</span>
1791
- </button>
1792
- </div>
1793
- <div class="query-sql-wrapper">
1794
- <div class="query-sql query-sql-collapsed" id="${queryId}-sql">
1795
- ${escapeHtml(truncatedSql)}
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 toggleQueryExpand(queryId) {
1806
- const collapsedEl = document.getElementById(`${queryId}-sql`);
1807
- const expandedEl = document.getElementById(`${queryId}-sql-full`);
1808
- const iconEl = document.getElementById(`${queryId}-icon`);
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
- if (collapsedEl.style.display === 'none') {
1811
- // Currently expanded, collapse it
1812
- collapsedEl.style.display = 'block';
1813
- expandedEl.style.display = 'none';
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
- // Currently collapsed, expand it
1817
- collapsedEl.style.display = 'none';
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, else ./pg_reports.rb or config/pg_reports.rb) is
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 (default: ./pg_reports.rb or config/pg_reports.rb)") { |v| options[:config_file] = v }
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 }