pg_reports 0.2.2 → 0.3.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.
@@ -44,49 +44,6 @@
44
44
  </div>
45
45
  </div>
46
46
 
47
- <!-- IDE Settings Modal -->
48
- <div id="ide-settings-modal" class="modal" style="display: none;">
49
- <div class="modal-content modal-small">
50
- <div class="modal-header">
51
- <h3>⚙️ IDE Settings</h3>
52
- <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
53
- </div>
54
- <div class="modal-body">
55
- <p class="settings-label">Default IDE for source links:</p>
56
- <div class="ide-options">
57
- <label class="ide-option">
58
- <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
59
- <span>Show menu (default)</span>
60
- </label>
61
- <label class="ide-option">
62
- <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
63
- <span>VS Code (WSL)</span>
64
- </label>
65
- <label class="ide-option">
66
- <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
67
- <span>VS Code</span>
68
- </label>
69
- <label class="ide-option">
70
- <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
71
- <span>RubyMine</span>
72
- </label>
73
- <label class="ide-option">
74
- <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
75
- <span>IntelliJ IDEA</span>
76
- </label>
77
- <label class="ide-option">
78
- <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
79
- <span>Cursor (WSL)</span>
80
- </label>
81
- <label class="ide-option">
82
- <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
83
- <span>Cursor</span>
84
- </label>
85
- </div>
86
- </div>
87
- </div>
88
- </div>
89
-
90
47
  <% if @error %>
91
48
  <div class="error-message">
92
49
  <strong>Error:</strong> <%= @error %>
@@ -98,24 +55,26 @@
98
55
  <details class="documentation-section">
99
56
  <summary class="documentation-toggle">
100
57
  <span class="toggle-icon">▶</span>
101
- <span class="toggle-text">ℹ️ About this report</span>
58
+ <span>📖 What does this report show?</span>
102
59
  </summary>
103
60
  <div class="documentation-content">
104
- <div class="doc-block">
105
- <h4>What is this?</h4>
106
- <p><%= @documentation[:what] %></p>
107
- </div>
61
+ <% if @documentation[:what].present? %>
62
+ <div class="doc-block">
63
+ <h4>📋 What</h4>
64
+ <p><%= @documentation[:what] %></p>
65
+ </div>
66
+ <% end %>
108
67
 
109
- <% if @documentation[:how].present? %>
68
+ <% if @documentation[:why].present? %>
110
69
  <div class="doc-block">
111
- <h4>How it works</h4>
112
- <p><%= @documentation[:how] %></p>
70
+ <h4>❓ Why It Matters</h4>
71
+ <p><%= @documentation[:why] %></p>
113
72
  </div>
114
73
  <% end %>
115
74
 
116
- <% if @documentation[:nuances].present? && @documentation[:nuances].any? %>
75
+ <% if @documentation[:nuances].present? %>
117
76
  <div class="doc-block">
118
- <h4>Important nuances</h4>
77
+ <h4>⚠️ Nuances</h4>
119
78
  <ul class="nuances-list">
120
79
  <% @documentation[:nuances].each do |nuance| %>
121
80
  <li><%= nuance %></li>
@@ -124,17 +83,17 @@
124
83
  </div>
125
84
  <% end %>
126
85
 
127
- <% if @thresholds.present? && @thresholds.any? %>
128
- <div class="doc-block thresholds-block">
129
- <h4>Thresholds</h4>
86
+ <% if @thresholds.present? %>
87
+ <div class="thresholds-block">
88
+ <h4>📊 Thresholds</h4>
130
89
  <div class="thresholds-grid">
131
- <% @thresholds.each do |field, levels| %>
90
+ <% @thresholds.each do |field, values| %>
132
91
  <div class="threshold-item">
133
92
  <span class="threshold-field"><%= field %></span>
134
- <span class="threshold-warning">⚠️ Warning: <%= levels[:warning] %></span>
135
- <span class="threshold-critical">🔴 Critical: <%= levels[:critical] %></span>
136
- <% if levels[:inverted] %>
137
- <span class="threshold-note">(inverted: lower is worse)</span>
93
+ <span class="threshold-warning">⚠️ Warning: <%= values[:warning] %></span>
94
+ <span class="threshold-critical">🔴 Critical: <%= values[:critical] %></span>
95
+ <% if values[:inverted] %>
96
+ <span class="threshold-note">(lower is worse)</span>
138
97
  <% end %>
139
98
  </div>
140
99
  <% end %>
@@ -182,2192 +141,13 @@
182
141
  </div>
183
142
  </div>
184
143
 
185
- <!-- Problem Explanation Modal -->
186
- <div id="problem-modal" class="problem-modal" style="display: none;">
187
- <div class="problem-modal-content">
188
- <div class="problem-modal-header">
189
- <h3>⚠️ Problem Detected</h3>
190
- <button class="modal-close" onclick="closeProblemModal()">&times;</button>
191
- </div>
192
- <div class="problem-modal-body" id="problem-modal-body">
193
- <!-- Content will be filled dynamically -->
194
- </div>
195
- </div>
196
- </div>
197
-
198
- <!-- EXPLAIN ANALYZE Modal -->
199
- <div id="explain-modal" class="modal" style="display: none;">
200
- <div class="modal-content modal-wide">
201
- <div class="modal-header">
202
- <h3>📊 EXPLAIN ANALYZE</h3>
203
- <button class="modal-close" onclick="closeExplainModal()">&times;</button>
204
- </div>
205
- <div class="modal-body" id="explain-modal-body">
206
- <div id="explain-loading" class="loading" style="display: none;">
207
- <div class="spinner"></div>
208
- </div>
209
- <div id="explain-content"></div>
210
- </div>
211
- </div>
212
- </div>
213
-
214
- <!-- Migration Modal -->
215
- <div id="migration-modal" class="modal" style="display: none;">
216
- <div class="modal-content">
217
- <div class="modal-header">
218
- <h3>🗑️ Drop Index Migration</h3>
219
- <button class="modal-close" onclick="closeMigrationModal()">&times;</button>
220
- </div>
221
- <div class="modal-body" id="migration-modal-body">
222
- <p class="settings-label">Generated migration to remove the index:</p>
223
- <div id="migration-code" class="migration-code"></div>
224
- <div class="migration-actions">
225
- <button class="btn btn-secondary" onclick="copyMigrationCode()">📋 Copy Code</button>
226
- <button class="btn btn-primary" onclick="createMigrationFile()">📁 Create File & Open in IDE</button>
227
- </div>
228
- </div>
229
- </div>
230
- </div>
144
+ <%= render 'pg_reports/dashboard/show_modals' %>
231
145
  </div>
232
146
 
233
- <style>
234
- /* Documentation Section */
235
- .documentation-section {
236
- background: var(--bg-card);
237
- border: 1px solid var(--border-color);
238
- border-radius: 12px;
239
- margin-bottom: 0.75rem;
240
- overflow: hidden;
241
- }
242
-
243
- .documentation-toggle {
244
- display: flex;
245
- align-items: center;
246
- gap: 0.75rem;
247
- padding: 1rem 1.25rem;
248
- cursor: pointer;
249
- user-select: none;
250
- color: var(--text-secondary);
251
- font-weight: 500;
252
- transition: all 0.15s;
253
- }
254
-
255
- .documentation-toggle:hover {
256
- background: var(--bg-tertiary);
257
- color: var(--text-primary);
258
- }
259
-
260
- .documentation-section[open] .documentation-toggle {
261
- border-bottom: 1px solid var(--border-color);
262
- background: var(--bg-tertiary);
263
- }
264
-
265
- .toggle-icon {
266
- font-size: 0.75rem;
267
- transition: transform 0.2s;
268
- }
269
-
270
- .documentation-section[open] .toggle-icon {
271
- transform: rotate(90deg);
272
- }
273
-
274
- .documentation-content {
275
- padding: 1.25rem;
276
- display: grid;
277
- gap: 1.25rem;
278
- }
279
-
280
- .doc-block h4 {
281
- font-size: 0.875rem;
282
- font-weight: 600;
283
- color: var(--accent-purple);
284
- margin-bottom: 0.5rem;
285
- text-transform: uppercase;
286
- letter-spacing: 0.03em;
287
- }
288
-
289
- .doc-block p {
290
- color: var(--text-secondary);
291
- line-height: 1.7;
292
- font-size: 0.9rem;
293
- }
294
-
295
- .nuances-list {
296
- list-style: none;
297
- padding: 0;
298
- margin: 0;
299
- display: flex;
300
- flex-direction: column;
301
- gap: 0.5rem;
302
- }
303
-
304
- .nuances-list li {
305
- position: relative;
306
- padding-left: 1.5rem;
307
- color: var(--text-secondary);
308
- font-size: 0.875rem;
309
- line-height: 1.6;
310
- }
311
-
312
- .nuances-list li::before {
313
- content: '→';
314
- position: absolute;
315
- left: 0;
316
- color: var(--accent-amber);
317
- }
318
-
319
- .thresholds-block {
320
- background: var(--bg-tertiary);
321
- padding: 1rem;
322
- border-radius: 8px;
323
- }
324
-
325
- .thresholds-grid {
326
- display: flex;
327
- flex-direction: column;
328
- gap: 0.5rem;
329
- }
330
-
331
- .threshold-item {
332
- display: flex;
333
- align-items: center;
334
- gap: 1rem;
335
- flex-wrap: wrap;
336
- font-size: 0.8rem;
337
- font-family: 'JetBrains Mono', monospace;
338
- }
339
-
340
- .threshold-field {
341
- color: var(--text-primary);
342
- font-weight: 500;
343
- min-width: 120px;
344
- }
345
-
346
- .threshold-warning {
347
- color: var(--accent-amber);
348
- }
349
-
350
- .threshold-critical {
351
- color: var(--accent-rose);
352
- }
353
-
354
- .threshold-note {
355
- color: var(--text-muted);
356
- font-size: 0.75rem;
357
- font-style: italic;
358
- }
359
-
360
- /* Dropdown */
361
- .dropdown {
362
- position: relative;
363
- display: inline-block;
364
- }
365
-
366
- .dropdown-menu {
367
- display: none;
368
- position: absolute;
369
- top: 100%;
370
- left: 0;
371
- margin-top: 4px;
372
- background: var(--bg-card);
373
- border: 1px solid var(--border-color);
374
- border-radius: 10px;
375
- min-width: 160px;
376
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
377
- z-index: 100;
378
- overflow: hidden;
379
- }
380
-
381
- .dropdown-menu.show {
382
- display: block;
383
- }
384
-
385
- .dropdown-menu a {
386
- display: block;
387
- padding: 0.75rem 1rem;
388
- color: var(--text-secondary);
389
- text-decoration: none;
390
- font-size: 0.875rem;
391
- transition: all 0.15s;
392
- }
393
-
394
- .dropdown-menu a:hover {
395
- background: var(--bg-tertiary);
396
- color: var(--text-primary);
397
- }
398
-
399
- .dropdown-menu a:not(:last-child) {
400
- border-bottom: 1px solid var(--border-color);
401
- }
402
-
403
- /* Clickable rows */
404
- .results-table tbody tr.data-row {
405
- cursor: pointer;
406
- transition: all 0.15s;
407
- }
408
-
409
- .results-table tbody tr.data-row:hover {
410
- background: var(--bg-secondary);
411
- }
412
-
413
- .results-table tbody tr.data-row.expanded {
414
- background: var(--bg-tertiary);
415
- }
416
-
417
- .results-table tbody tr.data-row td:first-child::before {
418
- content: '▸';
419
- display: inline-block;
420
- margin-right: 0.5rem;
421
- color: var(--text-muted);
422
- transition: transform 0.2s;
423
- }
424
-
425
- .results-table tbody tr.data-row.expanded td:first-child::before {
426
- content: '▾';
427
- color: var(--accent-purple);
428
- }
429
-
430
- /* Problem row highlighting */
431
- .results-table tbody tr.data-row.warning-row {
432
- background: rgba(245, 158, 11, 0.08);
433
- border-left: 3px solid var(--accent-amber);
434
- }
435
-
436
- .results-table tbody tr.data-row.warning-row:hover {
437
- background: rgba(245, 158, 11, 0.12);
438
- }
439
-
440
- .results-table tbody tr.data-row.critical-row {
441
- background: rgba(244, 63, 94, 0.08);
442
- border-left: 3px solid var(--accent-rose);
443
- }
444
-
445
- .results-table tbody tr.data-row.critical-row:hover {
446
- background: rgba(244, 63, 94, 0.12);
447
- }
448
-
449
- /* Problem indicator in row */
450
- .problem-indicator {
451
- display: inline-flex;
452
- align-items: center;
453
- gap: 0.25rem;
454
- margin-left: 0.5rem;
455
- padding: 0.125rem 0.375rem;
456
- border-radius: 4px;
457
- font-size: 0.65rem;
458
- font-weight: 600;
459
- cursor: pointer;
460
- transition: all 0.15s;
461
- }
462
-
463
- .problem-indicator.warning {
464
- background: rgba(245, 158, 11, 0.2);
465
- color: var(--accent-amber);
466
- }
467
-
468
- .problem-indicator.warning:hover {
469
- background: rgba(245, 158, 11, 0.35);
470
- }
471
-
472
- .problem-indicator.critical {
473
- background: rgba(244, 63, 94, 0.2);
474
- color: var(--accent-rose);
475
- }
476
-
477
- .problem-indicator.critical:hover {
478
- background: rgba(244, 63, 94, 0.35);
479
- }
480
-
481
- /* Problem Modal */
482
- .problem-modal {
483
- position: fixed;
484
- inset: 0;
485
- background: rgba(0, 0, 0, 0.7);
486
- display: flex;
487
- align-items: center;
488
- justify-content: center;
489
- z-index: 1000;
490
- backdrop-filter: blur(4px);
491
- }
492
-
493
- .problem-modal-content {
494
- background: var(--bg-card);
495
- border: 1px solid var(--border-color);
496
- border-radius: 16px;
497
- max-width: 600px;
498
- width: 90%;
499
- max-height: 80vh;
500
- overflow: auto;
501
- }
502
-
503
- .problem-modal-header {
504
- display: flex;
505
- align-items: center;
506
- justify-content: space-between;
507
- padding: 1.25rem 1.5rem;
508
- border-bottom: 1px solid var(--border-color);
509
- background: rgba(244, 63, 94, 0.1);
510
- }
511
-
512
- .problem-modal-header h3 {
513
- font-size: 1.125rem;
514
- font-weight: 600;
515
- color: var(--accent-rose);
516
- }
517
-
518
- .problem-modal-body {
519
- padding: 1.5rem;
520
- }
521
-
522
- .problem-details {
523
- display: flex;
524
- flex-direction: column;
525
- gap: 1rem;
526
- }
527
-
528
- .problem-field {
529
- display: flex;
530
- flex-direction: column;
531
- gap: 0.375rem;
532
- }
533
-
534
- .problem-field-label {
535
- font-size: 0.75rem;
536
- font-weight: 600;
537
- color: var(--text-muted);
538
- text-transform: uppercase;
539
- }
540
-
541
- .problem-field-value {
542
- font-family: 'JetBrains Mono', monospace;
543
- font-size: 0.9rem;
544
- padding: 0.75rem;
545
- background: var(--bg-tertiary);
546
- border-radius: 8px;
547
- }
548
-
549
- .problem-field-value.critical {
550
- color: var(--accent-rose);
551
- border-left: 3px solid var(--accent-rose);
552
- }
553
-
554
- .problem-field-value.warning {
555
- color: var(--accent-amber);
556
- border-left: 3px solid var(--accent-amber);
557
- }
558
-
559
- .problem-explanation {
560
- padding: 1rem;
561
- background: var(--bg-tertiary);
562
- border-radius: 8px;
563
- color: var(--text-secondary);
564
- line-height: 1.6;
565
- }
566
-
567
- .problem-explanation h4 {
568
- color: var(--text-primary);
569
- margin-bottom: 0.5rem;
570
- font-size: 0.875rem;
571
- }
572
-
573
- /* Expanded row detail */
574
- .results-table tbody tr.detail-row {
575
- display: none;
576
- }
577
-
578
- .results-table tbody tr.detail-row.show {
579
- display: table-row;
580
- }
581
-
582
- .results-table tbody tr.detail-row td {
583
- padding: 0;
584
- background: var(--bg-primary);
585
- border-bottom: 2px solid var(--accent-purple);
586
- }
587
-
588
- .row-detail {
589
- padding: 1.25rem;
590
- display: grid;
591
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
592
- gap: 1rem;
593
- }
594
-
595
- .row-detail-item {
596
- display: flex;
597
- flex-direction: column;
598
- gap: 0.375rem;
599
- }
600
-
601
- .row-detail-item.full-width {
602
- grid-column: 1 / -1;
603
- }
604
-
605
- .row-detail-label {
606
- font-size: 0.7rem;
607
- font-weight: 600;
608
- color: var(--text-muted);
609
- text-transform: uppercase;
610
- letter-spacing: 0.05em;
611
- }
612
-
613
- .row-detail-value {
614
- padding: 0.75rem 1rem;
615
- background: var(--bg-card);
616
- border: 1px solid var(--border-color);
617
- border-radius: 8px;
618
- font-family: 'JetBrains Mono', monospace;
619
- font-size: 0.8rem;
620
- color: var(--text-primary);
621
- white-space: pre-wrap;
622
- word-break: break-word;
623
- max-height: 200px;
624
- overflow-y: auto;
625
- }
626
-
627
- .row-detail-value.query {
628
- color: var(--accent-green);
629
- max-height: 300px;
630
- }
631
-
632
- .row-detail-value.number {
633
- color: var(--accent-blue);
634
- }
635
-
636
- .row-detail-value.source {
637
- color: var(--accent-amber);
638
- font-size: 0.75rem;
639
- }
640
-
641
- .row-detail-value.problem-warning {
642
- border-color: var(--accent-amber);
643
- background: rgba(245, 158, 11, 0.1);
644
- }
645
-
646
- .row-detail-value.problem-critical {
647
- border-color: var(--accent-rose);
648
- background: rgba(244, 63, 94, 0.1);
649
- }
650
-
651
- /* Source location badge in table */
652
- .source-badge {
653
- display: inline-block;
654
- padding: 0.25rem 0.5rem;
655
- background: rgba(245, 158, 11, 0.15);
656
- border: 1px solid rgba(245, 158, 11, 0.3);
657
- border-radius: 6px;
658
- font-family: 'JetBrains Mono', monospace;
659
- font-size: 0.7rem;
660
- color: var(--accent-amber);
661
- white-space: nowrap;
662
- max-width: 200px;
663
- overflow: hidden;
664
- text-overflow: ellipsis;
665
- text-decoration: none;
666
- transition: all 0.15s;
667
- }
668
-
669
- .source-badge:hover {
670
- background: rgba(245, 158, 11, 0.25);
671
- border-color: rgba(245, 158, 11, 0.5);
672
- }
673
-
674
- .source-badge.clickable {
675
- cursor: pointer;
676
- }
677
-
678
- .source-badge.clickable::after {
679
- content: ' ↗';
680
- opacity: 0.7;
681
- }
682
-
683
- .source-badge.empty {
684
- background: var(--bg-tertiary);
685
- border-color: var(--border-color);
686
- color: var(--text-muted);
687
- font-style: italic;
688
- }
689
-
690
- /* Copy button */
691
- .copy-btn {
692
- align-self: flex-start;
693
- margin-top: 0.375rem;
694
- padding: 0.25rem 0.625rem;
695
- background: var(--bg-tertiary);
696
- border: 1px solid var(--border-color);
697
- border-radius: 6px;
698
- color: var(--text-muted);
699
- font-size: 0.7rem;
700
- cursor: pointer;
701
- transition: all 0.15s;
702
- }
703
-
704
- .copy-btn:hover {
705
- background: var(--accent-purple);
706
- border-color: var(--accent-purple);
707
- color: white;
708
- }
709
-
710
- /* Row hint */
711
- .row-hint {
712
- display: inline-block;
713
- margin-left: 0.5rem;
714
- padding: 0.125rem 0.5rem;
715
- background: var(--bg-tertiary);
716
- border-radius: 4px;
717
- font-size: 0.7rem;
718
- color: var(--text-muted);
719
- }
720
-
721
- /* IDE Link dropdown */
722
- .ide-dropdown {
723
- display: inline-block;
724
- }
725
-
726
- .ide-dropdown-menu {
727
- display: none;
728
- position: fixed;
729
- background: var(--bg-card);
730
- border: 1px solid var(--border-color);
731
- border-radius: 8px;
732
- min-width: 140px;
733
- box-shadow: 0 5px 20px rgba(0, 0, 0, 0.4);
734
- z-index: 10000;
735
- }
736
-
737
- .ide-dropdown-menu.show {
738
- display: block;
739
- }
740
-
741
- .ide-dropdown-menu a {
742
- display: block;
743
- padding: 0.5rem 0.75rem;
744
- color: var(--text-secondary);
745
- text-decoration: none;
746
- font-size: 0.75rem;
747
- transition: all 0.15s;
748
- }
749
-
750
- .ide-dropdown-menu a:hover {
751
- background: var(--bg-tertiary);
752
- color: var(--text-primary);
753
- }
754
-
755
- /* Top scrollbar */
756
- .top-scroll-wrapper {
757
- overflow-x: auto;
758
- overflow-y: hidden;
759
- height: 12px;
760
- background: var(--bg-tertiary);
761
- border-bottom: 1px solid var(--border-color);
762
- }
763
-
764
- .top-scroll-content {
765
- height: 1px;
766
- }
767
-
768
- /* Sortable column headers */
769
- .results-table th.sortable {
770
- cursor: pointer;
771
- user-select: none;
772
- transition: background 0.15s;
773
- }
774
-
775
- .results-table th.sortable:hover {
776
- background: var(--bg-secondary);
777
- }
778
-
779
- .results-table th .sort-indicator {
780
- display: inline-block;
781
- margin-left: 0.375rem;
782
- opacity: 0.3;
783
- font-size: 0.65rem;
784
- transition: opacity 0.15s;
785
- }
786
-
787
- .results-table th.sortable:hover .sort-indicator {
788
- opacity: 0.6;
789
- }
790
-
791
- .results-table th.sorted .sort-indicator {
792
- opacity: 1;
793
- color: var(--accent-purple);
794
- }
795
-
796
- .results-table th.sorted {
797
- background: var(--bg-secondary);
798
- }
799
-
800
- /* IDE Settings */
801
- .btn-icon {
802
- width: 40px;
803
- padding: 0;
804
- background: var(--bg-tertiary);
805
- border: 1px solid var(--border-color);
806
- }
807
-
808
- .btn-icon:hover {
809
- background: var(--bg-secondary);
810
- }
811
-
812
- .settings-label {
813
- color: var(--text-secondary);
814
- margin-bottom: 1rem;
815
- font-size: 0.9rem;
816
- }
817
-
818
- .ide-options {
819
- display: flex;
820
- flex-direction: column;
821
- gap: 0.5rem;
822
- }
823
-
824
- .ide-option {
825
- display: flex;
826
- align-items: center;
827
- gap: 0.75rem;
828
- padding: 0.75rem 1rem;
829
- background: var(--bg-tertiary);
830
- border: 1px solid var(--border-color);
831
- border-radius: 8px;
832
- cursor: pointer;
833
- transition: all 0.15s;
834
- }
835
-
836
- .ide-option:hover {
837
- background: var(--bg-secondary);
838
- border-color: var(--accent-purple);
839
- }
840
-
841
- .ide-option:has(input:checked) {
842
- background: rgba(157, 140, 214, 0.15);
843
- border-color: var(--accent-purple);
844
- }
845
-
846
- .ide-option input[type="radio"] {
847
- accent-color: var(--accent-purple);
848
- }
849
-
850
- .ide-option span {
851
- color: var(--text-secondary);
852
- font-size: 0.875rem;
853
- }
854
-
855
- .ide-option:has(input:checked) span {
856
- color: var(--text-primary);
857
- }
858
-
859
- .modal-small {
860
- max-width: 360px;
861
- }
862
-
863
- /* Button variants */
864
- .btn.btn-small {
865
- padding: 0.5rem 1rem;
866
- height: auto;
867
- font-size: 0.8rem;
868
- }
869
-
870
- .btn.btn-muted {
871
- background: var(--bg-tertiary);
872
- border-color: var(--accent-rose);
873
- color: var(--text-secondary);
874
- }
875
-
876
- .btn.btn-muted:hover {
877
- background: var(--bg-card);
878
- color: var(--text-primary);
879
- }
880
-
881
- /* Saved Records Section */
882
- .saved-records-section {
883
- background: var(--bg-card);
884
- border: 1px solid var(--accent-purple);
885
- border-radius: 12px;
886
- margin-bottom: 1rem;
887
- overflow: hidden;
888
- }
889
-
890
- .saved-records-header {
891
- display: flex;
892
- align-items: center;
893
- justify-content: space-between;
894
- padding: 0.75rem 1rem;
895
- background: rgba(157, 140, 214, 0.1);
896
- border-bottom: 1px solid var(--border-color);
897
- }
898
-
899
- .saved-records-title {
900
- font-weight: 600;
901
- color: var(--accent-purple);
902
- font-size: 0.9rem;
903
- }
904
-
905
- .saved-records-list {
906
- padding: 0.75rem;
907
- display: flex;
908
- flex-direction: column;
909
- gap: 0.5rem;
910
- max-height: 400px;
911
- overflow-y: auto;
912
- }
913
-
914
- .saved-record-card {
915
- background: var(--bg-tertiary);
916
- border: 1px solid var(--border-color);
917
- border-radius: 8px;
918
- padding: 0.75rem;
919
- position: relative;
920
- cursor: pointer;
921
- transition: all 0.15s;
922
- }
923
-
924
- .saved-record-card:hover {
925
- border-color: var(--accent-purple);
926
- background: var(--bg-secondary);
927
- }
928
-
929
- .saved-record-card.expanded {
930
- background: var(--bg-card);
931
- border-color: var(--accent-purple);
932
- }
933
-
934
- .saved-record-header {
935
- display: flex;
936
- align-items: center;
937
- justify-content: space-between;
938
- margin-bottom: 0.5rem;
939
- }
940
-
941
- .saved-record-time {
942
- font-size: 0.7rem;
943
- color: var(--text-muted);
944
- font-family: 'JetBrains Mono', monospace;
945
- }
946
-
947
- .saved-record-remove {
948
- width: 24px;
949
- height: 24px;
950
- padding: 0;
951
- background: transparent;
952
- border: 1px solid transparent;
953
- border-radius: 4px;
954
- color: var(--text-muted);
955
- font-size: 1rem;
956
- cursor: pointer;
957
- transition: all 0.15s;
958
- }
959
-
960
- .saved-record-remove:hover {
961
- background: var(--accent-rose);
962
- border-color: var(--accent-rose);
963
- color: white;
964
- }
965
-
966
- .saved-record-data {
967
- display: grid;
968
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
969
- gap: 0.5rem;
970
- font-size: 0.75rem;
971
- }
972
-
973
- .saved-record-field {
974
- display: flex;
975
- flex-direction: column;
976
- gap: 0.125rem;
977
- }
978
-
979
- .saved-record-field-name {
980
- color: var(--text-muted);
981
- font-size: 0.65rem;
982
- text-transform: uppercase;
983
- }
984
-
985
- .saved-record-field-value {
986
- color: var(--text-primary);
987
- font-family: 'JetBrains Mono', monospace;
988
- white-space: nowrap;
989
- overflow: hidden;
990
- text-overflow: ellipsis;
991
- }
992
-
993
- .saved-record-field-value.highlight {
994
- color: var(--accent-green);
995
- font-weight: 600;
996
- }
997
-
998
- .saved-record-query {
999
- grid-column: 1 / -1;
1000
- margin-top: 0.5rem;
1001
- padding: 0.75rem;
1002
- background: var(--bg-primary);
1003
- border: 1px solid var(--border-color);
1004
- border-radius: 6px;
1005
- font-family: 'JetBrains Mono', monospace;
1006
- font-size: 0.75rem;
1007
- color: var(--accent-green);
1008
- white-space: pre-wrap;
1009
- word-break: break-word;
1010
- max-height: 150px;
1011
- overflow-y: auto;
1012
- }
1013
-
1014
- .saved-record-expand-hint {
1015
- font-size: 0.65rem;
1016
- color: var(--text-muted);
1017
- margin-left: auto;
1018
- }
1019
-
1020
- .saved-record-card.expanded .saved-record-expand-hint {
1021
- display: none;
1022
- }
1023
-
1024
- .saved-record-detail {
1025
- display: none;
1026
- grid-column: 1 / -1;
1027
- margin-top: 0.75rem;
1028
- padding-top: 0.75rem;
1029
- border-top: 1px solid var(--border-color);
1030
- }
1031
-
1032
- .saved-record-card.expanded .saved-record-detail {
1033
- display: block;
1034
- }
1035
-
1036
- .saved-record-detail-grid {
1037
- display: grid;
1038
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1039
- gap: 0.75rem;
1040
- }
1041
-
1042
- .saved-record-detail-item {
1043
- display: flex;
1044
- flex-direction: column;
1045
- gap: 0.25rem;
1046
- }
1047
-
1048
- .saved-record-detail-item.full-width {
1049
- grid-column: 1 / -1;
1050
- }
1051
-
1052
- .saved-record-detail-label {
1053
- font-size: 0.65rem;
1054
- color: var(--text-muted);
1055
- text-transform: uppercase;
1056
- }
1057
-
1058
- .saved-record-detail-value {
1059
- padding: 0.5rem;
1060
- background: var(--bg-primary);
1061
- border: 1px solid var(--border-color);
1062
- border-radius: 6px;
1063
- font-family: 'JetBrains Mono', monospace;
1064
- font-size: 0.75rem;
1065
- color: var(--text-primary);
1066
- }
1067
-
1068
- .saved-record-detail-value.query {
1069
- color: var(--accent-green);
1070
- white-space: pre-wrap;
1071
- word-break: break-word;
1072
- max-height: 200px;
1073
- overflow-y: auto;
1074
- }
1075
-
1076
- .saved-record-detail-value.number {
1077
- color: var(--accent-blue);
1078
- }
1079
-
1080
- /* Save button in detail row */
1081
- .detail-actions {
1082
- grid-column: 1 / -1;
1083
- display: flex;
1084
- gap: 0.5rem;
1085
- padding-top: 0.75rem;
1086
- border-top: 1px solid var(--border-color);
1087
- margin-top: 0.5rem;
1088
- }
1089
-
1090
- .btn-save {
1091
- padding: 0.375rem 0.75rem;
1092
- background: var(--bg-tertiary);
1093
- border: 1px solid var(--accent-purple);
1094
- border-radius: 6px;
1095
- color: var(--accent-purple);
1096
- font-size: 0.75rem;
1097
- cursor: pointer;
1098
- transition: all 0.15s;
1099
- }
1100
-
1101
- .btn-save:hover {
1102
- background: var(--accent-purple);
1103
- color: white;
1104
- }
1105
-
1106
- .btn-save.saved {
1107
- background: var(--accent-purple);
1108
- color: white;
1109
- }
1110
-
1111
- .btn-save.saved:hover {
1112
- background: var(--accent-rose);
1113
- border-color: var(--accent-rose);
1114
- }
1115
-
1116
- .btn-explain {
1117
- padding: 0.375rem 0.75rem;
1118
- background: var(--bg-tertiary);
1119
- border: 1px solid var(--accent-blue);
1120
- border-radius: 6px;
1121
- color: var(--accent-blue);
1122
- font-size: 0.75rem;
1123
- cursor: pointer;
1124
- transition: all 0.15s;
1125
- }
1126
-
1127
- .btn-explain:hover {
1128
- background: var(--accent-blue);
1129
- color: white;
1130
- }
1131
-
1132
- .btn-migration {
1133
- padding: 0.375rem 0.75rem;
1134
- background: var(--bg-tertiary);
1135
- border: 1px solid var(--accent-amber);
1136
- border-radius: 6px;
1137
- color: var(--accent-amber);
1138
- font-size: 0.75rem;
1139
- cursor: pointer;
1140
- transition: all 0.15s;
1141
- }
1142
-
1143
- .btn-migration:hover {
1144
- background: var(--accent-amber);
1145
- color: var(--bg-primary);
1146
- }
1147
-
1148
- /* EXPLAIN Modal */
1149
- .modal-wide {
1150
- max-width: 900px;
1151
- }
1152
-
1153
- .explain-result {
1154
- font-family: 'JetBrains Mono', monospace;
1155
- font-size: 0.8rem;
1156
- background: var(--bg-primary);
1157
- border: 1px solid var(--border-color);
1158
- border-radius: 8px;
1159
- padding: 1rem;
1160
- white-space: pre-wrap;
1161
- overflow-x: auto;
1162
- max-height: 500px;
1163
- overflow-y: auto;
1164
- color: var(--accent-green);
1165
- line-height: 1.5;
1166
- }
1167
-
1168
- .explain-stats {
1169
- display: grid;
1170
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1171
- gap: 0.75rem;
1172
- margin-bottom: 1rem;
1173
- padding: 1rem;
1174
- background: var(--bg-tertiary);
1175
- border-radius: 8px;
1176
- }
1177
-
1178
- .explain-stat {
1179
- display: flex;
1180
- flex-direction: column;
1181
- gap: 0.25rem;
1182
- }
1183
-
1184
- .explain-stat-label {
1185
- font-size: 0.7rem;
1186
- color: var(--text-muted);
1187
- text-transform: uppercase;
1188
- }
1189
-
1190
- .explain-stat-value {
1191
- font-family: 'JetBrains Mono', monospace;
1192
- font-size: 0.9rem;
1193
- color: var(--accent-blue);
1194
- font-weight: 600;
1195
- }
1196
-
1197
- /* Migration Modal */
1198
- .migration-code {
1199
- font-family: 'JetBrains Mono', monospace;
1200
- font-size: 0.8rem;
1201
- background: var(--bg-primary);
1202
- border: 1px solid var(--border-color);
1203
- border-radius: 8px;
1204
- padding: 1rem;
1205
- white-space: pre;
1206
- overflow-x: auto;
1207
- color: var(--accent-green);
1208
- line-height: 1.5;
1209
- margin-bottom: 1rem;
1210
- }
1211
-
1212
- .migration-actions {
1213
- display: flex;
1214
- gap: 0.75rem;
1215
- justify-content: flex-end;
1216
- }
1217
- </style>
147
+ <%= render 'pg_reports/dashboard/show_styles' %>
1218
148
 
1219
149
  <% if PgReports.config.fake_source_data %>
1220
150
  <%= render 'pg_reports/dashboard/fake_source_data' %>
1221
151
  <% end %>
1222
152
 
1223
- <script>
1224
- let currentReportData = null;
1225
- const category = '<%= @category %>';
1226
- const reportKey = '<%= @report_key %>';
1227
- let syncingScroll = false;
1228
- let currentSort = { column: null, direction: 'asc' };
1229
-
1230
- // Top scrollbar sync functionality
1231
- function setupTopScrollbar() {
1232
- const topScrollWrapper = document.getElementById('top-scroll-wrapper');
1233
- const topScrollContent = document.getElementById('top-scroll-content');
1234
- const tableWrapper = document.getElementById('results-table-wrapper');
1235
- const table = document.getElementById('results-table');
1236
-
1237
- if (!topScrollWrapper || !topScrollContent || !tableWrapper || !table) return;
1238
-
1239
- // Check if table overflows
1240
- if (table.scrollWidth > tableWrapper.clientWidth) {
1241
- topScrollWrapper.style.display = 'block';
1242
- topScrollContent.style.width = table.scrollWidth + 'px';
1243
-
1244
- // Sync scrolls
1245
- topScrollWrapper.addEventListener('scroll', function() {
1246
- if (syncingScroll) return;
1247
- syncingScroll = true;
1248
- tableWrapper.scrollLeft = topScrollWrapper.scrollLeft;
1249
- syncingScroll = false;
1250
- });
1251
-
1252
- tableWrapper.addEventListener('scroll', function() {
1253
- if (syncingScroll) return;
1254
- syncingScroll = true;
1255
- topScrollWrapper.scrollLeft = tableWrapper.scrollLeft;
1256
- syncingScroll = false;
1257
- });
1258
- } else {
1259
- topScrollWrapper.style.display = 'none';
1260
- }
1261
- }
1262
-
1263
- // Sort data by column
1264
- function sortData(data, column, direction) {
1265
- return [...data].sort((a, b) => {
1266
- let aVal = a[column];
1267
- let bVal = b[column];
1268
-
1269
- // Handle null/undefined
1270
- if (aVal == null) aVal = '';
1271
- if (bVal == null) bVal = '';
1272
-
1273
- // Try numeric comparison
1274
- const aNum = parseFloat(aVal);
1275
- const bNum = parseFloat(bVal);
1276
- if (!isNaN(aNum) && !isNaN(bNum)) {
1277
- return direction === 'asc' ? aNum - bNum : bNum - aNum;
1278
- }
1279
-
1280
- // String comparison
1281
- const aStr = String(aVal).toLowerCase();
1282
- const bStr = String(bVal).toLowerCase();
1283
- if (direction === 'asc') {
1284
- return aStr.localeCompare(bStr);
1285
- } else {
1286
- return bStr.localeCompare(aStr);
1287
- }
1288
- });
1289
- }
1290
-
1291
- // Handle column header click for sorting
1292
- function handleSortClick(column) {
1293
- if (!currentReportData || !currentReportData.data) return;
1294
-
1295
- // Toggle direction if same column, otherwise start with asc
1296
- if (currentSort.column === column) {
1297
- currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
1298
- } else {
1299
- currentSort.column = column;
1300
- currentSort.direction = 'asc';
1301
- }
1302
-
1303
- // Sort and re-render
1304
- const sortedData = sortData(currentReportData.data, column, currentSort.direction);
1305
- renderTableBody(sortedData, currentReportData.columns, currentReportData.thresholds, currentReportData.problem_fields);
1306
- updateSortIndicators();
1307
- }
1308
-
1309
- // Update sort indicators in headers
1310
- function updateSortIndicators() {
1311
- document.querySelectorAll('.results-table th').forEach(th => {
1312
- th.classList.remove('sorted');
1313
- const indicator = th.querySelector('.sort-indicator');
1314
- if (indicator) {
1315
- indicator.textContent = '↕';
1316
- }
1317
- });
1318
-
1319
- if (currentSort.column) {
1320
- const sortedTh = document.querySelector(`.results-table th[data-column="${currentSort.column}"]`);
1321
- if (sortedTh) {
1322
- sortedTh.classList.add('sorted');
1323
- const indicator = sortedTh.querySelector('.sort-indicator');
1324
- if (indicator) {
1325
- indicator.textContent = currentSort.direction === 'asc' ? '↑' : '↓';
1326
- }
1327
- }
1328
- }
1329
- }
1330
-
1331
- // Render table body (extracted for reuse after sorting)
1332
- function renderTableBody(data, columns, thresholds, problemFields) {
1333
- const tableBody = document.getElementById('results-body');
1334
- if (!tableBody) return;
1335
-
1336
- let rowsHtml = '';
1337
-
1338
- data.forEach((row, idx) => {
1339
- // Check for problems
1340
- const problemInfo = getRowProblemLevel(row, thresholds, problemFields);
1341
- let rowClass = 'data-row';
1342
- let problemIndicator = '';
1343
-
1344
- if (problemInfo && problemInfo.level) {
1345
- rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
1346
- const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
1347
- const indicatorIcon = problemInfo.level === 'critical' ? '🔴' : '⚠️';
1348
- problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showProblemModal(${JSON.stringify(problemInfo.problems).replace(/"/g, '&quot;')}, ${JSON.stringify(row).replace(/"/g, '&quot;')})">${indicatorIcon}</span>`;
1349
- }
1350
-
1351
- // Data row
1352
- rowsHtml += `<tr id="data-row-${idx}" class="${rowClass}" onclick="toggleRow(${idx})">`;
1353
- rowsHtml += columns.map((col, colIdx) => {
1354
- const value = row[col] ?? '';
1355
- const strValue = String(value);
1356
- const isQuery = col === 'query';
1357
- const isSource = col === 'source';
1358
-
1359
- if (isSource) {
1360
- return `<td>${buildSourceBadge(strValue)}</td>`;
1361
- }
1362
-
1363
- const displayValue = strValue.length > 80 ? strValue.substring(0, 80) + '...' : strValue;
1364
- // Add problem indicator to first column
1365
- const indicator = colIdx === 0 ? problemIndicator : '';
1366
- return `<td class="${isQuery ? 'query-cell' : ''}">${escapeHtml(displayValue)}${indicator}</td>`;
1367
- }).join('');
1368
- rowsHtml += '</tr>';
1369
-
1370
- // Detail row (hidden by default)
1371
- rowsHtml += `<tr id="detail-row-${idx}" class="detail-row">`;
1372
- rowsHtml += `<td colspan="${columns.length}">${buildDetailRow(row, columns, idx, thresholds, problemFields)}</td>`;
1373
- rowsHtml += '</tr>';
1374
- });
1375
-
1376
- tableBody.innerHTML = rowsHtml;
1377
-
1378
- // Re-setup top scrollbar
1379
- setTimeout(setupTopScrollbar, 0);
1380
- }
1381
-
1382
- // Problem explanations from I18n (passed from server)
1383
- const problemExplanations = <%= raw(I18n.t('pg_reports.problems').to_json) %>;
1384
-
1385
- function toggleDropdown() {
1386
- document.getElementById('dropdown-menu').classList.toggle('show');
1387
- }
1388
-
1389
- // Close dropdown when clicking outside
1390
- document.addEventListener('click', function(e) {
1391
- const dropdown = document.getElementById('download-dropdown');
1392
- if (dropdown && !dropdown.contains(e.target)) {
1393
- document.getElementById('dropdown-menu')?.classList.remove('show');
1394
- }
1395
- // Close IDE dropdowns
1396
- document.querySelectorAll('.ide-dropdown-menu.show').forEach(menu => {
1397
- if (!menu.parentElement.contains(e.target)) {
1398
- menu.classList.remove('show');
1399
- }
1400
- });
1401
- });
1402
-
1403
- function downloadReport(format) {
1404
- document.getElementById('dropdown-menu').classList.remove('show');
1405
- window.location.href = `${pgReportsRoot}/${category}/${reportKey}/download?format=${format}`;
1406
- }
1407
-
1408
- function toggleRow(rowIndex) {
1409
- const dataRow = document.getElementById(`data-row-${rowIndex}`);
1410
- const detailRow = document.getElementById(`detail-row-${rowIndex}`);
1411
-
1412
- if (!dataRow || !detailRow) return;
1413
-
1414
- const isExpanded = dataRow.classList.contains('expanded');
1415
-
1416
- // Collapse all other rows
1417
- document.querySelectorAll('.data-row.expanded').forEach(row => {
1418
- row.classList.remove('expanded');
1419
- });
1420
- document.querySelectorAll('.detail-row.show').forEach(row => {
1421
- row.classList.remove('show');
1422
- });
1423
-
1424
- // Toggle current row
1425
- if (!isExpanded) {
1426
- dataRow.classList.add('expanded');
1427
- detailRow.classList.add('show');
1428
- }
1429
- }
1430
-
1431
- function escapeHtml(text) {
1432
- const div = document.createElement('div');
1433
- div.textContent = text;
1434
- return div.innerHTML;
1435
- }
1436
-
1437
- function escapeHtmlAttr(text) {
1438
- return String(text)
1439
- .replace(/&/g, '&amp;')
1440
- .replace(/"/g, '&quot;')
1441
- .replace(/'/g, '&#39;')
1442
- .replace(/</g, '&lt;')
1443
- .replace(/>/g, '&gt;');
1444
- }
1445
-
1446
- function copyQueryFromButton(btn) {
1447
- const query = btn.dataset.query;
1448
- copyToClipboard(query, btn);
1449
- }
1450
-
1451
- function copyToClipboard(text, btn) {
1452
- navigator.clipboard.writeText(text).then(() => {
1453
- const originalText = btn.textContent;
1454
- btn.textContent = '✓ Copied!';
1455
- btn.style.background = 'var(--accent-green)';
1456
- btn.style.borderColor = 'var(--accent-green)';
1457
- btn.style.color = 'white';
1458
- setTimeout(() => {
1459
- btn.textContent = originalText;
1460
- btn.style.background = '';
1461
- btn.style.borderColor = '';
1462
- btn.style.color = '';
1463
- }, 1500);
1464
- }).catch(() => {
1465
- showToast('Failed to copy', 'error');
1466
- });
1467
- }
1468
-
1469
- // Check if a value exceeds threshold
1470
- function checkThreshold(value, threshold, inverted = false) {
1471
- if (!threshold || value === null || value === undefined) return null;
1472
-
1473
- const numValue = parseFloat(value);
1474
- if (isNaN(numValue)) return null;
1475
-
1476
- if (inverted) {
1477
- // For inverted thresholds (lower is worse), like cache_hit_ratio
1478
- if (numValue <= threshold.critical) return 'critical';
1479
- if (numValue <= threshold.warning) return 'warning';
1480
- } else {
1481
- // Normal thresholds (higher is worse)
1482
- if (numValue >= threshold.critical) return 'critical';
1483
- if (numValue >= threshold.warning) return 'warning';
1484
- }
1485
- return null;
1486
- }
1487
-
1488
- // Get row problem level
1489
- function getRowProblemLevel(row, thresholds, problemFields) {
1490
- if (!thresholds || !problemFields || problemFields.length === 0) return null;
1491
-
1492
- let maxLevel = null;
1493
- const problems = [];
1494
-
1495
- for (const field of problemFields) {
1496
- const value = row[field];
1497
- const threshold = thresholds[field];
1498
- if (!threshold) continue;
1499
-
1500
- const level = checkThreshold(value, threshold, threshold.inverted);
1501
- if (level) {
1502
- problems.push({ field, value, level, threshold });
1503
- if (level === 'critical') maxLevel = 'critical';
1504
- else if (level === 'warning' && maxLevel !== 'critical') maxLevel = 'warning';
1505
- }
1506
- }
1507
-
1508
- return { level: maxLevel, problems };
1509
- }
1510
-
1511
- // Show problem modal
1512
- function showProblemModal(problems, row) {
1513
- const modal = document.getElementById('problem-modal');
1514
- const body = document.getElementById('problem-modal-body');
1515
-
1516
- let html = '<div class="problem-details">';
1517
-
1518
- for (const problem of problems) {
1519
- const levelClass = problem.level === 'critical' ? 'critical' : 'warning';
1520
- const levelText = problem.level === 'critical' ? '🔴 Critical' : '⚠️ Warning';
1521
-
1522
- html += `
1523
- <div class="problem-field">
1524
- <span class="problem-field-label">${escapeHtml(problem.field)} (${levelText})</span>
1525
- <div class="problem-field-value ${levelClass}">
1526
- Current: ${escapeHtml(String(problem.value))}
1527
- <br>Threshold: warning=${problem.threshold.warning}, critical=${problem.threshold.critical}
1528
- ${problem.threshold.inverted ? '<br><em>(inverted: lower values are worse)</em>' : ''}
1529
- </div>
1530
- </div>
1531
- `;
1532
- }
1533
-
1534
- // Add explanation based on problem types
1535
- const explanationKey = getExplanationKey(problems);
1536
- const explanation = problemExplanations[explanationKey] || '';
1537
-
1538
- if (explanation) {
1539
- html += `
1540
- <div class="problem-explanation">
1541
- <h4>💡 Recommendation</h4>
1542
- <p>${escapeHtml(explanation)}</p>
1543
- </div>
1544
- `;
1545
- }
1546
-
1547
- html += '</div>';
1548
- body.innerHTML = html;
1549
- modal.style.display = 'flex';
1550
- }
1551
-
1552
- function closeProblemModal() {
1553
- document.getElementById('problem-modal').style.display = 'none';
1554
- }
1555
-
1556
- // Map problem fields to explanation keys
1557
- function getExplanationKey(problems) {
1558
- const fields = problems.map(p => p.field);
1559
-
1560
- if (fields.includes('mean_time_ms')) return 'high_mean_time';
1561
- if (fields.includes('calls')) return 'high_calls';
1562
- if (fields.includes('total_time_ms')) return 'high_total_time';
1563
- if (fields.includes('cache_hit_ratio')) return 'low_cache_hit';
1564
- if (fields.includes('seq_scan') || fields.includes('seq_tup_read')) return 'high_seq_scan';
1565
- if (fields.includes('idx_scan')) return 'unused_index';
1566
- if (fields.includes('bloat_ratio') || fields.includes('bloat_size')) return 'high_bloat';
1567
- if (fields.includes('dead_tuple_ratio') || fields.includes('n_dead_tup')) return 'many_dead_tuples';
1568
- if (fields.includes('duration_seconds') || fields.includes('duration')) return 'long_running';
1569
- if (fields.includes('blocked_count')) return 'blocking';
1570
- if (fields.includes('idle_in_transaction')) return 'idle_in_transaction';
1571
-
1572
- return '';
1573
- }
1574
-
1575
- // Parse source location and generate IDE link
1576
- function parseSourceLocation(source) {
1577
- if (!source || source === 'null' || source === '') return null;
1578
-
1579
- // Common patterns:
1580
- // app/controllers/users_controller.rb:42
1581
- // app/models/user.rb:123:in `find'
1582
- // UsersController#index
1583
- // app/controllers/users_controller.rb:42:in `index'
1584
-
1585
- let filePath = null;
1586
- let lineNumber = null;
1587
- let methodName = null;
1588
-
1589
- // Try to match file:line pattern
1590
- const fileLineMatch = source.match(/^([^:]+\.(rb|erb|js|ts|py|go|java)):(\d+)/);
1591
- if (fileLineMatch) {
1592
- filePath = fileLineMatch[1];
1593
- lineNumber = parseInt(fileLineMatch[3], 10);
1594
- }
1595
-
1596
- // Try to match Controller#action pattern (e.g., PostsController#index)
1597
- const controllerMatch = source.match(/^(\w+Controller)#(\w+)/);
1598
- if (controllerMatch) {
1599
- methodName = `${controllerMatch[1]}#${controllerMatch[2]}`;
1600
-
1601
- // Check known methods first for testing (if fake data is enabled)
1602
- if (typeof knownControllerMethods !== 'undefined' && knownControllerMethods[methodName]) {
1603
- filePath = knownControllerMethods[methodName].file;
1604
- lineNumber = knownControllerMethods[methodName].line;
1605
- } else {
1606
- // Derive file path from controller name
1607
- const controllerName = controllerMatch[1].replace(/Controller$/, '').toLowerCase();
1608
- filePath = `app/controllers/${controllerName}_controller.rb`;
1609
- }
1610
- }
1611
-
1612
- // Try to match short controller#action pattern (e.g., posts#index, dashboard#show)
1613
- if (!filePath) {
1614
- const shortMatch = source.match(/^(\w+)#(\w+)$/);
1615
- if (shortMatch) {
1616
- const controllerName = shortMatch[1].toLowerCase();
1617
- methodName = `${shortMatch[1]}#${shortMatch[2]}`;
1618
- filePath = `app/controllers/${controllerName}_controller.rb`;
1619
- }
1620
- }
1621
-
1622
- return { filePath, lineNumber, methodName, original: source };
1623
- }
1624
-
1625
- // Rails.root path for IDE links
1626
- const railsRootPath = '<%= Rails.root.to_s %>';
1627
- const wslDistro = 'Ubuntu';
1628
-
1629
- // Generate IDE URLs
1630
- function generateIdeUrls(filePath, lineNumber) {
1631
- if (!filePath) return [];
1632
-
1633
- // Convert relative paths to absolute paths using Rails.root
1634
- let absolutePath = filePath;
1635
- if (!filePath.startsWith('/')) {
1636
- absolutePath = `${railsRootPath}/${filePath}`;
1637
- }
1638
-
1639
- const line = lineNumber || 1;
1640
- const urls = [];
1641
-
1642
- // VSCode (WSL Remote format)
1643
- urls.push({
1644
- name: 'VS Code (WSL)',
1645
- url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1646
- });
1647
-
1648
- // VSCode (direct path - for native Linux or Windows)
1649
- urls.push({
1650
- name: 'VS Code',
1651
- url: `vscode://file${absolutePath}:${line}`
1652
- });
1653
-
1654
- // JetBrains (RubyMine, IntelliJ, etc.)
1655
- urls.push({
1656
- name: 'RubyMine',
1657
- url: `x-mine://open?file=${absolutePath}&line=${line}`
1658
- });
1659
-
1660
- urls.push({
1661
- name: 'IntelliJ',
1662
- url: `idea://open?file=${absolutePath}&line=${line}`
1663
- });
1664
-
1665
- // Cursor (WSL Remote format)
1666
- urls.push({
1667
- name: 'Cursor (WSL)',
1668
- url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1669
- });
1670
-
1671
- // Cursor (direct path)
1672
- urls.push({
1673
- name: 'Cursor',
1674
- url: `cursor://file${absolutePath}:${line}`
1675
- });
1676
-
1677
- return urls;
1678
- }
1679
-
1680
- // Map IDE key to URL index
1681
- const ideKeyMap = {
1682
- 'vscode-wsl': 0,
1683
- 'vscode': 1,
1684
- 'rubymine': 2,
1685
- 'intellij': 3,
1686
- 'cursor-wsl': 4,
1687
- 'cursor': 5
1688
- };
1689
-
1690
- // Build source badge HTML with IDE links
1691
- function buildSourceBadge(source) {
1692
- const parsed = parseSourceLocation(source);
1693
-
1694
- if (!parsed) {
1695
- return `<span class="source-badge empty">—</span>`;
1696
- }
1697
-
1698
- const ideUrls = generateIdeUrls(parsed.filePath, parsed.lineNumber);
1699
-
1700
- if (ideUrls.length === 0) {
1701
- return `<span class="source-badge" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>`;
1702
- }
1703
-
1704
- // Check if user has a default IDE set
1705
- const defaultIde = getDefaultIde();
1706
- if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
1707
- const ideUrl = ideUrls[ideKeyMap[defaultIde]];
1708
- if (ideUrl) {
1709
- return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</a>`;
1710
- }
1711
- }
1712
-
1713
- // No default IDE - show dropdown menu
1714
- const dropdownId = `ide-dropdown-${Math.random().toString(36).substr(2, 9)}`;
1715
-
1716
- let dropdownHtml = `
1717
- <div class="ide-dropdown">
1718
- <span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>
1719
- <div class="ide-dropdown-menu" id="${dropdownId}">
1720
- `;
1721
-
1722
- for (const ide of ideUrls) {
1723
- dropdownHtml += `<a href="${ide.url}">${ide.name}</a>`;
1724
- }
1725
-
1726
- dropdownHtml += '</div></div>';
1727
- return dropdownHtml;
1728
- }
1729
-
1730
- function toggleIdeDropdown(dropdownId, badgeElement) {
1731
- const menu = document.getElementById(dropdownId);
1732
- if (!menu) return;
1733
-
1734
- // Close other dropdowns
1735
- document.querySelectorAll('.ide-dropdown-menu.show').forEach(m => {
1736
- if (m.id !== dropdownId) m.classList.remove('show');
1737
- });
1738
-
1739
- // Toggle current menu
1740
- const isShowing = menu.classList.toggle('show');
1741
-
1742
- // Position the menu using fixed positioning
1743
- if (isShowing && badgeElement) {
1744
- const rect = badgeElement.getBoundingClientRect();
1745
- menu.style.top = (rect.bottom + 4) + 'px';
1746
- menu.style.left = rect.left + 'px';
1747
- }
1748
- }
1749
-
1750
- // Event delegation for source badge clicks
1751
- document.addEventListener('click', function(e) {
1752
- const badge = e.target.closest('.source-badge.clickable[data-dropdown-id]');
1753
- if (badge) {
1754
- e.stopPropagation();
1755
- e.preventDefault();
1756
- const dropdownId = badge.dataset.dropdownId;
1757
- toggleIdeDropdown(dropdownId, badge);
1758
- return;
1759
- }
1760
-
1761
- // Allow clicks on IDE dropdown menu links
1762
- const ideLink = e.target.closest('.ide-dropdown-menu a');
1763
- if (ideLink) {
1764
- e.stopPropagation();
1765
- // Let the link navigate normally
1766
- return;
1767
- }
1768
- }, true); // Use capture phase to intercept before row click
1769
-
1770
- function buildDetailRow(row, columns, rowIndex, thresholds, problemFields) {
1771
- let html = '<div class="row-detail">';
1772
- const hasQuery = columns.includes('query') && row.query;
1773
- const hasIndexName = columns.includes('index_name') && row.index_name;
1774
- const rowId = generateRowId(row);
1775
-
1776
- columns.forEach(col => {
1777
- const value = row[col] ?? '';
1778
- const strValue = String(value);
1779
- const isQuery = col === 'query';
1780
- const isSource = col === 'source';
1781
- const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
1782
-
1783
- // Check if this field has a problem
1784
- let problemClass = '';
1785
- if (problemFields && problemFields.includes(col) && thresholds && thresholds[col]) {
1786
- const level = checkThreshold(value, thresholds[col], thresholds[col].inverted);
1787
- if (level === 'critical') problemClass = 'problem-critical';
1788
- else if (level === 'warning') problemClass = 'problem-warning';
1789
- }
1790
-
1791
- let valueClass = '';
1792
- if (isQuery) valueClass = 'query';
1793
- else if (isSource) valueClass = 'source';
1794
- else if (isNumber) valueClass = 'number';
1795
- if (problemClass) valueClass += ' ' + problemClass;
1796
-
1797
- const isLongText = strValue.length > 100 || isQuery;
1798
-
1799
- // Skip empty source in detail view
1800
- if (isSource && (!strValue || strValue === 'null' || strValue === '')) {
1801
- return;
1802
- }
1803
-
1804
- html += `
1805
- <div class="row-detail-item${isLongText ? ' full-width' : ''}">
1806
- <span class="row-detail-label">${escapeHtml(col)}</span>
1807
- <div class="row-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
1808
- ${isQuery ? `<button class="copy-btn" data-query="${escapeHtmlAttr(strValue)}" onclick="event.stopPropagation(); copyQueryFromButton(this)">📋 Copy Query</button>` : ''}
1809
- </div>
1810
- `;
1811
- });
1812
-
1813
- // Action buttons
1814
- const isSaved = isRecordSaved(rowId);
1815
- const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
1816
-
1817
- html += `<div class="detail-actions">`;
1818
- html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${isSaved ? '📌 Saved' : '📌 Save for Comparison'}</button>`;
1819
-
1820
- if (hasQuery) {
1821
- const queryEscaped = row.query.replace(/'/g, "\\'").replace(/"/g, '&quot;');
1822
- html += `<button class="btn-explain" onclick="event.stopPropagation(); runExplainAnalyze('${queryEscaped}')">📊 EXPLAIN ANALYZE</button>`;
1823
- }
1824
-
1825
- // Show migration button for index reports
1826
- if (hasIndexName && (category === 'indexes')) {
1827
- const indexName = row.index_name;
1828
- const tableName = row.table_name || row.tablename || '';
1829
- const schemaName = row.schema_name || row.schemaname || 'public';
1830
- html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">🗑️ Generate Migration</button>`;
1831
- }
1832
-
1833
- html += `</div>`;
1834
-
1835
- html += '</div>';
1836
- return html;
1837
- }
1838
-
1839
- async function runReport(cat, report, button) {
1840
- const tableBody = document.getElementById('results-body');
1841
- const tableHead = document.getElementById('results-head');
1842
- const loadingEl = document.getElementById('loading');
1843
- const emptyEl = document.getElementById('empty-state');
1844
- const metaEl = document.getElementById('results-meta');
1845
- const downloadDropdown = document.getElementById('download-dropdown');
1846
- const telegramBtn = document.getElementById('telegram-btn');
1847
-
1848
- if (button) {
1849
- button.disabled = true;
1850
- button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> Running...';
1851
- }
1852
-
1853
- if (loadingEl) loadingEl.style.display = 'flex';
1854
- if (emptyEl) emptyEl.style.display = 'none';
1855
- if (tableBody) tableBody.innerHTML = '';
1856
- if (downloadDropdown) downloadDropdown.style.display = 'none';
1857
- if (telegramBtn) telegramBtn.style.display = 'none';
1858
-
1859
- try {
1860
- const response = await fetch(`${pgReportsRoot}/${cat}/${report}/run`, {
1861
- method: 'POST',
1862
- headers: {
1863
- 'Content-Type': 'application/json',
1864
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1865
- }
1866
- });
1867
-
1868
- const data = await response.json();
1869
-
1870
- if (loadingEl) loadingEl.style.display = 'none';
1871
-
1872
- if (data.success) {
1873
- // Inject fake source data for IDE link testing (if enabled)
1874
- if (typeof injectFakeSourceData === 'function') {
1875
- injectFakeSourceData(data);
1876
- }
1877
-
1878
- currentReportData = data;
1879
- const thresholds = data.thresholds || {};
1880
- const problemFields = data.problem_fields || [];
1881
-
1882
- if (metaEl) {
1883
- metaEl.innerHTML = `
1884
- <span>Total: ${data.total} rows</span>
1885
- <span>Generated: ${data.generated_at}</span>
1886
- <span class="row-hint">Click row to expand</span>
1887
- `;
1888
- }
1889
-
1890
- // Show download and telegram buttons
1891
- if (downloadDropdown) downloadDropdown.style.display = 'inline-block';
1892
- if (telegramBtn) telegramBtn.style.display = 'inline-flex';
1893
-
1894
- if (data.data.length === 0) {
1895
- if (emptyEl) emptyEl.style.display = 'block';
1896
- } else {
1897
- // Reset sort state for new data
1898
- currentSort = { column: null, direction: 'asc' };
1899
-
1900
- // Build table header with sortable columns
1901
- if (tableHead) {
1902
- tableHead.innerHTML = '<tr>' + data.columns.map(col =>
1903
- `<th class="sortable" data-column="${escapeHtml(col)}" onclick="handleSortClick('${escapeHtml(col)}')">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
1904
- ).join('') + '</tr>';
1905
- }
1906
-
1907
- // Build table body with expandable rows
1908
- renderTableBody(data.data, data.columns, thresholds, problemFields);
1909
- }
1910
-
1911
- showToast('Report generated successfully');
1912
- } else {
1913
- showToast(data.error || 'Failed to run report', 'error');
1914
- }
1915
- } catch (error) {
1916
- if (loadingEl) loadingEl.style.display = 'none';
1917
- showToast('Network error: ' + error.message, 'error');
1918
- }
1919
-
1920
- if (button) {
1921
- button.disabled = false;
1922
- button.innerHTML = '▶ Run Report';
1923
- }
1924
- }
1925
-
1926
- // Close modal on Escape key
1927
- document.addEventListener('keydown', function(e) {
1928
- if (e.key === 'Escape') {
1929
- closeProblemModal();
1930
- }
1931
- });
1932
-
1933
- // Close modal on backdrop click
1934
- document.getElementById('problem-modal')?.addEventListener('click', function(e) {
1935
- if (e.target === this) {
1936
- closeProblemModal();
1937
- }
1938
- });
1939
-
1940
- // IDE Settings functions
1941
- function showIdeSettingsModal() {
1942
- const modal = document.getElementById('ide-settings-modal');
1943
- modal.style.display = 'flex';
1944
- loadIdeSettingsState();
1945
- }
1946
-
1947
- function closeIdeSettingsModal() {
1948
- document.getElementById('ide-settings-modal').style.display = 'none';
1949
- }
1950
-
1951
- function setDefaultIde(ideKey) {
1952
- if (ideKey) {
1953
- localStorage.setItem('pgReportsDefaultIde', ideKey);
1954
- } else {
1955
- localStorage.removeItem('pgReportsDefaultIde');
1956
- }
1957
- }
1958
-
1959
- function getDefaultIde() {
1960
- return localStorage.getItem('pgReportsDefaultIde') || '';
1961
- }
1962
-
1963
- function loadIdeSettingsState() {
1964
- const currentIde = getDefaultIde();
1965
- const radios = document.querySelectorAll('input[name="default-ide"]');
1966
- radios.forEach(radio => {
1967
- radio.checked = (radio.value === currentIde);
1968
- });
1969
- }
1970
-
1971
- // Close IDE settings modal on backdrop click
1972
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
1973
- if (e.target === this) {
1974
- closeIdeSettingsModal();
1975
- }
1976
- });
1977
-
1978
- // Close IDE settings modal on Escape key
1979
- document.addEventListener('keydown', function(e) {
1980
- if (e.key === 'Escape') {
1981
- closeIdeSettingsModal();
1982
- closeExplainModal();
1983
- closeMigrationModal();
1984
- }
1985
- });
1986
-
1987
- // ==========================================
1988
- // SAVED RECORDS FUNCTIONALITY
1989
- // ==========================================
1990
-
1991
- function getSavedRecordsKey() {
1992
- return `pgReports_saved_${category}_${reportKey}`;
1993
- }
1994
-
1995
- function getSavedRecords() {
1996
- try {
1997
- const data = localStorage.getItem(getSavedRecordsKey());
1998
- return data ? JSON.parse(data) : [];
1999
- } catch (e) {
2000
- return [];
2001
- }
2002
- }
2003
-
2004
- function saveSavedRecords(records) {
2005
- localStorage.setItem(getSavedRecordsKey(), JSON.stringify(records));
2006
- }
2007
-
2008
- function generateRowId(row) {
2009
- // Generate a unique ID based on key fields
2010
- const keyFields = ['query', 'queryid', 'index_name', 'table_name', 'pid', 'datname'];
2011
- const parts = [];
2012
- for (const field of keyFields) {
2013
- if (row[field]) {
2014
- parts.push(String(row[field]).substring(0, 50));
2015
- }
2016
- }
2017
- // Simple hash
2018
- const str = parts.join('|');
2019
- let hash = 0;
2020
- for (let i = 0; i < str.length; i++) {
2021
- const char = str.charCodeAt(i);
2022
- hash = ((hash << 5) - hash) + char;
2023
- hash = hash & hash;
2024
- }
2025
- return 'r' + Math.abs(hash).toString(36);
2026
- }
2027
-
2028
- function isRecordSaved(rowId) {
2029
- const saved = getSavedRecords();
2030
- return saved.some(r => r.id === rowId);
2031
- }
2032
-
2033
- function toggleSaveRecord(rowId, row, btn) {
2034
- const saved = getSavedRecords();
2035
- const existingIdx = saved.findIndex(r => r.id === rowId);
2036
-
2037
- if (existingIdx >= 0) {
2038
- // Remove
2039
- saved.splice(existingIdx, 1);
2040
- btn.classList.remove('saved');
2041
- btn.textContent = '📌 Save for Comparison';
2042
- showToast('Record removed from saved');
2043
- } else {
2044
- // Add
2045
- saved.unshift({
2046
- id: rowId,
2047
- savedAt: new Date().toISOString(),
2048
- data: row
2049
- });
2050
- btn.classList.add('saved');
2051
- btn.textContent = '📌 Saved';
2052
- showToast('Record saved for comparison');
2053
- }
2054
-
2055
- saveSavedRecords(saved);
2056
- renderSavedRecords();
2057
- }
2058
-
2059
- function removeSavedRecord(rowId) {
2060
- const saved = getSavedRecords();
2061
- const filtered = saved.filter(r => r.id !== rowId);
2062
- saveSavedRecords(filtered);
2063
- renderSavedRecords();
2064
-
2065
- // Update button in table if visible
2066
- const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
2067
- if (btn) {
2068
- btn.classList.remove('saved');
2069
- btn.textContent = '📌 Save for Comparison';
2070
- }
2071
-
2072
- showToast('Record removed');
2073
- }
2074
-
2075
- function clearAllSavedRecords() {
2076
- if (!confirm('Remove all saved records for this report?')) return;
2077
- saveSavedRecords([]);
2078
- renderSavedRecords();
2079
-
2080
- // Update all buttons in table
2081
- document.querySelectorAll('.btn-save.saved').forEach(btn => {
2082
- btn.classList.remove('saved');
2083
- btn.textContent = '📌 Save for Comparison';
2084
- });
2085
-
2086
- showToast('All saved records cleared');
2087
- }
2088
-
2089
- function renderSavedRecords() {
2090
- const section = document.getElementById('saved-records-section');
2091
- const list = document.getElementById('saved-records-list');
2092
- const saved = getSavedRecords();
2093
-
2094
- if (saved.length === 0) {
2095
- section.style.display = 'none';
2096
- return;
2097
- }
2098
-
2099
- section.style.display = 'block';
2100
-
2101
- // Fields to highlight for comparison
2102
- const highlightFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows', 'shared_blks_hit', 'shared_blks_read'];
2103
- // Key metrics to show in summary
2104
- const summaryFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows'];
2105
-
2106
- let html = '';
2107
- saved.forEach((record, idx) => {
2108
- const savedTime = new Date(record.savedAt).toLocaleString();
2109
- const data = record.data;
2110
- const hasQuery = data.query;
2111
-
2112
- html += `
2113
- <div class="saved-record-card" id="saved-card-${idx}" onclick="toggleSavedRecordDetail(${idx}, event)">
2114
- <div class="saved-record-header">
2115
- <span class="saved-record-time">▸ Saved: ${savedTime}</span>
2116
- <span class="saved-record-expand-hint">Click to expand</span>
2117
- <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="Remove">×</button>
2118
- </div>
2119
- <div class="saved-record-data">
2120
- `;
2121
-
2122
- // Show key metrics in summary
2123
- summaryFields.forEach(field => {
2124
- const value = data[field];
2125
- if (value === null || value === undefined) return;
2126
- const strValue = String(value);
2127
- html += `
2128
- <div class="saved-record-field">
2129
- <span class="saved-record-field-name">${escapeHtml(field)}</span>
2130
- <span class="saved-record-field-value highlight">${escapeHtml(strValue)}</span>
2131
- </div>
2132
- `;
2133
- });
2134
-
2135
- // Show truncated query preview
2136
- if (hasQuery) {
2137
- const queryPreview = data.query.length > 100 ? data.query.substring(0, 100) + '...' : data.query;
2138
- html += `
2139
- <div class="saved-record-field" style="grid-column: 1 / -1;">
2140
- <span class="saved-record-field-name">query</span>
2141
- <span class="saved-record-field-value" style="color: var(--accent-green); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(queryPreview)}</span>
2142
- </div>
2143
- `;
2144
- }
2145
-
2146
- // Expandable detail section
2147
- html += `
2148
- </div>
2149
- <div class="saved-record-detail">
2150
- <div class="saved-record-detail-grid">
2151
- `;
2152
-
2153
- // Show all fields in detail
2154
- Object.keys(data).forEach(field => {
2155
- if (field === 'source') return;
2156
- const value = data[field];
2157
- if (value === null || value === undefined) return;
2158
- const strValue = String(value);
2159
- const isQuery = field === 'query';
2160
- const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
2161
-
2162
- let valueClass = '';
2163
- if (isQuery) valueClass = 'query';
2164
- else if (isNumber) valueClass = 'number';
2165
-
2166
- html += `
2167
- <div class="saved-record-detail-item ${isQuery ? 'full-width' : ''}">
2168
- <span class="saved-record-detail-label">${escapeHtml(field)}</span>
2169
- <div class="saved-record-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
2170
- </div>
2171
- `;
2172
- });
2173
-
2174
- html += `
2175
- </div>
2176
- </div>
2177
- </div>
2178
- `;
2179
- });
2180
-
2181
- list.innerHTML = html;
2182
- }
2183
-
2184
- function toggleSavedRecordDetail(idx, event) {
2185
- // Don't toggle if clicking on remove button
2186
- if (event.target.classList.contains('saved-record-remove')) return;
2187
-
2188
- const card = document.getElementById(`saved-card-${idx}`);
2189
- if (!card) return;
2190
-
2191
- const wasExpanded = card.classList.contains('expanded');
2192
-
2193
- // Collapse all cards
2194
- document.querySelectorAll('.saved-record-card.expanded').forEach(c => {
2195
- c.classList.remove('expanded');
2196
- const hint = c.querySelector('.saved-record-time');
2197
- if (hint) hint.textContent = hint.textContent.replace('▾', '▸');
2198
- });
2199
-
2200
- // Toggle current card
2201
- if (!wasExpanded) {
2202
- card.classList.add('expanded');
2203
- const hint = card.querySelector('.saved-record-time');
2204
- if (hint) hint.textContent = hint.textContent.replace('▸', '▾');
2205
- }
2206
- }
2207
-
2208
- // Render saved records on page load
2209
- document.addEventListener('DOMContentLoaded', renderSavedRecords);
2210
-
2211
- // ==========================================
2212
- // EXPLAIN ANALYZE FUNCTIONALITY
2213
- // ==========================================
2214
-
2215
- let currentExplainQuery = '';
2216
-
2217
- async function runExplainAnalyze(query) {
2218
- currentExplainQuery = query;
2219
- const modal = document.getElementById('explain-modal');
2220
- const loading = document.getElementById('explain-loading');
2221
- const content = document.getElementById('explain-content');
2222
-
2223
- modal.style.display = 'flex';
2224
- loading.style.display = 'flex';
2225
- content.innerHTML = '';
2226
-
2227
- try {
2228
- const response = await fetch(`${pgReportsRoot}/explain_analyze`, {
2229
- method: 'POST',
2230
- headers: {
2231
- 'Content-Type': 'application/json',
2232
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
2233
- },
2234
- body: JSON.stringify({ query: query })
2235
- });
2236
-
2237
- const data = await response.json();
2238
- loading.style.display = 'none';
2239
-
2240
- if (data.success) {
2241
- let html = '';
2242
-
2243
- // Show stats if available
2244
- if (data.stats) {
2245
- html += '<div class="explain-stats">';
2246
- if (data.stats.planning_time) {
2247
- html += `<div class="explain-stat"><span class="explain-stat-label">Planning Time</span><span class="explain-stat-value">${data.stats.planning_time} ms</span></div>`;
2248
- }
2249
- if (data.stats.execution_time) {
2250
- html += `<div class="explain-stat"><span class="explain-stat-label">Execution Time</span><span class="explain-stat-value">${data.stats.execution_time} ms</span></div>`;
2251
- }
2252
- if (data.stats.total_cost) {
2253
- html += `<div class="explain-stat"><span class="explain-stat-label">Total Cost</span><span class="explain-stat-value">${data.stats.total_cost}</span></div>`;
2254
- }
2255
- if (data.stats.rows) {
2256
- html += `<div class="explain-stat"><span class="explain-stat-label">Rows</span><span class="explain-stat-value">${data.stats.rows}</span></div>`;
2257
- }
2258
- html += '</div>';
2259
- }
2260
-
2261
- html += `<div class="explain-result">${escapeHtml(data.explain)}</div>`;
2262
- content.innerHTML = html;
2263
- } else {
2264
- content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to run EXPLAIN ANALYZE')}</div>`;
2265
- }
2266
- } catch (error) {
2267
- loading.style.display = 'none';
2268
- content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
2269
- }
2270
- }
2271
-
2272
- function closeExplainModal() {
2273
- document.getElementById('explain-modal').style.display = 'none';
2274
- }
2275
-
2276
- document.getElementById('explain-modal')?.addEventListener('click', function(e) {
2277
- if (e.target === this) closeExplainModal();
2278
- });
2279
-
2280
- // ==========================================
2281
- // MIGRATION GENERATION FUNCTIONALITY
2282
- // ==========================================
2283
-
2284
- let currentMigrationData = null;
2285
-
2286
- function showMigrationModal(indexName, tableName, schemaName) {
2287
- const migrationName = `remove_${indexName.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
2288
- const timestamp = new Date().toISOString().replace(/[-:T]/g, '').substring(0, 14);
2289
- const className = migrationName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
2290
-
2291
- const fullIndexName = schemaName && schemaName !== 'public'
2292
- ? `${schemaName}.${indexName}`
2293
- : indexName;
2294
-
2295
- const migrationCode = `# frozen_string_literal: true
2296
-
2297
- class ${className} < ActiveRecord::Migration[7.0]
2298
- def change
2299
- remove_index :${tableName}, name: :${indexName}, if_exists: true
2300
- end
2301
- end
2302
- `;
2303
-
2304
- currentMigrationData = {
2305
- fileName: `${timestamp}_${migrationName}.rb`,
2306
- code: migrationCode,
2307
- indexName,
2308
- tableName,
2309
- schemaName
2310
- };
2311
-
2312
- document.getElementById('migration-code').textContent = migrationCode;
2313
- document.getElementById('migration-modal').style.display = 'flex';
2314
- }
2315
-
2316
- function closeMigrationModal() {
2317
- document.getElementById('migration-modal').style.display = 'none';
2318
- }
2319
-
2320
- document.getElementById('migration-modal')?.addEventListener('click', function(e) {
2321
- if (e.target === this) closeMigrationModal();
2322
- });
2323
-
2324
- function copyMigrationCode() {
2325
- if (!currentMigrationData) return;
2326
- navigator.clipboard.writeText(currentMigrationData.code).then(() => {
2327
- showToast('Migration code copied to clipboard');
2328
- }).catch(() => {
2329
- showToast('Failed to copy', 'error');
2330
- });
2331
- }
2332
-
2333
- async function createMigrationFile() {
2334
- if (!currentMigrationData) return;
2335
-
2336
- try {
2337
- const response = await fetch(`${pgReportsRoot}/create_migration`, {
2338
- method: 'POST',
2339
- headers: {
2340
- 'Content-Type': 'application/json',
2341
- 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
2342
- },
2343
- body: JSON.stringify({
2344
- file_name: currentMigrationData.fileName,
2345
- code: currentMigrationData.code
2346
- })
2347
- });
2348
-
2349
- const data = await response.json();
2350
-
2351
- if (data.success) {
2352
- showToast('Migration file created');
2353
- closeMigrationModal();
2354
-
2355
- // Open in IDE if path provided
2356
- if (data.file_path) {
2357
- const ideUrls = generateIdeUrls(data.file_path, 1);
2358
- const defaultIde = getDefaultIde();
2359
-
2360
- if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
2361
- window.location.href = ideUrls[ideKeyMap[defaultIde]].url;
2362
- } else if (ideUrls.length > 0) {
2363
- window.location.href = ideUrls[0].url;
2364
- }
2365
- }
2366
- } else {
2367
- showToast(data.error || 'Failed to create migration', 'error');
2368
- }
2369
- } catch (error) {
2370
- showToast('Network error: ' + error.message, 'error');
2371
- }
2372
- }
2373
- </script>
153
+ <%= render 'pg_reports/dashboard/show_scripts' %>