pg_reports 0.5.4 → 0.6.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +123 -370
  4. data/app/controllers/pg_reports/dashboard_controller.rb +21 -21
  5. data/app/views/layouts/pg_reports/application.html.erb +135 -69
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +22 -22
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +105 -55
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +49 -11
  9. data/app/views/pg_reports/dashboard/index.html.erb +123 -114
  10. data/app/views/pg_reports/dashboard/show.html.erb +30 -26
  11. data/config/locales/en.yml +597 -0
  12. data/config/locales/ru.yml +562 -0
  13. data/config/locales/uk.yml +607 -0
  14. data/lib/pg_reports/compatibility.rb +63 -0
  15. data/lib/pg_reports/configuration.rb +2 -0
  16. data/lib/pg_reports/dashboard/reports_registry.rb +112 -5
  17. data/lib/pg_reports/definitions/indexes/fk_without_indexes.yml +30 -0
  18. data/lib/pg_reports/definitions/indexes/index_correlation.yml +31 -0
  19. data/lib/pg_reports/definitions/indexes/inefficient_indexes.yml +45 -0
  20. data/lib/pg_reports/definitions/queries/temp_file_queries.yml +39 -0
  21. data/lib/pg_reports/definitions/schema_analysis/always_null_columns.yml +31 -0
  22. data/lib/pg_reports/definitions/schema_analysis/unused_columns.yml +32 -0
  23. data/lib/pg_reports/definitions/system/wraparound_risk.yml +31 -0
  24. data/lib/pg_reports/definitions/tables/tables_without_pk.yml +28 -0
  25. data/lib/pg_reports/definitions/tables/unused_tables.yml +30 -0
  26. data/lib/pg_reports/definitions/tables/update_hotspots.yml +32 -0
  27. data/lib/pg_reports/engine.rb +6 -0
  28. data/lib/pg_reports/module_generator.rb +2 -1
  29. data/lib/pg_reports/modules/indexes.rb +3 -0
  30. data/lib/pg_reports/modules/queries.rb +1 -0
  31. data/lib/pg_reports/modules/schema_analysis.rb +261 -2
  32. data/lib/pg_reports/modules/system.rb +27 -0
  33. data/lib/pg_reports/modules/tables.rb +1 -0
  34. data/lib/pg_reports/query_monitor.rb +64 -36
  35. data/lib/pg_reports/report_definition.rb +20 -24
  36. data/lib/pg_reports/sql/indexes/fk_without_indexes.sql +23 -0
  37. data/lib/pg_reports/sql/indexes/index_correlation.sql +27 -0
  38. data/lib/pg_reports/sql/indexes/inefficient_indexes.sql +22 -0
  39. data/lib/pg_reports/sql/queries/temp_file_queries.sql +16 -0
  40. data/lib/pg_reports/sql/schema_analysis/always_null_columns.sql +25 -0
  41. data/lib/pg_reports/sql/schema_analysis/unused_columns.sql +36 -0
  42. data/lib/pg_reports/sql/system/checkpoint_stats.sql +20 -0
  43. data/lib/pg_reports/sql/system/checkpoint_stats_legacy.sql +19 -0
  44. data/lib/pg_reports/sql/system/wraparound_risk.sql +21 -0
  45. data/lib/pg_reports/sql/tables/tables_without_pk.sql +20 -0
  46. data/lib/pg_reports/sql/tables/unused_tables.sql +19 -0
  47. data/lib/pg_reports/sql/tables/update_hotspots.sql +26 -0
  48. data/lib/pg_reports/version.rb +1 -1
  49. data/lib/pg_reports.rb +5 -0
  50. metadata +24 -1
@@ -19,7 +19,7 @@ module PgReports
19
19
 
20
20
  def reset_statistics
21
21
  PgReports.reset_statistics!
22
- render json: {success: true, message: "Statistics have been reset successfully"}
22
+ render json: {success: true, message: I18n.t("pg_reports.ui.success.statistics_reset")}
23
23
  rescue => e
24
24
  render json: {success: false, error: e.message}, status: :unprocessable_entity
25
25
  end
@@ -35,7 +35,7 @@ module PgReports
35
35
  if data[:connections][:total].nil? && data[:transactions][:total].nil?
36
36
  render json: {
37
37
  success: false,
38
- error: "Unable to fetch database statistics. Check database permissions.",
38
+ error: I18n.t("pg_reports.ui.errors.fetch_metrics_check_perms"),
39
39
  available: false
40
40
  }, status: :service_unavailable
41
41
  return
@@ -50,7 +50,7 @@ module PgReports
50
50
  rescue PG::InsufficientPrivilege
51
51
  render json: {
52
52
  success: false,
53
- error: "Insufficient database permissions to access statistics views",
53
+ error: I18n.t("pg_reports.ui.errors.insufficient_database_perms"),
54
54
  available: false
55
55
  }, status: :forbidden
56
56
  rescue => e
@@ -68,7 +68,7 @@ module PgReports
68
68
  @report_info = Dashboard::ReportsRegistry.find(@category, @report_key)
69
69
 
70
70
  if @report_info.nil?
71
- redirect_to root_path, alert: "Report not found"
71
+ redirect_to root_path, alert: I18n.t("pg_reports.ui.errors.report_not_found")
72
72
  return
73
73
  end
74
74
 
@@ -138,7 +138,7 @@ module PgReports
138
138
  report.send_to_telegram
139
139
  end
140
140
 
141
- render json: {success: true, message: "Report sent to Telegram"}
141
+ render json: {success: true, message: I18n.t("pg_reports.ui.success.telegram_sent")}
142
142
  rescue => e
143
143
  render json: {success: false, error: e.message}, status: :unprocessable_entity
144
144
  end
@@ -177,7 +177,7 @@ module PgReports
177
177
  query_params = params[:params] || {}
178
178
 
179
179
  if query_hash.blank?
180
- render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
180
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_hash_required")}, status: :unprocessable_entity
181
181
  return
182
182
  end
183
183
 
@@ -185,7 +185,7 @@ module PgReports
185
185
  unless PgReports.config.allow_raw_query_execution
186
186
  render json: {
187
187
  success: false,
188
- error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
188
+ error: I18n.t("pg_reports.ui.errors.query_execution_disabled")
189
189
  }, status: :forbidden
190
190
  return
191
191
  end
@@ -195,11 +195,11 @@ module PgReports
195
195
  query = retrieve_query_by_hash(query_hash)
196
196
 
197
197
  if query.nil?
198
- render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
198
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_not_found_expired")}, status: :not_found
199
199
  return
200
200
  end
201
201
  rescue SecurityError => e
202
- render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
202
+ render json: {success: false, error: "#{I18n.t("pg_reports.ui.errors.security_violation_prefix")} #{e.message}"}, status: :forbidden
203
203
  return
204
204
  end
205
205
 
@@ -207,7 +207,7 @@ module PgReports
207
207
  if query.match?(/\b(NEW|OLD)\./i)
208
208
  render json: {
209
209
  success: false,
210
- error: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
210
+ error: I18n.t("pg_reports.ui.errors.trigger_variables_not_allowed")
211
211
  }, status: :unprocessable_entity
212
212
  return
213
213
  end
@@ -219,7 +219,7 @@ module PgReports
219
219
  if final_query.match?(/\$\d+/)
220
220
  render json: {
221
221
  success: false,
222
- error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
222
+ error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
223
223
  }, status: :unprocessable_entity
224
224
  return
225
225
  end
@@ -248,7 +248,7 @@ module PgReports
248
248
  query_params = params[:params] || {}
249
249
 
250
250
  if query_hash.blank?
251
- render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
251
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_hash_required")}, status: :unprocessable_entity
252
252
  return
253
253
  end
254
254
 
@@ -256,7 +256,7 @@ module PgReports
256
256
  unless PgReports.config.allow_raw_query_execution
257
257
  render json: {
258
258
  success: false,
259
- error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
259
+ error: I18n.t("pg_reports.ui.errors.query_execution_disabled")
260
260
  }, status: :forbidden
261
261
  return
262
262
  end
@@ -266,11 +266,11 @@ module PgReports
266
266
  query = retrieve_query_by_hash(query_hash)
267
267
 
268
268
  if query.nil?
269
- render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
269
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.query_not_found_expired")}, status: :not_found
270
270
  return
271
271
  end
272
272
  rescue SecurityError => e
273
- render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
273
+ render json: {success: false, error: "#{I18n.t("pg_reports.ui.errors.security_violation_prefix")} #{e.message}"}, status: :forbidden
274
274
  return
275
275
  end
276
276
 
@@ -281,7 +281,7 @@ module PgReports
281
281
  if final_query.match?(/\$\d+/)
282
282
  render json: {
283
283
  success: false,
284
- error: "Please provide values for all parameter placeholders ($1, $2, etc.)"
284
+ error: I18n.t("pg_reports.ui.errors.missing_parameter_values")
285
285
  }, status: :unprocessable_entity
286
286
  return
287
287
  end
@@ -326,7 +326,7 @@ module PgReports
326
326
  unless Rails.env.development?
327
327
  render json: {
328
328
  success: false,
329
- error: "Migration creation is only allowed in development environment"
329
+ error: I18n.t("pg_reports.ui.errors.migration_dev_only")
330
330
  }, status: :forbidden
331
331
  return
332
332
  end
@@ -335,28 +335,28 @@ module PgReports
335
335
  code = params[:code]
336
336
 
337
337
  if file_name.blank? || code.blank?
338
- render json: {success: false, error: "File name and code are required"}, status: :unprocessable_entity
338
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.filename_code_required")}, status: :unprocessable_entity
339
339
  return
340
340
  end
341
341
 
342
342
  # Sanitize file name
343
343
  safe_file_name = file_name.gsub(/[^a-z0-9_.]/, "")
344
344
  unless safe_file_name.match?(/\A\d{14}_\w+\.rb\z/)
345
- render json: {success: false, error: "Invalid migration file name format"}, status: :unprocessable_entity
345
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.invalid_filename_format")}, status: :unprocessable_entity
346
346
  return
347
347
  end
348
348
 
349
349
  # Find migrations directory
350
350
  migrations_path = Rails.root.join("db", "migrate")
351
351
  unless migrations_path.exist?
352
- render json: {success: false, error: "Migrations directory not found"}, status: :unprocessable_entity
352
+ render json: {success: false, error: I18n.t("pg_reports.ui.errors.migrations_dir_not_found")}, status: :unprocessable_entity
353
353
  return
354
354
  end
355
355
 
356
356
  file_path = migrations_path.join(safe_file_name)
357
357
  File.write(file_path, code)
358
358
 
359
- render json: {success: true, file_path: file_path.to_s, message: "Migration created successfully"}
359
+ render json: {success: true, file_path: file_path.to_s, message: I18n.t("pg_reports.ui.success.migration_created")}
360
360
  rescue => e
361
361
  render json: {success: false, error: e.message}, status: :unprocessable_entity
362
362
  end
@@ -1,10 +1,10 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="<%= I18n.locale %>">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <meta name="pg-reports-root" content="<%= request.script_name.presence || PgReports::Engine.routes.url_helpers.root_path %>">
7
- <title>PgReports Dashboard</title>
7
+ <title><%= t("pg_reports.ui.branding.page_title") %></title>
8
8
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%234f46e5'/%3E%3Crect x='5' y='18' width='5' height='9' rx='1' fill='%23fff'/%3E%3Crect x='13.5' y='12' width='5' height='15' rx='1' fill='%23fff'/%3E%3Crect x='22' y='6' width='5' height='21' rx='1' fill='%23fff'/%3E%3C/svg%3E">
9
9
  <% if PgReports.config.load_external_fonts %>
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -13,22 +13,22 @@
13
13
  <% end %>
14
14
  <style>
15
15
  :root {
16
- --bg-primary: #0f1114;
17
- --bg-secondary: #161a1e;
18
- --bg-tertiary: #1e2328;
19
- --bg-card: #1a1e23;
20
- --border-color: #2d3339;
21
- --text-primary: #d4d7dc;
22
- --text-secondary: #9ca3ab;
23
- --text-muted: #6b7280;
24
- --accent-purple: #9d8cd6;
25
- --accent-blue: #6b9fe8;
26
- --accent-green: #5fb89a;
27
- --accent-amber: #d4a056;
28
- --accent-rose: #d97084;
29
- --accent-indigo: #8b8fd9;
30
- --gradient-start: #6b9fe8;
31
- --gradient-end: #8b8fd9;
16
+ --bg-primary: #151719;
17
+ --bg-secondary: #1c1f22;
18
+ --bg-tertiary: #232629;
19
+ --bg-card: #1c1f22;
20
+ --border-color: #2e3235;
21
+ --text-primary: #c9cbcd;
22
+ --text-secondary: #888d93;
23
+ --text-muted: #5e6369;
24
+ --accent-purple: #7c8af6;
25
+ --accent-blue: #5c9eed;
26
+ --accent-green: #45a87a;
27
+ --accent-amber: #c89030;
28
+ --accent-rose: #d45d6e;
29
+ --accent-indigo: #7c8af6;
30
+ --gradient-start: #7c8af6;
31
+ --gradient-end: #7c8af6;
32
32
  }
33
33
 
34
34
  * {
@@ -68,27 +68,45 @@
68
68
  }
69
69
 
70
70
  .logo-icon {
71
- width: 44px;
72
- height: 44px;
73
- background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
71
+ width: 32px;
72
+ height: 32px;
73
+ background: var(--accent-purple);
74
74
  border-radius: 6px;
75
75
  display: flex;
76
76
  align-items: center;
77
77
  justify-content: center;
78
- font-size: 1.4rem;
78
+ font-size: 1rem;
79
+ }
80
+
81
+ .logo-text {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 5px;
85
+ line-height: 1.15;
79
86
  }
80
87
 
81
88
  .logo-text h1 {
82
- font-size: 1.25rem;
83
- font-weight: 700;
84
- background: linear-gradient(135deg, var(--text-primary), var(--text-secondary));
85
- -webkit-background-clip: text;
86
- -webkit-text-fill-color: transparent;
89
+ font-size: 1.1rem;
90
+ font-weight: 600;
91
+ color: var(--text-primary);
92
+ line-height: 1.15;
93
+ margin-top: -3px;
94
+ display: flex;
95
+ align-items: baseline;
96
+ gap: 0.5rem;
87
97
  }
88
98
 
89
- .logo-text span {
90
- font-size: 0.75rem;
99
+ .logo-version {
100
+ font-size: 0.7rem;
101
+ font-weight: 500;
102
+ color: var(--text-muted);
103
+ letter-spacing: 0.02em;
104
+ }
105
+
106
+ .logo-subtitle {
107
+ font-size: 0.7rem;
91
108
  color: var(--text-muted);
109
+ line-height: 1.15;
92
110
  }
93
111
 
94
112
  .header-badge {
@@ -130,14 +148,12 @@
130
148
  .category-card {
131
149
  background: var(--bg-card);
132
150
  border: 1px solid var(--border-color);
133
- border-radius: 8px;
151
+ border-radius: 6px;
134
152
  padding: 1rem;
135
- transition: all 0.2s ease;
136
153
  }
137
154
 
138
155
  .category-card:hover {
139
- border-color: var(--accent-purple);
140
- transform: translateY(-1px);
156
+ border-color: #3e4347;
141
157
  }
142
158
 
143
159
  .category-header {
@@ -148,13 +164,15 @@
148
164
  }
149
165
 
150
166
  .category-icon {
151
- width: 36px;
152
- height: 36px;
153
- border-radius: 6px;
167
+ width: 28px;
168
+ height: 28px;
169
+ border-radius: 4px;
154
170
  display: flex;
155
171
  align-items: center;
156
172
  justify-content: center;
157
- font-size: 1.125rem;
173
+ font-size: 0.95rem;
174
+ background: transparent !important;
175
+ color: var(--text-secondary) !important;
158
176
  }
159
177
 
160
178
  .category-title {
@@ -181,19 +199,18 @@
181
199
  display: flex;
182
200
  align-items: center;
183
201
  justify-content: space-between;
184
- padding: 0.6rem 0.875rem;
185
- background: var(--bg-tertiary);
202
+ padding: 0.45rem 0.7rem;
203
+ background: transparent;
186
204
  border: 1px solid transparent;
187
- border-radius: 6px;
205
+ border-radius: 4px;
188
206
  color: var(--text-secondary);
189
207
  text-decoration: none;
190
- font-size: 0.85rem;
191
- transition: all 0.15s ease;
208
+ font-size: 0.82rem;
209
+ transition: background 0.1s, color 0.1s;
192
210
  }
193
211
 
194
212
  .report-link:hover {
195
- background: var(--bg-secondary);
196
- border-color: var(--border-color);
213
+ background: var(--bg-tertiary);
197
214
  color: var(--text-primary);
198
215
  }
199
216
 
@@ -208,6 +225,20 @@
208
225
  transform: translateX(0);
209
226
  }
210
227
 
228
+ .report-badge-new {
229
+ display: inline-block;
230
+ margin-left: 0.4rem;
231
+ padding: 0.1rem 0.35rem;
232
+ font-size: 0.55rem;
233
+ font-weight: 600;
234
+ letter-spacing: 0.04em;
235
+ line-height: 1.2;
236
+ color: var(--accent-green);
237
+ background: rgba(69, 168, 122, 0.12);
238
+ border-radius: 3px;
239
+ vertical-align: middle;
240
+ }
241
+
211
242
  /* Report Detail Page */
212
243
  .report-page {
213
244
  display: flex;
@@ -230,7 +261,7 @@
230
261
  }
231
262
 
232
263
  .breadcrumb a:hover {
233
- color: var(--accent-purple);
264
+ color: var(--text-primary);
234
265
  }
235
266
 
236
267
  .report-header {
@@ -264,25 +295,24 @@
264
295
  height: 36px;
265
296
  padding: 0 1rem;
266
297
  border: 1px solid transparent;
267
- border-radius: 6px;
298
+ border-radius: 4px;
268
299
  font-family: inherit;
269
300
  font-size: 0.8rem;
270
301
  font-weight: 500;
271
302
  cursor: pointer;
272
- transition: all 0.15s ease;
303
+ transition: background 0.1s;
273
304
  white-space: nowrap;
274
305
  text-decoration: none;
275
306
  }
276
307
 
277
308
  .btn-primary {
278
- background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
279
- color: white;
280
- border-color: transparent;
309
+ background: var(--accent-purple);
310
+ color: #fff;
311
+ border-color: var(--accent-purple);
281
312
  }
282
313
 
283
314
  .btn-primary:hover {
284
- opacity: 0.9;
285
- transform: translateY(-1px);
315
+ background: #6b79e4;
286
316
  }
287
317
 
288
318
  .btn-secondary {
@@ -292,19 +322,19 @@
292
322
  }
293
323
 
294
324
  .btn-secondary:hover {
295
- background: var(--bg-secondary);
325
+ background: #2a2d31;
296
326
  color: var(--text-primary);
297
327
  }
298
328
 
299
329
  .btn-telegram {
300
- background: #4a9ebe;
301
- color: white;
302
- border-color: #4a9ebe;
330
+ background: var(--bg-tertiary);
331
+ color: var(--text-secondary);
332
+ border: 1px solid var(--border-color);
303
333
  }
304
334
 
305
335
  .btn-telegram:hover {
306
- background: #5aaccc;
307
- border-color: #5aaccc;
336
+ background: #2a2d31;
337
+ color: var(--text-primary);
308
338
  }
309
339
 
310
340
  .btn:disabled {
@@ -316,7 +346,7 @@
316
346
  .results-container {
317
347
  background: var(--bg-card);
318
348
  border: 1px solid var(--border-color);
319
- border-radius: 8px;
349
+ border-radius: 6px;
320
350
  overflow: hidden;
321
351
  }
322
352
 
@@ -462,22 +492,51 @@
462
492
  .modal {
463
493
  position: fixed;
464
494
  inset: 0;
465
- background: rgba(0, 0, 0, 0.7);
495
+ background: rgba(0, 0, 0, 0.6);
466
496
  display: flex;
467
497
  align-items: center;
468
498
  justify-content: center;
469
499
  z-index: 1000;
470
- backdrop-filter: blur(4px);
471
500
  }
472
501
 
473
502
  .modal-content {
474
503
  background: var(--bg-card);
475
504
  border: 1px solid var(--border-color);
476
- border-radius: 8px;
505
+ border-radius: 6px;
477
506
  max-width: 900px;
478
507
  width: 90%;
479
508
  max-height: 90vh;
480
509
  overflow: auto;
510
+ scrollbar-width: thin;
511
+ scrollbar-color: var(--border-color) var(--bg-secondary);
512
+ }
513
+
514
+ .modal-content::-webkit-scrollbar,
515
+ .modal-body::-webkit-scrollbar {
516
+ width: 8px;
517
+ height: 8px;
518
+ }
519
+
520
+ .modal-content::-webkit-scrollbar-track,
521
+ .modal-body::-webkit-scrollbar-track {
522
+ background: var(--bg-secondary);
523
+ border-radius: 4px;
524
+ }
525
+
526
+ .modal-content::-webkit-scrollbar-thumb,
527
+ .modal-body::-webkit-scrollbar-thumb {
528
+ background: var(--border-color);
529
+ border-radius: 4px;
530
+ }
531
+
532
+ .modal-content::-webkit-scrollbar-thumb:hover,
533
+ .modal-body::-webkit-scrollbar-thumb:hover {
534
+ background: var(--text-muted);
535
+ }
536
+
537
+ .modal-content::-webkit-scrollbar-corner,
538
+ .modal-body::-webkit-scrollbar-corner {
539
+ background: var(--bg-secondary);
481
540
  }
482
541
 
483
542
  .modal-large {
@@ -553,7 +612,7 @@
553
612
  margin-bottom: 1.5rem;
554
613
  background: var(--bg-card);
555
614
  border: 1px solid var(--border-color);
556
- border-radius: 8px;
615
+ border-radius: 6px;
557
616
  padding: 0.875rem 1rem;
558
617
  }
559
618
 
@@ -899,7 +958,7 @@
899
958
  padding: 0.75rem 1rem;
900
959
  background: var(--bg-tertiary);
901
960
  border: 1px solid var(--border-color);
902
- border-radius: 8px;
961
+ border-radius: 6px;
903
962
  cursor: pointer;
904
963
  transition: all 0.15s;
905
964
  }
@@ -945,6 +1004,13 @@
945
1004
 
946
1005
  <script>
947
1006
  const pgReportsRoot = document.querySelector('meta[name="pg-reports-root"]')?.content || '/pg_reports';
1007
+ window.PG_REPORTS_I18N = <%= raw I18n.t("pg_reports.ui").to_json %>;
1008
+
1009
+ // Format strings with %{var} placeholders
1010
+ window.pgReportsFormat = function(template, vars) {
1011
+ if (!template) return '';
1012
+ return template.replace(/%\{(\w+)\}/g, function(_, k) { return vars[k] != null ? vars[k] : ''; });
1013
+ };
948
1014
 
949
1015
  function showToast(message, type = 'success') {
950
1016
  const toast = document.getElementById('toast');
@@ -958,7 +1024,7 @@
958
1024
  async function sendToTelegram(category, report, button) {
959
1025
  if (button) {
960
1026
  button.disabled = true;
961
- button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> Sending...';
1027
+ button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> ' + PG_REPORTS_I18N.actions.sending;
962
1028
  }
963
1029
 
964
1030
  try {
@@ -973,17 +1039,17 @@
973
1039
  const data = await response.json();
974
1040
 
975
1041
  if (data.success) {
976
- showToast('Report sent to Telegram');
1042
+ showToast(PG_REPORTS_I18N.success.telegram_sent);
977
1043
  } else {
978
- showToast(data.error || 'Failed to send to Telegram', 'error');
1044
+ showToast(data.error || PG_REPORTS_I18N.errors.send_telegram_failed, 'error');
979
1045
  }
980
1046
  } catch (error) {
981
- showToast('Network error: ' + error.message, 'error');
1047
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
982
1048
  }
983
1049
 
984
1050
  if (button) {
985
1051
  button.disabled = false;
986
- button.innerHTML = '📨 Telegram';
1052
+ button.innerHTML = PG_REPORTS_I18N.actions.send_telegram;
987
1053
  }
988
1054
  }
989
1055
  </script>
@@ -2,39 +2,39 @@
2
2
  <div id="ide-settings-modal" class="modal" style="display: none;">
3
3
  <div class="modal-content modal-small">
4
4
  <div class="modal-header">
5
- <h3>⚙️ IDE Settings</h3>
5
+ <h3><%= t("pg_reports.ui.modals.ide_settings_title") %></h3>
6
6
  <button class="modal-close" onclick="closeIdeSettingsModal()">&times;</button>
7
7
  </div>
8
8
  <div class="modal-body">
9
- <p class="settings-label">Default IDE for source links:</p>
9
+ <p class="settings-label"><%= t("pg_reports.ui.settings.default_ide_label") %></p>
10
10
  <div class="ide-options">
11
11
  <label class="ide-option">
12
12
  <input type="radio" name="default-ide" value="" onchange="setDefaultIde('')">
13
- <span>Show menu (default)</span>
13
+ <span><%= t("pg_reports.ui.settings.ide_show_menu") %></span>
14
14
  </label>
15
15
  <label class="ide-option">
16
16
  <input type="radio" name="default-ide" value="vscode-wsl" onchange="setDefaultIde('vscode-wsl')">
17
- <span>VS Code (WSL)</span>
17
+ <span><%= t("pg_reports.ui.settings.ide_vscode_wsl") %></span>
18
18
  </label>
19
19
  <label class="ide-option">
20
20
  <input type="radio" name="default-ide" value="vscode" onchange="setDefaultIde('vscode')">
21
- <span>VS Code</span>
21
+ <span><%= t("pg_reports.ui.settings.ide_vscode") %></span>
22
22
  </label>
23
23
  <label class="ide-option">
24
24
  <input type="radio" name="default-ide" value="rubymine" onchange="setDefaultIde('rubymine')">
25
- <span>RubyMine</span>
25
+ <span><%= t("pg_reports.ui.settings.ide_rubymine") %></span>
26
26
  </label>
27
27
  <label class="ide-option">
28
28
  <input type="radio" name="default-ide" value="intellij" onchange="setDefaultIde('intellij')">
29
- <span>IntelliJ IDEA</span>
29
+ <span><%= t("pg_reports.ui.settings.ide_intellij") %></span>
30
30
  </label>
31
31
  <label class="ide-option">
32
32
  <input type="radio" name="default-ide" value="cursor-wsl" onchange="setDefaultIde('cursor-wsl')">
33
- <span>Cursor (WSL)</span>
33
+ <span><%= t("pg_reports.ui.settings.ide_cursor_wsl") %></span>
34
34
  </label>
35
35
  <label class="ide-option">
36
36
  <input type="radio" name="default-ide" value="cursor" onchange="setDefaultIde('cursor')">
37
- <span>Cursor</span>
37
+ <span><%= t("pg_reports.ui.settings.ide_cursor") %></span>
38
38
  </label>
39
39
  </div>
40
40
  </div>
@@ -45,7 +45,7 @@
45
45
  <div id="problem-modal" class="problem-modal" style="display: none;">
46
46
  <div class="problem-modal-content">
47
47
  <div class="problem-modal-header">
48
- <h3>⚠️ Problem Detected</h3>
48
+ <h3><%= t("pg_reports.ui.modals.problem_detected_title") %></h3>
49
49
  <button class="modal-close" onclick="closeProblemModal()">&times;</button>
50
50
  </div>
51
51
  <div class="problem-modal-body" id="problem-modal-body">
@@ -58,24 +58,24 @@
58
58
  <div id="explain-modal" class="modal" style="display: none;">
59
59
  <div class="modal-content modal-wide">
60
60
  <div class="modal-header">
61
- <h3>📊 Query Analyzer</h3>
61
+ <h3><%= t("pg_reports.ui.modals.query_analyzer_title") %></h3>
62
62
  <button class="modal-close" onclick="closeExplainModal()">&times;</button>
63
63
  </div>
64
64
  <div class="modal-body" id="explain-modal-body">
65
65
  <div class="explain-query-section">
66
- <label class="explain-label">Query:</label>
66
+ <label class="explain-label"><%= t("pg_reports.ui.modals.query_label") %></label>
67
67
  <pre class="explain-query" id="explain-query-display"></pre>
68
68
  </div>
69
69
  <div id="explain-params-section" class="explain-params-section" style="display: none;">
70
- <label class="explain-label">Parameters:</label>
70
+ <label class="explain-label"><%= t("pg_reports.ui.modals.parameters_label") %></label>
71
71
  <div id="explain-params-inputs"></div>
72
72
  </div>
73
73
  <div class="explain-actions">
74
74
  <button class="btn btn-secondary" onclick="executeExplainAnalyze()" id="btn-explain">
75
- 📊 EXPLAIN ANALYZE
75
+ <%= t("pg_reports.ui.actions.explain_analyze") %>
76
76
  </button>
77
77
  <button class="btn btn-secondary" onclick="executeQuery()" id="btn-execute">
78
- Execute Query
78
+ <%= t("pg_reports.ui.actions.execute_query") %>
79
79
  </button>
80
80
  </div>
81
81
  <div id="explain-loading" class="loading" style="display: none;">
@@ -90,22 +90,22 @@
90
90
  <div id="migration-modal" class="modal" style="display: none;">
91
91
  <div class="modal-content">
92
92
  <div class="modal-header">
93
- <h3>🗑️ Drop Index Migration</h3>
93
+ <h3><%= t("pg_reports.ui.modals.migration_title") %></h3>
94
94
  <button class="modal-close" onclick="closeMigrationModal()">&times;</button>
95
95
  </div>
96
96
  <div class="modal-body" id="migration-modal-body">
97
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;">⚠️ Warning</p>
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
99
  <p style="margin: 0; color: #ffcc80; font-size: 0.875rem; line-height: 1.5;">
100
- Creating a migration will generate a migration file in your project. Running this migration will drop the index from the database, which may significantly impact application performance.
101
- <strong>This operation should only be performed in a local development environment.</strong>
100
+ <%= t("pg_reports.ui.modals.migration_warning") %>
101
+ <strong><%= t("pg_reports.ui.modals.migration_warning_dev_only") %></strong>
102
102
  </p>
103
103
  </div>
104
- <p class="settings-label">Generated migration to remove the index:</p>
104
+ <p class="settings-label"><%= t("pg_reports.ui.modals.migration_subtitle") %></p>
105
105
  <div id="migration-code" class="migration-code"></div>
106
106
  <div class="migration-actions">
107
- <button class="btn btn-secondary" onclick="copyMigrationCode()">📋 Copy Code</button>
108
- <button class="btn btn-primary" id="create-migration-btn" onclick="createMigrationFile()">📁 Create File & Open in IDE</button>
107
+ <button class="btn btn-secondary" onclick="copyMigrationCode()"><%= t("pg_reports.ui.actions.copy_code") %></button>
108
+ <button class="btn btn-primary" id="create-migration-btn" onclick="createMigrationFile()"><%= t("pg_reports.ui.actions.create_migration_file") %></button>
109
109
  </div>
110
110
  </div>
111
111
  </div>