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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +183 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/pg_insights/application.js +436 -0
- data/app/assets/javascripts/pg_insights/health.js +104 -0
- data/app/assets/javascripts/pg_insights/results/chart_renderer.js +126 -0
- data/app/assets/javascripts/pg_insights/results/table_manager.js +378 -0
- data/app/assets/javascripts/pg_insights/results/view_toggles.js +25 -0
- data/app/assets/javascripts/pg_insights/results.js +13 -0
- data/app/assets/stylesheets/pg_insights/application.css +750 -0
- data/app/assets/stylesheets/pg_insights/health.css +501 -0
- data/app/assets/stylesheets/pg_insights/results.css +682 -0
- data/app/controllers/pg_insights/application_controller.rb +4 -0
- data/app/controllers/pg_insights/health_controller.rb +110 -0
- data/app/controllers/pg_insights/insights_controller.rb +77 -0
- data/app/controllers/pg_insights/queries_controller.rb +44 -0
- data/app/helpers/pg_insights/application_helper.rb +4 -0
- data/app/helpers/pg_insights/insights_helper.rb +190 -0
- data/app/jobs/pg_insights/application_job.rb +4 -0
- data/app/jobs/pg_insights/health_check_job.rb +45 -0
- data/app/jobs/pg_insights/health_check_scheduler_job.rb +52 -0
- data/app/jobs/pg_insights/recurring_health_checks_job.rb +49 -0
- data/app/models/pg_insights/application_record.rb +5 -0
- data/app/models/pg_insights/health_check_result.rb +46 -0
- data/app/models/pg_insights/query.rb +10 -0
- data/app/services/pg_insights/health_check_service.rb +298 -0
- data/app/services/pg_insights/insight_query_service.rb +21 -0
- data/app/views/layouts/pg_insights/application.html.erb +58 -0
- data/app/views/pg_insights/health/index.html.erb +324 -0
- data/app/views/pg_insights/insights/_chart_view.html.erb +25 -0
- data/app/views/pg_insights/insights/_column_panel.html.erb +18 -0
- data/app/views/pg_insights/insights/_query_examples.html.erb +32 -0
- data/app/views/pg_insights/insights/_query_panel.html.erb +36 -0
- data/app/views/pg_insights/insights/_result.html.erb +15 -0
- data/app/views/pg_insights/insights/_results_info.html.erb +19 -0
- data/app/views/pg_insights/insights/_results_panel.html.erb +13 -0
- data/app/views/pg_insights/insights/_results_table.html.erb +45 -0
- data/app/views/pg_insights/insights/_stats_view.html.erb +3 -0
- data/app/views/pg_insights/insights/_table_controls.html.erb +21 -0
- data/app/views/pg_insights/insights/_table_view.html.erb +5 -0
- data/app/views/pg_insights/insights/index.html.erb +5 -0
- data/config/default_queries.yml +85 -0
- data/config/routes.rb +22 -0
- data/lib/generators/pg_insights/clean_generator.rb +74 -0
- data/lib/generators/pg_insights/install_generator.rb +176 -0
- data/lib/pg_insights/engine.rb +40 -0
- data/lib/pg_insights/version.rb +3 -0
- data/lib/pg_insights.rb +83 -0
- data/lib/tasks/pg_insights.rake +172 -0
- 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,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,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,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
|