pg_reports 0.1.0 → 0.2.1

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,123 @@
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-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
+
44
90
  <% if @error %>
45
91
  <div class="error-message">
46
92
  <strong>Error:</strong> <%= @error %>
47
93
  </div>
48
94
  <% end %>
49
95
 
96
+ <!-- Collapsible Documentation Section -->
97
+ <% if @documentation && @documentation[:what].present? %>
98
+ <details class="documentation-section">
99
+ <summary class="documentation-toggle">
100
+ <span class="toggle-icon">â–ļ</span>
101
+ <span class="toggle-text">â„šī¸ About this report</span>
102
+ </summary>
103
+ <div class="documentation-content">
104
+ <div class="doc-block">
105
+ <h4>What is this?</h4>
106
+ <p><%= @documentation[:what] %></p>
107
+ </div>
108
+
109
+ <% if @documentation[:how].present? %>
110
+ <div class="doc-block">
111
+ <h4>How it works</h4>
112
+ <p><%= @documentation[:how] %></p>
113
+ </div>
114
+ <% end %>
115
+
116
+ <% if @documentation[:nuances].present? && @documentation[:nuances].any? %>
117
+ <div class="doc-block">
118
+ <h4>Important nuances</h4>
119
+ <ul class="nuances-list">
120
+ <% @documentation[:nuances].each do |nuance| %>
121
+ <li><%= nuance %></li>
122
+ <% end %>
123
+ </ul>
124
+ </div>
125
+ <% end %>
126
+
127
+ <% if @thresholds.present? && @thresholds.any? %>
128
+ <div class="doc-block thresholds-block">
129
+ <h4>Thresholds</h4>
130
+ <div class="thresholds-grid">
131
+ <% @thresholds.each do |field, levels| %>
132
+ <div class="threshold-item">
133
+ <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>
138
+ <% end %>
139
+ </div>
140
+ <% end %>
141
+ </div>
142
+ </div>
143
+ <% end %>
144
+ </div>
145
+ </details>
146
+ <% end %>
147
+
148
+ <!-- Saved Records Section -->
149
+ <div class="saved-records-section" id="saved-records-section" style="display: none;">
150
+ <div class="saved-records-header">
151
+ <span class="saved-records-title">📌 Saved for Comparison</span>
152
+ <button class="btn btn-small btn-muted" onclick="clearAllSavedRecords()">Clear All</button>
153
+ </div>
154
+ <div class="saved-records-list" id="saved-records-list"></div>
155
+ </div>
156
+
50
157
  <div class="results-container" id="results-container">
51
158
  <div class="results-header">
52
159
  <span class="results-title">Results</span>
@@ -64,16 +171,193 @@
64
171
  <p>No issues found. Everything looks good!</p>
65
172
  </div>
66
173
 
67
- <div class="results-table-wrapper">
174
+ <div class="top-scroll-wrapper" id="top-scroll-wrapper" style="display: none;">
175
+ <div class="top-scroll-content" id="top-scroll-content"></div>
176
+ </div>
177
+ <div class="results-table-wrapper" id="results-table-wrapper">
68
178
  <table class="results-table" id="results-table">
69
179
  <thead id="results-head"></thead>
70
180
  <tbody id="results-body"></tbody>
71
181
  </table>
72
182
  </div>
73
183
  </div>
184
+
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>
74
231
  </div>
75
232
 
76
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 */
77
361
  .dropdown {
78
362
  position: relative;
79
363
  display: inline-block;
@@ -143,6 +427,149 @@
143
427
  color: var(--accent-purple);
144
428
  }
145
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
+
146
573
  /* Expanded row detail */
147
574
  .results-table tbody tr.detail-row {
148
575
  display: none;
@@ -211,6 +638,16 @@
211
638
  font-size: 0.75rem;
212
639
  }
213
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
+
214
651
  /* Source location badge in table */
215
652
  .source-badge {
216
653
  display: inline-block;
@@ -225,6 +662,22 @@
225
662
  max-width: 200px;
226
663
  overflow: hidden;
227
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;
228
681
  }
229
682
 
230
683
  .source-badge.empty {
@@ -264,51 +717,711 @@
264
717
  font-size: 0.7rem;
265
718
  color: var(--text-muted);
266
719
  }
267
- </style>
268
-
269
- <script>
270
- let currentReportData = null;
271
- const category = '<%= @category %>';
272
- const reportKey = '<%= @report_key %>';
273
720
 
274
- function toggleDropdown() {
275
- document.getElementById('dropdown-menu').classList.toggle('show');
721
+ /* IDE Link dropdown */
722
+ .ide-dropdown {
723
+ display: inline-block;
276
724
  }
277
725
 
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}`;
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;
289
735
  }
290
736
 
291
- function toggleRow(rowIndex) {
292
- const dataRow = document.getElementById(`data-row-${rowIndex}`);
293
- const detailRow = document.getElementById(`detail-row-${rowIndex}`);
737
+ .ide-dropdown-menu.show {
738
+ display: block;
739
+ }
294
740
 
295
- if (!dataRow || !detailRow) return;
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
+ }
296
749
 
297
- const isExpanded = dataRow.classList.contains('expanded');
750
+ .ide-dropdown-menu a:hover {
751
+ background: var(--bg-tertiary);
752
+ color: var(--text-primary);
753
+ }
298
754
 
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
- });
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
+ }
306
763
 
307
- // Toggle current row
308
- if (!isExpanded) {
309
- dataRow.classList.add('expanded');
310
- detailRow.classList.add('show');
311
- }
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>
1218
+
1219
+ <script>
1220
+ let currentReportData = null;
1221
+ const category = '<%= @category %>';
1222
+ const reportKey = '<%= @report_key %>';
1223
+ let syncingScroll = false;
1224
+ let currentSort = { column: null, direction: 'asc' };
1225
+
1226
+ // Top scrollbar sync functionality
1227
+ function setupTopScrollbar() {
1228
+ const topScrollWrapper = document.getElementById('top-scroll-wrapper');
1229
+ const topScrollContent = document.getElementById('top-scroll-content');
1230
+ const tableWrapper = document.getElementById('results-table-wrapper');
1231
+ const table = document.getElementById('results-table');
1232
+
1233
+ if (!topScrollWrapper || !topScrollContent || !tableWrapper || !table) return;
1234
+
1235
+ // Check if table overflows
1236
+ if (table.scrollWidth > tableWrapper.clientWidth) {
1237
+ topScrollWrapper.style.display = 'block';
1238
+ topScrollContent.style.width = table.scrollWidth + 'px';
1239
+
1240
+ // Sync scrolls
1241
+ topScrollWrapper.addEventListener('scroll', function() {
1242
+ if (syncingScroll) return;
1243
+ syncingScroll = true;
1244
+ tableWrapper.scrollLeft = topScrollWrapper.scrollLeft;
1245
+ syncingScroll = false;
1246
+ });
1247
+
1248
+ tableWrapper.addEventListener('scroll', function() {
1249
+ if (syncingScroll) return;
1250
+ syncingScroll = true;
1251
+ topScrollWrapper.scrollLeft = tableWrapper.scrollLeft;
1252
+ syncingScroll = false;
1253
+ });
1254
+ } else {
1255
+ topScrollWrapper.style.display = 'none';
1256
+ }
1257
+ }
1258
+
1259
+ // Sort data by column
1260
+ function sortData(data, column, direction) {
1261
+ return [...data].sort((a, b) => {
1262
+ let aVal = a[column];
1263
+ let bVal = b[column];
1264
+
1265
+ // Handle null/undefined
1266
+ if (aVal == null) aVal = '';
1267
+ if (bVal == null) bVal = '';
1268
+
1269
+ // Try numeric comparison
1270
+ const aNum = parseFloat(aVal);
1271
+ const bNum = parseFloat(bVal);
1272
+ if (!isNaN(aNum) && !isNaN(bNum)) {
1273
+ return direction === 'asc' ? aNum - bNum : bNum - aNum;
1274
+ }
1275
+
1276
+ // String comparison
1277
+ const aStr = String(aVal).toLowerCase();
1278
+ const bStr = String(bVal).toLowerCase();
1279
+ if (direction === 'asc') {
1280
+ return aStr.localeCompare(bStr);
1281
+ } else {
1282
+ return bStr.localeCompare(aStr);
1283
+ }
1284
+ });
1285
+ }
1286
+
1287
+ // Handle column header click for sorting
1288
+ function handleSortClick(column) {
1289
+ if (!currentReportData || !currentReportData.data) return;
1290
+
1291
+ // Toggle direction if same column, otherwise start with asc
1292
+ if (currentSort.column === column) {
1293
+ currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
1294
+ } else {
1295
+ currentSort.column = column;
1296
+ currentSort.direction = 'asc';
1297
+ }
1298
+
1299
+ // Sort and re-render
1300
+ const sortedData = sortData(currentReportData.data, column, currentSort.direction);
1301
+ renderTableBody(sortedData, currentReportData.columns, currentReportData.thresholds, currentReportData.problem_fields);
1302
+ updateSortIndicators();
1303
+ }
1304
+
1305
+ // Update sort indicators in headers
1306
+ function updateSortIndicators() {
1307
+ document.querySelectorAll('.results-table th').forEach(th => {
1308
+ th.classList.remove('sorted');
1309
+ const indicator = th.querySelector('.sort-indicator');
1310
+ if (indicator) {
1311
+ indicator.textContent = '↕';
1312
+ }
1313
+ });
1314
+
1315
+ if (currentSort.column) {
1316
+ const sortedTh = document.querySelector(`.results-table th[data-column="${currentSort.column}"]`);
1317
+ if (sortedTh) {
1318
+ sortedTh.classList.add('sorted');
1319
+ const indicator = sortedTh.querySelector('.sort-indicator');
1320
+ if (indicator) {
1321
+ indicator.textContent = currentSort.direction === 'asc' ? '↑' : '↓';
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+
1327
+ // Render table body (extracted for reuse after sorting)
1328
+ function renderTableBody(data, columns, thresholds, problemFields) {
1329
+ const tableBody = document.getElementById('results-body');
1330
+ if (!tableBody) return;
1331
+
1332
+ let rowsHtml = '';
1333
+
1334
+ data.forEach((row, idx) => {
1335
+ // Check for problems
1336
+ const problemInfo = getRowProblemLevel(row, thresholds, problemFields);
1337
+ let rowClass = 'data-row';
1338
+ let problemIndicator = '';
1339
+
1340
+ if (problemInfo && problemInfo.level) {
1341
+ rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
1342
+ const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
1343
+ const indicatorIcon = problemInfo.level === 'critical' ? '🔴' : 'âš ī¸';
1344
+ 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>`;
1345
+ }
1346
+
1347
+ // Data row
1348
+ rowsHtml += `<tr id="data-row-${idx}" class="${rowClass}" onclick="toggleRow(${idx})">`;
1349
+ rowsHtml += columns.map((col, colIdx) => {
1350
+ const value = row[col] ?? '';
1351
+ const strValue = String(value);
1352
+ const isQuery = col === 'query';
1353
+ const isSource = col === 'source';
1354
+
1355
+ if (isSource) {
1356
+ return `<td>${buildSourceBadge(strValue)}</td>`;
1357
+ }
1358
+
1359
+ const displayValue = strValue.length > 80 ? strValue.substring(0, 80) + '...' : strValue;
1360
+ // Add problem indicator to first column
1361
+ const indicator = colIdx === 0 ? problemIndicator : '';
1362
+ return `<td class="${isQuery ? 'query-cell' : ''}">${escapeHtml(displayValue)}${indicator}</td>`;
1363
+ }).join('');
1364
+ rowsHtml += '</tr>';
1365
+
1366
+ // Detail row (hidden by default)
1367
+ rowsHtml += `<tr id="detail-row-${idx}" class="detail-row">`;
1368
+ rowsHtml += `<td colspan="${columns.length}">${buildDetailRow(row, columns, idx, thresholds, problemFields)}</td>`;
1369
+ rowsHtml += '</tr>';
1370
+ });
1371
+
1372
+ tableBody.innerHTML = rowsHtml;
1373
+
1374
+ // Re-setup top scrollbar
1375
+ setTimeout(setupTopScrollbar, 0);
1376
+ }
1377
+
1378
+ // Problem explanations from I18n (passed from server)
1379
+ const problemExplanations = <%= raw(I18n.t('pg_reports.problems').to_json) %>;
1380
+
1381
+ function toggleDropdown() {
1382
+ document.getElementById('dropdown-menu').classList.toggle('show');
1383
+ }
1384
+
1385
+ // Close dropdown when clicking outside
1386
+ document.addEventListener('click', function(e) {
1387
+ const dropdown = document.getElementById('download-dropdown');
1388
+ if (dropdown && !dropdown.contains(e.target)) {
1389
+ document.getElementById('dropdown-menu')?.classList.remove('show');
1390
+ }
1391
+ // Close IDE dropdowns
1392
+ document.querySelectorAll('.ide-dropdown-menu.show').forEach(menu => {
1393
+ if (!menu.parentElement.contains(e.target)) {
1394
+ menu.classList.remove('show');
1395
+ }
1396
+ });
1397
+ });
1398
+
1399
+ function downloadReport(format) {
1400
+ document.getElementById('dropdown-menu').classList.remove('show');
1401
+ window.location.href = `${pgReportsRoot}/${category}/${reportKey}/download?format=${format}`;
1402
+ }
1403
+
1404
+ function toggleRow(rowIndex) {
1405
+ const dataRow = document.getElementById(`data-row-${rowIndex}`);
1406
+ const detailRow = document.getElementById(`detail-row-${rowIndex}`);
1407
+
1408
+ if (!dataRow || !detailRow) return;
1409
+
1410
+ const isExpanded = dataRow.classList.contains('expanded');
1411
+
1412
+ // Collapse all other rows
1413
+ document.querySelectorAll('.data-row.expanded').forEach(row => {
1414
+ row.classList.remove('expanded');
1415
+ });
1416
+ document.querySelectorAll('.detail-row.show').forEach(row => {
1417
+ row.classList.remove('show');
1418
+ });
1419
+
1420
+ // Toggle current row
1421
+ if (!isExpanded) {
1422
+ dataRow.classList.add('expanded');
1423
+ detailRow.classList.add('show');
1424
+ }
312
1425
  }
313
1426
 
314
1427
  function escapeHtml(text) {
@@ -317,6 +1430,20 @@
317
1430
  return div.innerHTML;
318
1431
  }
319
1432
 
1433
+ function escapeHtmlAttr(text) {
1434
+ return String(text)
1435
+ .replace(/&/g, '&amp;')
1436
+ .replace(/"/g, '&quot;')
1437
+ .replace(/'/g, '&#39;')
1438
+ .replace(/</g, '&lt;')
1439
+ .replace(/>/g, '&gt;');
1440
+ }
1441
+
1442
+ function copyQueryFromButton(btn) {
1443
+ const query = btn.dataset.query;
1444
+ copyToClipboard(query, btn);
1445
+ }
1446
+
320
1447
  function copyToClipboard(text, btn) {
321
1448
  navigator.clipboard.writeText(text).then(() => {
322
1449
  const originalText = btn.textContent;
@@ -335,8 +1462,275 @@
335
1462
  });
336
1463
  }
337
1464
 
338
- function buildDetailRow(row, columns, rowIndex) {
1465
+ // Check if a value exceeds threshold
1466
+ function checkThreshold(value, threshold, inverted = false) {
1467
+ if (!threshold || value === null || value === undefined) return null;
1468
+
1469
+ const numValue = parseFloat(value);
1470
+ if (isNaN(numValue)) return null;
1471
+
1472
+ if (inverted) {
1473
+ // For inverted thresholds (lower is worse), like cache_hit_ratio
1474
+ if (numValue <= threshold.critical) return 'critical';
1475
+ if (numValue <= threshold.warning) return 'warning';
1476
+ } else {
1477
+ // Normal thresholds (higher is worse)
1478
+ if (numValue >= threshold.critical) return 'critical';
1479
+ if (numValue >= threshold.warning) return 'warning';
1480
+ }
1481
+ return null;
1482
+ }
1483
+
1484
+ // Get row problem level
1485
+ function getRowProblemLevel(row, thresholds, problemFields) {
1486
+ if (!thresholds || !problemFields || problemFields.length === 0) return null;
1487
+
1488
+ let maxLevel = null;
1489
+ const problems = [];
1490
+
1491
+ for (const field of problemFields) {
1492
+ const value = row[field];
1493
+ const threshold = thresholds[field];
1494
+ if (!threshold) continue;
1495
+
1496
+ const level = checkThreshold(value, threshold, threshold.inverted);
1497
+ if (level) {
1498
+ problems.push({ field, value, level, threshold });
1499
+ if (level === 'critical') maxLevel = 'critical';
1500
+ else if (level === 'warning' && maxLevel !== 'critical') maxLevel = 'warning';
1501
+ }
1502
+ }
1503
+
1504
+ return { level: maxLevel, problems };
1505
+ }
1506
+
1507
+ // Show problem modal
1508
+ function showProblemModal(problems, row) {
1509
+ const modal = document.getElementById('problem-modal');
1510
+ const body = document.getElementById('problem-modal-body');
1511
+
1512
+ let html = '<div class="problem-details">';
1513
+
1514
+ for (const problem of problems) {
1515
+ const levelClass = problem.level === 'critical' ? 'critical' : 'warning';
1516
+ const levelText = problem.level === 'critical' ? '🔴 Critical' : 'âš ī¸ Warning';
1517
+
1518
+ html += `
1519
+ <div class="problem-field">
1520
+ <span class="problem-field-label">${escapeHtml(problem.field)} (${levelText})</span>
1521
+ <div class="problem-field-value ${levelClass}">
1522
+ Current: ${escapeHtml(String(problem.value))}
1523
+ <br>Threshold: warning=${problem.threshold.warning}, critical=${problem.threshold.critical}
1524
+ ${problem.threshold.inverted ? '<br><em>(inverted: lower values are worse)</em>' : ''}
1525
+ </div>
1526
+ </div>
1527
+ `;
1528
+ }
1529
+
1530
+ // Add explanation based on problem types
1531
+ const explanationKey = getExplanationKey(problems);
1532
+ const explanation = problemExplanations[explanationKey] || '';
1533
+
1534
+ if (explanation) {
1535
+ html += `
1536
+ <div class="problem-explanation">
1537
+ <h4>💡 Recommendation</h4>
1538
+ <p>${escapeHtml(explanation)}</p>
1539
+ </div>
1540
+ `;
1541
+ }
1542
+
1543
+ html += '</div>';
1544
+ body.innerHTML = html;
1545
+ modal.style.display = 'flex';
1546
+ }
1547
+
1548
+ function closeProblemModal() {
1549
+ document.getElementById('problem-modal').style.display = 'none';
1550
+ }
1551
+
1552
+ // Map problem fields to explanation keys
1553
+ function getExplanationKey(problems) {
1554
+ const fields = problems.map(p => p.field);
1555
+
1556
+ if (fields.includes('mean_time_ms')) return 'high_mean_time';
1557
+ if (fields.includes('calls')) return 'high_calls';
1558
+ if (fields.includes('total_time_ms')) return 'high_total_time';
1559
+ if (fields.includes('cache_hit_ratio')) return 'low_cache_hit';
1560
+ if (fields.includes('seq_scan') || fields.includes('seq_tup_read')) return 'high_seq_scan';
1561
+ if (fields.includes('idx_scan')) return 'unused_index';
1562
+ if (fields.includes('bloat_ratio') || fields.includes('bloat_size')) return 'high_bloat';
1563
+ if (fields.includes('dead_tuple_ratio') || fields.includes('n_dead_tup')) return 'many_dead_tuples';
1564
+ if (fields.includes('duration_seconds') || fields.includes('duration')) return 'long_running';
1565
+ if (fields.includes('blocked_count')) return 'blocking';
1566
+ if (fields.includes('idle_in_transaction')) return 'idle_in_transaction';
1567
+
1568
+ return '';
1569
+ }
1570
+
1571
+ // Parse source location and generate IDE link
1572
+ function parseSourceLocation(source) {
1573
+ if (!source || source === 'null' || source === '') return null;
1574
+
1575
+ // Common patterns:
1576
+ // app/controllers/users_controller.rb:42
1577
+ // app/models/user.rb:123:in `find'
1578
+ // UsersController#index
1579
+ // app/controllers/users_controller.rb:42:in `index'
1580
+
1581
+ let filePath = null;
1582
+ let lineNumber = null;
1583
+ let methodName = null;
1584
+
1585
+ // Try to match file:line pattern
1586
+ const fileLineMatch = source.match(/^([^:]+\.(rb|erb|js|ts|py|go|java)):(\d+)/);
1587
+ if (fileLineMatch) {
1588
+ filePath = fileLineMatch[1];
1589
+ lineNumber = parseInt(fileLineMatch[3], 10);
1590
+ }
1591
+
1592
+ // Try to match Controller#action pattern
1593
+ const controllerMatch = source.match(/^(\w+Controller)#(\w+)/);
1594
+ if (controllerMatch) {
1595
+ methodName = `${controllerMatch[1]}#${controllerMatch[2]}`;
1596
+ // Derive file path from controller name
1597
+ const controllerName = controllerMatch[1].replace(/Controller$/, '').toLowerCase();
1598
+ filePath = `app/controllers/${controllerName}_controller.rb`;
1599
+ }
1600
+
1601
+ return { filePath, lineNumber, methodName, original: source };
1602
+ }
1603
+
1604
+ // Rails.root path for IDE links
1605
+ const railsRootPath = '<%= Rails.root.to_s %>';
1606
+ const wslDistro = 'Ubuntu';
1607
+
1608
+ // Generate IDE URLs
1609
+ function generateIdeUrls(filePath, lineNumber) {
1610
+ if (!filePath) return [];
1611
+
1612
+ // Convert relative paths to absolute paths using Rails.root
1613
+ let absolutePath = filePath;
1614
+ if (!filePath.startsWith('/')) {
1615
+ absolutePath = `${railsRootPath}/${filePath}`;
1616
+ }
1617
+
1618
+ const line = lineNumber || 1;
1619
+ const urls = [];
1620
+
1621
+ // VSCode (WSL Remote format)
1622
+ urls.push({
1623
+ name: 'VS Code (WSL)',
1624
+ url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1625
+ });
1626
+
1627
+ // VSCode (direct path - for native Linux or Windows)
1628
+ urls.push({
1629
+ name: 'VS Code',
1630
+ url: `vscode://file${absolutePath}:${line}`
1631
+ });
1632
+
1633
+ // JetBrains (RubyMine, IntelliJ, etc.)
1634
+ urls.push({
1635
+ name: 'RubyMine',
1636
+ url: `x-mine://open?file=${absolutePath}&line=${line}`
1637
+ });
1638
+
1639
+ urls.push({
1640
+ name: 'IntelliJ',
1641
+ url: `idea://open?file=${absolutePath}&line=${line}`
1642
+ });
1643
+
1644
+ // Cursor (WSL Remote format)
1645
+ urls.push({
1646
+ name: 'Cursor (WSL)',
1647
+ url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
1648
+ });
1649
+
1650
+ // Cursor (direct path)
1651
+ urls.push({
1652
+ name: 'Cursor',
1653
+ url: `cursor://file${absolutePath}:${line}`
1654
+ });
1655
+
1656
+ return urls;
1657
+ }
1658
+
1659
+ // Map IDE key to URL index
1660
+ const ideKeyMap = {
1661
+ 'vscode-wsl': 0,
1662
+ 'vscode': 1,
1663
+ 'rubymine': 2,
1664
+ 'intellij': 3,
1665
+ 'cursor-wsl': 4,
1666
+ 'cursor': 5
1667
+ };
1668
+
1669
+ // Build source badge HTML with IDE links
1670
+ function buildSourceBadge(source) {
1671
+ const parsed = parseSourceLocation(source);
1672
+
1673
+ if (!parsed) {
1674
+ return `<span class="source-badge empty">—</span>`;
1675
+ }
1676
+
1677
+ const ideUrls = generateIdeUrls(parsed.filePath, parsed.lineNumber);
1678
+
1679
+ if (ideUrls.length === 0) {
1680
+ return `<span class="source-badge" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>`;
1681
+ }
1682
+
1683
+ // Check if user has a default IDE set
1684
+ const defaultIde = getDefaultIde();
1685
+ if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
1686
+ const ideUrl = ideUrls[ideKeyMap[defaultIde]];
1687
+ if (ideUrl) {
1688
+ return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</a>`;
1689
+ }
1690
+ }
1691
+
1692
+ // No default IDE - show dropdown menu
1693
+ const dropdownId = `ide-dropdown-${Math.random().toString(36).substr(2, 9)}`;
1694
+
1695
+ let dropdownHtml = `
1696
+ <div class="ide-dropdown">
1697
+ <span class="source-badge clickable" onclick="event.stopPropagation(); toggleIdeDropdown('${dropdownId}', this)" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>
1698
+ <div class="ide-dropdown-menu" id="${dropdownId}">
1699
+ `;
1700
+
1701
+ for (const ide of ideUrls) {
1702
+ dropdownHtml += `<a href="${ide.url}" onclick="event.stopPropagation();">${ide.name}</a>`;
1703
+ }
1704
+
1705
+ dropdownHtml += '</div></div>';
1706
+ return dropdownHtml;
1707
+ }
1708
+
1709
+ function toggleIdeDropdown(dropdownId, badgeElement) {
1710
+ const menu = document.getElementById(dropdownId);
1711
+ if (!menu) return;
1712
+
1713
+ // Close other dropdowns
1714
+ document.querySelectorAll('.ide-dropdown-menu.show').forEach(m => {
1715
+ if (m.id !== dropdownId) m.classList.remove('show');
1716
+ });
1717
+
1718
+ // Toggle current menu
1719
+ const isShowing = menu.classList.toggle('show');
1720
+
1721
+ // Position the menu using fixed positioning
1722
+ if (isShowing && badgeElement) {
1723
+ const rect = badgeElement.getBoundingClientRect();
1724
+ menu.style.top = (rect.bottom + 4) + 'px';
1725
+ menu.style.left = rect.left + 'px';
1726
+ }
1727
+ }
1728
+
1729
+ function buildDetailRow(row, columns, rowIndex, thresholds, problemFields) {
339
1730
  let html = '<div class="row-detail">';
1731
+ const hasQuery = columns.includes('query') && row.query;
1732
+ const hasIndexName = columns.includes('index_name') && row.index_name;
1733
+ const rowId = generateRowId(row);
340
1734
 
341
1735
  columns.forEach(col => {
342
1736
  const value = row[col] ?? '';
@@ -345,10 +1739,19 @@
345
1739
  const isSource = col === 'source';
346
1740
  const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
347
1741
 
1742
+ // Check if this field has a problem
1743
+ let problemClass = '';
1744
+ if (problemFields && problemFields.includes(col) && thresholds && thresholds[col]) {
1745
+ const level = checkThreshold(value, thresholds[col], thresholds[col].inverted);
1746
+ if (level === 'critical') problemClass = 'problem-critical';
1747
+ else if (level === 'warning') problemClass = 'problem-warning';
1748
+ }
1749
+
348
1750
  let valueClass = '';
349
1751
  if (isQuery) valueClass = 'query';
350
1752
  else if (isSource) valueClass = 'source';
351
1753
  else if (isNumber) valueClass = 'number';
1754
+ if (problemClass) valueClass += ' ' + problemClass;
352
1755
 
353
1756
  const isLongText = strValue.length > 100 || isQuery;
354
1757
 
@@ -361,11 +1764,33 @@
361
1764
  <div class="row-detail-item${isLongText ? ' full-width' : ''}">
362
1765
  <span class="row-detail-label">${escapeHtml(col)}</span>
363
1766
  <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>` : ''}
1767
+ ${isQuery ? `<button class="copy-btn" data-query="${escapeHtmlAttr(strValue)}" onclick="event.stopPropagation(); copyQueryFromButton(this)">📋 Copy Query</button>` : ''}
365
1768
  </div>
366
1769
  `;
367
1770
  });
368
1771
 
1772
+ // Action buttons
1773
+ const isSaved = isRecordSaved(rowId);
1774
+ const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
1775
+
1776
+ html += `<div class="detail-actions">`;
1777
+ html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${isSaved ? '📌 Saved' : '📌 Save for Comparison'}</button>`;
1778
+
1779
+ if (hasQuery) {
1780
+ const queryEscaped = row.query.replace(/'/g, "\\'").replace(/"/g, '&quot;');
1781
+ html += `<button class="btn-explain" onclick="event.stopPropagation(); runExplainAnalyze('${queryEscaped}')">📊 EXPLAIN ANALYZE</button>`;
1782
+ }
1783
+
1784
+ // Show migration button for index reports
1785
+ if (hasIndexName && (category === 'indexes')) {
1786
+ const indexName = row.index_name;
1787
+ const tableName = row.table_name || row.tablename || '';
1788
+ const schemaName = row.schema_name || row.schemaname || 'public';
1789
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">đŸ—‘ī¸ Generate Migration</button>`;
1790
+ }
1791
+
1792
+ html += `</div>`;
1793
+
369
1794
  html += '</div>';
370
1795
  return html;
371
1796
  }
@@ -405,6 +1830,8 @@
405
1830
 
406
1831
  if (data.success) {
407
1832
  currentReportData = data;
1833
+ const thresholds = data.thresholds || {};
1834
+ const problemFields = data.problem_fields || [];
408
1835
 
409
1836
  if (metaEl) {
410
1837
  metaEl.innerHTML = `
@@ -421,47 +1848,18 @@
421
1848
  if (data.data.length === 0) {
422
1849
  if (emptyEl) emptyEl.style.display = 'block';
423
1850
  } else {
424
- // Build table header
1851
+ // Reset sort state for new data
1852
+ currentSort = { column: null, direction: 'asc' };
1853
+
1854
+ // Build table header with sortable columns
425
1855
  if (tableHead) {
426
1856
  tableHead.innerHTML = '<tr>' + data.columns.map(col =>
427
- `<th>${escapeHtml(col)}</th>`
1857
+ `<th class="sortable" data-column="${escapeHtml(col)}" onclick="handleSortClick('${escapeHtml(col)}')">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
428
1858
  ).join('') + '</tr>';
429
1859
  }
430
1860
 
431
1861
  // 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
- }
1862
+ renderTableBody(data.data, data.columns, thresholds, problemFields);
465
1863
  }
466
1864
 
467
1865
  showToast('Report generated successfully');
@@ -478,4 +1876,452 @@
478
1876
  button.innerHTML = 'â–ļ Run Report';
479
1877
  }
480
1878
  }
1879
+
1880
+ // Close modal on Escape key
1881
+ document.addEventListener('keydown', function(e) {
1882
+ if (e.key === 'Escape') {
1883
+ closeProblemModal();
1884
+ }
1885
+ });
1886
+
1887
+ // Close modal on backdrop click
1888
+ document.getElementById('problem-modal')?.addEventListener('click', function(e) {
1889
+ if (e.target === this) {
1890
+ closeProblemModal();
1891
+ }
1892
+ });
1893
+
1894
+ // IDE Settings functions
1895
+ function showIdeSettingsModal() {
1896
+ const modal = document.getElementById('ide-settings-modal');
1897
+ modal.style.display = 'flex';
1898
+ loadIdeSettingsState();
1899
+ }
1900
+
1901
+ function closeIdeSettingsModal() {
1902
+ document.getElementById('ide-settings-modal').style.display = 'none';
1903
+ }
1904
+
1905
+ function setDefaultIde(ideKey) {
1906
+ if (ideKey) {
1907
+ localStorage.setItem('pgReportsDefaultIde', ideKey);
1908
+ } else {
1909
+ localStorage.removeItem('pgReportsDefaultIde');
1910
+ }
1911
+ }
1912
+
1913
+ function getDefaultIde() {
1914
+ return localStorage.getItem('pgReportsDefaultIde') || '';
1915
+ }
1916
+
1917
+ function loadIdeSettingsState() {
1918
+ const currentIde = getDefaultIde();
1919
+ const radios = document.querySelectorAll('input[name="default-ide"]');
1920
+ radios.forEach(radio => {
1921
+ radio.checked = (radio.value === currentIde);
1922
+ });
1923
+ }
1924
+
1925
+ // Close IDE settings modal on backdrop click
1926
+ document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
1927
+ if (e.target === this) {
1928
+ closeIdeSettingsModal();
1929
+ }
1930
+ });
1931
+
1932
+ // Close IDE settings modal on Escape key
1933
+ document.addEventListener('keydown', function(e) {
1934
+ if (e.key === 'Escape') {
1935
+ closeIdeSettingsModal();
1936
+ closeExplainModal();
1937
+ closeMigrationModal();
1938
+ }
1939
+ });
1940
+
1941
+ // ==========================================
1942
+ // SAVED RECORDS FUNCTIONALITY
1943
+ // ==========================================
1944
+
1945
+ function getSavedRecordsKey() {
1946
+ return `pgReports_saved_${category}_${reportKey}`;
1947
+ }
1948
+
1949
+ function getSavedRecords() {
1950
+ try {
1951
+ const data = localStorage.getItem(getSavedRecordsKey());
1952
+ return data ? JSON.parse(data) : [];
1953
+ } catch (e) {
1954
+ return [];
1955
+ }
1956
+ }
1957
+
1958
+ function saveSavedRecords(records) {
1959
+ localStorage.setItem(getSavedRecordsKey(), JSON.stringify(records));
1960
+ }
1961
+
1962
+ function generateRowId(row) {
1963
+ // Generate a unique ID based on key fields
1964
+ const keyFields = ['query', 'queryid', 'index_name', 'table_name', 'pid', 'datname'];
1965
+ const parts = [];
1966
+ for (const field of keyFields) {
1967
+ if (row[field]) {
1968
+ parts.push(String(row[field]).substring(0, 50));
1969
+ }
1970
+ }
1971
+ // Simple hash
1972
+ const str = parts.join('|');
1973
+ let hash = 0;
1974
+ for (let i = 0; i < str.length; i++) {
1975
+ const char = str.charCodeAt(i);
1976
+ hash = ((hash << 5) - hash) + char;
1977
+ hash = hash & hash;
1978
+ }
1979
+ return 'r' + Math.abs(hash).toString(36);
1980
+ }
1981
+
1982
+ function isRecordSaved(rowId) {
1983
+ const saved = getSavedRecords();
1984
+ return saved.some(r => r.id === rowId);
1985
+ }
1986
+
1987
+ function toggleSaveRecord(rowId, row, btn) {
1988
+ const saved = getSavedRecords();
1989
+ const existingIdx = saved.findIndex(r => r.id === rowId);
1990
+
1991
+ if (existingIdx >= 0) {
1992
+ // Remove
1993
+ saved.splice(existingIdx, 1);
1994
+ btn.classList.remove('saved');
1995
+ btn.textContent = '📌 Save for Comparison';
1996
+ showToast('Record removed from saved');
1997
+ } else {
1998
+ // Add
1999
+ saved.unshift({
2000
+ id: rowId,
2001
+ savedAt: new Date().toISOString(),
2002
+ data: row
2003
+ });
2004
+ btn.classList.add('saved');
2005
+ btn.textContent = '📌 Saved';
2006
+ showToast('Record saved for comparison');
2007
+ }
2008
+
2009
+ saveSavedRecords(saved);
2010
+ renderSavedRecords();
2011
+ }
2012
+
2013
+ function removeSavedRecord(rowId) {
2014
+ const saved = getSavedRecords();
2015
+ const filtered = saved.filter(r => r.id !== rowId);
2016
+ saveSavedRecords(filtered);
2017
+ renderSavedRecords();
2018
+
2019
+ // Update button in table if visible
2020
+ const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
2021
+ if (btn) {
2022
+ btn.classList.remove('saved');
2023
+ btn.textContent = '📌 Save for Comparison';
2024
+ }
2025
+
2026
+ showToast('Record removed');
2027
+ }
2028
+
2029
+ function clearAllSavedRecords() {
2030
+ if (!confirm('Remove all saved records for this report?')) return;
2031
+ saveSavedRecords([]);
2032
+ renderSavedRecords();
2033
+
2034
+ // Update all buttons in table
2035
+ document.querySelectorAll('.btn-save.saved').forEach(btn => {
2036
+ btn.classList.remove('saved');
2037
+ btn.textContent = '📌 Save for Comparison';
2038
+ });
2039
+
2040
+ showToast('All saved records cleared');
2041
+ }
2042
+
2043
+ function renderSavedRecords() {
2044
+ const section = document.getElementById('saved-records-section');
2045
+ const list = document.getElementById('saved-records-list');
2046
+ const saved = getSavedRecords();
2047
+
2048
+ if (saved.length === 0) {
2049
+ section.style.display = 'none';
2050
+ return;
2051
+ }
2052
+
2053
+ section.style.display = 'block';
2054
+
2055
+ // Fields to highlight for comparison
2056
+ const highlightFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows', 'shared_blks_hit', 'shared_blks_read'];
2057
+ // Key metrics to show in summary
2058
+ const summaryFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows'];
2059
+
2060
+ let html = '';
2061
+ saved.forEach((record, idx) => {
2062
+ const savedTime = new Date(record.savedAt).toLocaleString();
2063
+ const data = record.data;
2064
+ const hasQuery = data.query;
2065
+
2066
+ html += `
2067
+ <div class="saved-record-card" id="saved-card-${idx}" onclick="toggleSavedRecordDetail(${idx}, event)">
2068
+ <div class="saved-record-header">
2069
+ <span class="saved-record-time">▸ Saved: ${savedTime}</span>
2070
+ <span class="saved-record-expand-hint">Click to expand</span>
2071
+ <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="Remove">×</button>
2072
+ </div>
2073
+ <div class="saved-record-data">
2074
+ `;
2075
+
2076
+ // Show key metrics in summary
2077
+ summaryFields.forEach(field => {
2078
+ const value = data[field];
2079
+ if (value === null || value === undefined) return;
2080
+ const strValue = String(value);
2081
+ html += `
2082
+ <div class="saved-record-field">
2083
+ <span class="saved-record-field-name">${escapeHtml(field)}</span>
2084
+ <span class="saved-record-field-value highlight">${escapeHtml(strValue)}</span>
2085
+ </div>
2086
+ `;
2087
+ });
2088
+
2089
+ // Show truncated query preview
2090
+ if (hasQuery) {
2091
+ const queryPreview = data.query.length > 100 ? data.query.substring(0, 100) + '...' : data.query;
2092
+ html += `
2093
+ <div class="saved-record-field" style="grid-column: 1 / -1;">
2094
+ <span class="saved-record-field-name">query</span>
2095
+ <span class="saved-record-field-value" style="color: var(--accent-green); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(queryPreview)}</span>
2096
+ </div>
2097
+ `;
2098
+ }
2099
+
2100
+ // Expandable detail section
2101
+ html += `
2102
+ </div>
2103
+ <div class="saved-record-detail">
2104
+ <div class="saved-record-detail-grid">
2105
+ `;
2106
+
2107
+ // Show all fields in detail
2108
+ Object.keys(data).forEach(field => {
2109
+ if (field === 'source') return;
2110
+ const value = data[field];
2111
+ if (value === null || value === undefined) return;
2112
+ const strValue = String(value);
2113
+ const isQuery = field === 'query';
2114
+ const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
2115
+
2116
+ let valueClass = '';
2117
+ if (isQuery) valueClass = 'query';
2118
+ else if (isNumber) valueClass = 'number';
2119
+
2120
+ html += `
2121
+ <div class="saved-record-detail-item ${isQuery ? 'full-width' : ''}">
2122
+ <span class="saved-record-detail-label">${escapeHtml(field)}</span>
2123
+ <div class="saved-record-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
2124
+ </div>
2125
+ `;
2126
+ });
2127
+
2128
+ html += `
2129
+ </div>
2130
+ </div>
2131
+ </div>
2132
+ `;
2133
+ });
2134
+
2135
+ list.innerHTML = html;
2136
+ }
2137
+
2138
+ function toggleSavedRecordDetail(idx, event) {
2139
+ // Don't toggle if clicking on remove button
2140
+ if (event.target.classList.contains('saved-record-remove')) return;
2141
+
2142
+ const card = document.getElementById(`saved-card-${idx}`);
2143
+ if (!card) return;
2144
+
2145
+ const wasExpanded = card.classList.contains('expanded');
2146
+
2147
+ // Collapse all cards
2148
+ document.querySelectorAll('.saved-record-card.expanded').forEach(c => {
2149
+ c.classList.remove('expanded');
2150
+ const hint = c.querySelector('.saved-record-time');
2151
+ if (hint) hint.textContent = hint.textContent.replace('▾', '▸');
2152
+ });
2153
+
2154
+ // Toggle current card
2155
+ if (!wasExpanded) {
2156
+ card.classList.add('expanded');
2157
+ const hint = card.querySelector('.saved-record-time');
2158
+ if (hint) hint.textContent = hint.textContent.replace('▸', '▾');
2159
+ }
2160
+ }
2161
+
2162
+ // Render saved records on page load
2163
+ document.addEventListener('DOMContentLoaded', renderSavedRecords);
2164
+
2165
+ // ==========================================
2166
+ // EXPLAIN ANALYZE FUNCTIONALITY
2167
+ // ==========================================
2168
+
2169
+ let currentExplainQuery = '';
2170
+
2171
+ async function runExplainAnalyze(query) {
2172
+ currentExplainQuery = query;
2173
+ const modal = document.getElementById('explain-modal');
2174
+ const loading = document.getElementById('explain-loading');
2175
+ const content = document.getElementById('explain-content');
2176
+
2177
+ modal.style.display = 'flex';
2178
+ loading.style.display = 'flex';
2179
+ content.innerHTML = '';
2180
+
2181
+ try {
2182
+ const response = await fetch(`${pgReportsRoot}/explain_analyze`, {
2183
+ method: 'POST',
2184
+ headers: {
2185
+ 'Content-Type': 'application/json',
2186
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
2187
+ },
2188
+ body: JSON.stringify({ query: query })
2189
+ });
2190
+
2191
+ const data = await response.json();
2192
+ loading.style.display = 'none';
2193
+
2194
+ if (data.success) {
2195
+ let html = '';
2196
+
2197
+ // Show stats if available
2198
+ if (data.stats) {
2199
+ html += '<div class="explain-stats">';
2200
+ if (data.stats.planning_time) {
2201
+ 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>`;
2202
+ }
2203
+ if (data.stats.execution_time) {
2204
+ 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>`;
2205
+ }
2206
+ if (data.stats.total_cost) {
2207
+ html += `<div class="explain-stat"><span class="explain-stat-label">Total Cost</span><span class="explain-stat-value">${data.stats.total_cost}</span></div>`;
2208
+ }
2209
+ if (data.stats.rows) {
2210
+ html += `<div class="explain-stat"><span class="explain-stat-label">Rows</span><span class="explain-stat-value">${data.stats.rows}</span></div>`;
2211
+ }
2212
+ html += '</div>';
2213
+ }
2214
+
2215
+ html += `<div class="explain-result">${escapeHtml(data.explain)}</div>`;
2216
+ content.innerHTML = html;
2217
+ } else {
2218
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to run EXPLAIN ANALYZE')}</div>`;
2219
+ }
2220
+ } catch (error) {
2221
+ loading.style.display = 'none';
2222
+ content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
2223
+ }
2224
+ }
2225
+
2226
+ function closeExplainModal() {
2227
+ document.getElementById('explain-modal').style.display = 'none';
2228
+ }
2229
+
2230
+ document.getElementById('explain-modal')?.addEventListener('click', function(e) {
2231
+ if (e.target === this) closeExplainModal();
2232
+ });
2233
+
2234
+ // ==========================================
2235
+ // MIGRATION GENERATION FUNCTIONALITY
2236
+ // ==========================================
2237
+
2238
+ let currentMigrationData = null;
2239
+
2240
+ function showMigrationModal(indexName, tableName, schemaName) {
2241
+ const migrationName = `remove_${indexName.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
2242
+ const timestamp = new Date().toISOString().replace(/[-:T]/g, '').substring(0, 14);
2243
+ const className = migrationName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
2244
+
2245
+ const fullIndexName = schemaName && schemaName !== 'public'
2246
+ ? `${schemaName}.${indexName}`
2247
+ : indexName;
2248
+
2249
+ const migrationCode = `# frozen_string_literal: true
2250
+
2251
+ class ${className} < ActiveRecord::Migration[7.0]
2252
+ def change
2253
+ remove_index :${tableName}, name: :${indexName}, if_exists: true
2254
+ end
2255
+ end
2256
+ `;
2257
+
2258
+ currentMigrationData = {
2259
+ fileName: `${timestamp}_${migrationName}.rb`,
2260
+ code: migrationCode,
2261
+ indexName,
2262
+ tableName,
2263
+ schemaName
2264
+ };
2265
+
2266
+ document.getElementById('migration-code').textContent = migrationCode;
2267
+ document.getElementById('migration-modal').style.display = 'flex';
2268
+ }
2269
+
2270
+ function closeMigrationModal() {
2271
+ document.getElementById('migration-modal').style.display = 'none';
2272
+ }
2273
+
2274
+ document.getElementById('migration-modal')?.addEventListener('click', function(e) {
2275
+ if (e.target === this) closeMigrationModal();
2276
+ });
2277
+
2278
+ function copyMigrationCode() {
2279
+ if (!currentMigrationData) return;
2280
+ navigator.clipboard.writeText(currentMigrationData.code).then(() => {
2281
+ showToast('Migration code copied to clipboard');
2282
+ }).catch(() => {
2283
+ showToast('Failed to copy', 'error');
2284
+ });
2285
+ }
2286
+
2287
+ async function createMigrationFile() {
2288
+ if (!currentMigrationData) return;
2289
+
2290
+ try {
2291
+ const response = await fetch(`${pgReportsRoot}/create_migration`, {
2292
+ method: 'POST',
2293
+ headers: {
2294
+ 'Content-Type': 'application/json',
2295
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
2296
+ },
2297
+ body: JSON.stringify({
2298
+ file_name: currentMigrationData.fileName,
2299
+ code: currentMigrationData.code
2300
+ })
2301
+ });
2302
+
2303
+ const data = await response.json();
2304
+
2305
+ if (data.success) {
2306
+ showToast('Migration file created');
2307
+ closeMigrationModal();
2308
+
2309
+ // Open in IDE if path provided
2310
+ if (data.file_path) {
2311
+ const ideUrls = generateIdeUrls(data.file_path, 1);
2312
+ const defaultIde = getDefaultIde();
2313
+
2314
+ if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
2315
+ window.location.href = ideUrls[ideKeyMap[defaultIde]].url;
2316
+ } else if (ideUrls.length > 0) {
2317
+ window.location.href = ideUrls[0].url;
2318
+ }
2319
+ }
2320
+ } else {
2321
+ showToast(data.error || 'Failed to create migration', 'error');
2322
+ }
2323
+ } catch (error) {
2324
+ showToast('Network error: ' + error.message, 'error');
2325
+ }
2326
+ }
481
2327
  </script>