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,298 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "timeout"
|
4
|
+
|
5
|
+
module PgInsights
|
6
|
+
class HealthCheckService
|
7
|
+
def self.check_unused_indexes(limit: 10)
|
8
|
+
get_cached_result("unused_indexes") || execute_unused_indexes_query(limit)
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.check_missing_indexes(limit: 10)
|
12
|
+
get_cached_result("missing_indexes") || execute_missing_indexes_query(limit)
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.check_sequential_scans(limit: 10)
|
16
|
+
get_cached_result("sequential_scans") || execute_sequential_scans_query(limit)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.check_slow_queries(limit: 10)
|
20
|
+
get_cached_result("slow_queries") || execute_slow_queries_query(limit)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.check_table_bloat(limit: 10)
|
24
|
+
get_cached_result("table_bloat") || execute_table_bloat_query(limit)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.check_parameter_settings
|
28
|
+
get_cached_result("parameter_settings") || execute_parameter_settings_query
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.refresh_all!(force_synchronous: false)
|
32
|
+
if force_synchronous || !PgInsights.background_jobs_available?
|
33
|
+
execute_all_checks_synchronously
|
34
|
+
else
|
35
|
+
PgInsights.execute_with_fallback(
|
36
|
+
HealthCheckSchedulerJob,
|
37
|
+
:perform_later
|
38
|
+
) do
|
39
|
+
Rails.logger.info "PgInsights: Falling back to synchronous health check execution"
|
40
|
+
execute_all_checks_synchronously
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.refresh_check!(check_type, limit: 10, force_synchronous: false)
|
46
|
+
if force_synchronous || !PgInsights.background_jobs_available?
|
47
|
+
execute_and_cache_check(check_type, limit)
|
48
|
+
else
|
49
|
+
PgInsights.execute_with_fallback(
|
50
|
+
HealthCheckJob,
|
51
|
+
:perform_later,
|
52
|
+
check_type,
|
53
|
+
{ "limit" => limit }
|
54
|
+
) do
|
55
|
+
Rails.logger.info "PgInsights: Falling back to synchronous execution for #{check_type}"
|
56
|
+
execute_and_cache_check(check_type, limit)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.execute_all_checks_synchronously
|
62
|
+
HealthCheckResult::VALID_CHECK_TYPES.each do |check_type|
|
63
|
+
execute_and_cache_check(check_type)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.execute_and_cache_check(check_type, limit = 10)
|
68
|
+
return unless HealthCheckResult::VALID_CHECK_TYPES.include?(check_type)
|
69
|
+
|
70
|
+
result = HealthCheckResult.create!(
|
71
|
+
check_type: check_type,
|
72
|
+
status: "running",
|
73
|
+
executed_at: Time.current
|
74
|
+
)
|
75
|
+
|
76
|
+
start_time = Time.current
|
77
|
+
|
78
|
+
begin
|
79
|
+
data = execute_health_check_query(check_type, limit)
|
80
|
+
execution_time = ((Time.current - start_time) * 1000).to_i
|
81
|
+
|
82
|
+
result.update!(
|
83
|
+
status: "success",
|
84
|
+
result_data: data,
|
85
|
+
execution_time_ms: execution_time
|
86
|
+
)
|
87
|
+
|
88
|
+
data
|
89
|
+
rescue => e
|
90
|
+
execution_time = ((Time.current - start_time) * 1000).to_i
|
91
|
+
|
92
|
+
result.update!(
|
93
|
+
status: "error",
|
94
|
+
error_message: e.message,
|
95
|
+
execution_time_ms: execution_time
|
96
|
+
)
|
97
|
+
|
98
|
+
Rails.logger.error "PgInsights: Health check failed for #{check_type}: #{e.message}"
|
99
|
+
{ error: e.message }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.execute_unused_indexes_query(limit)
|
104
|
+
execute_health_check_query("unused_indexes", limit)
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.execute_missing_indexes_query(limit)
|
108
|
+
execute_health_check_query("missing_indexes", limit)
|
109
|
+
end
|
110
|
+
|
111
|
+
def self.execute_sequential_scans_query(limit)
|
112
|
+
execute_health_check_query("sequential_scans", limit)
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.execute_slow_queries_query(limit)
|
116
|
+
execute_health_check_query("slow_queries", limit)
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.execute_table_bloat_query(limit)
|
120
|
+
execute_health_check_query("table_bloat", limit)
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.execute_parameter_settings_query
|
124
|
+
execute_health_check_query("parameter_settings")
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.execute_health_check_query(check_type, limit = 10)
|
128
|
+
case check_type
|
129
|
+
when "unused_indexes"
|
130
|
+
execute_unused_indexes_sql(limit)
|
131
|
+
when "missing_indexes"
|
132
|
+
execute_missing_indexes_sql(limit)
|
133
|
+
when "sequential_scans"
|
134
|
+
execute_sequential_scans_sql(limit)
|
135
|
+
when "slow_queries"
|
136
|
+
execute_slow_queries_sql(limit)
|
137
|
+
when "table_bloat"
|
138
|
+
execute_table_bloat_sql(limit)
|
139
|
+
when "parameter_settings"
|
140
|
+
execute_parameter_settings_sql
|
141
|
+
else
|
142
|
+
raise ArgumentError, "Unknown check type: #{check_type}"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
private
|
147
|
+
|
148
|
+
def self.execute_unused_indexes_sql(limit)
|
149
|
+
sql = <<-SQL
|
150
|
+
SELECT
|
151
|
+
schemaname || '.' || relname AS table,
|
152
|
+
indexrelname AS index,
|
153
|
+
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
|
154
|
+
idx_scan AS index_scans
|
155
|
+
FROM pg_stat_all_indexes
|
156
|
+
WHERE schemaname = 'public'
|
157
|
+
AND idx_scan < 50
|
158
|
+
AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
|
159
|
+
ORDER BY pg_relation_size(indexrelid) DESC
|
160
|
+
LIMIT #{limit.to_i};
|
161
|
+
SQL
|
162
|
+
|
163
|
+
execute_query(sql)
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.execute_missing_indexes_sql(limit)
|
167
|
+
sql = <<-SQL
|
168
|
+
SELECT
|
169
|
+
schemaname || '.' || relname AS table,
|
170
|
+
seq_scan,
|
171
|
+
idx_scan,
|
172
|
+
pg_size_pretty(pg_relation_size(relid)) AS table_size
|
173
|
+
FROM pg_stat_all_tables
|
174
|
+
WHERE schemaname = 'public'
|
175
|
+
AND seq_scan > 1000
|
176
|
+
AND idx_scan = 0
|
177
|
+
ORDER BY seq_scan DESC
|
178
|
+
LIMIT #{limit.to_i};
|
179
|
+
SQL
|
180
|
+
|
181
|
+
execute_query(sql)
|
182
|
+
end
|
183
|
+
|
184
|
+
def self.execute_sequential_scans_sql(limit)
|
185
|
+
sql = <<-SQL
|
186
|
+
SELECT
|
187
|
+
schemaname || '.' || relname AS table,
|
188
|
+
seq_scan,
|
189
|
+
seq_tup_read,
|
190
|
+
pg_size_pretty(pg_relation_size(relid)) AS table_size
|
191
|
+
FROM pg_stat_all_tables
|
192
|
+
WHERE schemaname = 'public' AND seq_scan > 100
|
193
|
+
ORDER BY seq_scan DESC
|
194
|
+
LIMIT #{limit.to_i};
|
195
|
+
SQL
|
196
|
+
execute_query(sql)
|
197
|
+
end
|
198
|
+
|
199
|
+
def self.execute_slow_queries_sql(limit)
|
200
|
+
sql = <<-SQL
|
201
|
+
SELECT
|
202
|
+
query,
|
203
|
+
calls,
|
204
|
+
total_exec_time,
|
205
|
+
mean_exec_time,
|
206
|
+
rows
|
207
|
+
FROM pg_stat_statements
|
208
|
+
ORDER BY total_exec_time DESC
|
209
|
+
LIMIT #{limit.to_i};
|
210
|
+
SQL
|
211
|
+
execute_query(sql)
|
212
|
+
end
|
213
|
+
|
214
|
+
def self.execute_table_bloat_sql(limit)
|
215
|
+
sql = <<-SQL
|
216
|
+
SELECT#{' '}
|
217
|
+
schemaname || '.' || relname as table_name,
|
218
|
+
pg_size_pretty(pg_total_relation_size(relid)) as table_size,
|
219
|
+
n_dead_tup,
|
220
|
+
n_live_tup,
|
221
|
+
CASE#{' '}
|
222
|
+
WHEN (n_live_tup + n_dead_tup) > 0#{' '}
|
223
|
+
THEN round((n_dead_tup::float / (n_live_tup + n_dead_tup) * 100)::numeric, 2)
|
224
|
+
ELSE 0#{' '}
|
225
|
+
END as dead_tuple_pct,
|
226
|
+
pg_size_pretty(pg_total_relation_size(relid) / (1024*1024)) || ' MB' as table_mb_text,
|
227
|
+
(pg_total_relation_size(relid) / (1024*1024))::bigint as table_mb,
|
228
|
+
last_vacuum,
|
229
|
+
last_autovacuum,
|
230
|
+
last_analyze,
|
231
|
+
last_autoanalyze
|
232
|
+
FROM pg_stat_user_tables#{' '}
|
233
|
+
WHERE schemaname = 'public'
|
234
|
+
AND (n_live_tup + n_dead_tup) > 0
|
235
|
+
AND (
|
236
|
+
(n_dead_tup::float / (n_live_tup + n_dead_tup)) > 0.1
|
237
|
+
OR n_dead_tup > 1000
|
238
|
+
)
|
239
|
+
AND pg_total_relation_size(relid) > 1024*1024
|
240
|
+
ORDER BY dead_tuple_pct DESC, n_dead_tup DESC
|
241
|
+
LIMIT #{limit.to_i};
|
242
|
+
SQL
|
243
|
+
execute_query(sql)
|
244
|
+
end
|
245
|
+
|
246
|
+
def self.execute_parameter_settings_sql
|
247
|
+
settings_to_check = [
|
248
|
+
"shared_buffers", "work_mem", "effective_cache_size",
|
249
|
+
"max_connections", "maintenance_work_mem", "checkpoint_completion_target"
|
250
|
+
]
|
251
|
+
|
252
|
+
sql = <<-SQL
|
253
|
+
SELECT name, setting, unit, short_desc
|
254
|
+
FROM pg_settings
|
255
|
+
WHERE name IN (#{settings_to_check.map { |s| "'#{s}'" }.join(',')});
|
256
|
+
SQL
|
257
|
+
|
258
|
+
result = execute_query(sql)
|
259
|
+
return result if result.is_a?(Hash) && result[:error]
|
260
|
+
|
261
|
+
# todo basic recommendations (can be improved with system info)
|
262
|
+
recommendations = {
|
263
|
+
"shared_buffers" => "Recommended: 25% of total system RAM.",
|
264
|
+
"work_mem" => "Recommended: Based on RAM, connections, and query complexity. Default is often too low.",
|
265
|
+
"effective_cache_size" => "Recommended: 75% of total system RAM.",
|
266
|
+
"max_connections" => "Varies. Check your connection pooler settings.",
|
267
|
+
"maintenance_work_mem" => "Recommended: 10% of RAM (up to 2GB).",
|
268
|
+
"checkpoint_completion_target" => "Recommended: 0.9"
|
269
|
+
}
|
270
|
+
|
271
|
+
result.map do |setting|
|
272
|
+
setting.merge("recommendation" => recommendations[setting["name"]])
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
def self.get_cached_result(check_type)
|
277
|
+
return nil unless defined?(HealthCheckResult)
|
278
|
+
|
279
|
+
result = HealthCheckResult.latest_for_type(check_type)
|
280
|
+
return nil unless result&.fresh?
|
281
|
+
|
282
|
+
result.result_data
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.execute_query(sql)
|
286
|
+
timeout = PgInsights.health_check_timeout
|
287
|
+
|
288
|
+
Timeout.timeout(timeout) do
|
289
|
+
ActiveRecord::Base.connection.exec_query(sql)
|
290
|
+
end
|
291
|
+
rescue Timeout::Error => e
|
292
|
+
Rails.logger.error "PgInsights: Query timeout (#{timeout}s) - #{e.message}"
|
293
|
+
{ error: "Query timeout after #{timeout} seconds" }
|
294
|
+
rescue ActiveRecord::StatementInvalid, PG::Error => e
|
295
|
+
{ error: e.message }
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module PgInsights
|
2
|
+
class InsightQueryService
|
3
|
+
def self.all
|
4
|
+
@all_queries ||= new.load_queries
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.find(id)
|
8
|
+
all.find { |q| q[:id] == id }
|
9
|
+
end
|
10
|
+
|
11
|
+
def load_queries
|
12
|
+
file_path = PgInsights::Engine.root.join("config", "default_queries.yml")
|
13
|
+
return [] unless File.exist?(file_path)
|
14
|
+
|
15
|
+
YAML.safe_load(File.read(file_path), symbolize_names: true)
|
16
|
+
rescue Psych::SyntaxError => e
|
17
|
+
Rails.logger.error "[PgInsights] Failed to load default_queries.yml: #{e.message}"
|
18
|
+
[]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Pg Insights</title>
|
5
|
+
<meta charset="utf-8" />
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
7
|
+
<meta name="csrf-token" content="<%= form_authenticity_token %>">
|
8
|
+
<%#= favicon_link_tag "favicon.png" %>
|
9
|
+
<%= stylesheet_link_tag "pg_insights/application", media: "all" %>
|
10
|
+
<%= javascript_include_tag "pg_insights/application", nonce: true %>
|
11
|
+
|
12
|
+
</head>
|
13
|
+
<body>
|
14
|
+
<div class="insights-layout">
|
15
|
+
<!-- Header Bar -->
|
16
|
+
<header class="top-header">
|
17
|
+
<div class="header-content">
|
18
|
+
<div class="header-left">
|
19
|
+
<h1 class="header-title">
|
20
|
+
<span class="icon">⚡</span>
|
21
|
+
Database Insights
|
22
|
+
</h1>
|
23
|
+
<span class="header-subtitle">SQL Query Runner</span>
|
24
|
+
</div>
|
25
|
+
<div class="header-right">
|
26
|
+
<nav class="header-nav">
|
27
|
+
<%= link_to "Health Dashboard", pg_insights.health_path, class: "nav-link #{'active' if current_page?(pg_insights.health_path)}" %>
|
28
|
+
<%= link_to "Query Runner", pg_insights.root_path, class: "nav-link #{'active' if current_page?(pg_insights.root_path)}" %>
|
29
|
+
</nav>
|
30
|
+
</div>
|
31
|
+
</div>
|
32
|
+
</header>
|
33
|
+
|
34
|
+
<!-- Flash Messages -->
|
35
|
+
<% if alert || notice %>
|
36
|
+
<div class="flash-container">
|
37
|
+
<% if alert %>
|
38
|
+
<div class="flash flash-error">
|
39
|
+
<span class="flash-icon">⚠️</span>
|
40
|
+
<%= alert %>
|
41
|
+
</div>
|
42
|
+
<% elsif notice %>
|
43
|
+
<div class="flash flash-success">
|
44
|
+
<span class="flash-icon">✅</span>
|
45
|
+
<%= notice %>
|
46
|
+
</div>
|
47
|
+
<% end %>
|
48
|
+
</div>
|
49
|
+
<% end %>
|
50
|
+
|
51
|
+
<!-- Main Content -->
|
52
|
+
<main class="main-content">
|
53
|
+
<%= yield %>
|
54
|
+
</main>
|
55
|
+
</div>
|
56
|
+
|
57
|
+
</body>
|
58
|
+
</html>
|