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,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>