pg_reports 0.9.0 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +222 -93
  7. data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
  8. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
  9. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
  10. data/app/views/pg_reports/dashboard/index.html.erb +346 -238
  11. data/app/views/pg_reports/dashboard/show.html.erb +1 -1
  12. data/bin/pg_reports +2 -2
  13. data/config/brakeman.ignore +10 -56
  14. data/config/locales/en.yml +45 -11
  15. data/config/locales/ru.yml +44 -11
  16. data/config/locales/uk.yml +44 -11
  17. data/config/routes.rb +2 -0
  18. data/lib/pg_reports/access_policy.rb +39 -0
  19. data/lib/pg_reports/compatibility.rb +10 -2
  20. data/lib/pg_reports/configuration.rb +33 -2
  21. data/lib/pg_reports/connection/error_translator.rb +21 -0
  22. data/lib/pg_reports/connection/target.rb +43 -10
  23. data/lib/pg_reports/csv_cell.rb +20 -0
  24. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  25. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  26. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  27. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  28. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  29. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  30. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  31. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  32. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  34. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  35. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  36. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  37. data/lib/pg_reports/drop_index_migration.rb +56 -0
  38. data/lib/pg_reports/engine.rb +4 -3
  39. data/lib/pg_reports/executor.rb +46 -9
  40. data/lib/pg_reports/modules/queries.rb +1 -0
  41. data/lib/pg_reports/query_monitor.rb +478 -192
  42. data/lib/pg_reports/rate_limiter.rb +63 -0
  43. data/lib/pg_reports/report.rb +1 -1
  44. data/lib/pg_reports/report_definition.rb +11 -1
  45. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  46. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  47. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  48. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  49. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  50. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  51. data/lib/pg_reports/sql_redactor.rb +32 -0
  52. data/lib/pg_reports/standalone.rb +83 -20
  53. data/lib/pg_reports/version.rb +1 -1
  54. data/lib/pg_reports.rb +7 -2
  55. metadata +18 -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 */
@@ -1330,44 +1368,57 @@
1330
1368
  font-size: 0.9rem;
1331
1369
  }
1332
1370
 
1333
- .ide-options {
1371
+ /* Settings modal sections. The option rows are shared by every section —
1372
+ language, IDE, and whatever is added next. */
1373
+ .settings-section-title {
1374
+ font-size: 0.6875rem;
1375
+ font-weight: 600;
1376
+ line-height: 1.4;
1377
+ color: var(--text-muted);
1378
+ text-transform: uppercase;
1379
+ letter-spacing: 0.06em;
1380
+ margin-bottom: 0.5rem;
1381
+ }
1382
+
1383
+ .settings-options {
1334
1384
  display: flex;
1335
1385
  flex-direction: column;
1336
- gap: 0.5rem;
1386
+ gap: 0.375rem;
1337
1387
  }
1338
1388
 
1339
- .ide-option {
1389
+ .settings-option {
1340
1390
  display: flex;
1341
1391
  align-items: center;
1342
- gap: 0.75rem;
1343
- padding: 0.75rem 1rem;
1392
+ gap: 0.625rem;
1393
+ padding: 0.5rem 0.75rem;
1344
1394
  background: var(--bg-tertiary);
1345
1395
  border: 1px solid var(--border-color);
1346
- border-radius: 6px;
1396
+ border-radius: var(--radius-sm);
1347
1397
  cursor: pointer;
1348
- transition: all 0.15s;
1398
+ transition: background-color 0.12s ease, border-color 0.12s ease;
1349
1399
  }
1350
1400
 
1351
- .ide-option:hover {
1401
+ .settings-option:hover {
1352
1402
  background: var(--bg-hover);
1353
1403
  border-color: var(--border-strong);
1354
1404
  }
1355
1405
 
1356
- .ide-option:has(input:checked) {
1406
+ .settings-option:has(input:checked) {
1357
1407
  background: var(--purple-soft);
1358
1408
  border-color: var(--purple-line);
1359
1409
  }
1360
1410
 
1361
- .ide-option input[type="radio"] {
1411
+ .settings-option input[type="radio"] {
1362
1412
  accent-color: var(--accent-purple);
1413
+ flex: none;
1363
1414
  }
1364
1415
 
1365
- .ide-option span {
1416
+ .settings-option span {
1366
1417
  color: var(--text-secondary);
1367
- font-size: 0.875rem;
1418
+ font-size: 0.8125rem;
1368
1419
  }
1369
1420
 
1370
- .ide-option:has(input:checked) span {
1421
+ .settings-option:has(input:checked) span {
1371
1422
  color: var(--text-primary);
1372
1423
  }
1373
1424
 
@@ -1508,6 +1559,14 @@
1508
1559
  flex-wrap: wrap;
1509
1560
  }
1510
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
+
1511
1570
  /* Anchored to its chip, this tooltip runs off the screen and stretches
1512
1571
  the page with it; on a phone it reads as a bottom sheet instead. */
1513
1572
  .query-monitoring-scope::after {
@@ -1634,6 +1693,76 @@
1634
1693
  ].join('');
1635
1694
  };
1636
1695
 
1696
+ // Settings modal. Shared by the dashboard and the report pages; the language
1697
+ // section posts a form (server-side session), the IDE section only writes
1698
+ // localStorage, so there is nothing to submit for it.
1699
+ // Picking a language posts a form and the page reloads (everything on it has
1700
+ // to re-render in the new language), which would leave the user staring at a
1701
+ // dashboard with the settings they were editing gone. Remember that the
1702
+ // modal was open and bring it back on the way in.
1703
+ const SETTINGS_REOPEN_KEY = 'pgReportsSettingsReopen';
1704
+
1705
+ function rememberSettingsOpen() {
1706
+ try {
1707
+ sessionStorage.setItem(SETTINGS_REOPEN_KEY, '1');
1708
+ } catch (e) {
1709
+ // Storage can be unavailable (private mode, blocked cookies); losing the
1710
+ // reopen is a cosmetic downgrade, not a reason to block the switch.
1711
+ }
1712
+ }
1713
+
1714
+ function consumeSettingsReopen() {
1715
+ try {
1716
+ if (!sessionStorage.getItem(SETTINGS_REOPEN_KEY)) return false;
1717
+ sessionStorage.removeItem(SETTINGS_REOPEN_KEY);
1718
+ return true;
1719
+ } catch (e) {
1720
+ return false;
1721
+ }
1722
+ }
1723
+
1724
+ function showSettingsModal() {
1725
+ const modal = document.getElementById('settings-modal');
1726
+ if (!modal) return;
1727
+
1728
+ modal.style.display = 'flex';
1729
+ loadSettingsState();
1730
+ }
1731
+
1732
+ function closeSettingsModal() {
1733
+ const modal = document.getElementById('settings-modal');
1734
+ if (modal) modal.style.display = 'none';
1735
+ }
1736
+
1737
+ function setDefaultIde(ideKey) {
1738
+ if (ideKey) {
1739
+ localStorage.setItem('pgReportsDefaultIde', ideKey);
1740
+ } else {
1741
+ localStorage.removeItem('pgReportsDefaultIde');
1742
+ }
1743
+ }
1744
+
1745
+ function getDefaultIde() {
1746
+ return localStorage.getItem('pgReportsDefaultIde') || '';
1747
+ }
1748
+
1749
+ // The language radios are rendered already checked by the server; only the
1750
+ // IDE choice lives in localStorage and has to be restored on open.
1751
+ function loadSettingsState() {
1752
+ const currentIde = getDefaultIde();
1753
+ document.querySelectorAll('input[name="default-ide"]').forEach(radio => {
1754
+ radio.checked = (radio.value === currentIde);
1755
+ });
1756
+ }
1757
+
1758
+ document.addEventListener('DOMContentLoaded', function() {
1759
+ document.getElementById('settings-modal')?.addEventListener('click', function(e) {
1760
+ if (e.target === this) closeSettingsModal();
1761
+ });
1762
+
1763
+ if (consumeSettingsReopen()) showSettingsModal();
1764
+ });
1765
+
1637
1766
  function showToast(message, type = 'success') {
1638
1767
  const toast = document.getElementById('toast');
1639
1768
  toast.textContent = message;
@@ -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;">