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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +12 -8
- data/app/controllers/pg_reports/dashboard_controller.rb +230 -221
- data/app/controllers/pg_reports/metrics_controller.rb +13 -4
- data/app/views/layouts/pg_reports/application.html.erb +126 -80
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +74 -66
- data/app/views/pg_reports/dashboard/index.html.erb +310 -124
- data/bin/pg_reports +2 -2
- data/config/brakeman.ignore +10 -56
- data/config/locales/en.yml +39 -8
- data/config/locales/ru.yml +38 -8
- data/config/locales/uk.yml +38 -8
- data/config/routes.rb +1 -0
- data/lib/pg_reports/access_policy.rb +39 -0
- data/lib/pg_reports/compatibility.rb +10 -2
- data/lib/pg_reports/configuration.rb +33 -2
- data/lib/pg_reports/connection/error_translator.rb +21 -0
- data/lib/pg_reports/connection/target.rb +43 -10
- data/lib/pg_reports/csv_cell.rb +20 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
- data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
- data/lib/pg_reports/definitions/connections/locks.yml +1 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
- data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
- data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
- data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
- data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
- data/lib/pg_reports/drop_index_migration.rb +56 -0
- data/lib/pg_reports/engine.rb +4 -3
- data/lib/pg_reports/executor.rb +46 -9
- data/lib/pg_reports/modules/queries.rb +1 -0
- data/lib/pg_reports/query_monitor.rb +478 -192
- data/lib/pg_reports/rate_limiter.rb +63 -0
- data/lib/pg_reports/report.rb +1 -1
- data/lib/pg_reports/report_definition.rb +11 -1
- data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
- data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
- data/lib/pg_reports/sql/connections/locks.sql +1 -0
- data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
- data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
- data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
- data/lib/pg_reports/sql_redactor.rb +32 -0
- data/lib/pg_reports/standalone.rb +83 -20
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +7 -2
- 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
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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:
|
|
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-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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-
|
|
1138
|
-
|
|
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-
|
|
1142
|
-
|
|
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-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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-
|
|
1153
|
-
|
|
1154
|
-
|
|
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-
|
|
1160
|
-
color: var(--text-
|
|
1161
|
-
|
|
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-
|
|
1179
|
-
color: var(--
|
|
1180
|
-
|
|
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-
|
|
1196
|
-
|
|
1197
|
-
color: var(--accent-purple);
|
|
1254
|
+
.query-source-link:hover {
|
|
1255
|
+
text-decoration: underline;
|
|
1198
1256
|
}
|
|
1199
1257
|
|
|
1200
|
-
.query-
|
|
1201
|
-
|
|
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-
|
|
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.
|
|
1211
|
-
color: var(--
|
|
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
|
|
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();
|
|
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}${
|
|
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${
|
|
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=${
|
|
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=${
|
|
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}${
|
|
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${
|
|
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, '"');
|
|
717
734
|
|
|
718
735
|
html += `<div class="detail-actions">`;
|
|
719
|
-
html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation();
|
|
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
|
-
|
|
735
|
-
|
|
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="${
|
|
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[
|
|
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(
|
|
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="' +
|
|
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="' +
|
|
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
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
:
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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 (!
|
|
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
|
-
|
|
1597
|
-
|
|
1604
|
+
index_name: currentMigrationData.index_name,
|
|
1605
|
+
schema: currentMigrationData.schema
|
|
1598
1606
|
})
|
|
1599
1607
|
});
|
|
1600
1608
|
|