pg_reports 0.8.1 → 0.9.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.
@@ -34,6 +34,7 @@
34
34
  </span>
35
35
  <% elsif @database_error %>
36
36
  <span class="db-selector-error" title="<%= @database_error[:detail] %>">
37
- <%= @database_error[:title] %>
37
+ <svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>
38
+ <%= @database_error[:title] %>
38
39
  </span>
39
40
  <% end %>
@@ -71,7 +71,7 @@
71
71
  <div id="explain-params-inputs"></div>
72
72
  </div>
73
73
  <div class="explain-actions">
74
- <button class="btn btn-secondary" onclick="executeExplainAnalyze()" id="btn-explain">
74
+ <button class="btn btn-primary" onclick="executeExplainAnalyze()" id="btn-explain">
75
75
  <%= t("pg_reports.ui.actions.explain_analyze") %>
76
76
  </button>
77
77
  <button class="btn btn-secondary" onclick="executeQuery()" id="btn-execute">
@@ -94,12 +94,15 @@
94
94
  <button class="modal-close" onclick="closeMigrationModal()">&times;</button>
95
95
  </div>
96
96
  <div class="modal-body" id="migration-modal-body">
97
- <div class="migration-warning" style="background: rgba(255, 152, 0, 0.1); border: 1px solid rgba(255, 152, 0, 0.3); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
98
- <p style="margin: 0 0 0.5rem 0; font-weight: 600; color: #ffb74d;"><%= t("pg_reports.ui.documentation.threshold_warning_label").chomp(":") %></p>
99
- <p style="margin: 0; color: #ffcc80; font-size: 0.875rem; line-height: 1.5;">
100
- <%= t("pg_reports.ui.modals.migration_warning") %>
101
- <strong><%= t("pg_reports.ui.modals.migration_warning_dev_only") %></strong>
102
- </p>
97
+ <div class="callout callout-warning">
98
+ <svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>
99
+ <div>
100
+ <strong><%= t("pg_reports.ui.documentation.threshold_warning_label").chomp(":") %></strong>
101
+ <p>
102
+ <%= t("pg_reports.ui.modals.migration_warning") %>
103
+ <strong><%= t("pg_reports.ui.modals.migration_warning_dev_only") %></strong>
104
+ </p>
105
+ </div>
103
106
  </div>
104
107
  <p class="settings-label"><%= t("pg_reports.ui.modals.migration_subtitle") %></p>
105
108
  <div id="migration-code" class="migration-code"></div>
@@ -1,4 +1,5 @@
1
1
  <script>
2
+ const svgIcon = (name, cls) => `<svg class="icon ${cls || ''}"><use href="#i-${name}"></use></svg>`;
2
3
  let currentReportData = null;
3
4
  let currentProblemExplanations = {};
4
5
  const category = '<%= @category %>';
@@ -18,8 +19,10 @@
18
19
 
19
20
  if (!topScrollWrapper || !topScrollContent || !tableWrapper || !table) return;
20
21
 
21
- // Check if table overflows
22
- if (table.scrollWidth > tableWrapper.clientWidth) {
22
+ // Check if table overflows. On touch there is no visible scrollbar to
23
+ // drag, so the proxy would only be an empty strip above the table.
24
+ const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
25
+ if (table.scrollWidth > tableWrapper.clientWidth && !coarsePointer) {
23
26
  topScrollWrapper.style.display = 'block';
24
27
  topScrollContent.style.width = table.scrollWidth + 'px';
25
28
 
@@ -126,7 +129,7 @@
126
129
  if (problemInfo && problemInfo.level) {
127
130
  rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
128
131
  const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
129
- const indicatorIcon = problemInfo.level === 'critical' ? '🔴' : '⚠️';
132
+ const indicatorIcon = svgIcon('alert');
130
133
  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>`;
131
134
  }
132
135
 
@@ -157,6 +160,8 @@
157
160
 
158
161
  tableBody.innerHTML = rowsHtml;
159
162
 
163
+ markClippedSources(tableBody);
164
+
160
165
  // Re-setup top scrollbar
161
166
  setTimeout(setupTopScrollbar, 0);
162
167
  }
@@ -409,11 +414,16 @@
409
414
  let lineNumber = null;
410
415
  let methodName = null;
411
416
 
412
- // Try to match file:line pattern
413
- const fileLineMatch = source.match(/^([^:]+\.(rb|erb|js|ts|py|go|java)):(\d+)/);
417
+ // Try to match file:line pattern. Deliberately no extension allow-list —
418
+ // source locations legitimately end in .rake, .jbuilder, .ru, .haml, or in
419
+ // no extension at all (Rakefile:5), and anything the list missed silently
420
+ // lost its shortening and its IDE link. Lazy up to the first ":<line>" that
421
+ // ends the token, so a trailing ":in `method'" or a Windows drive letter
422
+ // ("C:\app\x.rb:12") doesn't derail it.
423
+ const fileLineMatch = source.match(/^(.+?):(\d+)(?=$|[:\s])/);
414
424
  if (fileLineMatch) {
415
425
  filePath = fileLineMatch[1];
416
- lineNumber = parseInt(fileLineMatch[3], 10);
426
+ lineNumber = parseInt(fileLineMatch[2], 10);
417
427
  }
418
428
 
419
429
  // Try to match Controller#action pattern (e.g., PostsController#index)
@@ -449,6 +459,70 @@
449
459
  const railsRootPath = '<%= Rails.root.to_s %>';
450
460
  const wslDistro = 'Ubuntu';
451
461
 
462
+ // Prefix to strip when displaying a source path. Only meaningful when the
463
+ // engine is mounted in a host app — in standalone mode Rails.root is a
464
+ // throwaway tmpdir that no query's source location will ever sit under.
465
+ const sourceRootPath = <%== (PgReports.config.standalone ? nil : Rails.root.to_s).to_json %>;
466
+
467
+ // The badge shows the shortest path that still identifies the line. The full
468
+ // value stays in the tooltip, the expanded row and the exports; IDE links are
469
+ // built from the unshortened path.
470
+ function shortenSourcePath(filePath) {
471
+ if (!filePath) return filePath;
472
+
473
+ if (sourceRootPath && filePath.startsWith(sourceRootPath + '/')) {
474
+ return filePath.slice(sourceRootPath.length + 1);
475
+ }
476
+
477
+ // A bundled gem: everything before the gem's own directory is install noise.
478
+ // The greedy prefix is deliberate — a gem home nests "/gems/" twice
479
+ // ("…/lib/ruby/gems/3.4.0/gems/activerecord-8.1.3/…") and only the last one
480
+ // starts the gem's own path.
481
+ const gem = filePath.match(/^.*\/gems\/([^/]+\/.+)$/);
482
+ if (gem) return gem[1];
483
+
484
+ return filePath;
485
+ }
486
+
487
+ // Split so the directory can be clipped while the file name and line — the part
488
+ // you actually navigate by — stay readable. The directory is clipped at its
489
+ // *start*: see .source-dir in the styles, and markClippedSources below for the
490
+ // leading ellipsis.
491
+ function sourceBadgeLabel(parsed) {
492
+ if (!parsed.filePath) {
493
+ return `<span class="source-file"><span>${escapeHtml(parsed.original)}</span></span>`;
494
+ }
495
+
496
+ const shortened = shortenSourcePath(parsed.filePath);
497
+ const line = parsed.lineNumber ? ':' + parsed.lineNumber : '';
498
+ const cut = shortened.lastIndexOf('/');
499
+
500
+ const ellipsis = '<span class="source-ellipsis" aria-hidden="true">…</span>';
501
+ const file = (text) => `<span class="source-file"><span>${escapeHtml(text)}</span></span>`;
502
+
503
+ if (cut < 0) return ellipsis + file(shortened + line);
504
+
505
+ return ellipsis +
506
+ `<span class="source-dir"><span>${escapeHtml(shortened.slice(0, cut))}</span></span>` +
507
+ file(shortened.slice(cut) + line);
508
+ }
509
+
510
+ // Whether a part actually overflows can only be known after layout, and the
511
+ // leading ellipsis must not appear when nothing is cut. Measured on the inner
512
+ // span, not on its container: the overflow here runs off the *start* edge, and
513
+ // scrollWidth only ever reports overflow past the end edge.
514
+ function markClippedSources(root) {
515
+ (root || document).querySelectorAll('.source-badge').forEach(badge => {
516
+ const parts = badge.querySelectorAll('.source-dir, .source-file');
517
+ const clipped = Array.prototype.some.call(parts, part => {
518
+ const inner = part.firstElementChild;
519
+ return inner && inner.getBoundingClientRect().width > part.clientWidth + 1;
520
+ });
521
+
522
+ badge.classList.toggle('is-clipped', clipped);
523
+ });
524
+ }
525
+
452
526
  // Generate IDE URLs
453
527
  function generateIdeUrls(filePath, lineNumber) {
454
528
  if (!filePath) return [];
@@ -521,7 +595,7 @@
521
595
  const ideUrls = generateIdeUrls(parsed.filePath, parsed.lineNumber);
522
596
 
523
597
  if (ideUrls.length === 0) {
524
- return `<span class="source-badge" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>`;
598
+ return `<span class="source-badge" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</span>`;
525
599
  }
526
600
 
527
601
  // Check if user has a default IDE set
@@ -529,7 +603,7 @@
529
603
  if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
530
604
  const ideUrl = ideUrls[ideKeyMap[defaultIde]];
531
605
  if (ideUrl) {
532
- return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</a>`;
606
+ return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
533
607
  }
534
608
  }
535
609
 
@@ -538,7 +612,7 @@
538
612
 
539
613
  let dropdownHtml = `
540
614
  <div class="ide-dropdown">
541
- <span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${escapeHtml(parsed.original)}">${escapeHtml(parsed.original)}</span>
615
+ <span class="source-badge clickable" data-dropdown-id="${dropdownId}" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</span>
542
616
  <div class="ide-dropdown-menu" id="${dropdownId}">
543
617
  `;
544
618
 
@@ -642,7 +716,7 @@
642
716
  const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
643
717
 
644
718
  html += `<div class="detail-actions">`;
645
- html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${isSaved ? '📌 Saved' : '📌 Save for Comparison'}</button>`;
719
+ html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
646
720
 
647
721
  if (hasQuery) {
648
722
  // Only show EXPLAIN ANALYZE for SELECT queries
@@ -651,7 +725,7 @@
651
725
  // Use data attribute to pass query hash for security
652
726
  const queryBase64 = btoa(unescape(encodeURIComponent(row.query)));
653
727
  const queryHash = row.query_hash || '';
654
- html += `<button class="btn-explain" data-query-b64="${queryBase64}" data-query-hash="${queryHash}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">📊 EXPLAIN ANALYZE</button>`;
728
+ html += `<button class="btn-explain" data-query-b64="${queryBase64}" data-query-hash="${queryHash}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">${svgIcon('activity')}EXPLAIN ANALYZE</button>`;
655
729
  }
656
730
  }
657
731
 
@@ -661,9 +735,9 @@
661
735
  const tableName = row.table_name || row.tablename || '';
662
736
  const schemaName = row.schema_name || row.schemaname || 'public';
663
737
  if (isDevelopment) {
664
- html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">🗑️ Generate Migration</button>`;
738
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">${svgIcon('trash')}Generate Migration</button>`;
665
739
  } else {
666
- html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" style="opacity: 0.5; cursor: not-allowed;">🗑️ Generate Migration (Development Only)</button>`;
740
+ html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" >${svgIcon('trash')}Generate Migration (Development Only)</button>`;
667
741
  }
668
742
  }
669
743
 
@@ -1158,15 +1232,15 @@
1158
1232
  if (data.summary && data.summary.total_problems > 0) {
1159
1233
  html += '<div class="explain-summary explain-summary-' + data.summary.status + '">';
1160
1234
  html += '<div class="explain-summary-header">';
1161
- html += '<span class="explain-summary-icon">' + data.summary.status_icon + '</span>';
1235
+ html += svgIcon(data.summary.status === 'good' ? 'check' : 'alert', 'explain-summary-icon');
1162
1236
  html += '<span class="explain-summary-title">' + escapeHtml(data.summary.status_text) + '</span>';
1163
1237
  html += '</div>';
1164
1238
  html += '<div class="explain-summary-stats">';
1165
1239
  if (data.summary.critical_problems > 0) {
1166
- html += '<span class="explain-summary-stat critical">🔴 ' + data.summary.critical_problems + ' critical</span>';
1240
+ html += '<span class="explain-summary-stat critical">' + svgIcon('alert') + data.summary.critical_problems + ' critical</span>';
1167
1241
  }
1168
1242
  if (data.summary.warnings > 0) {
1169
- html += '<span class="explain-summary-stat warning">⚠️ ' + data.summary.warnings + ' warnings</span>';
1243
+ html += '<span class="explain-summary-stat warning">' + svgIcon('alert') + data.summary.warnings + ' warnings</span>';
1170
1244
  }
1171
1245
  html += '</div>';
1172
1246
  html += '</div>';
@@ -1212,7 +1286,7 @@
1212
1286
 
1213
1287
  data.problems.forEach(problem => {
1214
1288
  const severityClass = 'problem-' + problem.severity;
1215
- const severityIcon = problem.severity === 'critical' ? '🔴' : (problem.severity === 'warning' ? '⚠️' : 'ℹ️');
1289
+ const severityIcon = svgIcon(problem.severity === 'info' ? 'info' : 'alert');
1216
1290
 
1217
1291
  html += '<div class="explain-problem ' + severityClass + '">';
1218
1292
  html += '<div class="explain-problem-header">';
@@ -1226,7 +1300,7 @@
1226
1300
  html += '<div class="explain-problem-details">' + escapeHtml(problem.details) + '</div>';
1227
1301
  }
1228
1302
  if (problem.recommendation) {
1229
- html += '<div class="explain-problem-recommendation">💡 ' + escapeHtml(problem.recommendation) + '</div>';
1303
+ html += '<div class="explain-problem-recommendation">' + svgIcon('bulb') + escapeHtml(problem.recommendation) + '</div>';
1230
1304
  }
1231
1305
  html += '</div>';
1232
1306
  });
@@ -1301,7 +1375,7 @@
1301
1375
 
1302
1376
  // Problem indicator
1303
1377
  if (lineProblem) {
1304
- const problemIcon = lineProblem.severity === 'critical' ? '🔴' : '⚠️';
1378
+ const problemIcon = svgIcon('alert');
1305
1379
  html += '<span class="explain-line-problem-indicator" title="' + escapeHtml(lineProblem.message) + '">' + problemIcon + '</span>';
1306
1380
  }
1307
1381
 
@@ -1338,15 +1412,7 @@
1338
1412
  // Security check
1339
1413
  if (!allowRawQueryExecution) {
1340
1414
  showToast(PG_REPORTS_I18N.errors.explain_disabled_toast, 'error');
1341
- content.innerHTML = `<div class="error-message">
1342
- <strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
1343
- ${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
1344
- <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1345
- PgReports.configure do |config|<br>
1346
- &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1347
- end
1348
- </code>
1349
- </div>`;
1415
+ content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
1350
1416
  return;
1351
1417
  }
1352
1418
 
@@ -1387,15 +1453,7 @@ end
1387
1453
  // Security check
1388
1454
  if (!allowRawQueryExecution) {
1389
1455
  showToast(PG_REPORTS_I18N.errors.execute_disabled_toast, 'error');
1390
- content.innerHTML = `<div class="error-message">
1391
- <strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
1392
- ${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
1393
- <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1394
- PgReports.configure do |config|<br>
1395
- &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1396
- end
1397
- </code>
1398
- </div>`;
1456
+ content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
1399
1457
  return;
1400
1458
  }
1401
1459
 
@@ -1552,24 +1610,15 @@ end
1552
1610
 
1553
1611
  // Security check
1554
1612
  if (!allowRawQueryExecution) {
1555
- const message = '⚠️ Создание миграций отключено. Включите в конфигурации: config.allow_raw_query_execution = true';
1613
+ const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
1556
1614
  showToast(message, 'error');
1557
1615
 
1558
1616
  const modalBody = document.getElementById('migration-modal-body');
1559
1617
  if (modalBody) {
1560
- const errorDiv = document.createElement('div');
1561
- errorDiv.className = 'error-message';
1562
- errorDiv.style.marginTop = '1rem';
1563
- errorDiv.innerHTML = `
1564
- <strong>⚠️ Migration creation is disabled</strong><br><br>
1565
- To enable this feature, add to your configuration:<br>
1566
- <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1567
- PgReports.configure do |config|<br>
1568
- &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1569
- end
1570
- </code>
1571
- `;
1572
- modalBody.appendChild(errorDiv);
1618
+ modalBody.insertAdjacentHTML(
1619
+ 'beforeend',
1620
+ pgReportsDisabledNotice(PG_REPORTS_I18N.modals.migration_disabled_title)
1621
+ );
1573
1622
  }
1574
1623
  return;
1575
1624
  }