pg_reports 0.5.0 → 0.5.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.
@@ -5,6 +5,7 @@
5
5
  const reportKey = '<%= @report_key %>';
6
6
  const railsEnv = '<%= Rails.env %>';
7
7
  const isDevelopment = railsEnv === 'development';
8
+ const allowRawQueryExecution = <%= PgReports.config.allow_raw_query_execution.to_s.downcase %>;
8
9
  let syncingScroll = false;
9
10
  let currentSort = { column: null, direction: 'asc' };
10
11
 
@@ -595,9 +596,10 @@
595
596
  // Only show EXPLAIN ANALYZE for SELECT queries
596
597
  const queryNormalized = row.query.trim().toLowerCase();
597
598
  if (queryNormalized.startsWith('select')) {
598
- // Use data attribute to avoid escaping issues with special characters
599
+ // Use data attribute to pass query hash for security
599
600
  const queryBase64 = btoa(unescape(encodeURIComponent(row.query)));
600
- html += `<button class="btn-explain" data-query-b64="${queryBase64}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">📊 EXPLAIN ANALYZE</button>`;
601
+ const queryHash = row.query_hash || '';
602
+ html += `<button class="btn-explain" data-query-b64="${queryBase64}" data-query-hash="${queryHash}" onclick="event.stopPropagation(); runExplainAnalyzeFromButton(this)">📊 EXPLAIN ANALYZE</button>`;
601
603
  }
602
604
  }
603
605
 
@@ -1008,6 +1010,7 @@
1008
1010
  // ==========================================
1009
1011
 
1010
1012
  let currentExplainQuery = '';
1013
+ let currentExplainQueryHash = '';
1011
1014
  let currentQueryParams = [];
1012
1015
 
1013
1016
  // Parse $1, $2, etc. from query
@@ -1023,19 +1026,22 @@
1023
1026
  // Decode base64 query from button and run analyzer
1024
1027
  function runExplainAnalyzeFromButton(btn) {
1025
1028
  const queryBase64 = btn.dataset.queryB64;
1029
+ const queryHash = btn.dataset.queryHash;
1030
+
1026
1031
  if (!queryBase64) return;
1027
1032
 
1028
1033
  try {
1029
1034
  const query = decodeURIComponent(escape(atob(queryBase64)));
1030
- runExplainAnalyze(query);
1035
+ runExplainAnalyze(query, queryHash);
1031
1036
  } catch (e) {
1032
1037
  showToast('Failed to decode query', 'error');
1033
1038
  }
1034
1039
  }
1035
1040
 
1036
1041
  // Show query analyzer modal
1037
- function runExplainAnalyze(query) {
1042
+ function runExplainAnalyze(query, queryHash = '') {
1038
1043
  currentExplainQuery = query;
1044
+ currentExplainQueryHash = queryHash;
1039
1045
  currentQueryParams = parseQueryParams(query);
1040
1046
 
1041
1047
  const modal = document.getElementById('explain-modal');
@@ -1276,6 +1282,23 @@
1276
1282
  async function executeExplainAnalyze() {
1277
1283
  const loading = document.getElementById('explain-loading');
1278
1284
  const content = document.getElementById('explain-content');
1285
+
1286
+ // Security check
1287
+ if (!allowRawQueryExecution) {
1288
+ const message = '⚠️ EXPLAIN ANALYZE отключен. Включите в конфигурации: config.allow_raw_query_execution = true';
1289
+ showToast(message, 'error');
1290
+ content.innerHTML = `<div class="error-message">
1291
+ <strong>⚠️ Query execution is disabled</strong><br><br>
1292
+ To enable this feature, add to your configuration:<br>
1293
+ <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1294
+ PgReports.configure do |config|<br>
1295
+ &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1296
+ end
1297
+ </code>
1298
+ </div>`;
1299
+ return;
1300
+ }
1301
+
1279
1302
  const params = getParamValues();
1280
1303
 
1281
1304
  loading.style.display = 'flex';
@@ -1288,7 +1311,7 @@
1288
1311
  'Content-Type': 'application/json',
1289
1312
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1290
1313
  },
1291
- body: JSON.stringify({ query: currentExplainQuery, params: params })
1314
+ body: JSON.stringify({ query_hash: currentExplainQueryHash, params: params })
1292
1315
  });
1293
1316
 
1294
1317
  const data = await response.json();
@@ -1309,6 +1332,23 @@
1309
1332
  async function executeQuery() {
1310
1333
  const loading = document.getElementById('explain-loading');
1311
1334
  const content = document.getElementById('explain-content');
1335
+
1336
+ // Security check
1337
+ if (!allowRawQueryExecution) {
1338
+ const message = '⚠️ Выполнение запросов отключено. Включите в конфигурации: config.allow_raw_query_execution = true';
1339
+ showToast(message, 'error');
1340
+ content.innerHTML = `<div class="error-message">
1341
+ <strong>⚠️ Query execution is disabled</strong><br><br>
1342
+ To enable this feature, add to your configuration:<br>
1343
+ <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1344
+ PgReports.configure do |config|<br>
1345
+ &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1346
+ end
1347
+ </code>
1348
+ </div>`;
1349
+ return;
1350
+ }
1351
+
1312
1352
  const params = getParamValues();
1313
1353
 
1314
1354
  loading.style.display = 'flex';
@@ -1321,7 +1361,7 @@
1321
1361
  'Content-Type': 'application/json',
1322
1362
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
1323
1363
  },
1324
- body: JSON.stringify({ query: currentExplainQuery, params: params })
1364
+ body: JSON.stringify({ query_hash: currentExplainQueryHash, params: params })
1325
1365
  });
1326
1366
 
1327
1367
  const data = await response.json();
@@ -1460,6 +1500,30 @@ end
1460
1500
  async function createMigrationFile() {
1461
1501
  if (!currentMigrationData) return;
1462
1502
 
1503
+ // Security check
1504
+ if (!allowRawQueryExecution) {
1505
+ const message = '⚠️ Создание миграций отключено. Включите в конфигурации: config.allow_raw_query_execution = true';
1506
+ showToast(message, 'error');
1507
+
1508
+ const modalBody = document.getElementById('migration-modal-body');
1509
+ if (modalBody) {
1510
+ const errorDiv = document.createElement('div');
1511
+ errorDiv.className = 'error-message';
1512
+ errorDiv.style.marginTop = '1rem';
1513
+ errorDiv.innerHTML = `
1514
+ <strong>⚠️ Migration creation is disabled</strong><br><br>
1515
+ To enable this feature, add to your configuration:<br>
1516
+ <code style="display: block; margin-top: 0.5rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 4px;">
1517
+ PgReports.configure do |config|<br>
1518
+ &nbsp;&nbsp;config.allow_raw_query_execution = true<br>
1519
+ end
1520
+ </code>
1521
+ `;
1522
+ modalBody.appendChild(errorDiv);
1523
+ }
1524
+ return;
1525
+ }
1526
+
1463
1527
  try {
1464
1528
  const response = await fetch(`${pgReportsRoot}/create_migration`, {
1465
1529
  method: 'POST',
@@ -222,6 +222,9 @@ pg_stat_statements.track = all</pre>
222
222
  <button class="btn btn-small btn-danger" onclick="stopQueryMonitoring(this)" id="stop-monitor-btn" style="display: none;">
223
223
  ⏹ Stop Monitoring
224
224
  </button>
225
+ <button class="btn btn-small btn-secondary" onclick="loadQueryHistory(this)" id="load-history-btn">
226
+ 📜 Load History (50)
227
+ </button>
225
228
  <div class="download-dropdown" id="monitor-download-dropdown" style="display: none;">
226
229
  <button class="btn btn-small btn-secondary" onclick="toggleMonitorDownloadMenu(event)">
227
230
  📥 Download
@@ -855,6 +858,9 @@ pg_stat_statements.track = all</pre>
855
858
  let liveMonitoringEnabled = true;
856
859
  let pollTimer = null;
857
860
  let previousMetrics = null;
861
+ let metricsAvailable = true;
862
+ let consecutiveErrors = 0;
863
+ const MAX_CONSECUTIVE_ERRORS = 3;
858
864
  const metricsHistory = {
859
865
  connections: [],
860
866
  tps: [],
@@ -867,8 +873,6 @@ pg_stat_statements.track = all</pre>
867
873
  const panel = document.getElementById('live-monitoring');
868
874
  if (!panel) return;
869
875
 
870
- panel.style.display = 'block';
871
-
872
876
  const savedState = localStorage.getItem('pgReportsLiveMonitoring');
873
877
  if (savedState === 'disabled') {
874
878
  liveMonitoringEnabled = false;
@@ -876,11 +880,47 @@ pg_stat_statements.track = all</pre>
876
880
  }
877
881
 
878
882
  if (liveMonitoringEnabled) {
879
- fetchLiveMetrics();
880
- startPolling();
883
+ // First try to fetch metrics to see if they're available
884
+ fetchLiveMetrics().then(success => {
885
+ if (success) {
886
+ panel.style.display = 'block';
887
+ startPolling();
888
+ } else {
889
+ showMetricsUnavailableMessage();
890
+ }
891
+ });
892
+ } else {
893
+ panel.style.display = 'block';
881
894
  }
882
895
  }
883
896
 
897
+ function showMetricsUnavailableMessage() {
898
+ const panel = document.getElementById('live-monitoring');
899
+ if (!panel) return;
900
+
901
+ panel.style.display = 'block';
902
+ panel.innerHTML = `
903
+ <div class="live-monitoring-header">
904
+ <div class="live-monitoring-title">
905
+ <span class="badge-dot error"></span>
906
+ <span>Live Monitoring Unavailable</span>
907
+ </div>
908
+ </div>
909
+ <div style="padding: 2rem; text-align: center; color: var(--text-muted);">
910
+ <p style="margin-bottom: 1rem;">Unable to fetch database statistics.</p>
911
+ <p style="font-size: 0.875rem;">This may be due to:</p>
912
+ <ul style="list-style: none; padding: 0; margin: 1rem 0; font-size: 0.875rem;">
913
+ <li>• Insufficient database permissions</li>
914
+ <li>• Database statistics views not accessible</li>
915
+ <li>• Connection issues</li>
916
+ </ul>
917
+ <button class="btn btn-small btn-primary" onclick="location.reload()" style="margin-top: 1rem;">
918
+ Retry
919
+ </button>
920
+ </div>
921
+ `;
922
+ }
923
+
884
924
  function startPolling() {
885
925
  if (pollTimer) clearInterval(pollTimer);
886
926
  pollTimer = setInterval(fetchLiveMetrics, LIVE_CONFIG.pollInterval);
@@ -899,6 +939,9 @@ pg_stat_statements.track = all</pre>
899
939
  updateToggleUI();
900
940
 
901
941
  if (liveMonitoringEnabled) {
942
+ // Reset error counter when manually re-enabling
943
+ consecutiveErrors = 0;
944
+ metricsAvailable = true;
902
945
  fetchLiveMetrics();
903
946
  startPolling();
904
947
  } else {
@@ -924,11 +967,38 @@ pg_stat_statements.track = all</pre>
924
967
  const response = await fetch(`${pgReportsRoot}/live_metrics`);
925
968
  const data = await response.json();
926
969
 
927
- if (data.success) {
970
+ if (data.success && data.available !== false) {
971
+ consecutiveErrors = 0;
972
+ metricsAvailable = true;
928
973
  updateMetricsDisplay(data.metrics);
974
+ return true;
975
+ } else {
976
+ consecutiveErrors++;
977
+ console.error('Metrics unavailable:', data.error);
978
+
979
+ // If we get too many consecutive errors, show error state
980
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
981
+ metricsAvailable = false;
982
+ stopPolling();
983
+ showMetricsUnavailableMessage();
984
+ } else if (consecutiveErrors === 1) {
985
+ // Show toast on first error
986
+ showToast(data.error || 'Failed to fetch live metrics', 'error');
987
+ }
988
+ return false;
929
989
  }
930
990
  } catch (error) {
991
+ consecutiveErrors++;
931
992
  console.error('Failed to fetch live metrics:', error);
993
+
994
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
995
+ metricsAvailable = false;
996
+ stopPolling();
997
+ showMetricsUnavailableMessage();
998
+ } else if (consecutiveErrors === 1) {
999
+ showToast('Network error: ' + error.message, 'error');
1000
+ }
1001
+ return false;
932
1002
  }
933
1003
  }
934
1004
 
@@ -1095,9 +1165,8 @@ pg_stat_statements.track = all</pre>
1095
1165
  updateQueryMonitorUI(true);
1096
1166
  startQueryMonitorPolling();
1097
1167
  } else {
1098
- // Monitoring not active - load history from log file
1168
+ // Monitoring not active
1099
1169
  queryMonitorEnabled = false;
1100
- await loadQueryHistory();
1101
1170
  updateQueryMonitorUI(false);
1102
1171
  }
1103
1172
  } catch (error) {
@@ -1107,33 +1176,28 @@ pg_stat_statements.track = all</pre>
1107
1176
  }
1108
1177
  }
1109
1178
 
1110
- async function loadQueryHistory() {
1111
- try {
1112
- const response = await fetch(
1113
- `${pgReportsRoot}/query_monitor/history?limit=${QUERY_MONITOR_CONFIG.maxQueries}`
1114
- );
1115
- const data = await response.json();
1116
-
1117
- if (data.success && data.queries && data.queries.length > 0) {
1118
- renderQueryFeed(data.queries);
1119
- }
1120
- } catch (error) {
1121
- console.error('Failed to load query history:', error);
1122
- }
1123
- }
1179
+ async function loadQueryHistory(button) {
1180
+ button.disabled = true;
1181
+ button.innerHTML = '<span class="spinner"></span> Loading...';
1124
1182
 
1125
- async function loadSessionFromLog(sessionId) {
1126
1183
  try {
1127
1184
  const response = await fetch(
1128
- `${pgReportsRoot}/query_monitor/history?session_id=${sessionId}&limit=${QUERY_MONITOR_CONFIG.maxQueries}`
1185
+ `${pgReportsRoot}/query_monitor/history?limit=50`
1129
1186
  );
1130
1187
  const data = await response.json();
1131
1188
 
1132
1189
  if (data.success && data.queries && data.queries.length > 0) {
1133
1190
  renderQueryFeed(data.queries);
1191
+ showToast(`Loaded ${data.queries.length} queries from history`);
1192
+ } else {
1193
+ showToast('No query history found', 'error');
1134
1194
  }
1135
1195
  } catch (error) {
1136
- console.error('Failed to load session from log:', error);
1196
+ console.error('Failed to load query history:', error);
1197
+ showToast('Failed to load history: ' + error.message, 'error');
1198
+ } finally {
1199
+ button.disabled = false;
1200
+ button.innerHTML = '📜 Load History (50)';
1137
1201
  }
1138
1202
  }
1139
1203
 
@@ -1187,15 +1251,9 @@ pg_stat_statements.track = all</pre>
1187
1251
 
1188
1252
  if (data.success) {
1189
1253
  queryMonitorEnabled = false;
1190
- const stoppedSessionId = data.session_id || currentSessionId;
1191
1254
  currentSessionId = null;
1192
1255
  stopQueryMonitorPolling();
1193
1256
 
1194
- // Load complete session data from log file
1195
- if (stoppedSessionId) {
1196
- await loadSessionFromLog(stoppedSessionId);
1197
- }
1198
-
1199
1257
  updateQueryMonitorUI(false);
1200
1258
  showToast(data.message);
1201
1259
  } else {
@@ -1215,6 +1273,7 @@ pg_stat_statements.track = all</pre>
1215
1273
  const indicator = document.getElementById('monitor-indicator');
1216
1274
  const startBtn = document.getElementById('start-monitor-btn');
1217
1275
  const stopBtn = document.getElementById('stop-monitor-btn');
1276
+ const loadHistoryBtn = document.getElementById('load-history-btn');
1218
1277
  const sessionBadge = document.getElementById('session-badge');
1219
1278
  const sessionIdEl = document.getElementById('session-id');
1220
1279
  const downloadDropdown = document.getElementById('monitor-download-dropdown');
@@ -1228,6 +1287,7 @@ pg_stat_statements.track = all</pre>
1228
1287
  stopBtn.style.display = 'inline-block';
1229
1288
  stopBtn.disabled = false;
1230
1289
  stopBtn.innerHTML = '⏹ Stop Monitoring';
1290
+ loadHistoryBtn.style.display = 'none'; // Hide when monitoring active
1231
1291
  sessionBadge.style.display = 'inline-block';
1232
1292
  sessionIdEl.textContent = currentSessionId ? currentSessionId.substring(0, 8) : '';
1233
1293
  downloadDropdown.style.display = 'inline-block';
@@ -1237,6 +1297,7 @@ pg_stat_statements.track = all</pre>
1237
1297
  startBtn.disabled = false;
1238
1298
  startBtn.innerHTML = '▶ Start Monitoring';
1239
1299
  stopBtn.style.display = 'none';
1300
+ loadHistoryBtn.style.display = 'inline-block'; // Show when monitoring stopped
1240
1301
  sessionBadge.style.display = 'none';
1241
1302
 
1242
1303
  // Keep results visible after stopping, only hide download if no queries
@@ -38,6 +38,9 @@ module PgReports
38
38
  attr_accessor :query_monitor_max_queries # Maximum number of queries to keep in buffer
39
39
  attr_accessor :query_monitor_backtrace_filter # Proc to filter backtrace lines
40
40
 
41
+ # Security settings
42
+ attr_accessor :allow_raw_query_execution # Allow execute_query and explain_analyze from dashboard
43
+
41
44
  def initialize
42
45
  # Telegram
43
46
  @telegram_bot_token = ENV.fetch("PG_REPORTS_TELEGRAM_TOKEN", nil)
@@ -77,6 +80,11 @@ module PgReports
77
80
  # Exclude gem paths, framework paths
78
81
  !location.path.match?(%r{/(gems|ruby|railties)/})
79
82
  }
83
+
84
+ # Security
85
+ @allow_raw_query_execution = ActiveModel::Type::Boolean.new.cast(
86
+ ENV.fetch("PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION", false)
87
+ )
80
88
  end
81
89
 
82
90
  def connection
@@ -48,11 +48,17 @@ module PgReports
48
48
  # Live metrics for dashboard monitoring
49
49
  # @param long_query_threshold [Integer] Threshold in seconds for long queries
50
50
  # @return [Hash] Metrics data
51
+ # @raise [StandardError] If no data is returned
51
52
  def live_metrics(long_query_threshold: 60)
52
53
  data = executor.execute_from_file(:system, :live_metrics,
53
54
  long_query_threshold: long_query_threshold)
54
55
 
55
- row = data.first || {}
56
+ row = data.first
57
+
58
+ # If no data returned, something is wrong with the query or permissions
59
+ if row.nil? || row.empty?
60
+ raise StandardError, "No statistics data returned. Check database permissions and pg_stat views."
61
+ end
56
62
 
57
63
  {
58
64
  connections: {
@@ -123,7 +123,7 @@ module PgReports
123
123
 
124
124
  queries
125
125
  rescue => e
126
- Rails.logger.warn("PgReports: Failed to load queries from log: #{e.message}")
126
+ Rails.logger.warn("PgReports: Failed to load queries from log: #{e.message}") if defined?(Rails)
127
127
  []
128
128
  end
129
129
  end
@@ -188,33 +188,46 @@ module PgReports
188
188
  end
189
189
 
190
190
  def query_from_pg_reports?
191
- # Check if query originates from pg_reports gem code (not tests)
191
+ # Check if query originates from pg_reports gem internal code
192
192
  locations = caller_locations(0, 30)
193
193
  return false unless locations
194
194
 
195
195
  locations.any? do |location|
196
196
  path = location.path
197
- # Match gem paths: /gems/pg_reports-X.Y.Z/lib/ or local /lib/pg_reports/
198
- # But exclude test paths: /spec/
197
+ # Exclude test paths
199
198
  next if path.include?("/spec/")
200
199
 
201
- # Match both gem installation and local development lib paths
200
+ # Filter queries from pg_reports internal modules only:
201
+ # - Installed gem: /gems/pg_reports-X.Y.Z/lib/
202
+ # - Local gem: /pg_reports/lib/pg_reports/modules/
203
+ # - Dashboard controller: /pg_reports/app/controllers/pg_reports/
202
204
  path.match?(%r{/gems/pg_reports[-\d.]+/lib/}) ||
203
- path.match?(%r{/lib/pg_reports/})
205
+ path.match?(%r{/pg_reports/lib/pg_reports/modules/}) ||
206
+ path.match?(%r{/pg_reports/app/controllers/pg_reports/dashboard_controller\.rb})
204
207
  end
205
208
  end
206
209
 
207
210
  def extract_source_location
208
- filter_proc = PgReports.config.query_monitor_backtrace_filter
209
-
210
211
  # Get caller locations, skip first few frames (this file, active_support)
211
- locations = caller_locations(5, 20)
212
+ # Increase limit to 50 to capture more of the stack
213
+ locations = caller_locations(5, 50)
212
214
 
213
215
  return nil unless locations
214
216
 
215
217
  # Find first application code location
218
+ # Look for paths that are NOT from gems/ruby/railties
216
219
  app_location = locations.find do |location|
217
- filter_proc.call(location)
220
+ path = location.path
221
+
222
+ # Skip framework and gem paths
223
+ next if path.match?(%r{/(gems|ruby|railties)/})
224
+
225
+ # Skip pg_reports internal paths
226
+ next if path.match?(%r{/pg_reports/lib/pg_reports/})
227
+ next if path.match?(%r{/pg_reports/app/controllers/pg_reports/})
228
+
229
+ # This is likely application code
230
+ true
218
231
  end
219
232
 
220
233
  return nil unless app_location
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgReports
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_reports
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eldar Avatov
@@ -276,7 +276,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
276
276
  - !ruby/object:Gem::Version
277
277
  version: '0'
278
278
  requirements: []
279
- rubygems_version: 3.7.1
279
+ rubygems_version: 4.0.4
280
280
  specification_version: 4
281
281
  summary: PostgreSQL analysis and reporting tool with Telegram integration
282
282
  test_files: []