pgkiln 0.48.0 → 0.49.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.
- package/SECURITY.md +2 -2
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +179 -5
- package/docs/guide/08-security.md +11 -0
- package/docs/guide/12-development.md +44 -6
- package/docs/guide/13-rest-api.md +59 -1
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +420 -173
- package/public/builder.js +48 -0
- package/public/sql-monitor.js +73 -0
- package/src/app.ts +6 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +3 -2
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/datapump.ts +364 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +6 -2
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +6 -0
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +6 -4
- package/src/builder/worksheet.ts +5 -21
- package/src/changelog.ts +109 -0
- package/src/cron-schedule.ts +282 -0
- package/src/datapump.ts +546 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +116 -22
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { parsePlan, planText, type Plan } from '../sqlplan.ts';
|
|
6
|
+
import { splitScript } from '../sqlscript.ts';
|
|
7
|
+
import type { Session } from '../session.ts';
|
|
8
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
|
+
import { isAdmin } from './locks.ts';
|
|
10
|
+
import { planView } from './plan-view.ts';
|
|
11
|
+
import { statementsNote, statementsState } from './top-sql.ts';
|
|
12
|
+
|
|
13
|
+
// (sprint 102, Database Actions DA-14) SQL Workshop › SQL Monitor, for administrators: Database Actions' Real Time
|
|
14
|
+
// SQL Monitor and Performance Hub on PostgreSQL's statistics views.
|
|
15
|
+
// - Sessions (GET /builder/sql/monitor): the client sessions of this database (pg_stat_activity) with what they
|
|
16
|
+
// run, wait for and who blocks them, a summary of wait events and the maintenance commands in progress
|
|
17
|
+
// (pg_stat_progress_*). public/sql-monitor.js reloads the live part every 5, 15 or 60 seconds (?fragment=1),
|
|
18
|
+
// with a pause button; without JavaScript, Refresh reloads the page. A row's Cancel / Terminate post to
|
|
19
|
+
// /builder/sql/monitor/session (pg_cancel_backend / pg_terminate_backend), after a confirmation.
|
|
20
|
+
// - Top SQL (GET /builder/sql/monitor/top): pg_stat_statements for the whole database, sortable by any column,
|
|
21
|
+
// total and mean time as bars; Reset statistics posts to /builder/sql/monitor/reset.
|
|
22
|
+
// - A statement (GET /builder/sql/monitor/statements/:queryid): its text, its totals per role, an Explain Plan
|
|
23
|
+
// (EXPLAIN only, never ANALYZE: it is someone else's statement; a text with $1 parameters gets a generic plan,
|
|
24
|
+
// PostgreSQL 16+), and Open in worksheet.
|
|
25
|
+
// Every read runs read only with a statement timeout, as the owner connection.
|
|
26
|
+
|
|
27
|
+
const URL_BASE = `${BASE}/sql/monitor`;
|
|
28
|
+
export const REFRESH_SECONDS = [5, 15, 60] as const;
|
|
29
|
+
|
|
30
|
+
type Row = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
async function readOnly<T>(fn: (q: (sql: string, params?: unknown[]) => Promise<Row[]>) => Promise<T>): Promise<T> {
|
|
33
|
+
return owner.tx(async (c) => {
|
|
34
|
+
await c.query('set transaction read only');
|
|
35
|
+
await c.query(`set local statement_timeout = '10s'`);
|
|
36
|
+
return fn(async (sql, params) => (await c.query(sql, params)).rows);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const crumbs = (...more: [string, string?][]): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], ['SQL Monitor', more.length ? URL_BASE : undefined], ...more];
|
|
41
|
+
const num = (v: unknown) => (v == null ? '' : Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 }));
|
|
42
|
+
const QUERYID_RE = /^-?\d{1,20}$/;
|
|
43
|
+
|
|
44
|
+
/** A bar (an SVG: the CSP allows no style attributes) for `value` out of `max`, next to the number. */
|
|
45
|
+
const bar = (value: number, max: number) =>
|
|
46
|
+
html`<svg class="sm-bar" viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="sm-bar-track" width="100" height="8"></rect><rect class="sm-bar-fill" width="${max > 0 ? Math.max(1, Math.round((value / max) * 100)) : 0}" height="8"></rect></svg>`;
|
|
47
|
+
|
|
48
|
+
const viewTabs = (active: 'sessions' | 'top') =>
|
|
49
|
+
html`<nav class="seg u-mb1" aria-label="SQL Monitor views">
|
|
50
|
+
<a href="${URL_BASE}"${active === 'sessions' ? raw(' aria-current="page"') : ''}>Sessions</a>
|
|
51
|
+
<a href="${URL_BASE}/top"${active === 'top' ? raw(' aria-current="page"') : ''}>Top SQL</a></nav>`;
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------------ sessions
|
|
54
|
+
|
|
55
|
+
async function liveData(showIdle: boolean) {
|
|
56
|
+
return readOnly(async (q) => ({
|
|
57
|
+
sessions: await q(
|
|
58
|
+
`select a.pid, a.usename as "user", a.application_name as app, a.client_addr::text as client, a.state,
|
|
59
|
+
a.wait_event_type as wait_type, a.wait_event as wait, a.query_id::text as queryid,
|
|
60
|
+
case when a.state = 'active' then round(extract(epoch from now() - a.query_start)::numeric, 1) end as running_s,
|
|
61
|
+
case when a.state like 'idle in transaction%' then round(extract(epoch from now() - a.state_change)::numeric, 1) end as idle_tx_s,
|
|
62
|
+
round(extract(epoch from now() - a.xact_start)::numeric, 1) as tx_s,
|
|
63
|
+
pg_blocking_pids(a.pid) as blocked_by, left(a.query, 2000) as query
|
|
64
|
+
from pg_stat_activity a
|
|
65
|
+
where a.datname = current_database() and a.backend_type = 'client backend' and a.pid <> pg_backend_pid()
|
|
66
|
+
and ($1 or a.state is distinct from 'idle')
|
|
67
|
+
order by a.state = 'active' desc, a.query_start nulls last limit 200`, [showIdle]),
|
|
68
|
+
totals: (await q(
|
|
69
|
+
`select count(*)::int as sessions, count(*) filter (where state = 'active')::int as active,
|
|
70
|
+
count(*) filter (where state like 'idle in transaction%')::int as idle_tx,
|
|
71
|
+
count(*) filter (where cardinality(pg_blocking_pids(pid)) > 0)::int as blocked,
|
|
72
|
+
coalesce(max(extract(epoch from now() - query_start)) filter (where state = 'active'), 0)::numeric(12, 1)::float8 as longest,
|
|
73
|
+
(select setting::int from pg_settings where name = 'max_connections') as max
|
|
74
|
+
from pg_stat_activity where datname = current_database() and backend_type = 'client backend' and pid <> pg_backend_pid()`))[0],
|
|
75
|
+
waits: await q(
|
|
76
|
+
`select coalesce(wait_event_type, 'CPU') as type, coalesce(wait_event, 'running') as event, count(*)::int as sessions
|
|
77
|
+
from pg_stat_activity
|
|
78
|
+
where state = 'active' and pid <> pg_backend_pid() and (datname = current_database() or datname is null)
|
|
79
|
+
group by 1, 2 order by 3 desc, 1, 2`),
|
|
80
|
+
progress: await q(
|
|
81
|
+
`select pid, 'VACUUM' as command, relid::regclass::text as relation, phase,
|
|
82
|
+
case when heap_blks_total > 0 then round(100.0 * heap_blks_scanned / heap_blks_total) end::int as pct
|
|
83
|
+
from pg_stat_progress_vacuum where datname = current_database()
|
|
84
|
+
union all
|
|
85
|
+
select pid, 'ANALYZE', relid::regclass::text, phase,
|
|
86
|
+
case when sample_blks_total > 0 then round(100.0 * sample_blks_scanned / sample_blks_total) end::int
|
|
87
|
+
from pg_stat_progress_analyze where datname = current_database()
|
|
88
|
+
union all
|
|
89
|
+
select pid, command, coalesce(index_relid::regclass::text, relid::regclass::text), phase,
|
|
90
|
+
case when blocks_total > 0 then round(100.0 * blocks_done / blocks_total)
|
|
91
|
+
when tuples_total > 0 then round(100.0 * tuples_done / tuples_total) end::int
|
|
92
|
+
from pg_stat_progress_create_index where datname = current_database()
|
|
93
|
+
union all
|
|
94
|
+
select pid, command, relid::regclass::text, phase,
|
|
95
|
+
case when heap_blks_total > 0 then round(100.0 * heap_blks_scanned / heap_blks_total) end::int
|
|
96
|
+
from pg_stat_progress_cluster where datname = current_database()
|
|
97
|
+
union all
|
|
98
|
+
select pid, command || ' ' || type, relid::regclass::text, tuples_processed || ' rows',
|
|
99
|
+
case when bytes_total > 0 then round(100.0 * bytes_processed / bytes_total) end::int
|
|
100
|
+
from pg_stat_progress_copy where datname = current_database()
|
|
101
|
+
order by pid`),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The part of the Sessions view that public/sql-monitor.js reloads in place. */
|
|
106
|
+
async function livePart(s: Session, showIdle: boolean): Promise<Raw> {
|
|
107
|
+
const d = await liveData(showIdle);
|
|
108
|
+
const t = d.totals as Record<string, number>;
|
|
109
|
+
const backTo = `${URL_BASE}${showIdle ? '?idle=1' : ''}`;
|
|
110
|
+
const action = (pid: unknown, kind: 'cancel' | 'terminate') => html`<form method="post" action="${URL_BASE}/session" class="sm-action">${csrf(s)}
|
|
111
|
+
<input type="hidden" name="pid" value="${String(pid)}"><input type="hidden" name="action" value="${kind}"><input type="hidden" name="idle" value="${showIdle ? '1' : ''}">
|
|
112
|
+
<button class="btn btn-sm${kind === 'terminate' ? ' btn-danger' : ''}" data-confirm="${kind === 'cancel' ? `Cancel the statement session ${pid} is running?` : `Terminate session ${pid}? Its open transaction is rolled back and the client is disconnected.`}"
|
|
113
|
+
aria-label="${kind === 'cancel' ? 'Cancel the statement of' : 'Terminate'} session ${String(pid)}">${kind === 'cancel' ? 'Cancel' : 'Terminate'}</button></form>`;
|
|
114
|
+
return html`<div class="stat-grid u-mb1">
|
|
115
|
+
${([['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked], ['Longest running (s)', t.longest]] as [string, unknown][])
|
|
116
|
+
.map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
|
|
117
|
+
</div>
|
|
118
|
+
${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity)${showIdle ? '' : ', without idle ones'}, active ones first.
|
|
119
|
+
<a href="${showIdle ? URL_BASE : `${URL_BASE}?idle=1`}">${showIdle ? 'Hide idle sessions' : 'Show idle sessions'}</a>.
|
|
120
|
+
A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>).</p>
|
|
121
|
+
<div class="table-wrap"><table class="report report-reflow sm-sessions">
|
|
122
|
+
<thead><tr><th class="num">PID</th><th>User</th><th>Application</th><th>State</th><th>Wait</th><th class="num">Running (s)</th><th>Blocked by</th><th>Query</th><th>Actions</th></tr></thead>
|
|
123
|
+
<tbody>${d.sessions.length ? d.sessions.map((x) => html`<tr${(x.blocked_by as number[]).length ? raw(' class="is-blocked"') : ''}>
|
|
124
|
+
<td class="num" data-label="PID">${String(x.pid)}</td>
|
|
125
|
+
<td data-label="User">${String(x.user ?? '')}</td>
|
|
126
|
+
<td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
|
|
127
|
+
<td data-label="State">${x.idle_tx_s != null ? html`<span class="tag tag-warning">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
|
|
128
|
+
<td data-label="Wait">${x.wait_type ? `${x.wait_type}: ${x.wait ?? ''}` : ''}</td>
|
|
129
|
+
<td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
|
|
130
|
+
<td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag tag-warning">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
|
|
131
|
+
<td data-label="Query" class="u-break">${x.query ? x.queryid && x.queryid !== '0'
|
|
132
|
+
? html`<a href="${URL_BASE}/statements/${String(x.queryid)}" title="Statistics and plan of this statement"><code>${String(x.query).slice(0, 500)}</code></a>`
|
|
133
|
+
: html`<code>${String(x.query).slice(0, 500)}</code>` : ''}</td>
|
|
134
|
+
<td data-label="Actions"><div class="sm-actions">${x.state === 'active' ? action(x.pid, 'cancel') : ''}${action(x.pid, 'terminate')}</div></td></tr>`)
|
|
135
|
+
: html`<tr><td colspan="9" class="empty">No ${showIdle ? 'other' : 'busy'} sessions.</td></tr>`}</tbody></table></div>`)}
|
|
136
|
+
${region('Wait events', html`<p class="muted u-mt0">What the active sessions of the server wait for now (<em>CPU: running</em> when they don't wait).</p>
|
|
137
|
+
${d.waits.length ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Type</th><th>Event</th><th class="num">Sessions</th></tr></thead>
|
|
138
|
+
<tbody>${d.waits.map((w) => html`<tr><td data-label="Type">${String(w.type)}</td><td data-label="Event">${String(w.event)}</td><td class="num" data-label="Sessions">${String(w.sessions)}</td></tr>`)}</tbody></table></div>`
|
|
139
|
+
: html`<p class="muted">No other active sessions.</p>`}`)}
|
|
140
|
+
${d.progress.length ? region('In progress', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">PID</th><th>Command</th><th>Object</th><th>Phase</th><th>Done</th></tr></thead>
|
|
141
|
+
<tbody>${d.progress.map((p) => html`<tr><td class="num" data-label="PID">${String(p.pid)}</td><td data-label="Command">${String(p.command)}</td>
|
|
142
|
+
<td data-label="Object" class="u-break">${String(p.relation ?? '')}</td><td data-label="Phase">${String(p.phase ?? '')}</td>
|
|
143
|
+
<td data-label="Done">${p.pct != null ? html`<progress max="100" value="${String(p.pct)}" aria-label="${String(p.command)} ${String(p.pid)}"></progress> ${String(p.pct)}%` : ''}</td></tr>`)}</tbody></table></div>`) : ''}
|
|
144
|
+
<p class="muted small sm-updated" data-sm-updated>Updated ${new Date().toISOString().slice(11, 19)} UTC · <a href="${backTo}">Refresh</a></p>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------ Top SQL
|
|
148
|
+
|
|
149
|
+
const SORTS: Record<string, { col: string; label: string; asc?: boolean }> = {
|
|
150
|
+
role: { col: 'r.rolname', label: 'Role', asc: true },
|
|
151
|
+
calls: { col: 's.calls', label: 'Calls' },
|
|
152
|
+
total: { col: 's.total_exec_time', label: 'Total ms' },
|
|
153
|
+
mean: { col: 's.mean_exec_time', label: 'Mean ms' },
|
|
154
|
+
rows: { col: 's.rows', label: 'Rows' },
|
|
155
|
+
hit: { col: 's.shared_blks_hit', label: 'Blocks hit' },
|
|
156
|
+
read: { col: 's.shared_blks_read', label: 'Blocks read' },
|
|
157
|
+
query: { col: 's.query', label: 'Statement', asc: true },
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export interface TopRow { queryid: string | null; role: string | null; calls: number; total_ms: number; mean_ms: number; rows: number; hit: number; read: number; query: string }
|
|
161
|
+
|
|
162
|
+
/** pg_stat_statements of this database, by `sort` (a SORTS key), optionally filtered on the text. */
|
|
163
|
+
export async function topStatements(sort: string, desc: boolean, filter: string, limit = 100): Promise<TopRow[]> {
|
|
164
|
+
const by = SORTS[sort] ?? SORTS.total;
|
|
165
|
+
return (await readOnly((q) => q(
|
|
166
|
+
`select s.queryid::text as queryid, r.rolname as role, s.calls::float8 as calls, round(s.total_exec_time::numeric, 2)::float8 as total_ms,
|
|
167
|
+
round(s.mean_exec_time::numeric, 3)::float8 as mean_ms, s.rows::float8 as rows, s.shared_blks_hit::float8 as hit,
|
|
168
|
+
s.shared_blks_read::float8 as read, left(s.query, 1000) as query
|
|
169
|
+
from pg_stat_statements s left join pg_roles r on r.oid = s.userid
|
|
170
|
+
where s.dbid = (select oid from pg_database where datname = current_database())
|
|
171
|
+
and ($1 = '' or s.query ilike '%' || replace(replace(replace($1, '\\', '\\\\'), '%', '\\%'), '_', '\\_') || '%')
|
|
172
|
+
order by ${by.col} ${desc ? 'desc' : 'asc'} nulls last, s.queryid limit $2`, [filter, limit]))) as unknown as TopRow[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ------------------------------------------------------------------ Explain
|
|
176
|
+
|
|
177
|
+
const EXPLAINABLE = /^(select|with|insert|update|delete|merge|values|table)\b/i;
|
|
178
|
+
|
|
179
|
+
export type MonitorPlan = { ok: true; plan: Plan; text: string; generic: boolean } | { ok: false; error: string };
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* EXPLAIN (never ANALYZE) of a statement someone else ran: one statement only, of a kind that has a plan, in a
|
|
183
|
+
* read-only transaction that is rolled back, with timeouts. A normalised text with $1… gets a generic plan
|
|
184
|
+
* (PostgreSQL 16+). `schema` is put first in the search path, as unqualified names were resolved in the original.
|
|
185
|
+
*/
|
|
186
|
+
export async function explainForMonitor(sql: string, schema: string | null): Promise<MonitorPlan> {
|
|
187
|
+
const stmts = splitScript(sql);
|
|
188
|
+
if (stmts.length !== 1 || stmts[0].psql) return { ok: false, error: 'Only one SQL statement can be explained.' };
|
|
189
|
+
const text = stmts[0].sql;
|
|
190
|
+
if (!EXPLAINABLE.test(text)) return { ok: false, error: 'Only SELECT, INSERT, UPDATE, DELETE, MERGE and VALUES statements have a plan.' };
|
|
191
|
+
const generic = /\$\d+/.test(text);
|
|
192
|
+
const c = await owner.pool.connect();
|
|
193
|
+
try {
|
|
194
|
+
if (generic && Number((await c.query(`select current_setting('server_version_num') as v`)).rows[0].v) < 160000)
|
|
195
|
+
return { ok: false, error: 'The statement has parameters ($1, …): PostgreSQL 16 or later can explain it with a generic plan. Open it in the worksheet and fill in values.' };
|
|
196
|
+
await c.query('begin transaction read only');
|
|
197
|
+
await c.query(`set local statement_timeout = '5s'`);
|
|
198
|
+
await c.query(`set local lock_timeout = '1s'`);
|
|
199
|
+
if (schema) await c.query(`select set_config('search_path', format('%I, public', $1::text), true)`, [schema]);
|
|
200
|
+
// the extended protocol (Parse/Bind): the server accepts one statement only, whatever the text hides; a generic
|
|
201
|
+
// plan's parameters are bound to nulls, which it doesn't look at
|
|
202
|
+
const params = Math.max(0, ...[...text.matchAll(/\$(\d+)/g)].map((m) => Number(m[1])));
|
|
203
|
+
if (params > 100) return { ok: false, error: 'The statement has too many parameters to explain.' };
|
|
204
|
+
const out = await c.query({ text: `explain (format json, verbose, costs${generic ? ', generic_plan' : ''}) ${text}`, values: Array(params).fill(null), queryMode: 'extended' } as never);
|
|
205
|
+
const json = (out as { rows: Row[] }).rows[0]?.['QUERY PLAN'];
|
|
206
|
+
const plan = parsePlan(typeof json === 'string' ? JSON.parse(json) : json, false);
|
|
207
|
+
return { ok: true, plan, text: planText(plan), generic };
|
|
208
|
+
} catch (e) {
|
|
209
|
+
const err = e as { message: string; code?: string };
|
|
210
|
+
return { ok: false, error: `${err.message}${err.code ? ` (${err.code})` : ''}` };
|
|
211
|
+
} finally {
|
|
212
|
+
await c.query('rollback').catch(() => {});
|
|
213
|
+
c.release();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ------------------------------------------------------------------ routes
|
|
218
|
+
|
|
219
|
+
/** The administrator's session, or a 403 (anonymous: the sign-in redirect). */
|
|
220
|
+
async function admin(req: Req, reply: Parameters<typeof developer>[1]) {
|
|
221
|
+
const s = await developer(req, reply);
|
|
222
|
+
if (!s) return null;
|
|
223
|
+
if (!(await isAdmin(s.username))) {
|
|
224
|
+
reply.code(403).send('Administrators only.');
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const statementOf = async (queryid: string) => {
|
|
231
|
+
const rows = await readOnly((q) => q(
|
|
232
|
+
`select r.rolname as role, s.toplevel, s.calls::float8 as calls, s.total_exec_time::float8 as total_ms, s.mean_exec_time::float8 as mean_ms,
|
|
233
|
+
s.stddev_exec_time::float8 as stddev_ms, s.min_exec_time::float8 as min_ms, s.max_exec_time::float8 as max_ms, s.rows::float8 as rows,
|
|
234
|
+
s.shared_blks_hit::float8 as hit, s.shared_blks_read::float8 as read, s.temp_blks_read::float8 as temp_read,
|
|
235
|
+
s.temp_blks_written::float8 as temp_written, s.query
|
|
236
|
+
from pg_stat_statements s left join pg_roles r on r.oid = s.userid
|
|
237
|
+
where s.queryid = $1::bigint and s.dbid = (select oid from pg_database where datname = current_database())
|
|
238
|
+
order by s.total_exec_time desc`, [queryid]));
|
|
239
|
+
return rows;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export async function sqlMonitorRoutes(app: FastifyInstance) {
|
|
243
|
+
app.get(URL_BASE, async (req: Req, reply) => {
|
|
244
|
+
const s = await admin(req, reply);
|
|
245
|
+
if (!s) return;
|
|
246
|
+
const showIdle = req.query?.idle === '1';
|
|
247
|
+
const live = await livePart(s, showIdle);
|
|
248
|
+
if (req.query?.fragment === '1') return reply.header('cache-control', 'no-store').type('text/html').send(String(live));
|
|
249
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor')}${viewTabs('sessions')}
|
|
250
|
+
<div class="sm-toolbar" data-sm-controls hidden>
|
|
251
|
+
<label for="sm-interval">Refresh every</label>
|
|
252
|
+
<select id="sm-interval" data-sm-interval>${REFRESH_SECONDS.map((n) => html`<option value="${n}">${n} seconds</option>`)}</select>
|
|
253
|
+
<button type="button" class="btn btn-sm" data-sm-pause aria-pressed="false">Pause</button>
|
|
254
|
+
</div>
|
|
255
|
+
<div class="sm-live" data-sm-live="${URL_BASE}?${showIdle ? 'idle=1&' : ''}fragment=1" aria-live="off">${live}</div>`;
|
|
256
|
+
return send(reply, s, shell(s, 'SQL Monitor', crumbs(), main, 'sql'));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.post(`${URL_BASE}/session`, async (req: Req, reply) => {
|
|
260
|
+
const s = await admin(req, reply);
|
|
261
|
+
if (!s) return;
|
|
262
|
+
const b = (req.body ?? {}) as Record<string, string>;
|
|
263
|
+
const to = `${URL_BASE}${b.idle === '1' ? '?idle=1' : ''}`;
|
|
264
|
+
if (!/^\d{1,9}$/.test(String(b.pid ?? '')) || !['cancel', 'terminate'].includes(String(b.action))) return reply.code(400).send('Bad request');
|
|
265
|
+
// only a client session of this database, and not the connection doing it
|
|
266
|
+
const target = await owner.one<{ pid: number }>(
|
|
267
|
+
`select pid from pg_stat_activity where pid = $1 and datname = current_database() and backend_type = 'client backend' and pid <> pg_backend_pid()`, [Number(b.pid)]);
|
|
268
|
+
if (!target) {
|
|
269
|
+
flash(s, `Session ${b.pid} isn't a client session of this database (any more).`, 'error');
|
|
270
|
+
return back(reply, s, to);
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const fn = b.action === 'cancel' ? 'pg_cancel_backend' : 'pg_terminate_backend';
|
|
274
|
+
const r = await owner.one<{ ok: boolean }>(`select ${fn}($1) as ok`, [target.pid]);
|
|
275
|
+
if (r?.ok) flash(s, b.action === 'cancel' ? `Asked session ${target.pid} to cancel its statement.` : `Terminated session ${target.pid}.`);
|
|
276
|
+
else flash(s, `Session ${target.pid} could not be signalled.`, 'error');
|
|
277
|
+
} catch (e) {
|
|
278
|
+
flash(s, (e as Error).message, 'error');
|
|
279
|
+
}
|
|
280
|
+
return back(reply, s, to);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
app.get(`${URL_BASE}/top`, async (req: Req, reply) => {
|
|
284
|
+
const s = await admin(req, reply);
|
|
285
|
+
if (!s) return;
|
|
286
|
+
const q = req.query ?? {};
|
|
287
|
+
const sort = SORTS[q.sort ?? ''] ? q.sort! : 'total';
|
|
288
|
+
const desc = q.dir ? q.dir !== 'asc' : !SORTS[sort].asc;
|
|
289
|
+
const filter = String(q.q ?? '').slice(0, 200);
|
|
290
|
+
const state = await statementsState();
|
|
291
|
+
let body: Raw;
|
|
292
|
+
if (!state.ok) body = statementsNote(state.reason, state.message);
|
|
293
|
+
else {
|
|
294
|
+
const [rows, info] = await Promise.all([
|
|
295
|
+
topStatements(sort, desc, filter),
|
|
296
|
+
readOnly((r) => r(`select to_char(stats_reset, 'YYYY-MM-DD HH24:MI') as reset, dealloc from pg_stat_statements_info`)).catch(() => [] as Row[]),
|
|
297
|
+
]);
|
|
298
|
+
const maxTotal = Math.max(0, ...rows.map((r) => r.total_ms));
|
|
299
|
+
const maxMean = Math.max(0, ...rows.map((r) => r.mean_ms));
|
|
300
|
+
const link = (key: string) => {
|
|
301
|
+
const on = key === sort;
|
|
302
|
+
const nextDesc = on ? !desc : !SORTS[key].asc;
|
|
303
|
+
const params = new URLSearchParams({ sort: key, dir: nextDesc ? 'desc' : 'asc', ...(filter ? { q: filter } : {}) });
|
|
304
|
+
return html`<a href="${URL_BASE}/top?${params}" title="Sort by ${SORTS[key].label.toLowerCase()}">${SORTS[key].label}${on ? (desc ? ' ▼' : ' ▲') : ''}</a>`;
|
|
305
|
+
};
|
|
306
|
+
const th = (key: string, numeric = true) => html`<th${numeric ? raw(' class="num"') : ''}${key === sort ? raw(` aria-sort="${desc ? 'descending' : 'ascending'}"`) : ''}>${link(key)}</th>`;
|
|
307
|
+
const i = info[0] as Row | undefined;
|
|
308
|
+
body = html`<form method="get" action="${URL_BASE}/top" class="sm-filter u-mb1" role="search">
|
|
309
|
+
<input type="hidden" name="sort" value="${sort}"><input type="hidden" name="dir" value="${desc ? 'desc' : 'asc'}">
|
|
310
|
+
<label class="sr-only" for="sm-q">Statement contains</label><input id="sm-q" name="q" type="search" value="${filter}" maxlength="200" placeholder="Statement contains…">
|
|
311
|
+
<button class="btn btn-sm">${icon('search')} Search</button></form>
|
|
312
|
+
<div class="table-wrap"><table class="report report-reflow sm-top"><thead><tr>${th('role', false)}${th('calls')}${th('total')}${th('mean')}${th('rows')}${th('hit')}${th('read')}${th('query', false)}</tr></thead>
|
|
313
|
+
<tbody>${rows.length ? rows.map((r) => html`<tr>
|
|
314
|
+
<td data-label="Role">${r.role ?? ''}</td>
|
|
315
|
+
<td class="num" data-label="Calls">${num(r.calls)}</td>
|
|
316
|
+
<td class="num sm-barcell" data-label="Total ms">${bar(r.total_ms, maxTotal)}<span>${num(r.total_ms)}</span></td>
|
|
317
|
+
<td class="num sm-barcell" data-label="Mean ms">${bar(r.mean_ms, maxMean)}<span>${num(r.mean_ms)}</span></td>
|
|
318
|
+
<td class="num" data-label="Rows">${num(r.rows)}</td>
|
|
319
|
+
<td class="num" data-label="Blocks hit">${num(r.hit)}</td>
|
|
320
|
+
<td class="num" data-label="Blocks read">${num(r.read)}</td>
|
|
321
|
+
<td data-label="Statement" class="u-break">${r.queryid ? html`<a href="${URL_BASE}/statements/${r.queryid}"><code class="sql-text">${r.query}</code></a>` : html`<code class="sql-text">${r.query}</code>`}</td></tr>`)
|
|
322
|
+
: html`<tr><td colspan="8" class="empty">${filter ? 'No statements match.' : 'No statements recorded yet.'}</td></tr>`}</tbody></table></div>
|
|
323
|
+
<p class="muted small">${i?.reset ? `Statistics since ${String(i.reset)}. ` : ''}At most 100 statements are shown; literals and binds show as <code>$1</code>, <code>$2</code>….</p>
|
|
324
|
+
<form method="post" action="${URL_BASE}/reset">${csrf(s)}
|
|
325
|
+
<button class="btn" data-confirm="Clear the statement statistics of the whole server?">${icon('history')} Reset statistics</button></form>`;
|
|
326
|
+
}
|
|
327
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor')}${viewTabs('top')}
|
|
328
|
+
${region('Top SQL', html`<p class="muted u-mt0">The statements of this database from <code>pg_stat_statements</code>, every role. Click a statement for its plan.</p>${body}`)}`;
|
|
329
|
+
return send(reply, s, shell(s, 'Top SQL', crumbs(['Top SQL']), main, 'sql'));
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
app.post(`${URL_BASE}/reset`, async (req: Req, reply) => {
|
|
333
|
+
const s = await admin(req, reply);
|
|
334
|
+
if (!s) return;
|
|
335
|
+
try {
|
|
336
|
+
await owner.query('select pg_stat_statements_reset()');
|
|
337
|
+
flash(s, 'Statement statistics reset.');
|
|
338
|
+
} catch (e) {
|
|
339
|
+
flash(s, (e as Error).message, 'error');
|
|
340
|
+
}
|
|
341
|
+
return back(reply, s, `${URL_BASE}/top`);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
app.get(`${URL_BASE}/statements/:queryid`, async (req: Req, reply) => {
|
|
345
|
+
const s = await admin(req, reply);
|
|
346
|
+
if (!s) return;
|
|
347
|
+
const queryid = String(req.params.queryid);
|
|
348
|
+
if (!QUERYID_RE.test(queryid)) return reply.code(404).send('Not found');
|
|
349
|
+
const state = await statementsState();
|
|
350
|
+
if (!state.ok) {
|
|
351
|
+
const main = html`<h1 class="u-mb1">Statement</h1>${statementsNote(state.reason, state.message)}`;
|
|
352
|
+
return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
|
|
353
|
+
}
|
|
354
|
+
let rows: Row[];
|
|
355
|
+
try {
|
|
356
|
+
rows = await statementOf(queryid);
|
|
357
|
+
} catch {
|
|
358
|
+
return reply.code(404).send('Not found');
|
|
359
|
+
}
|
|
360
|
+
if (!rows.length) return reply.code(404).send('Not found');
|
|
361
|
+
const sql = String(rows[0].query ?? '');
|
|
362
|
+
const schemas = (await readOnly((q) => q(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname <> 'information_schema' order by 1`))).map((r) => String(r.nspname));
|
|
363
|
+
const schema = typeof req.query?.schema === 'string' && schemas.includes(req.query.schema) ? req.query.schema : '';
|
|
364
|
+
const explained = req.query?.explain === '1' ? await explainForMonitor(sql, schema || null) : null;
|
|
365
|
+
const sum = (k: string) => rows.reduce((a, r) => a + Number(r[k] ?? 0), 0);
|
|
366
|
+
const calls = sum('calls');
|
|
367
|
+
const total = sum('total_ms');
|
|
368
|
+
const tile = (label: string, v: string) => html`<div class="stat"><b>${v}</b><span>${label}</span></div>`;
|
|
369
|
+
const main = html`<h1 class="u-mb1">Statement</h1>
|
|
370
|
+
<div class="stat-grid u-mb1">${tile('Calls', num(calls))}${tile('Total ms', num(total))}${tile('Mean ms', num(calls ? total / calls : 0))}${tile('Rows', num(sum('rows')))}
|
|
371
|
+
${tile('Blocks hit', num(sum('hit')))}${tile('Blocks read', num(sum('read')))}${tile('Temp blocks written', num(sum('temp_written')))}</div>
|
|
372
|
+
${region('Text', html`<pre class="source u-break sm-sql">${sql}</pre>
|
|
373
|
+
<div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} Open in worksheet</button></form>
|
|
374
|
+
<a class="btn" href="${URL_BASE}/top">Back to Top SQL</a></div>`)}
|
|
375
|
+
${region('By role', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Role</th><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Std dev ms</th><th class="num">Min ms</th><th class="num">Max ms</th><th class="num">Rows</th><th class="num">Hit</th><th class="num">Read</th><th class="num">Temp read</th><th class="num">Temp written</th></tr></thead>
|
|
376
|
+
<tbody>${rows.map((r) => html`<tr><td data-label="Role">${String(r.role ?? '')}${r.toplevel === false ? html` <span class="tag">nested</span>` : ''}</td>
|
|
377
|
+
${(['calls', 'total_ms', 'mean_ms', 'stddev_ms', 'min_ms', 'max_ms', 'rows', 'hit', 'read', 'temp_read', 'temp_written'] as const).map((k) =>
|
|
378
|
+
html`<td class="num" data-label="${k.replace('_ms', ' ms').replace('_', ' ')}">${num(r[k])}</td>`)}</tr>`)}</tbody></table></div>`)}
|
|
379
|
+
${region('Explain Plan', html`<p class="muted u-mt0">EXPLAIN without running the statement${/\$\d+/.test(sql) ? ': its parameters ($1, …) get a generic plan' : ''}. Unqualified names are looked up in the schema you choose first.</p>
|
|
380
|
+
<form method="get" action="${URL_BASE}/statements/${queryid}" class="sm-filter u-mb1"><input type="hidden" name="explain" value="1">
|
|
381
|
+
<label for="sm-schema">Schema</label><select id="sm-schema" name="schema"><option value="">(the search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
382
|
+
<button class="btn">${icon('play')} Explain</button></form>
|
|
383
|
+
${explained ? explained.ok ? planView(explained.plan, explained.text) : html`<div class="alert alert-error" role="alert">${explained.error}</div>` : ''}`)}`;
|
|
384
|
+
return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
app.post(`${URL_BASE}/statements/:queryid/worksheet`, async (req: Req, reply) => {
|
|
388
|
+
const s = await admin(req, reply);
|
|
389
|
+
if (!s) return;
|
|
390
|
+
const queryid = String(req.params.queryid);
|
|
391
|
+
if (!QUERYID_RE.test(queryid) || !(await statementsState()).ok) return reply.code(404).send('Not found');
|
|
392
|
+
const rows = await statementOf(queryid).catch(() => [] as Row[]);
|
|
393
|
+
if (!rows.length) return reply.code(404).send('Not found');
|
|
394
|
+
s.state.__SQLDRAFT = String(rows[0].query ?? '');
|
|
395
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
396
|
+
});
|
|
397
|
+
}
|
package/src/builder/sql-tools.ts
CHANGED
|
@@ -34,15 +34,19 @@ export const SQL_TOOLS: SqlTool[] = [
|
|
|
34
34
|
{ key: 'sample', href: '/builder/sql/sample-data', icon: 'layers', label: 'Sample Data', description: 'Rows that look real, for development.', group: 'development', tab: 'sample' },
|
|
35
35
|
{ key: 'ddl', href: '/builder/sql/ddl', icon: 'file', label: 'Generate DDL', description: 'A schema\'s objects as one script.', group: 'development', tab: 'ddl' },
|
|
36
36
|
{ key: 'compare', href: '/builder/sql/compare', icon: 'layers', label: 'Schema Comparison', description: 'The differences between two schemas, and the SQL to align them.', group: 'development', tab: 'compare' },
|
|
37
|
+
{ key: 'changes', href: '/builder/sql/changes', icon: 'history', label: 'Change Log', description: 'DDL run here as change sets per schema; download as Flyway, Liquibase or SQL migrations.', group: 'development', tab: 'changes' },
|
|
37
38
|
{ key: 'modeler', href: '/builder/sql/modeler', icon: 'org', label: 'Data Modeler', description: 'Relational diagrams of tables and their foreign keys; DDL and a data dictionary from them.', group: 'development', tab: 'modeler' },
|
|
38
|
-
{ key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: '
|
|
39
|
+
{ key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'REST modules, templates and handlers; tables and views as REST endpoints (AutoREST); OpenAPI.', group: 'development', tab: 'rest' },
|
|
39
40
|
{ key: 'ai', href: '/builder/sql/ai', icon: 'bolt', label: 'AI', description: 'SQL from a question, explanations and table descriptions.', group: 'development', tab: 'ai' },
|
|
40
41
|
{ key: 'utilities', href: '/builder/sql/utilities', icon: 'wrench', label: 'Utilities', description: 'Object reports, About Database and the other utilities.', group: 'administration', tab: 'utilities', main: true },
|
|
41
42
|
{ key: 'backups', href: '/builder/backups', icon: 'archive', label: 'Manage Backups', description: 'Backups of the workspace\'s applications.', group: 'administration' },
|
|
42
43
|
{ key: 'workspaces', href: '/builder/workspaces', icon: 'layers', label: 'Workspaces', description: 'Workspaces, their developers and database roles.', group: 'administration', admin: true },
|
|
43
44
|
{ key: 'dbusers', href: '/builder/db-users', icon: 'key', label: 'Database Users', description: 'PostgreSQL roles: sign-in, expiry, memberships and schema privileges.', group: 'administration', admin: true },
|
|
45
|
+
{ key: 'datapump', href: '/builder/sql/data-pump', icon: 'archive', label: 'Schema Export / Import', description: 'A schema to a file with pg_dump, a file into a schema with pg_restore (Data Pump).', group: 'administration', tab: 'datapump', admin: true },
|
|
46
|
+
{ key: 'scheduling', href: '/builder/sql/scheduling', icon: 'clock', label: 'Scheduling', description: 'pg_cron jobs: SQL on a schedule, next runs, run history; pgkiln\'s automations without it.', group: 'administration', tab: 'scheduling', admin: true },
|
|
44
47
|
{ key: 'developers', href: '/builder/developers', icon: 'users', label: 'Developers', description: 'Builder accounts and their workspaces.', group: 'administration' },
|
|
45
|
-
{ key: 'monitor', href: '/builder/sql/
|
|
48
|
+
{ key: 'monitor', href: '/builder/sql/monitor', icon: 'activity', label: 'SQL Monitor', description: 'Sessions live: what they run, wait for and what blocks them; cancel or terminate.', group: 'monitoring', tab: 'monitor', admin: true },
|
|
49
|
+
{ key: 'topsql', href: '/builder/sql/monitor/top', icon: 'chart', label: 'Top SQL', description: 'The slowest and most called statements of the database, with their plans.', group: 'monitoring', tab: 'monitor', admin: true },
|
|
46
50
|
{ key: 'about', href: '/builder/sql/utilities/about', icon: 'info', label: 'About Database', description: 'The server: version, settings, extensions and sizes.', group: 'monitoring' },
|
|
47
51
|
{ key: 'reports', href: '/builder/sql/utilities/reports', icon: 'list', label: 'Object Reports', description: 'Tables without keys, unindexed foreign keys, row level security gaps and more.', group: 'monitoring' },
|
|
48
52
|
{ key: 'activity', href: '/builder/dashboard', icon: 'chart', label: 'Monitor Activity', description: 'Page views, errors and slow pages of the applications.', group: 'monitoring' },
|
|
@@ -15,8 +15,8 @@ import { workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
|
15
15
|
// the SQL Workshop's connection may use (workshopDb: an isolated
|
|
16
16
|
// workspace's developers see only their own roles' schemas);
|
|
17
17
|
// - About Database: version, a few settings, extensions, sizes;
|
|
18
|
-
// - Database Monitor
|
|
19
|
-
//
|
|
18
|
+
// - (sprint 102) the Database Monitor moved to SQL Workshop › SQL Monitor
|
|
19
|
+
// (sql-monitor.ts); its old address redirects there.
|
|
20
20
|
//
|
|
21
21
|
// Every query runs read only with a statement timeout.
|
|
22
22
|
|
|
@@ -153,7 +153,9 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
|
|
|
153
153
|
${tile('/sql/compare', 'layers', 'Schema Comparison', 'The differences between two schemas, and the SQL to align them.')}
|
|
154
154
|
${tile('/sql/utilities/reports', 'list', 'Object Reports', 'Tables without a primary key, foreign keys without an index, row-level security, grants, sizes.')}
|
|
155
155
|
${tile('/sql/utilities/about', 'help', 'About Database', 'The PostgreSQL version, settings, extensions and sizes.')}
|
|
156
|
-
${admin ? tile('/sql/
|
|
156
|
+
${admin ? tile('/sql/data-pump', 'archive', 'Schema Export / Import', 'A schema to a file with pg_dump, a file into a schema with pg_restore (administrators).') : ''}
|
|
157
|
+
${admin ? tile('/sql/scheduling', 'clock', 'Scheduling', 'pg_cron jobs: SQL on a schedule, next runs and run history (administrators).') : ''}
|
|
158
|
+
${admin ? tile('/sql/monitor', 'activity', 'SQL Monitor', 'Sessions, running and blocked queries, wait events and Top SQL (administrators).') : ''}
|
|
157
159
|
</div>`;
|
|
158
160
|
return send(reply, s, shell(s, 'SQL Workshop utilities', [['SQL Workshop', `${BASE}/sql`], ['Utilities']], main, 'sql'));
|
|
159
161
|
});
|
|
@@ -234,45 +236,11 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
|
|
|
234
236
|
return send(reply, s, shell(s, 'About Database', crumbs('About Database'), main, 'sql'));
|
|
235
237
|
});
|
|
236
238
|
|
|
239
|
+
// (sprint 102) the Database Monitor became SQL Workshop › SQL Monitor (sql-monitor.ts)
|
|
237
240
|
app.get(`${BASE}/sql/utilities/monitor`, async (req: Req, reply) => {
|
|
238
241
|
const s = await developer(req, reply);
|
|
239
242
|
if (!s) return;
|
|
240
243
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Administrators only.');
|
|
241
|
-
|
|
242
|
-
sessions: await q(`select a.pid, a.usename as "user", a.application_name as app, a.client_addr::text as client, a.state,
|
|
243
|
-
a.wait_event_type || coalesce(': ' || a.wait_event, '') as wait,
|
|
244
|
-
to_char(a.backend_start, 'YYYY-MM-DD HH24:MI') as since,
|
|
245
|
-
case when a.state = 'active' then round(extract(epoch from now() - a.query_start)::numeric, 1) end as running_s,
|
|
246
|
-
case when a.state like 'idle in transaction%' then round(extract(epoch from now() - a.state_change)::numeric, 1) end as idle_tx_s,
|
|
247
|
-
pg_blocking_pids(a.pid) as blocked_by, left(a.query, 500) as query
|
|
248
|
-
from pg_stat_activity a
|
|
249
|
-
where a.datname = current_database() and a.backend_type = 'client backend' and a.pid <> pg_backend_pid()
|
|
250
|
-
order by a.state = 'active' desc, a.query_start nulls last limit 200`),
|
|
251
|
-
totals: (await q(`select count(*)::int as sessions, count(*) filter (where state = 'active')::int as active,
|
|
252
|
-
count(*) filter (where state like 'idle in transaction%')::int as idle_tx,
|
|
253
|
-
count(*) filter (where cardinality(pg_blocking_pids(pid)) > 0)::int as blocked,
|
|
254
|
-
(select setting::int from pg_settings where name = 'max_connections') as max
|
|
255
|
-
from pg_stat_activity where datname = current_database() and backend_type = 'client backend'`))[0],
|
|
256
|
-
}));
|
|
257
|
-
const t = data.totals as Record<string, number>;
|
|
258
|
-
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
|
|
259
|
-
<div class="stat-grid u-mb1">
|
|
260
|
-
${[['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked]]
|
|
261
|
-
.map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
|
|
262
|
-
</div>
|
|
263
|
-
${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity), active ones first. A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>). Reload the page to refresh it.</p>
|
|
264
|
-
<div class="table-wrap"><table class="report report-reflow">
|
|
265
|
-
<thead><tr><th class="num">PID</th><th>User</th><th>Application</th><th>State</th><th>Wait</th><th class="num">Running (s)</th><th>Blocked by</th><th>Query</th></tr></thead>
|
|
266
|
-
<tbody>${data.sessions.length ? data.sessions.map((x) => html`<tr>
|
|
267
|
-
<td class="num" data-label="PID">${String(x.pid)}</td>
|
|
268
|
-
<td data-label="User">${String(x.user ?? '')}</td>
|
|
269
|
-
<td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
|
|
270
|
-
<td data-label="State">${x.idle_tx_s != null ? html`<span class="tag">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
|
|
271
|
-
<td data-label="Wait">${String(x.wait ?? '')}</td>
|
|
272
|
-
<td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
|
|
273
|
-
<td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
|
|
274
|
-
<td data-label="Query" class="u-break"><code>${String(x.query ?? '')}</code></td></tr>`)
|
|
275
|
-
: html`<tr><td colspan="8" class="empty">No other sessions.</td></tr>`}</tbody></table></div>`)}`;
|
|
276
|
-
return send(reply, s, shell(s, 'Database Monitor', crumbs('Database Monitor'), main, 'sql'));
|
|
244
|
+
return reply.redirect(`${BASE}/sql/monitor`, 301);
|
|
277
245
|
});
|
|
278
246
|
}
|
package/src/builder/sql.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { objectActionRoutes } from './object-actions.ts';
|
|
|
21
21
|
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
22
|
import { dataModelerRoutes } from './data-modeler.ts';
|
|
23
23
|
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
24
|
+
import { restModuleRoutes } from './rest-modules.ts';
|
|
25
|
+
import { changeLogRoutes } from './changelog.ts';
|
|
24
26
|
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
25
27
|
|
|
26
28
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
@@ -133,4 +135,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
133
135
|
await app.register(dataModelerRoutes);
|
|
134
136
|
// (sprint 100) RESTful Services: AutoREST (autorest.ts)
|
|
135
137
|
await app.register(autoRestBuilderRoutes);
|
|
138
|
+
// (sprint 104) RESTful Services: modules, templates and handlers (rest-modules.ts)
|
|
139
|
+
await app.register(restModuleRoutes);
|
|
140
|
+
// (sprint 106) the change log (changelog.ts)
|
|
141
|
+
await app.register(changeLogRoutes);
|
|
136
142
|
}
|
|
@@ -5,7 +5,8 @@ import { clientIp, logActivity, type Session } from '../session.ts';
|
|
|
5
5
|
import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
6
6
|
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
7
7
|
import { clearCompletions } from './code-editor.ts';
|
|
8
|
-
import { usableSchemas, workshopDb } from './workspaces.ts';
|
|
8
|
+
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
9
|
+
import { joinStatements, recordChanges } from '../changelog.ts';
|
|
9
10
|
import { checkTable, emptyTable, ON_DELETE, readTable, tableSql, type ColumnDef, type TableDef } from '../tabledef.ts';
|
|
10
11
|
|
|
11
12
|
// (sprint 95, Database Actions DA-8: Table Properties) Create a table, edit one, or start from one as a template, in
|
|
@@ -196,6 +197,8 @@ async function page(req: Req, reply: FastifyReply) {
|
|
|
196
197
|
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
|
|
197
198
|
clearCompletions();
|
|
198
199
|
if (!failed) {
|
|
200
|
+
// (sprint 106, DA-17) one change set in the change log
|
|
201
|
+
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: def.schema, statements: [joinStatements(ddl)] });
|
|
199
202
|
flash(s, `${editing ? 'Changed' : 'Created'} ${def.schema}.${def.name}: ${ddl.length} statement${ddl.length === 1 ? '' : 's'}.`);
|
|
200
203
|
const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
|
|
201
204
|
return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
|
package/src/builder/top-sql.ts
CHANGED
|
@@ -14,10 +14,32 @@ export type TopSql =
|
|
|
14
14
|
| { ok: true; rows: { queryid: string; calls: number; total_ms: number; mean_ms: number; rows: number; share: number; query: string }[] }
|
|
15
15
|
| { ok: false; reason: 'no_role' | 'not_installed' | 'not_loaded' | 'error'; message?: string };
|
|
16
16
|
|
|
17
|
+
/** (sprint 102) Whether pg_stat_statements can be read: created in this database and loaded by the server. */
|
|
18
|
+
export async function statementsState(): Promise<{ ok: true } | { ok: false; reason: 'not_installed' | 'not_loaded' | 'error'; message?: string }> {
|
|
19
|
+
if (!(await owner.one(`select 1 as ok from pg_extension where extname = 'pg_stat_statements'`))) return { ok: false, reason: 'not_installed' };
|
|
20
|
+
try {
|
|
21
|
+
await owner.query('select 1 from pg_stat_statements limit 1');
|
|
22
|
+
return { ok: true };
|
|
23
|
+
} catch (e) {
|
|
24
|
+
const message = (e as Error).message;
|
|
25
|
+
return { ok: false, reason: /shared_preload_libraries/.test(message) ? 'not_loaded' : 'error', message };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why the statistics can't be shown, and how to turn them on. */
|
|
30
|
+
export function statementsNote(reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
|
|
31
|
+
return html`<div class="alert alert-error" role="alert">${reason === 'not_installed'
|
|
32
|
+
? html`The <code>pg_stat_statements</code> extension isn't installed in this database. To turn it on: set <code>shared_preload_libraries = 'pg_stat_statements'</code> (postgresql.conf, or <code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>), restart PostgreSQL, then as a superuser: <code>create extension pg_stat_statements;</code>`
|
|
33
|
+
: reason === 'not_loaded'
|
|
34
|
+
? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
|
|
35
|
+
: html`The statistics could not be read: ${message ?? ''}`}</div>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/** The role's statements, slowest in total first (or by mean time, calls or rows). */
|
|
18
39
|
export async function topSql(role: string | null, sort = 'total', limit = 50): Promise<TopSql> {
|
|
19
40
|
if (!role) return { ok: false, reason: 'no_role' };
|
|
20
|
-
|
|
41
|
+
const state = await statementsState();
|
|
42
|
+
if (!state.ok) return state;
|
|
21
43
|
try {
|
|
22
44
|
const res = await owner.query(
|
|
23
45
|
`select queryid::text, calls::int, round(total_exec_time::numeric, 1)::float8 as total_ms, round(mean_exec_time::numeric, 2)::float8 as mean_ms,
|
|
@@ -61,11 +83,7 @@ export async function topSqlRoutes(app: FastifyInstance) {
|
|
|
61
83
|
: html`<p class="muted">No statements recorded for <code>${a.db_role}</code> yet. Use the application, then come back.</p>`
|
|
62
84
|
: top.reason === 'no_role'
|
|
63
85
|
? html`<div class="alert alert-error" role="alert">This application runs as the owner role, so its statements can't be told apart. Give it a database role in Settings.</div>`
|
|
64
|
-
:
|
|
65
|
-
? html`The <code>pg_stat_statements</code> extension isn't installed. As a superuser: <code>create extension pg_stat_statements;</code>`
|
|
66
|
-
: top.reason === 'not_loaded'
|
|
67
|
-
? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
|
|
68
|
-
: html`The statistics could not be read: ${top.message}`}</div>`;
|
|
86
|
+
: statementsNote(top.reason, top.message);
|
|
69
87
|
const main = html`${appHeader(a, 'activity')}
|
|
70
88
|
${region('Top SQL', html`<p class="muted u-mt0">Statements run as the role <code>${a.db_role ?? '(owner)'}</code> since the statistics were last reset, from <code>pg_stat_statements</code>. Literals and binds show as <code>$1</code>, <code>$2</code>…; applications that share a role share this list. <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
|
|
71
89
|
${body}`)}`;
|