pg_reports 0.8.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 %>
@@ -0,0 +1,55 @@
1
+ <%# Shared by the dashboard and the report pages. Language is persisted server
2
+ side (session, via a form post); the IDE choice is client side in
3
+ localStorage — hence one <form> section and one plain section. %>
4
+ <div id="settings-modal" class="modal" style="display: none;">
5
+ <div class="modal-content modal-small">
6
+ <div class="modal-header">
7
+ <h3><%= t("pg_reports.ui.modals.settings_title") %></h3>
8
+ <button class="modal-close" onclick="closeSettingsModal()" aria-label="<%= t("pg_reports.ui.actions.cancel") %>">&times;</button>
9
+ </div>
10
+ <div class="modal-body">
11
+ <% if dashboard_locales.size > 1 %>
12
+ <section class="settings-section">
13
+ <h4 class="settings-section-title"><%= t("pg_reports.ui.language.label") %></h4>
14
+ <form class="settings-options"
15
+ action="<%= switch_locale_path %>"
16
+ method="post"
17
+ data-turbo="false">
18
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
19
+ <% dashboard_locales.each do |locale| %>
20
+ <%# Each option is named in its own language, not the current one. %>
21
+ <label class="settings-option">
22
+ <input type="radio"
23
+ name="locale"
24
+ value="<%= locale %>"
25
+ <%= "checked" if locale.to_s == selected_locale.to_s %>
26
+ onchange="rememberSettingsOpen(); this.form.submit()">
27
+ <span><%= t("pg_reports.ui.language.name", locale: locale, default: locale.to_s.upcase) %></span>
28
+ </label>
29
+ <% end %>
30
+ </form>
31
+ </section>
32
+ <% end %>
33
+
34
+ <section class="settings-section">
35
+ <h4 class="settings-section-title"><%= t("pg_reports.ui.settings.default_ide_label") %></h4>
36
+ <div class="settings-options">
37
+ <% {
38
+ "" => "ide_show_menu",
39
+ "vscode-wsl" => "ide_vscode_wsl",
40
+ "vscode" => "ide_vscode",
41
+ "rubymine" => "ide_rubymine",
42
+ "intellij" => "ide_intellij",
43
+ "cursor-wsl" => "ide_cursor_wsl",
44
+ "cursor" => "ide_cursor"
45
+ }.each do |value, key| %>
46
+ <label class="settings-option">
47
+ <input type="radio" name="default-ide" value="<%= value %>" onchange="setDefaultIde('<%= value %>')">
48
+ <span><%= t("pg_reports.ui.settings.#{key}") %></span>
49
+ </label>
50
+ <% end %>
51
+ </div>
52
+ </section>
53
+ </div>
54
+ </div>
55
+ </div>
@@ -1,45 +1,4 @@
1
- <!-- IDE Settings Modal -->
2
- <div id="ide-settings-modal" class="modal" style="display: none;">
3
- <div class="modal-content modal-small">
4
- <div class="modal-header">
5
- <h3><%= t("pg_reports.ui.modals.ide_settings_title") %></h3>
6
- <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
7
- </div>
8
- <div class="modal-body">
9
- <p class="settings-label"><%= t("pg_reports.ui.settings.default_ide_label") %></p>
10
- <div class="ide-options">
11
- <label class="ide-option">
12
- <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
13
- <span><%= t("pg_reports.ui.settings.ide_show_menu") %></span>
14
- </label>
15
- <label class="ide-option">
16
- <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
17
- <span><%= t("pg_reports.ui.settings.ide_vscode_wsl") %></span>
18
- </label>
19
- <label class="ide-option">
20
- <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
21
- <span><%= t("pg_reports.ui.settings.ide_vscode") %></span>
22
- </label>
23
- <label class="ide-option">
24
- <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
25
- <span><%= t("pg_reports.ui.settings.ide_rubymine") %></span>
26
- </label>
27
- <label class="ide-option">
28
- <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
29
- <span><%= t("pg_reports.ui.settings.ide_intellij") %></span>
30
- </label>
31
- <label class="ide-option">
32
- <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
33
- <span><%= t("pg_reports.ui.settings.ide_cursor_wsl") %></span>
34
- </label>
35
- <label class="ide-option">
36
- <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
37
- <span><%= t("pg_reports.ui.settings.ide_cursor") %></span>
38
- </label>
39
- </div>
40
- </div>
41
- </div>
42
- </div>
1
+ <%= render "settings_modal" %>
43
2
 
44
3
  <!-- Problem Explanation Modal -->
45
4
  <div id="problem-modal" class="problem-modal" style="display: none;">
@@ -71,7 +30,7 @@
71
30
  <div id="explain-params-inputs"></div>
72
31
  </div>
73
32
  <div class="explain-actions">
74
- <button class="btn btn-secondary" onclick="executeExplainAnalyze()" id="btn-explain">
33
+ <button class="btn btn-primary" onclick="executeExplainAnalyze()" id="btn-explain">
75
34
  <%= t("pg_reports.ui.actions.explain_analyze") %>
76
35
  </button>
77
36
  <button class="btn btn-secondary" onclick="executeQuery()" id="btn-execute">
@@ -94,12 +53,15 @@
94
53
  <button class="modal-close" onclick="closeMigrationModal()">&times;</button>
95
54
  </div>
96
55
  <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>
56
+ <div class="callout callout-warning">
57
+ <svg class="icon" aria-hidden="true"><use href="#i-alert"></use></svg>
58
+ <div>
59
+ <strong><%= t("pg_reports.ui.documentation.threshold_warning_label").chomp(":") %></strong>
60
+ <p>
61
+ <%= t("pg_reports.ui.modals.migration_warning") %>
62
+ <strong><%= t("pg_reports.ui.modals.migration_warning_dev_only") %></strong>
63
+ </p>
64
+ </div>
103
65
  </div>
104
66
  <p class="settings-label"><%= t("pg_reports.ui.modals.migration_subtitle") %></p>
105
67
  <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
 
@@ -780,7 +854,7 @@
780
854
  document.addEventListener('keydown', function(e) {
781
855
  if (e.key === 'Escape') {
782
856
  closeProblemModal();
783
- closeIdeSettingsModal();
857
+ closeSettingsModal();
784
858
  closeExplainModal();
785
859
  closeMigrationModal();
786
860
  }
@@ -793,44 +867,6 @@
793
867
  }
794
868
  });
795
869
 
796
- // IDE Settings functions
797
- function showIdeSettingsModal() {
798
- const modal = document.getElementById('ide-settings-modal');
799
- modal.style.display = 'flex';
800
- loadIdeSettingsState();
801
- }
802
-
803
- function closeIdeSettingsModal() {
804
- document.getElementById('ide-settings-modal').style.display = 'none';
805
- }
806
-
807
- function setDefaultIde(ideKey) {
808
- if (ideKey) {
809
- localStorage.setItem('pgReportsDefaultIde', ideKey);
810
- } else {
811
- localStorage.removeItem('pgReportsDefaultIde');
812
- }
813
- }
814
-
815
- function getDefaultIde() {
816
- return localStorage.getItem('pgReportsDefaultIde') || '';
817
- }
818
-
819
- function loadIdeSettingsState() {
820
- const currentIde = getDefaultIde();
821
- const radios = document.querySelectorAll('input[name="default-ide"]');
822
- radios.forEach(radio => {
823
- radio.checked = (radio.value === currentIde);
824
- });
825
- }
826
-
827
- // Close IDE settings modal on backdrop click
828
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
829
- if (e.target === this) {
830
- closeIdeSettingsModal();
831
- }
832
- });
833
-
834
870
  // ==========================================
835
871
  // SAVED RECORDS FUNCTIONALITY
836
872
  // ==========================================
@@ -1158,15 +1194,15 @@
1158
1194
  if (data.summary && data.summary.total_problems > 0) {
1159
1195
  html += '<div class="explain-summary explain-summary-' + data.summary.status + '">';
1160
1196
  html += '<div class="explain-summary-header">';
1161
- html += '<span class="explain-summary-icon">' + data.summary.status_icon + '</span>';
1197
+ html += svgIcon(data.summary.status === 'good' ? 'check' : 'alert', 'explain-summary-icon');
1162
1198
  html += '<span class="explain-summary-title">' + escapeHtml(data.summary.status_text) + '</span>';
1163
1199
  html += '</div>';
1164
1200
  html += '<div class="explain-summary-stats">';
1165
1201
  if (data.summary.critical_problems > 0) {
1166
- html += '<span class="explain-summary-stat critical">🔴 ' + data.summary.critical_problems + ' critical</span>';
1202
+ html += '<span class="explain-summary-stat critical">' + svgIcon('alert') + data.summary.critical_problems + ' critical</span>';
1167
1203
  }
1168
1204
  if (data.summary.warnings > 0) {
1169
- html += '<span class="explain-summary-stat warning">⚠️ ' + data.summary.warnings + ' warnings</span>';
1205
+ html += '<span class="explain-summary-stat warning">' + svgIcon('alert') + data.summary.warnings + ' warnings</span>';
1170
1206
  }
1171
1207
  html += '</div>';
1172
1208
  html += '</div>';
@@ -1212,7 +1248,7 @@
1212
1248
 
1213
1249
  data.problems.forEach(problem => {
1214
1250
  const severityClass = 'problem-' + problem.severity;
1215
- const severityIcon = problem.severity === 'critical' ? '🔴' : (problem.severity === 'warning' ? '⚠️' : 'ℹ️');
1251
+ const severityIcon = svgIcon(problem.severity === 'info' ? 'info' : 'alert');
1216
1252
 
1217
1253
  html += '<div class="explain-problem ' + severityClass + '">';
1218
1254
  html += '<div class="explain-problem-header">';
@@ -1226,7 +1262,7 @@
1226
1262
  html += '<div class="explain-problem-details">' + escapeHtml(problem.details) + '</div>';
1227
1263
  }
1228
1264
  if (problem.recommendation) {
1229
- html += '<div class="explain-problem-recommendation">💡 ' + escapeHtml(problem.recommendation) + '</div>';
1265
+ html += '<div class="explain-problem-recommendation">' + svgIcon('bulb') + escapeHtml(problem.recommendation) + '</div>';
1230
1266
  }
1231
1267
  html += '</div>';
1232
1268
  });
@@ -1301,7 +1337,7 @@
1301
1337
 
1302
1338
  // Problem indicator
1303
1339
  if (lineProblem) {
1304
- const problemIcon = lineProblem.severity === 'critical' ? '🔴' : '⚠️';
1340
+ const problemIcon = svgIcon('alert');
1305
1341
  html += '<span class="explain-line-problem-indicator" title="' + escapeHtml(lineProblem.message) + '">' + problemIcon + '</span>';
1306
1342
  }
1307
1343
 
@@ -1338,15 +1374,7 @@
1338
1374
  // Security check
1339
1375
  if (!allowRawQueryExecution) {
1340
1376
  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>`;
1377
+ content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
1350
1378
  return;
1351
1379
  }
1352
1380
 
@@ -1387,15 +1415,7 @@ end
1387
1415
  // Security check
1388
1416
  if (!allowRawQueryExecution) {
1389
1417
  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>`;
1418
+ content.innerHTML = pgReportsDisabledNotice(PG_REPORTS_I18N.modals.query_execution_disabled_title);
1399
1419
  return;
1400
1420
  }
1401
1421
 
@@ -1552,24 +1572,15 @@ end
1552
1572
 
1553
1573
  // Security check
1554
1574
  if (!allowRawQueryExecution) {
1555
- const message = '⚠️ Создание миграций отключено. Включите в конфигурации: config.allow_raw_query_execution = true';
1575
+ const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
1556
1576
  showToast(message, 'error');
1557
1577
 
1558
1578
  const modalBody = document.getElementById('migration-modal-body');
1559
1579
  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);
1580
+ modalBody.insertAdjacentHTML(
1581
+ 'beforeend',
1582
+ pgReportsDisabledNotice(PG_REPORTS_I18N.modals.migration_disabled_title)
1583
+ );
1573
1584
  }
1574
1585
  return;
1575
1586
  }