pg_reports 0.3.1 → 0.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/app/controllers/pg_reports/dashboard_controller.rb +59 -4
  4. data/app/views/layouts/pg_reports/application.html.erb +1 -1
  5. data/app/views/pg_reports/dashboard/_show_modals.html.erb +8 -1
  6. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +56 -18
  7. data/app/views/pg_reports/dashboard/_show_styles.html.erb +122 -1
  8. data/app/views/pg_reports/dashboard/show.html.erb +89 -47
  9. data/config/locales/en.yml +13 -0
  10. data/config/locales/ru.yml +13 -0
  11. data/config/locales/uk.yml +13 -0
  12. data/lib/pg_reports/dashboard/reports_registry.rb +14 -0
  13. data/lib/pg_reports/definitions/connections/active_connections.yml +23 -0
  14. data/lib/pg_reports/definitions/connections/blocking_queries.yml +20 -0
  15. data/lib/pg_reports/definitions/connections/connection_stats.yml +18 -0
  16. data/lib/pg_reports/definitions/connections/idle_connections.yml +21 -0
  17. data/lib/pg_reports/definitions/connections/locks.yml +22 -0
  18. data/lib/pg_reports/definitions/connections/long_running_queries.yml +43 -0
  19. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +43 -0
  20. data/lib/pg_reports/definitions/indexes/duplicate_indexes.yml +19 -0
  21. data/lib/pg_reports/definitions/indexes/index_sizes.yml +29 -0
  22. data/lib/pg_reports/definitions/indexes/index_usage.yml +27 -0
  23. data/lib/pg_reports/definitions/indexes/invalid_indexes.yml +19 -0
  24. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +27 -0
  25. data/lib/pg_reports/definitions/indexes/unused_indexes.yml +41 -0
  26. data/lib/pg_reports/definitions/queries/all_queries.yml +35 -0
  27. data/lib/pg_reports/definitions/queries/expensive_queries.yml +43 -0
  28. data/lib/pg_reports/definitions/queries/heavy_queries.yml +49 -0
  29. data/lib/pg_reports/definitions/queries/low_cache_hit_queries.yml +47 -0
  30. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +31 -0
  31. data/lib/pg_reports/definitions/queries/slow_queries.yml +48 -0
  32. data/lib/pg_reports/definitions/system/activity_overview.yml +17 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +18 -0
  34. data/lib/pg_reports/definitions/system/database_sizes.yml +18 -0
  35. data/lib/pg_reports/definitions/system/extensions.yml +19 -0
  36. data/lib/pg_reports/definitions/system/settings.yml +20 -0
  37. data/lib/pg_reports/definitions/tables/bloated_tables.yml +43 -0
  38. data/lib/pg_reports/definitions/tables/cache_hit_ratios.yml +26 -0
  39. data/lib/pg_reports/definitions/tables/recently_modified.yml +27 -0
  40. data/lib/pg_reports/definitions/tables/row_counts.yml +29 -0
  41. data/lib/pg_reports/definitions/tables/seq_scans.yml +31 -0
  42. data/lib/pg_reports/definitions/tables/table_sizes.yml +31 -0
  43. data/lib/pg_reports/definitions/tables/vacuum_needed.yml +39 -0
  44. data/lib/pg_reports/filter.rb +58 -0
  45. data/lib/pg_reports/module_generator.rb +44 -0
  46. data/lib/pg_reports/modules/connections.rb +8 -73
  47. data/lib/pg_reports/modules/indexes.rb +9 -94
  48. data/lib/pg_reports/modules/queries.rb +9 -100
  49. data/lib/pg_reports/modules/schema_analysis.rb +156 -0
  50. data/lib/pg_reports/modules/system.rb +7 -59
  51. data/lib/pg_reports/modules/tables.rb +9 -96
  52. data/lib/pg_reports/report_definition.rb +161 -0
  53. data/lib/pg_reports/report_loader.rb +38 -0
  54. data/lib/pg_reports/sql/schema_analysis/unique_indexes.sql +35 -0
  55. data/lib/pg_reports/version.rb +1 -1
  56. data/lib/pg_reports.rb +24 -0
  57. metadata +38 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be278e9401573fc93ab6d619427d7356609bf26a03cb69ac6a5dbbc2f7b39409
4
- data.tar.gz: b5fae395bbae259280751183344407b69eb2e90da268b380e5bfb97a6ddbf382
3
+ metadata.gz: 7a57deebae9eda0df4aceb3cfeb1912e2559a7cd70c3c7f197fb86f06bb689b1
4
+ data.tar.gz: 9369b5d6f54d8b0f2939f57d429f477aa0a8c2a862add70e2116232f364fe1e7
5
5
  SHA512:
6
- metadata.gz: f76f4ff3f31c36aae1b21dc080283d6a49d5a41b6b9405a569548eae21df5a8f5c7f0c205a88d712f84a0b4ad166486a82bec9dbed0a0185fc2301ca375ceb6f
7
- data.tar.gz: cf31a2c98627e7d0a8aab41fa041682371fa75b2673500f9d65700a246cef7c6f323e6e1bc9238d4793b0e49a21785b0446cb3216de1c8759877c17e593532fc
6
+ metadata.gz: 0e6248b26056ffe059105c2399f7a9c8e607e3f34030d4af6f14b324deb76435163b8b2116ff5d7502350410f0a6a20368ac3757b9247d30328da3330a941a86
7
+ data.tar.gz: 5d67f8d3c5663421b839301c3c4463502b7cbaeb57d3351d808ff00ebb355141b6c7cf676df091f7a2bb52238809cb02b8e9123d16b7a597bbc636e0cf888540
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-01-29
9
+
10
+ ### Added
11
+
12
+ - **YAML-based Report Configuration System** - declarative report definitions:
13
+ - Each report now defined in a single YAML file (1 file = 1 report approach)
14
+ - Migrated all 31 reports from Ruby methods to YAML format
15
+ - New classes: `ReportDefinition`, `Filter`, `ReportLoader`, `ModuleGenerator`
16
+ - Support for post-SQL filtering with operators (eq, ne, lt, lte, gt, gte)
17
+ - Title interpolation with variable substitution using `${variable}` syntax
18
+ - Enrichment hooks pattern for data transformation
19
+ - Problem explanations configuration in YAML (field → explanation key mapping)
20
+ - **Filter UI** on report pages for manual parameter input:
21
+ - Collapsible filter section with form inputs for each parameter
22
+ - Support for threshold overrides (limit, min_duration_seconds, etc.)
23
+ - Real-time report refresh with custom filter values
24
+ - Horizontal layout with descriptions and inputs side-by-side
25
+ - Dark theme styling for filter inputs and labels
26
+
27
+ ### Changed
28
+
29
+ - Report definitions now use declarative YAML files instead of repetitive Ruby methods (~800 lines of code eliminated)
30
+ - Modules now use metaprogramming to dynamically generate methods from YAML at load time
31
+ - Dashboard category cards width increased from 280px to 350px for better readability
32
+ - Documentation and filter sections positioned side-by-side in responsive grid layout
33
+ - Filter parameters section positioned below documentation and collapsed by default
34
+ - Reduced vertical spacing between sections for more compact layout
35
+ - Problem field to explanation key mapping moved from hardcoded JavaScript to server-driven YAML configuration
36
+
37
+ ### Fixed
38
+
39
+ - CSS Grid `align-items: stretch` causing synchronized expansion of documentation and filter blocks
40
+ - Visual "chin" appearing on closed filter details block due to default margins
41
+ - TypeError when report has no limit parameter default value
42
+ - Excessive margins between report sections
43
+
8
44
  ## [0.3.0] - 2026-01-28
9
45
 
10
46
  ### Added
@@ -51,6 +51,9 @@ module PgReports
51
51
  @thresholds = Dashboard::ReportsRegistry.thresholds(@report_key)
52
52
  @problem_fields = Dashboard::ReportsRegistry.problem_fields(@report_key)
53
53
 
54
+ # Load filter parameters from YAML
55
+ @report_filters = load_report_filters(@category, @report_key)
56
+
54
57
  @report = execute_report(@category, @report_key)
55
58
  rescue => e
56
59
  @error = e.message
@@ -61,9 +64,13 @@ module PgReports
61
64
  category = params[:category].to_sym
62
65
  report_key = params[:report].to_sym
63
66
 
64
- report = execute_report(category, report_key)
67
+ # Extract filter parameters from request
68
+ filter_params = extract_filter_params
69
+
70
+ report = execute_report(category, report_key, **filter_params)
65
71
  thresholds = Dashboard::ReportsRegistry.thresholds(report_key)
66
72
  problem_fields = Dashboard::ReportsRegistry.problem_fields(report_key)
73
+ problem_explanations = load_problem_explanations(category, report_key)
67
74
 
68
75
  render json: {
69
76
  success: true,
@@ -73,7 +80,8 @@ module PgReports
73
80
  total: report.size,
74
81
  generated_at: report.generated_at.strftime("%Y-%m-%d %H:%M:%S"),
75
82
  thresholds: thresholds,
76
- problem_fields: problem_fields
83
+ problem_fields: problem_fields,
84
+ problem_explanations: problem_explanations
77
85
  }
78
86
  rescue => e
79
87
  render json: {success: false, error: e.message}, status: :unprocessable_entity
@@ -240,6 +248,15 @@ module PgReports
240
248
  end
241
249
 
242
250
  def create_migration
251
+ # Only allow migration creation in development environment
252
+ unless Rails.env.development?
253
+ render json: {
254
+ success: false,
255
+ error: "Migration creation is only allowed in development environment"
256
+ }, status: :forbidden
257
+ return
258
+ end
259
+
243
260
  file_name = params[:file_name]
244
261
  code = params[:code]
245
262
 
@@ -280,13 +297,51 @@ module PgReports
280
297
  @categories = Dashboard::ReportsRegistry.all
281
298
  end
282
299
 
283
- def execute_report(category, report_key)
300
+ def load_report_filters(category, report_key)
301
+ definition = ReportLoader.get(category.to_s, report_key.to_s)
302
+ return {} unless definition
303
+
304
+ definition.filter_parameters
305
+ end
306
+
307
+ def load_problem_explanations(category, report_key)
308
+ definition = ReportLoader.get(category.to_s, report_key.to_s)
309
+ return {} unless definition
310
+
311
+ definition.problem_explanations
312
+ end
313
+
314
+ def extract_filter_params
315
+ # Allow common filter parameters
316
+ allowed = [:limit, :min_duration_seconds, :min_calls]
317
+ result = {}
318
+
319
+ allowed.each do |key|
320
+ if params[key].present?
321
+ value = params[key].to_s
322
+ # Convert to appropriate type
323
+ result[key] = value.match?(/^\d+$/) ? value.to_i : value
324
+ end
325
+ end
326
+
327
+ # Also allow threshold overrides (calls_threshold, etc.)
328
+ params.each do |key, value|
329
+ if key.to_s.end_with?('_threshold') && value.present?
330
+ result[key.to_sym] = value.to_i
331
+ end
332
+ end
333
+
334
+ result
335
+ end
336
+
337
+ def execute_report(category, report_key, **filter_params)
284
338
  mod = case category
285
339
  when :queries then Modules::Queries
286
340
  when :indexes then Modules::Indexes
287
341
  when :tables then Modules::Tables
288
342
  when :connections then Modules::Connections
289
343
  when :system then Modules::System
344
+ when :schema_analysis then Modules::SchemaAnalysis
290
345
  else raise ArgumentError, "Unknown category: #{category}"
291
346
  end
292
347
 
@@ -294,7 +349,7 @@ module PgReports
294
349
  raise ArgumentError, "Unknown report: #{report_key}"
295
350
  end
296
351
 
297
- mod.public_send(report_key)
352
+ mod.public_send(report_key, **filter_params)
298
353
  end
299
354
 
300
355
  def substitute_params(query, params_hash)
@@ -122,7 +122,7 @@
122
122
  /* Categories Grid */
123
123
  .categories-grid {
124
124
  display: grid;
125
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
125
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
126
126
  gap: 1.5rem;
127
127
  margin-bottom: 2rem;
128
128
  }
@@ -94,11 +94,18 @@
94
94
  <button class="modal-close" onclick="closeMigrationModal()">&times;</button>
95
95
  </div>
96
96
  <div class="modal-body" id="migration-modal-body">
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>
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>
102
+ </p>
103
+ </div>
97
104
  <p class="settings-label">Generated migration to remove the index:</p>
98
105
  <div id="migration-code" class="migration-code"></div>
99
106
  <div class="migration-actions">
100
107
  <button class="btn btn-secondary" onclick="copyMigrationCode()">📋 Copy Code</button>
101
- <button class="btn btn-primary" onclick="createMigrationFile()">📁 Create File & Open in IDE</button>
108
+ <button class="btn btn-primary" id="create-migration-btn" onclick="createMigrationFile()">📁 Create File & Open in IDE</button>
102
109
  </div>
103
110
  </div>
104
111
  </div>
@@ -1,7 +1,10 @@
1
1
  <script>
2
2
  let currentReportData = null;
3
+ let currentProblemExplanations = {};
3
4
  const category = '<%= @category %>';
4
5
  const reportKey = '<%= @report_key %>';
6
+ const railsEnv = '<%= Rails.env %>';
7
+ const isDevelopment = railsEnv === 'development';
5
8
  let syncingScroll = false;
6
9
  let currentSort = { column: null, direction: 'asc' };
7
10
 
@@ -331,21 +334,16 @@
331
334
  document.getElementById('problem-modal').style.display = 'none';
332
335
  }
333
336
 
334
- // Map problem fields to explanation keys
337
+ // Map problem fields to explanation keys using server-provided mapping
335
338
  function getExplanationKey(problems) {
336
339
  const fields = problems.map(p => p.field);
337
340
 
338
- if (fields.includes('mean_time_ms')) return 'high_mean_time';
339
- if (fields.includes('calls')) return 'high_calls';
340
- if (fields.includes('total_time_ms')) return 'high_total_time';
341
- if (fields.includes('cache_hit_ratio')) return 'low_cache_hit';
342
- if (fields.includes('seq_scan') || fields.includes('seq_tup_read')) return 'high_seq_scan';
343
- if (fields.includes('idx_scan')) return 'unused_index';
344
- if (fields.includes('bloat_ratio') || fields.includes('bloat_size')) return 'high_bloat';
345
- if (fields.includes('dead_tuple_ratio') || fields.includes('n_dead_tup')) return 'many_dead_tuples';
346
- if (fields.includes('duration_seconds') || fields.includes('duration')) return 'long_running';
347
- if (fields.includes('blocked_count')) return 'blocking';
348
- if (fields.includes('idle_in_transaction')) return 'idle_in_transaction';
341
+ // Use server-provided problem explanations mapping
342
+ for (const field of fields) {
343
+ if (currentProblemExplanations[field]) {
344
+ return currentProblemExplanations[field];
345
+ }
346
+ }
349
347
 
350
348
  return '';
351
349
  }
@@ -541,11 +539,15 @@
541
539
 
542
540
  function buildDetailRow(row, columns, rowIndex, thresholds, problemFields) {
543
541
  let html = '<div class="row-detail">';
544
- const hasQuery = columns.includes('query') && row.query;
545
- const hasIndexName = columns.includes('index_name') && row.index_name;
542
+ const hasQuery = columns.includes('query') || row.query;
543
+ const hasIndexName = columns.includes('index_name') || row.index_name;
546
544
  const rowId = generateRowId(row);
547
545
 
548
- columns.forEach(col => {
546
+ // Show all fields from row, not just columns
547
+ // First show fields from columns array in order, then remaining fields
548
+ const allKeys = [...new Set([...columns, ...Object.keys(row)])];
549
+
550
+ allKeys.forEach(col => {
549
551
  const value = row[col] ?? '';
550
552
  const strValue = String(value);
551
553
  const isQuery = col === 'query';
@@ -599,12 +601,16 @@
599
601
  }
600
602
  }
601
603
 
602
- // Show migration button for index reports
604
+ // Show migration button for index reports (only in development)
603
605
  if (hasIndexName && (category === 'indexes')) {
604
606
  const indexName = row.index_name;
605
607
  const tableName = row.table_name || row.tablename || '';
606
608
  const schemaName = row.schema_name || row.schemaname || 'public';
607
- html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">🗑️ Generate Migration</button>`;
609
+ if (isDevelopment) {
610
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">🗑️ Generate Migration</button>`;
611
+ } else {
612
+ html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" style="opacity: 0.5; cursor: not-allowed;">🗑️ Generate Migration (Development Only)</button>`;
613
+ }
608
614
  }
609
615
 
610
616
  html += `</div>`;
@@ -634,12 +640,27 @@
634
640
  if (telegramBtn) telegramBtn.style.display = 'none';
635
641
 
636
642
  try {
643
+ // Collect filter parameters from form
644
+ const filterParams = {};
645
+ document.querySelectorAll('[data-param]').forEach(input => {
646
+ const paramName = input.dataset.param;
647
+ const value = input.value;
648
+ if (value && value.trim() !== '') {
649
+ filterParams[paramName] = value;
650
+ }
651
+ });
652
+
637
653
  const response = await fetch(`${pgReportsRoot}/${cat}/${report}/run`, {
638
654
  method: 'POST',
639
655
  headers: {
640
656
  'Content-Type': 'application/json',
641
657
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
642
- }
658
+ },
659
+ body: JSON.stringify({
660
+ category: cat,
661
+ report: report,
662
+ ...filterParams
663
+ })
643
664
  });
644
665
 
645
666
  const data = await response.json();
@@ -653,6 +674,7 @@
653
674
  }
654
675
 
655
676
  currentReportData = data;
677
+ currentProblemExplanations = data.problem_explanations || {};
656
678
  const thresholds = data.thresholds || {};
657
679
  const problemFields = data.problem_fields || [];
658
680
 
@@ -1239,6 +1261,22 @@ end
1239
1261
 
1240
1262
  document.getElementById('migration-code').textContent = migrationCode;
1241
1263
  document.getElementById('migration-modal').style.display = 'flex';
1264
+
1265
+ // Disable "Create File" button in non-development environments
1266
+ const createBtn = document.getElementById('create-migration-btn');
1267
+ if (createBtn) {
1268
+ if (!isDevelopment) {
1269
+ createBtn.disabled = true;
1270
+ createBtn.style.opacity = '0.5';
1271
+ createBtn.style.cursor = 'not-allowed';
1272
+ createBtn.title = 'Migration file creation is only available in development environment';
1273
+ } else {
1274
+ createBtn.disabled = false;
1275
+ createBtn.style.opacity = '1';
1276
+ createBtn.style.cursor = 'pointer';
1277
+ createBtn.title = '';
1278
+ }
1279
+ }
1242
1280
  }
1243
1281
 
1244
1282
  function closeMigrationModal() {
@@ -4,7 +4,6 @@
4
4
  background: var(--bg-card);
5
5
  border: 1px solid var(--border-color);
6
6
  border-radius: 12px;
7
- margin-bottom: 0.75rem;
8
7
  overflow: hidden;
9
8
  }
10
9
 
@@ -1143,4 +1142,126 @@
1143
1142
  gap: 0.75rem;
1144
1143
  justify-content: flex-end;
1145
1144
  }
1145
+
1146
+ /* Report details grid (documentation + filters side by side) */
1147
+ .report-details-grid {
1148
+ display: grid;
1149
+ grid-template-columns: 1fr 1fr;
1150
+ gap: 1rem;
1151
+ align-items: start;
1152
+ }
1153
+
1154
+ @media (max-width: 1200px) {
1155
+ .report-details-grid {
1156
+ grid-template-columns: 1fr;
1157
+ }
1158
+ }
1159
+
1160
+ /* Filter Section */
1161
+ .filter-section {
1162
+ background: var(--bg-card);
1163
+ border: 1px solid var(--border-color);
1164
+ border-radius: 12px;
1165
+ overflow: hidden;
1166
+ }
1167
+
1168
+ .filter-details {
1169
+ margin: 0;
1170
+ display: block;
1171
+ }
1172
+
1173
+ .filter-toggle {
1174
+ display: flex;
1175
+ align-items: center;
1176
+ gap: 0.75rem;
1177
+ padding: 1rem 1.25rem;
1178
+ margin: 0;
1179
+ cursor: pointer;
1180
+ user-select: none;
1181
+ color: var(--text-secondary);
1182
+ font-weight: 500;
1183
+ transition: all 0.15s;
1184
+ }
1185
+
1186
+ .filter-toggle:hover {
1187
+ background: var(--bg-tertiary);
1188
+ color: var(--text-primary);
1189
+ }
1190
+
1191
+ .filter-details[open] .filter-toggle {
1192
+ border-bottom: 1px solid var(--border-color);
1193
+ background: var(--bg-tertiary);
1194
+ }
1195
+
1196
+ .filter-details[open] .toggle-icon {
1197
+ transform: rotate(90deg);
1198
+ }
1199
+
1200
+ .filter-details:not([open]) .filter-toggle {
1201
+ background: transparent;
1202
+ }
1203
+
1204
+ .filter-details:not([open]) .filter-toggle:hover {
1205
+ background: var(--bg-tertiary);
1206
+ }
1207
+
1208
+ .filter-content {
1209
+ padding: 1rem 1.25rem;
1210
+ background: var(--bg-card);
1211
+ }
1212
+
1213
+ .filter-grid {
1214
+ display: flex;
1215
+ flex-direction: column;
1216
+ gap: 0.75rem;
1217
+ }
1218
+
1219
+ .filter-item {
1220
+ display: grid;
1221
+ grid-template-columns: 1fr auto;
1222
+ gap: 1.5rem;
1223
+ align-items: start;
1224
+ }
1225
+
1226
+ .filter-label {
1227
+ display: flex;
1228
+ flex-direction: column;
1229
+ gap: 0.25rem;
1230
+ font-weight: 500;
1231
+ color: var(--text-primary);
1232
+ font-size: 0.9rem;
1233
+ line-height: 1.4;
1234
+ }
1235
+
1236
+ .filter-description {
1237
+ font-size: 0.8rem;
1238
+ font-weight: 400;
1239
+ color: var(--text-secondary);
1240
+ opacity: 0.8;
1241
+ }
1242
+
1243
+ .filter-current-value {
1244
+ font-size: 0.8rem;
1245
+ font-weight: 400;
1246
+ color: var(--accent-blue);
1247
+ opacity: 0.9;
1248
+ }
1249
+
1250
+ .filter-input {
1251
+ padding: 0.625rem 0.875rem;
1252
+ border: 1px solid var(--border-color);
1253
+ border-radius: 8px;
1254
+ font-size: 1rem;
1255
+ background: var(--bg-primary);
1256
+ color: var(--text-primary);
1257
+ transition: all 0.15s;
1258
+ min-width: 120px;
1259
+ max-width: 200px;
1260
+ }
1261
+
1262
+ .filter-input:focus {
1263
+ outline: none;
1264
+ border-color: var(--accent-blue);
1265
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
1266
+ }
1146
1267
  </style>
@@ -50,59 +50,101 @@
50
50
  </div>
51
51
  <% end %>
52
52
 
53
- <!-- Collapsible Documentation Section -->
54
- <% if @documentation && @documentation[:what].present? %>
55
- <details class="documentation-section">
56
- <summary class="documentation-toggle">
57
- <span class="toggle-icon">▶</span>
58
- <span>📖 What does this report show?</span>
59
- </summary>
60
- <div class="documentation-content">
61
- <% if @documentation[:what].present? %>
62
- <div class="doc-block">
63
- <h4>📋 What</h4>
64
- <p><%= @documentation[:what] %></p>
65
- </div>
66
- <% end %>
53
+ <div class="report-details-grid">
54
+ <!-- Collapsible Documentation Section -->
55
+ <% if @documentation && @documentation[:what].present? %>
56
+ <details class="documentation-section">
57
+ <summary class="documentation-toggle">
58
+ <span class="toggle-icon">▶</span>
59
+ <span>📖 What does this report show?</span>
60
+ </summary>
61
+ <div class="documentation-content">
62
+ <% if @documentation[:what].present? %>
63
+ <div class="doc-block">
64
+ <h4>📋 What</h4>
65
+ <p><%= @documentation[:what] %></p>
66
+ </div>
67
+ <% end %>
67
68
 
68
- <% if @documentation[:why].present? %>
69
- <div class="doc-block">
70
- <h4>❓ Why It Matters</h4>
71
- <p><%= @documentation[:why] %></p>
72
- </div>
73
- <% end %>
74
-
75
- <% if @documentation[:nuances].present? %>
76
- <div class="doc-block">
77
- <h4>⚠️ Nuances</h4>
78
- <ul class="nuances-list">
79
- <% @documentation[:nuances].each do |nuance| %>
80
- <li><%= nuance %></li>
81
- <% end %>
82
- </ul>
83
- </div>
84
- <% end %>
85
-
86
- <% if @thresholds.present? %>
87
- <div class="thresholds-block">
88
- <h4>📊 Thresholds</h4>
89
- <div class="thresholds-grid">
90
- <% @thresholds.each do |field, values| %>
91
- <div class="threshold-item">
92
- <span class="threshold-field"><%= field %></span>
93
- <span class="threshold-warning">⚠️ Warning: <%= values[:warning] %></span>
94
- <span class="threshold-critical">🔴 Critical: <%= values[:critical] %></span>
95
- <% if values[:inverted] %>
96
- <span class="threshold-note">(lower is worse)</span>
97
- <% end %>
69
+ <% if @documentation[:why].present? %>
70
+ <div class="doc-block">
71
+ <h4>❓ Why It Matters</h4>
72
+ <p><%= @documentation[:why] %></p>
73
+ </div>
74
+ <% end %>
75
+
76
+ <% if @documentation[:nuances].present? %>
77
+ <div class="doc-block">
78
+ <h4>⚠️ Nuances</h4>
79
+ <ul class="nuances-list">
80
+ <% @documentation[:nuances].each do |nuance| %>
81
+ <li><%= nuance %></li>
82
+ <% end %>
83
+ </ul>
84
+ </div>
85
+ <% end %>
86
+
87
+ <% if @thresholds.present? %>
88
+ <div class="thresholds-block">
89
+ <h4>📊 Thresholds</h4>
90
+ <div class="thresholds-grid">
91
+ <% @thresholds.each do |field, values| %>
92
+ <div class="threshold-item">
93
+ <span class="threshold-field"><%= field %></span>
94
+ <span class="threshold-warning">⚠️ Warning: <%= values[:warning] %></span>
95
+ <span class="threshold-critical">🔴 Critical: <%= values[:critical] %></span>
96
+ <% if values[:inverted] %>
97
+ <span class="threshold-note">(lower is worse)</span>
98
+ <% end %>
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+ <% end %>
104
+ </div>
105
+ </details>
106
+ <% end %>
107
+
108
+ <!-- Filter Parameters Section -->
109
+ <% if @report_filters.present? %>
110
+ <div class="filter-section">
111
+ <details class="filter-details">
112
+ <summary class="filter-toggle">
113
+ <span class="toggle-icon">▶</span>
114
+ <span>🔍 Параметры фильтрации</span>
115
+ </summary>
116
+ <div class="filter-content">
117
+ <div class="filter-grid">
118
+ <% @report_filters.each do |name, config| %>
119
+ <div class="filter-item">
120
+ <label class="filter-label">
121
+ <%= config[:label] %>
122
+ <% if config[:description] %>
123
+ <span class="filter-description"><%= config[:description] %></span>
124
+ <% end %>
125
+ <% if config[:is_threshold] && config[:current_config] %>
126
+ <span class="filter-current-value">(current: <%= config[:current_config] %>)</span>
127
+ <% end %>
128
+ </label>
129
+ <input
130
+ type="<%= config[:type] == 'integer' ? 'number' : 'text' %>"
131
+ class="filter-input"
132
+ data-param="<%= name %>"
133
+ value="<%= config[:default] %>"
134
+ <% if config[:type] == 'integer' %>
135
+ min="0"
136
+ step="1"
137
+ <% end %>
138
+ placeholder="<%= config[:default] %>"
139
+ >
98
140
  </div>
99
141
  <% end %>
100
142
  </div>
101
143
  </div>
102
- <% end %>
144
+ </details>
103
145
  </div>
104
- </details>
105
- <% end %>
146
+ <% end %>
147
+ </div>
106
148
 
107
149
  <!-- Saved Records Section -->
108
150
  <div class="saved-records-section" id="saved-records-section" style="display: none;">
@@ -295,6 +295,19 @@ en:
295
295
  - "Target value: >99% for OLTP, >95% for mixed workload."
296
296
  - "Low cache hit: increase shared_buffers (up to 25% RAM), or the problem is in queries."
297
297
 
298
+ # === SCHEMA ANALYSIS ===
299
+ missing_validations:
300
+ title: "Missing Validations"
301
+ what: "Unique indexes in the database without corresponding uniqueness validations in Rails models."
302
+ how: "Analyzes all unique indexes (including composite ones) and checks if the corresponding Rails model has validates :column, uniqueness: true validation."
303
+ nuances:
304
+ - "Database constraints (indexes) and model validations serve different purposes — both should be present."
305
+ - "Unique index prevents duplicates at database level, validation provides user-friendly error messages."
306
+ - "For composite indexes (a, b), need validation with scope: validates :a, uniqueness: { scope: :b }"
307
+ - "Some tables may not have models — this is normal for join tables or legacy tables."
308
+ - "Validations in concerns and parent models are also detected."
309
+ - "Primary keys don't need validations — they are automatically unique."
310
+
298
311
  # Problem explanations for highlighting
299
312
  problems:
300
313
  high_mean_time: "High mean execution time. Consider adding indexes or optimizing the query."
@@ -295,6 +295,19 @@ ru:
295
295
  - "Целевое значение: >99% для OLTP, >95% для mixed workload."
296
296
  - "Низкий cache hit: увеличьте shared_buffers (до 25% RAM), или проблема в запросах."
297
297
 
298
+ # === SCHEMA ANALYSIS ===
299
+ missing_validations:
300
+ title: "Отсутствующие валидации"
301
+ what: "Уникальные индексы в базе данных без соответствующих валидаций uniqueness в Rails-моделях."
302
+ how: "Анализирует все уникальные индексы (включая композитные) и проверяет наличие validates :column, uniqueness: true в соответствующей Rails-модели."
303
+ nuances:
304
+ - "Ограничения БД (индексы) и валидации модели служат разным целям — оба должны присутствовать."
305
+ - "Уникальный индекс предотвращает дубликаты на уровне БД, валидация даёт понятные сообщения об ошибках."
306
+ - "Для композитных индексов (a, b) нужна валидация со scope: validates :a, uniqueness: { scope: :b }"
307
+ - "Некоторые таблицы могут не иметь моделей — это нормально для join-таблиц или legacy-таблиц."
308
+ - "Валидации в concerns и родительских моделях также детектируются."
309
+ - "Первичные ключи не требуют валидаций — они автоматически уникальны."
310
+
298
311
  # Problem explanations for highlighting
299
312
  problems:
300
313
  high_mean_time: "Высокое среднее время выполнения. Рассмотрите добавление индексов или оптимизацию запроса."