pg_peek 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 +217 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/pg_peek/application.js +147 -0
- data/app/assets/stylesheets/pg_peek/application.css +362 -0
- data/app/controllers/pg_peek/activity_controller.rb +16 -0
- data/app/controllers/pg_peek/application_controller.rb +36 -0
- data/app/controllers/pg_peek/databases_controller.rb +96 -0
- data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
- data/app/controllers/pg_peek/indexes_controller.rb +7 -0
- data/app/controllers/pg_peek/jobs_controller.rb +44 -0
- data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
- data/app/controllers/pg_peek/queries_controller.rb +13 -0
- data/app/controllers/pg_peek/tables_controller.rb +6 -0
- data/app/helpers/pg_peek/application_helper.rb +179 -0
- data/app/helpers/pg_peek/report_helper.rb +58 -0
- data/app/jobs/pg_peek/application_job.rb +4 -0
- data/app/mailers/pg_peek/application_mailer.rb +6 -0
- data/app/models/pg_peek/application_record.rb +3 -0
- data/app/models/pg_peek/database.rb +131 -0
- data/app/models/pg_peek/pg_stat_statements.rb +132 -0
- data/app/models/pg_peek/sql_comment.rb +25 -0
- data/app/models/pg_peek/table.rb +266 -0
- data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
- data/app/reports/pg_peek/column.rb +27 -0
- data/app/reports/pg_peek/report.rb +63 -0
- data/app/reports/pg_peek/reports/blocked.rb +60 -0
- data/app/reports/pg_peek/reports/databases.rb +47 -0
- data/app/reports/pg_peek/reports/endpoints.rb +35 -0
- data/app/reports/pg_peek/reports/indexes.rb +44 -0
- data/app/reports/pg_peek/reports/job_queries.rb +14 -0
- data/app/reports/pg_peek/reports/jobs.rb +21 -0
- data/app/reports/pg_peek/reports/queries.rb +23 -0
- data/app/reports/pg_peek/reports/sessions.rb +88 -0
- data/app/reports/pg_peek/reports/tables.rb +25 -0
- data/app/views/layouts/pg_peek/application.html.erb +56 -0
- data/app/views/pg_peek/activity/show.html.erb +70 -0
- data/app/views/pg_peek/authentication_required.html.erb +31 -0
- data/app/views/pg_peek/databases/index.html.erb +43 -0
- data/app/views/pg_peek/databases/show.html.erb +169 -0
- data/app/views/pg_peek/endpoints/index.html.erb +24 -0
- data/app/views/pg_peek/indexes/index.html.erb +31 -0
- data/app/views/pg_peek/jobs/index.html.erb +51 -0
- data/app/views/pg_peek/jobs/show.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
- data/app/views/pg_peek/queries/index.html.erb +10 -0
- data/app/views/pg_peek/reports/_report.html.erb +32 -0
- data/app/views/pg_peek/tables/show.html.erb +128 -0
- data/config/routes.rb +16 -0
- data/lib/generators/pg_peek/install/install_generator.rb +40 -0
- data/lib/generators/pg_peek/install/templates/README +31 -0
- data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
- data/lib/pg_peek/configuration.rb +59 -0
- data/lib/pg_peek/engine.rb +7 -0
- data/lib/pg_peek/query_loader.rb +59 -0
- data/lib/pg_peek/version.rb +3 -0
- data/lib/pg_peek.rb +10 -0
- data/lib/tasks/pg_peek_tasks.rake +4 -0
- metadata +192 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
module PgPeek::ApplicationHelper
|
|
2
|
+
# Where the current section lives in another database. Detail pages fall back
|
|
3
|
+
# to their section, since the other database may not have that table or job.
|
|
4
|
+
def switch_database_path(database)
|
|
5
|
+
case controller_name
|
|
6
|
+
when "queries" then database_queries_path(database)
|
|
7
|
+
when "endpoints" then database_endpoints_path(database)
|
|
8
|
+
when "jobs" then database_jobs_path(database)
|
|
9
|
+
when "indexes" then database_indexes_path(database)
|
|
10
|
+
when "activity" then database_activity_path(database)
|
|
11
|
+
else database_path(database)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# example sql comment: /*action='show',application='Trmz',namespaced_controller='staff%2Fprocesses%2Fprocesses'*/
|
|
16
|
+
|
|
17
|
+
SQLCOMMENTER_PATTERN = %r{
|
|
18
|
+
\s* # optional leading whitespace
|
|
19
|
+
/\* # opening /*
|
|
20
|
+
(?: # one or more key:value pairs
|
|
21
|
+
[a-zA-Z_][a-zA-Z0-9_]* # key (starts with letter/underscore)
|
|
22
|
+
= # colon
|
|
23
|
+
[^:*]* # value (anything except * or : to avoid breaking */
|
|
24
|
+
\s*)+
|
|
25
|
+
\*/ # closing */
|
|
26
|
+
\s* # optional trailing whitespace
|
|
27
|
+
}xi
|
|
28
|
+
|
|
29
|
+
def strip_sqlcommenter(sql)
|
|
30
|
+
sql.gsub(SQLCOMMENTER_PATTERN, "").strip
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Where a query's SQLcommenter tags point, if anywhere: a job's own page,
|
|
34
|
+
# or the endpoints list for a controller/action (there is no per-endpoint
|
|
35
|
+
# page to link into yet, so this lands on the list rather than one row of it).
|
|
36
|
+
def sqlcommenter_href(database, tags)
|
|
37
|
+
return database_job_path(database, tags["job"]) if tags["job"].present?
|
|
38
|
+
|
|
39
|
+
controller = tags["namespaced_controller"] || tags["controller"]
|
|
40
|
+
return database_endpoints_path(database) if controller.present?
|
|
41
|
+
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Format a rate with auto-scaled units based on magnitude
|
|
46
|
+
def format_rate(count, seconds_elapsed)
|
|
47
|
+
return "N/A" if seconds_elapsed.nil? || seconds_elapsed.zero? || count.nil?
|
|
48
|
+
|
|
49
|
+
per_second = count.to_f / seconds_elapsed
|
|
50
|
+
|
|
51
|
+
formatted = case per_second
|
|
52
|
+
when 0...0.017 # < 1/min
|
|
53
|
+
"#{format_number((per_second * 86400).round(1))}/day"
|
|
54
|
+
when 0.017...1 # < 1/sec
|
|
55
|
+
"#{format_number((per_second * 3600).round(1))}/hr"
|
|
56
|
+
when 1...60 # < 60/sec
|
|
57
|
+
"#{format_number((per_second * 60).round(1))}/min"
|
|
58
|
+
else
|
|
59
|
+
"#{format_number(per_second.round(1))}/s"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
"(~#{formatted})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Format a count with its rate
|
|
66
|
+
def format_count_with_rate(count, seconds_elapsed)
|
|
67
|
+
return "N/A" if count.nil?
|
|
68
|
+
|
|
69
|
+
formatted_count = format_number(count)
|
|
70
|
+
rate = format_rate(count, seconds_elapsed)
|
|
71
|
+
|
|
72
|
+
if rate == "N/A"
|
|
73
|
+
formatted_count
|
|
74
|
+
else
|
|
75
|
+
"#{formatted_count} #{rate}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Format a number with thousands separators
|
|
80
|
+
def format_number(number)
|
|
81
|
+
return "N/A" if number.nil?
|
|
82
|
+
number_with_delimiter(number)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Format a percentage, returning "N/A" for nil
|
|
86
|
+
def format_percentage(value)
|
|
87
|
+
return "N/A" if value.nil?
|
|
88
|
+
"#{value}%"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Format a timestamp as relative time (e.g., "3 hours ago")
|
|
92
|
+
def format_time_ago(timestamp)
|
|
93
|
+
return "Never" if timestamp.nil?
|
|
94
|
+
time_ago_in_words(timestamp) + " ago"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Every pg_stat_statements figure is cumulative since the last reset. Each
|
|
98
|
+
# page says so in its header and links to the overview block that resets it.
|
|
99
|
+
def stats_since_link(database, reset_at)
|
|
100
|
+
link_to "stats since #{format_time_ago(reset_at)}",
|
|
101
|
+
database_path(database, anchor: "pg_stat_statements"),
|
|
102
|
+
title: "figures are cumulative since the last pg_stat_statements reset"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Format cache hit ratio for inline display
|
|
106
|
+
def format_inline_cache_stats(heap_ratio, index_ratio)
|
|
107
|
+
heap_display = heap_ratio.nil? ? "-" : "#{heap_ratio}%"
|
|
108
|
+
index_display = index_ratio.nil? ? "-" : "#{index_ratio}%"
|
|
109
|
+
"H: #{heap_display} I: #{index_display}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Horizontal bar meter for call intensity visualization
|
|
113
|
+
# Returns a 5-segment bar: ██░░░ (low) → █████ (high)
|
|
114
|
+
INTENSITY_SEGMENTS = 5
|
|
115
|
+
|
|
116
|
+
# One character throughout, with colour rather than shading separating the
|
|
117
|
+
# filled part from the rest: mixed block glyphs vary in weight between fonts
|
|
118
|
+
# and read as noise at this size.
|
|
119
|
+
def intensity_bar(value, max_value)
|
|
120
|
+
filled = intensity_segments(value, max_value)
|
|
121
|
+
|
|
122
|
+
# The level class lets the stylesheet brighten the bar with its value.
|
|
123
|
+
tag.span(class: "bar bar-#{filled}") do
|
|
124
|
+
tag.span("█" * filled, class: "bar-on") +
|
|
125
|
+
tag.span("█" * (INTENSITY_SEGMENTS - filled), class: "bar-off")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A non-zero value always keeps one segment lit, so "rare but present" is
|
|
130
|
+
# distinguishable from "absent" at a glance.
|
|
131
|
+
def intensity_segments(value, max_value)
|
|
132
|
+
return 0 if value.nil? || max_value.nil? || max_value.zero?
|
|
133
|
+
|
|
134
|
+
((value.to_f / max_value) * INTENSITY_SEGMENTS).ceil.clamp(0, INTENSITY_SEGMENTS)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Durations pick their unit by magnitude: 210ms, 1.20s, 2m 05s, 1h 12m.
|
|
138
|
+
# Values stay narrow and read at a glance, and a header sits over data of
|
|
139
|
+
# similar width. The exact figure belongs in a title, not the cell.
|
|
140
|
+
def format_duration_from_ms(total_ms)
|
|
141
|
+
ms = total_ms.to_f
|
|
142
|
+
return "0ms" if ms <= 0
|
|
143
|
+
return format("%.1fms", ms) if ms < 10
|
|
144
|
+
return "#{ms.round}ms" if ms < 1000
|
|
145
|
+
|
|
146
|
+
seconds = ms / 1000.0
|
|
147
|
+
return format("%.2fs", seconds) if seconds < 60
|
|
148
|
+
|
|
149
|
+
minutes, secs = seconds.divmod(60)
|
|
150
|
+
return format("%dm %02ds", minutes, secs) if minutes < 60
|
|
151
|
+
|
|
152
|
+
hours, mins = minutes.divmod(60)
|
|
153
|
+
format("%dh %02dm", hours, mins)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Rails sets intervalstyle = iso_8601 on its connections, so an interval
|
|
157
|
+
# arrives as "PT1.204S". Anything else -- a raw psql connection, or a host
|
|
158
|
+
# that overrides the style -- renders as "HH:MM:SS.fff" with a "N days"
|
|
159
|
+
# prefix once long enough. Both are read.
|
|
160
|
+
def format_duration(interval_text)
|
|
161
|
+
format_duration_from_ms(interval_to_ms(interval_text))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def interval_to_ms(interval_text)
|
|
165
|
+
text = interval_text.to_s.strip
|
|
166
|
+
return 0.0 if text.empty?
|
|
167
|
+
return ActiveSupport::Duration.parse(text).to_f * 1000.0 if text.start_with?("P")
|
|
168
|
+
|
|
169
|
+
days = text[/(\d+)\s+days?/, 1].to_i
|
|
170
|
+
hours = minutes = seconds = 0.0
|
|
171
|
+
if (match = text.match(/(\d+):(\d{2}):(\d{2}(?:\.\d+)?)/))
|
|
172
|
+
hours, minutes, seconds = match[1].to_i, match[2].to_i, match[3].to_f
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
(days * 86_400 + hours * 3600 + minutes * 60 + seconds) * 1000.0
|
|
176
|
+
rescue ActiveSupport::Duration::ISO8601Parser::ParsingError
|
|
177
|
+
0.0
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Turns a report cell into markup. The report is passed alongside the column
|
|
2
|
+
# because some formats are relative -- an intensity bar means nothing without
|
|
3
|
+
# the largest value in its column.
|
|
4
|
+
module PgPeek::ReportHelper
|
|
5
|
+
def format_cell(report, column, row)
|
|
6
|
+
value = row.value(column)
|
|
7
|
+
return "" if value.nil? || value.to_s.empty?
|
|
8
|
+
|
|
9
|
+
case column.format
|
|
10
|
+
when :number then format_number(value.to_i)
|
|
11
|
+
when :duration_ms then duration_cell(value.to_f)
|
|
12
|
+
when :duration then duration_cell(interval_to_ms(value))
|
|
13
|
+
when :percent then "#{value}%"
|
|
14
|
+
when :ratio then "~#{value}"
|
|
15
|
+
when :sql then sql_cell(report, value.to_s)
|
|
16
|
+
when :intensity then intensity_bar(value.to_f, report.max_for(column))
|
|
17
|
+
else value.to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The compact figure is what you scan; the exact one is a hover away.
|
|
22
|
+
def duration_cell(ms)
|
|
23
|
+
tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(1))} ms")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The SQL text, plus -- when SQLcommenter tagged it -- what issued it (a
|
|
27
|
+
# link into the page that already aggregates this exact query across every
|
|
28
|
+
# call site) and every raw tag behind a disclosure, for the rest.
|
|
29
|
+
def sql_cell(report, sql)
|
|
30
|
+
code = tag.code(strip_sqlcommenter(sql).squish, class: "sql")
|
|
31
|
+
tags = PgPeek::SqlComment.tags(sql)
|
|
32
|
+
return code if tags.empty?
|
|
33
|
+
|
|
34
|
+
safe_join([ code, sql_source(report, tags), sql_tags(tags) ].compact)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def sql_source(report, tags)
|
|
38
|
+
label = PgPeek::SqlComment.label(tags)
|
|
39
|
+
return nil unless label
|
|
40
|
+
|
|
41
|
+
href = report.database && sqlcommenter_href(report.database, tags)
|
|
42
|
+
tag.div(safe_join([ "→ ", href ? link_to(label, href) : label ]), class: "dim")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sql_tags(tags)
|
|
46
|
+
tag.details(safe_join([
|
|
47
|
+
tag.summary("tags"),
|
|
48
|
+
tag.pre(tags.map { |key, value| "#{key}: #{value}" }.join("\n"))
|
|
49
|
+
]), class: "tags")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cell_class(column)
|
|
53
|
+
case column.format
|
|
54
|
+
when :intensity then "intensity"
|
|
55
|
+
else column.numeric? ? "num" : nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
class PgPeek::Database
|
|
2
|
+
attr_reader :name, :primary, :replica, :schema_format
|
|
3
|
+
|
|
4
|
+
def self.available_postgres_databases
|
|
5
|
+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).select do |db_config|
|
|
6
|
+
db_config.adapter == "postgresql"
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.all
|
|
11
|
+
available_postgres_databases.map do |db_config|
|
|
12
|
+
PgPeek::Database.new(
|
|
13
|
+
name: db_config.name,
|
|
14
|
+
primary: db_config.primary?,
|
|
15
|
+
replica: db_config.replica?,
|
|
16
|
+
schema_format: db_config.schema_format
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.find(name)
|
|
22
|
+
all.find { |db| db.name == name }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(name:, primary: false, replica: false, schema_format:)
|
|
26
|
+
@name = name
|
|
27
|
+
@primary = primary
|
|
28
|
+
@replica = replica
|
|
29
|
+
@schema_format = schema_format
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_param
|
|
33
|
+
@name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The other configured databases on the same server, judged by host and
|
|
37
|
+
# port. Production tends to give each database its own server; review apps
|
|
38
|
+
# put them all on one, where a server-wide action such as a
|
|
39
|
+
# pg_stat_statements reset reaches every one of them.
|
|
40
|
+
def server_peers
|
|
41
|
+
self.class.all.reject { |other| other.name == name }.select { |other| other.server == server }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def server
|
|
45
|
+
config = self.class.available_postgres_databases.find { |db_config| db_config.name == name }
|
|
46
|
+
return unless config
|
|
47
|
+
|
|
48
|
+
[ config.host.presence || "localhost", config.configuration_hash[:port].presence || 5432 ].map(&:to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def connection
|
|
52
|
+
connection_class = PgPeek.config.connection_class_for(name)
|
|
53
|
+
connection_class&.connection
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Cheap enough for the layout to ask on every request: reads the config, does
|
|
57
|
+
# not resolve the class or check out a connection.
|
|
58
|
+
def configured?
|
|
59
|
+
PgPeek.config.connections.key?(name)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connection_configured?
|
|
63
|
+
connection.present?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def version_full
|
|
67
|
+
@version_full ||= connection.execute(PgPeek::QueryLoader.mark("SELECT version()")).first["version"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def version
|
|
71
|
+
@version ||= version_full.match(/PostgreSQL ([\d.]+)/)[1]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def major_version
|
|
75
|
+
@major_version ||= version.split(".").first.to_i
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def installed_extensions
|
|
79
|
+
connection.execute(PgPeek::QueryLoader.mark("SELECT name, default_version, installed_version FROM pg_available_extensions WHERE installed_version IS NOT NULL ORDER BY name")).to_a
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def vitals
|
|
83
|
+
@vitals ||= begin
|
|
84
|
+
query = PgPeek::QueryLoader.load("database/vitals", pg_version: major_version)
|
|
85
|
+
connection.execute(query).first || {}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def indexes
|
|
90
|
+
query = PgPeek::QueryLoader.load("indexes/all", pg_version: major_version)
|
|
91
|
+
connection.execute(query).to_a
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def sessions
|
|
95
|
+
query = PgPeek::QueryLoader.load("activity/sessions", pg_version: major_version)
|
|
96
|
+
connection.execute(query).to_a
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def waiting_locks
|
|
100
|
+
query = PgPeek::QueryLoader.load("activity/waiting_locks", pg_version: major_version)
|
|
101
|
+
connection.execute(query).to_a
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def connection_summary
|
|
105
|
+
query = PgPeek::QueryLoader.load("activity/summary", pg_version: major_version)
|
|
106
|
+
connection.execute(query).first || {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stats_reset_at
|
|
110
|
+
value = vitals["stats_reset"]
|
|
111
|
+
Time.zone.parse(value.to_s) if value.present?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def tables_with_dead_tuples(threshold)
|
|
115
|
+
query = PgPeek::QueryLoader.load("tables/dead_tuples", pg_version: major_version, threshold: threshold.to_i)
|
|
116
|
+
connection.execute(query).to_a
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def tables
|
|
120
|
+
connection.tables.sort - PgPeek.config.excluded_tables
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def table_exists?(table_name)
|
|
124
|
+
connection.tables.include?(table_name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def find_table(name)
|
|
128
|
+
return nil unless table_exists?(name)
|
|
129
|
+
PgPeek::Table.new(self, name)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
class PgPeek::PgStatStatements
|
|
2
|
+
LIBRARY_NAME = "pg_stat_statements".freeze
|
|
3
|
+
|
|
4
|
+
attr_reader :database, :default_version, :installed_version
|
|
5
|
+
|
|
6
|
+
def initialize(database:)
|
|
7
|
+
@database = database
|
|
8
|
+
|
|
9
|
+
verify_installation
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def connection
|
|
13
|
+
database.connection
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
default_version.present?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def installed?
|
|
21
|
+
installed_version.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The extension can be created in the database while the module itself was
|
|
25
|
+
# never loaded at server start. Querying the view then raises
|
|
26
|
+
# PG::ObjectNotInPrerequisiteState, so every read has to check this first.
|
|
27
|
+
def preloaded?
|
|
28
|
+
shared_preload_libraries.split(",").map(&:strip).include?(LIBRARY_NAME)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def usable?
|
|
32
|
+
installed? && preloaded?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def shared_preload_libraries
|
|
36
|
+
@shared_preload_libraries ||= connection.select_value(PgPeek::QueryLoader.mark("SHOW shared_preload_libraries")).to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def outdated?
|
|
40
|
+
return false unless installed?
|
|
41
|
+
|
|
42
|
+
@installed_version != @default_version
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Server-wide on purpose. pg_stat_statements_info keeps a single stats_reset
|
|
46
|
+
# for the whole server and only moves it when every entry is removed, so a
|
|
47
|
+
# reset scoped to this database would leave "stats since" pointing at the
|
|
48
|
+
# old time.
|
|
49
|
+
def reset!
|
|
50
|
+
return unless usable?
|
|
51
|
+
|
|
52
|
+
connection.execute PgPeek::QueryLoader.mark(<<-SQL)
|
|
53
|
+
SELECT pg_stat_statements_reset();
|
|
54
|
+
SQL
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_at
|
|
58
|
+
return unless usable?
|
|
59
|
+
|
|
60
|
+
value = connection.select_value PgPeek::QueryLoader.mark(<<-SQL)
|
|
61
|
+
SELECT stats_reset FROM pg_stat_statements_info;
|
|
62
|
+
SQL
|
|
63
|
+
|
|
64
|
+
Time.zone.parse(value.to_s) if value.present?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def outliers
|
|
68
|
+
return unless usable?
|
|
69
|
+
|
|
70
|
+
query = PgPeek::QueryLoader.load("pg_stat_statements/outliers",
|
|
71
|
+
pg_version: database.major_version,
|
|
72
|
+
limit: PgPeek.config.outliers_limit)
|
|
73
|
+
result = connection.execute(query)
|
|
74
|
+
result.to_a
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def statement_count
|
|
78
|
+
return unless usable?
|
|
79
|
+
|
|
80
|
+
connection.select_value(PgPeek::QueryLoader.mark(<<~SQL)).to_i
|
|
81
|
+
SELECT count(*) FROM pg_stat_statements
|
|
82
|
+
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
83
|
+
SQL
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def endpoints
|
|
87
|
+
return unless usable?
|
|
88
|
+
|
|
89
|
+
query = PgPeek::QueryLoader.load("pg_stat_statements/endpoints",
|
|
90
|
+
pg_version: database.major_version,
|
|
91
|
+
limit: PgPeek.config.outliers_limit)
|
|
92
|
+
connection.execute(query).to_a
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def jobs
|
|
96
|
+
return unless usable?
|
|
97
|
+
|
|
98
|
+
query = PgPeek::QueryLoader.load("pg_stat_statements/jobs",
|
|
99
|
+
pg_version: database.major_version)
|
|
100
|
+
result = connection.execute(query)
|
|
101
|
+
result.to_a
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def outliers_by_job(job_class)
|
|
105
|
+
return unless usable?
|
|
106
|
+
|
|
107
|
+
# Build pattern to match job='JobClassName' in SQLcommenter
|
|
108
|
+
# Need to escape for SQL LIKE and quote properly
|
|
109
|
+
escaped_job = job_class.gsub("'", "''")
|
|
110
|
+
pattern = "%job='#{escaped_job}'%"
|
|
111
|
+
|
|
112
|
+
query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_job",
|
|
113
|
+
pg_version: database.major_version,
|
|
114
|
+
job_pattern: connection.quote(pattern),
|
|
115
|
+
limit: PgPeek.config.outliers_limit)
|
|
116
|
+
result = connection.execute(query)
|
|
117
|
+
result.to_a
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def verify_installation
|
|
123
|
+
result = connection.execute PgPeek::QueryLoader.mark(<<-SQL)
|
|
124
|
+
SELECT default_version, installed_version FROM pg_available_extensions WHERE name = 'pg_stat_statements';
|
|
125
|
+
SQL
|
|
126
|
+
|
|
127
|
+
if result.any?
|
|
128
|
+
@default_version = result.first["default_version"]
|
|
129
|
+
@installed_version = result.first["installed_version"]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Parses the trailing SQLcommenter comment Rails' query_log_tags appends to a
|
|
2
|
+
# query -- e.g. /*action='index',application='Dummy',controller='posts'*/ --
|
|
3
|
+
# and reduces it to the one thing worth showing at a glance: what issued this
|
|
4
|
+
# query. Used by both the Sessions report and the view layer, so there is one
|
|
5
|
+
# parser rather than the two that used to disagree on how tags are shaped.
|
|
6
|
+
class PgPeek::SqlComment
|
|
7
|
+
TAG = /(\w+)='([^']*)'/
|
|
8
|
+
|
|
9
|
+
def self.tags(query)
|
|
10
|
+
comment = query.to_s[%r{/\*([^*]*=[^*]*)\*/}, 1] or return {}
|
|
11
|
+
comment.scan(TAG).to_h { |key, value| [ key, CGI.unescape(value) ] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A job always names itself. Otherwise, the endpoint that issued this, if
|
|
15
|
+
# SQLcommenter tagged it. Otherwise nil -- callers fall back to whatever
|
|
16
|
+
# else identifies the source (an application_name, or nothing).
|
|
17
|
+
def self.label(tags)
|
|
18
|
+
return tags["job"] if tags["job"].present?
|
|
19
|
+
|
|
20
|
+
controller = tags["namespaced_controller"] || tags["controller"]
|
|
21
|
+
return nil if controller.blank?
|
|
22
|
+
|
|
23
|
+
[ controller, tags["action"] ].compact_blank.join("#")
|
|
24
|
+
end
|
|
25
|
+
end
|