pg_peek 0.1.1 → 0.2.1
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 +4 -4
- data/app/assets/javascripts/pg_peek/application.js +25 -1
- data/app/assets/stylesheets/pg_peek/application.css +48 -2
- data/app/controllers/pg_peek/databases_controller.rb +12 -10
- data/app/controllers/pg_peek/endpoints_controller.rb +15 -3
- data/app/controllers/pg_peek/jobs_controller.rb +1 -1
- data/app/controllers/pg_peek/pg_stat_statements_controller.rb +17 -6
- data/app/controllers/pg_peek/tables_controller.rb +11 -1
- data/app/helpers/pg_peek/application_helper.rb +27 -11
- data/app/helpers/pg_peek/report_helper.rb +32 -8
- data/app/models/pg_peek/pg_stat_statements.rb +51 -4
- data/app/models/pg_peek/sql_format.rb +74 -0
- data/app/models/pg_peek/table.rb +4 -3
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +1 -1
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_endpoint.sql +24 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +1 -1
- data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +7 -3
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +1 -1
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_endpoint.sql +24 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +1 -1
- data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +7 -3
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +1 -1
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_endpoint.sql +24 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +1 -1
- data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +7 -3
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +1 -1
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_endpoint.sql +24 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +1 -1
- data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +7 -3
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +1 -1
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_endpoint.sql +24 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +1 -1
- data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +7 -3
- data/app/reports/pg_peek/column.rb +6 -1
- data/app/reports/pg_peek/report.rb +1 -2
- data/app/reports/pg_peek/reports/endpoint_queries.rb +14 -0
- data/app/reports/pg_peek/reports/endpoints.rb +5 -8
- data/app/reports/pg_peek/reports/jobs.rb +7 -7
- data/app/reports/pg_peek/reports/queries.rb +2 -1
- data/app/views/layouts/pg_peek/application.html.erb +1 -0
- data/app/views/pg_peek/databases/show.html.erb +8 -60
- data/app/views/pg_peek/endpoints/index.html.erb +14 -1
- data/app/views/pg_peek/endpoints/show.html.erb +18 -0
- data/app/views/pg_peek/jobs/index.html.erb +1 -8
- data/app/views/pg_peek/jobs/show.html.erb +2 -1
- data/app/views/pg_peek/pg_stat_statements/show.html.erb +87 -0
- data/app/views/pg_peek/tables/index.html.erb +18 -0
- data/app/views/pg_peek/tables/show.html.erb +2 -2
- data/config/routes.rb +5 -2
- data/lib/generators/pg_peek/install/templates/initializer.rb +9 -0
- data/lib/pg_peek/configuration.rb +8 -1
- data/lib/pg_peek/version.rb +1 -1
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: abc82aa27c9e7fcebbe27a38cc17ff102ae578afdd11d0be75908874ee5058e8
|
|
4
|
+
data.tar.gz: 7bc328de015861d67c0250ad0373c4639fe1a45c15426c084939d56409b74d5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 72349c589551ec7dd8b5875f1ccbd579a7bbeaba94588fd29251cec72f041f4610077963efdfdd47683f7741469f9ac0cc7f912453770698e5e68e346ea1a7a3
|
|
7
|
+
data.tar.gz: 8c336b095487533a4862016ee4ffd1d332f3dad19f2134a03716375df017809fa123b1cfa37c286a0d833efeffbc4104c126c2df5a50ae74dce5b7e9eb6ee5d3
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// pg_peek — no framework, no CDN, no import map.
|
|
1
|
+
// pg_peek — no framework, no CDN, no import map. A few small behaviours.
|
|
2
2
|
(function () {
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
@@ -112,6 +112,29 @@
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Long queries --------------------------------------------------------------
|
|
116
|
+
// Clamped to their first lines by CSS. Measured here, so only a query that
|
|
117
|
+
// is actually longer gets the toggle.
|
|
118
|
+
function initClamp() {
|
|
119
|
+
document.querySelectorAll("[data-clamp]").forEach(function (code) {
|
|
120
|
+
code.classList.add("clamped");
|
|
121
|
+
if (code.scrollHeight <= code.clientHeight) {
|
|
122
|
+
code.classList.remove("clamped");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var button = document.createElement("button");
|
|
127
|
+
button.type = "button";
|
|
128
|
+
button.className = "clamp-toggle";
|
|
129
|
+
button.textContent = "show all";
|
|
130
|
+
button.addEventListener("click", function () {
|
|
131
|
+
var expanded = code.classList.toggle("expanded");
|
|
132
|
+
button.textContent = expanded ? "show less" : "show all";
|
|
133
|
+
});
|
|
134
|
+
code.insertAdjacentElement("afterend", button);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
// Database switcher -------------------------------------------------------
|
|
116
139
|
function initSwitcher() {
|
|
117
140
|
document.querySelectorAll("[data-switcher]").forEach(function (select) {
|
|
@@ -135,6 +158,7 @@
|
|
|
135
158
|
initTheme();
|
|
136
159
|
initFilter();
|
|
137
160
|
initConfirm();
|
|
161
|
+
initClamp();
|
|
138
162
|
initSwitcher();
|
|
139
163
|
initReload();
|
|
140
164
|
}
|
|
@@ -149,6 +149,11 @@ pre {
|
|
|
149
149
|
|
|
150
150
|
pre code { background: none; border: 0; padding: 0; }
|
|
151
151
|
|
|
152
|
+
/* Names from the data (a job class, a table) keep their case inside the
|
|
153
|
+
lowercase headings, and read as the heading rather than as a code box. */
|
|
154
|
+
.name { text-transform: none; }
|
|
155
|
+
code.name { background: none; border: 0; padding: 0; }
|
|
156
|
+
|
|
152
157
|
/* Tables ------------------------------------------------------------------ */
|
|
153
158
|
|
|
154
159
|
.scroll { overflow-x: auto; }
|
|
@@ -194,7 +199,6 @@ tbody th + td { text-align: right; }
|
|
|
194
199
|
.nowrap { white-space: nowrap; }
|
|
195
200
|
.dim { color: var(--dim); }
|
|
196
201
|
.warn { color: var(--warn); }
|
|
197
|
-
.intensity { white-space: nowrap; }
|
|
198
202
|
.bar { letter-spacing: 0.08em; }
|
|
199
203
|
.bar-on { color: var(--accent); }
|
|
200
204
|
/* Brightness follows the value: a one-segment bar sits at 80%, five at full. */
|
|
@@ -204,7 +208,48 @@ tbody th + td { text-align: right; }
|
|
|
204
208
|
.bar-4 .bar-on { opacity: 0.95; }
|
|
205
209
|
.bar-5 .bar-on { opacity: 1; }
|
|
206
210
|
.bar-off { color: var(--line); }
|
|
207
|
-
|
|
211
|
+
/* An endpoint or job name takes the width the figures leave over, so a long
|
|
212
|
+
one is cut instead of pushing them out of view (max-width: 0 drops the name
|
|
213
|
+
from the table's width calculation). The dimmed namespace shrinks first and
|
|
214
|
+
is cut from the left, since the end of a name tells rows apart: rtl puts
|
|
215
|
+
the ellipsis on the left, the inner span keeps the text itself ltr so "::"
|
|
216
|
+
and "/" stay in place. A leaf wider than the cell is cut on the right. */
|
|
217
|
+
.name-column { width: 100%; max-width: 0; min-width: 12ch; }
|
|
218
|
+
.name-column > a { display: block; }
|
|
219
|
+
.qualified { display: flex; overflow: hidden; white-space: nowrap; }
|
|
220
|
+
.qualified .namespace {
|
|
221
|
+
min-width: 0;
|
|
222
|
+
overflow: hidden;
|
|
223
|
+
text-overflow: ellipsis;
|
|
224
|
+
direction: rtl;
|
|
225
|
+
opacity: 0.6;
|
|
226
|
+
}
|
|
227
|
+
.qualified .namespace > span { direction: ltr; unicode-bidi: embed; }
|
|
228
|
+
.qualified .leaf { flex-shrink: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
|
229
|
+
/* Formatted one clause per line (SqlFormat); a line longer than the cell
|
|
230
|
+
wraps at spaces and breaks inside a word only when nothing else fits. */
|
|
231
|
+
.sql { display: block; overflow-wrap: anywhere; white-space: pre-wrap; background: none; border: 0; padding: 0; }
|
|
232
|
+
|
|
233
|
+
/* A long query shows its first lines, with a toggle for the rest. The script
|
|
234
|
+
adds the class only where the query is longer, so short ones stay as they
|
|
235
|
+
are, and without it every query shows in full. */
|
|
236
|
+
.sql.clamped:not(.expanded) {
|
|
237
|
+
display: -webkit-box;
|
|
238
|
+
-webkit-box-orient: vertical;
|
|
239
|
+
-webkit-line-clamp: 3;
|
|
240
|
+
line-clamp: 3;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
button.clamp-toggle {
|
|
245
|
+
background: none;
|
|
246
|
+
border: 0;
|
|
247
|
+
padding: 0;
|
|
248
|
+
color: var(--dim);
|
|
249
|
+
font-size: inherit;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
button.clamp-toggle:hover { color: var(--fg); }
|
|
208
253
|
|
|
209
254
|
/* What issued a query, from its SQLcommenter tags: a link line under the SQL,
|
|
210
255
|
plus every raw tag behind a disclosure. The page-level details/summary
|
|
@@ -353,6 +398,7 @@ footer.status button:hover { color: var(--fg); }
|
|
|
353
398
|
|
|
354
399
|
.vitals { margin: 0 0 1rem; }
|
|
355
400
|
.vitals span + span::before { content: " · "; color: var(--line); }
|
|
401
|
+
a.provisional { color: color-mix(in srgb, var(--warn) 70%, var(--dim)); }
|
|
356
402
|
|
|
357
403
|
.panels {
|
|
358
404
|
display: grid;
|
|
@@ -21,7 +21,6 @@ class PgPeek::DatabasesController < PgPeek::ApplicationController
|
|
|
21
21
|
def show
|
|
22
22
|
@database = PgPeek::Database.find(params[:id]) or raise ActiveRecord::RecordNotFound, "Database not found"
|
|
23
23
|
@pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
|
|
24
|
-
@tables = PgPeek::Reports::Tables.new(database: @database)
|
|
25
24
|
@vitals = @database.vitals
|
|
26
25
|
@stats_reset_at = @pg_stat_statements.reset_at
|
|
27
26
|
@sessions = PgPeek::Reports::Sessions.new(database: @database)
|
|
@@ -54,10 +53,18 @@ class PgPeek::DatabasesController < PgPeek::ApplicationController
|
|
|
54
53
|
href: database_table_path(@database, table["relname"]) }
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
# Scan counts restart at zero on a statistics reset, so right after one
|
|
57
|
+
# every index looks unused. Small ones cost too little to act on; the
|
|
58
|
+
# indexes page still lists them all.
|
|
59
|
+
reset_at = @database.stats_reset_at
|
|
60
|
+
if reset_at.nil? || reset_at < PgPeek.config.unused_index_min_stats_age.ago
|
|
61
|
+
unused = PgPeek::Reports::Indexes.new(database: @database).unused
|
|
62
|
+
.select { |row| row["size_bytes"].to_i >= PgPeek.config.unused_index_min_size }
|
|
63
|
+
if unused.any?
|
|
64
|
+
bytes = unused.sum { |row| row["size_bytes"].to_i }
|
|
65
|
+
items << { text: "#{helpers.pluralize(unused.size, "unused index")} · #{helpers.number_to_human_size(bytes)}",
|
|
66
|
+
href: database_indexes_path(@database) }
|
|
67
|
+
end
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
activity = database_activity_path(@database)
|
|
@@ -86,11 +93,6 @@ class PgPeek::DatabasesController < PgPeek::ApplicationController
|
|
|
86
93
|
items << { text: "#{used} of #{max} connections in use", href: activity }
|
|
87
94
|
end
|
|
88
95
|
|
|
89
|
-
if @stats_reset_at && @stats_reset_at > 1.hour.ago
|
|
90
|
-
items << { text: "statistics were reset #{helpers.time_ago_in_words(@stats_reset_at)} ago -- figures may not be representative yet",
|
|
91
|
-
href: database_path(@database, anchor: "pg_stat_statements") }
|
|
92
|
-
end
|
|
93
|
-
|
|
94
96
|
items
|
|
95
97
|
end
|
|
96
98
|
end
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
class PgPeek::EndpointsController < PgPeek::ApplicationController
|
|
2
|
-
|
|
3
|
-
@database = PgPeek::Database.find(params[:database_id])
|
|
4
|
-
@pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
|
|
2
|
+
before_action :set_database
|
|
5
3
|
|
|
4
|
+
def index
|
|
6
5
|
return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
|
|
7
6
|
|
|
8
7
|
@report = PgPeek::Reports::Endpoints.new(database: @database)
|
|
9
8
|
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@endpoint = "#{params[:endpoint_controller]}##{params[:endpoint_action]}"
|
|
12
|
+
return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
|
|
13
|
+
|
|
14
|
+
@report = PgPeek::Reports::EndpointQueries.new(database: @database, endpoint: @endpoint)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
def set_database
|
|
19
|
+
@database = PgPeek::Database.find(params[:database_id])
|
|
20
|
+
@pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
|
|
21
|
+
end
|
|
10
22
|
end
|
|
@@ -13,7 +13,7 @@ class PgPeek::JobsController < PgPeek::ApplicationController
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def show
|
|
16
|
-
@job_class =
|
|
16
|
+
@job_class = params[:job_class]
|
|
17
17
|
return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
|
|
18
18
|
|
|
19
19
|
@report = PgPeek::Reports::JobQueries.new(database: @database, job_class: @job_class)
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
# Manages the extension itself. What it records is read by QueriesController,
|
|
2
2
|
# EndpointsController and JobsController.
|
|
3
3
|
class PgPeek::PgStatStatementsController < PgPeek::ApplicationController
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
before_action :set_database
|
|
5
|
+
|
|
6
|
+
def show
|
|
7
|
+
return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
|
|
8
|
+
|
|
9
|
+
@connection_summary = @database.connection_summary
|
|
10
|
+
end
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
def reset
|
|
13
|
+
if @pg_stat_statements.resettable?
|
|
14
|
+
@pg_stat_statements.reset!
|
|
10
15
|
flash[:notice] = "pg_stat_statements has been reset."
|
|
11
16
|
else
|
|
12
17
|
flash[:alert] = "This role may not reset pg_stat_statements; the statistics were left as they are."
|
|
13
18
|
end
|
|
14
19
|
|
|
15
|
-
redirect_to
|
|
20
|
+
redirect_to database_pg_stat_statements_path(@database)
|
|
16
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
def set_database
|
|
25
|
+
@database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
|
|
26
|
+
@pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
|
|
27
|
+
end
|
|
17
28
|
end
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
class PgPeek::TablesController < PgPeek::ApplicationController
|
|
2
|
+
before_action :set_database
|
|
3
|
+
|
|
4
|
+
def index
|
|
5
|
+
@tables = PgPeek::Reports::Tables.new(database: @database)
|
|
6
|
+
end
|
|
7
|
+
|
|
2
8
|
def show
|
|
3
|
-
@database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
|
|
4
9
|
@table = @database.find_table(params[:name]) or raise ActiveRecord::RecordNotFound, "Table '#{params[:name]}' not found"
|
|
5
10
|
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
def set_database
|
|
14
|
+
@database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
|
|
15
|
+
end
|
|
6
16
|
end
|
|
@@ -30,16 +30,23 @@ module PgPeek::ApplicationHelper
|
|
|
30
30
|
sql.gsub(SQLCOMMENTER_PATTERN, "").strip
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Where a query's SQLcommenter tags point, if anywhere: a job's own page,
|
|
34
|
-
#
|
|
35
|
-
#
|
|
33
|
+
# Where a query's SQLcommenter tags point, if anywhere: a job's own page, or
|
|
34
|
+
# a controller/action's own page. A controller without an action has no page,
|
|
35
|
+
# so it lands on the endpoints list.
|
|
36
36
|
def sqlcommenter_href(database, tags)
|
|
37
37
|
return database_job_path(database, tags["job"]) if tags["job"].present?
|
|
38
38
|
|
|
39
39
|
controller = tags["namespaced_controller"] || tags["controller"]
|
|
40
|
-
return
|
|
40
|
+
return nil if controller.blank?
|
|
41
|
+
return database_endpoints_path(database) if tags["action"].blank?
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
endpoint_path(database, "#{controller}##{tags["action"]}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# An endpoint's page, from its controller#action name.
|
|
47
|
+
def endpoint_path(database, endpoint)
|
|
48
|
+
controller, action = endpoint.split("#", 2)
|
|
49
|
+
database_endpoint_path(database, endpoint_controller: controller, endpoint_action: action)
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
# Format a rate with auto-scaled units based on magnitude
|
|
@@ -95,11 +102,17 @@ module PgPeek::ApplicationHelper
|
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
# Every pg_stat_statements figure is cumulative since the last reset. Each
|
|
98
|
-
# page says so in its header and links to the
|
|
105
|
+
# page says so in its header and links to the extension's page, which
|
|
106
|
+
# resets it.
|
|
107
|
+
# Within the first hour the figures cover too little to go by, and the link
|
|
108
|
+
# is marked provisional.
|
|
99
109
|
def stats_since_link(database, reset_at)
|
|
110
|
+
provisional = reset_at && reset_at > 1.hour.ago
|
|
111
|
+
|
|
100
112
|
link_to "stats since #{format_time_ago(reset_at)}",
|
|
101
|
-
|
|
102
|
-
|
|
113
|
+
database_pg_stat_statements_path(database),
|
|
114
|
+
class: ("provisional" if provisional),
|
|
115
|
+
title: provisional ? "figures may not be representative yet" : "figures are cumulative since the last pg_stat_statements reset"
|
|
103
116
|
end
|
|
104
117
|
|
|
105
118
|
# Format cache hit ratio for inline display
|
|
@@ -134,12 +147,15 @@ module PgPeek::ApplicationHelper
|
|
|
134
147
|
((value.to_f / max_value) * INTENSITY_SEGMENTS).ceil.clamp(0, INTENSITY_SEGMENTS)
|
|
135
148
|
end
|
|
136
149
|
|
|
137
|
-
# Durations pick their unit by magnitude: 210ms, 1.20s, 2m 05s,
|
|
138
|
-
# Values stay narrow and read at a glance, and a header sits over
|
|
139
|
-
# similar width. The exact figure belongs in a title, not the cell.
|
|
150
|
+
# Durations pick their unit by magnitude: <1ms, 210ms, 1.20s, 2m 05s,
|
|
151
|
+
# 1h 12m. Values stay narrow and read at a glance, and a header sits over
|
|
152
|
+
# data of similar width. The exact figure belongs in a title, not the cell.
|
|
153
|
+
# Anything above zero but under a millisecond is <1ms, so a fast query never
|
|
154
|
+
# reads as 0ms -- that is kept for a real zero.
|
|
140
155
|
def format_duration_from_ms(total_ms)
|
|
141
156
|
ms = total_ms.to_f
|
|
142
157
|
return "0ms" if ms <= 0
|
|
158
|
+
return "<1ms" if ms < 1
|
|
143
159
|
return format("%.1fms", ms) if ms < 10
|
|
144
160
|
return "#{ms.round}ms" if ms < 1000
|
|
145
161
|
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
# Turns a report cell into markup. The report is passed alongside the column
|
|
2
|
-
# because
|
|
3
|
-
#
|
|
2
|
+
# because a bar is relative -- it means nothing without the largest value in
|
|
3
|
+
# its column.
|
|
4
4
|
module PgPeek::ReportHelper
|
|
5
5
|
def format_cell(report, column, row)
|
|
6
6
|
value = row.value(column)
|
|
7
7
|
return "" if value.nil? || value.to_s.empty?
|
|
8
8
|
|
|
9
|
+
content = format_value(report, column, value)
|
|
10
|
+
return content unless column.bar?
|
|
11
|
+
|
|
12
|
+
safe_join([ content, intensity_bar(value.to_f, report.max_for(column)) ], " ")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format_value(report, column, value)
|
|
9
16
|
case column.format
|
|
10
17
|
when :number then format_number(value.to_i)
|
|
11
18
|
when :duration_ms then duration_cell(value.to_f)
|
|
@@ -13,21 +20,37 @@ module PgPeek::ReportHelper
|
|
|
13
20
|
when :percent then "#{value}%"
|
|
14
21
|
when :ratio then "~#{value}"
|
|
15
22
|
when :sql then sql_cell(report, value.to_s)
|
|
16
|
-
when :
|
|
23
|
+
when :name then name_cell(value.to_s)
|
|
17
24
|
else value.to_s
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
# The compact figure is what you scan; the exact one is a hover away.
|
|
22
29
|
def duration_cell(ms)
|
|
23
|
-
tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(
|
|
30
|
+
tag.span(format_duration_from_ms(ms), title: "#{number_with_delimiter(ms.round(3))} ms")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# An endpoint or job name in its native form, the namespace dimmed so the
|
|
34
|
+
# eye goes to the last part. When the column is too narrow, the namespace
|
|
35
|
+
# is what gets cut; the full name is on hover.
|
|
36
|
+
def name_cell(name)
|
|
37
|
+
namespace, leaf = name_parts(name)
|
|
38
|
+
tag.span(class: "qualified", title: name) do
|
|
39
|
+
safe_join([ (tag.span(tag.span(namespace), class: "namespace") if namespace), tag.span(leaf, class: "leaf") ].compact)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Splits after the last "/" or "::": courier_company_app/dashboard/ and
|
|
44
|
+
# executions#show, or GDPR::OrderAnonymizationBatch:: and ProgressMonitorJob.
|
|
45
|
+
def name_parts(name)
|
|
46
|
+
name.match(%r{\A(.*(?:/|::))?(.*)\z}m).captures
|
|
24
47
|
end
|
|
25
48
|
|
|
26
49
|
# The SQL text, plus -- when SQLcommenter tagged it -- what issued it (a
|
|
27
50
|
# link into the page that already aggregates this exact query across every
|
|
28
51
|
# call site) and every raw tag behind a disclosure, for the rest.
|
|
29
52
|
def sql_cell(report, sql)
|
|
30
|
-
code = tag.code(strip_sqlcommenter(sql)
|
|
53
|
+
code = tag.code(PgPeek::SqlFormat.format(strip_sqlcommenter(sql)), class: "sql", data: { clamp: true })
|
|
31
54
|
tags = PgPeek::SqlComment.tags(sql)
|
|
32
55
|
return code if tags.empty?
|
|
33
56
|
|
|
@@ -50,9 +73,10 @@ module PgPeek::ReportHelper
|
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
def cell_class(column)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
76
|
+
if column.format == :name
|
|
77
|
+
"name-column"
|
|
78
|
+
elsif column.numeric?
|
|
79
|
+
"num"
|
|
56
80
|
end
|
|
57
81
|
end
|
|
58
82
|
end
|
|
@@ -93,6 +93,37 @@ class PgPeek::PgStatStatements
|
|
|
93
93
|
Time.zone.parse(value.to_s) if value.present?
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# How many statements the server dropped since the last reset because the
|
|
97
|
+
# table was full (pg_stat_statements.max). Above zero, every total is an
|
|
98
|
+
# undercount: whatever was dropped took its calls and time with it.
|
|
99
|
+
def dealloc
|
|
100
|
+
return unless usable?
|
|
101
|
+
|
|
102
|
+
connection.select_value(PgPeek::QueryLoader.mark(<<~SQL)).to_i
|
|
103
|
+
SELECT dealloc FROM pg_stat_statements_info
|
|
104
|
+
SQL
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The table is shared by every database on the server, so max is measured
|
|
108
|
+
# against all of them, not just this one.
|
|
109
|
+
def server_statement_count
|
|
110
|
+
return unless usable?
|
|
111
|
+
|
|
112
|
+
connection.select_value(PgPeek::QueryLoader.mark(<<~SQL)).to_i
|
|
113
|
+
SELECT count(*) FROM pg_stat_statements
|
|
114
|
+
SQL
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# pg_stat_statements.* settings, by name without the prefix.
|
|
118
|
+
def settings
|
|
119
|
+
return {} unless usable?
|
|
120
|
+
|
|
121
|
+
connection.select_rows(PgPeek::QueryLoader.mark(<<~SQL)).to_h
|
|
122
|
+
SELECT substr(name, length('pg_stat_statements.') + 1), setting
|
|
123
|
+
FROM pg_settings WHERE name LIKE 'pg_stat_statements.%' ORDER BY name
|
|
124
|
+
SQL
|
|
125
|
+
end
|
|
126
|
+
|
|
96
127
|
def outliers
|
|
97
128
|
return unless usable?
|
|
98
129
|
|
|
@@ -133,10 +164,10 @@ class PgPeek::PgStatStatements
|
|
|
133
164
|
def outliers_by_job(job_class)
|
|
134
165
|
return unless usable?
|
|
135
166
|
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
|
|
139
|
-
pattern = "%job='#{
|
|
167
|
+
# Rails writes the job tag url-encoded (job='Reports%3A%3ADigestJob'), so
|
|
168
|
+
# match it encoded, with the % of the encoding escaped for LIKE.
|
|
169
|
+
encoded_job = ActiveRecord::Base.sanitize_sql_like(ERB::Util.url_encode(job_class))
|
|
170
|
+
pattern = "%job='#{encoded_job}'%"
|
|
140
171
|
|
|
141
172
|
query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_job",
|
|
142
173
|
pg_version: database.major_version,
|
|
@@ -146,6 +177,22 @@ class PgPeek::PgStatStatements
|
|
|
146
177
|
result.to_a
|
|
147
178
|
end
|
|
148
179
|
|
|
180
|
+
# endpoint is controller#action, as the endpoints report names it.
|
|
181
|
+
def outliers_by_endpoint(endpoint)
|
|
182
|
+
return unless usable?
|
|
183
|
+
|
|
184
|
+
# Rails writes the tags url-encoded (namespaced_controller='staff%2Fprocesses'),
|
|
185
|
+
# so compare them encoded.
|
|
186
|
+
controller, action = endpoint.split("#", 2).map { |part| ERB::Util.url_encode(part) }
|
|
187
|
+
|
|
188
|
+
query = PgPeek::QueryLoader.load("pg_stat_statements/outliers_by_endpoint",
|
|
189
|
+
pg_version: database.major_version,
|
|
190
|
+
controller: connection.quote(controller),
|
|
191
|
+
action: connection.quote(action),
|
|
192
|
+
limit: PgPeek.config.outliers_limit)
|
|
193
|
+
connection.execute(query).to_a
|
|
194
|
+
end
|
|
195
|
+
|
|
149
196
|
private
|
|
150
197
|
|
|
151
198
|
# pg_stat_statements_info() raises PG::ObjectNotInPrerequisiteState unless
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Lays a query out one clause per line, so the first lines of a long query say
|
|
2
|
+
# what it reads and from where, and its joins and conditions read as a list.
|
|
3
|
+
# Only line breaks and spacing change. Breaks go in at the top level only --
|
|
4
|
+
# never inside parentheses, quotes or a CASE -- so subqueries and function
|
|
5
|
+
# calls stay on one line, and a query this does not understand comes out with
|
|
6
|
+
# an odd break at worst.
|
|
7
|
+
class PgPeek::SqlFormat
|
|
8
|
+
TOKEN = /'(?:[^']|'')*'|"(?:[^"]|"")*"|[(),;]|[^\s'"(),;]+|\s+/
|
|
9
|
+
CLAUSES = %w[SELECT FROM WHERE GROUP HAVING WINDOW ORDER LIMIT OFFSET FETCH FOR
|
|
10
|
+
UNION INTERSECT EXCEPT RETURNING SET VALUES].freeze
|
|
11
|
+
JOIN_PREFIXES = %w[INNER LEFT RIGHT FULL CROSS NATURAL].freeze
|
|
12
|
+
CONDITIONS = %w[WHERE HAVING].freeze
|
|
13
|
+
|
|
14
|
+
def self.format(sql)
|
|
15
|
+
tokens = []
|
|
16
|
+
space = false
|
|
17
|
+
sql.to_s.strip.scan(TOKEN) do |token|
|
|
18
|
+
if token.match?(/\A\s/)
|
|
19
|
+
space = true
|
|
20
|
+
else
|
|
21
|
+
tokens << [ token, space ]
|
|
22
|
+
space = false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
out = +""
|
|
27
|
+
depth = case_depth = 0
|
|
28
|
+
clause = nil
|
|
29
|
+
between = false
|
|
30
|
+
|
|
31
|
+
tokens.each_with_index do |(token, space_before), index|
|
|
32
|
+
word = token.upcase
|
|
33
|
+
top = index.positive? && depth.zero? && case_depth.zero?
|
|
34
|
+
previous = tokens[index - 1]&.first&.upcase if index.positive?
|
|
35
|
+
following = tokens[index + 1]&.first&.upcase
|
|
36
|
+
|
|
37
|
+
line_break = nil
|
|
38
|
+
if top
|
|
39
|
+
if CLAUSES.include?(word)
|
|
40
|
+
line_break = "\n"
|
|
41
|
+
clause = word
|
|
42
|
+
elsif JOIN_PREFIXES.include?(word) && %w[JOIN OUTER].include?(following)
|
|
43
|
+
line_break = "\n"
|
|
44
|
+
clause = "JOIN"
|
|
45
|
+
elsif word == "JOIN" && !(JOIN_PREFIXES + %w[OUTER]).include?(previous)
|
|
46
|
+
line_break = "\n"
|
|
47
|
+
clause = "JOIN"
|
|
48
|
+
elsif word == "BETWEEN"
|
|
49
|
+
between = true
|
|
50
|
+
elsif word == "AND" && between
|
|
51
|
+
between = false
|
|
52
|
+
elsif %w[AND OR].include?(word) && CONDITIONS.include?(clause)
|
|
53
|
+
line_break = "\n "
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if line_break
|
|
58
|
+
out << line_break
|
|
59
|
+
elsif space_before && !out.empty?
|
|
60
|
+
out << " "
|
|
61
|
+
end
|
|
62
|
+
out << token
|
|
63
|
+
|
|
64
|
+
case word
|
|
65
|
+
when "(" then depth += 1
|
|
66
|
+
when ")" then depth -= 1 if depth.positive?
|
|
67
|
+
when "CASE" then case_depth += 1
|
|
68
|
+
when "END" then case_depth -= 1 if case_depth.positive?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
out
|
|
73
|
+
end
|
|
74
|
+
end
|
data/app/models/pg_peek/table.rb
CHANGED
|
@@ -147,10 +147,11 @@ class PgPeek::Table
|
|
|
147
147
|
(dead_tuples.to_f / total * 100).round(1)
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# The same rule as the overview's attention list: past autovacuum's trigger
|
|
151
|
+
# point as well as the percentage threshold.
|
|
150
152
|
def dead_tuple_warning?
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
ratio >= PgPeek.config.dead_tuple_warning_threshold
|
|
153
|
+
database.tables_with_dead_tuples(PgPeek.config.dead_tuple_warning_threshold)
|
|
154
|
+
.any? { |table| table["relname"] == name }
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
def row_estimate
|
|
@@ -2,7 +2,7 @@ SELECT
|
|
|
2
2
|
interval '1 millisecond' * total_exec_time AS total_exec_time,
|
|
3
3
|
COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
|
|
4
4
|
to_char(calls, 'FM999G999G999G990') AS ncalls,
|
|
5
|
-
|
|
5
|
+
total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
|
|
6
6
|
interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
|
|
7
7
|
query
|
|
8
8
|
FROM pg_stat_statements
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- The query shapes one controller#action issued. The endpoint is read from the
|
|
2
|
+
-- tags exactly as endpoints.sql reads it, so this page lists the same shapes
|
|
3
|
+
-- that endpoints.sql counts for it. An equality, not a LIKE: with both tags on,
|
|
4
|
+
-- controller='posts' also appears in the queries of admin/posts.
|
|
5
|
+
SELECT
|
|
6
|
+
interval '1 millisecond' * total_exec_time AS total_exec_time,
|
|
7
|
+
COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
|
|
8
|
+
to_char(calls, 'FM999G999G999G990') AS ncalls,
|
|
9
|
+
ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
|
|
10
|
+
interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
|
|
11
|
+
query
|
|
12
|
+
FROM pg_stat_statements
|
|
13
|
+
WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1)
|
|
14
|
+
AND dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
15
|
+
AND query NOT LIKE '%/* pg_peek */%'
|
|
16
|
+
AND COALESCE(
|
|
17
|
+
substring(query from 'namespaced_controller=''([^'']+)'''),
|
|
18
|
+
substring(query from 'controller=''([^'']+)''')
|
|
19
|
+
) = {{controller}}
|
|
20
|
+
AND substring(query from 'action=''([^'']+)''') = {{action}}
|
|
21
|
+
AND query !~* 'pg_catalog|information_schema|pg_index|pg_class|pg_attribute'
|
|
22
|
+
AND query !~* '^\s*SHOW '
|
|
23
|
+
ORDER BY total_exec_time DESC
|
|
24
|
+
LIMIT {{limit}}
|
|
@@ -2,7 +2,7 @@ SELECT
|
|
|
2
2
|
interval '1 millisecond' * total_exec_time AS total_exec_time,
|
|
3
3
|
COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
|
|
4
4
|
to_char(calls, 'FM999G999G999G990') AS ncalls,
|
|
5
|
-
|
|
5
|
+
total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
|
|
6
6
|
interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
|
|
7
7
|
query
|
|
8
8
|
FROM pg_stat_statements
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
-- Tables
|
|
1
|
+
-- Tables that autovacuum should have cleaned by now: more dead tuples than its
|
|
2
|
+
-- own trigger point (autovacuum_vacuum_threshold + scale_factor * live rows),
|
|
3
|
+
-- and a dead-tuple share at or above the warning threshold. Small tables never
|
|
4
|
+
-- reach the trigger point, so a high share alone is not a problem.
|
|
2
5
|
SELECT
|
|
3
6
|
relname,
|
|
4
7
|
n_live_tup,
|
|
@@ -6,6 +9,7 @@ SELECT
|
|
|
6
9
|
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_ratio,
|
|
7
10
|
last_autovacuum
|
|
8
11
|
FROM pg_stat_user_tables
|
|
9
|
-
WHERE
|
|
10
|
-
|
|
12
|
+
WHERE n_dead_tup > current_setting('autovacuum_vacuum_threshold')::int
|
|
13
|
+
+ current_setting('autovacuum_vacuum_scale_factor')::float * n_live_tup
|
|
14
|
+
AND 100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) >= {{threshold}}
|
|
11
15
|
ORDER BY dead_ratio DESC
|
|
@@ -2,7 +2,7 @@ SELECT
|
|
|
2
2
|
interval '1 millisecond' * total_exec_time AS total_exec_time,
|
|
3
3
|
COALESCE(to_char((total_exec_time/NULLIF(sum(total_exec_time) OVER(), 0)) * 100, 'FM990D0'), '0') || '%' AS prop_exec_time,
|
|
4
4
|
to_char(calls, 'FM999G999G999G990') AS ncalls,
|
|
5
|
-
|
|
5
|
+
total_exec_time/NULLIF(calls, 0) AS avg_exec_ms,
|
|
6
6
|
interval '1 millisecond' * (blk_read_time + blk_write_time) AS sync_io_time,
|
|
7
7
|
query
|
|
8
8
|
FROM pg_stat_statements
|