pg_reports 0.4.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -0
  3. data/README.md +170 -4
  4. data/app/controllers/pg_reports/dashboard_controller.rb +348 -47
  5. data/app/views/layouts/pg_reports/application.html.erb +370 -79
  6. data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -1
  7. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +254 -29
  8. data/app/views/pg_reports/dashboard/_show_styles.html.erb +373 -0
  9. data/app/views/pg_reports/dashboard/index.html.erb +485 -5
  10. data/config/locales/en.yml +45 -0
  11. data/config/locales/ru.yml +45 -0
  12. data/config/routes.rb +8 -0
  13. data/lib/pg_reports/configuration.rb +21 -0
  14. data/lib/pg_reports/dashboard/reports_registry.rb +24 -1
  15. data/lib/pg_reports/definitions/connections/connection_churn.yml +49 -0
  16. data/lib/pg_reports/definitions/connections/pool_saturation.yml +42 -0
  17. data/lib/pg_reports/definitions/connections/pool_usage.yml +43 -0
  18. data/lib/pg_reports/definitions/connections/pool_wait_times.yml +44 -0
  19. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +3 -3
  20. data/lib/pg_reports/explain_analyzer.rb +338 -0
  21. data/lib/pg_reports/modules/schema_analysis.rb +4 -6
  22. data/lib/pg_reports/modules/system.rb +26 -3
  23. data/lib/pg_reports/query_monitor.rb +293 -0
  24. data/lib/pg_reports/sql/connections/connection_churn.sql +37 -0
  25. data/lib/pg_reports/sql/connections/pool_saturation.sql +90 -0
  26. data/lib/pg_reports/sql/connections/pool_usage.sql +31 -0
  27. data/lib/pg_reports/sql/connections/pool_wait_times.sql +19 -0
  28. data/lib/pg_reports/sql/queries/all_queries.sql +17 -15
  29. data/lib/pg_reports/sql/queries/expensive_queries.sql +9 -4
  30. data/lib/pg_reports/sql/queries/heavy_queries.sql +14 -12
  31. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +16 -14
  32. data/lib/pg_reports/sql/queries/missing_index_queries.sql +18 -16
  33. data/lib/pg_reports/sql/queries/slow_queries.sql +14 -12
  34. data/lib/pg_reports/sql/system/databases_list.sql +8 -0
  35. data/lib/pg_reports/version.rb +1 -1
  36. data/lib/pg_reports.rb +2 -0
  37. metadata +55 -2
@@ -9,6 +9,7 @@ module PgReports
9
9
 
10
10
  def index
11
11
  @pg_stat_status = PgReports.pg_stat_statements_status
12
+ @current_database = PgReports.system.current_database
12
13
  end
13
14
 
14
15
  def enable_pg_stat_statements
@@ -25,15 +26,40 @@ module PgReports
25
26
 
26
27
  def live_metrics
27
28
  threshold = params[:long_query_threshold]&.to_i || 60
28
- data = Modules::System.live_metrics(long_query_threshold: threshold)
29
29
 
30
- render json: {
31
- success: true,
32
- metrics: data,
33
- timestamp: Time.current.to_i
34
- }
35
- rescue => e
36
- render json: {success: false, error: e.message}, status: :unprocessable_entity
30
+ # Check if we have access to required statistics
31
+ begin
32
+ data = Modules::System.live_metrics(long_query_threshold: threshold)
33
+
34
+ # Validate that we got actual data
35
+ if data[:connections][:total].nil? && data[:transactions][:total].nil?
36
+ render json: {
37
+ success: false,
38
+ error: "Unable to fetch database statistics. Check database permissions.",
39
+ available: false
40
+ }, status: :service_unavailable
41
+ return
42
+ end
43
+
44
+ render json: {
45
+ success: true,
46
+ metrics: data,
47
+ timestamp: Time.current.to_i,
48
+ available: true
49
+ }
50
+ rescue PG::InsufficientPrivilege => e
51
+ render json: {
52
+ success: false,
53
+ error: "Insufficient database permissions to access statistics views",
54
+ available: false
55
+ }, status: :forbidden
56
+ rescue => e
57
+ render json: {
58
+ success: false,
59
+ error: e.message,
60
+ available: false
61
+ }, status: :unprocessable_entity
62
+ end
37
63
  end
38
64
 
39
65
  def show
@@ -72,11 +98,24 @@ module PgReports
72
98
  problem_fields = Dashboard::ReportsRegistry.problem_fields(report_key)
73
99
  problem_explanations = load_problem_explanations(category, report_key)
74
100
 
101
+ # Add query hashes for security
102
+ data_with_hashes = report.data.first(100).map do |row|
103
+ row_hash = row.dup
104
+
105
+ # If this row contains a query column, store it with a hash
106
+ if row_hash.key?("query") && row_hash["query"].present?
107
+ query_hash = store_query_with_hash(row_hash["query"])
108
+ row_hash["query_hash"] = query_hash
109
+ end
110
+
111
+ row_hash
112
+ end
113
+
75
114
  render json: {
76
115
  success: true,
77
116
  title: report.title,
78
117
  columns: report.columns,
79
- data: report.data.first(100),
118
+ data: data_with_hashes,
80
119
  total: report.size,
81
120
  generated_at: report.generated_at.strftime("%Y-%m-%d %H:%M:%S"),
82
121
  thresholds: thresholds,
@@ -134,18 +173,42 @@ module PgReports
134
173
  end
135
174
 
136
175
  def explain_analyze
137
- query = params[:query]
176
+ query_hash = params[:query_hash]
138
177
  query_params = params[:params] || {}
139
178
 
140
- if query.blank?
141
- render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
179
+ if query_hash.blank?
180
+ render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
142
181
  return
143
182
  end
144
183
 
145
- # Security: Only allow SELECT queries for EXPLAIN ANALYZE (SHOW not supported by EXPLAIN)
146
- normalized = query.strip.gsub(/\s+/, " ").downcase
147
- unless normalized.start_with?("select")
148
- render json: {success: false, error: "Only SELECT queries are allowed for EXPLAIN ANALYZE"}, status: :unprocessable_entity
184
+ # Security: Check if raw query execution is allowed
185
+ unless PgReports.config.allow_raw_query_execution
186
+ render json: {
187
+ success: false,
188
+ error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
189
+ }, status: :forbidden
190
+ return
191
+ end
192
+
193
+ # Security: Retrieve and validate query by hash
194
+ begin
195
+ query = retrieve_query_by_hash(query_hash)
196
+
197
+ if query.nil?
198
+ render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
199
+ return
200
+ end
201
+ rescue SecurityError => e
202
+ render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
203
+ return
204
+ end
205
+
206
+ # Check for trigger variables (NEW, OLD) which are only available in trigger context
207
+ if query.match?(/\b(NEW|OLD)\./i)
208
+ render json: {
209
+ success: false,
210
+ error: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
211
+ }, status: :unprocessable_entity
149
212
  return
150
213
  end
151
214
 
@@ -164,39 +227,50 @@ module PgReports
164
227
  result = ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{final_query}")
165
228
  explain_output = result.map { |r| r["QUERY PLAN"] }.join("\n")
166
229
 
167
- # Extract stats from the output
168
- stats = {}
169
- if (match = explain_output.match(/Planning Time: ([\d.]+) ms/))
170
- stats[:planning_time] = match[1].to_f
171
- end
172
- if (match = explain_output.match(/Execution Time: ([\d.]+) ms/))
173
- stats[:execution_time] = match[1].to_f
174
- end
175
- if (match = explain_output.match(/cost=[\d.]+\.\.([\d.]+)/))
176
- stats[:total_cost] = match[1].to_f
177
- end
178
- if (match = explain_output.match(/rows=(\d+)/))
179
- stats[:rows] = match[1].to_i
180
- end
230
+ # Analyze the EXPLAIN output
231
+ analyzer = ExplainAnalyzer.new(explain_output)
232
+ analysis = analyzer.to_h
181
233
 
182
- render json: {success: true, explain: explain_output, stats: stats}
234
+ render json: {
235
+ success: true,
236
+ explain: explain_output,
237
+ stats: analysis[:stats],
238
+ annotated_lines: analysis[:annotated_lines],
239
+ problems: analysis[:problems],
240
+ summary: analysis[:summary]
241
+ }
183
242
  rescue => e
184
243
  render json: {success: false, error: e.message}, status: :unprocessable_entity
185
244
  end
186
245
 
187
246
  def execute_query
188
- query = params[:query]
247
+ query_hash = params[:query_hash]
189
248
  query_params = params[:params] || {}
190
249
 
191
- if query.blank?
192
- render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
250
+ if query_hash.blank?
251
+ render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
193
252
  return
194
253
  end
195
254
 
196
- # Security: Only allow SELECT and SHOW queries
197
- normalized = query.strip.gsub(/\s+/, " ").downcase
198
- unless normalized.start_with?("select") || normalized.start_with?("show")
199
- render json: {success: false, error: "Only SELECT and SHOW queries are allowed"}, status: :unprocessable_entity
255
+ # Security: Check if raw query execution is allowed
256
+ unless PgReports.config.allow_raw_query_execution
257
+ render json: {
258
+ success: false,
259
+ error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
260
+ }, status: :forbidden
261
+ return
262
+ end
263
+
264
+ # Security: Retrieve and validate query by hash
265
+ begin
266
+ query = retrieve_query_by_hash(query_hash)
267
+
268
+ if query.nil?
269
+ render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
270
+ return
271
+ end
272
+ rescue SecurityError => e
273
+ render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
200
274
  return
201
275
  end
202
276
 
@@ -287,6 +361,123 @@ module PgReports
287
361
  render json: {success: false, error: e.message}, status: :unprocessable_entity
288
362
  end
289
363
 
364
+ def start_query_monitoring
365
+ monitor = PgReports::QueryMonitor.instance
366
+
367
+ result = monitor.start
368
+
369
+ if result[:success]
370
+ render json: result
371
+ else
372
+ render json: result, status: :unprocessable_entity
373
+ end
374
+ rescue => e
375
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
376
+ end
377
+
378
+ def stop_query_monitoring
379
+ monitor = PgReports::QueryMonitor.instance
380
+
381
+ result = monitor.stop
382
+
383
+ if result[:success]
384
+ render json: result
385
+ else
386
+ render json: result, status: :unprocessable_entity
387
+ end
388
+ rescue => e
389
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
390
+ end
391
+
392
+ def query_monitor_status
393
+ monitor = PgReports::QueryMonitor.instance
394
+ status = monitor.status
395
+
396
+ render json: {
397
+ success: true,
398
+ enabled: status[:enabled],
399
+ session_id: status[:session_id],
400
+ query_count: status[:query_count]
401
+ }
402
+ rescue => e
403
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
404
+ end
405
+
406
+ def query_monitor_feed
407
+ monitor = PgReports::QueryMonitor.instance
408
+
409
+ unless monitor.enabled
410
+ render json: {success: false, message: "Monitoring not active"}
411
+ return
412
+ end
413
+
414
+ limit = params[:limit]&.to_i || 50
415
+ session_id = params[:session_id]
416
+
417
+ queries = monitor.queries(limit: limit, session_id: session_id)
418
+
419
+ render json: {
420
+ success: true,
421
+ queries: queries,
422
+ timestamp: Time.current.to_i
423
+ }
424
+ rescue => e
425
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
426
+ end
427
+
428
+ def load_query_history
429
+ monitor = PgReports::QueryMonitor.instance
430
+
431
+ limit = params[:limit]&.to_i || 50
432
+ session_id = params[:session_id]
433
+
434
+ queries = monitor.load_from_log(limit: limit, session_id: session_id)
435
+
436
+ render json: {
437
+ success: true,
438
+ queries: queries,
439
+ timestamp: Time.current.to_i
440
+ }
441
+ rescue => e
442
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
443
+ end
444
+
445
+ def download_query_monitor
446
+ monitor = PgReports::QueryMonitor.instance
447
+
448
+ # Allow download even when monitoring is stopped, as long as there are queries
449
+ queries = monitor.queries
450
+ if queries.empty?
451
+ render json: {success: false, error: "No queries to download"}, status: :unprocessable_entity
452
+ return
453
+ end
454
+
455
+ format_type = params[:format] || "txt"
456
+ filename = "query-monitor-#{Time.current.strftime("%Y%m%d-%H%M%S")}"
457
+
458
+ case format_type
459
+ when "csv"
460
+ csv_data = generate_query_monitor_csv(queries)
461
+ send_data csv_data,
462
+ filename: "#{filename}.csv",
463
+ type: "text/csv; charset=utf-8",
464
+ disposition: "attachment"
465
+ when "json"
466
+ send_data queries.to_json,
467
+ filename: "#{filename}.json",
468
+ type: "application/json; charset=utf-8",
469
+ disposition: "attachment"
470
+ else
471
+ text_data = generate_query_monitor_text(queries)
472
+ send_data text_data,
473
+ filename: "#{filename}.txt",
474
+ type: "text/plain; charset=utf-8",
475
+ disposition: "attachment"
476
+ end
477
+ rescue => e
478
+ render json: {success: false, error: e.message}, status: :unprocessable_entity
479
+ end
480
+
290
481
  private
291
482
 
292
483
  def authenticate_dashboard!
@@ -326,7 +517,7 @@ module PgReports
326
517
 
327
518
  # Also allow threshold overrides (calls_threshold, etc.)
328
519
  params.each do |key, value|
329
- if key.to_s.end_with?('_threshold') && value.present?
520
+ if key.to_s.end_with?("_threshold") && value.present?
330
521
  result[key.to_sym] = value.to_i
331
522
  end
332
523
  end
@@ -356,7 +547,7 @@ module PgReports
356
547
  result = query.dup
357
548
 
358
549
  # Sort by param number descending to replace $10 before $1
359
- params_hash.keys.map(&:to_i).sort.reverse.each do |num|
550
+ params_hash.keys.map(&:to_i).sort.reverse_each do |num|
360
551
  value = params_hash[num.to_s] || params_hash[num]
361
552
  next if value.nil? || value.to_s.empty?
362
553
 
@@ -371,17 +562,15 @@ module PgReports
371
562
  def quote_param_value(value)
372
563
  str = value.to_s
373
564
 
374
- # Check if it looks like a number
375
- if str.match?(/\A-?\d+(\.\d+)?\z/)
376
- str
565
+ # Check if it looks like NULL
566
+ if str.downcase == "null"
567
+ "NULL"
377
568
  # Check if it looks like a boolean
378
569
  elsif str.downcase.in?(["true", "false"])
379
570
  str.downcase
380
- # Check if it looks like NULL
381
- elsif str.downcase == "null"
382
- "NULL"
383
571
  else
384
- # Quote as string, escape single quotes
572
+ # Quote as string by default - PostgreSQL will handle type casting
573
+ # This ensures compatibility with both text and numeric columns
385
574
  "'#{str.gsub("'", "''")}'"
386
575
  end
387
576
  end
@@ -397,5 +586,117 @@ module PgReports
397
586
  "#{query} LIMIT #{limit}"
398
587
  end
399
588
  end
589
+
590
+ # Generate a secure hash for a query and store it in cache
591
+ def store_query_with_hash(query)
592
+ require "digest"
593
+
594
+ # Generate SHA256 hash of the query
595
+ query_hash = Digest::SHA256.hexdigest(query)
596
+
597
+ # Store in Rails cache with 1 hour expiration
598
+ begin
599
+ Rails.cache.write("pg_reports:query:#{query_hash}", query, expires_in: 1.hour)
600
+ rescue => e
601
+ # Log cache error but don't fail - we'll catch it on retrieval
602
+ Rails.logger.warn("PgReports: Failed to store query hash in cache: #{e.message}") if defined?(Rails.logger)
603
+ end
604
+
605
+ query_hash
606
+ end
607
+
608
+ # Retrieve and validate a query by its hash
609
+ def retrieve_query_by_hash(query_hash)
610
+ return nil if query_hash.blank?
611
+
612
+ # Retrieve from cache with error handling
613
+ query = nil
614
+ begin
615
+ query = Rails.cache.read("pg_reports:query:#{query_hash}")
616
+ rescue => e
617
+ # Cache backend is unavailable
618
+ Rails.logger.error("PgReports: Failed to read from cache: #{e.message}") if defined?(Rails.logger)
619
+ raise SecurityError, "Cache system unavailable. Query execution temporarily disabled for security. Please ensure Redis/cache backend is running."
620
+ end
621
+
622
+ if query.blank?
623
+ # Query not found - either expired or was never stored
624
+ return nil
625
+ end
626
+
627
+ # Strict validation: must be a SELECT query only
628
+ normalized = query.strip.gsub(/\s+/, " ").downcase
629
+
630
+ # Check for semicolons (prevents multiple statements)
631
+ if query.include?(";")
632
+ raise SecurityError, "Multiple statements are not allowed (semicolons detected)"
633
+ end
634
+
635
+ # Must start with SELECT (case insensitive)
636
+ unless normalized.start_with?("select")
637
+ raise SecurityError, "Only SELECT queries are allowed. Found: #{normalized.split.first&.upcase || 'unknown'}"
638
+ end
639
+
640
+ # Check for dangerous keywords that might be in subqueries or CTEs
641
+ dangerous_keywords = %w[insert update delete drop alter create truncate grant revoke]
642
+ dangerous_keywords.each do |keyword|
643
+ if normalized.match?(/\b#{keyword}\b/)
644
+ raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
645
+ end
646
+ end
647
+
648
+ query
649
+ end
650
+
651
+ def generate_query_monitor_csv(queries)
652
+ require "csv"
653
+
654
+ CSV.generate do |csv|
655
+ # Header
656
+ csv << ["Timestamp", "Duration (ms)", "Query Name", "SQL", "Source File", "Source Line"]
657
+
658
+ # Data rows
659
+ queries.each do |query|
660
+ csv << [
661
+ query[:timestamp],
662
+ query[:duration_ms],
663
+ query[:name],
664
+ query[:sql],
665
+ query.dig(:source_location, :file),
666
+ query.dig(:source_location, :line)
667
+ ]
668
+ end
669
+ end
670
+ end
671
+
672
+ def generate_query_monitor_text(queries)
673
+ output = []
674
+ output << "=" * 80
675
+ output << "Query Monitor Export"
676
+ output << "Generated: #{Time.current.strftime("%Y-%m-%d %H:%M:%S")}"
677
+ output << "Total Queries: #{queries.size}"
678
+ output << "=" * 80
679
+ output << ""
680
+
681
+ queries.each_with_index do |query, index|
682
+ output << "Query ##{index + 1}"
683
+ output << "-" * 80
684
+ output << "Timestamp: #{query[:timestamp]}"
685
+ output << "Duration: #{query[:duration_ms]}ms"
686
+ output << "Name: #{query[:name]}"
687
+
688
+ if query[:source_location]
689
+ output << "Source: #{query[:source_location][:file]}:#{query[:source_location][:line]}"
690
+ end
691
+
692
+ output << ""
693
+ output << "SQL:"
694
+ output << query[:sql]
695
+ output << ""
696
+ output << ""
697
+ end
698
+
699
+ output.join("\n")
700
+ end
400
701
  end
401
702
  end