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
@@ -233,7 +233,7 @@
233
233
  function copyToClipboard(text, btn) {
234
234
  navigator.clipboard.writeText(text).then(() => {
235
235
  const originalText = btn.textContent;
236
- btn.textContent = '✓ Copied!';
236
+ btn.textContent = PG_REPORTS_I18N.actions.copied_feedback;
237
237
  btn.style.background = 'var(--accent-green)';
238
238
  btn.style.borderColor = 'var(--accent-green)';
239
239
  btn.style.color = 'white';
@@ -244,7 +244,7 @@
244
244
  btn.style.color = '';
245
245
  }, 1500);
246
246
  }).catch(() => {
247
- showToast('Failed to copy', 'error');
247
+ showToast(PG_REPORTS_I18N.errors.copy_failed, 'error');
248
248
  });
249
249
  }
250
250
 
@@ -288,15 +288,15 @@
288
288
  function copyAiPrompt(el) {
289
289
  const prompt = buildAiPrompt();
290
290
  if (!prompt) {
291
- showToast('Run the report first', 'error');
291
+ showToast(PG_REPORTS_I18N.errors.run_report_first, 'error');
292
292
  return;
293
293
  }
294
294
 
295
295
  document.getElementById('dropdown-menu').classList.remove('show');
296
296
  navigator.clipboard.writeText(prompt).then(() => {
297
- showToast('AI prompt copied to clipboard');
297
+ showToast(PG_REPORTS_I18N.success.ai_prompt_copied);
298
298
  }).catch(() => {
299
- showToast('Failed to copy', 'error');
299
+ showToast(PG_REPORTS_I18N.errors.copy_failed, 'error');
300
300
  });
301
301
  }
302
302
 
@@ -351,15 +351,15 @@
351
351
 
352
352
  for (const problem of problems) {
353
353
  const levelClass = problem.level === 'critical' ? 'critical' : 'warning';
354
- const levelText = problem.level === 'critical' ? '🔴 Critical' : '⚠️ Warning';
354
+ const levelText = problem.level === 'critical' ? PG_REPORTS_I18N.levels.critical : PG_REPORTS_I18N.levels.warning;
355
355
 
356
356
  html += `
357
357
  <div class="problem-field">
358
358
  <span class="problem-field-label">${escapeHtml(problem.field)} (${levelText})</span>
359
359
  <div class="problem-field-value ${levelClass}">
360
- Current: ${escapeHtml(String(problem.value))}
361
- <br>Threshold: warning=${problem.threshold.warning}, critical=${problem.threshold.critical}
362
- ${problem.threshold.inverted ? '<br><em>(inverted: lower values are worse)</em>' : ''}
360
+ ${PG_REPORTS_I18N.sections.current_label} ${escapeHtml(String(problem.value))}
361
+ <br>${PG_REPORTS_I18N.sections.threshold_label} ${PG_REPORTS_I18N.sections.warning_eq}=${problem.threshold.warning}, ${PG_REPORTS_I18N.sections.critical_eq}=${problem.threshold.critical}
362
+ ${problem.threshold.inverted ? '<br><em>' + PG_REPORTS_I18N.sections.threshold_inverted_long + '</em>' : ''}
363
363
  </div>
364
364
  </div>
365
365
  `;
@@ -372,7 +372,7 @@
372
372
  if (explanation) {
373
373
  html += `
374
374
  <div class="problem-explanation">
375
- <h4>💡 Recommendation</h4>
375
+ <h4>${PG_REPORTS_I18N.sections.recommendation}</h4>
376
376
  <p>${escapeHtml(explanation)}</p>
377
377
  </div>
378
378
  `;
@@ -632,7 +632,7 @@
632
632
  <div class="row-detail-item${isLongText ? ' full-width' : ''}">
633
633
  <span class="row-detail-label">${escapeHtml(col)}</span>
634
634
  <div class="row-detail-value ${valueClass}">${escapeHtml(strValue)}</div>
635
- ${isQuery ? `<button class="copy-btn" data-query="${escapeHtmlAttr(strValue)}" onclick="event.stopPropagation(); copyQueryFromButton(this)">📋 Copy Query</button>` : ''}
635
+ ${isQuery ? `<button class="copy-btn" data-query="${escapeHtmlAttr(strValue)}" onclick="event.stopPropagation(); copyQueryFromButton(this)">${PG_REPORTS_I18N.actions.copy_query}</button>` : ''}
636
636
  </div>
637
637
  `;
638
638
  });
@@ -684,7 +684,7 @@
684
684
 
685
685
  if (button) {
686
686
  button.disabled = true;
687
- button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> Running...';
687
+ button.innerHTML = '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span> ' + PG_REPORTS_I18N.actions.running;
688
688
  }
689
689
 
690
690
  if (loadingEl) loadingEl.style.display = 'flex';
@@ -761,18 +761,18 @@
761
761
  renderTableBody(data.data, data.columns, thresholds, problemFields);
762
762
  }
763
763
 
764
- showToast('Report generated successfully');
764
+ showToast(PG_REPORTS_I18N.success.report_generated);
765
765
  } else {
766
- showToast(data.error || 'Failed to run report', 'error');
766
+ showToast(data.error || PG_REPORTS_I18N.errors.run_report_failed, 'error');
767
767
  }
768
768
  } catch (error) {
769
769
  if (loadingEl) loadingEl.style.display = 'none';
770
- showToast('Network error: ' + error.message, 'error');
770
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
771
771
  }
772
772
 
773
773
  if (button) {
774
774
  button.disabled = false;
775
- button.innerHTML = '▶ Run Report';
775
+ button.innerHTML = PG_REPORTS_I18N.actions.run_report;
776
776
  }
777
777
  }
778
778
 
@@ -885,8 +885,8 @@
885
885
  // Remove
886
886
  saved.splice(existingIdx, 1);
887
887
  btn.classList.remove('saved');
888
- btn.textContent = '📌 Save for Comparison';
889
- showToast('Record removed from saved');
888
+ btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
889
+ showToast(PG_REPORTS_I18N.success.record_removed_saved);
890
890
  } else {
891
891
  // Add
892
892
  saved.unshift({
@@ -895,8 +895,8 @@
895
895
  data: row
896
896
  });
897
897
  btn.classList.add('saved');
898
- btn.textContent = '📌 Saved';
899
- showToast('Record saved for comparison');
898
+ btn.textContent = PG_REPORTS_I18N.actions.saved_marker;
899
+ showToast(PG_REPORTS_I18N.success.record_saved);
900
900
  }
901
901
 
902
902
  saveSavedRecords(saved);
@@ -913,24 +913,24 @@
913
913
  const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
914
914
  if (btn) {
915
915
  btn.classList.remove('saved');
916
- btn.textContent = '📌 Save for Comparison';
916
+ btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
917
917
  }
918
918
 
919
- showToast('Record removed');
919
+ showToast(PG_REPORTS_I18N.success.record_removed);
920
920
  }
921
921
 
922
922
  function clearAllSavedRecords() {
923
- if (!confirm('Remove all saved records for this report?')) return;
923
+ if (!confirm(PG_REPORTS_I18N.saved.confirm_clear_all)) return;
924
924
  saveSavedRecords([]);
925
925
  renderSavedRecords();
926
926
 
927
927
  // Update all buttons in table
928
928
  document.querySelectorAll('.btn-save.saved').forEach(btn => {
929
929
  btn.classList.remove('saved');
930
- btn.textContent = '📌 Save for Comparison';
930
+ btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
931
931
  });
932
932
 
933
- showToast('All saved records cleared');
933
+ showToast(PG_REPORTS_I18N.success.all_saved_cleared);
934
934
  }
935
935
 
936
936
  function renderSavedRecords() {
@@ -959,9 +959,9 @@
959
959
  html += `
960
960
  <div class="saved-record-card" id="saved-card-${idx}" onclick="toggleSavedRecordDetail(${idx}, event)">
961
961
  <div class="saved-record-header">
962
- <span class="saved-record-time">▸ Saved: ${savedTime}</span>
963
- <span class="saved-record-expand-hint">Click to expand</span>
964
- <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="Remove">×</button>
962
+ <span class="saved-record-time">${PG_REPORTS_I18N.saved.saved_at_prefix} ${savedTime}</span>
963
+ <span class="saved-record-expand-hint">${PG_REPORTS_I18N.saved.click_to_expand}</span>
964
+ <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
965
965
  </div>
966
966
  <div class="saved-record-data">
967
967
  `;
@@ -1086,7 +1086,7 @@
1086
1086
  const query = decodeURIComponent(escape(atob(queryBase64)));
1087
1087
  runExplainAnalyze(query, queryHash);
1088
1088
  } catch (e) {
1089
- showToast('Failed to decode query', 'error');
1089
+ showToast(PG_REPORTS_I18N.errors.decode_query_failed, 'error');
1090
1090
  }
1091
1091
  }
1092
1092
 
@@ -1207,7 +1207,7 @@
1207
1207
  // Show problems list
1208
1208
  if (data.problems && data.problems.length > 0) {
1209
1209
  html += '<div class="explain-problems">';
1210
- html += '<div class="explain-problems-header">⚠️ Detected Issues</div>';
1210
+ html += '<div class="explain-problems-header">' + PG_REPORTS_I18N.sections.detected_issues + '</div>';
1211
1211
  html += '<div class="explain-problems-list">';
1212
1212
 
1213
1213
  data.problems.forEach(problem => {
@@ -1219,7 +1219,7 @@
1219
1219
  html += '<span class="explain-problem-icon">' + severityIcon + '</span>';
1220
1220
  html += '<span class="explain-problem-message">' + escapeHtml(problem.message) + '</span>';
1221
1221
  if (problem.line_number) {
1222
- html += '<span class="explain-problem-line">Line ' + problem.line_number + '</span>';
1222
+ html += '<span class="explain-problem-line">' + PG_REPORTS_I18N.sections.line_label + ' ' + problem.line_number + '</span>';
1223
1223
  }
1224
1224
  html += '</div>';
1225
1225
  if (problem.details) {
@@ -1239,8 +1239,8 @@
1239
1239
  if (data.annotated_lines && data.annotated_lines.length > 0) {
1240
1240
  html += '<div class="explain-output">';
1241
1241
  html += '<div class="explain-output-header">';
1242
- html += '📊 Execution Plan';
1243
- html += '<button class="btn-copy-small" onclick="copyExplainOutput()" title="Copy to clipboard">📋 Copy</button>';
1242
+ html += PG_REPORTS_I18N.sections.execution_plan;
1243
+ html += '<button class="btn-copy-small" onclick="copyExplainOutput()" title="' + PG_REPORTS_I18N.actions.copy_to_clipboard_title + '">' + PG_REPORTS_I18N.actions.copy + '</button>';
1244
1244
  html += '</div>';
1245
1245
  html += '<div class="explain-lines">';
1246
1246
 
@@ -1324,9 +1324,9 @@
1324
1324
  const text = Array.from(lines).map(line => line.textContent).join('\n');
1325
1325
 
1326
1326
  navigator.clipboard.writeText(text).then(() => {
1327
- showToast('EXPLAIN output copied to clipboard');
1327
+ showToast(PG_REPORTS_I18N.success.explain_copied);
1328
1328
  }).catch(() => {
1329
- showToast('Failed to copy', 'error');
1329
+ showToast(PG_REPORTS_I18N.errors.copy_failed, 'error');
1330
1330
  });
1331
1331
  }
1332
1332
 
@@ -1337,11 +1337,10 @@
1337
1337
 
1338
1338
  // Security check
1339
1339
  if (!allowRawQueryExecution) {
1340
- const message = '⚠️ EXPLAIN ANALYZE отключен. Включите в конфигурации: config.allow_raw_query_execution = true';
1341
- showToast(message, 'error');
1340
+ showToast(PG_REPORTS_I18N.errors.explain_disabled_toast, 'error');
1342
1341
  content.innerHTML = `<div class="error-message">
1343
- <strong>⚠️ Query execution is disabled</strong><br><br>
1344
- To enable this feature, add to your configuration:<br>
1342
+ <strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
1343
+ ${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
1345
1344
  <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1346
1345
  PgReports.configure do |config|<br>
1347
1346
  &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
@@ -1372,11 +1371,11 @@ end
1372
1371
  if (data.success) {
1373
1372
  content.innerHTML = renderExplainAnalysis(data);
1374
1373
  } else {
1375
- content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to run EXPLAIN ANALYZE')}</div>`;
1374
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || PG_REPORTS_I18N.errors.explain_analyze_failed)}</div>`;
1376
1375
  }
1377
1376
  } catch (error) {
1378
1377
  loading.style.display = 'none';
1379
- content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
1378
+ content.innerHTML = `<div class="error-message">${PG_REPORTS_I18N.errors.network_error_prefix} ${escapeHtml(error.message)}</div>`;
1380
1379
  }
1381
1380
  }
1382
1381
 
@@ -1387,11 +1386,10 @@ end
1387
1386
 
1388
1387
  // Security check
1389
1388
  if (!allowRawQueryExecution) {
1390
- const message = '⚠️ Выполнение запросов отключено. Включите в конфигурации: config.allow_raw_query_execution = true';
1391
- showToast(message, 'error');
1389
+ showToast(PG_REPORTS_I18N.errors.execute_disabled_toast, 'error');
1392
1390
  content.innerHTML = `<div class="error-message">
1393
- <strong>⚠️ Query execution is disabled</strong><br><br>
1394
- To enable this feature, add to your configuration:<br>
1391
+ <strong>${PG_REPORTS_I18N.modals.query_execution_disabled_title}</strong><br><br>
1392
+ ${PG_REPORTS_I18N.modals.query_execution_disabled_intro}<br>
1395
1393
  <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1396
1394
  PgReports.configure do |config|<br>
1397
1395
  &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
@@ -1424,8 +1422,8 @@ end
1424
1422
 
1425
1423
  // Show info
1426
1424
  html += `<div class="query-results-info">
1427
- <span>Rows: <span class="count">${data.count}</span></span>
1428
- <span>Execution time: <span class="time">${data.execution_time} ms</span></span>
1425
+ <span>${PG_REPORTS_I18N.results.rows_label} <span class="count">${data.count}</span></span>
1426
+ <span>${PG_REPORTS_I18N.results.execution_time_label} <span class="time">${data.execution_time} ms</span></span>
1429
1427
  </div>`;
1430
1428
 
1431
1429
  if (data.rows && data.rows.length > 0) {
@@ -1445,7 +1443,7 @@ end
1445
1443
  html += '<tr>';
1446
1444
  data.columns.forEach(col => {
1447
1445
  const value = row[col];
1448
- const displayValue = value === null ? '<null>' : String(value);
1446
+ const displayValue = value === null ? PG_REPORTS_I18N.results.null_placeholder : String(value);
1449
1447
  html += `<td title="${escapeHtmlAttr(displayValue)}">${escapeHtml(displayValue)}</td>`;
1450
1448
  });
1451
1449
  html += '</tr>';
@@ -1456,19 +1454,19 @@ end
1456
1454
  html += '</div>';
1457
1455
 
1458
1456
  if (data.truncated) {
1459
- html += `<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.75rem;">Showing first ${data.rows.length} of ${data.total_count} rows</p>`;
1457
+ html += `<p style="margin-top: 0.5rem; color: var(--text-muted); font-size: 0.75rem;">${pgReportsFormat(PG_REPORTS_I18N.results.showing_first_of_total, { count: data.rows.length, total: data.total_count })}</p>`;
1460
1458
  }
1461
1459
  } else {
1462
- html += '<p style="margin-top: 1rem; color: var(--text-muted);">No rows returned</p>';
1460
+ html += '<p style="margin-top: 1rem; color: var(--text-muted);">' + PG_REPORTS_I18N.results.no_rows_returned + '</p>';
1463
1461
  }
1464
1462
 
1465
1463
  content.innerHTML = html;
1466
1464
  } else {
1467
- content.innerHTML = `<div class="error-message">${escapeHtml(data.error || 'Failed to execute query')}</div>`;
1465
+ content.innerHTML = `<div class="error-message">${escapeHtml(data.error || PG_REPORTS_I18N.errors.execute_query_failed)}</div>`;
1468
1466
  }
1469
1467
  } catch (error) {
1470
1468
  loading.style.display = 'none';
1471
- content.innerHTML = `<div class="error-message">Network error: ${escapeHtml(error.message)}</div>`;
1469
+ content.innerHTML = `<div class="error-message">${PG_REPORTS_I18N.errors.network_error_prefix} ${escapeHtml(error.message)}</div>`;
1472
1470
  }
1473
1471
  }
1474
1472
 
@@ -1543,9 +1541,9 @@ end
1543
1541
  function copyMigrationCode() {
1544
1542
  if (!currentMigrationData) return;
1545
1543
  navigator.clipboard.writeText(currentMigrationData.code).then(() => {
1546
- showToast('Migration code copied to clipboard');
1544
+ showToast(PG_REPORTS_I18N.success.migration_copied);
1547
1545
  }).catch(() => {
1548
- showToast('Failed to copy', 'error');
1546
+ showToast(PG_REPORTS_I18N.errors.copy_failed, 'error');
1549
1547
  });
1550
1548
  }
1551
1549
 
@@ -1592,7 +1590,7 @@ end
1592
1590
  const data = await response.json();
1593
1591
 
1594
1592
  if (data.success) {
1595
- showToast('Migration file created');
1593
+ showToast(PG_REPORTS_I18N.success.migration_created);
1596
1594
  closeMigrationModal();
1597
1595
 
1598
1596
  // Open in IDE if path provided
@@ -1607,10 +1605,10 @@ end
1607
1605
  }
1608
1606
  }
1609
1607
  } else {
1610
- showToast(data.error || 'Failed to create migration', 'error');
1608
+ showToast(data.error || PG_REPORTS_I18N.errors.create_migration_failed, 'error');
1611
1609
  }
1612
1610
  } catch (error) {
1613
- showToast('Network error: ' + error.message, 'error');
1611
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
1614
1612
  }
1615
1613
  }
1616
1614
  </script>
@@ -285,6 +285,24 @@
285
285
  width: 90%;
286
286
  max-height: 80vh;
287
287
  overflow: auto;
288
+ scrollbar-width: thin;
289
+ scrollbar-color: var(--border-color) var(--bg-secondary);
290
+ }
291
+
292
+ .problem-modal-content::-webkit-scrollbar {
293
+ width: 8px;
294
+ height: 8px;
295
+ }
296
+ .problem-modal-content::-webkit-scrollbar-track {
297
+ background: var(--bg-secondary);
298
+ border-radius: 4px;
299
+ }
300
+ .problem-modal-content::-webkit-scrollbar-thumb {
301
+ background: var(--border-color);
302
+ border-radius: 4px;
303
+ }
304
+ .problem-modal-content::-webkit-scrollbar-thumb:hover {
305
+ background: var(--text-muted);
288
306
  }
289
307
 
290
308
  .problem-modal-header {