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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/app/controllers/pg_reports/dashboard_controller.rb +59 -4
  4. data/app/views/layouts/pg_reports/application.html.erb +1 -1
  5. data/app/views/pg_reports/dashboard/_show_modals.html.erb +8 -1
  6. data/app/views/pg_reports/dashboard/_show_scripts.html.erb +56 -18
  7. data/app/views/pg_reports/dashboard/_show_styles.html.erb +122 -1
  8. data/app/views/pg_reports/dashboard/show.html.erb +89 -47
  9. data/config/locales/en.yml +13 -0
  10. data/config/locales/ru.yml +13 -0
  11. data/config/locales/uk.yml +13 -0
  12. data/lib/pg_reports/dashboard/reports_registry.rb +14 -0
  13. data/lib/pg_reports/definitions/connections/active_connections.yml +23 -0
  14. data/lib/pg_reports/definitions/connections/blocking_queries.yml +20 -0
  15. data/lib/pg_reports/definitions/connections/connection_stats.yml +18 -0
  16. data/lib/pg_reports/definitions/connections/idle_connections.yml +21 -0
  17. data/lib/pg_reports/definitions/connections/locks.yml +22 -0
  18. data/lib/pg_reports/definitions/connections/long_running_queries.yml +43 -0
  19. data/lib/pg_reports/definitions/indexes/bloated_indexes.yml +43 -0
  20. data/lib/pg_reports/definitions/indexes/duplicate_indexes.yml +19 -0
  21. data/lib/pg_reports/definitions/indexes/index_sizes.yml +29 -0
  22. data/lib/pg_reports/definitions/indexes/index_usage.yml +27 -0
  23. data/lib/pg_reports/definitions/indexes/invalid_indexes.yml +19 -0
  24. data/lib/pg_reports/definitions/indexes/missing_indexes.yml +27 -0
  25. data/lib/pg_reports/definitions/indexes/unused_indexes.yml +41 -0
  26. data/lib/pg_reports/definitions/queries/all_queries.yml +35 -0
  27. data/lib/pg_reports/definitions/queries/expensive_queries.yml +43 -0
  28. data/lib/pg_reports/definitions/queries/heavy_queries.yml +49 -0
  29. data/lib/pg_reports/definitions/queries/low_cache_hit_queries.yml +47 -0
  30. data/lib/pg_reports/definitions/queries/missing_index_queries.yml +31 -0
  31. data/lib/pg_reports/definitions/queries/slow_queries.yml +48 -0
  32. data/lib/pg_reports/definitions/system/activity_overview.yml +17 -0
  33. data/lib/pg_reports/definitions/system/cache_stats.yml +18 -0
  34. data/lib/pg_reports/definitions/system/database_sizes.yml +18 -0
  35. data/lib/pg_reports/definitions/system/extensions.yml +19 -0
  36. data/lib/pg_reports/definitions/system/settings.yml +20 -0
  37. data/lib/pg_reports/definitions/tables/bloated_tables.yml +43 -0
  38. data/lib/pg_reports/definitions/tables/cache_hit_ratios.yml +26 -0
  39. data/lib/pg_reports/definitions/tables/recently_modified.yml +27 -0
  40. data/lib/pg_reports/definitions/tables/row_counts.yml +29 -0
  41. data/lib/pg_reports/definitions/tables/seq_scans.yml +31 -0
  42. data/lib/pg_reports/definitions/tables/table_sizes.yml +31 -0
  43. data/lib/pg_reports/definitions/tables/vacuum_needed.yml +39 -0
  44. data/lib/pg_reports/filter.rb +58 -0
  45. data/lib/pg_reports/module_generator.rb +44 -0
  46. data/lib/pg_reports/modules/connections.rb +8 -73
  47. data/lib/pg_reports/modules/indexes.rb +9 -94
  48. data/lib/pg_reports/modules/queries.rb +9 -100
  49. data/lib/pg_reports/modules/schema_analysis.rb +156 -0
  50. data/lib/pg_reports/modules/system.rb +7 -59
  51. data/lib/pg_reports/modules/tables.rb +9 -96
  52. data/lib/pg_reports/report_definition.rb +161 -0
  53. data/lib/pg_reports/report_loader.rb +38 -0
  54. data/lib/pg_reports/sql/schema_analysis/unique_indexes.sql +35 -0
  55. data/lib/pg_reports/version.rb +1 -1
  56. data/lib/pg_reports.rb +24 -0
  57. 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
- # 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
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
- # 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
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
- # Table sizes including indexes
10
- # @return [Report] Report with table sizes
11
- def table_sizes(limit: 50)
12
- data = executor.execute_from_file(:tables, :table_sizes)
13
- .first(limit)
14
-
15
- Report.new(
16
- title: "Table Sizes (top #{limit})",
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