@mason-data/cli 0.7.0 → 0.8.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/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE USAGE VIEW (#observability/usage, a view of the Observability group; `#usage`, its old address, lands here;
|
|
3
|
+
* docs/OBSERVABILITY.md "Pages"): what the Postgres API answered over the time range the Observability views share —
|
|
4
|
+
* every part following it. Now: who is connected now (`/api/state` `sessions`) and the statements running now
|
|
5
|
+
* (`mason logs --running`, GET /api/running) with a Cancel that asks first (`mason cancel`, POST /api/cancel), read
|
|
6
|
+
* every few seconds. Any other range: who sent statements in it and the slowest that completed (GET /api/activity),
|
|
7
|
+
* read once when chosen. Either way: KPI cards for the range (`/api/activity` `window`: the statements, the failed ones,
|
|
8
|
+
* p95, rows and bytes sent, each with a sparkline), four small charts (statements, p95, the copy's size and its rows,
|
|
9
|
+
* `mason usage`'s samples: the hour a minute a point, the day an hour, the week six hours), the unsupported SQL of the
|
|
10
|
+
* range (mason status's, GET /api/unsupported), the samples a line, newest first (behind a details once there are
|
|
11
|
+
* charts), and each copy's own size. A row LEADS ON: a client to its statements, a card to the statements it counts,
|
|
12
|
+
* on Observability › Queries filtered so (`queriesHref`). Every number a chart draws is also text, in a card or the
|
|
13
|
+
* samples' table. A page module (src/web/app.js has the contract). Never the engine's name.
|
|
14
|
+
*/
|
|
15
|
+
import { esc, n, msText, bytesText, fileSize, duration, details, createOpenSet, cards, card, empty, panel, skeleton, sortTh, sortedBy, nextSort, readPart, usagePath, utcMinute,
|
|
16
|
+
RANGES, DEFAULT_RANGE, rangeOf, isLive, rangeEvery, firstLine, spanText, agoText, utcText, clientText, clip, TITLE_MAX, unsupportedEntry, renderFailure, json, pop, fresh, actId,
|
|
17
|
+
statementCell, POLL_MS } from "../shared.js";
|
|
18
|
+
import { spark, seriesChart, seriesOf, drawable } from "../charts.js";
|
|
19
|
+
|
|
20
|
+
const EVERY_MS = 60_000;
|
|
21
|
+
/** A step said as the charts' titles say it: `a minute`, `an hour`, `every 6 hours`. */
|
|
22
|
+
const perStep = (seconds) => seconds === 60 ? "a minute" : seconds === 3600 ? "an hour" : seconds === 86_400 ? "a day" : seconds % 3600 === 0 ? `every ${seconds / 3600} hours` : `every ${duration(seconds)}`;
|
|
23
|
+
/** How often a copy's own size is measured, in seconds (src/duck/usage.ts SIZE_EVERY_MS, held equal by the tests). */
|
|
24
|
+
export const SIZE_EVERY_SECONDS = 600;
|
|
25
|
+
|
|
26
|
+
const th = (cols) => `<thead><tr>${cols.map((c) => `<th scope="col">${esc(c)}</th>`).join("")}</tr></thead>`;
|
|
27
|
+
const num = (v) => `<td class="num">${esc(v)}</td>`;
|
|
28
|
+
|
|
29
|
+
/** The caveat of a window the request log keeps fewer days of than it asks (src/cli/commands.ts `queryLines`); "" when it keeps all. */
|
|
30
|
+
export const coveredText = (w) => w.covered < w.days ? `the request log keeps ${w.covered} day${w.covered === 1 ? "" : "s"}: MASON_HISTORY_DAYS` : "";
|
|
31
|
+
|
|
32
|
+
/** One sample's cells in `mason usage`'s words (src/cli/usage.ts `sampleLine`): the strings the table writes, held equal by the tests. */
|
|
33
|
+
export function sampleCells(s) {
|
|
34
|
+
const bad = s.statementsRefused + s.statementsFailed + s.statementsCancelled;
|
|
35
|
+
return {
|
|
36
|
+
time: utcMinute(s.at), statements: n(s.statements), errors: bad === 0 ? "" : n(bad),
|
|
37
|
+
rowsSent: n(s.rowsSent), bytesSent: bytesText(s.bytesSent), p95: s.durationP95Ms === null ? "–" : msText(s.durationP95Ms),
|
|
38
|
+
copy: s.copyBytes === null ? "–" : fileSize(s.copyBytes), rows: n(s.copyRows), failing: s.tablesFailed === 0 ? "" : n(s.tablesFailed),
|
|
39
|
+
oldest: s.oldestSyncSeconds === null ? "–" : duration(s.oldestSyncSeconds), lag: s.streamLagSeconds === null ? "–" : duration(s.streamLagSeconds),
|
|
40
|
+
memory: `${fileSize(s.rssBytes)}${s.copyMemoryBytes === null ? "" : ` (engine ${fileSize(s.copyMemoryBytes)} of ${fileSize(s.copyMemoryLimitBytes)})`}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The charts, in order: [holder key, title, sample field, kind, how a hovered value is said (exactly, as the table
|
|
45
|
+
* says it), the axis's unit]. One series each, from a zero baseline. "Statements" is "Statements an hour" by the step. */
|
|
46
|
+
const CHARTS = [
|
|
47
|
+
["usage-statements", "Statements", "statements", "bars", n, "count"],
|
|
48
|
+
["usage-p95", "p95 duration", "durationP95Ms", "line", (v) => msText(v), "ms"],
|
|
49
|
+
["usage-copy-bytes", "Size of the synced data", "copyBytes", "line", fileSize, "bytes"],
|
|
50
|
+
["usage-copy-rows", "Rows in the synced data", "copyRows", "line", n, "count"],
|
|
51
|
+
];
|
|
52
|
+
/** A chart's title at a step: the statements are counted per step, the rest are levels. */
|
|
53
|
+
const chartTitle = (key, title, step) => key === "usage-statements" ? `Statements ${perStep(step ?? 3600)}` : title;
|
|
54
|
+
/** The sparklines of the cards, by card: [holder key, title, sample field]. The failed ones are the samples' refused and failed together. */
|
|
55
|
+
const SPARKS = { statements: ["usage-spark-statements", "Statements", "statements"], failed: ["usage-spark-failed", "Failed statements", "failedCount"],
|
|
56
|
+
p95: ["usage-spark-p95", "p95", "durationP95Ms"], rows: ["usage-spark-rows", "Rows sent", "rowsSent"], bytes: ["usage-spark-bytes", "Bytes sent", "bytesSent"] };
|
|
57
|
+
|
|
58
|
+
const samplesOf = (usage) => usage && Array.isArray(usage.samples) ? usage.samples.map((s) => ({ ...s, failedCount: s.statementsRefused + s.statementsFailed })) : [];
|
|
59
|
+
|
|
60
|
+
/** The specs of the charts and the sparklines from `/api/usage`'s body (anything else draws none), by holder key: what `charts()` returns. */
|
|
61
|
+
export function usageSpecs(usage) {
|
|
62
|
+
const samples = samplesOf(usage);
|
|
63
|
+
const specs = [
|
|
64
|
+
...CHARTS.map(([key, label, field, kind, format, unit]) => [key, { kind, label: chartTitle(key, label, usage?.stepSeconds), format, unit, ...seriesOf(samples, field) }]),
|
|
65
|
+
...Object.values(SPARKS).map(([key, label, field]) => [key, { kind: "spark", label, ...seriesOf(samples, field) }]),
|
|
66
|
+
];
|
|
67
|
+
return Object.fromEntries(specs.filter(([, spec]) => drawable(spec)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The four charts' holders, each under its title (cut with an ellipsis when long, its whole the title): a series that can
|
|
72
|
+
* be drawn gets its holder; one with a single sample says it and its value (a chart needs two); none with no sample at
|
|
73
|
+
* all (the samples' table says so). `range` names the span in each chart's label.
|
|
74
|
+
*/
|
|
75
|
+
export function renderCharts(usage, range = DEFAULT_RANGE) {
|
|
76
|
+
const specs = usageSpecs(usage);
|
|
77
|
+
const samples = samplesOf(usage);
|
|
78
|
+
if (!samples.length) return "";
|
|
79
|
+
const span = (RANGES[rangeOf(range)] ?? RANGES[DEFAULT_RANGE]).span;
|
|
80
|
+
const blocks = CHARTS.map(([key, title, field, , format]) => {
|
|
81
|
+
const name = chartTitle(key, title, usage.stepSeconds);
|
|
82
|
+
const head = `<p class="card-label" title="${esc(name)}">${esc(name)}</p>`;
|
|
83
|
+
if (specs[key]) return `<div class="chart-card">${head}${seriesChart(key, `${name}, ${span}`)}</div>`;
|
|
84
|
+
const values = samples.map((x) => x[field]).filter((v) => typeof v === "number");
|
|
85
|
+
return `<div class="chart-card note">${head}<p class="dim">${values.length === 1 ? `One sample so far: ${esc(format(values[0]))}. A chart needs two.` : "No value in this time."}</p></div>`;
|
|
86
|
+
});
|
|
87
|
+
return `<div class="chart-grid">${blocks.join("")}</div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const RUNNING = "/api/running";
|
|
91
|
+
const STATE = "/api/state";
|
|
92
|
+
/** Who is connected: the clients with the most sessions (or statements) shown, this many; the rest in one row. */
|
|
93
|
+
const SESSIONS_TOP = 10;
|
|
94
|
+
/** What a past range's statements were (GET /api/activity): read once when chosen, every 5 s while the range is Now. */
|
|
95
|
+
export const activityPath = (range = DEFAULT_RANGE) => `/api/activity?since=${RANGES[rangeOf(range)].since}`;
|
|
96
|
+
const ACTIVITY_EVERY_MS = 5000;
|
|
97
|
+
/** The unsupported SQL of a range (`/api/unsupported`: its 10 entries the most refused first): it says what to rewrite. */
|
|
98
|
+
const unsupportedPath = (range = DEFAULT_RANGE) => `/api/unsupported?since=${RANGES[rangeOf(range)].since}`;
|
|
99
|
+
const UNSUPPORTED_EVERY_MS = 60_000;
|
|
100
|
+
const plural = (count, word) => `${n(count)} ${word}${count === 1 ? "" : "s"}`;
|
|
101
|
+
/** The key a running statement is asked about by: its session and its statement's number. */
|
|
102
|
+
const keyOf = (session, statement) => `${session}:${statement}`;
|
|
103
|
+
/** A range's words on a card: `last 15 min`, `last hour`, `last 24 hours`. */
|
|
104
|
+
const shortSpan = (range) => { const r = RANGES[rangeOf(range)]; return r.live ? "last 15 min" : r.span.replace(/^the /, ""); };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* WHERE A ROW LEADS: Observability › Queries filtered to what it counts (`user`, `client`, `slow`, `status`), at the
|
|
108
|
+
* range shown (the default left out, as the shell's links do). A filter that is "" is no filter.
|
|
109
|
+
*/
|
|
110
|
+
export function queriesHref(filters = {}, range = DEFAULT_RANGE) {
|
|
111
|
+
const q = new URLSearchParams(Object.entries({ ...filters, range: rangeOf(range) === DEFAULT_RANGE ? "" : rangeOf(range) }).filter(([, v]) => v !== "" && v !== undefined && v !== null && v !== false));
|
|
112
|
+
const qs = q.toString();
|
|
113
|
+
return `#observability/queries${qs ? `?${qs}` : ""}`;
|
|
114
|
+
}
|
|
115
|
+
/** A client's Queries link: its statements in the range. */
|
|
116
|
+
const queriesLink = (user, client, range, label = "Queries") => `<a class="btn small" href="${esc(queriesHref({ user, client }, range))}" title="${esc(user || client
|
|
117
|
+
? `the statements ${user ? `of ${user}` : ""}${user && client ? " " : ""}${client ? `from ${client}` : ""} in ${RANGES[rangeOf(range)].span}`
|
|
118
|
+
: `every statement in ${RANGES[rangeOf(range)].span}: a token's session that names no client cannot be told apart`)}">${esc(label)}</a>`;
|
|
119
|
+
|
|
120
|
+
/** What a cancel answered, as `mason cancel` says it: its headline, or the refusal as the Failure. */
|
|
121
|
+
function outcomeHtml(o) {
|
|
122
|
+
if (o.failure) return `${renderFailure(o.failure)}<p class="pop-actions"><button type="button" class="small" data-dismiss-cancelled data-pop-close>Dismiss</button></p>`;
|
|
123
|
+
return `<p class="done"><span class="headline" title="SQLSTATE 57014">Cancelled session ${o.cancelled.session}'s statement: it ends now, and its client gets a "canceling statement" error.</span></p>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* WHO IS CONNECTED NOW (`/api/state` `sessions`: { open, byClient: [{ user, client, open, running }] } or { error }): a row
|
|
128
|
+
* per user and client — the sessions it has open, the statements it is running, and Queries: its statements on
|
|
129
|
+
* Observability › Queries. "" when the run did not say.
|
|
130
|
+
*/
|
|
131
|
+
export function renderSessions(sessions, top = SESSIONS_TOP, range = DEFAULT_RANGE) {
|
|
132
|
+
if (!sessions) return "";
|
|
133
|
+
if ("error" in sessions) return `<p class="count">Who is connected could not be read (${esc(firstLine(String(sessions.error)))})</p>`;
|
|
134
|
+
if (!sessions.byClient.length) return empty("No client is connected now.");
|
|
135
|
+
// the clients with the most sessions first; past `top`, the others in one row (200 clients are not 200 rows)
|
|
136
|
+
const byOpen = sortedBy(sessions.byClient, (c) => c.open * 1e6 + c.running, "desc");
|
|
137
|
+
const shown = byOpen.slice(0, top), rest = byOpen.slice(top);
|
|
138
|
+
const sum = (list, k) => list.reduce((a, c) => a + c[k], 0);
|
|
139
|
+
const others = rest.length ? `<tr class="others"><td colspan="2">${esc(n(rest.length))} other client${rest.length === 1 ? "" : "s"}</td><td class="num">${esc(n(sum(rest, "open")))}</td><td class="num">${esc(n(sum(rest, "running")))}</td><td></td></tr>` : "";
|
|
140
|
+
const cell = (v) => `<td><span class="clip" title="${esc(v)}">${esc(v)}</span></td>`;
|
|
141
|
+
return panel(`<table class="sessions"><thead><tr><th scope="col">User</th><th scope="col">Client</th><th scope="col" class="num">Open</th><th scope="col" class="num">Running</th><th scope="col" class="act"></th></tr></thead><tbody>` +
|
|
142
|
+
shown.map((c) => `<tr>${cell(c.user || "(no user)")}${cell(clientText(c.client))}<td class="num">${esc(n(c.open))}</td><td class="num">${esc(n(c.running))}</td><td class="act">${queriesLink(c.user, c.client, range)}</td></tr>`).join("") + others +
|
|
143
|
+
`</tbody></table>`) + `<p class="dim">${esc(n(sessions.open))} session${sessions.open === 1 ? "" : "s"} open${rest.length ? `, from ${esc(n(byOpen.length))} clients` : ""}.</p>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* THE THREE CARDS of a window of the request log (`day`: `/api/activity`'s `window`, `/api/queries`' `day` or `week`; `label` its name): the
|
|
148
|
+
* statements, the failed ones (the log's Failed: refused or failed; its sub-line names each and the cancelled) and the
|
|
149
|
+
* completed ones' p95. `line(key)` is a card's sparkline holder (the Usage page draws them; "" for none): ONE place
|
|
150
|
+
* derives these facts. `hrefs` (by card: statements, failed, p95) makes a card a link to what it counts.
|
|
151
|
+
*/
|
|
152
|
+
export function statementCards(day, line = () => "", label = "last 24 hours", hrefs = {}) {
|
|
153
|
+
const failed = day.refused + day.failed;
|
|
154
|
+
return [
|
|
155
|
+
card(`Statements, ${label}`, n(day.statements), `${n(day.completed)} completed`, line("statements"), "", hrefs.statements ?? ""),
|
|
156
|
+
card(`Failed, ${label}`, n(failed), `${n(day.refused)} refused, ${n(day.failed)} failed${day.cancelled ? `, ${n(day.cancelled)} cancelled` : ""}`, line("failed"), failed ? "warn" : "", hrefs.failed ?? ""),
|
|
157
|
+
card(`p95, ${label}`, day.durationMs === null ? "–" : msText(day.durationMs.p95),
|
|
158
|
+
day.durationMs === null ? "no statement completed" : `average ${msText(day.durationMs.avg)} · p50 ${msText(day.durationMs.p50)}`, line("p95"), "", hrefs.p95 ?? ""),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* THE STATEMENTS RUNNING NOW (`mason logs --running --json`'s `running`): a row each — session, statement, user, client,
|
|
164
|
+
* how long it has run (a relative time that moves with each poll; when it started, in UTC, its title), the rows sent so
|
|
165
|
+
* far, the statement — and Cancel. The one being cancelled (`asking`, `session:statement`) asks first, its question a
|
|
166
|
+
* popover beside its Cancel (`pop`: no row is inserted, so none below moves; its Cancel stays, held down); `outcome` is
|
|
167
|
+
* what the last cancel answered (a Failure, or `{ cancelled, at }`), a popover beside the row it was for — or beside the
|
|
168
|
+
* part when that statement has ended since — gone by itself after OUTCOME_MS at `now` (a refusal stays until dismissed).
|
|
169
|
+
*/
|
|
170
|
+
export function renderRunning(running, asking = null, outcome = null, now = Date.now()) {
|
|
171
|
+
const pops = [];
|
|
172
|
+
const said = fresh(outcome, now) ? outcome : null;
|
|
173
|
+
let shown = false;
|
|
174
|
+
const rows = running.map((r) => {
|
|
175
|
+
const key = keyOf(r.session, r.statement);
|
|
176
|
+
const anchor = actId("running", key);
|
|
177
|
+
if (asking === key) {
|
|
178
|
+
pops.push(pop(anchor, `<p class="confirm" title="SQLSTATE 57014">Cancel session ${r.session}'s statement ${r.statement}? Its client gets a "canceling statement" error.</p>` +
|
|
179
|
+
`<p class="pop-actions"><button type="button" class="danger small" data-confirm-cancel="${esc(key)}">Cancel statement ${r.statement}</button> <button type="button" class="small" data-dismiss-cancel data-pop-close>Keep it running</button></p>`,
|
|
180
|
+
{ kind: "dialog", label: `Cancel session ${r.session}'s statement ${r.statement}?` }));
|
|
181
|
+
}
|
|
182
|
+
if (said !== null && said.key === key) { shown = true; pops.push(pop(anchor, outcomeHtml(said))); }
|
|
183
|
+
return `<tr><td>${r.session}</td><td>${r.statement}</td><td><span class="clip" title="${esc(r.user)}">${esc(r.user)}</span></td><td><span class="clip" title="${esc(clientText(r.applicationName))}">${esc(clientText(r.applicationName))}</span></td>` +
|
|
184
|
+
`<td class="num"><time data-tick datetime="${esc(r.startedAt)}" title="started ${esc(utcText(r.startedAt))} UTC">${esc(spanText(r.runningMs / 1000))}</time></td><td class="num">${esc(plural(r.rows, "row"))} sent</td>` +
|
|
185
|
+
`<td>${statementCell(r.query)}</td><td class="act" id="${esc(anchor)}">${asking === key ? `<button type="button" class="danger small" aria-expanded="true" disabled>Cancel</button>` : `<button type="button" class="danger small" data-ask-cancel="${esc(key)}">Cancel</button>`}</td></tr>`;
|
|
186
|
+
}).join("");
|
|
187
|
+
const table = running.length
|
|
188
|
+
? `<div class="scroll"><table class="running"><thead><tr><th scope="col">Session</th><th scope="col" title="the statement's number in its session, as mason cancel --statement takes it">No.</th><th scope="col">User</th><th scope="col">Client</th>` +
|
|
189
|
+
`<th scope="col" class="num">Running for</th><th scope="col">Rows</th><th scope="col">Statement</th><th scope="col"></th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
190
|
+
: empty("No statement is running now.");
|
|
191
|
+
// the statement ended since: said beside the part, which is there whatever it lists
|
|
192
|
+
if (said !== null && !shown) pops.push(pop("usage-running", outcomeHtml(said)));
|
|
193
|
+
return table + pops.join("");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* THE UNSUPPORTED SQL of the range (`/api/unsupported`'s body: { since, unsupported, keptDays }): the statements Mason
|
|
198
|
+
* refused because they used SQL it does not support, grouped by its entry of docs/SQL-SUPPORT.md, the most refused first
|
|
199
|
+
* — each in mason status's two lines (`unsupportedEntry`): how many used what and the entry's name, then when the latest
|
|
200
|
+
* was, who sent them, and the latest statement on a line (its whole, up to TITLE_MAX, is the title). `span` is the range's
|
|
201
|
+
* words. None: the empty sentence, with how long the log keeps them.
|
|
202
|
+
*/
|
|
203
|
+
export function renderUnsupported(b, span = RANGES[DEFAULT_RANGE].span) {
|
|
204
|
+
const days = `${b.keptDays} day${b.keptDays === 1 ? "" : "s"}`;
|
|
205
|
+
if (!b.unsupported.length) return empty(`No statement refused for unsupported SQL in ${span} (the request log keeps ${days}: MASON_HISTORY_DAYS).`);
|
|
206
|
+
return `<p class="lead">The statements refused in ${esc(span)} by the SQL they used that Mason does not support, the most first.</p>` +
|
|
207
|
+
panel(`<ul class="unsupported">${b.unsupported.map((g) => {
|
|
208
|
+
const e = unsupportedEntry(g);
|
|
209
|
+
return `<li><p class="headline" title="docs/SQL-SUPPORT.md entry ${esc(e.id)}">${esc(e.headline)}</p><p class="dim">${esc(e.last)} — <code class="clip" title="${esc(clip(g.example, TITLE_MAX))}">${esc(e.example)}</code></p><p class="dim">${esc(e.how)}</p></li>`;
|
|
210
|
+
}).join("")}</ul>`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* WHO SENT STATEMENTS IN A PAST RANGE (`/api/activity` `clients`: [{ user, tokenName, client, statements, failed,
|
|
215
|
+
* sessions, last }], the most statements first): a row per user and client — its statements, the failed ones, the
|
|
216
|
+
* sessions they came from, when the latest started — and Queries, its statements on Observability › Queries. Past
|
|
217
|
+
* `top`, the others in one row; `total` how many clients in all (`clientsTotal`: the endpoint lists 50). `span` the range's words.
|
|
218
|
+
*/
|
|
219
|
+
export function renderClients(clients, { top = SESSIONS_TOP, range = DEFAULT_RANGE, now = Date.now(), total = clients.length } = {}) {
|
|
220
|
+
const span = RANGES[rangeOf(range)].span;
|
|
221
|
+
if (!clients.length) return empty(`No client sent a statement the request log lists in ${span}.`);
|
|
222
|
+
const shown = clients.slice(0, top), rest = clients.slice(top);
|
|
223
|
+
const sum = (k) => rest.reduce((a, c) => a + c[k], 0);
|
|
224
|
+
const cell = (v) => `<td><span class="clip" title="${esc(v)}">${esc(v)}</span></td>`;
|
|
225
|
+
const who = (c) => c.user || (c.tokenName ? `token ${c.tokenName}` : "(no user)");
|
|
226
|
+
return panel(`<table class="sessions"><thead><tr><th scope="col">User</th><th scope="col">Client</th><th scope="col" class="num">Statements</th><th scope="col" class="num">Failed</th>` +
|
|
227
|
+
`<th scope="col" class="num">Sessions</th><th scope="col">Last</th><th scope="col" class="act"></th></tr></thead><tbody>` +
|
|
228
|
+
shown.map((c) => `<tr>${cell(who(c))}${cell(clientText(c.client))}<td class="num">${esc(n(c.statements))}</td><td class="num">${c.failed ? esc(n(c.failed)) : ""}</td>` +
|
|
229
|
+
`<td class="num">${esc(n(c.sessions))}</td><td><time data-tick datetime="${esc(c.last)}" title="${esc(utcText(c.last))} UTC">${esc(agoText(c.last, now))}</time></td><td class="act">${queriesLink(c.user, c.client, range)}</td></tr>`).join("") +
|
|
230
|
+
(rest.length ? `<tr class="others"><td colspan="2">${esc(n(rest.length))} other client${rest.length === 1 ? "" : "s"}</td><td class="num">${esc(n(sum("statements")))}</td><td class="num">${esc(n(sum("failed")))}</td><td class="num">${esc(n(sum("sessions")))}</td><td></td><td></td></tr>` : "") +
|
|
231
|
+
`</tbody></table>`) + `<p class="dim">${esc(plural(total, "client"))}${total > clients.length ? ` (the ${esc(n(clients.length))} with the most statements are listed)` : ""} sent statements in ${esc(span)}. A session that sent only what the log does not list (select 1, SET, a catalog query) is not here.</p>`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* THE SLOWEST STATEMENTS OF A PAST RANGE (`/api/activity` `slowest`, slowest first): a past range's analogue of what
|
|
236
|
+
* runs now — when each started, how long it took, who sent it from where, the statement — and Queries, its client's
|
|
237
|
+
* slow statements on Observability › Queries.
|
|
238
|
+
*/
|
|
239
|
+
export function renderSlowest(list, { range = DEFAULT_RANGE, now = Date.now() } = {}) {
|
|
240
|
+
const span = RANGES[rangeOf(range)].span;
|
|
241
|
+
if (!list.length) return empty(`No statement completed in ${span}.`);
|
|
242
|
+
return `<div class="scroll"><table class="running"><thead><tr><th scope="col">Started</th><th scope="col" class="num">Took</th><th scope="col">User</th><th scope="col">Client</th>` +
|
|
243
|
+
`<th scope="col" class="num">Rows</th><th scope="col">Statement</th><th scope="col" class="act"></th></tr></thead><tbody>` +
|
|
244
|
+
list.map((r) => `<tr><td><time data-tick datetime="${esc(r.startedAt)}" title="${esc(utcText(r.startedAt))} UTC">${esc(agoText(r.startedAt, now))}</time></td><td class="num">${esc(msText(r.durationMs))}</td>` +
|
|
245
|
+
`<td><span class="clip" title="${esc(r.user || r.tokenName)}">${esc(r.user || (r.tokenName ? `token ${r.tokenName}` : ""))}</span></td><td><span class="clip" title="${esc(clientText(r.applicationName))}">${esc(clientText(r.applicationName))}</span></td>` +
|
|
246
|
+
`<td class="num">${r.rows === null ? "" : esc(n(r.rows))}</td><td class="stmt-cell">${statementCell(r.query)}</td>` +
|
|
247
|
+
`<td class="act"><a class="btn small" href="${esc(queriesHref({ user: r.user, client: r.applicationName, slow: r.durationMs >= 1000 ? "1" : "" }, range))}" title="its client's statements on Observability › Queries">Queries</a></td></tr>`).join("") +
|
|
248
|
+
`</tbody></table></div>`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* THE KPI CARDS of the range: while it is Now, who is connected (`sessions`, `/api/state`'s) and what runs (`running`);
|
|
253
|
+
* for any other, the clients that sent statements in it (`clients`, `/api/activity`'s). Then the range's statements, the
|
|
254
|
+
* failed ones, the p95 (`statementCards`, of `/api/activity` `window`), the bytes and rows sent (one card) — each with a
|
|
255
|
+
* sparkline where `usage` (`/api/usage`'s body) has two samples to draw, and each statement card leading to the
|
|
256
|
+
* statements it counts on Observability › Queries. `activity` is the body or `{ failure }`; one not read yet draws its
|
|
257
|
+
* card as a skeleton, one that could not be read `unknown` with its reason.
|
|
258
|
+
*/
|
|
259
|
+
export function renderUsageKpis({ activity, usage, sessions, running, range = DEFAULT_RANGE }) {
|
|
260
|
+
const specs = usageSpecs(usage);
|
|
261
|
+
const r = RANGES[rangeOf(range)];
|
|
262
|
+
const line = (k) => specs[SPARKS[k][0]] ? spark(SPARKS[k][0], `${SPARKS[k][1]}, ${r.span} ${perStep(r.stepSeconds)} a point`) : "";
|
|
263
|
+
const label = shortSpan(range);
|
|
264
|
+
const list = [];
|
|
265
|
+
if (isLive(range)) {
|
|
266
|
+
if (sessions) list.push("error" in sessions ? card("Connected now", "unknown", firstLine(String(sessions.error)))
|
|
267
|
+
: card("Connected now", plural(sessions.open, "session"), plural(sessions.byClient.length, "client")));
|
|
268
|
+
if (running) list.push(card("Running now", n(running.length), running.length ? `the longest for ${spanText(Math.max(...running.map((x) => x.runningMs)) / 1000)}` : "no statement is running"));
|
|
269
|
+
} else if (activity && !("failure" in activity)) {
|
|
270
|
+
// the totals over every client, never the 50 the table lists (`clientsTotal`)
|
|
271
|
+
const total = activity.clientsTotal;
|
|
272
|
+
list.push(card(`Clients, ${label}`, n(total.clients), plural(total.sessions, "session")));
|
|
273
|
+
}
|
|
274
|
+
if (activity === undefined) return cards(list) + skeleton(2);
|
|
275
|
+
if ("failure" in activity) return cards([...list, card(`Statements, ${label}`, "unknown", `could not read them: ${firstLine(activity.failure.message)}`)]);
|
|
276
|
+
const w = activity.window;
|
|
277
|
+
const href = (filters) => queriesHref(filters, range);
|
|
278
|
+
list.push(...statementCards(w, line, label, { statements: href({}), failed: href({ status: "refused-or-failed" }), p95: href({ slow: "1" }) }),
|
|
279
|
+
card(`Sent, ${label}`, bytesText(w.bytesSent), plural(w.rows, "row"), line("bytes")));
|
|
280
|
+
// a range longer than the log keeps is said, never a short one passed off as whole
|
|
281
|
+
const covered = coveredText(w);
|
|
282
|
+
return cards(list) + (covered ? `<p class="warn">${esc(`${RANGES[rangeOf(range)].label}: ${covered}`)}</p>` : "");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The usage answer as `mason usage` prints it: its heading, then the samples a line each (or that there are none yet), then
|
|
287
|
+
* each copy's size. With charts drawn the hours are their numbers, behind a closed details `usage-hours`; the process's
|
|
288
|
+
* memory is behind another.
|
|
289
|
+
*/
|
|
290
|
+
export function renderUsage(b, isOpen = () => false, sort = { key: "time", dir: "desc" }) {
|
|
291
|
+
const at = utcMinute;
|
|
292
|
+
const span = b.stepSeconds === null ? "every sample" : `${duration(b.stepSeconds)} a line`;
|
|
293
|
+
const head = `<p class="dim">From ${esc(at(b.since))} to ${esc(at(b.until))} UTC, ${esc(span)} (a sample every ${esc(duration(b.sampleSeconds))}, kept ${b.keptDays} day${b.keptDays === 1 ? "" : "s"}).</p>`;
|
|
294
|
+
let samples;
|
|
295
|
+
if (!b.samples.length) samples = empty("No sample in this time: Mason takes the first a minute after it starts.");
|
|
296
|
+
else {
|
|
297
|
+
const withLag = b.samples.some((s) => s.streamLagBytes !== null || s.streamLagSeconds !== null);
|
|
298
|
+
const cols = ["Time (UTC)", "Statements", "Errors", "Rows sent", "Bytes sent", "p95", "Size", "Rows", "Failing", "Oldest", ...(withLag ? ["Lag"] : [])];
|
|
299
|
+
// newest first unless the time's header turned it round (the charts read left to right, oldest first)
|
|
300
|
+
const ordered = sort.dir === "asc" ? b.samples : [...b.samples].reverse();
|
|
301
|
+
const head = (list) => `<thead><tr>${sortTh("hours", "time", list[0], sort)}${list.slice(1).map((c) => `<th scope="col"${c === "Time (UTC)" ? "" : ` class="num"`}>${esc(c)}</th>`).join("")}</tr></thead>`;
|
|
302
|
+
const hours = panel(`<table class="samples">${head(cols)}<tbody>` + ordered.map((s) => {
|
|
303
|
+
const c = sampleCells(s);
|
|
304
|
+
return `<tr><th scope="row">${esc(c.time)}</th>${[c.statements, c.errors, c.rowsSent, c.bytesSent, c.p95, c.copy, c.rows, c.failing, c.oldest, ...(withLag ? [c.lag] : [])].map(num).join("")}</tr>`;
|
|
305
|
+
}).join("") + "</tbody></table>");
|
|
306
|
+
// the charts draw these numbers: then they are one click away, and open by the person's choice (keyed: a poll rewrites the hours)
|
|
307
|
+
samples = (Object.keys(usageSpecs(b)).some((k) => CHARTS.some(([key]) => key === k)) ? details("usage-hours", `The numbers, ${perStep(b.stepSeconds ?? 60).replace(/^every /, "")} a line`, hours, isOpen("usage-hours"), "hours") : hours) +
|
|
308
|
+
// the process's memory, an hour a line: a debugging aid, behind a closed details
|
|
309
|
+
// (keyed: a poll rewrites the hours, and an open Memory stays open)
|
|
310
|
+
details("usage-memory", "Memory", panel(`<table class="samples">${th(["Time (UTC)", "Memory"])}<tbody>` +
|
|
311
|
+
ordered.map((s) => { const c = sampleCells(s); return `<tr><th scope="row">${esc(c.time)}</th><td>${esc(c.memory)}</td></tr>`; }).join("") + "</tbody></table>"), isOpen("usage-memory"), "memory");
|
|
312
|
+
}
|
|
313
|
+
return head + samples;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Each synced table's own size, measured every 10 min (src/cli/usage.ts): table, bytes, when. */
|
|
317
|
+
export function renderCopySizes(sizes) {
|
|
318
|
+
if (!sizes.length) return empty(`No table measured yet: each is measured every ${duration(SIZE_EVERY_SECONDS)}.`);
|
|
319
|
+
return `<p class="dim">Measured every ${esc(duration(SIZE_EVERY_SECONDS))}.</p>` + panel(`<table class="sizes">${th(["Table", "Size", "Measured at (UTC)"])}<tbody>` +
|
|
320
|
+
sizes.map((c) => `<tr><th scope="row"><code class="name" title="${esc(c.table)}">${esc(c.table)}</code></th>${num(fileSize(c.bytes))}<td>${esc(c.at.slice(11, 16))}</td></tr>`).join("") + "</tbody></table>");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const page = {
|
|
324
|
+
id: "usage",
|
|
325
|
+
hash: "observability/usage",
|
|
326
|
+
parent: "observability",
|
|
327
|
+
title: "Usage",
|
|
328
|
+
create(ctx) {
|
|
329
|
+
const opened = createOpenSet();
|
|
330
|
+
const m = { open: opened.open, sort: { key: "time", dir: "desc" }, asking: null, outcome: null };
|
|
331
|
+
const samples = () => usagePath(ctx.range());
|
|
332
|
+
const activity = () => activityPath(ctx.range());
|
|
333
|
+
const part = (path, render, says) => readPart(ctx, path, render, says);
|
|
334
|
+
const actions = {
|
|
335
|
+
sortHours() { m.sort = nextSort(m.sort, "time"); },
|
|
336
|
+
askCancelStatement(session, statement) { m.asking = keyOf(session, statement); m.outcome = null; },
|
|
337
|
+
dismissCancelStatement() { m.asking = null; },
|
|
338
|
+
dismissCancelled() { m.outcome = null; },
|
|
339
|
+
/** POST /api/cancel { session, statement }: the statement listed, never the one the session runs next (409). */
|
|
340
|
+
async confirmCancelStatement(session, statement) {
|
|
341
|
+
const r = await ctx.ask("/api/cancel", json("POST", { session, statement }));
|
|
342
|
+
m.asking = null;
|
|
343
|
+
m.outcome = "failure" in r ? { key: keyOf(session, statement), failure: r.failure } : { key: keyOf(session, statement), cancelled: r.body.cancelled, at: ctx.now().getTime() };
|
|
344
|
+
// who is connected says its running statements too: both read again, so neither says the cancelled one runs
|
|
345
|
+
await ctx.refresh([RUNNING, STATE]);
|
|
346
|
+
return !("failure" in r);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
model: m,
|
|
351
|
+
// Now: the running statements every poll, who is connected and the range's numbers every 5 s, the samples and
|
|
352
|
+
// the unsupported SQL every minute. Any other range: each read once, when it is chosen (or on Refresh)
|
|
353
|
+
reads: () => [{ path: activity(), everyMs: rangeEvery(ctx.range(), ACTIVITY_EVERY_MS) }, { path: samples(), everyMs: rangeEvery(ctx.range(), EVERY_MS) },
|
|
354
|
+
{ path: unsupportedPath(ctx.range()), everyMs: rangeEvery(ctx.range(), UNSUPPORTED_EVERY_MS) },
|
|
355
|
+
...(isLive(ctx.range()) ? [{ path: RUNNING, everyMs: POLL_MS }, { path: STATE, everyMs: ACTIVITY_EVERY_MS }] : [])],
|
|
356
|
+
afterRead() {
|
|
357
|
+
// a statement that ended since it was asked about has nothing left to cancel
|
|
358
|
+
const r = ctx.read(RUNNING)?.body?.running;
|
|
359
|
+
if (m.asking !== null && r && !r.some((x) => keyOf(x.session, x.statement) === m.asking)) m.asking = null;
|
|
360
|
+
},
|
|
361
|
+
actions,
|
|
362
|
+
toggle: (target) => opened.toggle(target),
|
|
363
|
+
click(d) {
|
|
364
|
+
if (d.sortTable === "hours") return actions.sortHours() ?? true;
|
|
365
|
+
const pair = (v) => v.split(":").map(Number);
|
|
366
|
+
if (d.askCancel !== undefined) return actions.askCancelStatement(...pair(d.askCancel)) ?? true;
|
|
367
|
+
if (d.dismissCancel !== undefined) return actions.dismissCancelStatement() ?? true;
|
|
368
|
+
if (d.dismissCancelled !== undefined) return actions.dismissCancelled() ?? true;
|
|
369
|
+
if (d.confirmCancel !== undefined) return actions.confirmCancelStatement(...pair(d.confirmCancel));
|
|
370
|
+
return null;
|
|
371
|
+
},
|
|
372
|
+
charts: () => usageSpecs(ctx.read(samples())?.body),
|
|
373
|
+
view() {
|
|
374
|
+
const range = ctx.range();
|
|
375
|
+
const live = isLive(range);
|
|
376
|
+
const span = RANGES[rangeOf(range)].span;
|
|
377
|
+
const a = ctx.read(activity());
|
|
378
|
+
const running = live ? ctx.read(RUNNING)?.body : undefined;
|
|
379
|
+
const sessions = live ? ctx.read(STATE)?.body?.sessions : undefined;
|
|
380
|
+
const now = ctx.now().getTime();
|
|
381
|
+
return {
|
|
382
|
+
"usage-kpis": renderUsageKpis({ activity: a === undefined ? undefined : a.body ?? { failure: a.failure }, usage: ctx.read(samples())?.body, sessions, running: running?.running, range }),
|
|
383
|
+
"usage-charts": renderCharts(ctx.read(samples())?.body, range),
|
|
384
|
+
"usage-connected-h": live ? "Connected now" : `Connected in ${esc(span)}`,
|
|
385
|
+
"usage-connected": live ? renderSessions(sessions, SESSIONS_TOP, range) : part(activity(), (b) => renderClients(b.clients, { range, now, total: b.clientsTotal.clients }), false),
|
|
386
|
+
"usage-running-h": live ? "Running now" : `Slowest in ${esc(span)}`,
|
|
387
|
+
"usage-running-lead": live ? `As <code>mason logs --running</code> lists them. Cancel stops one, as <code>mason cancel</code> does.`
|
|
388
|
+
: `The statements that took longest to complete, as the request log recorded them.`,
|
|
389
|
+
"usage-running": live ? (running ? renderRunning(running.running, m.asking, m.outcome, now) : "") : part(activity(), (b) => renderSlowest(b.slowest, { range, now }), false),
|
|
390
|
+
unsupported: part(unsupportedPath(range), (b) => renderUnsupported(b, span), true),
|
|
391
|
+
"usage-hourly": part(samples(), (b) => renderUsage(b, opened.has, m.sort), true),
|
|
392
|
+
"usage-sizes": part(samples(), (b) => renderCopySizes(b.copySizes), false),
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
},
|
|
397
|
+
};
|