pg_reports 0.1.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 +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +335 -0
  5. data/app/controllers/pg_reports/dashboard_controller.rb +133 -0
  6. data/app/views/layouts/pg_reports/application.html.erb +594 -0
  7. data/app/views/pg_reports/dashboard/index.html.erb +435 -0
  8. data/app/views/pg_reports/dashboard/show.html.erb +481 -0
  9. data/config/routes.rb +13 -0
  10. data/lib/pg_reports/annotation_parser.rb +114 -0
  11. data/lib/pg_reports/configuration.rb +83 -0
  12. data/lib/pg_reports/dashboard/reports_registry.rb +89 -0
  13. data/lib/pg_reports/engine.rb +22 -0
  14. data/lib/pg_reports/error.rb +15 -0
  15. data/lib/pg_reports/executor.rb +51 -0
  16. data/lib/pg_reports/modules/connections.rb +106 -0
  17. data/lib/pg_reports/modules/indexes.rb +111 -0
  18. data/lib/pg_reports/modules/queries.rb +140 -0
  19. data/lib/pg_reports/modules/system.rb +148 -0
  20. data/lib/pg_reports/modules/tables.rb +113 -0
  21. data/lib/pg_reports/report.rb +228 -0
  22. data/lib/pg_reports/sql/connections/active_connections.sql +20 -0
  23. data/lib/pg_reports/sql/connections/blocking_queries.sql +35 -0
  24. data/lib/pg_reports/sql/connections/connection_stats.sql +13 -0
  25. data/lib/pg_reports/sql/connections/idle_connections.sql +19 -0
  26. data/lib/pg_reports/sql/connections/locks.sql +20 -0
  27. data/lib/pg_reports/sql/connections/long_running_queries.sql +21 -0
  28. data/lib/pg_reports/sql/indexes/bloated_indexes.sql +36 -0
  29. data/lib/pg_reports/sql/indexes/duplicate_indexes.sql +38 -0
  30. data/lib/pg_reports/sql/indexes/index_sizes.sql +14 -0
  31. data/lib/pg_reports/sql/indexes/index_usage.sql +19 -0
  32. data/lib/pg_reports/sql/indexes/invalid_indexes.sql +15 -0
  33. data/lib/pg_reports/sql/indexes/missing_indexes.sql +27 -0
  34. data/lib/pg_reports/sql/indexes/unused_indexes.sql +18 -0
  35. data/lib/pg_reports/sql/queries/all_queries.sql +20 -0
  36. data/lib/pg_reports/sql/queries/expensive_queries.sql +22 -0
  37. data/lib/pg_reports/sql/queries/heavy_queries.sql +17 -0
  38. data/lib/pg_reports/sql/queries/low_cache_hit_queries.sql +19 -0
  39. data/lib/pg_reports/sql/queries/missing_index_queries.sql +25 -0
  40. data/lib/pg_reports/sql/queries/slow_queries.sql +17 -0
  41. data/lib/pg_reports/sql/system/activity_overview.sql +29 -0
  42. data/lib/pg_reports/sql/system/cache_stats.sql +19 -0
  43. data/lib/pg_reports/sql/system/database_sizes.sql +10 -0
  44. data/lib/pg_reports/sql/system/extensions.sql +12 -0
  45. data/lib/pg_reports/sql/system/settings.sql +33 -0
  46. data/lib/pg_reports/sql/tables/bloated_tables.sql +23 -0
  47. data/lib/pg_reports/sql/tables/cache_hit_ratios.sql +24 -0
  48. data/lib/pg_reports/sql/tables/recently_modified.sql +20 -0
  49. data/lib/pg_reports/sql/tables/row_counts.sql +18 -0
  50. data/lib/pg_reports/sql/tables/seq_scans.sql +26 -0
  51. data/lib/pg_reports/sql/tables/table_sizes.sql +16 -0
  52. data/lib/pg_reports/sql/tables/vacuum_needed.sql +22 -0
  53. data/lib/pg_reports/sql_loader.rb +35 -0
  54. data/lib/pg_reports/telegram_sender.rb +83 -0
  55. data/lib/pg_reports/version.rb +5 -0
  56. data/lib/pg_reports.rb +114 -0
  57. metadata +184 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Dashboard
5
+ # Registry of all available reports for the dashboard
6
+ module ReportsRegistry
7
+ REPORTS = {
8
+ queries: {
9
+ name: "Queries",
10
+ icon: "⚡",
11
+ color: "#6366f1",
12
+ reports: {
13
+ slow_queries: {name: "Slow Queries", description: "Queries with high mean execution time"},
14
+ heavy_queries: {name: "Heavy Queries", description: "Most frequently called queries"},
15
+ expensive_queries: {name: "Expensive Queries", description: "Queries consuming most total time"},
16
+ missing_index_queries: {name: "Missing Index Queries", description: "Queries potentially missing indexes"},
17
+ low_cache_hit_queries: {name: "Low Cache Hit", description: "Queries with poor cache utilization"},
18
+ all_queries: {name: "All Queries", description: "All query statistics"}
19
+ }
20
+ },
21
+ indexes: {
22
+ name: "Indexes",
23
+ icon: "📇",
24
+ color: "#10b981",
25
+ reports: {
26
+ unused_indexes: {name: "Unused Indexes", description: "Indexes rarely or never scanned"},
27
+ duplicate_indexes: {name: "Duplicate Indexes", description: "Redundant indexes"},
28
+ invalid_indexes: {name: "Invalid Indexes", description: "Indexes that failed to build"},
29
+ missing_indexes: {name: "Missing Indexes", description: "Tables potentially missing indexes"},
30
+ index_usage: {name: "Index Usage", description: "Index scan statistics"},
31
+ bloated_indexes: {name: "Bloated Indexes", description: "Indexes with high bloat"},
32
+ index_sizes: {name: "Index Sizes", description: "Index disk usage"}
33
+ }
34
+ },
35
+ tables: {
36
+ name: "Tables",
37
+ icon: "📊",
38
+ color: "#f59e0b",
39
+ reports: {
40
+ table_sizes: {name: "Table Sizes", description: "Table disk usage"},
41
+ bloated_tables: {name: "Bloated Tables", description: "Tables with high dead tuple ratio"},
42
+ vacuum_needed: {name: "Vacuum Needed", description: "Tables needing vacuum"},
43
+ row_counts: {name: "Row Counts", description: "Table row counts"},
44
+ cache_hit_ratios: {name: "Cache Hit Ratios", description: "Table cache statistics"},
45
+ seq_scans: {name: "Sequential Scans", description: "Tables with high sequential scans"},
46
+ recently_modified: {name: "Recently Modified", description: "Tables with recent activity"}
47
+ }
48
+ },
49
+ connections: {
50
+ name: "Connections",
51
+ icon: "🔌",
52
+ color: "#ec4899",
53
+ reports: {
54
+ active_connections: {name: "Active Connections", description: "Current database connections"},
55
+ connection_stats: {name: "Connection Stats", description: "Connection statistics by state"},
56
+ long_running_queries: {name: "Long Running", description: "Queries running for extended period"},
57
+ blocking_queries: {name: "Blocking Queries", description: "Queries blocking others"},
58
+ locks: {name: "Locks", description: "Current database locks"},
59
+ idle_connections: {name: "Idle Connections", description: "Idle connections"}
60
+ }
61
+ },
62
+ system: {
63
+ name: "System",
64
+ icon: "🖥️",
65
+ color: "#8b5cf6",
66
+ reports: {
67
+ database_sizes: {name: "Database Sizes", description: "Size of all databases"},
68
+ settings: {name: "Settings", description: "PostgreSQL configuration"},
69
+ extensions: {name: "Extensions", description: "Installed extensions"},
70
+ activity_overview: {name: "Activity Overview", description: "Current activity summary"},
71
+ cache_stats: {name: "Cache Stats", description: "Database cache statistics"}
72
+ }
73
+ }
74
+ }.freeze
75
+
76
+ def self.all
77
+ REPORTS
78
+ end
79
+
80
+ def self.find(category, report)
81
+ REPORTS.dig(category.to_sym, :reports, report.to_sym)
82
+ end
83
+
84
+ def self.category(category)
85
+ REPORTS[category.to_sym]
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace PgReports
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ initializer "pg_reports.assets" do |_app|
12
+ # Assets are inline in views, no precompilation needed
13
+ end
14
+
15
+ initializer "pg_reports.append_routes" do |app|
16
+ # Allow mounting at custom path
17
+ app.routes.append do
18
+ # Routes are mounted by the user in their routes.rb
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Base error class for PgReports
5
+ class Error < StandardError; end
6
+
7
+ # Raised when Telegram is not configured
8
+ class TelegramNotConfiguredError < Error; end
9
+
10
+ # Raised when SQL file is not found
11
+ class SqlFileNotFoundError < Error; end
12
+
13
+ # Raised when database connection fails
14
+ class ConnectionError < Error; end
15
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ # Executes SQL queries and returns results
5
+ class Executor
6
+ def initialize(connection: nil)
7
+ @connection = connection || PgReports.config.connection
8
+ end
9
+
10
+ # Execute SQL from a file and return results as array of hashes
11
+ def execute_from_file(category, name, **params)
12
+ sql = SqlLoader.load(category, name)
13
+ execute(sql, **params)
14
+ end
15
+
16
+ # Execute raw SQL and return results as array of hashes
17
+ def execute(sql, **params)
18
+ processed_sql = interpolate_params(sql, params)
19
+ result = @connection.exec_query(processed_sql)
20
+ result.to_a
21
+ end
22
+
23
+ private
24
+
25
+ # Simple parameter interpolation for SQL
26
+ # Replaces :param_name with quoted values
27
+ def interpolate_params(sql, params)
28
+ return sql if params.empty?
29
+
30
+ params.reduce(sql) do |query, (key, value)|
31
+ quoted_value = quote_value(value)
32
+ query.gsub(":#{key}", quoted_value)
33
+ end
34
+ end
35
+
36
+ def quote_value(value)
37
+ case value
38
+ when nil
39
+ "NULL"
40
+ when Integer, Float
41
+ value.to_s
42
+ when String
43
+ @connection.quote(value)
44
+ when Array
45
+ "(#{value.map { |v| quote_value(v) }.join(", ")})"
46
+ else
47
+ @connection.quote(value.to_s)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Modules
5
+ # Connection and lock analysis module
6
+ module Connections
7
+ extend self
8
+
9
+ # Active connections
10
+ # @return [Report] Report with active connections
11
+ def active_connections
12
+ data = executor.execute_from_file(:connections, :active_connections)
13
+
14
+ Report.new(
15
+ title: "Active Connections",
16
+ data: data,
17
+ columns: %w[pid database username application state query_start state_change query]
18
+ )
19
+ end
20
+
21
+ # Connection statistics by state
22
+ # @return [Report] Report with connection counts by state
23
+ def connection_stats
24
+ data = executor.execute_from_file(:connections, :connection_stats)
25
+
26
+ Report.new(
27
+ title: "Connection Statistics",
28
+ data: data,
29
+ columns: %w[database state count]
30
+ )
31
+ end
32
+
33
+ # Long running queries
34
+ # @return [Report] Report with long running queries
35
+ def long_running_queries(min_duration_seconds: 60)
36
+ data = executor.execute_from_file(:connections, :long_running_queries)
37
+
38
+ filtered = data.select { |row| row["duration_seconds"].to_f >= min_duration_seconds }
39
+
40
+ Report.new(
41
+ title: "Long Running Queries (>= #{min_duration_seconds}s)",
42
+ data: filtered,
43
+ columns: %w[pid database username duration_seconds state query]
44
+ )
45
+ end
46
+
47
+ # Blocking queries - queries that are blocking others
48
+ # @return [Report] Report with blocking queries
49
+ def blocking_queries
50
+ data = executor.execute_from_file(:connections, :blocking_queries)
51
+
52
+ Report.new(
53
+ title: "Blocking Queries",
54
+ data: data,
55
+ columns: %w[blocked_pid blocking_pid blocked_query blocking_query blocked_duration]
56
+ )
57
+ end
58
+
59
+ # Lock statistics
60
+ # @return [Report] Report with lock statistics
61
+ def locks
62
+ data = executor.execute_from_file(:connections, :locks)
63
+
64
+ Report.new(
65
+ title: "Current Locks",
66
+ data: data,
67
+ columns: %w[pid database relation locktype mode granted waiting]
68
+ )
69
+ end
70
+
71
+ # Idle connections
72
+ # @return [Report] Report with idle connections
73
+ def idle_connections
74
+ data = executor.execute_from_file(:connections, :idle_connections)
75
+
76
+ Report.new(
77
+ title: "Idle Connections",
78
+ data: data,
79
+ columns: %w[pid database username application idle_duration state_change]
80
+ )
81
+ end
82
+
83
+ # Kill a specific backend process
84
+ # @param pid [Integer] Process ID to terminate
85
+ # @return [Boolean] Success status
86
+ def kill_connection(pid)
87
+ result = executor.execute("SELECT pg_terminate_backend(:pid)", pid: pid)
88
+ result.first&.fetch("pg_terminate_backend", false) || false
89
+ end
90
+
91
+ # Cancel a specific query (softer than kill)
92
+ # @param pid [Integer] Process ID to cancel
93
+ # @return [Boolean] Success status
94
+ def cancel_query(pid)
95
+ result = executor.execute("SELECT pg_cancel_backend(:pid)", pid: pid)
96
+ result.first&.fetch("pg_cancel_backend", false) || false
97
+ end
98
+
99
+ private
100
+
101
+ def executor
102
+ @executor ||= Executor.new
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Modules
5
+ # Index analysis module
6
+ module Indexes
7
+ extend self
8
+
9
+ # Unused indexes - indexes that are rarely or never scanned
10
+ # @return [Report] Report with unused indexes
11
+ def unused_indexes(limit: 50)
12
+ data = executor.execute_from_file(:indexes, :unused_indexes)
13
+ threshold = PgReports.config.unused_index_threshold_scans
14
+
15
+ filtered = data.select { |row| row["idx_scan"].to_i <= threshold }
16
+ .first(limit)
17
+
18
+ Report.new(
19
+ title: "Unused Indexes (scans <= #{threshold})",
20
+ data: filtered,
21
+ columns: %w[schema table_name index_name idx_scan index_size_mb]
22
+ )
23
+ end
24
+
25
+ # Duplicate indexes - indexes that may be redundant
26
+ # @return [Report] Report with duplicate indexes
27
+ def duplicate_indexes
28
+ data = executor.execute_from_file(:indexes, :duplicate_indexes)
29
+
30
+ Report.new(
31
+ title: "Duplicate Indexes",
32
+ data: data,
33
+ columns: %w[table_name index_name duplicate_of index_size_mb]
34
+ )
35
+ end
36
+
37
+ # Invalid indexes - indexes that are not valid (e.g., failed to build)
38
+ # @return [Report] Report with invalid indexes
39
+ def invalid_indexes
40
+ data = executor.execute_from_file(:indexes, :invalid_indexes)
41
+
42
+ Report.new(
43
+ title: "Invalid Indexes",
44
+ data: data,
45
+ columns: %w[schema table_name index_name index_definition]
46
+ )
47
+ end
48
+
49
+ # Missing indexes - tables with high sequential scans
50
+ # @return [Report] Report suggesting missing indexes
51
+ def missing_indexes(limit: 20)
52
+ data = executor.execute_from_file(:indexes, :missing_indexes)
53
+ .first(limit)
54
+
55
+ Report.new(
56
+ title: "Tables Potentially Missing Indexes",
57
+ data: data,
58
+ columns: %w[schema table_name seq_scan seq_tup_read idx_scan table_size_mb]
59
+ )
60
+ end
61
+
62
+ # Index usage statistics
63
+ # @return [Report] Report with index usage statistics
64
+ def index_usage(limit: 50)
65
+ data = executor.execute_from_file(:indexes, :index_usage)
66
+ .first(limit)
67
+
68
+ Report.new(
69
+ title: "Index Usage Statistics",
70
+ data: data,
71
+ columns: %w[schema table_name index_name idx_scan idx_tup_read index_size_mb]
72
+ )
73
+ end
74
+
75
+ # Bloated indexes - indexes with high bloat
76
+ # @return [Report] Report with bloated indexes
77
+ def bloated_indexes(limit: 20)
78
+ data = executor.execute_from_file(:indexes, :bloated_indexes)
79
+ threshold = PgReports.config.bloat_threshold_percent
80
+
81
+ filtered = data.select { |row| row["bloat_percent"].to_f >= threshold }
82
+ .first(limit)
83
+
84
+ Report.new(
85
+ title: "Bloated Indexes (bloat >= #{threshold}%)",
86
+ data: filtered,
87
+ columns: %w[schema table_name index_name index_size_mb bloat_size_mb bloat_percent]
88
+ )
89
+ end
90
+
91
+ # Index sizes
92
+ # @return [Report] Report with index sizes
93
+ def index_sizes(limit: 50)
94
+ data = executor.execute_from_file(:indexes, :index_sizes)
95
+ .first(limit)
96
+
97
+ Report.new(
98
+ title: "Index Sizes (top #{limit})",
99
+ data: data,
100
+ columns: %w[schema table_name index_name index_size_mb]
101
+ )
102
+ end
103
+
104
+ private
105
+
106
+ def executor
107
+ @executor ||= Executor.new
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Modules
5
+ # Query analysis module - analyzes pg_stat_statements data
6
+ module Queries
7
+ extend self
8
+
9
+ # Slow queries - queries with high mean execution time
10
+ # @return [Report] Report with slow queries
11
+ def slow_queries(limit: 20)
12
+ data = executor.execute_from_file(:queries, :slow_queries)
13
+ threshold = PgReports.config.slow_query_threshold_ms
14
+
15
+ filtered = data.select { |row| row["mean_time_ms"].to_f >= threshold }
16
+ .first(limit)
17
+
18
+ enriched = enrich_with_annotations(filtered)
19
+
20
+ Report.new(
21
+ title: "Slow Queries (mean time >= #{threshold}ms)",
22
+ data: enriched,
23
+ columns: %w[query source calls mean_time_ms total_time_ms rows_per_call]
24
+ )
25
+ end
26
+
27
+ # Heavy queries - queries called most frequently
28
+ # @return [Report] Report with heavy queries
29
+ def heavy_queries(limit: 20)
30
+ data = executor.execute_from_file(:queries, :heavy_queries)
31
+ threshold = PgReports.config.heavy_query_threshold_calls
32
+
33
+ filtered = data.select { |row| row["calls"].to_i >= threshold }
34
+ .first(limit)
35
+
36
+ enriched = enrich_with_annotations(filtered)
37
+
38
+ Report.new(
39
+ title: "Heavy Queries (calls >= #{threshold})",
40
+ data: enriched,
41
+ columns: %w[query source calls total_time_ms mean_time_ms cache_hit_ratio]
42
+ )
43
+ end
44
+
45
+ # Expensive queries - queries consuming most total time
46
+ # @return [Report] Report with expensive queries
47
+ def expensive_queries(limit: 20)
48
+ data = executor.execute_from_file(:queries, :expensive_queries)
49
+ threshold = PgReports.config.expensive_query_threshold_ms
50
+
51
+ filtered = data.select { |row| row["total_time_ms"].to_f >= threshold }
52
+ .first(limit)
53
+
54
+ enriched = enrich_with_annotations(filtered)
55
+
56
+ Report.new(
57
+ title: "Expensive Queries (total time >= #{threshold}ms)",
58
+ data: enriched,
59
+ columns: %w[query source calls total_time_ms percent_of_total mean_time_ms]
60
+ )
61
+ end
62
+
63
+ # Queries missing indexes - sequential scans on large tables
64
+ # @return [Report] Report with queries likely missing indexes
65
+ def missing_index_queries(limit: 20)
66
+ data = executor.execute_from_file(:queries, :missing_index_queries)
67
+ .first(limit)
68
+
69
+ enriched = enrich_with_annotations(data)
70
+
71
+ Report.new(
72
+ title: "Queries Potentially Missing Indexes",
73
+ data: enriched,
74
+ columns: %w[query source calls seq_scan_count rows_examined table_name]
75
+ )
76
+ end
77
+
78
+ # Queries with low cache hit ratio
79
+ # @return [Report] Report with queries having poor cache utilization
80
+ def low_cache_hit_queries(limit: 20, min_calls: 100)
81
+ data = executor.execute_from_file(:queries, :low_cache_hit_queries)
82
+
83
+ filtered = data.select { |row| row["calls"].to_i >= min_calls }
84
+ .first(limit)
85
+
86
+ enriched = enrich_with_annotations(filtered)
87
+
88
+ Report.new(
89
+ title: "Queries with Low Cache Hit Ratio (min #{min_calls} calls)",
90
+ data: enriched,
91
+ columns: %w[query source calls cache_hit_ratio shared_blks_hit shared_blks_read]
92
+ )
93
+ end
94
+
95
+ # All query statistics ordered by total time
96
+ # @return [Report] Report with all query statistics
97
+ def all_queries(limit: 50)
98
+ data = executor.execute_from_file(:queries, :all_queries)
99
+ .first(limit)
100
+
101
+ enriched = enrich_with_annotations(data)
102
+
103
+ Report.new(
104
+ title: "All Query Statistics (top #{limit})",
105
+ data: enriched,
106
+ columns: %w[query source calls total_time_ms mean_time_ms rows]
107
+ )
108
+ end
109
+
110
+ # Reset pg_stat_statements statistics
111
+ def reset_statistics!
112
+ executor.execute("SELECT pg_stat_statements_reset()")
113
+ true
114
+ end
115
+
116
+ private
117
+
118
+ def executor
119
+ @executor ||= Executor.new
120
+ end
121
+
122
+ # Enrich query data with parsed annotations (Marginalia, Rails QueryLogs, etc.)
123
+ def enrich_with_annotations(data)
124
+ data.map do |row|
125
+ query = row["query"].to_s
126
+ annotation = AnnotationParser.parse(query)
127
+
128
+ if annotation.any?
129
+ row.merge(
130
+ "source" => AnnotationParser.format_for_display(annotation),
131
+ "query" => AnnotationParser.strip_annotations(query)
132
+ )
133
+ else
134
+ row.merge("source" => nil)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgReports
4
+ module Modules
5
+ # System-level database statistics
6
+ module System
7
+ extend self
8
+
9
+ # Database sizes
10
+ # @return [Report] Report with database sizes
11
+ def database_sizes
12
+ data = executor.execute_from_file(:system, :database_sizes)
13
+
14
+ Report.new(
15
+ title: "Database Sizes",
16
+ data: data,
17
+ columns: %w[database size_mb size_pretty]
18
+ )
19
+ end
20
+
21
+ # PostgreSQL settings
22
+ # @return [Report] Report with important PostgreSQL settings
23
+ def settings
24
+ data = executor.execute_from_file(:system, :settings)
25
+
26
+ Report.new(
27
+ title: "PostgreSQL Settings",
28
+ data: data,
29
+ columns: %w[name setting unit category description]
30
+ )
31
+ end
32
+
33
+ # Extension information
34
+ # @return [Report] Report with installed extensions
35
+ def extensions
36
+ data = executor.execute_from_file(:system, :extensions)
37
+
38
+ Report.new(
39
+ title: "Installed Extensions",
40
+ data: data,
41
+ columns: %w[name version schema description]
42
+ )
43
+ end
44
+
45
+ # Database activity overview
46
+ # @return [Report] Report with current activity
47
+ def activity_overview
48
+ data = executor.execute_from_file(:system, :activity_overview)
49
+
50
+ Report.new(
51
+ title: "Database Activity Overview",
52
+ data: data,
53
+ columns: %w[metric value]
54
+ )
55
+ end
56
+
57
+ # Cache hit ratio for the entire database
58
+ # @return [Report] Report with cache statistics
59
+ def cache_stats
60
+ data = executor.execute_from_file(:system, :cache_stats)
61
+
62
+ Report.new(
63
+ title: "Database Cache Statistics",
64
+ data: data,
65
+ columns: %w[database heap_hit_ratio index_hit_ratio]
66
+ )
67
+ end
68
+
69
+ # pg_stat_statements availability check
70
+ # @return [Boolean] Whether pg_stat_statements is available
71
+ def pg_stat_statements_available?
72
+ result = executor.execute(<<~SQL)
73
+ SELECT EXISTS (
74
+ SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements'
75
+ ) AS available
76
+ SQL
77
+ result.first&.fetch("available", false) || false
78
+ end
79
+
80
+ # Check if pg_stat_statements is in shared_preload_libraries
81
+ # @return [Boolean] Whether pg_stat_statements is preloaded
82
+ def pg_stat_statements_preloaded?
83
+ result = executor.execute(<<~SQL)
84
+ SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'
85
+ SQL
86
+ setting = result.first&.fetch("setting", "") || ""
87
+ setting.include?("pg_stat_statements")
88
+ end
89
+
90
+ # Get pg_stat_statements status details
91
+ # @return [Hash] Status information
92
+ def pg_stat_statements_status
93
+ {
94
+ extension_installed: pg_stat_statements_available?,
95
+ preloaded: pg_stat_statements_preloaded?,
96
+ ready: pg_stat_statements_available? && pg_stat_statements_preloaded?
97
+ }
98
+ end
99
+
100
+ # Enable pg_stat_statements extension
101
+ # Tries to create extension, returns helpful error if it fails
102
+ # @return [Hash] Result with success status and message
103
+ def enable_pg_stat_statements!
104
+ # Check if already enabled
105
+ if pg_stat_statements_available?
106
+ return {success: true, message: "pg_stat_statements is already enabled"}
107
+ end
108
+
109
+ # Try to create extension
110
+ begin
111
+ executor.execute("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
112
+
113
+ # Verify it worked
114
+ if pg_stat_statements_available?
115
+ {success: true, message: "pg_stat_statements extension created successfully"}
116
+ else
117
+ {
118
+ success: false,
119
+ message: "Extension created but not working. Check shared_preload_libraries in postgresql.conf",
120
+ requires_restart: true
121
+ }
122
+ end
123
+ rescue => e
124
+ error_message = e.message
125
+
126
+ # Provide helpful message for common errors
127
+ if error_message.include?("could not open extension control file") ||
128
+ error_message.include?("extension \"pg_stat_statements\" is not available")
129
+ {
130
+ success: false,
131
+ message: "pg_stat_statements is not installed. Add to postgresql.conf: " \
132
+ "shared_preload_libraries = 'pg_stat_statements' and restart PostgreSQL.",
133
+ requires_restart: true
134
+ }
135
+ else
136
+ {success: false, message: "Failed to create extension: #{error_message}"}
137
+ end
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def executor
144
+ @executor ||= Executor.new
145
+ end
146
+ end
147
+ end
148
+ end