pg_reports 0.9.1 → 1.0.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +126 -80
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
  8. data/app/views/pg_reports/dashboard/index.html.erb +310 -124
  9. data/bin/pg_reports +2 -2
  10. data/config/brakeman.ignore +10 -56
  11. data/config/locales/en.yml +39 -8
  12. data/config/locales/ru.yml +38 -8
  13. data/config/locales/uk.yml +38 -8
  14. data/config/routes.rb +1 -0
  15. data/lib/pg_reports/access_policy.rb +39 -0
  16. data/lib/pg_reports/compatibility.rb +10 -2
  17. data/lib/pg_reports/configuration.rb +33 -2
  18. data/lib/pg_reports/connection/error_translator.rb +21 -0
  19. data/lib/pg_reports/connection/target.rb +43 -10
  20. data/lib/pg_reports/csv_cell.rb +20 -0
  21. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  22. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  23. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  24. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  25. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  26. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  27. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  28. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  29. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  30. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  31. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  32. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  33. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  34. data/lib/pg_reports/drop_index_migration.rb +56 -0
  35. data/lib/pg_reports/engine.rb +4 -3
  36. data/lib/pg_reports/executor.rb +46 -9
  37. data/lib/pg_reports/modules/queries.rb +1 -0
  38. data/lib/pg_reports/query_monitor.rb +478 -192
  39. data/lib/pg_reports/rate_limiter.rb +63 -0
  40. data/lib/pg_reports/report.rb +1 -1
  41. data/lib/pg_reports/report_definition.rb +11 -1
  42. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  43. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  44. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  45. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  46. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  47. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  48. data/lib/pg_reports/sql_redactor.rb +32 -0
  49. data/lib/pg_reports/standalone.rb +83 -20
  50. data/lib/pg_reports/version.rb +1 -1
  51. data/lib/pg_reports.rb +7 -2
  52. metadata +17 -9
@@ -6,7 +6,7 @@ module PgReports
6
6
  class MetricsController < ActionController::Base
7
7
  CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
8
8
 
9
- before_action :authenticate_metrics!, if: -> { PgReports.config.grafana_metrics_token.present? }
9
+ before_action :authenticate_metrics!
10
10
 
11
11
  def show
12
12
  render plain: PgReports::Grafana::Exporter.render, content_type: CONTENT_TYPE
@@ -14,14 +14,23 @@ module PgReports
14
14
 
15
15
  private
16
16
 
17
+ # Row labels can hold table names and query text, so without a token this is
18
+ # held to the same rule as the dashboard.
17
19
  def authenticate_metrics!
18
- expected = PgReports.config.grafana_metrics_token.to_s
20
+ token = PgReports.config.grafana_metrics_token
21
+ return if token == AccessPolicy::HOST_MANAGED
22
+
23
+ if token.blank?
24
+ head :forbidden unless AccessPolicy.open_access_allowed?(request)
25
+ return
26
+ end
27
+
19
28
  provided = request.headers["Authorization"].to_s.sub(/\Abearer\s+/i, "")
20
29
 
21
- a = ::Digest::SHA256.digest(expected)
30
+ a = ::Digest::SHA256.digest(token.to_s)
22
31
  b = ::Digest::SHA256.digest(provided)
23
32
 
24
- head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(a, b) && expected.present?
33
+ head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(a, b)
25
34
  end
26
35
  end
27
36
  end
@@ -1089,14 +1089,63 @@
1089
1089
  font-weight: 600;
1090
1090
  }
1091
1091
 
1092
+ .query-source-switch {
1093
+ display: inline-flex;
1094
+ border: 1px solid var(--border-color);
1095
+ border-radius: var(--radius-sm);
1096
+ overflow: hidden;
1097
+ }
1098
+
1099
+ .query-source-switch[hidden] {
1100
+ display: none;
1101
+ }
1102
+
1103
+ .query-source-switch button {
1104
+ padding: 0.3rem 0.6rem;
1105
+ background: transparent;
1106
+ border: none;
1107
+ color: var(--text-muted);
1108
+ font-size: 0.72rem;
1109
+ cursor: pointer;
1110
+ }
1111
+
1112
+ .query-source-switch button + button {
1113
+ border-left: 1px solid var(--border-color);
1114
+ }
1115
+
1116
+ .query-source-switch button.active {
1117
+ background: var(--bg-tertiary);
1118
+ color: var(--text-primary);
1119
+ }
1120
+
1121
+ .query-source-switch button:disabled {
1122
+ cursor: not-allowed;
1123
+ opacity: 0.6;
1124
+ }
1125
+
1126
+ .query-scope-warning {
1127
+ margin-bottom: 0.75rem;
1128
+ padding: 0.5rem 0.75rem;
1129
+ font-size: 0.75rem;
1130
+ color: var(--accent-amber);
1131
+ background: var(--amber-soft);
1132
+ border: 1px solid var(--amber-line);
1133
+ border-radius: var(--radius-sm);
1134
+ }
1135
+
1136
+ .query-monitor-error {
1137
+ color: var(--accent-rose);
1138
+ background: var(--rose-soft);
1139
+ border-color: var(--rose-line);
1140
+ }
1141
+
1092
1142
  /* Query Feed */
1093
1143
  .query-feed {
1094
- max-height: 500px;
1144
+ max-height: 420px;
1095
1145
  overflow-y: auto;
1096
1146
  background: var(--bg-tertiary);
1097
1147
  border: 1px solid var(--border-color);
1098
1148
  border-radius: 6px;
1099
- padding: 0.75rem;
1100
1149
  }
1101
1150
 
1102
1151
  .query-feed::-webkit-scrollbar {
@@ -1124,118 +1173,107 @@
1124
1173
  font-size: 0.8rem;
1125
1174
  }
1126
1175
 
1127
- .query-item {
1128
- background: var(--bg-card);
1129
- border: 1px solid var(--border-color);
1130
- border-radius: 6px;
1131
- padding: 0.5rem;
1132
- margin-bottom: 0.5rem;
1133
- font-size: 0.8rem;
1134
- transition: all 0.15s;
1176
+ .query-feed-table {
1177
+ width: 100%;
1178
+ /* Fixed, so the SQL cell ellipsizes instead of widening the table */
1179
+ table-layout: fixed;
1180
+ border-collapse: collapse;
1181
+ font-size: 0.72rem;
1135
1182
  }
1136
1183
 
1137
- .query-item:hover {
1138
- border-color: var(--border-strong);
1184
+ .query-feed-table th {
1185
+ position: sticky;
1186
+ top: 0;
1187
+ z-index: 1;
1188
+ padding: 0.3rem 0.6rem;
1189
+ background: var(--bg-secondary);
1190
+ border-bottom: 1px solid var(--border-color);
1191
+ color: var(--text-muted);
1192
+ font-weight: 500;
1193
+ text-align: left;
1139
1194
  }
1140
1195
 
1141
- .query-item:last-child {
1142
- margin-bottom: 0;
1196
+ .query-feed-table td {
1197
+ padding: 0.2rem 0.6rem;
1198
+ border-bottom: 1px solid var(--border-color);
1199
+ color: var(--text-secondary);
1200
+ white-space: nowrap;
1201
+ overflow: hidden;
1202
+ text-overflow: ellipsis;
1143
1203
  }
1144
1204
 
1145
- .query-header {
1146
- display: flex;
1147
- justify-content: space-between;
1148
- align-items: center;
1149
- margin-bottom: 0.3rem;
1205
+ .query-feed-table .col-time { width: 6.5rem; }
1206
+ .query-feed-table .col-duration { width: 4.5rem; text-align: right; }
1207
+ .query-feed-table .col-source { width: 15rem; }
1208
+
1209
+ .query-feed-table td.col-time,
1210
+ .query-feed-table td.col-duration {
1211
+ font-variant-numeric: tabular-nums;
1150
1212
  }
1151
1213
 
1152
- .query-meta {
1153
- display: flex;
1154
- gap: 0.6rem;
1155
- font-size: 0.7rem;
1156
- color: var(--text-muted);
1214
+ .query-feed-table td.col-sql,
1215
+ .query-feed-table td.col-source {
1216
+ font-family: <%= PgReports.config.load_external_fonts ? "'JetBrains Mono', " : "" %>monospace;
1157
1217
  }
1158
1218
 
1159
- .query-timestamp {
1160
- color: var(--text-secondary);
1161
- font-size: 0.7rem;
1219
+ .query-feed-table td.col-sql {
1220
+ color: var(--text-primary);
1221
+ }
1222
+
1223
+ .query-row {
1224
+ cursor: pointer;
1225
+ }
1226
+
1227
+ .query-row:hover td,
1228
+ .query-row.expanded td {
1229
+ background: var(--bg-card);
1230
+ }
1231
+
1232
+ .query-row.expanded td {
1233
+ border-bottom-color: transparent;
1162
1234
  }
1163
1235
 
1164
- .query-duration {
1236
+ .query-feed-table td.query-duration {
1165
1237
  color: var(--accent-amber);
1166
1238
  font-weight: 600;
1167
- font-size: 0.7rem;
1168
1239
  }
1169
1240
 
1170
- .query-duration.fast {
1241
+ .query-feed-table td.query-duration.fast {
1171
1242
  color: var(--accent-green);
1172
1243
  }
1173
1244
 
1174
- .query-duration.slow {
1245
+ .query-feed-table td.query-duration.slow {
1175
1246
  color: var(--accent-rose);
1176
1247
  }
1177
1248
 
1178
- .query-name {
1179
- color: var(--text-secondary);
1180
- font-style: italic;
1181
- font-size: 0.7rem;
1182
- }
1183
-
1184
- .query-expand-btn {
1185
- background: transparent;
1186
- border: none;
1187
- color: var(--text-muted);
1188
- cursor: pointer;
1189
- padding: 0.15rem 0.35rem;
1190
- font-size: 0.7rem;
1191
- transition: all 0.15s;
1192
- border-radius: 3px;
1249
+ .query-source-link {
1250
+ color: var(--accent-purple);
1251
+ text-decoration: none;
1193
1252
  }
1194
1253
 
1195
- .query-expand-btn:hover {
1196
- background: var(--bg-tertiary);
1197
- color: var(--accent-purple);
1254
+ .query-source-link:hover {
1255
+ text-decoration: underline;
1198
1256
  }
1199
1257
 
1200
- .query-sql-wrapper {
1201
- margin-bottom: 0.3rem;
1258
+ .query-feed-table .query-row-detail td {
1259
+ padding: 0.25rem 0.6rem 0.6rem;
1260
+ background: var(--bg-card);
1261
+ white-space: normal;
1202
1262
  }
1203
1263
 
1204
- .query-sql {
1264
+ .query-row-detail pre {
1265
+ margin: 0.35rem 0 0;
1205
1266
  padding: 0.5rem;
1267
+ max-height: 16rem;
1268
+ overflow: auto;
1206
1269
  background: var(--bg-primary);
1207
1270
  border: 1px solid var(--border-color);
1208
1271
  border-radius: 4px;
1209
1272
  font-family: <%= PgReports.config.load_external_fonts ? "'JetBrains Mono', " : "" %>monospace;
1210
- font-size: 0.7rem;
1211
- color: var(--accent-blue);
1212
- word-wrap: break-word;
1213
- overflow-x: auto;
1214
- }
1215
-
1216
- .query-sql-collapsed {
1217
- white-space: nowrap;
1218
- overflow: hidden;
1219
- text-overflow: ellipsis;
1220
- }
1221
-
1222
- .query-sql-expanded {
1273
+ font-size: 0.72rem;
1274
+ color: var(--text-primary);
1223
1275
  white-space: pre-wrap;
1224
- }
1225
-
1226
- .query-source {
1227
- font-size: 0.7rem;
1228
- color: var(--text-muted);
1229
- font-family: <%= PgReports.config.load_external_fonts ? "'JetBrains Mono', " : "" %>monospace;
1230
- }
1231
-
1232
- .query-source a {
1233
- color: var(--accent-purple);
1234
- text-decoration: none;
1235
- }
1236
-
1237
- .query-source a:hover {
1238
- text-decoration: underline;
1276
+ word-break: break-word;
1239
1277
  }
1240
1278
 
1241
1279
  /* IDE Dropdown for query source links */
@@ -1521,6 +1559,14 @@
1521
1559
  flex-wrap: wrap;
1522
1560
  }
1523
1561
 
1562
+ /* The source is still one tap away, in the expanded row */
1563
+ .query-feed-table .col-source {
1564
+ display: none;
1565
+ }
1566
+
1567
+ .query-feed-table .col-time { width: 5rem; }
1568
+ .query-feed-table .col-duration { width: 3.5rem; }
1569
+
1524
1570
  /* Anchored to its chip, this tooltip runs off the screen and stretches
1525
1571
  the page with it; on a phone it reads as a bottom sheet instead. */
1526
1572
  .query-monitoring-scope::after {
@@ -1,11 +1,14 @@
1
1
  <script>
2
2
  const svgIcon = (name, cls) => `<svg class="icon ${cls || ''}"><use href="#i-${name}"></use></svg>`;
3
3
  let currentReportData = null;
4
+ // Rows in their rendered order. Inline handlers carry an index into this, never
5
+ // the row itself: report data comes from the database, and any encoding of it
6
+ // into an attribute that JS re-parses is one decoding step from being code.
7
+ let renderedRows = [];
4
8
  let currentProblemExplanations = {};
5
9
  const category = '<%= @category %>';
6
10
  const reportKey = '<%= @report_key %>';
7
- const railsEnv = '<%= Rails.env %>';
8
- const isDevelopment = railsEnv === 'development';
11
+ const allowMigrationCreation = <%= PgReports.config.allow_migration_creation ? true : false %>;
9
12
  const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
10
13
  let syncingScroll = false;
11
14
  let currentSort = { column: null, direction: 'asc' };
@@ -118,6 +121,8 @@
118
121
  const tableBody = document.getElementById('results-body');
119
122
  if (!tableBody) return;
120
123
 
124
+ renderedRows = data;
125
+
121
126
  let rowsHtml = '';
122
127
 
123
128
  data.forEach((row, idx) => {
@@ -130,7 +135,7 @@
130
135
  rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
131
136
  const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
132
137
  const indicatorIcon = svgIcon('alert');
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>`;
138
+ problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showRowProblems(${idx})">${indicatorIcon}</span>`;
134
139
  }
135
140
 
136
141
  // Data row
@@ -347,6 +352,14 @@
347
352
  return { level: maxLevel, problems };
348
353
  }
349
354
 
355
+ function showRowProblems(rowIndex) {
356
+ const row = renderedRows[rowIndex];
357
+ if (!row) return;
358
+
359
+ const problemInfo = getRowProblemLevel(row, currentReportData.thresholds, currentReportData.problem_fields);
360
+ showProblemModal(problemInfo.problems, row);
361
+ }
362
+
350
363
  // Show problem modal
351
364
  function showProblemModal(problems, row) {
352
365
  const modal = document.getElementById('problem-modal');
@@ -536,39 +549,44 @@
536
549
  const line = lineNumber || 1;
537
550
  const urls = [];
538
551
 
552
+ // The path can come from a SQL comment, which anyone with database access
553
+ // can write, so it is encoded here and never reaches a URL as it was.
554
+ const urlPath = absolutePath.split('/').map(encodeURIComponent).join('/');
555
+ const urlParam = encodeURIComponent(absolutePath);
556
+
539
557
  // VSCode (WSL Remote format)
540
558
  urls.push({
541
559
  name: 'VS Code (WSL)',
542
- url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
560
+ url: `vscode://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
543
561
  });
544
562
 
545
563
  // VSCode (direct path - for native Linux or Windows)
546
564
  urls.push({
547
565
  name: 'VS Code',
548
- url: `vscode://file${absolutePath}:${line}`
566
+ url: `vscode://file${urlPath}:${line}`
549
567
  });
550
568
 
551
569
  // JetBrains (RubyMine, IntelliJ, etc.)
552
570
  urls.push({
553
571
  name: 'RubyMine',
554
- url: `x-mine://open?file=${absolutePath}&line=${line}`
572
+ url: `x-mine://open?file=${urlParam}&line=${line}`
555
573
  });
556
574
 
557
575
  urls.push({
558
576
  name: 'IntelliJ',
559
- url: `idea://open?file=${absolutePath}&line=${line}`
577
+ url: `idea://open?file=${urlParam}&line=${line}`
560
578
  });
561
579
 
562
580
  // Cursor (WSL Remote format)
563
581
  urls.push({
564
582
  name: 'Cursor (WSL)',
565
- url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
583
+ url: `cursor://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
566
584
  });
567
585
 
568
586
  // Cursor (direct path)
569
587
  urls.push({
570
588
  name: 'Cursor',
571
- url: `cursor://file${absolutePath}:${line}`
589
+ url: `cursor://file${urlPath}:${line}`
572
590
  });
573
591
 
574
592
  return urls;
@@ -603,7 +621,7 @@
603
621
  if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
604
622
  const ideUrl = ideUrls[ideKeyMap[defaultIde]];
605
623
  if (ideUrl) {
606
- return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
624
+ return `<a class="source-badge clickable" href="${escapeHtmlAttr(ideUrl.url)}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
607
625
  }
608
626
  }
609
627
 
@@ -617,7 +635,7 @@
617
635
  `;
618
636
 
619
637
  for (const ide of ideUrls) {
620
- dropdownHtml += `<a href="${ide.url}">${ide.name}</a>`;
638
+ dropdownHtml += `<a href="${escapeHtmlAttr(ide.url)}">${escapeHtml(ide.name)}</a>`;
621
639
  }
622
640
 
623
641
  dropdownHtml += '</div></div>';
@@ -713,10 +731,9 @@
713
731
 
714
732
  // Action buttons
715
733
  const isSaved = isRecordSaved(rowId);
716
- const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
717
734
 
718
735
  html += `<div class="detail-actions">`;
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>`;
736
+ html += `<button class="btn-save ${isSaved ? 'saved' : ''}" data-row-id="${rowId}" onclick="event.stopPropagation(); toggleSaveRow(${rowIndex}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
720
737
 
721
738
  if (hasQuery) {
722
739
  // Only show EXPLAIN ANALYZE for SELECT queries
@@ -731,11 +748,8 @@
731
748
 
732
749
  // Show migration button for index reports (only in development)
733
750
  if (hasIndexName && (category === 'indexes')) {
734
- const indexName = row.index_name;
735
- const tableName = row.table_name || row.tablename || '';
736
- const schemaName = row.schema_name || row.schemaname || 'public';
737
- if (isDevelopment) {
738
- html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">${svgIcon('trash')}Generate Migration</button>`;
751
+ if (allowMigrationCreation) {
752
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showRowMigration(${rowIndex})">${svgIcon('trash')}Generate Migration</button>`;
739
753
  } else {
740
754
  html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" >${svgIcon('trash')}Generate Migration (Development Only)</button>`;
741
755
  }
@@ -827,7 +841,7 @@
827
841
  // Build table header with sortable columns
828
842
  if (tableHead) {
829
843
  tableHead.innerHTML = '<tr>' + data.columns.map(col =>
830
- `<th class="sortable" data-column="${escapeHtml(col)}" onclick="handleSortClick('${escapeHtml(col)}')">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
844
+ `<th class="sortable" data-column="${escapeHtmlAttr(col)}" onclick="handleSortClick(this.dataset.column)">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
831
845
  ).join('') + '</tr>';
832
846
  }
833
847
 
@@ -913,6 +927,11 @@
913
927
  return saved.some(r => r.id === rowId);
914
928
  }
915
929
 
930
+ function toggleSaveRow(rowIndex, btn) {
931
+ const row = renderedRows[rowIndex];
932
+ if (row) toggleSaveRecord(generateRowId(row), row, btn);
933
+ }
934
+
916
935
  function toggleSaveRecord(rowId, row, btn) {
917
936
  const saved = getSavedRecords();
918
937
  const existingIdx = saved.findIndex(r => r.id === rowId);
@@ -946,7 +965,7 @@
946
965
  renderSavedRecords();
947
966
 
948
967
  // Update button in table if visible
949
- const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
968
+ const btn = document.querySelector(`.btn-save[data-row-id="${CSS.escape(rowId)}"]`);
950
969
  if (btn) {
951
970
  btn.classList.remove('saved');
952
971
  btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
@@ -997,7 +1016,7 @@
997
1016
  <div class="saved-record-header">
998
1017
  <span class="saved-record-time">${PG_REPORTS_I18N.saved.saved_at_prefix} ${savedTime}</span>
999
1018
  <span class="saved-record-expand-hint">${PG_REPORTS_I18N.saved.click_to_expand}</span>
1000
- <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
1019
+ <button class="saved-record-remove" data-record-id="${escapeHtmlAttr(record.id)}" onclick="event.stopPropagation(); removeSavedRecord(this.dataset.recordId)" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
1001
1020
  </div>
1002
1021
  <div class="saved-record-data">
1003
1022
  `;
@@ -1312,7 +1331,7 @@
1312
1331
  const indentPx = line.indent_level * 20;
1313
1332
  html += '<span class="explain-line-content ' + nodeColorClass + '" style="padding-left: ' + indentPx + 'px"';
1314
1333
  if (tooltip) {
1315
- html += ' title="' + escapeHtml(tooltip) + '"';
1334
+ html += ' title="' + escapeHtmlAttr(tooltip) + '"';
1316
1335
  }
1317
1336
  html += '>';
1318
1337
 
@@ -1338,7 +1357,7 @@
1338
1357
  // Problem indicator
1339
1358
  if (lineProblem) {
1340
1359
  const problemIcon = svgIcon('alert');
1341
- html += '<span class="explain-line-problem-indicator" title="' + escapeHtml(lineProblem.message) + '">' + problemIcon + '</span>';
1360
+ html += '<span class="explain-line-problem-indicator" title="' + escapeHtmlAttr(lineProblem.message) + '">' + problemIcon + '</span>';
1342
1361
  }
1343
1362
 
1344
1363
  html += '</div>';
@@ -1504,49 +1523,38 @@
1504
1523
 
1505
1524
  let currentMigrationData = null;
1506
1525
 
1507
- function showMigrationModal(indexName, tableName, schemaName) {
1508
- const migrationName = `remove_${indexName.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
1509
- const timestamp = new Date().toISOString().replace(/[-:T]/g, '').substring(0, 14);
1510
- const className = migrationName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1511
-
1512
- const fullIndexName = schemaName && schemaName !== 'public'
1513
- ? `${schemaName}.${indexName}`
1514
- : indexName;
1515
-
1516
- const migrationCode = `# frozen_string_literal: true
1517
-
1518
- class ${className} < ActiveRecord::Migration[7.0]
1519
- def change
1520
- remove_index :${tableName}, name: :${indexName}, if_exists: true
1521
- end
1522
- end
1523
- `;
1524
-
1525
- currentMigrationData = {
1526
- fileName: `${timestamp}_${migrationName}.rb`,
1527
- code: migrationCode,
1528
- indexName,
1529
- tableName,
1530
- schemaName
1526
+ async function showRowMigration(rowIndex) {
1527
+ const row = renderedRows[rowIndex];
1528
+ if (!row) return;
1529
+
1530
+ // The server builds the migration from the index it finds in the database,
1531
+ // so all it is told is which one.
1532
+ const target = {
1533
+ index_name: row.index_name,
1534
+ schema: row.schema || row.schema_name || row.schemaname || ''
1531
1535
  };
1532
1536
 
1533
- document.getElementById('migration-code').textContent = migrationCode;
1534
- document.getElementById('migration-modal').style.display = 'flex';
1535
-
1536
- // Disable "Create File" button in non-development environments
1537
- const createBtn = document.getElementById('create-migration-btn');
1538
- if (createBtn) {
1539
- if (!isDevelopment) {
1540
- createBtn.disabled = true;
1541
- createBtn.style.opacity = '0.5';
1542
- createBtn.style.cursor = 'not-allowed';
1543
- createBtn.title = 'Migration file creation is only available in development environment';
1544
- } else {
1545
- createBtn.disabled = false;
1546
- createBtn.style.opacity = '1';
1547
- createBtn.style.cursor = 'pointer';
1548
- createBtn.title = '';
1537
+ try {
1538
+ const response = await fetch(`${pgReportsRoot}/migration_preview`, {
1539
+ method: 'POST',
1540
+ headers: {
1541
+ 'Content-Type': 'application/json',
1542
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1543
+ },
1544
+ body: JSON.stringify(target)
1545
+ });
1546
+ const data = await response.json();
1547
+
1548
+ if (!data.success) {
1549
+ showToast(data.error || PG_REPORTS_I18N.errors.create_migration_failed, 'error');
1550
+ return;
1549
1551
  }
1552
+
1553
+ currentMigrationData = { ...target, code: data.code };
1554
+ document.getElementById('migration-code').textContent = data.code;
1555
+ document.getElementById('migration-modal').style.display = 'flex';
1556
+ } catch (error) {
1557
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
1550
1558
  }
1551
1559
  }
1552
1560
 
@@ -1571,7 +1579,7 @@ end
1571
1579
  if (!currentMigrationData) return;
1572
1580
 
1573
1581
  // Security check
1574
- if (!allowRawQueryExecution) {
1582
+ if (!allowMigrationCreation) {
1575
1583
  const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
1576
1584
  showToast(message, 'error');
1577
1585
 
@@ -1593,8 +1601,8 @@ end
1593
1601
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1594
1602
  },
1595
1603
  body: JSON.stringify({
1596
- file_name: currentMigrationData.fileName,
1597
- code: currentMigrationData.code
1604
+ index_name: currentMigrationData.index_name,
1605
+ schema: currentMigrationData.schema
1598
1606
  })
1599
1607
  });
1600
1608