pg_reports 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +12 -8
  4. data/app/controllers/pg_reports/dashboard_controller.rb +277 -221
  5. data/app/controllers/pg_reports/metrics_controller.rb +13 -4
  6. data/app/views/layouts/pg_reports/application.html.erb +222 -93
  7. data/app/views/pg_reports/dashboard/_settings_modal.html.erb +55 -0
  8. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -42
  9. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +75 -105
  10. data/app/views/pg_reports/dashboard/index.html.erb +346 -238
  11. data/app/views/pg_reports/dashboard/show.html.erb +1 -1
  12. data/bin/pg_reports +2 -2
  13. data/config/brakeman.ignore +10 -56
  14. data/config/locales/en.yml +45 -11
  15. data/config/locales/ru.yml +44 -11
  16. data/config/locales/uk.yml +44 -11
  17. data/config/routes.rb +2 -0
  18. data/lib/pg_reports/access_policy.rb +39 -0
  19. data/lib/pg_reports/compatibility.rb +10 -2
  20. data/lib/pg_reports/configuration.rb +33 -2
  21. data/lib/pg_reports/connection/error_translator.rb +21 -0
  22. data/lib/pg_reports/connection/target.rb +43 -10
  23. data/lib/pg_reports/csv_cell.rb +20 -0
  24. data/lib/pg_reports/dashboard/reports_registry.rb +37 -30
  25. data/lib/pg_reports/definitions/connections/connection_stats.yml +4 -0
  26. data/lib/pg_reports/definitions/connections/idle_connections.yml +4 -0
  27. data/lib/pg_reports/definitions/connections/locks.yml +1 -0
  28. data/lib/pg_reports/definitions/connections/long_running_queries.yml +0 -1
  29. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +1 -2
  30. data/lib/pg_reports/definitions/indexes/index_correlation.yml +2 -1
  31. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +1 -0
  32. data/lib/pg_reports/definitions/queries/frequent_queries.yml +50 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +2 -1
  34. data/lib/pg_reports/definitions/tables/bloated_tables.yml +1 -2
  35. data/lib/pg_reports/definitions/tables/recently_modified.yml +3 -3
  36. data/lib/pg_reports/definitions/tables/unused_tables.yml +1 -1
  37. data/lib/pg_reports/drop_index_migration.rb +56 -0
  38. data/lib/pg_reports/engine.rb +4 -3
  39. data/lib/pg_reports/executor.rb +46 -9
  40. data/lib/pg_reports/modules/queries.rb +1 -0
  41. data/lib/pg_reports/query_monitor.rb +478 -192
  42. data/lib/pg_reports/rate_limiter.rb +63 -0
  43. data/lib/pg_reports/report.rb +1 -1
  44. data/lib/pg_reports/report_definition.rb +11 -1
  45. data/lib/pg_reports/sql/connections/connection_stats.sql +2 -1
  46. data/lib/pg_reports/sql/connections/idle_connections.sql +1 -0
  47. data/lib/pg_reports/sql/connections/locks.sql +1 -0
  48. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +16 -8
  49. data/lib/pg_reports/sql/queries/activity_sample.sql +22 -0
  50. data/lib/pg_reports/sql/queries/frequent_queries.sql +42 -0
  51. data/lib/pg_reports/sql_redactor.rb +32 -0
  52. data/lib/pg_reports/standalone.rb +83 -20
  53. data/lib/pg_reports/version.rb +1 -1
  54. data/lib/pg_reports.rb +7 -2
  55. metadata +18 -9
@@ -1,11 +1,14 @@
1
1
  <script>
2
2
  const svgIcon = (name, cls) => `<svg class="icon ${cls || ''}"><use href="#i-${name}"></use></svg>`;
3
3
  let currentReportData = null;
4
+ // Rows in their rendered order. Inline handlers carry an index into this, never
5
+ // the row itself: report data comes from the database, and any encoding of it
6
+ // into an attribute that JS re-parses is one decoding step from being code.
7
+ let renderedRows = [];
4
8
  let currentProblemExplanations = {};
5
9
  const category = '<%= @category %>';
6
10
  const reportKey = '<%= @report_key %>';
7
- const railsEnv = '<%= Rails.env %>';
8
- const isDevelopment = railsEnv === 'development';
11
+ const allowMigrationCreation = <%= PgReports.config.allow_migration_creation ? true : false %>;
9
12
  const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
10
13
  let syncingScroll = false;
11
14
  let currentSort = { column: null, direction: 'asc' };
@@ -118,6 +121,8 @@
118
121
  const tableBody = document.getElementById('results-body');
119
122
  if (!tableBody) return;
120
123
 
124
+ renderedRows = data;
125
+
121
126
  let rowsHtml = '';
122
127
 
123
128
  data.forEach((row, idx) => {
@@ -130,7 +135,7 @@
130
135
  rowClass += problemInfo.level === 'critical' ? ' critical-row' : ' warning-row';
131
136
  const indicatorClass = problemInfo.level === 'critical' ? 'critical' : 'warning';
132
137
  const indicatorIcon = svgIcon('alert');
133
- problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showProblemModal(${JSON.stringify(problemInfo.problems).replace(/"/g, '&quot;')}, ${JSON.stringify(row).replace(/"/g, '&quot;')})">${indicatorIcon}</span>`;
138
+ problemIndicator = `<span class="problem-indicator ${indicatorClass}" onclick="event.stopPropagation(); showRowProblems(${idx})">${indicatorIcon}</span>`;
134
139
  }
135
140
 
136
141
  // Data row
@@ -347,6 +352,14 @@
347
352
  return { level: maxLevel, problems };
348
353
  }
349
354
 
355
+ function showRowProblems(rowIndex) {
356
+ const row = renderedRows[rowIndex];
357
+ if (!row) return;
358
+
359
+ const problemInfo = getRowProblemLevel(row, currentReportData.thresholds, currentReportData.problem_fields);
360
+ showProblemModal(problemInfo.problems, row);
361
+ }
362
+
350
363
  // Show problem modal
351
364
  function showProblemModal(problems, row) {
352
365
  const modal = document.getElementById('problem-modal');
@@ -536,39 +549,44 @@
536
549
  const line = lineNumber || 1;
537
550
  const urls = [];
538
551
 
552
+ // The path can come from a SQL comment, which anyone with database access
553
+ // can write, so it is encoded here and never reaches a URL as it was.
554
+ const urlPath = absolutePath.split('/').map(encodeURIComponent).join('/');
555
+ const urlParam = encodeURIComponent(absolutePath);
556
+
539
557
  // VSCode (WSL Remote format)
540
558
  urls.push({
541
559
  name: 'VS Code (WSL)',
542
- url: `vscode://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
560
+ url: `vscode://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
543
561
  });
544
562
 
545
563
  // VSCode (direct path - for native Linux or Windows)
546
564
  urls.push({
547
565
  name: 'VS Code',
548
- url: `vscode://file${absolutePath}:${line}`
566
+ url: `vscode://file${urlPath}:${line}`
549
567
  });
550
568
 
551
569
  // JetBrains (RubyMine, IntelliJ, etc.)
552
570
  urls.push({
553
571
  name: 'RubyMine',
554
- url: `x-mine://open?file=${absolutePath}&line=${line}`
572
+ url: `x-mine://open?file=${urlParam}&line=${line}`
555
573
  });
556
574
 
557
575
  urls.push({
558
576
  name: 'IntelliJ',
559
- url: `idea://open?file=${absolutePath}&line=${line}`
577
+ url: `idea://open?file=${urlParam}&line=${line}`
560
578
  });
561
579
 
562
580
  // Cursor (WSL Remote format)
563
581
  urls.push({
564
582
  name: 'Cursor (WSL)',
565
- url: `cursor://vscode-remote/wsl+${wslDistro}${absolutePath}:${line}`
583
+ url: `cursor://vscode-remote/wsl+${wslDistro}${urlPath}:${line}`
566
584
  });
567
585
 
568
586
  // Cursor (direct path)
569
587
  urls.push({
570
588
  name: 'Cursor',
571
- url: `cursor://file${absolutePath}:${line}`
589
+ url: `cursor://file${urlPath}:${line}`
572
590
  });
573
591
 
574
592
  return urls;
@@ -603,7 +621,7 @@
603
621
  if (defaultIde && ideKeyMap[defaultIde] !== undefined) {
604
622
  const ideUrl = ideUrls[ideKeyMap[defaultIde]];
605
623
  if (ideUrl) {
606
- return `<a class="source-badge clickable" href="${ideUrl.url}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
624
+ return `<a class="source-badge clickable" href="${escapeHtmlAttr(ideUrl.url)}" onclick="event.stopPropagation();" title="${escapeHtmlAttr(parsed.original)}">${sourceBadgeLabel(parsed)}</a>`;
607
625
  }
608
626
  }
609
627
 
@@ -617,7 +635,7 @@
617
635
  `;
618
636
 
619
637
  for (const ide of ideUrls) {
620
- dropdownHtml += `<a href="${ide.url}">${ide.name}</a>`;
638
+ dropdownHtml += `<a href="${escapeHtmlAttr(ide.url)}">${escapeHtml(ide.name)}</a>`;
621
639
  }
622
640
 
623
641
  dropdownHtml += '</div></div>';
@@ -713,10 +731,9 @@
713
731
 
714
732
  // Action buttons
715
733
  const isSaved = isRecordSaved(rowId);
716
- const rowJson = JSON.stringify(row).replace(/"/g, '&quot;');
717
734
 
718
735
  html += `<div class="detail-actions">`;
719
- html += `<button class="btn-save ${isSaved ? 'saved' : ''}" onclick="event.stopPropagation(); toggleSaveRecord('${rowId}', ${rowJson}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
736
+ html += `<button class="btn-save ${isSaved ? 'saved' : ''}" data-row-id="${rowId}" onclick="event.stopPropagation(); toggleSaveRow(${rowIndex}, this)">${svgIcon('pin')}${isSaved ? PG_REPORTS_I18N.actions.saved_marker : PG_REPORTS_I18N.actions.save_for_comparison}</button>`;
720
737
 
721
738
  if (hasQuery) {
722
739
  // Only show EXPLAIN ANALYZE for SELECT queries
@@ -731,11 +748,8 @@
731
748
 
732
749
  // Show migration button for index reports (only in development)
733
750
  if (hasIndexName && (category === 'indexes')) {
734
- const indexName = row.index_name;
735
- const tableName = row.table_name || row.tablename || '';
736
- const schemaName = row.schema_name || row.schemaname || 'public';
737
- if (isDevelopment) {
738
- html += `<button class="btn-migration" onclick="event.stopPropagation(); showMigrationModal('${escapeHtml(indexName)}', '${escapeHtml(tableName)}', '${escapeHtml(schemaName)}')">${svgIcon('trash')}Generate Migration</button>`;
751
+ if (allowMigrationCreation) {
752
+ html += `<button class="btn-migration" onclick="event.stopPropagation(); showRowMigration(${rowIndex})">${svgIcon('trash')}Generate Migration</button>`;
739
753
  } else {
740
754
  html += `<button class="btn-migration" disabled title="Migration generation is only available in development environment" >${svgIcon('trash')}Generate Migration (Development Only)</button>`;
741
755
  }
@@ -827,7 +841,7 @@
827
841
  // Build table header with sortable columns
828
842
  if (tableHead) {
829
843
  tableHead.innerHTML = '<tr>' + data.columns.map(col =>
830
- `<th class="sortable" data-column="${escapeHtml(col)}" onclick="handleSortClick('${escapeHtml(col)}')">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
844
+ `<th class="sortable" data-column="${escapeHtmlAttr(col)}" onclick="handleSortClick(this.dataset.column)">${escapeHtml(col)}<span class="sort-indicator">↕</span></th>`
831
845
  ).join('') + '</tr>';
832
846
  }
833
847
 
@@ -854,7 +868,7 @@
854
868
  document.addEventListener('keydown', function(e) {
855
869
  if (e.key === 'Escape') {
856
870
  closeProblemModal();
857
- closeIdeSettingsModal();
871
+ closeSettingsModal();
858
872
  closeExplainModal();
859
873
  closeMigrationModal();
860
874
  }
@@ -867,44 +881,6 @@
867
881
  }
868
882
  });
869
883
 
870
- // IDE Settings functions
871
- function showIdeSettingsModal() {
872
- const modal = document.getElementById('ide-settings-modal');
873
- modal.style.display = 'flex';
874
- loadIdeSettingsState();
875
- }
876
-
877
- function closeIdeSettingsModal() {
878
- document.getElementById('ide-settings-modal').style.display = 'none';
879
- }
880
-
881
- function setDefaultIde(ideKey) {
882
- if (ideKey) {
883
- localStorage.setItem('pgReportsDefaultIde', ideKey);
884
- } else {
885
- localStorage.removeItem('pgReportsDefaultIde');
886
- }
887
- }
888
-
889
- function getDefaultIde() {
890
- return localStorage.getItem('pgReportsDefaultIde') || '';
891
- }
892
-
893
- function loadIdeSettingsState() {
894
- const currentIde = getDefaultIde();
895
- const radios = document.querySelectorAll('input[name="default-ide"]');
896
- radios.forEach(radio => {
897
- radio.checked = (radio.value === currentIde);
898
- });
899
- }
900
-
901
- // Close IDE settings modal on backdrop click
902
- document.getElementById('ide-settings-modal')?.addEventListener('click', function(e) {
903
- if (e.target === this) {
904
- closeIdeSettingsModal();
905
- }
906
- });
907
-
908
884
  // ==========================================
909
885
  // SAVED RECORDS FUNCTIONALITY
910
886
  // ==========================================
@@ -951,6 +927,11 @@
951
927
  return saved.some(r => r.id === rowId);
952
928
  }
953
929
 
930
+ function toggleSaveRow(rowIndex, btn) {
931
+ const row = renderedRows[rowIndex];
932
+ if (row) toggleSaveRecord(generateRowId(row), row, btn);
933
+ }
934
+
954
935
  function toggleSaveRecord(rowId, row, btn) {
955
936
  const saved = getSavedRecords();
956
937
  const existingIdx = saved.findIndex(r => r.id === rowId);
@@ -984,7 +965,7 @@
984
965
  renderSavedRecords();
985
966
 
986
967
  // Update button in table if visible
987
- const btn = document.querySelector(`.btn-save[onclick*="'${rowId}'"]`);
968
+ const btn = document.querySelector(`.btn-save[data-row-id="${CSS.escape(rowId)}"]`);
988
969
  if (btn) {
989
970
  btn.classList.remove('saved');
990
971
  btn.textContent = PG_REPORTS_I18N.actions.save_for_comparison;
@@ -1035,7 +1016,7 @@
1035
1016
  <div class="saved-record-header">
1036
1017
  <span class="saved-record-time">${PG_REPORTS_I18N.saved.saved_at_prefix} ${savedTime}</span>
1037
1018
  <span class="saved-record-expand-hint">${PG_REPORTS_I18N.saved.click_to_expand}</span>
1038
- <button class="saved-record-remove" onclick="event.stopPropagation(); removeSavedRecord('${record.id}')" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
1019
+ <button class="saved-record-remove" data-record-id="${escapeHtmlAttr(record.id)}" onclick="event.stopPropagation(); removeSavedRecord(this.dataset.recordId)" title="${PG_REPORTS_I18N.saved.remove_title}">×</button>
1039
1020
  </div>
1040
1021
  <div class="saved-record-data">
1041
1022
  `;
@@ -1350,7 +1331,7 @@
1350
1331
  const indentPx = line.indent_level * 20;
1351
1332
  html += '<span class="explain-line-content ' + nodeColorClass + '" style="padding-left: ' + indentPx + 'px"';
1352
1333
  if (tooltip) {
1353
- html += ' title="' + escapeHtml(tooltip) + '"';
1334
+ html += ' title="' + escapeHtmlAttr(tooltip) + '"';
1354
1335
  }
1355
1336
  html += '>';
1356
1337
 
@@ -1376,7 +1357,7 @@
1376
1357
  // Problem indicator
1377
1358
  if (lineProblem) {
1378
1359
  const problemIcon = svgIcon('alert');
1379
- html += '<span class="explain-line-problem-indicator" title="' + escapeHtml(lineProblem.message) + '">' + problemIcon + '</span>';
1360
+ html += '<span class="explain-line-problem-indicator" title="' + escapeHtmlAttr(lineProblem.message) + '">' + problemIcon + '</span>';
1380
1361
  }
1381
1362
 
1382
1363
  html += '</div>';
@@ -1542,49 +1523,38 @@
1542
1523
 
1543
1524
  let currentMigrationData = null;
1544
1525
 
1545
- function showMigrationModal(indexName, tableName, schemaName) {
1546
- const migrationName = `remove_${indexName.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
1547
- const timestamp = new Date().toISOString().replace(/[-:T]/g, '').substring(0, 14);
1548
- const className = migrationName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1549
-
1550
- const fullIndexName = schemaName && schemaName !== 'public'
1551
- ? `${schemaName}.${indexName}`
1552
- : indexName;
1553
-
1554
- const migrationCode = `# frozen_string_literal: true
1555
-
1556
- class ${className} < ActiveRecord::Migration[7.0]
1557
- def change
1558
- remove_index :${tableName}, name: :${indexName}, if_exists: true
1559
- end
1560
- end
1561
- `;
1562
-
1563
- currentMigrationData = {
1564
- fileName: `${timestamp}_${migrationName}.rb`,
1565
- code: migrationCode,
1566
- indexName,
1567
- tableName,
1568
- schemaName
1526
+ async function showRowMigration(rowIndex) {
1527
+ const row = renderedRows[rowIndex];
1528
+ if (!row) return;
1529
+
1530
+ // The server builds the migration from the index it finds in the database,
1531
+ // so all it is told is which one.
1532
+ const target = {
1533
+ index_name: row.index_name,
1534
+ schema: row.schema || row.schema_name || row.schemaname || ''
1569
1535
  };
1570
1536
 
1571
- document.getElementById('migration-code').textContent = migrationCode;
1572
- document.getElementById('migration-modal').style.display = 'flex';
1573
-
1574
- // Disable "Create File" button in non-development environments
1575
- const createBtn = document.getElementById('create-migration-btn');
1576
- if (createBtn) {
1577
- if (!isDevelopment) {
1578
- createBtn.disabled = true;
1579
- createBtn.style.opacity = '0.5';
1580
- createBtn.style.cursor = 'not-allowed';
1581
- createBtn.title = 'Migration file creation is only available in development environment';
1582
- } else {
1583
- createBtn.disabled = false;
1584
- createBtn.style.opacity = '1';
1585
- createBtn.style.cursor = 'pointer';
1586
- createBtn.title = '';
1537
+ try {
1538
+ const response = await fetch(`${pgReportsRoot}/migration_preview`, {
1539
+ method: 'POST',
1540
+ headers: {
1541
+ 'Content-Type': 'application/json',
1542
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1543
+ },
1544
+ body: JSON.stringify(target)
1545
+ });
1546
+ const data = await response.json();
1547
+
1548
+ if (!data.success) {
1549
+ showToast(data.error || PG_REPORTS_I18N.errors.create_migration_failed, 'error');
1550
+ return;
1587
1551
  }
1552
+
1553
+ currentMigrationData = { ...target, code: data.code };
1554
+ document.getElementById('migration-code').textContent = data.code;
1555
+ document.getElementById('migration-modal').style.display = 'flex';
1556
+ } catch (error) {
1557
+ showToast(PG_REPORTS_I18N.errors.network_error_prefix + ' ' + error.message, 'error');
1588
1558
  }
1589
1559
  }
1590
1560
 
@@ -1609,7 +1579,7 @@ end
1609
1579
  if (!currentMigrationData) return;
1610
1580
 
1611
1581
  // Security check
1612
- if (!allowRawQueryExecution) {
1582
+ if (!allowMigrationCreation) {
1613
1583
  const message = PG_REPORTS_I18N.errors.migration_disabled_toast;
1614
1584
  showToast(message, 'error');
1615
1585
 
@@ -1631,8 +1601,8 @@ end
1631
1601
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1632
1602
  },
1633
1603
  body: JSON.stringify({
1634
- file_name: currentMigrationData.fileName,
1635
- code: currentMigrationData.code
1604
+ index_name: currentMigrationData.index_name,
1605
+ schema: currentMigrationData.schema
1636
1606
  })
1637
1607
  });
1638
1608