pg_reports 0.6.0 → 0.6.2

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +143 -378
  4. data/app/controllers/pg_reports/dashboard_controller.rb +21 -21
  5. data/app/views/layouts/pg_reports/application.html.erb +65 -8
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +22 -22
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +55 -57
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +18 -0
  9. data/app/views/pg_reports/dashboard/index.html.erb +109 -106
  10. data/app/views/pg_reports/dashboard/show.html.erb +26 -26
  11. data/config/locales/en.yml +488 -0
  12. data/config/locales/ru.yml +481 -0
  13. data/config/locales/uk.yml +481 -0
  14. data/lib/pg_reports/annotation_parser.rb +13 -1
  15. data/lib/pg_reports/compatibility.rb +3 -3
  16. data/lib/pg_reports/dashboard/reports_registry.rb +83 -12
  17. data/lib/pg_reports/definitions/schema_analysis/always_null_columns.yml +31 -0
  18. data/lib/pg_reports/definitions/schema_analysis/unused_columns.yml +32 -0
  19. data/lib/pg_reports/definitions/tables/unused_tables.yml +30 -0
  20. data/lib/pg_reports/definitions/tables/update_hotspots.yml +32 -0
  21. data/lib/pg_reports/module_generator.rb +2 -1
  22. data/lib/pg_reports/modules/schema_analysis.rb +261 -2
  23. data/lib/pg_reports/modules/system.rb +3 -3
  24. data/lib/pg_reports/query_monitor.rb +2 -6
  25. data/lib/pg_reports/report_definition.rb +20 -24
  26. data/lib/pg_reports/sql/schema_analysis/always_null_columns.sql +25 -0
  27. data/lib/pg_reports/sql/schema_analysis/unused_columns.sql +36 -0
  28. data/lib/pg_reports/sql/tables/unused_tables.sql +19 -0
  29. data/lib/pg_reports/sql/tables/update_hotspots.sql +26 -0
  30. data/lib/pg_reports/version.rb +1 -1
  31. metadata +9 -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">
@@ -78,15 +78,35 @@
78
78
  font-size: 1rem;
79
79
  }
80
80
 
81
+ .logo-text {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 5px;
85
+ line-height: 1.15;
86
+ }
87
+
81
88
  .logo-text h1 {
82
89
  font-size: 1.1rem;
83
90
  font-weight: 600;
84
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;
85
97
  }
86
98
 
87
- .logo-text span {
99
+ .logo-version {
88
100
  font-size: 0.7rem;
101
+ font-weight: 500;
89
102
  color: var(--text-muted);
103
+ letter-spacing: 0.02em;
104
+ }
105
+
106
+ .logo-subtitle {
107
+ font-size: 0.7rem;
108
+ color: var(--text-muted);
109
+ line-height: 1.15;
90
110
  }
91
111
 
92
112
  .header-badge {
@@ -487,6 +507,36 @@
487
507
  width: 90%;
488
508
  max-height: 90vh;
489
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);
490
540
  }
491
541
 
492
542
  .modal-large {
@@ -954,6 +1004,13 @@
954
1004
 
955
1005
  <script>
956
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
+ };
957
1014
 
958
1015
  function showToast(message, type = 'success') {
959
1016
  const toast = document.getElementById('toast');
@@ -967,7 +1024,7 @@
967
1024
  async function sendToTelegram(category, report, button) {
968
1025
  if (button) {
969
1026
  button.disabled = true;
970
- 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;
971
1028
  }
972
1029
 
973
1030
  try {
@@ -982,17 +1039,17 @@
982
1039
  const data = await response.json();
983
1040
 
984
1041
  if (data.success) {
985
- showToast('Report sent to Telegram');
1042
+ showToast(PG_REPORTS_I18N.success.telegram_sent);
986
1043
  } else {
987
- showToast(data.error || 'Failed to send to Telegram', 'error');
1044
+ showToast(data.error || PG_REPORTS_I18N.errors.send_telegram_failed, 'error');
988
1045
  }
989
1046
  } catch (error) {
990
- showToast('Network error: ' + error.message, 'error');
1047
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
991
1048
  }
992
1049
 
993
1050
  if (button) {
994
1051
  button.disabled = false;
995
- button.innerHTML = '📨 Telegram';
1052
+ button.innerHTML = PG_REPORTS_I18N.actions.send_telegram;
996
1053
  }
997
1054
  }
998
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>