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