pg_insights 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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +183 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/pg_insights/application.js +436 -0
  6. data/app/assets/javascripts/pg_insights/health.js +104 -0
  7. data/app/assets/javascripts/pg_insights/results/chart_renderer.js +126 -0
  8. data/app/assets/javascripts/pg_insights/results/table_manager.js +378 -0
  9. data/app/assets/javascripts/pg_insights/results/view_toggles.js +25 -0
  10. data/app/assets/javascripts/pg_insights/results.js +13 -0
  11. data/app/assets/stylesheets/pg_insights/application.css +750 -0
  12. data/app/assets/stylesheets/pg_insights/health.css +501 -0
  13. data/app/assets/stylesheets/pg_insights/results.css +682 -0
  14. data/app/controllers/pg_insights/application_controller.rb +4 -0
  15. data/app/controllers/pg_insights/health_controller.rb +110 -0
  16. data/app/controllers/pg_insights/insights_controller.rb +77 -0
  17. data/app/controllers/pg_insights/queries_controller.rb +44 -0
  18. data/app/helpers/pg_insights/application_helper.rb +4 -0
  19. data/app/helpers/pg_insights/insights_helper.rb +190 -0
  20. data/app/jobs/pg_insights/application_job.rb +4 -0
  21. data/app/jobs/pg_insights/health_check_job.rb +45 -0
  22. data/app/jobs/pg_insights/health_check_scheduler_job.rb +52 -0
  23. data/app/jobs/pg_insights/recurring_health_checks_job.rb +49 -0
  24. data/app/models/pg_insights/application_record.rb +5 -0
  25. data/app/models/pg_insights/health_check_result.rb +46 -0
  26. data/app/models/pg_insights/query.rb +10 -0
  27. data/app/services/pg_insights/health_check_service.rb +298 -0
  28. data/app/services/pg_insights/insight_query_service.rb +21 -0
  29. data/app/views/layouts/pg_insights/application.html.erb +58 -0
  30. data/app/views/pg_insights/health/index.html.erb +324 -0
  31. data/app/views/pg_insights/insights/_chart_view.html.erb +25 -0
  32. data/app/views/pg_insights/insights/_column_panel.html.erb +18 -0
  33. data/app/views/pg_insights/insights/_query_examples.html.erb +32 -0
  34. data/app/views/pg_insights/insights/_query_panel.html.erb +36 -0
  35. data/app/views/pg_insights/insights/_result.html.erb +15 -0
  36. data/app/views/pg_insights/insights/_results_info.html.erb +19 -0
  37. data/app/views/pg_insights/insights/_results_panel.html.erb +13 -0
  38. data/app/views/pg_insights/insights/_results_table.html.erb +45 -0
  39. data/app/views/pg_insights/insights/_stats_view.html.erb +3 -0
  40. data/app/views/pg_insights/insights/_table_controls.html.erb +21 -0
  41. data/app/views/pg_insights/insights/_table_view.html.erb +5 -0
  42. data/app/views/pg_insights/insights/index.html.erb +5 -0
  43. data/config/default_queries.yml +85 -0
  44. data/config/routes.rb +22 -0
  45. data/lib/generators/pg_insights/clean_generator.rb +74 -0
  46. data/lib/generators/pg_insights/install_generator.rb +176 -0
  47. data/lib/pg_insights/engine.rb +40 -0
  48. data/lib/pg_insights/version.rb +3 -0
  49. data/lib/pg_insights.rb +83 -0
  50. data/lib/tasks/pg_insights.rake +172 -0
  51. metadata +124 -0
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class HealthController < ApplicationController
5
+ layout "pg_insights/application"
6
+
7
+ def index
8
+ @health_results = HealthCheckResult.latest_results
9
+
10
+ @unused_indexes = extract_or_fetch("unused_indexes") { HealthCheckService.check_unused_indexes }
11
+ @missing_indexes = extract_or_fetch("missing_indexes") { HealthCheckService.check_missing_indexes }
12
+ @sequential_scans = extract_or_fetch("sequential_scans") { HealthCheckService.check_sequential_scans }
13
+ @slow_queries = extract_or_fetch("slow_queries") { HealthCheckService.check_slow_queries }
14
+ @table_bloat = extract_or_fetch("table_bloat") { HealthCheckService.check_table_bloat }
15
+ @parameter_settings = extract_or_fetch("parameter_settings") { HealthCheckService.check_parameter_settings }
16
+
17
+ refresh_stale_data_async
18
+ end
19
+
20
+ def unused_indexes
21
+ result = HealthCheckResult.latest_for_type("unused_indexes")
22
+ render json: {
23
+ data: result&.result_data || [],
24
+ status: result&.status || "pending",
25
+ executed_at: result&.executed_at,
26
+ fresh: result&.fresh? || false
27
+ }
28
+ end
29
+
30
+ def missing_indexes
31
+ result = HealthCheckResult.latest_for_type("missing_indexes")
32
+ render json: {
33
+ data: result&.result_data || [],
34
+ status: result&.status || "pending",
35
+ executed_at: result&.executed_at,
36
+ fresh: result&.fresh? || false
37
+ }
38
+ end
39
+
40
+ def sequential_scans
41
+ result = HealthCheckResult.latest_for_type("sequential_scans")
42
+ render json: {
43
+ data: result&.result_data || [],
44
+ status: result&.status || "pending",
45
+ executed_at: result&.executed_at,
46
+ fresh: result&.fresh? || false
47
+ }
48
+ end
49
+
50
+ def slow_queries
51
+ result = HealthCheckResult.latest_for_type("slow_queries")
52
+ render json: {
53
+ data: result&.result_data || [],
54
+ status: result&.status || "pending",
55
+ executed_at: result&.executed_at,
56
+ fresh: result&.fresh? || false
57
+ }
58
+ end
59
+
60
+ def table_bloat
61
+ result = HealthCheckResult.latest_for_type("table_bloat")
62
+ render json: {
63
+ data: result&.result_data || [],
64
+ status: result&.status || "pending",
65
+ executed_at: result&.executed_at,
66
+ fresh: result&.fresh? || false
67
+ }
68
+ end
69
+
70
+ def parameter_settings
71
+ result = HealthCheckResult.latest_for_type("parameter_settings")
72
+ render json: {
73
+ data: result&.result_data || [],
74
+ status: result&.status || "pending",
75
+ executed_at: result&.executed_at,
76
+ fresh: result&.fresh? || false
77
+ }
78
+ end
79
+
80
+ def refresh
81
+ HealthCheckService.refresh_all!
82
+ render json: { message: "Health checks queued for refresh" }
83
+ end
84
+
85
+ private
86
+
87
+ def extract_or_fetch(check_type)
88
+ cached_result = @health_results[check_type]
89
+
90
+ if cached_result&.fresh?
91
+ return cached_result.result_data
92
+ end
93
+
94
+ begin
95
+ yield
96
+ rescue => e
97
+ Rails.logger.error "Health check failed for #{check_type}: #{e.message}"
98
+ { error: e.message }
99
+ end
100
+ end
101
+
102
+ def refresh_stale_data_async
103
+ stale_checks = @health_results.select { |_, result| result.nil? || !result.fresh? }
104
+
105
+ if stale_checks.any?
106
+ HealthCheckService.refresh_all!
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class InsightsController < PgInsights::ApplicationController
5
+ layout "pg_insights/application"
6
+ protect_from_forgery with: :exception
7
+
8
+ MAX_ROWS = 1_000
9
+ TIMEOUT = 5_000
10
+
11
+ # GET /pg_insights
12
+ # POST /pg_insights
13
+ def index
14
+ # Combine built-in and user-saved queries for the UI
15
+ built_in_queries = PgInsights::InsightQueryService.all
16
+
17
+ saved_queries = PgInsights::Query.order(updated_at: :desc).map do |q|
18
+ {
19
+ id: q.id,
20
+ name: q.name,
21
+ sql: q.sql,
22
+ description: q.description,
23
+ category: q.category || "saved"
24
+ }
25
+ end
26
+
27
+ @insight_queries = built_in_queries + saved_queries
28
+
29
+ return unless request.post?
30
+ sql = params.require(:sql)
31
+
32
+ unless read_only?(sql)
33
+ flash.now[:alert] = "Only single SELECT statements are allowed"
34
+ return render :index, status: :unprocessable_entity
35
+ end
36
+
37
+ sql = append_limit(sql, MAX_ROWS) unless sql.match?(/limit\s+\d+/i)
38
+
39
+ begin
40
+ ActiveRecord::Base.connection.transaction do
41
+ ActiveRecord::Base.connection.execute("SET LOCAL statement_timeout = #{TIMEOUT}")
42
+ @result = ActiveRecord::Base.connection.exec_query(sql)
43
+ end
44
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
45
+ flash.now[:alert] = "Query Error: #{e.message}"
46
+ return render :index, status: :unprocessable_entity
47
+ end
48
+
49
+ render :index
50
+ end
51
+
52
+ # GET /pg_insights/table_names
53
+ def table_names
54
+ tables = ActiveRecord::Base.connection.exec_query(
55
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
56
+ )
57
+ render json: { tables: tables.rows.map(&:first) }
58
+ rescue ActiveRecord::StatementInvalid, PG::Error => e
59
+ Rails.logger.error "Failed to fetch table names: #{e.message}"
60
+ render json: { tables: [] }
61
+ end
62
+
63
+ private
64
+
65
+ def read_only?(sql)
66
+ sql.strip!
67
+ # Check for a single SELECT statement
68
+ sql.downcase.start_with?("select") &&
69
+ !sql.include?(";") &&
70
+ !sql.match?(/\b(insert|update|delete|alter|drop|create|grant|revoke)\b/i)
71
+ end
72
+
73
+ def append_limit(sql, n)
74
+ "#{sql.strip} LIMIT #{n}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class QueriesController < PgInsights::ApplicationController
5
+ protect_from_forgery with: :null_session
6
+
7
+ before_action :set_query, only: [ :update, :destroy ]
8
+
9
+ # POST /pg_insights/queries
10
+ def create
11
+ @query = PgInsights::Query.new(query_params)
12
+ if @query.save
13
+ render json: { success: true, query: @query.as_json(only: [ :id, :name, :sql, :description, :category ]) }, status: :created
14
+ else
15
+ render json: { success: false, errors: @query.errors.full_messages }, status: :unprocessable_entity
16
+ end
17
+ end
18
+
19
+ # PATCH/PUT /pg_insights/queries/:id
20
+ def update
21
+ if @query.update(query_params)
22
+ render json: { success: true, query: @query.as_json(only: [ :id, :name, :sql, :description, :category ]) }
23
+ else
24
+ render json: { success: false, errors: @query.errors.full_messages }, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ # DELETE /pg_insights/queries/:id
29
+ def destroy
30
+ @query.destroy
31
+ head :no_content
32
+ end
33
+
34
+ private
35
+
36
+ def set_query
37
+ @query = PgInsights::Query.find(params[:id])
38
+ end
39
+
40
+ def query_params
41
+ params.require(:query).permit(:name, :sql, :description, :category)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,4 @@
1
+ module PgInsights
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,190 @@
1
+ module PgInsights
2
+ module InsightsHelper
3
+ def should_show_chart?(result)
4
+ return false unless result&.rows&.any?
5
+ return false if result.columns.size < 2 || result.columns.size > 4
6
+
7
+ # Check if we have at least one numeric column
8
+ has_numeric = result.rows.any? do |row|
9
+ row.any? { |cell| cell.is_a?(Numeric) || (cell.is_a?(String) && cell.match?(/^\d+(\.\d+)?$/)) }
10
+ end
11
+
12
+ has_numeric && result.rows.size <= 50 # Limit for better chart readability
13
+ end
14
+
15
+ def render_chart(result)
16
+ chart_data = prepare_chart_data(result)
17
+ return "" unless chart_data[:chartData].present?
18
+
19
+ # Use bar chart as default with ChartKick
20
+ begin
21
+ bar_chart(
22
+ chart_data[:chartData],
23
+ height: "350px",
24
+ colors: [ "#00979D", "#00838a", "#00767a" ],
25
+ responsive: true
26
+ )
27
+ rescue => e
28
+ content_tag(:div,
29
+ "Chart data format error: #{e.message}",
30
+ style: "text-align: center; padding: 40px; color: #64748b;"
31
+ )
32
+ end
33
+ end
34
+
35
+ def render_stats(result)
36
+ stats = calculate_stats(result)
37
+
38
+ content_tag(:div) do
39
+ content_tag(:div, class: "stats-section") do
40
+ content_tag(:h4, "Summary Statistics") +
41
+ content_tag(:div, class: "stats-grid") do
42
+ stats.map do |stat|
43
+ content_tag(:div, class: "stat-card") do
44
+ content_tag(:div, stat[:value], class: "stat-value") +
45
+ content_tag(:div, stat[:label], class: "stat-label")
46
+ end
47
+ end.join.html_safe
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def query_category_badge_class(category)
54
+ "query-category-badge #{category.to_s.downcase}"
55
+ end
56
+
57
+ def sql_placeholder_text
58
+ <<~SQL.strip
59
+ -- Enter your SQL query here
60
+
61
+ -- Example: Database table sizes (great for charts!)
62
+ SELECT tablename,
63
+ pg_total_relation_size(schemaname||'.'||tablename) as size_bytes
64
+ FROM pg_tables
65
+ WHERE schemaname = 'public'
66
+ ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
67
+ LIMIT 10;
68
+ SQL
69
+ end
70
+
71
+ def query_example_button_data(query)
72
+ {
73
+ query_id: query["id"],
74
+ category: query["category"],
75
+ title: query["description"]
76
+ }
77
+ end
78
+
79
+ def filter_buttons_data
80
+ [
81
+ { category: "all", label: "All", active: true },
82
+ { category: "database", label: "Database", active: false },
83
+ { category: "business", label: "Business", active: false },
84
+ { category: "saved", label: "Saved", active: false }
85
+ ]
86
+ end
87
+
88
+ def cell_value_class(cell)
89
+ if cell.nil?
90
+ "null-value"
91
+ elsif cell.to_s.strip.empty?
92
+ "empty-value"
93
+ elsif cell.is_a?(Numeric) || (cell.is_a?(String) && cell.match?(/^\d+(\.\d+)?$/))
94
+ "numeric-value"
95
+ elsif cell.to_s.length > 50
96
+ "long-text"
97
+ else
98
+ "text-value"
99
+ end
100
+ end
101
+
102
+ def format_cell_value(cell)
103
+ if cell.nil?
104
+ "NULL"
105
+ elsif cell.to_s.strip.empty?
106
+ "empty"
107
+ elsif cell.to_s.length > 100
108
+ truncate(cell.to_s, length: 100)
109
+ else
110
+ cell.to_s
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def prepare_chart_data(result)
117
+ return { labels: [], chartData: [] } unless result&.rows&.any?
118
+
119
+ # For ChartKick, we need simple key-value pairs
120
+ chart_data = result.rows.map do |row|
121
+ label = row[0].to_s.truncate(30) # Truncate long labels
122
+ value = parse_numeric_value(row[1])
123
+ value ? [ label, value ] : nil
124
+ end.compact
125
+
126
+ {
127
+ labels: chart_data.map(&:first),
128
+ chartData: chart_data
129
+ }
130
+ rescue => e
131
+ Rails.logger.error "Chart data preparation error: #{e.message}"
132
+ { labels: [], chartData: [] }
133
+ end
134
+
135
+ def calculate_stats(result)
136
+ return [] unless result&.rows&.any?
137
+
138
+ stats = []
139
+
140
+ # Basic stats
141
+ stats << { label: "Total Records", value: number_with_delimiter(result.rows.size) }
142
+ stats << { label: "Columns", value: result.columns.size }
143
+
144
+ # Numeric column stats
145
+ result.columns.each_with_index do |col, idx|
146
+ next if idx == 0 # Skip first column (typically labels)
147
+
148
+ numeric_values = result.rows.map { |row| parse_numeric_value(row[idx]) }.compact
149
+
150
+ if numeric_values.any?
151
+ stats << {
152
+ label: "#{col} (Sum)",
153
+ value: number_with_delimiter(numeric_values.sum.round(2))
154
+ }
155
+ stats << {
156
+ label: "#{col} (Avg)",
157
+ value: number_with_delimiter((numeric_values.sum / numeric_values.size).round(2))
158
+ }
159
+ stats << {
160
+ label: "#{col} (Max)",
161
+ value: number_with_delimiter(numeric_values.max)
162
+ }
163
+ stats << {
164
+ label: "#{col} (Min)",
165
+ value: number_with_delimiter(numeric_values.min)
166
+ }
167
+ end
168
+ end
169
+
170
+ stats
171
+ end
172
+
173
+ def parse_numeric_value(value)
174
+ return nil if value.nil?
175
+ return value if value.is_a?(Numeric)
176
+
177
+ # Handle string numbers
178
+ if value.is_a?(String)
179
+ cleaned = value.to_s.gsub(/[,$\s%]/, "")
180
+ if cleaned.match?(/^\d+$/)
181
+ return cleaned.to_i
182
+ elsif cleaned.match?(/^\d+\.\d+$/)
183
+ return cleaned.to_f
184
+ end
185
+ end
186
+
187
+ nil
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,4 @@
1
+ module PgInsights
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class HealthCheckJob < ApplicationJob
5
+ queue_as -> { PgInsights.background_job_queue }
6
+
7
+ rescue_from(StandardError) do |exception|
8
+ Rails.logger.error "PgInsights::HealthCheckJob failed: #{exception.message}"
9
+ end
10
+
11
+ def perform(check_type, options = {})
12
+ unless PgInsights.background_jobs_available?
13
+ Rails.logger.warn "PgInsights: Background jobs not available, skipping #{check_type} check"
14
+ return
15
+ end
16
+
17
+ limit = options.fetch("limit", 10)
18
+
19
+ Rails.logger.debug "PgInsights: Starting background health check for #{check_type}"
20
+
21
+ begin
22
+ HealthCheckService.execute_and_cache_check(check_type, limit)
23
+ Rails.logger.debug "PgInsights: Completed background health check for #{check_type}"
24
+ rescue => e
25
+ Rails.logger.error "PgInsights: Background health check failed for #{check_type}: #{e.message}"
26
+ end
27
+ end
28
+
29
+ def self.queue_name
30
+ PgInsights.background_job_queue
31
+ end
32
+
33
+ def self.perform_check(check_type, options = {})
34
+ return false unless PgInsights.background_jobs_available?
35
+
36
+ begin
37
+ perform_later(check_type, options)
38
+ true
39
+ rescue => e
40
+ Rails.logger.warn "PgInsights: Failed to enqueue health check job for #{check_type}: #{e.message}"
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class HealthCheckSchedulerJob < ApplicationJob
5
+ queue_as -> { PgInsights.background_job_queue }
6
+
7
+ rescue_from(StandardError) do |exception|
8
+ Rails.logger.error "PgInsights::HealthCheckSchedulerJob failed: #{exception.message}"
9
+ end
10
+
11
+ def perform
12
+ unless PgInsights.background_jobs_available?
13
+ Rails.logger.warn "PgInsights: Background jobs not available, skipping scheduler"
14
+ return
15
+ end
16
+
17
+ Rails.logger.debug "PgInsights: Starting health check scheduler"
18
+
19
+ scheduled_count = 0
20
+
21
+ HealthCheckResult::VALID_CHECK_TYPES.each do |check_type|
22
+ latest = HealthCheckResult.latest_for_type(check_type)
23
+
24
+ if latest.nil? || !latest.fresh?
25
+ if HealthCheckJob.perform_check(check_type)
26
+ scheduled_count += 1
27
+ Rails.logger.debug "PgInsights: Scheduled health check for #{check_type}"
28
+ else
29
+ Rails.logger.warn "PgInsights: Failed to schedule health check for #{check_type}"
30
+ end
31
+ else
32
+ Rails.logger.debug "PgInsights: Skipping #{check_type} - data is fresh"
33
+ end
34
+ end
35
+
36
+ Rails.logger.info "PgInsights: Health check scheduler completed. Scheduled #{scheduled_count} checks."
37
+ end
38
+
39
+ def self.schedule_health_checks
40
+ return false unless PgInsights.background_jobs_available?
41
+
42
+ begin
43
+ perform_later
44
+ Rails.logger.info "PgInsights: Health check scheduler enqueued successfully"
45
+ true
46
+ rescue => e
47
+ Rails.logger.warn "PgInsights: Failed to enqueue health check scheduler: #{e.message}"
48
+ false
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class RecurringHealthChecksJob < ApplicationJob
5
+ queue_as -> { PgInsights.background_job_queue }
6
+
7
+ rescue_from(StandardError) do |exception|
8
+ Rails.logger.error "PgInsights::RecurringHealthChecksJob failed: #{exception.message}"
9
+ end
10
+
11
+ def perform
12
+ unless PgInsights.background_jobs_available?
13
+ Rails.logger.warn "PgInsights: Background jobs not available, skipping recurring health checks"
14
+ return
15
+ end
16
+
17
+ Rails.logger.debug "PgInsights: Starting recurring health check cycle"
18
+
19
+ if HealthCheckSchedulerJob.schedule_health_checks
20
+ Rails.logger.info "PgInsights: Recurring health check cycle initiated successfully"
21
+ else
22
+ Rails.logger.warn "PgInsights: Recurring health check cycle failed to start"
23
+ end
24
+ end
25
+
26
+ def self.should_be_scheduled?
27
+ PgInsights.background_jobs_available? &&
28
+ PgInsights.enable_background_jobs &&
29
+ defined?(HealthCheckSchedulerJob) &&
30
+ defined?(HealthCheckJob)
31
+ end
32
+
33
+ def self.validate_setup
34
+ issues = []
35
+
36
+ issues << "Background jobs are disabled" unless PgInsights.enable_background_jobs
37
+ issues << "ActiveJob not available" unless defined?(ActiveJob::Base)
38
+ issues << "Queue adapter is inline (no async processing)" if ActiveJob::Base.queue_adapter.is_a?(ActiveJob::QueueAdapters::InlineAdapter)
39
+ issues << "HealthCheckJob not available" unless defined?(HealthCheckJob)
40
+ issues << "HealthCheckSchedulerJob not available" unless defined?(HealthCheckSchedulerJob)
41
+
42
+ if issues.empty?
43
+ { valid: true, message: "PgInsights recurring health checks are properly configured" }
44
+ else
45
+ { valid: false, issues: issues }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ module PgInsights
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgInsights
4
+ class HealthCheckResult < ApplicationRecord
5
+ VALID_CHECK_TYPES = %w[
6
+ unused_indexes
7
+ missing_indexes
8
+ sequential_scans
9
+ slow_queries
10
+ table_bloat
11
+ parameter_settings
12
+ ].freeze
13
+
14
+ VALID_STATUSES = %w[pending running success error].freeze
15
+
16
+ validates :check_type, presence: true, inclusion: { in: VALID_CHECK_TYPES }
17
+ validates :status, presence: true, inclusion: { in: VALID_STATUSES }
18
+
19
+ scope :recent, -> { order(executed_at: :desc) }
20
+ scope :successful, -> { where(status: "success") }
21
+ scope :by_type, ->(type) { where(check_type: type) }
22
+
23
+ def self.latest_for_type(check_type)
24
+ by_type(check_type).successful.recent.first
25
+ end
26
+
27
+ def self.latest_results
28
+ VALID_CHECK_TYPES.map do |check_type|
29
+ [ check_type, latest_for_type(check_type) ]
30
+ end.to_h
31
+ end
32
+
33
+ def success?
34
+ status == "success"
35
+ end
36
+
37
+ def error?
38
+ status == "error"
39
+ end
40
+
41
+ def fresh?(threshold = nil)
42
+ threshold ||= PgInsights.health_cache_expiry
43
+ executed_at && executed_at > threshold.ago
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ module PgInsights
2
+ class Query < ApplicationRecord
3
+ self.table_name = "pg_insights_queries"
4
+
5
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
6
+ validates :sql, presence: true
7
+ validates :description, length: { maximum: 500 }
8
+ validates :category, presence: true
9
+ end
10
+ end