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.
Files changed (120) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +217 -0
  4. data/Rakefile +16 -0
  5. data/app/assets/javascripts/pg_peek/application.js +147 -0
  6. data/app/assets/stylesheets/pg_peek/application.css +362 -0
  7. data/app/controllers/pg_peek/activity_controller.rb +16 -0
  8. data/app/controllers/pg_peek/application_controller.rb +36 -0
  9. data/app/controllers/pg_peek/databases_controller.rb +96 -0
  10. data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
  11. data/app/controllers/pg_peek/indexes_controller.rb +7 -0
  12. data/app/controllers/pg_peek/jobs_controller.rb +44 -0
  13. data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
  14. data/app/controllers/pg_peek/queries_controller.rb +13 -0
  15. data/app/controllers/pg_peek/tables_controller.rb +6 -0
  16. data/app/helpers/pg_peek/application_helper.rb +179 -0
  17. data/app/helpers/pg_peek/report_helper.rb +58 -0
  18. data/app/jobs/pg_peek/application_job.rb +4 -0
  19. data/app/mailers/pg_peek/application_mailer.rb +6 -0
  20. data/app/models/pg_peek/application_record.rb +3 -0
  21. data/app/models/pg_peek/database.rb +131 -0
  22. data/app/models/pg_peek/pg_stat_statements.rb +132 -0
  23. data/app/models/pg_peek/sql_comment.rb +25 -0
  24. data/app/models/pg_peek/table.rb +266 -0
  25. data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
  26. data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
  27. data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
  28. data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
  29. data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
  30. data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
  31. data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
  32. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
  33. data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
  34. data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
  35. data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
  36. data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
  37. data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
  38. data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
  39. data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
  40. data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
  41. data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
  42. data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
  43. data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
  44. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
  45. data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
  46. data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
  47. data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
  48. data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
  49. data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
  50. data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
  51. data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
  52. data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
  53. data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
  54. data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
  55. data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
  56. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
  57. data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
  58. data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
  59. data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
  60. data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
  61. data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
  62. data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
  63. data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
  64. data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
  65. data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
  66. data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
  67. data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
  68. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
  69. data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
  70. data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
  71. data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
  72. data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
  73. data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
  74. data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
  75. data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
  76. data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
  77. data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
  78. data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
  79. data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
  80. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
  81. data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
  82. data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
  83. data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
  84. data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
  85. data/app/reports/pg_peek/column.rb +27 -0
  86. data/app/reports/pg_peek/report.rb +63 -0
  87. data/app/reports/pg_peek/reports/blocked.rb +60 -0
  88. data/app/reports/pg_peek/reports/databases.rb +47 -0
  89. data/app/reports/pg_peek/reports/endpoints.rb +35 -0
  90. data/app/reports/pg_peek/reports/indexes.rb +44 -0
  91. data/app/reports/pg_peek/reports/job_queries.rb +14 -0
  92. data/app/reports/pg_peek/reports/jobs.rb +21 -0
  93. data/app/reports/pg_peek/reports/queries.rb +23 -0
  94. data/app/reports/pg_peek/reports/sessions.rb +88 -0
  95. data/app/reports/pg_peek/reports/tables.rb +25 -0
  96. data/app/views/layouts/pg_peek/application.html.erb +56 -0
  97. data/app/views/pg_peek/activity/show.html.erb +70 -0
  98. data/app/views/pg_peek/authentication_required.html.erb +31 -0
  99. data/app/views/pg_peek/databases/index.html.erb +43 -0
  100. data/app/views/pg_peek/databases/show.html.erb +169 -0
  101. data/app/views/pg_peek/endpoints/index.html.erb +24 -0
  102. data/app/views/pg_peek/indexes/index.html.erb +31 -0
  103. data/app/views/pg_peek/jobs/index.html.erb +51 -0
  104. data/app/views/pg_peek/jobs/show.html.erb +17 -0
  105. data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
  106. data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
  107. data/app/views/pg_peek/queries/index.html.erb +10 -0
  108. data/app/views/pg_peek/reports/_report.html.erb +32 -0
  109. data/app/views/pg_peek/tables/show.html.erb +128 -0
  110. data/config/routes.rb +16 -0
  111. data/lib/generators/pg_peek/install/install_generator.rb +40 -0
  112. data/lib/generators/pg_peek/install/templates/README +31 -0
  113. data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
  114. data/lib/pg_peek/configuration.rb +59 -0
  115. data/lib/pg_peek/engine.rb +7 -0
  116. data/lib/pg_peek/query_loader.rb +59 -0
  117. data/lib/pg_peek/version.rb +3 -0
  118. data/lib/pg_peek.rb +10 -0
  119. data/lib/tasks/pg_peek_tasks.rake +4 -0
  120. metadata +192 -0
@@ -0,0 +1,362 @@
1
+ /*
2
+ * pg_peek — a terminal-shaped stylesheet.
3
+ *
4
+ * The data here is durations, counts, percentages and SQL: monospace with
5
+ * tabular figures makes columns scannable in a way proportional type cannot.
6
+ * Styling targets semantic elements rather than classes wherever possible, so
7
+ * views stay plain HTML.
8
+ */
9
+
10
+ :root {
11
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
12
+
13
+ --bg: #fcfcfa;
14
+ --panel: #f1f1ec;
15
+ --fg: #1b1b19;
16
+ --dim: #6e6e67;
17
+ --line: #d8d8d1;
18
+ --accent: #0a5fbf;
19
+ --warn: #9a5b00;
20
+ --danger: #b32218;
21
+ --ok: #17683a;
22
+ }
23
+
24
+ :root[data-theme="dark"] {
25
+ --bg: #111312;
26
+ --panel: #1a1d1c;
27
+ --fg: #d8dbd8;
28
+ --dim: #8a8f8b;
29
+ --line: #2b302e;
30
+ --accent: #3ee83e;
31
+ --warn: #e0a24a;
32
+ --danger: #f0796b;
33
+ --ok: #3ee83e;
34
+ }
35
+
36
+ @media (prefers-color-scheme: dark) {
37
+ :root:not([data-theme="light"]) {
38
+ --bg: #111312;
39
+ --panel: #1a1d1c;
40
+ --fg: #d8dbd8;
41
+ --dim: #8a8f8b;
42
+ --line: #2b302e;
43
+ --accent: #3ee83e;
44
+ --warn: #e0a24a;
45
+ --danger: #f0796b;
46
+ --ok: #3ee83e;
47
+ }
48
+ }
49
+
50
+ * { box-sizing: border-box; }
51
+
52
+ body {
53
+ margin: 0;
54
+ padding: 0 1.5rem 3.5rem;
55
+ background: var(--bg);
56
+ color: var(--fg);
57
+ font-family: var(--mono);
58
+ font-size: 13px;
59
+ line-height: 1.55;
60
+ font-variant-numeric: tabular-nums;
61
+ -webkit-font-smoothing: antialiased;
62
+ }
63
+
64
+ main { max-width: 120ch; margin: 0 auto; }
65
+
66
+ /* Header ------------------------------------------------------------------ */
67
+
68
+ nav {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 1rem;
72
+ padding: 0.75rem 0;
73
+ border-bottom: 1px solid var(--line);
74
+ margin-bottom: 1.5rem;
75
+ }
76
+
77
+ nav ul {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 1rem;
81
+ margin: 0;
82
+ padding: 0;
83
+ list-style: none;
84
+ }
85
+
86
+ nav ul:last-of-type { margin-left: auto; }
87
+ nav .wordmark { color: var(--fg); font-weight: 600; }
88
+
89
+ nav a {
90
+ color: var(--dim);
91
+ text-decoration: none;
92
+ text-transform: lowercase;
93
+ }
94
+
95
+ nav a:hover, nav a[aria-current] { color: var(--fg); }
96
+
97
+ nav .current-database { margin-left: 0.5rem; padding-left: 1rem; border-left: 1px solid var(--line); }
98
+
99
+ .switcher { position: relative; display: inline-block; }
100
+ .switcher::after { content: "▾"; color: var(--dim); position: absolute; right: 0; top: 0; pointer-events: none; }
101
+
102
+ .switcher select {
103
+ appearance: none;
104
+ -webkit-appearance: none;
105
+ background: none;
106
+ border: 0;
107
+ padding: 0 1.4ch 0 0;
108
+ margin: 0;
109
+ color: var(--fg);
110
+ font: inherit;
111
+ font-weight: 600;
112
+ cursor: pointer;
113
+ }
114
+
115
+ /* Keyboard focus only: after a mouse pick the select keeps focus, and a ring
116
+ that lingers after every switch reads as a stuck highlight. */
117
+ .switcher select:focus { outline: none; }
118
+ .switcher select:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
119
+
120
+ /* Type -------------------------------------------------------------------- */
121
+
122
+ h1, h2, h3 {
123
+ font-size: 13px;
124
+ font-weight: 600;
125
+ margin: 1.75rem 0 0.75rem;
126
+ letter-spacing: 0.02em;
127
+ text-transform: lowercase;
128
+ }
129
+
130
+ p { margin: 0.5rem 0; }
131
+ a { color: var(--accent); }
132
+ strong { font-weight: 600; }
133
+ small { color: var(--dim); }
134
+
135
+ code {
136
+ background: var(--panel);
137
+ border: 1px solid var(--line);
138
+ padding: 0 0.4ch;
139
+ font-family: var(--mono);
140
+ }
141
+
142
+ pre {
143
+ background: var(--panel);
144
+ border: 1px solid var(--line);
145
+ padding: 0.6rem 0.8rem;
146
+ overflow-x: auto;
147
+ margin: 0.5rem 0;
148
+ }
149
+
150
+ pre code { background: none; border: 0; padding: 0; }
151
+
152
+ /* Tables ------------------------------------------------------------------ */
153
+
154
+ .scroll { overflow-x: auto; }
155
+
156
+ table {
157
+ width: 100%;
158
+ border-collapse: collapse;
159
+ margin: 0.5rem 0;
160
+ }
161
+
162
+ thead th {
163
+ text-align: left;
164
+ font-weight: 600;
165
+ color: var(--dim);
166
+ text-transform: lowercase;
167
+ border-bottom: 1px solid var(--line);
168
+ padding: 0.35rem 0.75rem 0.35rem 0;
169
+ white-space: nowrap;
170
+ }
171
+
172
+ tbody td {
173
+ vertical-align: top;
174
+ padding: 0.4rem 0.75rem 0.4rem 0;
175
+ border-bottom: 1px solid var(--line);
176
+ }
177
+
178
+ tbody tr:hover td { background: var(--panel); }
179
+
180
+ /* A table of label/value pairs (e.g. the table detail page): the row header
181
+ reads like a column header -- dim, plain weight -- and its value lines up
182
+ right like every other figure. */
183
+ tbody th {
184
+ text-align: left;
185
+ font-weight: 400;
186
+ color: var(--dim);
187
+ white-space: nowrap;
188
+ padding: 0.4rem 0.75rem 0.4rem 0;
189
+ border-bottom: 1px solid var(--line);
190
+ }
191
+ tbody th + td { text-align: right; }
192
+
193
+ .num { text-align: right; white-space: nowrap; }
194
+ .nowrap { white-space: nowrap; }
195
+ .dim { color: var(--dim); }
196
+ .warn { color: var(--warn); }
197
+ .intensity { white-space: nowrap; }
198
+ .bar { letter-spacing: 0.08em; }
199
+ .bar-on { color: var(--accent); }
200
+ /* Brightness follows the value: a one-segment bar sits at 80%, five at full. */
201
+ .bar-1 .bar-on { opacity: 0.80; }
202
+ .bar-2 .bar-on { opacity: 0.85; }
203
+ .bar-3 .bar-on { opacity: 0.90; }
204
+ .bar-4 .bar-on { opacity: 0.95; }
205
+ .bar-5 .bar-on { opacity: 1; }
206
+ .bar-off { color: var(--line); }
207
+ .sql { word-break: break-all; white-space: pre-wrap; background: none; border: 0; padding: 0; }
208
+
209
+ /* What issued a query, from its SQLcommenter tags: a link line under the SQL,
210
+ plus every raw tag behind a disclosure. The page-level details/summary
211
+ below is a bordered block sized for a whole section; this is one line in a
212
+ table cell, so it drops the border and shrinks to fit. */
213
+ .sql + .dim { margin-top: 0.25rem; }
214
+ details.tags { border: 0; padding: 0; margin: 0.25rem 0 0; font-size: inherit; }
215
+ details.tags summary { display: inline; }
216
+ details.tags[open] summary { margin-bottom: 0.25rem; }
217
+
218
+ /* Panels ------------------------------------------------------------------ */
219
+
220
+ article {
221
+ border: 1px solid var(--line);
222
+ background: var(--panel);
223
+ padding: 0 1rem 0.75rem;
224
+ margin: 1rem 0;
225
+ }
226
+
227
+ article > header {
228
+ margin: 0 -1rem 0.75rem;
229
+ padding: 0.5rem 1rem;
230
+ border-bottom: 1px solid var(--line);
231
+ font-weight: 600;
232
+ }
233
+
234
+ article > header::before { content: "! "; color: var(--warn); }
235
+
236
+ article > footer {
237
+ margin: 0.75rem -1rem 0;
238
+ padding: 0.5rem 1rem 0;
239
+ border-top: 1px solid var(--line);
240
+ color: var(--dim);
241
+ }
242
+
243
+ article ol, article ul { margin: 0.5rem 0; padding-left: 2.5ch; }
244
+
245
+ details {
246
+ border: 1px solid var(--line);
247
+ padding: 0.4rem 0.75rem;
248
+ margin: 0.5rem 0;
249
+ }
250
+
251
+ summary { cursor: pointer; color: var(--dim); }
252
+ summary:hover { color: var(--fg); }
253
+ details[open] summary { color: var(--fg); margin-bottom: 0.5rem; }
254
+ details pre { margin: 0; border: 0; background: none; padding: 0; }
255
+
256
+ /* Controls ---------------------------------------------------------------- */
257
+
258
+ input[type="search"] {
259
+ width: 100%;
260
+ background: var(--bg);
261
+ color: var(--fg);
262
+ border: 1px solid var(--line);
263
+ padding: 0.4rem 0.75rem;
264
+ font-family: var(--mono);
265
+ font-size: 13px;
266
+ }
267
+
268
+ input[type="search"]:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
269
+
270
+ button, .button {
271
+ background: var(--panel);
272
+ color: var(--fg);
273
+ border: 1px solid var(--line);
274
+ padding: 0.3rem 1ch;
275
+ font-family: var(--mono);
276
+ font-size: 13px;
277
+ cursor: pointer;
278
+ }
279
+
280
+ button:hover { border-color: var(--fg); }
281
+ button.danger { color: var(--danger); border-color: var(--danger); }
282
+ button.danger:hover { background: var(--danger); color: var(--bg); }
283
+
284
+ form.inline { display: inline; margin: 0; }
285
+
286
+ .toolbar {
287
+ display: flex;
288
+ align-items: baseline;
289
+ justify-content: space-between;
290
+ gap: 1rem;
291
+ }
292
+
293
+ .notice {
294
+ border: 1px solid var(--ok);
295
+ color: var(--ok);
296
+ padding: 0.4rem 0.75rem;
297
+ margin: 0.75rem 0;
298
+ }
299
+
300
+ mark { background: var(--accent); color: var(--bg); }
301
+
302
+ .grid {
303
+ display: grid;
304
+ grid-template-columns: repeat(auto-fill, minmax(28ch, 1fr));
305
+ gap: 1rem;
306
+ }
307
+
308
+ .stack > * + * { margin-top: 0.25rem; }
309
+
310
+ /* Status line ------------------------------------------------------------- */
311
+
312
+ footer.status {
313
+ position: fixed;
314
+ left: 0; right: 0; bottom: 0;
315
+ display: flex;
316
+ gap: 2ch;
317
+ align-items: center;
318
+ padding: 0.35rem 1.5rem;
319
+ background: var(--panel);
320
+ border-top: 1px solid var(--line);
321
+ color: var(--dim);
322
+ font-size: 12px;
323
+ }
324
+
325
+ footer.status a { color: var(--dim); text-decoration: none; }
326
+ footer.status a:hover { color: var(--fg); }
327
+
328
+ footer.status button {
329
+ margin-left: auto;
330
+ background: none;
331
+ border: 0;
332
+ padding: 0;
333
+ color: var(--dim);
334
+ font-size: 12px;
335
+ }
336
+
337
+ footer.status button:hover { color: var(--fg); }
338
+
339
+ /* Overview ---------------------------------------------------------------- */
340
+
341
+ .vitals { margin: 0 0 1rem; }
342
+ .vitals span + span::before { content: " · "; color: var(--line); }
343
+
344
+ .panels {
345
+ display: grid;
346
+ grid-template-columns: 1fr 1fr;
347
+ gap: 0 2rem;
348
+ }
349
+
350
+ .panels > section { min-width: 0; }
351
+ .panels > section.wide { grid-column: 1 / -1; }
352
+ .panels h2 { margin-top: 1.25rem; }
353
+ .panels h2 a { color: var(--dim); font-weight: normal; margin-left: 1ch; text-decoration: none; }
354
+ .panels h2 a:hover { color: var(--fg); }
355
+
356
+ .attention { list-style: none; margin: 0; padding: 0; }
357
+ .attention li { padding: 0.3rem 0; border-bottom: 1px solid var(--line); }
358
+ .attention li::before { content: "! "; color: var(--warn); }
359
+ .attention .ok::before { content: "✓ "; color: var(--ok); }
360
+
361
+ @media (max-width: 90ch) { .panels { grid-template-columns: 1fr; } }
362
+ [hidden] { display: none !important; }
@@ -0,0 +1,16 @@
1
+ # What is happening right now, as opposed to what has been expensive since the
2
+ # last reset. Needs no pg_stat_statements, so it works on any install.
3
+ class PgPeek::ActivityController < PgPeek::ApplicationController
4
+ REFRESH_INTERVALS = [ 5, 15, 60 ].freeze
5
+
6
+ def show
7
+ @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
8
+ @sessions = PgPeek::Reports::Sessions.new(database: @database)
9
+ @blocked = PgPeek::Reports::Blocked.new(database: @database, sessions: @sessions)
10
+ @summary = @database.connection_summary
11
+ @captured_at = Time.zone.now
12
+ # Only the offered intervals are honoured: a URL cannot make the page hammer the server.
13
+ @refresh = REFRESH_INTERVALS.find { |seconds| seconds == params[:refresh].to_i }
14
+ @show_idle = params[:idle].present?
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ class PgPeek::ApplicationController < ActionController::Base
2
+ layout "pg_peek/application"
3
+
4
+ # Host applications may set include_all_helpers = false, which limits a
5
+ # controller to its own matching helper.
6
+ helper PgPeek::ApplicationHelper, PgPeek::ReportHelper
7
+ helper_method :switchable_databases
8
+
9
+ before_action :authenticate
10
+
11
+ private
12
+ def switchable_databases
13
+ @switchable_databases ||= PgPeek::Database.all.select(&:configured?)
14
+ end
15
+
16
+ # Resolved per request rather than when this class is loaded, so credentials
17
+ # set after boot are honoured instead of silently ignored.
18
+ #
19
+ # Credentials win over the local exemption: a tunnelled development server
20
+ # is reachable, and setting them is how you protect it.
21
+ def authenticate
22
+ return if PgPeek.config.public_dashboard
23
+ return request_http_basic_credentials if PgPeek.config.credentials?
24
+ return if Rails.env.local?
25
+
26
+ render template: "pg_peek/authentication_required", status: :forbidden
27
+ end
28
+
29
+ def request_http_basic_credentials
30
+ authenticate_or_request_with_http_basic("PgPeek") do |username, password|
31
+ # & rather than && so both comparisons always run.
32
+ ActiveSupport::SecurityUtils.secure_compare(username, PgPeek.config.username) &
33
+ ActiveSupport::SecurityUtils.secure_compare(password, PgPeek.config.password)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,96 @@
1
+ class PgPeek::DatabasesController < PgPeek::ApplicationController
2
+ OVERVIEW_ROWS = 5
3
+
4
+ def index
5
+ @databases = PgPeek::Database.all
6
+ # Resolved once: each miss logs a warning, and the view needs the answer
7
+ # twice -- to list what works and to explain what does not.
8
+ @configured_databases = @databases.select(&:connection_configured?)
9
+ @report = PgPeek::Reports::Databases.new(databases: @configured_databases)
10
+ end
11
+
12
+ # Root. The primary's overview is what nearly everyone came for; the list of
13
+ # databases is one click away as a switcher.
14
+ def home
15
+ configured = PgPeek::Database.all.select(&:connection_configured?)
16
+ landing = configured.find(&:primary) || configured.first
17
+
18
+ redirect_to landing ? database_path(landing) : databases_path
19
+ end
20
+
21
+ def show
22
+ @database = PgPeek::Database.find(params[:id]) or raise ActiveRecord::RecordNotFound, "Database not found"
23
+ @pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
24
+ @tables = PgPeek::Reports::Tables.new(database: @database)
25
+ @vitals = @database.vitals
26
+ @stats_reset_at = @pg_stat_statements.reset_at
27
+ @sessions = PgPeek::Reports::Sessions.new(database: @database)
28
+ @connection_summary = @database.connection_summary
29
+
30
+ if @pg_stat_statements.usable?
31
+ @endpoints = PgPeek::Reports::Endpoints.new(database: @database)
32
+ @jobs = PgPeek::Reports::Jobs.new(database: @database)
33
+ @queries = PgPeek::Reports::Queries.new(database: @database)
34
+ end
35
+
36
+ @attention = attention
37
+ end
38
+
39
+ private
40
+ # Computed, not browsed: the things worth acting on, each linking to where
41
+ # you would act.
42
+ def attention
43
+ items = []
44
+
45
+ ratio = @vitals["cache_hit_ratio"]&.to_f
46
+ threshold = PgPeek.config.cache_hit_warning_threshold
47
+ if ratio && ratio < threshold
48
+ items << { text: "cache hit ratio #{ratio}% (below #{threshold}%)", href: nil }
49
+ end
50
+
51
+ @database.tables_with_dead_tuples(PgPeek.config.dead_tuple_warning_threshold).each do |table|
52
+ vacuumed = table["last_autovacuum"] ? "last autovacuum #{helpers.time_ago_in_words(table["last_autovacuum"])} ago" : "never autovacuumed"
53
+ items << { text: "#{table["relname"]}: #{table["dead_ratio"]}% dead tuples, #{vacuumed}",
54
+ href: database_table_path(@database, table["relname"]) }
55
+ end
56
+
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) }
61
+ end
62
+
63
+ activity = database_activity_path(@database)
64
+
65
+ if @sessions.blocked.any?
66
+ items << { text: "#{helpers.pluralize(@sessions.blocked.size, "session")} waiting for a lock", href: activity }
67
+ end
68
+
69
+ seconds = PgPeek.config.long_query_warning_seconds
70
+ long_running = @sessions.long_running(seconds * 1000)
71
+ if long_running.any?
72
+ items << { text: "#{helpers.pluralize(long_running.size, "query")} running longer than #{seconds}s, longest #{helpers.format_duration_from_ms(long_running.first["duration_ms"])}",
73
+ href: activity }
74
+ end
75
+
76
+ seconds = PgPeek.config.idle_in_transaction_warning_seconds
77
+ stale = @sessions.stale_transactions(seconds * 1000)
78
+ if stale.any?
79
+ items << { text: "#{helpers.pluralize(stale.size, "session")} idle in transaction for over #{seconds}s -- holding locks and blocking vacuum",
80
+ href: activity }
81
+ end
82
+
83
+ used = @connection_summary["client_connections"].to_i
84
+ max = @connection_summary["max_connections"].to_i
85
+ if max.positive? && used >= max * 0.8
86
+ items << { text: "#{used} of #{max} connections in use", href: activity }
87
+ end
88
+
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
+ items
95
+ end
96
+ end
@@ -0,0 +1,10 @@
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)
5
+
6
+ return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
7
+
8
+ @report = PgPeek::Reports::Endpoints.new(database: @database)
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ class PgPeek::IndexesController < PgPeek::ApplicationController
2
+ def index
3
+ @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
4
+ @report = PgPeek::Reports::Indexes.new(database: @database)
5
+ @stats_reset_at = @database.stats_reset_at
6
+ end
7
+ end
@@ -0,0 +1,44 @@
1
+ class PgPeek::JobsController < PgPeek::ApplicationController
2
+ before_action :set_database
3
+ before_action :require_sqlcommenter_tags
4
+
5
+ def index
6
+ return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
7
+
8
+ # Without the job tag no query can be attributed to a job, so an empty
9
+ # dashboard has nothing to do with whether jobs have run.
10
+ @job_tag_configured = query_log_tags.include?(:job)
11
+ @stats_reset_at = @pg_stat_statements.reset_at
12
+ @report = PgPeek::Reports::Jobs.new(database: @database)
13
+ end
14
+
15
+ def show
16
+ @job_class = CGI.unescape(params[:job_class])
17
+ return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
18
+
19
+ @report = PgPeek::Reports::JobQueries.new(database: @database, job_class: @job_class)
20
+ end
21
+
22
+ private
23
+ def set_database
24
+ @database = PgPeek::Database.find(params[:database_id]) or
25
+ raise ActiveRecord::RecordNotFound, "Database not found"
26
+ @pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
27
+ end
28
+
29
+ def query_log_tags
30
+ Rails.application.config.active_record.query_log_tags.to_a
31
+ end
32
+
33
+ def require_sqlcommenter_tags
34
+ config = Rails.application.config.active_record
35
+
36
+ unless config.query_log_tags_enabled
37
+ return render plain: "Query log tags are not enabled. Please enable config.active_record.query_log_tags_enabled in your Rails configuration.", status: :unprocessable_entity
38
+ end
39
+
40
+ if config.query_log_tags_format == :legacy
41
+ render plain: "Legacy query log tags format is not supported. Please set config.active_record.query_log_tags_format to :sqlcommenter in your Rails configuration.", status: :unprocessable_entity
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,11 @@
1
+ # Manages the extension itself. What it records is read by QueriesController,
2
+ # EndpointsController and JobsController.
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
+ PgPeek::PgStatStatements.new(database: @database).reset!
7
+
8
+ redirect_to database_path(@database, anchor: "pg_stat_statements"),
9
+ notice: "pg_stat_statements has been reset."
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # https://docs.percona.com/pg-stat-monitor/comparison.html
2
+ class PgPeek::QueriesController < PgPeek::ApplicationController
3
+ def index
4
+ @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
5
+ @pg_stat_statements = PgPeek::PgStatStatements.new(database: @database)
6
+
7
+ # The extension can be installed while the module was never preloaded, in
8
+ # which case querying its views raises instead of returning rows.
9
+ return render "pg_peek/pg_stat_statements/not_preloaded" unless @pg_stat_statements.usable?
10
+
11
+ @queries = PgPeek::Reports::Queries.new(database: @database)
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ class PgPeek::TablesController < PgPeek::ApplicationController
2
+ def show
3
+ @database = PgPeek::Database.find(params[:database_id]) or raise ActiveRecord::RecordNotFound, "Database not found"
4
+ @table = @database.find_table(params[:name]) or raise ActiveRecord::RecordNotFound, "Table '#{params[:name]}' not found"
5
+ end
6
+ end