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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/pg_peek/application.js +25 -1
  3. data/app/assets/stylesheets/pg_peek/application.css +48 -2
  4. data/app/controllers/pg_peek/databases_controller.rb +12 -10
  5. data/app/controllers/pg_peek/endpoints_controller.rb +15 -3
  6. data/app/controllers/pg_peek/jobs_controller.rb +1 -1
  7. data/app/controllers/pg_peek/pg_stat_statements_controller.rb +17 -6
  8. data/app/controllers/pg_peek/tables_controller.rb +11 -1
  9. data/app/helpers/pg_peek/application_helper.rb +27 -11
  10. data/app/helpers/pg_peek/report_helper.rb +32 -8
  11. data/app/models/pg_peek/pg_stat_statements.rb +51 -4
  12. data/app/models/pg_peek/sql_format.rb +74 -0
  13. data/app/models/pg_peek/table.rb +4 -3
  14. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +1 -1
  15. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  16. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +1 -1
  17. data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +7 -3
  18. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +1 -1
  19. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  20. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +1 -1
  21. data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +7 -3
  22. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +1 -1
  23. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  24. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +1 -1
  25. data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +7 -3
  26. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +1 -1
  27. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  28. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +1 -1
  29. data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +7 -3
  30. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +1 -1
  31. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_endpoint.sql +24 -0
  32. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +1 -1
  33. data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +7 -3
  34. data/app/reports/pg_peek/column.rb +6 -1
  35. data/app/reports/pg_peek/report.rb +1 -2
  36. data/app/reports/pg_peek/reports/endpoint_queries.rb +14 -0
  37. data/app/reports/pg_peek/reports/endpoints.rb +5 -8
  38. data/app/reports/pg_peek/reports/jobs.rb +7 -7
  39. data/app/reports/pg_peek/reports/queries.rb +2 -1
  40. data/app/views/layouts/pg_peek/application.html.erb +1 -0
  41. data/app/views/pg_peek/databases/show.html.erb +8 -60
  42. data/app/views/pg_peek/endpoints/index.html.erb +14 -1
  43. data/app/views/pg_peek/endpoints/show.html.erb +18 -0
  44. data/app/views/pg_peek/jobs/index.html.erb +1 -8
  45. data/app/views/pg_peek/jobs/show.html.erb +2 -1
  46. data/app/views/pg_peek/pg_stat_statements/show.html.erb +87 -0
  47. data/app/views/pg_peek/tables/index.html.erb +18 -0
  48. data/app/views/pg_peek/tables/show.html.erb +2 -2
  49. data/config/routes.rb +5 -2
  50. data/lib/generators/pg_peek/install/templates/initializer.rb +9 -0
  51. data/lib/pg_peek/configuration.rb +8 -1
  52. data/lib/pg_peek/version.rb +1 -1
  53. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb99f16f0d2715eb1e038f98b4c7a43077678408b4f7f216f1238a823540f448
4
- data.tar.gz: 2bda074f80788e6ab201e49948ce76539cb2e62ce2d0f06276ec22262ed32fda
3
+ metadata.gz: abc82aa27c9e7fcebbe27a38cc17ff102ae578afdd11d0be75908874ee5058e8
4
+ data.tar.gz: 7bc328de015861d67c0250ad0373c4639fe1a45c15426c084939d56409b74d5a
5
5
  SHA512:
6
- metadata.gz: cbd81cbdb7abff990c656557f38e5d889e96c4b80a632a9c55d48b49b0363dd3443ee3ca13adc8ced93edcd5fde0b9c5fdeb4d202f8f64a755aeebacbcffe37d
7
- data.tar.gz: 0f60578fbe2b529b6fc076d087a2908a0806e23f77597e70d57a8abc8166e8aa89509805f493af20a7495aba75c683a395ec4af1e1e6b032891491d20ae7b1c5
6
+ metadata.gz: 72349c589551ec7dd8b5875f1ccbd579a7bbeaba94588fd29251cec72f041f4610077963efdfdd47683f7741469f9ac0cc7f912453770698e5e68e346ea1a7a3
7
+ data.tar.gz: 8c336b095487533a4862016ee4ffd1d332f3dad19f2134a03716375df017809fa123b1cfa37c286a0d833efeffbc4104c126c2df5a50ae74dce5b7e9eb6ee5d3
@@ -1,4 +1,4 @@
1
- // pg_peek — no framework, no CDN, no import map. Three small behaviours.
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
- .sql { word-break: break-all; white-space: pre-wrap; background: none; border: 0; padding: 0; }
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
- indexes = PgPeek::Reports::Indexes.new(database: @database)
58
- if indexes.unused.any?
59
- items << { text: "#{helpers.pluralize(indexes.unused.size, "unused index")} · #{helpers.number_to_human_size(indexes.unused_bytes)}",
60
- href: database_indexes_path(@database) }
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
- def index
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 = CGI.unescape(params[: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
- def reset
5
- @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
6
- pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
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
- if pg_stat_statements.resettable?
9
- pg_stat_statements.reset!
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 database_path(@database, anchor: "pg_stat_statements")
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
- # 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).
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 database_endpoints_path(database) if controller.present?
40
+ return nil if controller.blank?
41
+ return database_endpoints_path(database) if tags["action"].blank?
41
42
 
42
- nil
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 overview block that resets it.
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
- database_path(database, anchor: "pg_stat_statements"),
102
- title: "figures are cumulative since the last pg_stat_statements reset"
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, 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.
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 some formats are relative -- an intensity bar means nothing without
3
- # the largest value in its column.
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 :intensity then intensity_bar(value.to_f, report.max_for(column))
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(1))} ms")
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).squish, class: "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
- case column.format
54
- when :intensity then "intensity"
55
- else column.numeric? ? "num" : nil
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
- # Build pattern to match job='JobClassName' in SQLcommenter
137
- # Need to escape for SQL LIKE and quote properly
138
- escaped_job = job_class.gsub("'", "''")
139
- pattern = "%job='#{escaped_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
@@ -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
- ratio = dead_tuple_ratio
152
- return false if ratio.nil?
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
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
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
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
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 whose dead-tuple share is at or above the warning threshold.
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 n_live_tup + n_dead_tup > 0
10
- AND 100.0 * n_dead_tup / (n_live_tup + n_dead_tup) >= {{threshold}}
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
- ROUND(total_exec_time/NULLIF(calls, 0)) AS avg_exec_ms,
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