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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +54 -3
- data/app/controllers/pg_reports/dashboard_controller.rb +90 -1
- data/app/views/layouts/pg_reports/application.html.erb +2 -1
- data/app/views/pg_reports/dashboard/index.html.erb +33 -2
- data/app/views/pg_reports/dashboard/show.html.erb +1960 -74
- data/config/locales/en.yml +310 -0
- data/config/locales/ru.yml +310 -0
- data/config/locales/uk.yml +310 -0
- data/config/routes.rb +2 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +170 -0
- data/lib/pg_reports/engine.rb +5 -0
- data/lib/pg_reports/version.rb +1 -1
- metadata +4 -1
|
@@ -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()">×</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="
|
|
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()">×</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()">×</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()">×</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
|
-
|
|
275
|
-
|
|
717
|
+
/* IDE Link dropdown */
|
|
718
|
+
.ide-dropdown {
|
|
719
|
+
display: inline-block;
|
|
276
720
|
}
|
|
277
721
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
733
|
+
.ide-dropdown-menu.show {
|
|
734
|
+
display: block;
|
|
735
|
+
}
|
|
294
736
|
|
|
295
|
-
|
|
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
|
-
|
|
746
|
+
.ide-dropdown-menu a:hover {
|
|
747
|
+
background: var(--bg-tertiary);
|
|
748
|
+
color: var(--text-primary);
|
|
749
|
+
}
|
|
298
750
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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, '"')}, ${JSON.stringify(row).replace(/"/g, '"')})">${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, '&')
|
|
1432
|
+
.replace(/"/g, '"')
|
|
1433
|
+
.replace(/'/g, ''')
|
|
1434
|
+
.replace(/</g, '<')
|
|
1435
|
+
.replace(/>/g, '>');
|
|
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
|
-
|
|
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();
|
|
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, '"');
|
|
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, '"');
|
|
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
|
-
//
|
|
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)}
|
|
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
|
-
|
|
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>
|