pg_reports 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,23 +12,25 @@
12
12
 
13
13
  <header class="header">
14
14
  <div class="logo">
15
- <div class="logo-icon">🐘</div>
16
- <div class="logo-text">
17
- <h1>
18
- <%= t("pg_reports.ui.branding.title") %>
19
- <span class="logo-version">v<%= PgReports::VERSION %></span>
20
- </h1>
21
- <span class="logo-subtitle"><%= t("pg_reports.ui.branding.subtitle") %></span>
22
- </div>
15
+ <h1>
16
+ <%= t("pg_reports.ui.branding.title") %>
17
+ <span class="logo-version">v<%= PgReports::VERSION %></span>
18
+ </h1>
19
+ <span class="logo-subtitle"><%= t("pg_reports.ui.branding.subtitle") %></span>
23
20
  </div>
24
21
 
25
22
  <div class="header-actions">
23
+ <button class="btn btn-primary" onclick="showRunQueryModal()" id="run-query-open-btn">
24
+ <%= t("pg_reports.ui.actions.run_query") %>
25
+ </button>
26
26
  <% if @pg_stat_status[:ready] %>
27
- <button class="btn btn-small btn-muted" onclick="showResetConfirmModal()" id="reset-btn">
27
+ <button class="btn btn-muted" onclick="showResetConfirmModal()" id="reset-btn">
28
28
  <%= t("pg_reports.ui.actions.reset_statistics") %>
29
29
  </button>
30
30
  <% end %>
31
- <button class="btn-info" onclick="showIdeSettingsModal()" title="<%= t("pg_reports.ui.actions.ide_settings_button_title") %>">⚙️</button>
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") %>">
32
+ <svg class="icon icon-lg"><use href="#i-settings"></use></svg>
33
+ </button>
32
34
 
33
35
  <% if !@pg_stat_status[:connected] %>
34
36
  <div class="header-badge" id="pg-stat-badge">
@@ -45,14 +47,14 @@
45
47
  onclick="showPgStatInfo()" title="<%= t("pg_reports.ui.status.click_for_details") %>">
46
48
  <span class="badge-dot warning"></span>
47
49
  <span><%= t("pg_reports.ui.status.not_preloaded") %></span>
48
- <span class="badge-info-icon" aria-hidden="true">ⓘ</span>
50
+ <svg class="icon badge-info-icon" aria-hidden="true"><use href="#i-info"></use></svg>
49
51
  </button>
50
52
  <% else %>
51
53
  <button type="button" class="header-badge header-badge-clickable" id="pg-stat-badge"
52
54
  onclick="showCreateExtensionModal()" title="<%= t("pg_reports.ui.status.click_for_details") %>">
53
55
  <span class="badge-dot warning"></span>
54
56
  <span><%= t("pg_reports.ui.status.extension_missing") %></span>
55
- <span class="badge-info-icon" aria-hidden="true">ⓘ</span>
57
+ <svg class="icon badge-info-icon" aria-hidden="true"><use href="#i-info"></use></svg>
56
58
  </button>
57
59
  <% end %>
58
60
  </div>
@@ -167,6 +169,40 @@ pg_stat_statements.track = all</pre>
167
169
  </div>
168
170
  </div>
169
171
 
172
+ <!-- Run Query Modal -->
173
+ <div id="run-query-modal" class="modal" style="display: none;">
174
+ <div class="modal-content modal-run-query">
175
+ <div class="modal-header">
176
+ <h3><%= t("pg_reports.ui.modals.run_query_title") %></h3>
177
+ <button class="modal-close" onclick="closeRunQueryModal()">&times;</button>
178
+ </div>
179
+ <div class="modal-body">
180
+ <% if PgReports.config.allow_raw_query_execution %>
181
+ <label class="explain-label" for="run-query-input"><%= t("pg_reports.ui.modals.query_label") %></label>
182
+ <textarea id="run-query-input" class="run-query-textarea" spellcheck="false" placeholder="<%= t("pg_reports.ui.modals.run_query_placeholder") %>"></textarea>
183
+ <div class="run-query-actions">
184
+ <button class="btn btn-secondary" onclick="clearRunQuery()"><%= t("pg_reports.ui.actions.clear_all") %></button>
185
+ <button class="btn btn-primary" onclick="executeRunQuery()" id="btn-run-query"><%= t("pg_reports.ui.actions.run_query") %></button>
186
+ </div>
187
+ <div id="run-query-loading" class="loading" style="display: none;">
188
+ <div class="spinner"></div>
189
+ </div>
190
+ <div id="run-query-content"></div>
191
+ <% else %>
192
+ <div class="error-message">
193
+ <p class="error-title"><%= t("pg_reports.ui.modals.query_execution_disabled_title") %></p>
194
+ <p><%= t("pg_reports.ui.modals.query_execution_disabled_intro") %></p>
195
+ <pre class="code-block">PgReports.configure do |config|
196
+ config.allow_raw_query_execution = true
197
+ end</pre>
198
+ <p><%= t("pg_reports.ui.modals.query_execution_disabled_env_note") %></p>
199
+ <pre class="code-block">PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION=true</pre>
200
+ </div>
201
+ <% end %>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
170
206
  <!-- Live Monitoring Panel -->
171
207
  <div id="live-monitoring" class="live-monitoring-panel" style="display: none;">
172
208
  <div class="live-monitoring-header">
@@ -178,8 +214,8 @@ pg_stat_statements.track = all</pre>
178
214
  </div>
179
215
  <div class="live-monitoring-controls">
180
216
  <span class="live-monitoring-interval"><%= t("pg_reports.ui.monitoring.update_interval") %></span>
181
- <button class="btn-toggle-live" onclick="toggleLiveMonitoring()" title="<%= t("pg_reports.ui.monitoring.toggle_title") %>">
182
- <span id="toggle-icon">⏸</span>
217
+ <button type="button" class="btn-icon btn-small" onclick="toggleLiveMonitoring()" title="<%= t("pg_reports.ui.monitoring.toggle_title") %>" aria-label="<%= t("pg_reports.ui.monitoring.toggle_title") %>">
218
+ <svg class="icon" id="toggle-icon"><use href="#i-pause"></use></svg>
183
219
  </button>
184
220
  </div>
185
221
  </div>
@@ -188,7 +224,7 @@ pg_stat_statements.track = all</pre>
188
224
  <!-- Connections -->
189
225
  <div class="live-metric-card" data-metric="connections">
190
226
  <div class="metric-header">
191
- <span class="metric-icon" style="background: rgba(107, 159, 232, 0.12); color: var(--accent-blue);">🔗</span>
227
+ <svg class="icon metric-icon"><use href="#i-link"></use></svg>
192
228
  <span class="metric-name"><%= t("pg_reports.ui.metrics.connections_label") %></span>
193
229
  </div>
194
230
  <div class="metric-body">
@@ -209,7 +245,7 @@ pg_stat_statements.track = all</pre>
209
245
  <!-- TPS -->
210
246
  <div class="live-metric-card" data-metric="tps">
211
247
  <div class="metric-header">
212
- <span class="metric-icon" style="background: rgba(95, 184, 154, 0.12); color: var(--accent-green);">⚡</span>
248
+ <svg class="icon metric-icon"><use href="#i-activity"></use></svg>
213
249
  <span class="metric-name"><%= t("pg_reports.ui.metrics.tps_label") %></span>
214
250
  </div>
215
251
  <div class="metric-body">
@@ -230,7 +266,7 @@ pg_stat_statements.track = all</pre>
230
266
  <!-- Cache Hit Ratio -->
231
267
  <div class="live-metric-card" data-metric="cache">
232
268
  <div class="metric-header">
233
- <span class="metric-icon" style="background: rgba(157, 140, 214, 0.12); color: var(--accent-purple);">💾</span>
269
+ <svg class="icon metric-icon"><use href="#i-database"></use></svg>
234
270
  <span class="metric-name"><%= t("pg_reports.ui.metrics.cache_hit_label") %></span>
235
271
  </div>
236
272
  <div class="metric-body">
@@ -249,7 +285,7 @@ pg_stat_statements.track = all</pre>
249
285
  <!-- Long Running Queries -->
250
286
  <div class="live-metric-card" data-metric="longrunning">
251
287
  <div class="metric-header">
252
- <span class="metric-icon" style="background: rgba(212, 160, 86, 0.12); color: var(--accent-amber);">🐢</span>
288
+ <svg class="icon metric-icon"><use href="#i-clock"></use></svg>
253
289
  <span class="metric-name"><%= t("pg_reports.ui.metrics.long_queries_label") %></span>
254
290
  </div>
255
291
  <div class="metric-body">
@@ -268,7 +304,7 @@ pg_stat_statements.track = all</pre>
268
304
  <!-- Blocked Queries -->
269
305
  <div class="live-metric-card" data-metric="blocked">
270
306
  <div class="metric-header">
271
- <span class="metric-icon" style="background: rgba(217, 112, 132, 0.12); color: var(--accent-rose);">🔒</span>
307
+ <svg class="icon metric-icon"><use href="#i-lock"></use></svg>
272
308
  <span class="metric-name"><%= t("pg_reports.ui.metrics.blocked_label") %></span>
273
309
  </div>
274
310
  <div class="metric-body">
@@ -286,7 +322,11 @@ pg_stat_statements.track = all</pre>
286
322
  </div>
287
323
  </div>
288
324
 
289
- <!-- Query Monitoring Panel -->
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 %>
290
330
  <div id="query-monitoring" class="query-monitoring-panel" style="display: none;">
291
331
  <div class="query-monitoring-header">
292
332
  <div class="query-monitoring-title">
@@ -300,17 +340,17 @@ pg_stat_statements.track = all</pre>
300
340
  </span>
301
341
  </div>
302
342
  <div class="query-monitoring-controls">
303
- <button class="btn btn-small btn-primary" onclick="startQueryMonitoring(this)" id="start-monitor-btn">
343
+ <button class="btn btn-primary" onclick="startQueryMonitoring(this)" id="start-monitor-btn">
304
344
  <%= t("pg_reports.ui.actions.start_monitoring") %>
305
345
  </button>
306
- <button class="btn btn-small btn-danger" onclick="stopQueryMonitoring(this)" id="stop-monitor-btn" style="display: none;">
346
+ <button class="btn btn-danger" onclick="stopQueryMonitoring(this)" id="stop-monitor-btn" style="display: none;">
307
347
  <%= t("pg_reports.ui.actions.stop_monitoring") %>
308
348
  </button>
309
- <button class="btn btn-small btn-secondary" onclick="loadQueryHistory(this)" id="load-history-btn">
349
+ <button class="btn btn-secondary" onclick="loadQueryHistory(this)" id="load-history-btn" disabled>
310
350
  <%= t("pg_reports.ui.actions.load_history") %>
311
351
  </button>
312
352
  <div class="download-dropdown" id="monitor-download-dropdown" style="display: none;">
313
- <button class="btn btn-small btn-secondary" onclick="toggleMonitorDownloadMenu(event)">
353
+ <button class="btn btn-secondary" onclick="toggleMonitorDownloadMenu(event)">
314
354
  <%= t("pg_reports.ui.actions.download") %>
315
355
  </button>
316
356
  <div class="download-menu" id="monitor-download-menu" style="display: none;">
@@ -331,35 +371,35 @@ pg_stat_statements.track = all</pre>
331
371
  </div>
332
372
  </div>
333
373
  </div>
374
+ <% end %>
334
375
 
376
+ <%
377
+ # Sprite ids for the category glyphs defined in the layout. The registry's own
378
+ # :icon/:color keys stay untouched — they are part of its public shape.
379
+ category_icons = {
380
+ queries: "queries", indexes: "indexes", tables: "tables",
381
+ connections: "connections", system: "system", schema_analysis: "schema"
382
+ }
383
+ %>
335
384
  <div class="categories-grid">
336
385
  <% @categories.each do |category_key, category| %>
337
- <%
338
- requires_pg_stat = (category_key == :queries && !@pg_stat_status[:ready])
339
- target_disabled_reason = category_disabled_reason(category_key)
340
- is_disabled = requires_pg_stat || target_disabled_reason.present?
341
- %>
342
- <div class="category-card<%= ' disabled' if is_disabled %>">
343
- <% if requires_pg_stat %>
344
- <div class="category-warning">
345
- <%= t("pg_reports.ui.categories.requires_pg_stat") %>
346
- </div>
347
- <% elsif target_disabled_reason %>
386
+ <% disabled_reason = category_disabled_reason(category_key) %>
387
+ <div class="category-card<%= ' disabled' if disabled_reason %>">
388
+ <% if disabled_reason %>
348
389
  <div class="category-warning">
349
- <%= target_disabled_reason %>
390
+ <svg class="icon" aria-hidden="true"><use href="#i-lock"></use></svg>
391
+ <span><%= disabled_reason %></span>
350
392
  </div>
351
393
  <% end %>
352
394
  <div class="category-header">
353
- <div class="category-icon" style="background: <%= category[:color] %>20; color: <%= category[:color] %>;">
354
- <%= category[:icon] %>
355
- </div>
395
+ <svg class="icon category-icon" aria-hidden="true"><use href="#i-<%= category_icons.fetch(category_key, "tables") %>"></use></svg>
356
396
  <span class="category-title"><%= category[:name] %></span>
357
397
  <span class="category-count"><%= category[:reports].size %> <%= t("pg_reports.ui.categories.reports_count_suffix") %></span>
358
398
  </div>
359
399
 
360
400
  <div class="reports-list">
361
401
  <% category[:reports].each do |report_key, report| %>
362
- <% if is_disabled %>
402
+ <% if disabled_reason %>
363
403
  <div class="report-link disabled">
364
404
  <div class="report-link-info">
365
405
  <span class="report-link-name">
@@ -368,7 +408,7 @@ pg_stat_statements.track = all</pre>
368
408
  </span>
369
409
  <span class="report-link-desc"><%= report[:description] %></span>
370
410
  </div>
371
- <span class="lock">🔒</span>
411
+ <svg class="icon lock" aria-hidden="true"><use href="#i-lock"></use></svg>
372
412
  </div>
373
413
  <% else %>
374
414
  <%= link_to report_path(category: category_key, report: report_key), class: "report-link" do %>
@@ -379,7 +419,7 @@ pg_stat_statements.track = all</pre>
379
419
  </span>
380
420
  <span class="report-link-desc"><%= report[:description] %></span>
381
421
  </div>
382
- <span class="arrow">→</span>
422
+ <svg class="icon arrow" aria-hidden="true"><use href="#i-chevron-right"></use></svg>
383
423
  <% end %>
384
424
  <% end %>
385
425
  <% end %>
@@ -412,7 +452,7 @@ pg_stat_statements.track = all</pre>
412
452
  text-decoration: none;
413
453
  }
414
454
  .dashboard-footer a:hover {
415
- color: var(--accent-purple);
455
+ color: var(--text-primary);
416
456
  text-decoration: underline;
417
457
  }
418
458
  .dashboard-footer .footer-sep {
@@ -425,104 +465,45 @@ pg_stat_statements.track = all</pre>
425
465
  gap: 0.5rem;
426
466
  }
427
467
 
428
- .btn.btn-small {
429
- padding: 0.5rem 1rem;
430
- font-size: 0.8rem;
431
- }
432
-
433
- /* Keep the header action button the same height as the status badge and
434
- the settings button (all 2.125rem); inline-flex centers the label so no
435
- vertical padding is needed. */
436
- .header-actions .btn {
437
- height: 2.125rem;
438
- padding: 0 1rem;
439
- }
440
-
441
- .btn-info {
442
- width: 2.125rem;
443
- height: 2.125rem;
444
- padding: 0;
445
- display: inline-flex;
446
- align-items: center;
447
- justify-content: center;
448
- background: var(--bg-tertiary);
449
- color: var(--text-muted);
450
- border: 1px solid var(--border-color);
451
- border-radius: 4px;
452
- font-size: 0.9rem;
453
- font-weight: 600;
454
- cursor: pointer;
455
- transition: all 0.15s;
456
- }
457
- .btn-info:hover {
458
- background: var(--accent-purple);
459
- color: white;
460
- border-color: var(--accent-purple);
461
- }
462
-
463
- .btn.btn-danger {
464
- background: var(--accent-rose);
465
- border-color: var(--accent-rose);
466
- }
467
- .btn.btn-danger:hover {
468
- background: #c45a6e;
469
- border-color: #c45a6e;
470
- }
471
-
472
- .btn.btn-secondary {
473
- background: var(--bg-tertiary);
474
- border-color: var(--border-color);
475
- color: var(--text-secondary);
476
- }
477
- .btn.btn-secondary:hover {
478
- background: var(--bg-card);
479
- color: var(--text-primary);
480
- }
481
-
482
- .btn.btn-muted {
483
- background: var(--bg-tertiary);
484
- border-color: var(--accent-rose);
485
- color: var(--text-secondary);
486
- }
487
- .btn.btn-muted:hover {
488
- background: var(--bg-card);
489
- color: var(--text-primary);
490
- }
491
-
492
468
  .badge-dot.error { background: var(--accent-rose); }
493
469
 
494
470
  .category-card.disabled {
495
471
  opacity: 0.7;
496
472
  }
497
473
 
474
+ /* Why a category is unavailable. The icon carries the state; the reason
475
+ itself is body copy, not a wall of amber. */
498
476
  .category-warning {
499
- padding: 0.625rem 1rem;
500
- margin: -1rem -1rem 1rem -1rem;
501
- background: rgba(245, 158, 11, 0.1);
502
- border-bottom: 1px solid rgba(245, 158, 11, 0.2);
503
- border-radius: 6px 6px 0 0;
477
+ display: flex;
478
+ align-items: flex-start;
479
+ gap: 0.45rem;
480
+ padding: 0.5rem 1rem;
481
+ margin: -1rem -1rem 1rem;
482
+ background: var(--bg-tertiary);
483
+ border-bottom: 1px solid var(--border-color);
484
+ border-radius: var(--radius) var(--radius) 0 0;
485
+ color: var(--text-secondary);
486
+ font-size: 0.75rem;
487
+ line-height: 1.5;
488
+ }
489
+
490
+ .category-warning .icon {
491
+ width: 0.8125rem;
492
+ height: 0.8125rem;
493
+ margin-top: 0.25rem;
504
494
  color: var(--accent-amber);
505
- font-size: 0.8rem;
506
- font-weight: 500;
507
- text-align: center;
508
495
  }
509
496
 
510
497
  .report-link.disabled {
511
- display: flex;
512
- align-items: center;
513
- justify-content: space-between;
514
- padding: 0.75rem 1rem;
515
- background: var(--bg-tertiary);
516
- border: 1px solid transparent;
517
- border-radius: 6px;
498
+ background: transparent;
518
499
  color: var(--text-muted);
519
- font-size: 0.9rem;
520
500
  cursor: not-allowed;
521
501
  }
522
502
 
523
503
  .report-link .lock {
524
- font-size: 0.75rem;
525
- opacity: 0.5;
504
+ width: 0.8125rem;
505
+ height: 0.8125rem;
506
+ opacity: 0.6;
526
507
  }
527
508
 
528
509
  .report-link-info {
@@ -550,66 +531,8 @@ pg_stat_statements.track = all</pre>
550
531
  color: var(--text-secondary);
551
532
  }
552
533
 
553
- /* Modal */
554
- .modal {
555
- position: fixed;
556
- inset: 0;
557
- background: rgba(0, 0, 0, 0.7);
558
- display: flex;
559
- align-items: center;
560
- justify-content: center;
561
- z-index: 1000;
562
- backdrop-filter: blur(4px);
563
- }
564
-
565
- .modal-content {
566
- background: var(--bg-card);
567
- border: 1px solid var(--border-color);
568
- border-radius: 6px;
569
- max-width: 600px;
570
- width: 90%;
571
- max-height: 80vh;
572
- overflow: auto;
573
- }
574
-
575
- .modal-header {
576
- display: flex;
577
- align-items: center;
578
- justify-content: space-between;
579
- padding: 1.25rem 1.5rem;
580
- border-bottom: 1px solid var(--border-color);
581
- }
582
-
583
- .modal-header h3 {
584
- font-size: 1.125rem;
585
- font-weight: 600;
586
- }
587
-
588
- .modal-close {
589
- width: 32px;
590
- height: 32px;
591
- background: var(--bg-tertiary);
592
- border: 1px solid var(--border-color);
593
- border-radius: 8px;
594
- color: var(--text-muted);
595
- font-size: 1.25rem;
596
- cursor: pointer;
597
- transition: all 0.15s;
598
- }
599
-
600
- .modal-close:hover {
601
- background: var(--accent-rose);
602
- border-color: var(--accent-rose);
603
- color: white;
604
- }
605
-
606
- .modal-body {
607
- padding: 1.5rem;
608
- }
609
-
610
534
  .modal-body p {
611
535
  color: var(--text-secondary);
612
- margin-bottom: 1rem;
613
536
  }
614
537
 
615
538
  .modal-body ol {
@@ -617,33 +540,14 @@ pg_stat_statements.track = all</pre>
617
540
  color: var(--text-secondary);
618
541
  }
619
542
 
620
- .modal-body li {
621
- margin-bottom: 1.25rem;
622
- }
623
-
624
543
  .modal-body strong {
625
544
  color: var(--text-primary);
626
545
  }
627
546
 
628
- .modal-body pre {
547
+ /* A step's label and its snippet are one unit — tighter than the gap
548
+ between steps. */
549
+ .modal-body li > strong + pre {
629
550
  margin-top: 0.5rem;
630
- padding: 1rem;
631
- background: var(--bg-primary);
632
- border: 1px solid var(--border-color);
633
- border-radius: 8px;
634
- font-family: 'JetBrains Mono', monospace;
635
- font-size: 0.8rem;
636
- color: var(--accent-green);
637
- overflow-x: auto;
638
- }
639
-
640
- .modal-small {
641
- max-width: 420px;
642
- }
643
-
644
- .modal-header-danger {
645
- background: rgba(244, 63, 94, 0.1);
646
- border-bottom-color: rgba(244, 63, 94, 0.2);
647
551
  }
648
552
 
649
553
  .modal-header-danger h3 {
@@ -651,58 +555,55 @@ pg_stat_statements.track = all</pre>
651
555
  }
652
556
 
653
557
  .warning-text {
654
- font-size: 1rem;
558
+ font-size: 0.9375rem;
655
559
  font-weight: 500;
656
- color: var(--text-primary) !important;
657
- margin-bottom: 0.5rem !important;
560
+ color: var(--text-primary);
658
561
  }
659
562
 
660
563
  .warning-subtext {
661
- font-size: 0.875rem;
662
- color: var(--text-muted) !important;
663
- margin-bottom: 1.5rem !important;
564
+ font-size: 0.8125rem;
565
+ color: var(--text-muted);
664
566
  }
665
567
 
666
568
  .modal-actions {
667
569
  display: flex;
668
- gap: 0.75rem;
570
+ gap: 0.5rem;
669
571
  justify-content: flex-end;
670
572
  }
671
573
 
672
- .modal-actions .btn {
673
- padding: 0.625rem 1.25rem;
674
- font-size: 0.875rem;
675
- }
676
-
677
574
  /* Live Monitoring Panel */
678
575
  .live-monitoring-panel {
679
- margin-bottom: 2rem;
576
+ margin-bottom: 1.5rem;
680
577
  background: var(--bg-card);
681
578
  border: 1px solid var(--border-color);
682
- border-radius: 6px;
683
- padding: 1.25rem 1.5rem;
579
+ border-radius: var(--radius);
580
+ padding: 0.875rem 1rem;
684
581
  }
685
582
 
686
583
  .live-monitoring-header {
687
584
  display: flex;
688
585
  align-items: center;
689
586
  justify-content: space-between;
690
- margin-bottom: 1.25rem;
691
- padding-bottom: 1rem;
587
+ gap: 1rem;
588
+ flex-wrap: wrap;
589
+ margin-bottom: 0.875rem;
590
+ padding-bottom: 0.75rem;
692
591
  border-bottom: 1px solid var(--border-color);
693
592
  }
694
593
 
695
594
  .live-monitoring-title {
696
595
  display: flex;
697
596
  align-items: center;
698
- gap: 0.75rem;
597
+ gap: 0.625rem;
699
598
  font-weight: 600;
700
- font-size: 1rem;
599
+ font-size: 0.875rem;
600
+ min-width: 0;
701
601
  }
702
602
 
703
603
  .live-indicator {
704
- width: 8px;
705
- height: 8px;
604
+ width: 7px;
605
+ height: 7px;
606
+ flex: none;
706
607
  border-radius: 50%;
707
608
  background: var(--accent-green);
708
609
  animation: pulse 2s infinite;
@@ -724,29 +625,11 @@ pg_stat_statements.track = all</pre>
724
625
  color: var(--text-muted);
725
626
  }
726
627
 
727
- .btn-toggle-live {
728
- width: 32px;
729
- height: 32px;
730
- padding: 0;
731
- background: var(--bg-tertiary);
732
- border: 1px solid var(--border-color);
733
- border-radius: 8px;
734
- color: var(--text-muted);
735
- font-size: 0.875rem;
736
- cursor: pointer;
737
- transition: all 0.15s;
738
- }
739
-
740
- .btn-toggle-live:hover {
741
- background: var(--bg-secondary);
742
- color: var(--text-primary);
743
- }
744
-
745
628
  /* Metrics Grid */
746
629
  .live-metrics-grid {
747
630
  display: grid;
748
631
  grid-template-columns: repeat(5, 1fr);
749
- gap: 1rem;
632
+ gap: 0.75rem;
750
633
  }
751
634
 
752
635
  @media (max-width: 1200px) {
@@ -767,74 +650,69 @@ pg_stat_statements.track = all</pre>
767
650
  }
768
651
  }
769
652
 
770
- /* Metric Card */
653
+ /* Metric Card — a plain bordered tile. Status is carried by the dot, the
654
+ value colour and the sparkline, never by a coloured edge: "healthy" is
655
+ the common case and should read as quiet, not decorated. */
771
656
  .live-metric-card {
772
657
  background: var(--bg-tertiary);
773
658
  border: 1px solid var(--border-color);
774
- border-radius: 6px;
775
- padding: 1rem;
659
+ border-radius: var(--radius);
660
+ padding: 0.875rem 1rem;
776
661
  position: relative;
777
- transition: all 0.2s;
778
- }
779
-
780
- .live-metric-card:hover {
781
- border-color: var(--accent-purple);
782
- }
783
-
784
- .live-metric-card.status-ok {
785
- border-left: 3px solid var(--accent-green);
662
+ transition: border-color 0.12s ease;
786
663
  }
787
664
 
788
665
  .live-metric-card.status-warning {
789
- border-left: 3px solid var(--accent-amber);
666
+ border-color: var(--amber-line);
790
667
  }
791
668
 
792
669
  .live-metric-card.status-critical {
793
- border-left: 3px solid var(--accent-rose);
670
+ border-color: var(--rose-line);
794
671
  }
795
672
 
796
673
  .metric-header {
797
674
  display: flex;
798
675
  align-items: center;
799
676
  gap: 0.5rem;
800
- margin-bottom: 0.75rem;
677
+ margin-bottom: 0.625rem;
801
678
  }
802
679
 
803
680
  .metric-icon {
804
- width: 28px;
805
- height: 28px;
806
- border-radius: 6px;
807
- display: flex;
808
- align-items: center;
809
- justify-content: center;
810
- font-size: 0.875rem;
681
+ width: 0.9375rem;
682
+ height: 0.9375rem;
683
+ color: var(--text-muted);
811
684
  }
812
685
 
813
686
  .metric-name {
814
- font-size: 0.8rem;
687
+ font-size: 0.75rem;
815
688
  font-weight: 500;
689
+ letter-spacing: 0.02em;
816
690
  color: var(--text-secondary);
817
691
  }
818
692
 
819
693
  .metric-body {
820
694
  display: flex;
821
695
  flex-direction: column;
822
- gap: 0.25rem;
696
+ gap: 0.1875rem;
823
697
  }
824
698
 
825
699
  .metric-value {
826
700
  display: flex;
827
701
  align-items: baseline;
828
- gap: 0.25rem;
702
+ gap: 0.3rem;
829
703
  }
830
704
 
831
705
  .metric-value span:first-child {
832
- font-size: 1.5rem;
833
- font-weight: 700;
706
+ font-size: 1.625rem;
707
+ font-weight: 600;
708
+ letter-spacing: -0.02em;
834
709
  color: var(--text-primary);
835
- line-height: 1;
710
+ line-height: 1.1;
836
711
  }
837
712
 
713
+ .status-warning .metric-value span:first-child { color: var(--accent-amber); }
714
+ .status-critical .metric-value span:first-child { color: var(--accent-rose); }
715
+
838
716
  .metric-unit {
839
717
  font-size: 0.75rem;
840
718
  color: var(--text-muted);
@@ -843,73 +721,274 @@ pg_stat_statements.track = all</pre>
843
721
  .metric-detail {
844
722
  font-size: 0.7rem;
845
723
  color: var(--text-muted);
724
+ white-space: nowrap;
725
+ overflow: hidden;
726
+ text-overflow: ellipsis;
846
727
  }
847
728
 
848
729
  /* Sparkline */
849
730
  .metric-sparkline {
850
- height: 30px;
851
- margin-top: 0.5rem;
731
+ height: 28px;
732
+ margin-top: 0.625rem;
733
+ color: var(--text-muted);
852
734
  }
853
735
 
736
+ .status-warning .metric-sparkline { color: var(--accent-amber); }
737
+ .status-critical .metric-sparkline { color: var(--accent-rose); }
738
+
854
739
  .metric-sparkline svg {
855
740
  width: 100%;
856
741
  height: 100%;
742
+ display: block;
857
743
  }
858
744
 
859
745
  /* Status indicator */
860
746
  .metric-status {
861
747
  position: absolute;
862
- top: 0.5rem;
863
- right: 0.5rem;
748
+ top: 0.75rem;
749
+ right: 0.75rem;
864
750
  width: 6px;
865
751
  height: 6px;
866
752
  border-radius: 50%;
753
+ background: var(--border-strong);
867
754
  }
868
755
 
869
- .metric-status.ok { background: var(--accent-green); }
870
756
  .metric-status.warning { background: var(--accent-amber); }
871
757
  .metric-status.critical { background: var(--accent-rose); }
872
758
 
873
- /* Toast Notifications */
874
- .toast {
875
- position: fixed;
876
- bottom: 2rem;
877
- right: 2rem;
878
- padding: 1rem 1.5rem;
879
- background: var(--bg-card);
759
+ /* Run Query Modal */
760
+ .modal-run-query {
761
+ max-width: 1100px;
762
+ width: 95%;
763
+ }
764
+
765
+ .explain-label {
766
+ display: block;
767
+ font-size: 0.6875rem;
768
+ font-weight: 600;
769
+ line-height: 1.4;
770
+ letter-spacing: 0.06em;
771
+ color: var(--text-muted);
772
+ text-transform: uppercase;
773
+ margin-bottom: 0.4rem;
774
+ }
775
+
776
+ .run-query-textarea {
777
+ width: 100%;
778
+ min-height: 180px;
779
+ padding: 0.875rem;
780
+ background: var(--bg-primary);
880
781
  border: 1px solid var(--border-color);
881
- border-radius: 6px;
782
+ border-radius: var(--radius-sm);
882
783
  color: var(--text-primary);
883
- font-size: 0.875rem;
784
+ font-family: var(--font-mono);
785
+ font-size: 0.8125rem;
786
+ line-height: 1.6;
787
+ resize: vertical;
788
+ }
789
+
790
+ .run-query-textarea:focus-visible {
791
+ outline: 2px solid var(--accent-blue);
792
+ outline-offset: -1px;
793
+ border-color: var(--accent-blue);
794
+ }
795
+
796
+ .run-query-textarea::placeholder {
797
+ color: var(--text-muted);
798
+ }
799
+
800
+ .run-query-actions {
801
+ display: flex;
802
+ gap: 0.5rem;
803
+ }
804
+
805
+ /* Query Results Table (mirrors _show_styles.html.erb — index.html.erb has
806
+ its own <style> block and isn't loaded through that partial) */
807
+ .query-results-wrapper {
808
+ max-height: 400px;
809
+ overflow: auto;
810
+ border: 1px solid var(--border-color);
811
+ border-radius: var(--radius);
812
+ }
813
+
814
+ .query-results-table {
815
+ width: 100%;
816
+ border-collapse: collapse;
817
+ font-family: var(--font-mono);
818
+ font-size: 0.75rem;
819
+ }
820
+
821
+ .query-results-table th {
822
+ position: sticky;
823
+ top: 0;
824
+ padding: 0.75rem;
825
+ text-align: left;
826
+ background: var(--bg-tertiary);
827
+ color: var(--text-muted);
884
828
  font-weight: 500;
885
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
886
- z-index: 9999;
887
- opacity: 0;
888
- transform: translateY(20px);
889
- transition: all 0.3s ease;
829
+ text-transform: uppercase;
830
+ font-size: 0.65rem;
831
+ letter-spacing: 0.05em;
832
+ border-bottom: 1px solid var(--border-color);
833
+ }
834
+
835
+ .query-results-table td {
836
+ padding: 0.5rem 0.75rem;
837
+ border-bottom: 1px solid var(--border-color);
838
+ color: var(--text-secondary);
839
+ max-width: 300px;
840
+ overflow: hidden;
841
+ text-overflow: ellipsis;
842
+ white-space: nowrap;
890
843
  }
891
844
 
892
- .toast.show {
893
- opacity: 1;
894
- transform: translateY(0);
845
+ .query-results-table tr:hover td {
846
+ background: var(--bg-tertiary);
847
+ color: var(--text-primary);
895
848
  }
896
849
 
897
- .toast.toast-success {
898
- border-left: 3px solid var(--accent-green);
850
+ .query-results-info {
851
+ display: flex;
852
+ align-items: center;
853
+ justify-content: space-between;
854
+ padding: 0.75rem 1rem;
855
+ background: var(--bg-tertiary);
856
+ border-radius: var(--radius);
857
+ font-size: 0.8rem;
858
+ color: var(--text-secondary);
899
859
  }
900
860
 
901
- .toast.toast-error {
902
- border-left: 3px solid var(--accent-rose);
903
- background: rgba(244, 63, 94, 0.1);
904
- border-color: var(--accent-rose);
861
+ .query-results-info .count {
862
+ color: var(--accent-blue);
863
+ font-weight: 600;
905
864
  }
906
865
 
907
- .toast.toast-warning {
908
- border-left: 3px solid var(--accent-amber);
866
+ .query-results-info .time {
867
+ color: var(--accent-green);
868
+ font-weight: 600;
909
869
  }
910
870
  </style>
911
871
 
912
872
  <script>
873
+ const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
874
+
875
+ function escapeHtmlAttr(text) {
876
+ return String(text)
877
+ .replace(/&/g, '&amp;')
878
+ .replace(/"/g, '&quot;')
879
+ .replace(/'/g, '&#39;')
880
+ .replace(/</g, '&lt;')
881
+ .replace(/>/g, '&gt;');
882
+ }
883
+
884
+ function showRunQueryModal() {
885
+ document.getElementById('run-query-modal').style.display = 'flex';
886
+ document.getElementById('run-query-input')?.focus();
887
+ }
888
+
889
+ function closeRunQueryModal() {
890
+ document.getElementById('run-query-modal').style.display = 'none';
891
+ }
892
+
893
+ document.getElementById('run-query-modal')?.addEventListener('click', function(e) {
894
+ if (e.target === this) closeRunQueryModal();
895
+ });
896
+
897
+ document.getElementById('run-query-input')?.addEventListener('keydown', function(e) {
898
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
899
+ e.preventDefault();
900
+ executeRunQuery();
901
+ }
902
+ });
903
+
904
+ function clearRunQuery() {
905
+ document.getElementById('run-query-input').value = '';
906
+ document.getElementById('run-query-content').innerHTML = '';
907
+ }
908
+
909
+ async function executeRunQuery() {
910
+ const input = document.getElementById('run-query-input');
911
+ const loading = document.getElementById('run-query-loading');
912
+ const content = document.getElementById('run-query-content');
913
+ const query = input.value.trim();
914
+
915
+ if (!query) {
916
+ showToast(PG_REPORTS_I18N.errors.query_required, 'error');
917
+ return;
918
+ }
919
+
920
+ if (!allowRawQueryExecution) {
921
+ showToast(PG_REPORTS_I18N.errors.execute_disabled_toast, 'error');
922
+ content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
923
+ return;
924
+ }
925
+
926
+ loading.style.display = 'flex';
927
+ content.innerHTML = '';
928
+
929
+ try {
930
+ const response = await fetch(`${pgReportsRoot}/run_query`, {
931
+ method: 'POST',
932
+ headers: {
933
+ 'Content-Type': 'application/json',
934
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
935
+ },
936
+ body: JSON.stringify({ query: query })
937
+ });
938
+
939
+ const data = await response.json();
940
+ loading.style.display = 'none';
941
+
942
+ if (data.success) {
943
+ let html = '';
944
+
945
+ html += `<div class="query-results-info">
946
+ <span>${PG_REPORTS_I18N.results.rows_label} <span class="count">${data.count}</span></span>
947
+ <span>${PG_REPORTS_I18N.results.execution_time_label} <span class="time">${data.execution_time} ms</span></span>
948
+ </div>`;
949
+
950
+ if (data.rows && data.rows.length > 0) {
951
+ html += '<div class="query-results-wrapper">';
952
+ html += '<table class="query-results-table">';
953
+
954
+ html += '<thead><tr>';
955
+ data.columns.forEach(col => {
956
+ html += `<th>${escapeHtml(col)}</th>`;
957
+ });
958
+ html += '</tr></thead>';
959
+
960
+ html += '<tbody>';
961
+ data.rows.forEach(row => {
962
+ html += '<tr>';
963
+ data.columns.forEach(col => {
964
+ const value = row[col];
965
+ const displayValue = value === null ? PG_REPORTS_I18N.results.null_placeholder : String(value);
966
+ html += `<td title="${escapeHtmlAttr(displayValue)}">${escapeHtml(displayValue)}</td>`;
967
+ });
968
+ html += '</tr>';
969
+ });
970
+ html += '</tbody>';
971
+
972
+ html += '</table>';
973
+ html += '</div>';
974
+
975
+ if (data.truncated) {
976
+ html += `<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.75rem;">${pgReportsFormat(PG_REPORTS_I18N.results.showing_first_of_total, { count: data.rows.length, total: data.total_count })}</p>`;
977
+ }
978
+ } else {
979
+ html += '<p style="margin-top: 1rem; color: var(--text-muted);">' + PG_REPORTS_I18N.results.no_rows_returned + '</p>';
980
+ }
981
+
982
+ content.innerHTML = html;
983
+ } else {
984
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || PG_REPORTS_I18N.errors.run_query_failed)}</div>`;
985
+ }
986
+ } catch (error) {
987
+ loading.style.display = 'none';
988
+ content.innerHTML = `<div class="error-message">${PG_REPORTS_I18N.errors.network_error_prefix} ${escapeHtml(error.message)}</div>`;
989
+ }
990
+ }
991
+
913
992
  function showPgStatInfo() {
914
993
  document.getElementById('pg-stat-modal').style.display = 'flex';
915
994
  }
@@ -1136,14 +1215,12 @@ pg_stat_statements.track = all</pre>
1136
1215
  function updateToggleUI() {
1137
1216
  const icon = document.getElementById('toggle-icon');
1138
1217
  const indicator = document.querySelector('.live-indicator');
1218
+ const use = icon && icon.querySelector('use');
1139
1219
 
1140
- if (liveMonitoringEnabled) {
1141
- icon.textContent = '';
1142
- indicator.classList.remove('paused');
1143
- } else {
1144
- icon.textContent = '▶';
1145
- indicator.classList.add('paused');
1220
+ if (use) {
1221
+ use.setAttribute('href', liveMonitoringEnabled ? '#i-pause' : '#i-play');
1146
1222
  }
1223
+ indicator.classList.toggle('paused', !liveMonitoringEnabled);
1147
1224
  }
1148
1225
 
1149
1226
  async function fetchLiveMetrics() {
@@ -1216,18 +1293,18 @@ pg_stat_statements.track = all</pre>
1216
1293
  addToHistory('longrunning', metrics.long_running_count);
1217
1294
  addToHistory('blocked', metrics.blocked_count);
1218
1295
 
1219
- renderSparkline('connections', metricsHistory.connections, 'var(--accent-blue)');
1220
- renderSparkline('tps', metricsHistory.tps, 'var(--accent-green)');
1221
- renderSparkline('cache', metricsHistory.cache, 'var(--accent-purple)');
1222
- renderSparkline('longrunning', metricsHistory.longrunning, 'var(--accent-amber)');
1223
- renderSparkline('blocked', metricsHistory.blocked, 'var(--accent-rose)');
1224
-
1225
1296
  updateStatus('connections', metrics.connections.percent, 'higher');
1226
1297
  updateStatus('tps', tps, null);
1227
1298
  updateStatus('cache', metrics.cache_hit_ratio, 'lower');
1228
1299
  updateStatus('longrunning', metrics.long_running_count, 'higher');
1229
1300
  updateStatus('blocked', metrics.blocked_count, 'higher');
1230
1301
 
1302
+ renderSparkline('connections', metricsHistory.connections);
1303
+ renderSparkline('tps', metricsHistory.tps);
1304
+ renderSparkline('cache', metricsHistory.cache);
1305
+ renderSparkline('longrunning', metricsHistory.longrunning);
1306
+ renderSparkline('blocked', metricsHistory.blocked);
1307
+
1231
1308
  previousMetrics = metrics;
1232
1309
  }
1233
1310
 
@@ -1244,7 +1321,7 @@ pg_stat_statements.track = all</pre>
1244
1321
  return num.toString();
1245
1322
  }
1246
1323
 
1247
- function renderSparkline(metric, data, color) {
1324
+ function renderSparkline(metric, data) {
1248
1325
  const svg = document.getElementById(`sparkline-${metric}`);
1249
1326
  if (!svg || data.length < 2) return;
1250
1327
 
@@ -1272,15 +1349,16 @@ pg_stat_statements.track = all</pre>
1272
1349
 
1273
1350
  const area = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1274
1351
  area.setAttribute('d', areaD);
1275
- area.setAttribute('fill', color);
1276
- area.setAttribute('opacity', '0.15');
1352
+ area.setAttribute('fill', 'currentColor');
1353
+ area.setAttribute('opacity', '0.12');
1277
1354
  svg.appendChild(area);
1278
1355
 
1279
1356
  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1280
1357
  path.setAttribute('d', pathD);
1281
1358
  path.setAttribute('fill', 'none');
1282
- path.setAttribute('stroke', color);
1283
- path.setAttribute('stroke-width', '1.5');
1359
+ path.setAttribute('stroke', 'currentColor');
1360
+ path.setAttribute('stroke-width', '1.25');
1361
+ path.setAttribute('vector-effect', 'non-scaling-stroke');
1284
1362
  path.setAttribute('stroke-linecap', 'round');
1285
1363
  path.setAttribute('stroke-linejoin', 'round');
1286
1364
  svg.appendChild(path);
@@ -1292,24 +1370,22 @@ pg_stat_statements.track = all</pre>
1292
1370
  if (!card || !statusDot) return;
1293
1371
 
1294
1372
  const thresholds = LIVE_CONFIG.thresholds[metric];
1295
- if (!thresholds.warning && !thresholds.critical) {
1296
- card.className = 'live-metric-card status-ok';
1297
- statusDot.className = 'metric-status ok';
1298
- return;
1299
- }
1300
-
1301
1373
  let status = 'ok';
1302
1374
 
1303
- if (badDirection === 'higher') {
1304
- if (value >= thresholds.critical) status = 'critical';
1305
- else if (value >= thresholds.warning) status = 'warning';
1306
- } else if (badDirection === 'lower') {
1307
- if (value <= thresholds.critical) status = 'critical';
1308
- else if (value <= thresholds.warning) status = 'warning';
1375
+ if (thresholds.warning || thresholds.critical) {
1376
+ if (badDirection === 'higher') {
1377
+ if (value >= thresholds.critical) status = 'critical';
1378
+ else if (value >= thresholds.warning) status = 'warning';
1379
+ } else if (badDirection === 'lower') {
1380
+ if (value <= thresholds.critical) status = 'critical';
1381
+ else if (value <= thresholds.warning) status = 'warning';
1382
+ }
1309
1383
  }
1310
1384
 
1311
- card.className = `live-metric-card status-${status}`;
1312
- statusDot.className = `metric-status ${status}`;
1385
+ card.classList.remove('status-ok', 'status-warning', 'status-critical');
1386
+ card.classList.add(`status-${status}`);
1387
+ statusDot.classList.remove('ok', 'warning', 'critical');
1388
+ statusDot.classList.add(status);
1313
1389
  }
1314
1390
 
1315
1391
  document.addEventListener('DOMContentLoaded', initLiveMonitoring);
@@ -1495,6 +1571,8 @@ pg_stat_statements.track = all</pre>
1495
1571
  const response = await fetch(`${pgReportsRoot}/query_monitor/status`);
1496
1572
  const data = await response.json();
1497
1573
 
1574
+ queryHistoryAvailable = data.history_available === true;
1575
+
1498
1576
  if (data.success && data.enabled) {
1499
1577
  queryMonitorEnabled = true;
1500
1578
  currentSessionId = data.session_id;
@@ -1513,6 +1591,17 @@ pg_stat_statements.track = all</pre>
1513
1591
  }
1514
1592
  }
1515
1593
 
1594
+ // Set from /query_monitor/status; null until the first check answers.
1595
+ let queryHistoryAvailable = null;
1596
+
1597
+ function applyHistoryAvailability(button) {
1598
+ const btn = button || document.getElementById('load-history-btn');
1599
+ if (!btn || queryHistoryAvailable === null) return;
1600
+
1601
+ btn.disabled = !queryHistoryAvailable;
1602
+ btn.title = queryHistoryAvailable ? '' : PG_REPORTS_I18N.actions.load_history_unavailable_title;
1603
+ }
1604
+
1516
1605
  async function loadQueryHistory(button) {
1517
1606
  button.disabled = true;
1518
1607
  button.innerHTML = '<span class="spinner"></span> ' + PG_REPORTS_I18N.actions.loading;
@@ -1533,8 +1622,9 @@ pg_stat_statements.track = all</pre>
1533
1622
  console.error('Failed to load query history:', error);
1534
1623
  showToast(PG_REPORTS_I18N.errors.load_history_failed + ' ' + error.message, 'error');
1535
1624
  } finally {
1536
- button.disabled = false;
1537
1625
  button.innerHTML = PG_REPORTS_I18N.actions.load_history;
1626
+ button.disabled = false;
1627
+ applyHistoryAvailability(button);
1538
1628
  }
1539
1629
  }
1540
1630
 
@@ -1635,6 +1725,7 @@ pg_stat_statements.track = all</pre>
1635
1725
  startBtn.innerHTML = PG_REPORTS_I18N.actions.start_monitoring;
1636
1726
  stopBtn.style.display = 'none';
1637
1727
  loadHistoryBtn.style.display = 'inline-block'; // Show when monitoring stopped
1728
+ applyHistoryAvailability(loadHistoryBtn);
1638
1729
  sessionBadge.style.display = 'none';
1639
1730
 
1640
1731
  // Keep results visible after stopping, only hide download if no queries