pg_peek 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +217 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/pg_peek/application.js +147 -0
- data/app/assets/stylesheets/pg_peek/application.css +362 -0
- data/app/controllers/pg_peek/activity_controller.rb +16 -0
- data/app/controllers/pg_peek/application_controller.rb +36 -0
- data/app/controllers/pg_peek/databases_controller.rb +96 -0
- data/app/controllers/pg_peek/endpoints_controller.rb +10 -0
- data/app/controllers/pg_peek/indexes_controller.rb +7 -0
- data/app/controllers/pg_peek/jobs_controller.rb +44 -0
- data/app/controllers/pg_peek/pg_stat_statements_controller.rb +11 -0
- data/app/controllers/pg_peek/queries_controller.rb +13 -0
- data/app/controllers/pg_peek/tables_controller.rb +6 -0
- data/app/helpers/pg_peek/application_helper.rb +179 -0
- data/app/helpers/pg_peek/report_helper.rb +58 -0
- data/app/jobs/pg_peek/application_job.rb +4 -0
- data/app/mailers/pg_peek/application_mailer.rb +6 -0
- data/app/models/pg_peek/application_record.rb +3 -0
- data/app/models/pg_peek/database.rb +131 -0
- data/app/models/pg_peek/pg_stat_statements.rb +132 -0
- data/app/models/pg_peek/sql_comment.rb +25 -0
- data/app/models/pg_peek/table.rb +266 -0
- data/app/queries/pg_peek/pg14/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg14/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg14/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg14/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg14/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg14/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg14/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg14/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg14/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg15/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg15/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg15/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg15/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg15/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg15/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg15/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg15/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg15/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg16/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg16/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg16/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg16/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg16/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg16/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg16/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg16/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg16/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg17/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg17/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg17/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg17/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg17/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg17/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg17/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg17/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg17/tables/stats.sql +32 -0
- data/app/queries/pg_peek/pg18/activity/sessions.sql +26 -0
- data/app/queries/pg_peek/pg18/activity/summary.sql +8 -0
- data/app/queries/pg_peek/pg18/activity/waiting_locks.sql +11 -0
- data/app/queries/pg_peek/pg18/database/vitals.sql +11 -0
- data/app/queries/pg_peek/pg18/indexes/all.sql +17 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/endpoints.sql +43 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/jobs.sql +12 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers.sql +13 -0
- data/app/queries/pg_peek/pg18/pg_stat_statements/outliers_by_job.sql +14 -0
- data/app/queries/pg_peek/pg18/tables/dead_tuples.sql +11 -0
- data/app/queries/pg_peek/pg18/tables/inline_stats.sql +9 -0
- data/app/queries/pg_peek/pg18/tables/stats.sql +32 -0
- data/app/reports/pg_peek/column.rb +27 -0
- data/app/reports/pg_peek/report.rb +63 -0
- data/app/reports/pg_peek/reports/blocked.rb +60 -0
- data/app/reports/pg_peek/reports/databases.rb +47 -0
- data/app/reports/pg_peek/reports/endpoints.rb +35 -0
- data/app/reports/pg_peek/reports/indexes.rb +44 -0
- data/app/reports/pg_peek/reports/job_queries.rb +14 -0
- data/app/reports/pg_peek/reports/jobs.rb +21 -0
- data/app/reports/pg_peek/reports/queries.rb +23 -0
- data/app/reports/pg_peek/reports/sessions.rb +88 -0
- data/app/reports/pg_peek/reports/tables.rb +25 -0
- data/app/views/layouts/pg_peek/application.html.erb +56 -0
- data/app/views/pg_peek/activity/show.html.erb +70 -0
- data/app/views/pg_peek/authentication_required.html.erb +31 -0
- data/app/views/pg_peek/databases/index.html.erb +43 -0
- data/app/views/pg_peek/databases/show.html.erb +169 -0
- data/app/views/pg_peek/endpoints/index.html.erb +24 -0
- data/app/views/pg_peek/indexes/index.html.erb +31 -0
- data/app/views/pg_peek/jobs/index.html.erb +51 -0
- data/app/views/pg_peek/jobs/show.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/_preload_instructions.html.erb +17 -0
- data/app/views/pg_peek/pg_stat_statements/not_preloaded.html.erb +5 -0
- data/app/views/pg_peek/queries/index.html.erb +10 -0
- data/app/views/pg_peek/reports/_report.html.erb +32 -0
- data/app/views/pg_peek/tables/show.html.erb +128 -0
- data/config/routes.rb +16 -0
- data/lib/generators/pg_peek/install/install_generator.rb +40 -0
- data/lib/generators/pg_peek/install/templates/README +31 -0
- data/lib/generators/pg_peek/install/templates/initializer.rb +38 -0
- data/lib/pg_peek/configuration.rb +59 -0
- data/lib/pg_peek/engine.rb +7 -0
- data/lib/pg_peek/query_loader.rb +59 -0
- data/lib/pg_peek/version.rb +3 -0
- data/lib/pg_peek.rb +10 -0
- data/lib/tasks/pg_peek_tasks.rake +4 -0
- metadata +192 -0
|
@@ -0,0 +1,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
|