pg_reports 0.2.1 → 0.2.3

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.
@@ -0,0 +1,1301 @@
1
+ <script>
2
+ let currentReportData = null;
3
+ const category = '<%= @category %>';
4
+ const reportKey = '<%= @report_key %>';
5
+ let syncingScroll = false;
6
+ let currentSort = { column: null, direction: 'asc' };
7
+
8
+ // Top scrollbar sync functionality
9
+ function setupTopScrollbar() {
10
+ const topScrollWrapper = document.getElementById('top-scroll-wrapper');
11
+ const topScrollContent = document.getElementById('top-scroll-content');
12
+ const tableWrapper = document.getElementById('results-table-wrapper');
13
+ const table = document.getElementById('results-table');
14
+
15
+ if (!topScrollWrapper || !topScrollContent || !tableWrapper || !table) return;
16
+
17
+ // Check if table overflows
18
+ if (table.scrollWidth > tableWrapper.clientWidth) {
19
+ topScrollWrapper.style.display = 'block';
20
+ topScrollContent.style.width = table.scrollWidth + 'px';
21
+
22
+ // Sync scrolls
23
+ topScrollWrapper.addEventListener('scroll', function() {
24
+ if (syncingScroll) return;
25
+ syncingScroll = true;
26
+ tableWrapper.scrollLeft = topScrollWrapper.scrollLeft;
27
+ syncingScroll = false;
28
+ });
29
+
30
+ tableWrapper.addEventListener('scroll', function() {
31
+ if (syncingScroll) return;
32
+ syncingScroll = true;
33
+ topScrollWrapper.scrollLeft = tableWrapper.scrollLeft;
34
+ syncingScroll = false;
35
+ });
36
+ } else {
37
+ topScrollWrapper.style.display = 'none';
38
+ }
39
+ }
40
+
41
+ // Sort data by column
42
+ function sortData(data, column, direction) {
43
+ return [...data].sort((a, b) => {
44
+ let aVal = a[column];
45
+ let bVal = b[column];
46
+
47
+ // Handle null/undefined
48
+ if (aVal == null) aVal = '';
49
+ if (bVal == null) bVal = '';
50
+
51
+ // Try numeric comparison
52
+ const aNum = parseFloat(aVal);
53
+ const bNum = parseFloat(bVal);
54
+ if (!isNaN(aNum) && !isNaN(bNum)) {
55
+ return direction === 'asc' ? aNum - bNum : bNum - aNum;
56
+ }
57
+
58
+ // String comparison
59
+ const aStr = String(aVal).toLowerCase();
60
+ const bStr = String(bVal).toLowerCase();
61
+ if (direction === 'asc') {
62
+ return aStr.localeCompare(bStr);
63
+ } else {
64
+ return bStr.localeCompare(aStr);
65
+ }
66
+ });
67
+ }
68
+
69
+ // Handle column header click for sorting
70
+ function handleSortClick(column) {
71
+ if (!currentReportData || !currentReportData.data) return;
72
+
73
+ // Toggle direction if same column, otherwise start with asc
74
+ if (currentSort.column === column) {
75
+ currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
76
+ } else {
77
+ currentSort.column = column;
78
+ currentSort.direction = 'asc';
79
+ }
80
+
81
+ // Sort and re-render
82
+ const sortedData = sortData(currentReportData.data, column, currentSort.direction);
83
+ renderTableBody(sortedData, currentReportData.columns, currentReportData.thresholds, currentReportData.problem_fields);
84
+ updateSortIndicators();
85
+ }
86
+
87
+ // Update sort indicators in headers
88
+ function updateSortIndicators() {
89
+ document.querySelectorAll('.results-table th').forEach(th => {
90
+ th.classList.remove('sorted');
91
+ const indicator = th.querySelector('.sort-indicator');
92
+ if (indicator) {
93
+ indicator.textContent = '↕';
94
+ }
95
+ });
96
+
97
+ if (currentSort.column) {
98
+ const sortedTh = document.querySelector(`.results-table th[data-column="${currentSort.column}"]`);
99
+ if (sortedTh) {
100
+ sortedTh.classList.add('sorted');
101
+ const indicator = sortedTh.querySelector('.sort-indicator');
102
+ if (indicator) {
103
+ indicator.textContent = currentSort.direction === 'asc' ? '↑' : '↓';
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Render table body (extracted for reuse after sorting)
110
+ function renderTableBody(data, columns, thresholds, problemFields) {
111
+ const tableBody = document.getElementById('results-body');
112
+ if (!tableBody) return;
113
+
114
+ let rowsHtml = '';
115
+
116
+ data.forEach((row, idx) => {
117
+ // Check for problems
118
+ const problemInfo = getRowProblemLevel(row, thresholds, problemFields);
119
+ let rowClass = 'data-row';
120
+ let problemIndicator = '';
121
+
122
+ if (problemInfo && problemInfo.level) {
123
+ rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
124
+ const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
125
+ const indicatorIcon = problemInfo.level === 'critical' ? '🔴' : '⚠️';
126
+ 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>`;
127
+ }
128
+
129
+ // Data row
130
+ rowsHtml += `<tr id="data-row-${idx}" class="${rowClass}" onclick="toggleRow(${idx})">`;
131
+ rowsHtml += columns.map((col, colIdx) => {
132
+ const value = row[col] ?? '';
133
+ const strValue = String(value);
134
+ const isQuery = col === 'query';
135
+ const isSource = col === 'source';
136
+
137
+ if (isSource) {
138
+ return `<td>${buildSourceBadge(strValue)}</td>`;
139
+ }
140
+
141
+ const displayValue = strValue.length > 80 ? strValue.substring(0, 80) + '...' : strValue;
142
+ // Add problem indicator to first column
143
+ const indicator = colIdx === 0 ? problemIndicator : '';
144
+ return `<td class="${isQuery ? 'query-cell' : ''}">${escapeHtml(displayValue)}${indicator}</td>`;
145
+ }).join('');
146
+ rowsHtml += '</tr>';
147
+
148
+ // Detail row (hidden by default)
149
+ rowsHtml += `<tr id="detail-row-${idx}" class="detail-row">`;
150
+ rowsHtml += `<td colspan="${columns.length}">${buildDetailRow(row, columns, idx, thresholds, problemFields)}</td>`;
151
+ rowsHtml += '</tr>';
152
+ });
153
+
154
+ tableBody.innerHTML = rowsHtml;
155
+
156
+ // Re-setup top scrollbar
157
+ setTimeout(setupTopScrollbar, 0);
158
+ }
159
+
160
+ // Problem explanations from I18n (passed from server)
161
+ const problemExplanations = <%= raw(I18n.t('pg_reports.problems').to_json) %>;
162
+
163
+ function toggleDropdown() {
164
+ document.getElementById('dropdown-menu').classList.toggle('show');
165
+ }
166
+
167
+ // Close dropdown when clicking outside
168
+ document.addEventListener('click', function(e) {
169
+ const dropdown = document.getElementById('download-dropdown');
170
+ if (dropdown && !dropdown.contains(e.target)) {
171
+ document.getElementById('dropdown-menu')?.classList.remove('show');
172
+ }
173
+ // Close IDE dropdowns
174
+ document.querySelectorAll('.ide-dropdown-menu.show').forEach(menu => {
175
+ if (!menu.parentElement.contains(e.target)) {
176
+ menu.classList.remove('show');
177
+ }
178
+ });
179
+ });
180
+
181
+ function downloadReport(format) {
182
+ document.getElementById('dropdown-menu').classList.remove('show');
183
+ window.location.href = `${pgReportsRoot}/${category}/${reportKey}/download?format=${format}`;
184
+ }
185
+
186
+ function toggleRow(rowIndex) {
187
+ const dataRow = document.getElementById(`data-row-${rowIndex}`);
188
+ const detailRow = document.getElementById(`detail-row-${rowIndex}`);
189
+
190
+ if (!dataRow || !detailRow) return;
191
+
192
+ const isExpanded = dataRow.classList.contains('expanded');
193
+
194
+ // Collapse all other rows
195
+ document.querySelectorAll('.data-row.expanded').forEach(row => {
196
+ row.classList.remove('expanded');
197
+ });
198
+ document.querySelectorAll('.detail-row.show').forEach(row => {
199
+ row.classList.remove('show');
200
+ });
201
+
202
+ // Toggle current row
203
+ if (!isExpanded) {
204
+ dataRow.classList.add('expanded');
205
+ detailRow.classList.add('show');
206
+ }
207
+ }
208
+
209
+ function escapeHtml(text) {
210
+ const div = document.createElement('div');
211
+ div.textContent = text;
212
+ return div.innerHTML;
213
+ }
214
+
215
+ function escapeHtmlAttr(text) {
216
+ return String(text)
217
+ .replace(/&/g, '&amp;')
218
+ .replace(/"/g, '&quot;')
219
+ .replace(/'/g, '&#39;')
220
+ .replace(/</g, '&lt;')
221
+ .replace(/>/g, '&gt;');
222
+ }
223
+
224
+ function copyQueryFromButton(btn) {
225
+ const query = btn.dataset.query;
226
+ copyToClipboard(query, btn);
227
+ }
228
+
229
+ function copyToClipboard(text, btn) {
230
+ navigator.clipboard.writeText(text).then(() => {
231
+ const originalText = btn.textContent;
232
+ btn.textContent = '✓ Copied!';
233
+ btn.style.background = 'var(--accent-green)';
234
+ btn.style.borderColor = 'var(--accent-green)';
235
+ btn.style.color = 'white';
236
+ setTimeout(() => {
237
+ btn.textContent = originalText;
238
+ btn.style.background = '';
239
+ btn.style.borderColor = '';
240
+ btn.style.color = '';
241
+ }, 1500);
242
+ }).catch(() => {
243
+ showToast('Failed to copy', 'error');
244
+ });
245
+ }
246
+
247
+ // Check if a value exceeds threshold
248
+ function checkThreshold(value, threshold, inverted = false) {
249
+ if (!threshold || value === null || value === undefined) return null;
250
+
251
+ const numValue = parseFloat(value);
252
+ if (isNaN(numValue)) return null;
253
+
254
+ if (inverted) {
255
+ // For inverted thresholds (lower is worse), like cache_hit_ratio
256
+ if (numValue <= threshold.critical) return 'critical';
257
+ if (numValue <= threshold.warning) return 'warning';
258
+ } else {
259
+ // Normal thresholds (higher is worse)
260
+ if (numValue >= threshold.critical) return 'critical';
261
+ if (numValue >= threshold.warning) return 'warning';
262
+ }
263
+ return null;
264
+ }
265
+
266
+ // Get row problem level
267
+ function getRowProblemLevel(row, thresholds, problemFields) {
268
+ if (!thresholds || !problemFields || problemFields.length === 0) return null;
269
+
270
+ let maxLevel = null;
271
+ const problems = [];
272
+
273
+ for (const field of problemFields) {
274
+ const value = row[field];
275
+ const threshold = thresholds[field];
276
+ if (!threshold) continue;
277
+
278
+ const level = checkThreshold(value, threshold, threshold.inverted);
279
+ if (level) {
280
+ problems.push({ field, value, level, threshold });
281
+ if (level === 'critical') maxLevel = 'critical';
282
+ else if (level === 'warning' && maxLevel !== 'critical') maxLevel = 'warning';
283
+ }
284
+ }
285
+
286
+ return { level: maxLevel, problems };
287
+ }
288
+
289
+ // Show problem modal
290
+ function showProblemModal(problems, row) {
291
+ const modal = document.getElementById('problem-modal');
292
+ const body = document.getElementById('problem-modal-body');
293
+
294
+ let html = '<div class="problem-details">';
295
+
296
+ for (const problem of problems) {
297
+ const levelClass = problem.level === 'critical' ? 'critical' : 'warning';
298
+ const levelText = problem.level === 'critical' ? '🔴 Critical' : '⚠️ Warning';
299
+
300
+ html += `
301
+ <div class="problem-field">
302
+ <span class="problem-field-label">${escapeHtml(problem.field)} (${levelText})</span>
303
+ <div class="problem-field-value ${levelClass}">
304
+ Current: ${escapeHtml(String(problem.value))}
305
+ <br>Threshold: warning=${problem.threshold.warning}, critical=${problem.threshold.critical}
306
+ ${problem.threshold.inverted ? '<br><em>(inverted: lower values are worse)</em>' : ''}
307
+ </div>
308
+ </div>
309
+ `;
310
+ }
311
+
312
+ // Add explanation based on problem types
313
+ const explanationKey = getExplanationKey(problems);
314
+ const explanation = problemExplanations[explanationKey] || '';
315
+
316
+ if (explanation) {
317
+ html += `
318
+ <div class="problem-explanation">
319
+ <h4>💡 Recommendation</h4>
320
+ <p>${escapeHtml(explanation)}</p>
321
+ </div>
322
+ `;
323
+ }
324
+
325
+ html += '</div>';
326
+ body.innerHTML = html;
327
+ modal.style.display = 'flex';
328
+ }
329
+
330
+ function closeProblemModal() {
331
+ document.getElementById('problem-modal').style.display = 'none';
332
+ }
333
+
334
+ // Map problem fields to explanation keys
335
+ function getExplanationKey(problems) {
336
+ const fields = problems.map(p => p.field);
337
+
338
+ if (fields.includes('mean_time_ms')) return 'high_mean_time';
339
+ if (fields.includes('calls')) return 'high_calls';
340
+ if (fields.includes('total_time_ms')) return 'high_total_time';
341
+ if (fields.includes('cache_hit_ratio')) return 'low_cache_hit';
342
+ if (fields.includes('seq_scan') || fields.includes('seq_tup_read')) return 'high_seq_scan';
343
+ if (fields.includes('idx_scan')) return 'unused_index';
344
+ if (fields.includes('bloat_ratio') || fields.includes('bloat_size')) return 'high_bloat';
345
+ if (fields.includes('dead_tuple_ratio') || fields.includes('n_dead_tup')) return 'many_dead_tuples';
346
+ if (fields.includes('duration_seconds') || fields.includes('duration')) return 'long_running';
347
+ if (fields.includes('blocked_count')) return 'blocking';
348
+ if (fields.includes('idle_in_transaction')) return 'idle_in_transaction';
349
+
350
+ return '';
351
+ }
352
+
353
+ // Parse source location and generate IDE link
354
+ function parseSourceLocation(source) {
355
+ if (!source || source === 'null' || source === '') return null;
356
+
357
+ let filePath = null;
358
+ let lineNumber = null;
359
+ let methodName = null;
360
+
361
+ // Try to match file:line pattern
362
+ const fileLineMatch = source.match(/^([^:]+\.(rb|erb|js|ts|py|go|java)):(\d+)/);
363
+ if (fileLineMatch) {
364
+ filePath = fileLineMatch[1];
365
+ lineNumber = parseInt(fileLineMatch[3], 10);
366
+ }
367
+
368
+ // Try to match Controller#action pattern (e.g., PostsController#index)
369
+ const controllerMatch = source.match(/^(\w+Controller)#(\w+)/);
370
+ if (controllerMatch) {
371
+ methodName = `${controllerMatch[1]}#${controllerMatch[2]}`;
372
+
373
+ // Check known methods first for testing (if fake data is enabled)
374
+ if (typeof knownControllerMethods !== 'undefined' && knownControllerMethods[methodName]) {
375
+ filePath = knownControllerMethods[methodName].file;
376
+ lineNumber = knownControllerMethods[methodName].line;
377
+ } else {
378
+ // Derive file path from controller name
379
+ const controllerName = controllerMatch[1].replace(/Controller$/, '').toLowerCase();
380
+ filePath = `app/controllers/${controllerName}_controller.rb`;
381
+ }
382
+ }
383
+
384
+ // Try to match short controller#action pattern (e.g., posts#index, dashboard#show)
385
+ if (!filePath) {
386
+ const shortMatch = source.match(/^(\w+)#(\w+)$/);
387
+ if (shortMatch) {
388
+ const controllerName = shortMatch[1].toLowerCase();
389
+ methodName = `${shortMatch[1]}#${shortMatch[2]}`;
390
+ filePath = `app/controllers/${controllerName}_controller.rb`;
391
+ }
392
+ }
393
+
394
+ return { filePath, lineNumber, methodName, original: source };
395
+ }
396
+
397
+ // Rails.root path for IDE links
398
+ const railsRootPath = '<%= Rails.root.to_s %>';
399
+ const wslDistro = 'Ubuntu';
400
+
401
+ // Generate IDE URLs
402
+ function generateIdeUrls(filePath, lineNumber) {
403
+ if (!filePath) return [];
404
+
405
+ // Convert relative paths to absolute paths using Rails.root
406
+ let absolutePath = filePath;
407
+ if (!filePath.startsWith('/')) {
408
+ absolutePath = `${railsRootPath}/${filePath}`;
409
+ }
410
+
411
+ const line = lineNumber || 1;
412
+ const urls = [];
413
+
414
+ // VSCode (WSL Remote format)
415
+ urls.push({
416
+ name: 'VS Code (WSL)',
417
+ url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
418
+ });
419
+
420
+ // VSCode (direct path - for native Linux or Windows)
421
+ urls.push({
422
+ name: 'VS Code',
423
+ url: `vscode://file${absolutePath}:${line}`
424
+ });
425
+
426
+ // JetBrains (RubyMine, IntelliJ, etc.)
427
+ urls.push({
428
+ name: 'RubyMine',
429
+ url: `x-mine://open?file=${absolutePath}&line=${line}`
430
+ });
431
+
432
+ urls.push({
433
+ name: 'IntelliJ',
434
+ url: `idea://open?file=${absolutePath}&line=${line}`
435
+ });
436
+
437
+ // Cursor (WSL Remote format)
438
+ urls.push({
439
+ name: 'Cursor (WSL)',
440
+ url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
441
+ });
442
+
443
+ // Cursor (direct path)
444
+ urls.push({
445
+ name: 'Cursor',
446
+ url: `cursor://file${absolutePath}:${line}`
447
+ });
448
+
449
+ return urls;
450
+ }
451
+
452
+ // Map IDE key to URL index
453
+ const ideKeyMap = {
454
+ 'vscode-wsl': 0,
455
+ 'vscode': 1,
456
+ 'rubymine': 2,
457
+ 'intellij': 3,
458
+ 'cursor-wsl': 4,
459
+ 'cursor': 5
460
+ };
461
+
462
+ // Build source badge HTML with IDE links
463
+ function buildSourceBadge(source) {
464
+ const parsed = parseSourceLocation(source);
465
+
466
+ if (!parsed) {
467
+ return `<span class="source-badge empty">—</span>`;
468
+ }
469
+
470
+ const ideUrls = generateIdeUrls(parsed.filePath, parsed.lineNumber);
471
+
472
+ if (ideUrls.length === 0) {
473
+ return `<span class="source-badge" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>`;
474
+ }
475
+
476
+ // Check if user has a default IDE set
477
+ const defaultIde = getDefaultIde();
478
+ if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
479
+ const ideUrl = ideUrls[ideKeyMap[defaultIde]];
480
+ if (ideUrl) {
481
+ return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</a>`;
482
+ }
483
+ }
484
+
485
+ // No default IDE - show dropdown menu
486
+ const dropdownId = `ide-dropdown-${Math.random().toString(36).substr(2, 9)}`;
487
+
488
+ let dropdownHtml = `
489
+ <div class="ide-dropdown">
490
+ <span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>
491
+ <div class="ide-dropdown-menu" id="${dropdownId}">
492
+ `;
493
+
494
+ for (const ide of ideUrls) {
495
+ dropdownHtml += `<a href="${ide.url}">${ide.name}</a>`;
496
+ }
497
+
498
+ dropdownHtml += '</div></div>';
499
+ return dropdownHtml;
500
+ }
501
+
502
+ function toggleIdeDropdown(dropdownId, badgeElement) {
503
+ const menu = document.getElementById(dropdownId);
504
+ if (!menu) return;
505
+
506
+ // Close other dropdowns
507
+ document.querySelectorAll('.ide-dropdown-menu.show').forEach(m => {
508
+ if (m.id !== dropdownId) m.classList.remove('show');
509
+ });
510
+
511
+ // Toggle current menu
512
+ const isShowing = menu.classList.toggle('show');
513
+
514
+ // Position the menu using fixed positioning
515
+ if (isShowing && badgeElement) {
516
+ const rect = badgeElement.getBoundingClientRect();
517
+ menu.style.top = (rect.bottom + 4) + 'px';
518
+ menu.style.left = rect.left + 'px';
519
+ }
520
+ }
521
+
522
+ // Event delegation for source badge clicks
523
+ document.addEventListener('click', function(e) {
524
+ const badge = e.target.closest('.source-badge.clickable[data-dropdown-id]');
525
+ if (badge) {
526
+ e.stopPropagation();
527
+ e.preventDefault();
528
+ const dropdownId = badge.dataset.dropdownId;
529
+ toggleIdeDropdown(dropdownId, badge);
530
+ return;
531
+ }
532
+
533
+ // Allow clicks on IDE dropdown menu links
534
+ const ideLink = e.target.closest('.ide-dropdown-menu a');
535
+ if (ideLink) {
536
+ e.stopPropagation();
537
+ // Let the link navigate normally
538
+ return;
539
+ }
540
+ }, true); // Use capture phase to intercept before row click
541
+
542
+ function buildDetailRow(row, columns, rowIndex, thresholds, problemFields) {
543
+ let html = '<div class="row-detail">';
544
+ const hasQuery = columns.includes('query') && row.query;
545
+ const hasIndexName = columns.includes('index_name') && row.index_name;
546
+ const rowId = generateRowId(row);
547
+
548
+ columns.forEach(col => {
549
+ const value = row[col] ?? '';
550
+ const strValue = String(value);
551
+ const isQuery = col === 'query';
552
+ const isSource = col === 'source';
553
+ const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
554
+
555
+ // Check if this field has a problem
556
+ let problemClass = '';
557
+ if (problemFields && problemFields.includes(col) && thresholds && thresholds[col]) {
558
+ const level = checkThreshold(value, thresholds[col], thresholds[col].inverted);
559
+ if (level === 'critical') problemClass = 'problem-critical';
560
+ else if (level === 'warning') problemClass = 'problem-warning';
561
+ }
562
+
563
+ let valueClass = '';
564
+ if (isQuery) valueClass = 'query';
565
+ else if (isSource) valueClass = 'source';
566
+ else if (isNumber) valueClass = 'number';
567
+ if (problemClass) valueClass += ' ' + problemClass;
568
+
569
+ const isLongText = strValue.length > 100 || isQuery;
570
+
571
+ // Skip empty source in detail view
572
+ if (isSource && (!strValue || strValue === 'null' || strValue === '')) {
573
+ return;
574
+ }
575
+
576
+ html += `
577
+ <div class="row-detail-item${isLongText ? ' full-width' : ''}">
578
+ <span class="row-detail-label">${escapeHtml(col)}</span>
579
+ <div class="row-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
580
+ ${isQuery ? `<button class="copy-btn" data-query="${escapeHtmlAttr(strValue)}" onclick="event.stopPropagation(); copyQueryFromButton(this)">📋 Copy Query</button>` : ''}
581
+ </div>
582
+ `;
583
+ });
584
+
585
+ // Action buttons
586
+ const isSaved = isRecordSaved(rowId);
587
+ const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
588
+
589
+ html += `<div class="detail-actions">`;
590
+ html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${isSaved ? '📌 Saved' : '📌 Save for Comparison'}</button>`;
591
+
592
+ if (hasQuery) {
593
+ // Only show EXPLAIN ANALYZE for SELECT queries
594
+ const queryNormalized = row.query.trim().toLowerCase();
595
+ if (queryNormalized.startsWith('select')) {
596
+ // Use data attribute to avoid escaping issues with special characters
597
+ const queryBase64 = btoa(unescape(encodeURIComponent(row.query)));
598
+ html += `<button class="btn-explain" data-query-b64="${queryBase64}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">📊 EXPLAIN ANALYZE</button>`;
599
+ }
600
+ }
601
+
602
+ // Show migration button for index reports
603
+ if (hasIndexName && (category === 'indexes')) {
604
+ const indexName = row.index_name;
605
+ const tableName = row.table_name || row.tablename || '';
606
+ const schemaName = row.schema_name || row.schemaname || 'public';
607
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">🗑️ Generate Migration</button>`;
608
+ }
609
+
610
+ html += `</div>`;
611
+
612
+ html += '</div>';
613
+ return html;
614
+ }
615
+
616
+ async function runReport(cat, report, button) {
617
+ const tableBody = document.getElementById('results-body');
618
+ const tableHead = document.getElementById('results-head');
619
+ const loadingEl = document.getElementById('loading');
620
+ const emptyEl = document.getElementById('empty-state');
621
+ const metaEl = document.getElementById('results-meta');
622
+ const downloadDropdown = document.getElementById('download-dropdown');
623
+ const telegramBtn = document.getElementById('telegram-btn');
624
+
625
+ if (button) {
626
+ button.disabled = true;
627
+ button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> Running...';
628
+ }
629
+
630
+ if (loadingEl) loadingEl.style.display = 'flex';
631
+ if (emptyEl) emptyEl.style.display = 'none';
632
+ if (tableBody) tableBody.innerHTML = '';
633
+ if (downloadDropdown) downloadDropdown.style.display = 'none';
634
+ if (telegramBtn) telegramBtn.style.display = 'none';
635
+
636
+ try {
637
+ const response = await fetch(`${pgReportsRoot}/${cat}/${report}/run`, {
638
+ method: 'POST',
639
+ headers: {
640
+ 'Content-Type': 'application/json',
641
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
642
+ }
643
+ });
644
+
645
+ const data = await response.json();
646
+
647
+ if (loadingEl) loadingEl.style.display = 'none';
648
+
649
+ if (data.success) {
650
+ // Inject fake source data for IDE link testing (if enabled)
651
+ if (typeof injectFakeSourceData === 'function') {
652
+ injectFakeSourceData(data);
653
+ }
654
+
655
+ currentReportData = data;
656
+ const thresholds = data.thresholds || {};
657
+ const problemFields = data.problem_fields || [];
658
+
659
+ if (metaEl) {
660
+ metaEl.innerHTML = `
661
+ <span>Total: ${data.total} rows</span>
662
+ <span>Generated: ${data.generated_at}</span>
663
+ <span class="row-hint">Click row to expand</span>
664
+ `;
665
+ }
666
+
667
+ // Show download and telegram buttons
668
+ if (downloadDropdown) downloadDropdown.style.display = 'inline-block';
669
+ if (telegramBtn) telegramBtn.style.display = 'inline-flex';
670
+
671
+ if (data.data.length === 0) {
672
+ if (emptyEl) emptyEl.style.display = 'block';
673
+ } else {
674
+ // Reset sort state for new data
675
+ currentSort = { column: null, direction: 'asc' };
676
+
677
+ // Build table header with sortable columns
678
+ if (tableHead) {
679
+ tableHead.innerHTML = '<tr>' + data.columns.map(col =>
680
+ `<th class="sortable" data-column="${escapeHtml(col)}" onclick="handleSortClick('${escapeHtml(col)}')">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
681
+ ).join('') + '</tr>';
682
+ }
683
+
684
+ // Build table body with expandable rows
685
+ renderTableBody(data.data, data.columns, thresholds, problemFields);
686
+ }
687
+
688
+ showToast('Report generated successfully');
689
+ } else {
690
+ showToast(data.error || 'Failed to run report', 'error');
691
+ }
692
+ } catch (error) {
693
+ if (loadingEl) loadingEl.style.display = 'none';
694
+ showToast('Network error: ' + error.message, 'error');
695
+ }
696
+
697
+ if (button) {
698
+ button.disabled = false;
699
+ button.innerHTML = '▶ Run Report';
700
+ }
701
+ }
702
+
703
+ // Close modal on Escape key
704
+ document.addEventListener('keydown', function(e) {
705
+ if (e.key === 'Escape') {
706
+ closeProblemModal();
707
+ closeIdeSettingsModal();
708
+ closeExplainModal();
709
+ closeMigrationModal();
710
+ }
711
+ });
712
+
713
+ // Close modal on backdrop click
714
+ document.getElementById('problem-modal')?.addEventListener('click', function(e) {
715
+ if (e.target === this) {
716
+ closeProblemModal();
717
+ }
718
+ });
719
+
720
+ // IDE Settings functions
721
+ function showIdeSettingsModal() {
722
+ const modal = document.getElementById('ide-settings-modal');
723
+ modal.style.display = 'flex';
724
+ loadIdeSettingsState();
725
+ }
726
+
727
+ function closeIdeSettingsModal() {
728
+ document.getElementById('ide-settings-modal').style.display = 'none';
729
+ }
730
+
731
+ function setDefaultIde(ideKey) {
732
+ if (ideKey) {
733
+ localStorage.setItem('pgReportsDefaultIde', ideKey);
734
+ } else {
735
+ localStorage.removeItem('pgReportsDefaultIde');
736
+ }
737
+ }
738
+
739
+ function getDefaultIde() {
740
+ return localStorage.getItem('pgReportsDefaultIde') || '';
741
+ }
742
+
743
+ function loadIdeSettingsState() {
744
+ const currentIde = getDefaultIde();
745
+ const radios = document.querySelectorAll('input[name="default-ide"]');
746
+ radios.forEach(radio => {
747
+ radio.checked = (radio.value === currentIde);
748
+ });
749
+ }
750
+
751
+ // Close IDE settings modal on backdrop click
752
+ document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
753
+ if (e.target === this) {
754
+ closeIdeSettingsModal();
755
+ }
756
+ });
757
+
758
+ // ==========================================
759
+ // SAVED RECORDS FUNCTIONALITY
760
+ // ==========================================
761
+
762
+ function getSavedRecordsKey() {
763
+ return `pgReports_saved_${category}_${reportKey}`;
764
+ }
765
+
766
+ function getSavedRecords() {
767
+ try {
768
+ const data = localStorage.getItem(getSavedRecordsKey());
769
+ return data ? JSON.parse(data) : [];
770
+ } catch (e) {
771
+ return [];
772
+ }
773
+ }
774
+
775
+ function saveSavedRecords(records) {
776
+ localStorage.setItem(getSavedRecordsKey(), JSON.stringify(records));
777
+ }
778
+
779
+ function generateRowId(row) {
780
+ // Generate a unique ID based on key fields
781
+ const keyFields = ['query', 'queryid', 'index_name', 'table_name', 'pid', 'datname'];
782
+ const parts = [];
783
+ for (const field of keyFields) {
784
+ if (row[field]) {
785
+ parts.push(String(row[field]).substring(0, 50));
786
+ }
787
+ }
788
+ // Simple hash
789
+ const str = parts.join('|');
790
+ let hash = 0;
791
+ for (let i = 0; i < str.length; i++) {
792
+ const char = str.charCodeAt(i);
793
+ hash = ((hash << 5) - hash) + char;
794
+ hash = hash & hash;
795
+ }
796
+ return 'r' + Math.abs(hash).toString(36);
797
+ }
798
+
799
+ function isRecordSaved(rowId) {
800
+ const saved = getSavedRecords();
801
+ return saved.some(r => r.id === rowId);
802
+ }
803
+
804
+ function toggleSaveRecord(rowId, row, btn) {
805
+ const saved = getSavedRecords();
806
+ const existingIdx = saved.findIndex(r => r.id === rowId);
807
+
808
+ if (existingIdx >= 0) {
809
+ // Remove
810
+ saved.splice(existingIdx, 1);
811
+ btn.classList.remove('saved');
812
+ btn.textContent = '📌 Save for Comparison';
813
+ showToast('Record removed from saved');
814
+ } else {
815
+ // Add
816
+ saved.unshift({
817
+ id: rowId,
818
+ savedAt: new Date().toISOString(),
819
+ data: row
820
+ });
821
+ btn.classList.add('saved');
822
+ btn.textContent = '📌 Saved';
823
+ showToast('Record saved for comparison');
824
+ }
825
+
826
+ saveSavedRecords(saved);
827
+ renderSavedRecords();
828
+ }
829
+
830
+ function removeSavedRecord(rowId) {
831
+ const saved = getSavedRecords();
832
+ const filtered = saved.filter(r => r.id !== rowId);
833
+ saveSavedRecords(filtered);
834
+ renderSavedRecords();
835
+
836
+ // Update button in table if visible
837
+ const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
838
+ if (btn) {
839
+ btn.classList.remove('saved');
840
+ btn.textContent = '📌 Save for Comparison';
841
+ }
842
+
843
+ showToast('Record removed');
844
+ }
845
+
846
+ function clearAllSavedRecords() {
847
+ if (!confirm('Remove all saved records for this report?')) return;
848
+ saveSavedRecords([]);
849
+ renderSavedRecords();
850
+
851
+ // Update all buttons in table
852
+ document.querySelectorAll('.btn-save.saved').forEach(btn => {
853
+ btn.classList.remove('saved');
854
+ btn.textContent = '📌 Save for Comparison';
855
+ });
856
+
857
+ showToast('All saved records cleared');
858
+ }
859
+
860
+ function renderSavedRecords() {
861
+ const section = document.getElementById('saved-records-section');
862
+ const list = document.getElementById('saved-records-list');
863
+ const saved = getSavedRecords();
864
+
865
+ if (saved.length === 0) {
866
+ section.style.display = 'none';
867
+ return;
868
+ }
869
+
870
+ section.style.display = 'block';
871
+
872
+ // Fields to highlight for comparison
873
+ const highlightFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows', 'shared_blks_hit', 'shared_blks_read'];
874
+ // Key metrics to show in summary
875
+ const summaryFields = ['mean_time_ms', 'total_time_ms', 'calls', 'rows'];
876
+
877
+ let html = '';
878
+ saved.forEach((record, idx) => {
879
+ const savedTime = new Date(record.savedAt).toLocaleString();
880
+ const data = record.data;
881
+ const hasQuery = data.query;
882
+
883
+ html += `
884
+ <div class="saved-record-card" id="saved-card-${idx}" onclick="toggleSavedRecordDetail(${idx}, event)">
885
+ <div class="saved-record-header">
886
+ <span class="saved-record-time">▸ Saved: ${savedTime}</span>
887
+ <span class="saved-record-expand-hint">Click to expand</span>
888
+ <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="Remove">×</button>
889
+ </div>
890
+ <div class="saved-record-data">
891
+ `;
892
+
893
+ // Show key metrics in summary
894
+ summaryFields.forEach(field => {
895
+ const value = data[field];
896
+ if (value === null || value === undefined) return;
897
+ const strValue = String(value);
898
+ html += `
899
+ <div class="saved-record-field">
900
+ <span class="saved-record-field-name">${escapeHtml(field)}</span>
901
+ <span class="saved-record-field-value highlight">${escapeHtml(strValue)}</span>
902
+ </div>
903
+ `;
904
+ });
905
+
906
+ // Show truncated query preview
907
+ if (hasQuery) {
908
+ const queryPreview = data.query.length > 100 ? data.query.substring(0, 100) + '...' : data.query;
909
+ html += `
910
+ <div class="saved-record-field" style="grid-column: 1 / -1;">
911
+ <span class="saved-record-field-name">query</span>
912
+ <span class="saved-record-field-value" style="color: var(--accent-green); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(queryPreview)}</span>
913
+ </div>
914
+ `;
915
+ }
916
+
917
+ // Expandable detail section
918
+ html += `
919
+ </div>
920
+ <div class="saved-record-detail">
921
+ <div class="saved-record-detail-grid">
922
+ `;
923
+
924
+ // Show all fields in detail
925
+ Object.keys(data).forEach(field => {
926
+ if (field === 'source') return;
927
+ const value = data[field];
928
+ if (value === null || value === undefined) return;
929
+ const strValue = String(value);
930
+ const isQuery = field === 'query';
931
+ const isNumber = typeof value === 'number' || (!isNaN(parseFloat(value)) && isFinite(value));
932
+
933
+ let valueClass = '';
934
+ if (isQuery) valueClass = 'query';
935
+ else if (isNumber) valueClass = 'number';
936
+
937
+ html += `
938
+ <div class="saved-record-detail-item ${isQuery ? 'full-width' : ''}">
939
+ <span class="saved-record-detail-label">${escapeHtml(field)}</span>
940
+ <div class="saved-record-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
941
+ </div>
942
+ `;
943
+ });
944
+
945
+ html += `
946
+ </div>
947
+ </div>
948
+ </div>
949
+ `;
950
+ });
951
+
952
+ list.innerHTML = html;
953
+ }
954
+
955
+ function toggleSavedRecordDetail(idx, event) {
956
+ // Don't toggle if clicking on remove button
957
+ if (event.target.classList.contains('saved-record-remove')) return;
958
+
959
+ const card = document.getElementById(`saved-card-${idx}`);
960
+ if (!card) return;
961
+
962
+ const wasExpanded = card.classList.contains('expanded');
963
+
964
+ // Collapse all cards
965
+ document.querySelectorAll('.saved-record-card.expanded').forEach(c => {
966
+ c.classList.remove('expanded');
967
+ const hint = c.querySelector('.saved-record-time');
968
+ if (hint) hint.textContent = hint.textContent.replace('▾', '▸');
969
+ });
970
+
971
+ // Toggle current card
972
+ if (!wasExpanded) {
973
+ card.classList.add('expanded');
974
+ const hint = card.querySelector('.saved-record-time');
975
+ if (hint) hint.textContent = hint.textContent.replace('▸', '▾');
976
+ }
977
+ }
978
+
979
+ // Render saved records on page load
980
+ document.addEventListener('DOMContentLoaded', renderSavedRecords);
981
+
982
+ // ==========================================
983
+ // EXPLAIN ANALYZE FUNCTIONALITY
984
+ // ==========================================
985
+
986
+ let currentExplainQuery = '';
987
+ let currentQueryParams = [];
988
+
989
+ // Parse $1, $2, etc. from query
990
+ function parseQueryParams(query) {
991
+ const matches = query.match(/\$(\d+)/g);
992
+ if (!matches) return [];
993
+
994
+ // Get unique param numbers and sort them
995
+ const paramNumbers = [...new Set(matches.map(m => parseInt(m.substring(1))))].sort((a, b) => a - b);
996
+ return paramNumbers;
997
+ }
998
+
999
+ // Decode base64 query from button and run analyzer
1000
+ function runExplainAnalyzeFromButton(btn) {
1001
+ const queryBase64 = btn.dataset.queryB64;
1002
+ if (!queryBase64) return;
1003
+
1004
+ try {
1005
+ const query = decodeURIComponent(escape(atob(queryBase64)));
1006
+ runExplainAnalyze(query);
1007
+ } catch (e) {
1008
+ showToast('Failed to decode query', 'error');
1009
+ }
1010
+ }
1011
+
1012
+ // Show query analyzer modal
1013
+ function runExplainAnalyze(query) {
1014
+ currentExplainQuery = query;
1015
+ currentQueryParams = parseQueryParams(query);
1016
+
1017
+ const modal = document.getElementById('explain-modal');
1018
+ const queryDisplay = document.getElementById('explain-query-display');
1019
+ const paramsSection = document.getElementById('explain-params-section');
1020
+ const paramsInputs = document.getElementById('explain-params-inputs');
1021
+ const loading = document.getElementById('explain-loading');
1022
+ const content = document.getElementById('explain-content');
1023
+
1024
+ // Show query with highlighted parameters
1025
+ const highlightedQuery = escapeHtml(query).replace(
1026
+ /\$(\d+)/g,
1027
+ '<span class="query-param">$$$1</span>'
1028
+ );
1029
+ queryDisplay.innerHTML = highlightedQuery;
1030
+
1031
+ // Show/hide params section
1032
+ if (currentQueryParams.length > 0) {
1033
+ paramsSection.style.display = 'block';
1034
+
1035
+ // Generate input fields
1036
+ let inputsHtml = '<div class="explain-params-grid">';
1037
+ currentQueryParams.forEach(num => {
1038
+ inputsHtml += `
1039
+ <div class="param-input-group">
1040
+ <label class="param-input-label">$${num}</label>
1041
+ <input type="text" class="param-input" id="param-input-${num}" placeholder="Value for $${num}" data-param="${num}">
1042
+ </div>
1043
+ `;
1044
+ });
1045
+ inputsHtml += '</div>';
1046
+ paramsInputs.innerHTML = inputsHtml;
1047
+ } else {
1048
+ paramsSection.style.display = 'none';
1049
+ paramsInputs.innerHTML = '';
1050
+ }
1051
+
1052
+ // Reset content area
1053
+ loading.style.display = 'none';
1054
+ content.innerHTML = '';
1055
+
1056
+ modal.style.display = 'flex';
1057
+ }
1058
+
1059
+ // Get parameter values from inputs
1060
+ function getParamValues() {
1061
+ const params = {};
1062
+ currentQueryParams.forEach(num => {
1063
+ const input = document.getElementById(`param-input-${num}`);
1064
+ if (input) {
1065
+ params[num] = input.value;
1066
+ }
1067
+ });
1068
+ return params;
1069
+ }
1070
+
1071
+ // Execute EXPLAIN ANALYZE with parameters
1072
+ async function executeExplainAnalyze() {
1073
+ const loading = document.getElementById('explain-loading');
1074
+ const content = document.getElementById('explain-content');
1075
+ const params = getParamValues();
1076
+
1077
+ loading.style.display = 'flex';
1078
+ content.innerHTML = '';
1079
+
1080
+ try {
1081
+ const response = await fetch(`${pgReportsRoot}/explain_analyze`, {
1082
+ method: 'POST',
1083
+ headers: {
1084
+ 'Content-Type': 'application/json',
1085
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1086
+ },
1087
+ body: JSON.stringify({ query: currentExplainQuery, params: params })
1088
+ });
1089
+
1090
+ const data = await response.json();
1091
+ loading.style.display = 'none';
1092
+
1093
+ if (data.success) {
1094
+ let html = '';
1095
+
1096
+ // Show stats if available
1097
+ if (data.stats) {
1098
+ html += '<div class="explain-stats">';
1099
+ if (data.stats.planning_time) {
1100
+ 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>`;
1101
+ }
1102
+ if (data.stats.execution_time) {
1103
+ 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>`;
1104
+ }
1105
+ if (data.stats.total_cost) {
1106
+ html += `<div class="explain-stat"><span class="explain-stat-label">Total Cost</span><span class="explain-stat-value">${data.stats.total_cost}</span></div>`;
1107
+ }
1108
+ if (data.stats.rows) {
1109
+ html += `<div class="explain-stat"><span class="explain-stat-label">Rows</span><span class="explain-stat-value">${data.stats.rows}</span></div>`;
1110
+ }
1111
+ html += '</div>';
1112
+ }
1113
+
1114
+ html += `<div class="explain-result">${escapeHtml(data.explain)}</div>`;
1115
+ content.innerHTML = html;
1116
+ } else {
1117
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to run EXPLAIN ANALYZE')}</div>`;
1118
+ }
1119
+ } catch (error) {
1120
+ loading.style.display = 'none';
1121
+ content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
1122
+ }
1123
+ }
1124
+
1125
+ // Execute query and show results
1126
+ async function executeQuery() {
1127
+ const loading = document.getElementById('explain-loading');
1128
+ const content = document.getElementById('explain-content');
1129
+ const params = getParamValues();
1130
+
1131
+ loading.style.display = 'flex';
1132
+ content.innerHTML = '';
1133
+
1134
+ try {
1135
+ const response = await fetch(`${pgReportsRoot}/execute_query`, {
1136
+ method: 'POST',
1137
+ headers: {
1138
+ 'Content-Type': 'application/json',
1139
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1140
+ },
1141
+ body: JSON.stringify({ query: currentExplainQuery, params: params })
1142
+ });
1143
+
1144
+ const data = await response.json();
1145
+ loading.style.display = 'none';
1146
+
1147
+ if (data.success) {
1148
+ let html = '';
1149
+
1150
+ // Show info
1151
+ html += `<div class="query-results-info">
1152
+ <span>Rows: <span class="count">${data.count}</span></span>
1153
+ <span>Execution time: <span class="time">${data.execution_time} ms</span></span>
1154
+ </div>`;
1155
+
1156
+ if (data.rows && data.rows.length > 0) {
1157
+ html += '<div class="query-results-wrapper">';
1158
+ html += '<table class="query-results-table">';
1159
+
1160
+ // Header
1161
+ html += '<thead><tr>';
1162
+ data.columns.forEach(col => {
1163
+ html += `<th>${escapeHtml(col)}</th>`;
1164
+ });
1165
+ html += '</tr></thead>';
1166
+
1167
+ // Body
1168
+ html += '<tbody>';
1169
+ data.rows.forEach(row => {
1170
+ html += '<tr>';
1171
+ data.columns.forEach(col => {
1172
+ const value = row[col];
1173
+ const displayValue = value === null ? '<null>' : String(value);
1174
+ html += `<td title="${escapeHtmlAttr(displayValue)}">${escapeHtml(displayValue)}</td>`;
1175
+ });
1176
+ html += '</tr>';
1177
+ });
1178
+ html += '</tbody>';
1179
+
1180
+ html += '</table>';
1181
+ html += '</div>';
1182
+
1183
+ if (data.truncated) {
1184
+ html += `<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.75rem;">Showing first ${data.rows.length} of ${data.total_count} rows</p>`;
1185
+ }
1186
+ } else {
1187
+ html += '<p style="margin-top: 1rem; color: var(--text-muted);">No rows returned</p>';
1188
+ }
1189
+
1190
+ content.innerHTML = html;
1191
+ } else {
1192
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to execute query')}</div>`;
1193
+ }
1194
+ } catch (error) {
1195
+ loading.style.display = 'none';
1196
+ content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
1197
+ }
1198
+ }
1199
+
1200
+ function closeExplainModal() {
1201
+ document.getElementById('explain-modal').style.display = 'none';
1202
+ }
1203
+
1204
+ document.getElementById('explain-modal')?.addEventListener('click', function(e) {
1205
+ if (e.target === this) closeExplainModal();
1206
+ });
1207
+
1208
+ // ==========================================
1209
+ // MIGRATION GENERATION FUNCTIONALITY
1210
+ // ==========================================
1211
+
1212
+ let currentMigrationData = null;
1213
+
1214
+ function showMigrationModal(indexName, tableName, schemaName) {
1215
+ const migrationName = `remove_${indexName.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
1216
+ const timestamp = new Date().toISOString().replace(/[-:T]/g, '').substring(0, 14);
1217
+ const className = migrationName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1218
+
1219
+ const fullIndexName = schemaName && schemaName !== 'public'
1220
+ ? `${schemaName}.${indexName}`
1221
+ : indexName;
1222
+
1223
+ const migrationCode = `# frozen_string_literal: true
1224
+
1225
+ class ${className} < ActiveRecord::Migration[7.0]
1226
+ def change
1227
+ remove_index :${tableName}, name: :${indexName}, if_exists: true
1228
+ end
1229
+ end
1230
+ `;
1231
+
1232
+ currentMigrationData = {
1233
+ fileName: `${timestamp}_${migrationName}.rb`,
1234
+ code: migrationCode,
1235
+ indexName,
1236
+ tableName,
1237
+ schemaName
1238
+ };
1239
+
1240
+ document.getElementById('migration-code').textContent = migrationCode;
1241
+ document.getElementById('migration-modal').style.display = 'flex';
1242
+ }
1243
+
1244
+ function closeMigrationModal() {
1245
+ document.getElementById('migration-modal').style.display = 'none';
1246
+ }
1247
+
1248
+ document.getElementById('migration-modal')?.addEventListener('click', function(e) {
1249
+ if (e.target === this) closeMigrationModal();
1250
+ });
1251
+
1252
+ function copyMigrationCode() {
1253
+ if (!currentMigrationData) return;
1254
+ navigator.clipboard.writeText(currentMigrationData.code).then(() => {
1255
+ showToast('Migration code copied to clipboard');
1256
+ }).catch(() => {
1257
+ showToast('Failed to copy', 'error');
1258
+ });
1259
+ }
1260
+
1261
+ async function createMigrationFile() {
1262
+ if (!currentMigrationData) return;
1263
+
1264
+ try {
1265
+ const response = await fetch(`${pgReportsRoot}/create_migration`, {
1266
+ method: 'POST',
1267
+ headers: {
1268
+ 'Content-Type': 'application/json',
1269
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1270
+ },
1271
+ body: JSON.stringify({
1272
+ file_name: currentMigrationData.fileName,
1273
+ code: currentMigrationData.code
1274
+ })
1275
+ });
1276
+
1277
+ const data = await response.json();
1278
+
1279
+ if (data.success) {
1280
+ showToast('Migration file created');
1281
+ closeMigrationModal();
1282
+
1283
+ // Open in IDE if path provided
1284
+ if (data.file_path) {
1285
+ const ideUrls = generateIdeUrls(data.file_path, 1);
1286
+ const defaultIde = getDefaultIde();
1287
+
1288
+ if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
1289
+ window.location.href = ideUrls[ideKeyMap[defaultIde]].url;
1290
+ } else if (ideUrls.length > 0) {
1291
+ window.location.href = ideUrls[0].url;
1292
+ }
1293
+ }
1294
+ } else {
1295
+ showToast(data.error || 'Failed to create migration', 'error');
1296
+ }
1297
+ } catch (error) {
1298
+ showToast('Network error: ' + error.message, 'error');
1299
+ }
1300
+ }
1301
+ </script>