pg_reports 0.3.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +59 -4
- data/app/views/layouts/pg_reports/application.html.erb +1 -1
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +8 -1
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +56 -18
- data/app/views/pg_reports/dashboard/_show_styles.html.erb +122 -1
- data/app/views/pg_reports/dashboard/show.html.erb +89 -47
- data/config/locales/en.yml +13 -0
- data/config/locales/ru.yml +13 -0
- data/config/locales/uk.yml +13 -0
- data/lib/pg_reports/dashboard/reports_registry.rb +14 -0
- data/lib/pg_reports/definitions/connections/active_connections.yml +23 -0
- data/lib/pg_reports/definitions/connections/blocking_queries.yml +20 -0
- data/lib/pg_reports/definitions/connections/connection_stats.yml +18 -0
- data/lib/pg_reports/definitions/connections/idle_connections.yml +21 -0
- data/lib/pg_reports/definitions/connections/locks.yml +22 -0
- data/lib/pg_reports/definitions/connections/long_running_queries.yml +43 -0
- data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +43 -0
- data/lib/pg_reports/definitions/indexes/duplicate_indexes.yml +19 -0
- data/lib/pg_reports/definitions/indexes/index_sizes.yml +29 -0
- data/lib/pg_reports/definitions/indexes/index_usage.yml +27 -0
- data/lib/pg_reports/definitions/indexes/invalid_indexes.yml +19 -0
- data/lib/pg_reports/definitions/indexes/missing_indexes.yml +27 -0
- data/lib/pg_reports/definitions/indexes/unused_indexes.yml +41 -0
- data/lib/pg_reports/definitions/queries/all_queries.yml +35 -0
- data/lib/pg_reports/definitions/queries/expensive_queries.yml +43 -0
- data/lib/pg_reports/definitions/queries/heavy_queries.yml +49 -0
- data/lib/pg_reports/definitions/queries/low_cache_hit_queries.yml +47 -0
- data/lib/pg_reports/definitions/queries/missing_index_queries.yml +31 -0
- data/lib/pg_reports/definitions/queries/slow_queries.yml +48 -0
- data/lib/pg_reports/definitions/system/activity_overview.yml +17 -0
- data/lib/pg_reports/definitions/system/cache_stats.yml +18 -0
- data/lib/pg_reports/definitions/system/database_sizes.yml +18 -0
- data/lib/pg_reports/definitions/system/extensions.yml +19 -0
- data/lib/pg_reports/definitions/system/settings.yml +20 -0
- data/lib/pg_reports/definitions/tables/bloated_tables.yml +43 -0
- data/lib/pg_reports/definitions/tables/cache_hit_ratios.yml +26 -0
- data/lib/pg_reports/definitions/tables/recently_modified.yml +27 -0
- data/lib/pg_reports/definitions/tables/row_counts.yml +29 -0
- data/lib/pg_reports/definitions/tables/seq_scans.yml +31 -0
- data/lib/pg_reports/definitions/tables/table_sizes.yml +31 -0
- data/lib/pg_reports/definitions/tables/vacuum_needed.yml +39 -0
- data/lib/pg_reports/filter.rb +58 -0
- data/lib/pg_reports/module_generator.rb +44 -0
- data/lib/pg_reports/modules/connections.rb +8 -73
- data/lib/pg_reports/modules/indexes.rb +9 -94
- data/lib/pg_reports/modules/queries.rb +9 -100
- data/lib/pg_reports/modules/schema_analysis.rb +156 -0
- data/lib/pg_reports/modules/system.rb +7 -59
- data/lib/pg_reports/modules/tables.rb +9 -96
- data/lib/pg_reports/report_definition.rb +161 -0
- data/lib/pg_reports/report_loader.rb +38 -0
- data/lib/pg_reports/sql/schema_analysis/unique_indexes.sql +35 -0
- data/lib/pg_reports/version.rb +1 -1
- data/lib/pg_reports.rb +24 -0
- metadata +38 -1
|
@@ -3,109 +3,17 @@
|
|
|
3
3
|
module PgReports
|
|
4
4
|
module Modules
|
|
5
5
|
# Query analysis module - analyzes pg_stat_statements data
|
|
6
|
+
# Most report methods are generated from YAML definitions in lib/pg_reports/definitions/queries/
|
|
6
7
|
module Queries
|
|
7
8
|
extend self
|
|
8
9
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
10
|
+
# The following methods are auto-generated from YAML:
|
|
11
|
+
# - slow_queries(limit: 20)
|
|
12
|
+
# - heavy_queries(limit: 20)
|
|
13
|
+
# - expensive_queries(limit: 20)
|
|
14
|
+
# - missing_index_queries(limit: 20)
|
|
15
|
+
# - low_cache_hit_queries(limit: 20, min_calls: 100)
|
|
16
|
+
# - all_queries(limit: 50)
|
|
109
17
|
|
|
110
18
|
# Reset pg_stat_statements statistics
|
|
111
19
|
def reset_statistics!
|
|
@@ -120,6 +28,7 @@ module PgReports
|
|
|
120
28
|
end
|
|
121
29
|
|
|
122
30
|
# Enrich query data with parsed annotations (Marginalia, Rails QueryLogs, etc.)
|
|
31
|
+
# Used by YAML-based reports via enrichment hook
|
|
123
32
|
def enrich_with_annotations(data)
|
|
124
33
|
data.map do |row|
|
|
125
34
|
query = row["query"].to_s
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
module Modules
|
|
5
|
+
# Schema analysis module - validates database schema consistency with application code
|
|
6
|
+
module SchemaAnalysis
|
|
7
|
+
extend self
|
|
8
|
+
|
|
9
|
+
# Missing validations - unique indexes without corresponding validations
|
|
10
|
+
# @return [Report] Report with unique indexes missing validations
|
|
11
|
+
def missing_validations
|
|
12
|
+
unique_indexes = executor.execute_from_file(:schema_analysis, :unique_indexes)
|
|
13
|
+
results = []
|
|
14
|
+
|
|
15
|
+
unique_indexes.each do |index|
|
|
16
|
+
schema_name = index["schema_name"]
|
|
17
|
+
table_name = index["table_name"]
|
|
18
|
+
index_name = index["index_name"]
|
|
19
|
+
column_names = parse_array(index["column_names"])
|
|
20
|
+
index_type = index["index_type"]
|
|
21
|
+
|
|
22
|
+
# Skip primary keys (they don't need validation)
|
|
23
|
+
next if index_type == "primary_key"
|
|
24
|
+
|
|
25
|
+
# Try to find the model for this table
|
|
26
|
+
model = find_model_for_table(table_name)
|
|
27
|
+
|
|
28
|
+
if model.nil?
|
|
29
|
+
results << {
|
|
30
|
+
"schema" => schema_name,
|
|
31
|
+
"table_name" => table_name,
|
|
32
|
+
"index_name" => index_name,
|
|
33
|
+
"columns" => column_names.join(", "),
|
|
34
|
+
"index_type" => index_type,
|
|
35
|
+
"status" => "no_model",
|
|
36
|
+
"validation_status" => "Model not found",
|
|
37
|
+
"suggestion" => "Create a model for this table or add validates :#{column_names.first}, uniqueness: true"
|
|
38
|
+
}
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Check if validation exists
|
|
43
|
+
has_validation = check_uniqueness_validation(model, column_names)
|
|
44
|
+
|
|
45
|
+
unless has_validation
|
|
46
|
+
results << {
|
|
47
|
+
"schema" => schema_name,
|
|
48
|
+
"table_name" => table_name,
|
|
49
|
+
"model_name" => model.name,
|
|
50
|
+
"index_name" => index_name,
|
|
51
|
+
"columns" => column_names.join(", "),
|
|
52
|
+
"index_type" => index_type,
|
|
53
|
+
"status" => "missing_validation",
|
|
54
|
+
"validation_status" => "Missing uniqueness validation",
|
|
55
|
+
"suggestion" => build_validation_suggestion(column_names)
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
Report.new(
|
|
61
|
+
title: "Unique Indexes Missing Validations",
|
|
62
|
+
data: results,
|
|
63
|
+
columns: %w[table_name model_name columns status validation_status]
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def executor
|
|
70
|
+
@executor ||= Executor.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Parse PostgreSQL array string to Ruby array
|
|
74
|
+
def parse_array(array_string)
|
|
75
|
+
return [] if array_string.nil? || array_string.empty?
|
|
76
|
+
|
|
77
|
+
# PostgreSQL returns arrays as {val1,val2,val3}
|
|
78
|
+
array_string.gsub(/[{}]/, "").split(",").map(&:strip)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Find ActiveRecord model for a given table name
|
|
82
|
+
def find_model_for_table(table_name)
|
|
83
|
+
# Try common naming conventions
|
|
84
|
+
possible_names = [
|
|
85
|
+
table_name.classify, # users -> User
|
|
86
|
+
table_name.singularize.classify, # users -> User
|
|
87
|
+
table_name.camelize, # user_profiles -> UserProfile
|
|
88
|
+
table_name.singularize.camelize # user_profiles -> UserProfile
|
|
89
|
+
].uniq
|
|
90
|
+
|
|
91
|
+
possible_names.each do |model_name|
|
|
92
|
+
begin
|
|
93
|
+
model = model_name.constantize
|
|
94
|
+
return model if model.is_a?(Class) && model < ActiveRecord::Base
|
|
95
|
+
rescue NameError
|
|
96
|
+
# Model doesn't exist, try next one
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Check if model has uniqueness validation for given columns
|
|
104
|
+
def check_uniqueness_validation(model, column_names)
|
|
105
|
+
return false unless model.respond_to?(:validators)
|
|
106
|
+
|
|
107
|
+
# Get all uniqueness validators
|
|
108
|
+
uniqueness_validators = model.validators.select do |v|
|
|
109
|
+
v.is_a?(ActiveRecord::Validations::UniquenessValidator)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return false if uniqueness_validators.empty?
|
|
113
|
+
|
|
114
|
+
# Check if any validator covers our columns
|
|
115
|
+
column_names.each do |column|
|
|
116
|
+
column_sym = column.to_sym
|
|
117
|
+
|
|
118
|
+
# Check if this column has a uniqueness validator
|
|
119
|
+
has_validator = uniqueness_validators.any? do |validator|
|
|
120
|
+
validator.attributes.include?(column_sym)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
return false unless has_validator
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# If we have multiple columns, check for composite uniqueness
|
|
127
|
+
if column_names.size > 1
|
|
128
|
+
# For composite indexes, we need to check if there's a validation with scope
|
|
129
|
+
primary_column = column_names.first.to_sym
|
|
130
|
+
scope_columns = column_names[1..-1].map(&:to_sym)
|
|
131
|
+
|
|
132
|
+
has_composite = uniqueness_validators.any? do |validator|
|
|
133
|
+
validator.attributes.include?(primary_column) &&
|
|
134
|
+
validator.options[:scope] &&
|
|
135
|
+
Array(validator.options[:scope]).sort == scope_columns.sort
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return has_composite
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Build validation suggestion based on columns
|
|
145
|
+
def build_validation_suggestion(column_names)
|
|
146
|
+
if column_names.size == 1
|
|
147
|
+
"validates :#{column_names.first}, uniqueness: true"
|
|
148
|
+
else
|
|
149
|
+
primary = column_names.first
|
|
150
|
+
scopes = column_names[1..-1]
|
|
151
|
+
"validates :#{primary}, uniqueness: { scope: #{scopes.inspect} }"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -3,68 +3,16 @@
|
|
|
3
3
|
module PgReports
|
|
4
4
|
module Modules
|
|
5
5
|
# System-level database statistics
|
|
6
|
+
# Most report methods are generated from YAML definitions in lib/pg_reports/definitions/system/
|
|
6
7
|
module System
|
|
7
8
|
extend self
|
|
8
9
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
10
|
+
# The following methods are auto-generated from YAML:
|
|
11
|
+
# - database_sizes
|
|
12
|
+
# - settings
|
|
13
|
+
# - extensions
|
|
14
|
+
# - activity_overview
|
|
15
|
+
# - cache_stats
|
|
68
16
|
|
|
69
17
|
# pg_stat_statements availability check
|
|
70
18
|
# @return [Boolean] Whether pg_stat_statements is available
|
|
@@ -3,105 +3,18 @@
|
|
|
3
3
|
module PgReports
|
|
4
4
|
module Modules
|
|
5
5
|
# Table analysis module
|
|
6
|
+
# All report methods are generated from YAML definitions in lib/pg_reports/definitions/tables/
|
|
6
7
|
module Tables
|
|
7
8
|
extend self
|
|
8
9
|
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
data: data,
|
|
18
|
-
columns: %w[schema table_name table_size_mb index_size_mb total_size_mb row_count]
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Bloated tables - tables with high dead tuple ratio
|
|
23
|
-
# @return [Report] Report with bloated tables
|
|
24
|
-
def bloated_tables(limit: 20)
|
|
25
|
-
data = executor.execute_from_file(:tables, :bloated_tables)
|
|
26
|
-
threshold = PgReports.config.bloat_threshold_percent
|
|
27
|
-
|
|
28
|
-
filtered = data.select { |row| row["bloat_percent"].to_f >= threshold }
|
|
29
|
-
.first(limit)
|
|
30
|
-
|
|
31
|
-
Report.new(
|
|
32
|
-
title: "Bloated Tables (bloat >= #{threshold}%)",
|
|
33
|
-
data: filtered,
|
|
34
|
-
columns: %w[schema table_name live_rows dead_rows bloat_percent table_size_mb]
|
|
35
|
-
)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Tables needing vacuum - high dead rows count
|
|
39
|
-
# @return [Report] Report with tables needing vacuum
|
|
40
|
-
def vacuum_needed(limit: 20)
|
|
41
|
-
data = executor.execute_from_file(:tables, :vacuum_needed)
|
|
42
|
-
threshold = PgReports.config.dead_rows_threshold
|
|
43
|
-
|
|
44
|
-
filtered = data.select { |row| row["n_dead_tup"].to_i >= threshold }
|
|
45
|
-
.first(limit)
|
|
46
|
-
|
|
47
|
-
Report.new(
|
|
48
|
-
title: "Tables Needing Vacuum (dead rows >= #{threshold})",
|
|
49
|
-
data: filtered,
|
|
50
|
-
columns: %w[schema table_name n_live_tup n_dead_tup last_vacuum last_autovacuum]
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Table row counts
|
|
55
|
-
# @return [Report] Report with table row counts
|
|
56
|
-
def row_counts(limit: 50)
|
|
57
|
-
data = executor.execute_from_file(:tables, :row_counts)
|
|
58
|
-
.first(limit)
|
|
59
|
-
|
|
60
|
-
Report.new(
|
|
61
|
-
title: "Table Row Counts (top #{limit})",
|
|
62
|
-
data: data,
|
|
63
|
-
columns: %w[schema table_name row_count table_size_mb]
|
|
64
|
-
)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Table cache hit ratios
|
|
68
|
-
# @return [Report] Report with table cache hit ratios
|
|
69
|
-
def cache_hit_ratios(limit: 50)
|
|
70
|
-
data = executor.execute_from_file(:tables, :cache_hit_ratios)
|
|
71
|
-
.first(limit)
|
|
72
|
-
|
|
73
|
-
Report.new(
|
|
74
|
-
title: "Table Cache Hit Ratios",
|
|
75
|
-
data: data,
|
|
76
|
-
columns: %w[schema table_name heap_blks_read heap_blks_hit cache_hit_ratio]
|
|
77
|
-
)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Sequential scan statistics
|
|
81
|
-
# @return [Report] Report with sequential scan statistics
|
|
82
|
-
def seq_scans(limit: 20)
|
|
83
|
-
data = executor.execute_from_file(:tables, :seq_scans)
|
|
84
|
-
.first(limit)
|
|
85
|
-
|
|
86
|
-
Report.new(
|
|
87
|
-
title: "Sequential Scans (top #{limit})",
|
|
88
|
-
data: data,
|
|
89
|
-
columns: %w[schema table_name seq_scan seq_tup_read idx_scan rows_per_seq_scan]
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Recently modified tables
|
|
94
|
-
# @return [Report] Report with recently modified tables
|
|
95
|
-
def recently_modified(limit: 20)
|
|
96
|
-
data = executor.execute_from_file(:tables, :recently_modified)
|
|
97
|
-
.first(limit)
|
|
98
|
-
|
|
99
|
-
Report.new(
|
|
100
|
-
title: "Recently Modified Tables",
|
|
101
|
-
data: data,
|
|
102
|
-
columns: %w[schema table_name n_tup_ins n_tup_upd n_tup_del last_analyze]
|
|
103
|
-
)
|
|
104
|
-
end
|
|
10
|
+
# The following methods are auto-generated from YAML:
|
|
11
|
+
# - table_sizes(limit: 50)
|
|
12
|
+
# - bloated_tables(limit: 20)
|
|
13
|
+
# - vacuum_needed(limit: 20)
|
|
14
|
+
# - row_counts(limit: 50)
|
|
15
|
+
# - cache_hit_ratios(limit: 50)
|
|
16
|
+
# - seq_scans(limit: 20)
|
|
17
|
+
# - recently_modified(limit: 20)
|
|
105
18
|
|
|
106
19
|
private
|
|
107
20
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgReports
|
|
4
|
+
# Parses YAML report definitions and generates Report objects
|
|
5
|
+
class ReportDefinition
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize(yaml_path)
|
|
9
|
+
@config = YAML.load_file(yaml_path)["report"]
|
|
10
|
+
@yaml_path = yaml_path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def generate_report(**params)
|
|
14
|
+
# 1. Execute SQL
|
|
15
|
+
data = execute_sql(**params)
|
|
16
|
+
|
|
17
|
+
# 2. Apply filters
|
|
18
|
+
data = apply_filters(data, params)
|
|
19
|
+
|
|
20
|
+
# 3. Apply enrichment
|
|
21
|
+
data = apply_enrichment(data) if enrichment?
|
|
22
|
+
|
|
23
|
+
# 4. Apply limit
|
|
24
|
+
limit = params[:limit] || default_limit
|
|
25
|
+
data = data.first(limit) if limit && data.respond_to?(:first)
|
|
26
|
+
|
|
27
|
+
# 5. Create Report
|
|
28
|
+
Report.new(
|
|
29
|
+
title: interpolate_title(params),
|
|
30
|
+
data: data,
|
|
31
|
+
columns: config["columns"]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def execute_sql(**params)
|
|
38
|
+
sql_config = config["sql"]
|
|
39
|
+
sql_params = extract_sql_params(params)
|
|
40
|
+
|
|
41
|
+
executor = Executor.new
|
|
42
|
+
executor.execute_from_file(
|
|
43
|
+
sql_config["category"].to_sym,
|
|
44
|
+
sql_config["file"].to_sym,
|
|
45
|
+
**sql_params
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_sql_params(params)
|
|
50
|
+
return {} unless config["sql"]["params"]
|
|
51
|
+
|
|
52
|
+
config["sql"]["params"].each_with_object({}) do |(key, value_config), result|
|
|
53
|
+
result[key.to_sym] = resolve_value(value_config, params)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def apply_filters(data, params)
|
|
58
|
+
return data unless config["filters"]
|
|
59
|
+
|
|
60
|
+
config["filters"].reduce(data) do |filtered, filter_config|
|
|
61
|
+
Filter.new(filter_config).apply(filtered, params)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enrichment?
|
|
66
|
+
config["enrichment"].present?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apply_enrichment(data)
|
|
70
|
+
enrichment = config["enrichment"]
|
|
71
|
+
module_name = enrichment["module"]
|
|
72
|
+
hook_name = enrichment["hook"]
|
|
73
|
+
|
|
74
|
+
# Call private method from module
|
|
75
|
+
module_class = PgReports::Modules.const_get(module_name.capitalize)
|
|
76
|
+
module_class.send(hook_name, data)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def interpolate_title(params)
|
|
80
|
+
title = config["title"]
|
|
81
|
+
return title unless config["title_vars"]
|
|
82
|
+
|
|
83
|
+
config["title_vars"].each do |var_name, var_config|
|
|
84
|
+
value = resolve_value(var_config, params)
|
|
85
|
+
title = title.gsub("${#{var_name}}", value.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
title
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_value(value_config, params)
|
|
92
|
+
case value_config["source"]
|
|
93
|
+
when "config"
|
|
94
|
+
PgReports.config.public_send(value_config["key"])
|
|
95
|
+
when "param"
|
|
96
|
+
key = value_config["key"].to_sym
|
|
97
|
+
# Try to get from params, fallback to default value
|
|
98
|
+
params[key] || get_default_param_value(key)
|
|
99
|
+
else
|
|
100
|
+
raise ArgumentError, "Unknown value source: #{value_config["source"]}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def get_default_param_value(param_key)
|
|
105
|
+
return nil unless config["parameters"]&.dig(param_key.to_s)
|
|
106
|
+
|
|
107
|
+
config["parameters"][param_key.to_s]["default"]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def default_limit
|
|
111
|
+
return nil unless config["parameters"]&.dig("limit")
|
|
112
|
+
|
|
113
|
+
config["parameters"]["limit"]["default"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
public
|
|
117
|
+
|
|
118
|
+
# Extract filter parameters for UI
|
|
119
|
+
def filter_parameters
|
|
120
|
+
params = {}
|
|
121
|
+
|
|
122
|
+
# Parameters from parameters section
|
|
123
|
+
if config["parameters"]
|
|
124
|
+
config["parameters"].each do |name, param_config|
|
|
125
|
+
params[name] = {
|
|
126
|
+
type: param_config["type"],
|
|
127
|
+
default: param_config["default"],
|
|
128
|
+
description: param_config["description"],
|
|
129
|
+
label: name.to_s.titleize
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Add threshold parameters from filters (config-based)
|
|
135
|
+
if config["filters"]
|
|
136
|
+
config["filters"].each do |filter|
|
|
137
|
+
if filter["value"]["source"] == "config"
|
|
138
|
+
config_key = filter["value"]["key"]
|
|
139
|
+
field_name = filter["field"]
|
|
140
|
+
|
|
141
|
+
params["#{field_name}_threshold"] = {
|
|
142
|
+
type: filter["cast"] || "integer",
|
|
143
|
+
default: PgReports.config.public_send(config_key),
|
|
144
|
+
description: "Override threshold for #{field_name}",
|
|
145
|
+
label: "#{field_name.titleize} Threshold",
|
|
146
|
+
current_config: PgReports.config.public_send(config_key),
|
|
147
|
+
is_threshold: true
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
params
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Extract problem explanations mapping from YAML
|
|
157
|
+
def problem_explanations
|
|
158
|
+
config["problem_explanations"] || {}
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module PgReports
|
|
6
|
+
# Loads YAML report definitions from the definitions directory
|
|
7
|
+
class ReportLoader
|
|
8
|
+
def self.load_all
|
|
9
|
+
@definitions ||= begin
|
|
10
|
+
definitions = {}
|
|
11
|
+
|
|
12
|
+
Dir.glob(definitions_path.join("**/*.yml")).each do |yaml_file|
|
|
13
|
+
definition = ReportDefinition.new(yaml_file)
|
|
14
|
+
module_name = definition.config["module"]
|
|
15
|
+
report_name = definition.config["name"]
|
|
16
|
+
|
|
17
|
+
definitions[module_name] ||= {}
|
|
18
|
+
definitions[module_name][report_name] = definition
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
definitions
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.get(module_name, report_name)
|
|
26
|
+
load_all.dig(module_name.to_s, report_name.to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.definitions_path
|
|
30
|
+
Pathname.new(__dir__).join("definitions")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.reload!
|
|
34
|
+
@definitions = nil
|
|
35
|
+
load_all
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|