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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +222 -93
  7. data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
  8. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
  9. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
  10. data/app/views/pg_reports/dashboard/index.html.erb +346 -238
  11. data/app/views/pg_reports/dashboard/show.html.erb +1 -1
  12. data/bin/pg_reports +2 -2
  13. data/config/brakeman.ignore +10 -56
  14. data/config/locales/en.yml +45 -11
  15. data/config/locales/ru.yml +44 -11
  16. data/config/locales/uk.yml +44 -11
  17. data/config/routes.rb +2 -0
  18. data/lib/pg_reports/access_policy.rb +39 -0
  19. data/lib/pg_reports/compatibility.rb +10 -2
  20. data/lib/pg_reports/configuration.rb +33 -2
  21. data/lib/pg_reports/connection/error_translator.rb +21 -0
  22. data/lib/pg_reports/connection/target.rb +43 -10
  23. data/lib/pg_reports/csv_cell.rb +20 -0
  24. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  25. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  26. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  27. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  28. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  29. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  30. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  31. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  32. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  34. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  35. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  36. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  37. data/lib/pg_reports/drop_index_migration.rb +56 -0
  38. data/lib/pg_reports/engine.rb +4 -3
  39. data/lib/pg_reports/executor.rb +46 -9
  40. data/lib/pg_reports/modules/queries.rb +1 -0
  41. data/lib/pg_reports/query_monitor.rb +478 -192
  42. data/lib/pg_reports/rate_limiter.rb +63 -0
  43. data/lib/pg_reports/report.rb +1 -1
  44. data/lib/pg_reports/report_definition.rb +11 -1
  45. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  46. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  47. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  48. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  49. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  50. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  51. data/lib/pg_reports/sql_redactor.rb +32 -0
  52. data/lib/pg_reports/standalone.rb +83 -20
  53. data/lib/pg_reports/version.rb +1 -1
  54. data/lib/pg_reports.rb +7 -2
  55. metadata +18 -9
@@ -28,7 +28,7 @@
28
28
  <%= t("pg_reports.ui.actions.reset_statistics") %>
29
29
  </button>
30
30
  <% end %>
31
- <button type="button" class="btn-icon" onclick="showIdeSettingsModal()" title="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>">
31
+ <button type="button" class="btn-icon" onclick="showSettingsModal()" title="<%= t("pg_reports.ui.actions.settings_button_title") %>" aria-label="<%= t("pg_reports.ui.actions.settings_button_title") %>">
32
32
  <svg class="icon icon-lg"><use href="#i-settings"></use></svg>
33
33
  </button>
34
34
 
@@ -127,47 +127,7 @@ pg_stat_statements.track = all</pre>
127
127
  </div>
128
128
 
129
129
  <!-- IDE Settings Modal -->
130
- <div id="ide-settings-modal" class="modal" style="display: none;">
131
- <div class="modal-content modal-small">
132
- <div class="modal-header">
133
- <h3><%= t("pg_reports.ui.modals.ide_settings_title") %></h3>
134
- <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
135
- </div>
136
- <div class="modal-body">
137
- <p class="settings-label"><%= t("pg_reports.ui.settings.default_ide_label") %></p>
138
- <div class="ide-options">
139
- <label class="ide-option">
140
- <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
141
- <span><%= t("pg_reports.ui.settings.ide_show_menu") %></span>
142
- </label>
143
- <label class="ide-option">
144
- <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
145
- <span><%= t("pg_reports.ui.settings.ide_vscode_wsl") %></span>
146
- </label>
147
- <label class="ide-option">
148
- <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
149
- <span><%= t("pg_reports.ui.settings.ide_vscode") %></span>
150
- </label>
151
- <label class="ide-option">
152
- <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
153
- <span><%= t("pg_reports.ui.settings.ide_rubymine") %></span>
154
- </label>
155
- <label class="ide-option">
156
- <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
157
- <span><%= t("pg_reports.ui.settings.ide_intellij") %></span>
158
- </label>
159
- <label class="ide-option">
160
- <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
161
- <span><%= t("pg_reports.ui.settings.ide_cursor_wsl") %></span>
162
- </label>
163
- <label class="ide-option">
164
- <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
165
- <span><%= t("pg_reports.ui.settings.ide_cursor") %></span>
166
- </label>
167
- </div>
168
- </div>
169
- </div>
170
- </div>
130
+ <%= render "settings_modal" %>
171
131
 
172
132
  <!-- Run Query Modal -->
173
133
  <div id="run-query-modal" class="modal" style="display: none;">
@@ -239,7 +199,6 @@ end</pre>
239
199
  <svg id="sparkline-connections" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
240
200
  </div>
241
201
  </div>
242
- <div class="metric-status" id="status-connections"></div>
243
202
  </div>
244
203
 
245
204
  <!-- TPS -->
@@ -260,7 +219,6 @@ end</pre>
260
219
  <svg id="sparkline-tps" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
261
220
  </div>
262
221
  </div>
263
- <div class="metric-status" id="status-tps"></div>
264
222
  </div>
265
223
 
266
224
  <!-- Cache Hit Ratio -->
@@ -279,7 +237,6 @@ end</pre>
279
237
  <svg id="sparkline-cache" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
280
238
  </div>
281
239
  </div>
282
- <div class="metric-status" id="status-cache"></div>
283
240
  </div>
284
241
 
285
242
  <!-- Long Running Queries -->
@@ -298,7 +255,6 @@ end</pre>
298
255
  <svg id="sparkline-longrunning" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
299
256
  </div>
300
257
  </div>
301
- <div class="metric-status" id="status-longrunning"></div>
302
258
  </div>
303
259
 
304
260
  <!-- Blocked Queries -->
@@ -317,29 +273,32 @@ end</pre>
317
273
  <svg id="sparkline-blocked" viewBox="0 0 100 30" preserveAspectRatio="none"></svg>
318
274
  </div>
319
275
  </div>
320
- <div class="metric-status" id="status-blocked"></div>
321
276
  </div>
322
277
  </div>
323
278
  </div>
324
279
 
325
- <!-- Query Monitoring Panel: taps ActiveSupport::Notifications in the host
326
- application's process. There is no host app in standalone mode, so the
327
- panel is hidden entirely rather than showing a monitor with nothing to
328
- monitor. -->
329
- <% 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? %>
330
284
  <div id="query-monitoring" class="query-monitoring-panel" style="display: none;">
331
285
  <div class="query-monitoring-header">
332
286
  <div class="query-monitoring-title">
333
287
  <span class="monitoring-indicator" id="monitor-indicator"></span>
334
288
  <span><%= t("pg_reports.ui.monitoring.query_monitor_title") %></span>
335
- <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.") %>">
336
- <%= 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}") %>
337
292
  </span>
338
293
  <span class="session-badge" id="session-badge" style="display: none;">
339
- <%= 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>
340
295
  </span>
341
296
  </div>
342
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>
343
302
  <button class="btn btn-primary" onclick="startQueryMonitoring(this)" id="start-monitor-btn">
344
303
  <%= t("pg_reports.ui.actions.start_monitoring") %>
345
304
  </button>
@@ -365,10 +324,28 @@ end</pre>
365
324
  </div>
366
325
  </div>
367
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
+
368
334
  <div class="query-feed" id="query-feed">
369
- <div class="query-feed-empty">
335
+ <div class="query-feed-empty" id="query-feed-empty">
370
336
  <%= t("pg_reports.ui.monitoring.feed_empty") %>
371
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>
372
349
  </div>
373
350
  </div>
374
351
  <% end %>
@@ -654,11 +631,14 @@ end</pre>
654
631
  value colour and the sparkline, never by a coloured edge: "healthy" is
655
632
  the common case and should read as quiet, not decorated. */
656
633
  .live-metric-card {
634
+ display: flex;
635
+ flex-direction: column;
657
636
  background: var(--bg-tertiary);
658
637
  border: 1px solid var(--border-color);
659
638
  border-radius: var(--radius);
660
639
  padding: 0.875rem 1rem;
661
- position: relative;
640
+ /* The trend band below bleeds to the card's edges. */
641
+ overflow: hidden;
662
642
  transition: border-color 0.12s ease;
663
643
  }
664
644
 
@@ -674,7 +654,7 @@ end</pre>
674
654
  display: flex;
675
655
  align-items: center;
676
656
  gap: 0.5rem;
677
- margin-bottom: 0.625rem;
657
+ margin-bottom: 0.25rem;
678
658
  }
679
659
 
680
660
  .metric-icon {
@@ -693,6 +673,9 @@ end</pre>
693
673
  .metric-body {
694
674
  display: flex;
695
675
  flex-direction: column;
676
+ /* Figures hug the right edge; the trend band below opts back out. */
677
+ align-items: flex-end;
678
+ flex: 1;
696
679
  gap: 0.1875rem;
697
680
  }
698
681
 
@@ -719,17 +702,27 @@ end</pre>
719
702
  }
720
703
 
721
704
  .metric-detail {
705
+ max-width: 100%;
722
706
  font-size: 0.7rem;
723
707
  color: var(--text-muted);
708
+ text-align: right;
724
709
  white-space: nowrap;
725
710
  overflow: hidden;
726
711
  text-overflow: ellipsis;
727
712
  }
728
713
 
729
- /* Sparkline */
714
+ /* Trend band. Negative margins cancel the card's padding so it spans the full
715
+ width and sits flush with the bottom edge — as a floating inset line it left
716
+ the lower half of the card reading as dead space. `margin-top: auto` keeps
717
+ it pinned down when cards in a row differ in height. */
730
718
  .metric-sparkline {
731
- height: 28px;
732
- margin-top: 0.625rem;
719
+ /* Full width, regardless of the right-alignment its siblings use. */
720
+ align-self: stretch;
721
+ height: 2.25rem;
722
+ /* auto keeps the band on the bottom edge when cards in a row differ in
723
+ height; the negative side/bottom margins cancel the card's padding. */
724
+ margin: auto -1rem -0.875rem;
725
+ padding-top: 0.5rem;
733
726
  color: var(--text-muted);
734
727
  }
735
728
 
@@ -742,20 +735,6 @@ end</pre>
742
735
  display: block;
743
736
  }
744
737
 
745
- /* Status indicator */
746
- .metric-status {
747
- position: absolute;
748
- top: 0.75rem;
749
- right: 0.75rem;
750
- width: 6px;
751
- height: 6px;
752
- border-radius: 50%;
753
- background: var(--border-strong);
754
- }
755
-
756
- .metric-status.warning { background: var(--accent-amber); }
757
- .metric-status.critical { background: var(--accent-rose); }
758
-
759
738
  /* Run Query Modal */
760
739
  .modal-run-query {
761
740
  max-width: 1100px;
@@ -1329,23 +1308,30 @@ end</pre>
1329
1308
 
1330
1309
  const width = 100;
1331
1310
  const height = 30;
1311
+ // No horizontal inset: the band spans the card's full width, so the trace
1312
+ // should reach both edges. Vertical inset only, to keep the stroke from
1313
+ // being clipped at the extremes.
1332
1314
  const padding = 2;
1333
1315
 
1334
1316
  const min = Math.min(...data);
1335
1317
  const max = Math.max(...data);
1336
- const range = max - min || 1;
1318
+ const range = max - min;
1337
1319
 
1338
1320
  const points = data.map((value, index) => {
1339
- const x = padding + (index / (data.length - 1)) * (width - 2 * padding);
1340
- const y = height - padding - ((value - min) / range) * (height - 2 * padding);
1321
+ const x = (index / (data.length - 1)) * width;
1322
+ // A series that never moves is drawn mid-band: pinned to the floor it
1323
+ // reads as "zero" (or as a stray underline) rather than "steady".
1324
+ const y = range === 0
1325
+ ? height / 2
1326
+ : height - padding - ((value - min) / range) * (height - 2 * padding);
1341
1327
  return { x, y };
1342
1328
  });
1343
1329
 
1344
1330
  const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ');
1345
1331
 
1346
1332
  const areaD = pathD +
1347
- ` L ${points[points.length - 1].x} ${height - padding}` +
1348
- ` L ${points[0].x} ${height - padding} Z`;
1333
+ ` L ${points[points.length - 1].x} ${height}` +
1334
+ ` L ${points[0].x} ${height} Z`;
1349
1335
 
1350
1336
  const area = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1351
1337
  area.setAttribute('d', areaD);
@@ -1366,8 +1352,7 @@ end</pre>
1366
1352
 
1367
1353
  function updateStatus(metric, value, badDirection) {
1368
1354
  const card = document.querySelector(`[data-metric="${metric}"]`);
1369
- const statusDot = document.getElementById(`status-${metric}`);
1370
- if (!card || !statusDot) return;
1355
+ if (!card) return;
1371
1356
 
1372
1357
  const thresholds = LIVE_CONFIG.thresholds[metric];
1373
1358
  let status = 'ok';
@@ -1384,8 +1369,6 @@ end</pre>
1384
1369
 
1385
1370
  card.classList.remove('status-ok', 'status-warning', 'status-critical');
1386
1371
  card.classList.add(`status-${status}`);
1387
- statusDot.classList.remove('ok', 'warning', 'critical');
1388
- statusDot.classList.add(status);
1389
1372
  }
1390
1373
 
1391
1374
  document.addEventListener('DOMContentLoaded', initLiveMonitoring);
@@ -1407,10 +1390,6 @@ end</pre>
1407
1390
  'cursor': 5
1408
1391
  };
1409
1392
 
1410
- function getDefaultIde() {
1411
- return localStorage.getItem('pgReportsDefaultIde') || '';
1412
- }
1413
-
1414
1393
  function generateIdeUrls(filePath, lineNumber) {
1415
1394
  if (!filePath) return [];
1416
1395
 
@@ -1423,40 +1402,45 @@ end</pre>
1423
1402
  const line = lineNumber || 1;
1424
1403
  const urls = [];
1425
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
+
1426
1410
  // VSCode (WSL Remote format)
1427
1411
  urls.push({
1428
1412
  name: PG_REPORTS_I18N.settings.ide_vscode_wsl,
1429
- url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1413
+ url: `vscode://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
1430
1414
  });
1431
1415
 
1432
1416
  // VSCode (direct path)
1433
1417
  urls.push({
1434
1418
  name: PG_REPORTS_I18N.settings.ide_vscode,
1435
- url: `vscode://file${absolutePath}:${line}`
1419
+ url: `vscode://file${urlPath}:${line}`
1436
1420
  });
1437
1421
 
1438
1422
  // RubyMine
1439
1423
  urls.push({
1440
1424
  name: PG_REPORTS_I18N.settings.ide_rubymine,
1441
- url: `rubymine://open?file=${absolutePath}&line=${line}`
1425
+ url: `rubymine://open?file=${urlParam}&line=${line}`
1442
1426
  });
1443
1427
 
1444
1428
  // IntelliJ
1445
1429
  urls.push({
1446
1430
  name: PG_REPORTS_I18N.settings.ide_intellij,
1447
- url: `idea://open?file=${absolutePath}&line=${line}`
1431
+ url: `idea://open?file=${urlParam}&line=${line}`
1448
1432
  });
1449
1433
 
1450
1434
  // Cursor (WSL Remote format)
1451
1435
  urls.push({
1452
1436
  name: PG_REPORTS_I18N.settings.ide_cursor_wsl,
1453
- url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1437
+ url: `cursor://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
1454
1438
  });
1455
1439
 
1456
1440
  // Cursor (direct path)
1457
1441
  urls.push({
1458
1442
  name: PG_REPORTS_I18N.settings.ide_cursor,
1459
- url: `cursor://file${absolutePath}:${line}`
1443
+ url: `cursor://file${urlPath}:${line}`
1460
1444
  });
1461
1445
 
1462
1446
  return urls;
@@ -1486,11 +1470,13 @@ end</pre>
1486
1470
  const menu = document.getElementById('ide-menu');
1487
1471
  const overlay = document.getElementById('ide-dropdown-overlay');
1488
1472
 
1489
- let menuHtml = '';
1490
- ideUrls.forEach(ide => {
1491
- menuHtml += `<a href="${ide.url}" onclick="closeIdeMenu()">${ide.name}</a>`;
1492
- });
1493
- 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
+ }));
1494
1480
 
1495
1481
  // Position menu near the clicked element
1496
1482
  if (event && event.target) {
@@ -1509,80 +1495,130 @@ end</pre>
1509
1495
  document.getElementById('ide-dropdown-overlay')?.classList.remove('show');
1510
1496
  }
1511
1497
 
1512
- function showIdeSettingsModal() {
1513
- const modal = document.getElementById('ide-settings-modal');
1514
- modal.style.display = 'flex';
1515
- loadIdeSettingsState();
1516
- }
1517
-
1518
- function closeIdeSettingsModal() {
1519
- document.getElementById('ide-settings-modal').style.display = 'none';
1520
- }
1521
-
1522
- function setDefaultIde(ideKey) {
1523
- if (ideKey) {
1524
- localStorage.setItem('pgReportsDefaultIde', ideKey);
1525
- } else {
1526
- localStorage.removeItem('pgReportsDefaultIde');
1527
- }
1528
- }
1529
-
1530
- function loadIdeSettingsState() {
1531
- const currentIde = getDefaultIde();
1532
- const radios = document.querySelectorAll('input[name="default-ide"]');
1533
- radios.forEach(radio => {
1534
- radio.checked = (radio.value === currentIde);
1535
- });
1536
- }
1537
-
1538
- // Close IDE settings modal on backdrop click
1539
- document.addEventListener('DOMContentLoaded', function() {
1540
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
1541
- if (e.target === this) {
1542
- closeIdeSettingsModal();
1543
- }
1544
- });
1545
- });
1546
-
1547
1498
  // ============================================
1548
1499
  // Query Monitoring
1549
1500
  // ============================================
1550
1501
 
1551
1502
  const QUERY_MONITOR_CONFIG = {
1552
1503
  pollInterval: 2000,
1553
- maxQueries: 50
1504
+ // Reaching every process takes one flush, so a stopped session settles shortly after
1505
+ settleDelay: 1200,
1506
+ maxQueries: 100
1554
1507
  };
1555
1508
 
1556
1509
  let queryMonitorEnabled = false;
1557
1510
  let queryMonitorPollTimer = null;
1511
+ let queryMonitorCountdownTimer = null;
1512
+ let queryMonitorExpiresAt = null;
1558
1513
  let currentSessionId = null;
1559
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
+ }
1560
1558
 
1561
1559
  function initQueryMonitoring() {
1562
1560
  const panel = document.getElementById('query-monitoring');
1563
1561
  if (!panel) return;
1564
1562
 
1563
+ document.getElementById('query-feed-body').addEventListener('click', handleQueryFeedClick);
1564
+
1565
1565
  // Check initial status
1566
1566
  checkQueryMonitorStatus();
1567
1567
  }
1568
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
+
1569
1600
  async function checkQueryMonitorStatus() {
1570
1601
  try {
1571
1602
  const response = await fetch(`${pgReportsRoot}/query_monitor/status`);
1572
1603
  const data = await response.json();
1573
1604
 
1574
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);
1575
1610
 
1576
1611
  if (data.success && data.enabled) {
1577
1612
  queryMonitorEnabled = true;
1578
1613
  currentSessionId = data.session_id;
1579
- queryCount = data.query_count;
1580
1614
  updateQueryMonitorUI(true);
1581
1615
  startQueryMonitorPolling();
1582
1616
  } else {
1583
1617
  // Monitoring not active
1584
1618
  queryMonitorEnabled = false;
1585
1619
  updateQueryMonitorUI(false);
1620
+ // The last session's queries outlive it, so a reload does not lose them
1621
+ if (queryCount > 0) fetchQueryFeed();
1586
1622
  }
1587
1623
  } catch (error) {
1588
1624
  console.error('Failed to check query monitor status:', error);
@@ -1638,7 +1674,8 @@ end</pre>
1638
1674
  headers: {
1639
1675
  'Content-Type': 'application/json',
1640
1676
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1641
- }
1677
+ },
1678
+ body: JSON.stringify({ source: queryMonitorSource })
1642
1679
  });
1643
1680
 
1644
1681
  const data = await response.json();
@@ -1646,11 +1683,13 @@ end</pre>
1646
1683
  if (data.success) {
1647
1684
  queryMonitorEnabled = true;
1648
1685
  currentSessionId = data.session_id;
1686
+ clearQueryFeed();
1649
1687
  updateQueryMonitorUI(true);
1688
+ renderQueryMonitorSource();
1650
1689
  startQueryMonitorPolling();
1651
1690
  showToast(data.message);
1652
1691
  } else {
1653
- 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');
1654
1693
  button.disabled = false;
1655
1694
  button.innerHTML = PG_REPORTS_I18N.actions.start_monitoring;
1656
1695
  }
@@ -1677,11 +1716,7 @@ end</pre>
1677
1716
  const data = await response.json();
1678
1717
 
1679
1718
  if (data.success) {
1680
- queryMonitorEnabled = false;
1681
- currentSessionId = null;
1682
- stopQueryMonitorPolling();
1683
-
1684
- updateQueryMonitorUI(false);
1719
+ endQueryMonitoringSession();
1685
1720
  showToast(data.message);
1686
1721
  } else {
1687
1722
  showToast(data.message || PG_REPORTS_I18N.errors.stop_monitoring_failed, 'error');
@@ -1702,9 +1737,7 @@ end</pre>
1702
1737
  const stopBtn = document.getElementById('stop-monitor-btn');
1703
1738
  const loadHistoryBtn = document.getElementById('load-history-btn');
1704
1739
  const sessionBadge = document.getElementById('session-badge');
1705
- const sessionIdEl = document.getElementById('session-id');
1706
1740
  const downloadDropdown = document.getElementById('monitor-download-dropdown');
1707
- const feed = document.getElementById('query-feed');
1708
1741
 
1709
1742
  panel.style.display = 'block';
1710
1743
 
@@ -1716,7 +1749,6 @@ end</pre>
1716
1749
  stopBtn.innerHTML = PG_REPORTS_I18N.actions.stop_monitoring;
1717
1750
  loadHistoryBtn.style.display = 'none'; // Hide when monitoring active
1718
1751
  sessionBadge.style.display = 'inline-block';
1719
- sessionIdEl.textContent = currentSessionId ? currentSessionId.substring(0, 8) : '';
1720
1752
  downloadDropdown.style.display = 'inline-block';
1721
1753
  } else {
1722
1754
  indicator.classList.remove('active');
@@ -1729,14 +1761,10 @@ end</pre>
1729
1761
  sessionBadge.style.display = 'none';
1730
1762
 
1731
1763
  // Keep results visible after stopping, only hide download if no queries
1732
- const hasQueries = queryCount > 0;
1733
- downloadDropdown.style.display = hasQueries ? 'inline-block' : 'none';
1734
-
1735
- // Only clear feed if no queries captured
1736
- if (!hasQueries) {
1737
- feed.innerHTML = '<div class="query-feed-empty">' + PG_REPORTS_I18N.monitoring.feed_empty + '</div>';
1738
- }
1764
+ downloadDropdown.style.display = queryCount > 0 ? 'inline-block' : 'none';
1739
1765
  }
1766
+
1767
+ renderQueryFeedEmptyState(enabled);
1740
1768
  }
1741
1769
 
1742
1770
  function toggleMonitorDownloadMenu(event) {
@@ -1775,126 +1803,206 @@ end</pre>
1775
1803
  });
1776
1804
 
1777
1805
  function startQueryMonitorPolling() {
1778
- if (queryMonitorPollTimer) clearInterval(queryMonitorPollTimer);
1806
+ stopQueryMonitorPolling();
1779
1807
 
1780
1808
  fetchQueryFeed(); // Immediate fetch
1781
1809
  queryMonitorPollTimer = setInterval(fetchQueryFeed, QUERY_MONITOR_CONFIG.pollInterval);
1810
+ queryMonitorCountdownTimer = setInterval(renderQueryMonitorCountdown, 1000);
1782
1811
  }
1783
1812
 
1784
1813
  function stopQueryMonitorPolling() {
1785
- if (queryMonitorPollTimer) {
1786
- clearInterval(queryMonitorPollTimer);
1787
- queryMonitorPollTimer = null;
1788
- }
1814
+ clearInterval(queryMonitorPollTimer);
1815
+ clearInterval(queryMonitorCountdownTimer);
1816
+ queryMonitorPollTimer = null;
1817
+ queryMonitorCountdownTimer = null;
1789
1818
  }
1790
1819
 
1791
- async function fetchQueryFeed() {
1792
- 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
+ }
1793
1828
 
1829
+ async function fetchQueryFeed() {
1794
1830
  try {
1795
- const response = await fetch(
1796
- `${pgReportsRoot}/query_monitor/feed?limit=${QUERY_MONITOR_CONFIG.maxQueries}&session_id=${currentSessionId || ''}`
1797
- );
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}`);
1798
1837
  const data = await response.json();
1799
1838
 
1800
- if (data.success && data.queries) {
1801
- renderQueryFeed(data.queries);
1802
- } else if (!data.success) {
1803
- // Server returned an error (e.g., "Monitoring not active")
1804
- const errorMsg = data.message || data.error || PG_REPORTS_I18N.errors.query_monitoring_error;
1805
- showToast(errorMsg, 'error');
1839
+ if (!data.success) {
1840
+ renderQueryMonitorError(data.error);
1841
+ return;
1842
+ }
1806
1843
 
1807
- // Stop polling and update UI since monitoring is not active
1808
- queryMonitorEnabled = false;
1809
- currentSessionId = null;
1810
- 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
1811
1864
  updateQueryMonitorUI(false);
1812
1865
  }
1813
1866
  } catch (error) {
1814
1867
  console.error('Failed to fetch query feed:', error);
1815
- showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
1816
1868
  }
1817
1869
  }
1818
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
1819
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
+
1820
1901
  const feed = document.getElementById('query-feed');
1902
+ const body = document.getElementById('query-feed-body');
1903
+ const heightBefore = body.offsetHeight;
1821
1904
 
1822
- if (queries.length === 0) {
1823
- feed.innerHTML = '<div class="query-feed-empty">' + PG_REPORTS_I18N.monitoring.feed_no_queries + '</div>';
1824
- queryCount = 0;
1825
- document.getElementById('query-count').textContent = '0';
1826
- 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();
1827
1911
  }
1828
1912
 
1829
- queryCount = queries.length;
1830
- 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;
1831
1929
 
1832
- // Render queries in reverse chronological order
1833
- const html = queries.slice().reverse().map(query => renderQueryItem(query)).join('');
1834
- 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;
1835
1939
  }
1836
1940
 
1837
- function renderQueryItem(query) {
1838
- 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;
1839
1947
  const durationClass = duration < 10 ? 'fast' : duration > 100 ? 'slow' : '';
1840
- const timestamp = new Date(query.timestamp).toLocaleTimeString();
1841
- const queryId = `query-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1842
-
1843
- let sourceInfo = PG_REPORTS_I18N.monitoring.unknown_source;
1844
- if (query.source_location && query.source_location.file) {
1845
- const file = query.source_location.file;
1846
- const line = query.source_location.line;
1847
- sourceInfo = `<a href="#" onclick="openInIDE('${escapeHtml(file)}', ${line}, event); return false;">
1848
- ${escapeHtml(file)}:${line}
1849
- </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;
1850
1962
  }
1851
1963
 
1852
- // Prepare SQL: collapse to single line and truncate
1853
- const fullSql = query.sql.trim();
1854
- const collapsedSql = fullSql.replace(/\s+/g, ' ').trim();
1855
- const truncatedSql = collapsedSql.length > 100
1856
- ? collapsedSql.substring(0, 100) + '...'
1857
- : collapsedSql;
1858
-
1859
- return `
1860
- <div class="query-item">
1861
- <div class="query-header">
1862
- <div class="query-meta">
1863
- <span class="query-timestamp">${timestamp}</span>
1864
- <span class="query-duration ${durationClass}">${duration.toFixed(2)}ms</span>
1865
- <span class="query-source">${sourceInfo}</span>
1866
- </div>
1867
- <button class="query-expand-btn" onclick="toggleQueryExpand('${queryId}')" title="${PG_REPORTS_I18N.monitoring.expand_collapse_title}">
1868
- <span id="${queryId}-icon">▼</span>
1869
- </button>
1870
- </div>
1871
- <div class="query-sql-wrapper">
1872
- <div class="query-sql query-sql-collapsed" id="${queryId}-sql">
1873
- ${escapeHtml(truncatedSql)}
1874
- </div>
1875
- <div class="query-sql query-sql-expanded" id="${queryId}-sql-full" style="display: none;">
1876
- ${escapeHtml(fullSql)}
1877
- </div>
1878
- </div>
1879
- </div>
1880
- `;
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;
1881
1986
  }
1882
1987
 
1883
- function toggleQueryExpand(queryId) {
1884
- const collapsedEl = document.getElementById(`${queryId}-sql`);
1885
- const expandedEl = document.getElementById(`${queryId}-sql-full`);
1886
- 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;
1887
1998
 
1888
- if (collapsedEl.style.display === 'none') {
1889
- // Currently expanded, collapse it
1890
- collapsedEl.style.display = 'block';
1891
- expandedEl.style.display = 'none';
1892
- iconEl.textContent = '▼';
1999
+ const detail = row.nextElementSibling;
2000
+ if (detail && detail.classList.contains('query-row-detail')) {
2001
+ detail.remove();
2002
+ row.classList.remove('expanded');
1893
2003
  } else {
1894
- // Currently collapsed, expand it
1895
- collapsedEl.style.display = 'none';
1896
- expandedEl.style.display = 'block';
1897
- iconEl.textContent = '▲';
2004
+ row.after(buildQueryDetail(queryFeedData.get(row)));
2005
+ row.classList.add('expanded');
1898
2006
  }
1899
2007
  }
1900
2008