@mason-data/cli 0.7.0 → 0.9.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 +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- 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 +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- 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 +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -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 +12 -4
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE OVERVIEW (#overview; `#status`, its old address, lands here; the module and its section keep the id `status`): what `mason status` prints — the run in one word (`readiness`), ONE table of facts (the
|
|
3
|
+
* database and how its changes arrive, Mason's version, uptime and restarts, the copies in all, the copy's file, the
|
|
4
|
+
* disk, the sessions open, the warnings), the changes the stream carries (replication mode: its position, lag, last
|
|
5
|
+
* batch and the slot's hold on the database's WAL), the statements of the last day and week, the copies not finished (a
|
|
6
|
+
* bar each, only while there are any), and the recent errors (the sync's and the statements', and the last day's
|
|
7
|
+
* unsupported SQL in a line). The slow and the refused statements are the Queries page's. Reads `/api/state` every poll, the request log's every 30 s, the last 24 hours of
|
|
8
|
+
* usage (the cards' sparklines) every 60 s. Never the engine's name.
|
|
9
|
+
*
|
|
10
|
+
* Laid out as a dashboard (docs/WEB-UI.md "Presentation"): the word with a big dot, then KPI cards (the copies in all,
|
|
11
|
+
* their rows, the last sync, the copy's file, the last day's statements and their p95 — each its own fact, said once,
|
|
12
|
+
* with a sparkline of the last 24 hours where one helps), the copies not finished, the ONE table of the other facts,
|
|
13
|
+
* the stream, the statements, the recent errors. Each card links into the view that lists what it counts (the tables,
|
|
14
|
+
* filtered to the failed ones when a copy failed; Observability's Queries — the slow ones for the p95 — and Usage).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where each card leads: the views that list what it counts (src/web/app.js hashes). */
|
|
18
|
+
// each with the range of what it counts — the statements of the last day, the sync's failures of the days kept — never
|
|
19
|
+
// the Observability views' default, Now: a card that says 12 failed must land on the 12
|
|
20
|
+
const LINKS = { tables: "#tables", failedTables: "#tables?show=failed", usage: "#observability/usage?range=24h", queries: "#observability/queries?range=24h",
|
|
21
|
+
slow: "#observability/queries?slow=1&range=24h", failedQueries: "#observability/queries?status=refused-or-failed&range=24h", failedEvents: "#observability/syncs?failures=1&range=7d" };
|
|
22
|
+
import { msText, bytesText, code, esc, fileSize, firstLine, n, percent, leftText, spanText, duration, entryName, streamRows, slotWarns, masonRow, sessionsRows, diskRows, restartsRows, utcText,
|
|
23
|
+
utcMinute, oneLine, clientText, eventCells, unsupportedEntry, usagePath, POLL_MS, card, cards, details, createOpenSet, skeleton, renderConnect, copyClick,
|
|
24
|
+
badge, button, copyable, pop, renderFailure, json } from "../shared.js";
|
|
25
|
+
import { spark, seriesOf, drawable } from "../charts.js";
|
|
26
|
+
|
|
27
|
+
/** How many recent errors of each kind are shown: mason status's (src/cli/commands.ts `RECENT_ERRORS`, held equal by the tests). */
|
|
28
|
+
export const RECENT_ERRORS = 5;
|
|
29
|
+
|
|
30
|
+
/** The last 24 hours an hour a point: the cards' sparklines (Usage's read at its default range). */
|
|
31
|
+
const HOURLY = usagePath("24h");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
|
|
35
|
+
* page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
|
|
36
|
+
*/
|
|
37
|
+
export function copyingText(copied, rowsEstimate, p) {
|
|
38
|
+
const shown = copied + (p?.rowsReading ?? 0);
|
|
39
|
+
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
40
|
+
const past = estimate !== null && shown > estimate;
|
|
41
|
+
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
42
|
+
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
43
|
+
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
44
|
+
if (p === undefined) return { fraction, text: rowsText(copied) };
|
|
45
|
+
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
46
|
+
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
47
|
+
if (p.rowsPerSecond === null) {
|
|
48
|
+
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
49
|
+
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to sync`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
50
|
+
}
|
|
51
|
+
const parts = [rowsText(shown)];
|
|
52
|
+
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
53
|
+
if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
54
|
+
else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
55
|
+
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
56
|
+
if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
|
|
57
|
+
return { fraction, text: parts.join(" · ") };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
|
|
62
|
+
* its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says — a copied one with how it is kept
|
|
63
|
+
* current and how old its copy is (`keptAge`).
|
|
64
|
+
*/
|
|
65
|
+
export function tableLine(t, now = Date.now()) {
|
|
66
|
+
if (t.error) return { state: "failed", fraction: 1, what: t.copy?.phase === "done" ? copiedFailedText(t, now) : `its first sync failed: ${firstLine(t.error)}` };
|
|
67
|
+
if (t.copy?.phase === "copying") {
|
|
68
|
+
const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
|
|
69
|
+
return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `syncing ${c.text}` : c.text };
|
|
70
|
+
}
|
|
71
|
+
if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its first sync to start" };
|
|
72
|
+
const age = keptWords(t, now);
|
|
73
|
+
const what = `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""}${age ? ` · ${age}` : ""}`;
|
|
74
|
+
// a copy the poll does not keep current (it waits for its catch-up) is a warning, as mason status paints it (paintBar)
|
|
75
|
+
// and so is one behind what its sync promises (`tables[].sync.state`, src/cli/readiness.ts behindOf), of any kind
|
|
76
|
+
if ((t.kept?.by === "poll" && t.kept.pending !== undefined) || (t.kept?.by === "replication" && (t.kept.state !== "streamed" || t.kept.notRunning !== null)) || t.sync?.state === "behind") {
|
|
77
|
+
return { state: "unsynced", fraction: 1, what };
|
|
78
|
+
}
|
|
79
|
+
return { state: "done", fraction: 1, what };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What a served table's line says after `synced`, for every kind: its keeping and age, then behind (src/cli/commands.ts `keptWords`). */
|
|
83
|
+
export function keptWords(t, now = Date.now()) {
|
|
84
|
+
const age = keptAge(t, now), s = t.sync;
|
|
85
|
+
if (s?.state !== "behind" || s.behindBySeconds == null) return age;
|
|
86
|
+
const why = s.behindWhy ?? "its next sync is late";
|
|
87
|
+
const by = `behind by ${duration(s.behindBySeconds)}${age !== null && age.includes(why) ? "" : `: ${why}`}`;
|
|
88
|
+
return age === null ? by : `${age} — ${by}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** How long ago a table was synced, as `mason status` says it on its line (src/lib/table-sync.ts `agoText`, which
|
|
92
|
+
* writes `4 s ago`, `3 days ago`; shared.js `agoText` is mason status's `ago`, `4s ago`, for a stream's times). */
|
|
93
|
+
function syncedAgo(iso, now = Date.now()) {
|
|
94
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
95
|
+
if (s < 90) return `${s} s ago`;
|
|
96
|
+
if (s < 90 * 60) return `${Math.round(s / 60)} min ago`;
|
|
97
|
+
if (s < 36 * 3600) return `${Math.round(s / 3600)} h ago`;
|
|
98
|
+
const d = Math.round(s / 86_400);
|
|
99
|
+
return `${d} day${d === 1 ? "" : "s"} ago`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** How a copied table is kept current and how old its copy is (src/cli/commands.ts `keptAge`). */
|
|
103
|
+
export function keptAge(t, now = Date.now()) {
|
|
104
|
+
const k = t.kept;
|
|
105
|
+
if (k?.by === "replication") {
|
|
106
|
+
if (k.state !== "streamed") return `NOT kept current (${k.syncedAt === null ? "never synced" : `last synced ${syncedAgo(k.syncedAt, now)}`}): ${k.why ?? "it is not streamed yet"}`;
|
|
107
|
+
const at = `replicated, at ${k.position}, ${k.syncedAt === null ? "not synced yet" : syncedAgo(k.syncedAt, now)}`;
|
|
108
|
+
return k.notRunning === null || t.sync?.state === "behind" ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
|
|
109
|
+
}
|
|
110
|
+
if (k?.by !== "poll") return null;
|
|
111
|
+
const synced = k.syncedAt === null ? "never synced" : `last synced ${syncedAgo(k.syncedAt, now)}`;
|
|
112
|
+
if (k.pending !== undefined) return `NOT kept current (${synced}): ${k.pending}`;
|
|
113
|
+
if (k.sync.mode === "off") return `sync: off, ${synced} — mason sync ${entryName(t.table)} syncs it now; sync: poll 5m on its entry polls it`;
|
|
114
|
+
return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${syncedAgo(k.syncedAt, now)}`}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A served table whose sync failed, as mason status says it (src/cli/commands.ts `copiedFailedText`). */
|
|
118
|
+
function copiedFailedText(t, now) {
|
|
119
|
+
const k = t.kept;
|
|
120
|
+
const reason = k?.by === "replication" ? (k.state !== "streamed" ? k.why : k.notRunning === null ? null : `the stream is not running: ${k.notRunning}`)
|
|
121
|
+
: k?.by === "poll" || k?.by === "recopy" ? k.pending ?? null : null;
|
|
122
|
+
const asOf = t.sync?.asOf ?? (k && k.by !== "generated" && k.by !== "recopy" ? k.syncedAt : null) ?? t.sync?.syncedAt ?? null;
|
|
123
|
+
return `${n(t.copy?.rowsCopied ?? 0)} rows · synced · NOT kept current, its sync failed (${asOf === null ? "never synced" : `last synced ${syncedAgo(asOf, now)}`}): ${reason ?? firstLine(t.error)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A COPY'S BAR, the one renderer of the Overview's "copies not finished" and the Tables page's rows: a thin rounded
|
|
128
|
+
* track and a solid fill to the share drawn, its % as text at the bar's END (as `mason wait` prints it beside its bar),
|
|
129
|
+
* with an explicit `role="progressbar"` and `aria-valuemin/max/now` (and a text label, `aria-valuetext`, the line's
|
|
130
|
+
* words); the track is `aria-hidden` drawing. `l` is `tableLine`'s, `fraction` the share to draw (`monotone`'s; the
|
|
131
|
+
* line's own by default): the fill's colour is the line's state (the stylesheet's `.t-<state>`: green served, the accent
|
|
132
|
+
* while it copies, amber not kept current, rose failed). A table waiting for its copy has no value: no fill but the
|
|
133
|
+
* stylesheet's quiet shimmer over the track, `aria-valuenow` and the % left out. `from`, the share at the page's last
|
|
134
|
+
* read (`monotone.before`), is kept for the callers; the fill's own transition moves it from there.
|
|
135
|
+
*/
|
|
136
|
+
export function copyBar(table, l, fraction = l.fraction, _from) {
|
|
137
|
+
const waiting = l.state === "waiting";
|
|
138
|
+
const value = Math.floor(Math.max(0, Math.min(1, fraction)) * 100);
|
|
139
|
+
return `<span class="copybar${waiting ? " indeterminate" : ""}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
|
|
140
|
+
`${waiting ? "" : ` aria-valuenow="${value}"`} aria-label="${esc(`${table}: its first sync`)}" aria-valuetext="${esc(waiting ? l.what : `${value}%, ${l.what}`)}">` +
|
|
141
|
+
`<span class="track" aria-hidden="true"><span class="fill"${waiting ? "" : ` style="width: ${value}%"`}></span></span></span>` +
|
|
142
|
+
// a copy not begun has no share yet: no "0%" at its end (its line says it waits)
|
|
143
|
+
(waiting ? "" : ` <span class="pct">${percent(fraction)}</span>`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* THE SHARES A PAGE DRAWS, NEVER BACKWARDS: for each added table that is copying, the most its share has been since the
|
|
148
|
+
* copy began (the server's share is of pages and does not go back within a copy; this holds it so across a reconnect or
|
|
149
|
+
* a re-read, too). A table that stops copying (done, failed, waiting) is forgotten, so its next copy begins at its own.
|
|
150
|
+
* `of(s)` → Map(table → share) for the tables copying in the state `s`.
|
|
151
|
+
*/
|
|
152
|
+
export function createMonotone() {
|
|
153
|
+
const seen = new Map();
|
|
154
|
+
let before = new Map();
|
|
155
|
+
return {
|
|
156
|
+
/** The shares of the read before the last `of`: each copying table's (`copyBar`'s `from`). */
|
|
157
|
+
get before() { return before; },
|
|
158
|
+
of(s) {
|
|
159
|
+
before = new Map(seen);
|
|
160
|
+
const now = new Map();
|
|
161
|
+
for (const t of s.tables) {
|
|
162
|
+
if (!t.added) continue;
|
|
163
|
+
const l = tableLine(t);
|
|
164
|
+
if (l.state === "copying") now.set(t.table, Math.max(seen.get(t.table) ?? 0, l.fraction));
|
|
165
|
+
}
|
|
166
|
+
seen.clear();
|
|
167
|
+
for (const [k, v] of now) seen.set(k, v);
|
|
168
|
+
return now;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `mason status`'s rows for the database (src/cli/commands.ts `sourceRows`): its sources file (from the project's root:
|
|
174
|
+
* the page never shows an absolute path), its URL as the run masked it, how its changes arrive. */
|
|
175
|
+
export function sourceRows(s) {
|
|
176
|
+
return [
|
|
177
|
+
...(s.source.file ? [["Source", `${s.source.shownFile ?? s.source.file} (the tables it names are added)`]] : []),
|
|
178
|
+
["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
|
|
179
|
+
: `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
|
|
180
|
+
// in a web developer's words first (each table as its sync: says, streamed as they happen); the mechanism an operator needs in parentheses
|
|
181
|
+
["Changes", `each table as its sync: says${s.source.replication ? "" : " (a SELECT of its changed rows)"}${s.source.sync ? ` · sync: ${s.source.sync.default} ${s.source.sync.written ? "for every table without its own" : "(the default: the file writes none)"}` : ""} · ` +
|
|
182
|
+
(s.source.replication ? `sync: replication tables streamed as they happen (logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication}, ` +
|
|
183
|
+
`${s.source.replication.owned ? "Mason's own, carrying them alone" : "named: read as it is, never altered"}); the others by a SELECT of their changed rows`
|
|
184
|
+
: s.source.demo ? "the demo database is checked this way only"
|
|
185
|
+
: "sync: replication on an entry streams that table as it happens instead")],
|
|
186
|
+
...(s.source.textOrder ? [["Text order", s.source.textOrder]] : []),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** THE RUN IN ONE WORD, as `mason status` heads its last line (src/cli/readiness.ts `readinessWord`): Ready, Failed or
|
|
191
|
+
* Under way. */
|
|
192
|
+
export function readinessWord(r) {
|
|
193
|
+
return r.ready ? "Ready" : r.failed.length ? "Failed" : "Under way";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The copies in all, as mason status's `Copies` row says them (src/cli/commands.ts `totalsText`). */
|
|
197
|
+
export function totalsText(t, now = Date.now()) {
|
|
198
|
+
const last = t.syncedAt === null ? "" : `, the last synced ${duration((now - Date.parse(t.syncedAt)) / 1000)} ago`;
|
|
199
|
+
return `${n(t.copied)} of ${n(t.added)} added synced, ${n(t.rows)} row${t.rows === 1 ? "" : "s"}${t.failed ? `, ${n(t.failed)} failed` : ""}${last}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The word at the top of the page: the run's readiness ("" with no table added: `renderStatus` says so, and what adds one). */
|
|
203
|
+
export function renderHealth(s) {
|
|
204
|
+
if (!s.tables.some((t) => t.added) || !s.readiness) return "";
|
|
205
|
+
const word = readinessWord(s.readiness);
|
|
206
|
+
return `<div class="health ${word === "Ready" ? "ok" : word === "Failed" ? "bad" : "warn"}" role="status"><span class="dot" aria-hidden="true"></span><span class="word">${esc(word)}</span></div>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The sparklines the cards draw, by holder key: a usage sample's field each (src/web/charts.js specs). */
|
|
210
|
+
const SPARKS = { rows: ["copyRows", "Rows synced"], "copy-bytes": ["copyBytes", "The synced data's file"], statements: ["statements", "Statements"], p95: ["durationP95Ms", "p95"] };
|
|
211
|
+
|
|
212
|
+
/** The sparklines' specs from the usage answer (`/api/usage`'s body; anything else draws none): what `charts()` returns. */
|
|
213
|
+
export function sparkSpecs(usage) {
|
|
214
|
+
const samples = usage && Array.isArray(usage.samples) ? usage.samples : [];
|
|
215
|
+
return Object.fromEntries(Object.entries(SPARKS).map(([key, [field, label]]) => [key, { kind: "spark", label, ...seriesOf(samples, field) }]).filter(([, spec]) => drawable(spec)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** How many added tables are copying now (tableLine's word), the cards' "N copying". */
|
|
219
|
+
export const copyingCount = (s) => s.tables.filter((t) => t.added && tableLine(t).state === "copying").length;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* THE KPI CARDS: the copies in all (synced of added, the failed), their rows, the last sync (how long ago, and when),
|
|
223
|
+
* the copy's file (its size, or why it is unknown), and the last day's statements and their p95 from the request log
|
|
224
|
+
* (`q`: `/api/queries`' body, or `{ failure }`). Each card's sparkline is the last 24 hours (`usage`), only where there
|
|
225
|
+
* are two hours to draw; its numbers are the card's text either way. These facts are said here, and not in the facts table.
|
|
226
|
+
*/
|
|
227
|
+
export function renderKpis(s, o = {}) {
|
|
228
|
+
const now = o.now ?? Date.now();
|
|
229
|
+
const sparks = sparkSpecs(o.usage);
|
|
230
|
+
const line = (key) => sparks[key] ? spark(key, `${SPARKS[key][1]}, the last 24 hours an hour a point`) : "";
|
|
231
|
+
const list = [];
|
|
232
|
+
const t = s.totals;
|
|
233
|
+
// as renderStatus judges it: a table added (the totals count what the sources file names)
|
|
234
|
+
const added = Boolean(t) && s.tables.some((x) => x.added);
|
|
235
|
+
if (added) list.push(
|
|
236
|
+
card("Tables synced", `${n(t.copied)} of ${n(t.added)}`, `added${t.failed ? `, ${n(t.failed)} failed` : ""}${copyingCount(s) ? `, ${n(copyingCount(s))} syncing` : ""}`, "", t.failed ? "bad" : "", t.failed ? LINKS.failedTables : LINKS.tables),
|
|
237
|
+
card("Rows", n(t.rows), "", line("rows"), "", LINKS.tables),
|
|
238
|
+
t.syncedAt === null ? card("Last sync", "none yet", "", "", "", LINKS.tables) : card("Last sync", `${duration((now - Date.parse(t.syncedAt)) / 1000)} ago`, `${utcText(t.syncedAt)} UTC`, "", "", LINKS.tables));
|
|
239
|
+
const d = s.disk;
|
|
240
|
+
const copyBytes = s.engine.bytes ?? (d && typeof d.copyBytes === "number" ? d.copyBytes : null);
|
|
241
|
+
if (added || copyBytes !== null) list.push(copyBytes === null ? card("The synced data's file", "unknown", "its size could not be read", "", "", LINKS.usage) : card("The synced data's file", fileSize(copyBytes), "", line("copy-bytes"), "", LINKS.usage));
|
|
242
|
+
const q = o.queries;
|
|
243
|
+
if (added && q !== undefined) {
|
|
244
|
+
if ("failure" in q) list.push(card("Statements, last day", "unknown", `could not read them: ${firstLine(q.failure.message)}`, "", "", LINKS.queries));
|
|
245
|
+
else {
|
|
246
|
+
const day = q.queries.day;
|
|
247
|
+
const bad = day.refused + day.failed + day.cancelled;
|
|
248
|
+
list.push(card("Statements, last day", n(day.statements), `${n(day.completed)} completed${bad ? `, ${n(bad)} refused, failed or cancelled` : ""}`, line("statements"), day.failed ? "warn" : "", LINKS.queries),
|
|
249
|
+
card("p95, last day", day.durationMs === null ? "–" : msText(day.durationMs.p95),
|
|
250
|
+
day.durationMs === null ? "no statement completed" : `average ${msText(day.durationMs.avg)} · p50 ${msText(day.durationMs.p50)}`, line("p95"), "", LINKS.slow));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return cards(list);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A fact's row; `warn` marks one the reader should act on. */
|
|
257
|
+
const factRow = (k, v, warn = false) => `<tr${warn ? ` class="warn"` : ""}><th scope="row">${esc(k)}</th><td>${esc(v)}</td></tr>`;
|
|
258
|
+
|
|
259
|
+
/** The stream's lag, said once: the bytes behind the database and the age of the newest change applied. */
|
|
260
|
+
const lagText = (lag) => `${lag.bytes === null || lag.bytes === undefined ? "bytes behind unknown" : `${bytesText(lag.bytes)} behind the database`}${lag.seconds === null || lag.seconds === undefined ? "" : `, the newest change applied was committed ${spanText(lag.seconds)} ago`}`;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* THE CHANGES THE STREAM CARRIES (replication mode: `/api/state` `stream`; "" without one), a table of facts: whether it
|
|
264
|
+
* runs and why not, its position, its lag, the last batch applied (`streamRows`, held equal to `mason status`'s
|
|
265
|
+
* `streamLines`), and the WAL the slot holds on the database (`slotText`, one of its rows). Each table's own stream state is the Tables page's.
|
|
266
|
+
*/
|
|
267
|
+
function renderStream(s, now = Date.now()) {
|
|
268
|
+
const st = s.stream;
|
|
269
|
+
if (!st) return "";
|
|
270
|
+
const { head } = streamRows(st, now);
|
|
271
|
+
// `head` is the CLI's rows: "stream" (only when it is not running, and why), the position, the last change
|
|
272
|
+
const label = (k) => k[0].toUpperCase() + k.slice(1);
|
|
273
|
+
const rows = [...(st.running ? [factRow("Stream", "running")] : []), ...head.map(([k, v]) => factRow(label(k), v, k === "stream" || (k === "slot" && slotWarns(st.slotWal))))];
|
|
274
|
+
rows.splice(2, 0, factRow("Lag", lagText(st.lag)));
|
|
275
|
+
return `<table class="facts-table"><caption>Changes (the stream${s.source.replication ? `, slot ${esc(s.source.replication.slot)}` : ""})</caption><tbody>${rows.join("")}</tbody></table>`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* THE FACTS, ONE table of what `mason status` says beside the cards: the database and how its changes arrive (`sourceRows`), the
|
|
280
|
+
* warnings, Mason's version, how long it has been up and its restarts (`engine`), the free disk and what a running copy is projected to add (`disk`), the sessions open
|
|
281
|
+
* (`sessions`). A fact the run did not say is absent; one it could not read is said as the CLI says it (its reason, never a 0). Mason's, the disk's, the restarts' and the sessions' rows are the CLI's (`masonRow`, `diskRows`, `restartsRows`, `sessionsRows`).
|
|
282
|
+
*/
|
|
283
|
+
export function renderFacts(s, now = Date.now()) {
|
|
284
|
+
const rows = [...sourceRows(s).map(([k, v]) => factRow(k, v)), ...s.warnings.map((w) => factRow("Warning", w, true))];
|
|
285
|
+
const e = s.engine;
|
|
286
|
+
if (typeof e.version === "string") rows.push(factRow(...masonRow(e)));
|
|
287
|
+
rows.push(...restartsRows(e.restarts, now).map(([k, v]) => factRow(k, v, k === "Restarts")));
|
|
288
|
+
// the copies in all, their rows, the last sync and the copy's file are the cards' (`renderKpis`): a fact in ONE place
|
|
289
|
+
const d = s.disk;
|
|
290
|
+
rows.push(...diskRows(d).map(([k, v]) => factRow(k, v, d.freeBytes === null || (d.projectedBytes !== null && d.projectedBytes > d.freeBytes))));
|
|
291
|
+
rows.push(...sessionsRows(s.sessions).map(([k, v]) => factRow(k, v)));
|
|
292
|
+
return `<table class="facts-table"><caption>Mason</caption><tbody>${rows.join("")}</tbody></table>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* THE COPIES NOT FINISHED, a bar each as `mason status` and `mason wait` print them (`tableLine`): copying, waiting,
|
|
297
|
+
* failed, or copied but not kept current — and nothing once every copy is done. With no table added, the commands'
|
|
298
|
+
* sentence and what adds one.
|
|
299
|
+
*/
|
|
300
|
+
function renderProgress(s, isOpen = () => false, shares = new Map(), before = new Map()) {
|
|
301
|
+
const picked = s.tables.filter((t) => t.added);
|
|
302
|
+
if (!picked.length) {
|
|
303
|
+
return `<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>` +
|
|
304
|
+
`<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} syncs one.</p>`;
|
|
305
|
+
}
|
|
306
|
+
// as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
|
|
307
|
+
const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
|
|
308
|
+
const lines = picked.map((t) => ({ t, l: tableLine(t) })).filter(({ l }) => l.state !== "done")
|
|
309
|
+
.sort((a, b) => rank[a.l.state] - rank[b.l.state] || (a.t.copy?.updatedAt ?? "").localeCompare(b.t.copy?.updatedAt ?? "") || a.t.table.localeCompare(b.t.table));
|
|
310
|
+
if (!lines.length) return "";
|
|
311
|
+
return `<h3>Tables not synced yet (${lines.length} of ${picked.length} added)</h3>` +
|
|
312
|
+
`<table class="tables"><thead><tr><th scope="col">Table</th><th scope="col">Sync</th><th scope="col"></th></tr></thead><tbody>${lines.map(({ t, l }) =>
|
|
313
|
+
`<tr class="t-${l.state}"><th scope="row"><code class="name" title="${esc(t.table)}">${esc(t.table)}</code></th>` +
|
|
314
|
+
`<td class="bar">${copyBar(t.table, l, shares.get(t.table) ?? l.fraction, before.get(t.table))}</td>` +
|
|
315
|
+
`<td class="what">${esc(l.what)}${t.error && t.error.includes("\n") ? details(`error:${t.table}`, "the whole error", `<pre>${esc(t.error)}</pre>`, isOpen(`error:${t.table}`)) : ""}</td></tr>`).join("")}</tbody></table>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* THE STATEMENTS of the last day and week (`/api/queries`, mason status's `Queries`, src/cli/commands.ts `queryLines`):
|
|
320
|
+
* how many, each outcome, the completed ones' average / p50 / p95 in ms, the rows and bytes sent; when the request log
|
|
321
|
+
* keeps fewer days than the week, that it is only what it keeps. `q` is the body, or `{ failure }`.
|
|
322
|
+
*/
|
|
323
|
+
export function renderQueries(q) {
|
|
324
|
+
if (q === undefined) return "";
|
|
325
|
+
if ("failure" in q) return `<p class="count">Queries: could not read them (${esc(firstLine(q.failure.message))}; ${code("mason logs")} has the detail)</p>`;
|
|
326
|
+
const w = q.queries;
|
|
327
|
+
const ms = msText;
|
|
328
|
+
const row = (label, x) => `<tr><th scope="row">${esc(label)}</th>${[n(x.statements), n(x.completed), n(x.refused), n(x.failed), n(x.cancelled),
|
|
329
|
+
x.durationMs === null ? "" : ms(x.durationMs.avg), x.durationMs === null ? "" : ms(x.durationMs.p50), x.durationMs === null ? "" : ms(x.durationMs.p95),
|
|
330
|
+
n(x.rows), bytesText(x.bytesSent)].map((v) => `<td class="num">${esc(v)}</td>`).join("")}</tr>`;
|
|
331
|
+
const short = [w.day, w.week].find((x) => x.covered < x.days);
|
|
332
|
+
// eleven columns of numbers: wider than a laptop's page beside the sidebar, so the table scrolls inside its own box, never the page
|
|
333
|
+
return `<div class="table-wrap"><table class="queries"><caption>Statements (the completed ones' time; ${code("mason logs --requests")} lists each)</caption><thead><tr><th scope="col"></th>` +
|
|
334
|
+
["Statements", "Completed", "Refused", "Failed", "Cancelled", "Average", "p50", "p95", "Rows sent", "Sent"].map((h) => `<th scope="col">${h}</th>`).join("") +
|
|
335
|
+
`</tr></thead><tbody>${row("Last day", w.day)}${row("Last week", w.week)}</tbody></table></div>` +
|
|
336
|
+
(short ? `<p class="dim">The request log keeps ${short.covered} day${short.covered === 1 ? "" : "s"}: MASON_HISTORY_DAYS.</p>` : "") +
|
|
337
|
+
`<p class="dim">The slow and the refused statements, and every other, are on <a href="${esc(LINKS.queries)}">Observability › Queries</a> (${code("mason logs --requests")}).</p>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A failed statement's cells, as mason status's `Query errors` line says them (src/cli/commands.ts `requestLine`): when,
|
|
341
|
+
* the outcome and SQLSTATE, how long, the rows sent, who, the statement on one line and the error's first line. */
|
|
342
|
+
export function requestCells(r) {
|
|
343
|
+
return [utcText(r.startedAt), r.outcome === "ok" ? "ok" : `${r.outcome} ${r.sqlstate ?? ""}`.trim(), msText(r.durationMs),
|
|
344
|
+
r.rows === null ? "" : `${n(r.rows)} row${r.rows === 1 ? "" : "s"}`,
|
|
345
|
+
`${clientText(r.applicationName)}${r.tokenName ? ` (token ${r.tokenName})` : ""}${r.user ? ` (user ${r.user})` : ""}`,
|
|
346
|
+
oneLine(r.query, 120) || "(no statement)", r.message ? firstLine(r.message) : ""];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A row of cells; `outcomeAt` is the column that says `refused 42P01`: the word is shown, the SQLSTATE is its title (`--json` and the CLI's line keep it). */
|
|
350
|
+
const cellsRow = (cells, outcomeAt = -1) => `<tr><th scope="row">${esc(cells[0])}</th>${cells.slice(1).map((c, i) => {
|
|
351
|
+
const m = i + 1 === outcomeAt ? /^(\S+) (\S+)$/.exec(c) : null;
|
|
352
|
+
return m ? `<td title="SQLSTATE ${esc(m[2])}">${esc(m[1])}</td>` : `<td>${esc(c)}</td>`;
|
|
353
|
+
}).join("")}</tr>`;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* THE UNSUPPORTED SQL OF THE LAST DAY in one line, as mason status heads it (`/api/unsupported`'s body, its default day):
|
|
357
|
+
* the most refused entry's line (`unsupportedEntry`), how many others there are, and See all (Observability › Queries
|
|
358
|
+
* lists each). Nothing when none was refused; one that could not be read says why, as mason status does.
|
|
359
|
+
*/
|
|
360
|
+
export function renderUnsupportedLine(u) {
|
|
361
|
+
if (u === undefined) return "";
|
|
362
|
+
if ("failure" in u) return `<p class="count">Unsupported SQL: could not read it (${esc(firstLine(u.failure.message))}; ${code("mason logs")} has the detail)</p>`;
|
|
363
|
+
const [top, ...rest] = u.unsupported;
|
|
364
|
+
if (top === undefined) return "";
|
|
365
|
+
return `<p class="unsupported-line"><strong>Unsupported SQL, the last day:</strong> <span title="docs/SQL-SUPPORT.md entry ${esc(top.gap)}">${esc(unsupportedEntry(top).headline)}</span>` +
|
|
366
|
+
`${rest.length ? ` (and ${n(rest.length)} other${rest.length === 1 ? "" : "s"})` : ""} <a class="see" href="${esc(LINKS.queries)}">See all</a></p>`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* THE RECENT ERRORS, as mason status lists them (`recentErrors`): the sync's latest failures (`/api/events?failed=1`)
|
|
371
|
+
* and the latest statements refused, failed or cancelled (`/api/requests?failed=1`), newest last; a kind with none
|
|
372
|
+
* says so, one that could not be read says why; then the last day's unsupported SQL in a line, when there is any
|
|
373
|
+
* (`renderUnsupportedLine`). Each argument is the body, or `{ failure }`.
|
|
374
|
+
*/
|
|
375
|
+
export function renderRecentErrors(events, requests, unsupported) {
|
|
376
|
+
const part = (title, r, list, head, cells, more, href, outcomeAt) => r === undefined ? ""
|
|
377
|
+
: "failure" in r ? `<h3>${title}</h3><p class="count">Could not read them (${esc(firstLine(r.failure.message))}; ${code("mason logs")} has the detail)</p>`
|
|
378
|
+
: !list(r).length ? `<h3>${title}</h3><p class="dim">None.</p>`
|
|
379
|
+
: `<table class="errors"><caption>${title} (the latest, newest last; ${code(more)} lists each) <a class="see" href="${esc(href)}">See all</a></caption><thead><tr>${head.map((h) => `<th scope="col">${h}</th>`).join("")}</tr></thead>` +
|
|
380
|
+
`<tbody>${list(r).map((x) => cellsRow(cells(x), outcomeAt)).join("")}</tbody></table>`;
|
|
381
|
+
return part("Sync errors", events, (r) => r.events, ["At (UTC)", "What", "Table", "Why"], (e) => Object.values(eventCells(e)), "mason logs --events", LINKS.failedEvents) +
|
|
382
|
+
part("Query errors", requests, (r) => r.requests, ["Started (UTC)", "Outcome", "Took", "Rows", "Who", "Statement", "Error"], requestCells, "mason logs --requests --failed", LINKS.failedQueries, 1) +
|
|
383
|
+
renderUnsupportedLine(unsupported);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* THE FIRST-RUN CHECKLIST, while setting up is not done: Mason is running (the state answered), a table copied (`totals`:
|
|
388
|
+
* one served), a user created (`/api/users`: one not removed), a first statement answered (`/api/queries`: one completed
|
|
389
|
+
* in the week the log keeps) — each ✓ or ○, a ○ with its one action: Add a table (Tables), Create a user (Users), the
|
|
390
|
+
* psql line's Copy (Connect's, under it). "" once all four are done, and while a read it needs has not answered or
|
|
391
|
+
* could not be read: a checklist never says ○ for what it could not tell. `users` is `/api/users`' body (or `{ failure }`).
|
|
392
|
+
*/
|
|
393
|
+
export function renderChecklist(s, queries, users) {
|
|
394
|
+
if (queries === undefined || users === undefined || "failure" in queries || "failure" in users) return "";
|
|
395
|
+
const added = s.tables.some((t) => t.added);
|
|
396
|
+
const copied = (s.totals?.copied ?? 0) > 0;
|
|
397
|
+
const user = users.users.some((u) => u.removedAt == null);
|
|
398
|
+
const answered = queries.queries.week.completed > 0;
|
|
399
|
+
if (copied && user && answered) return "";
|
|
400
|
+
const copying = added && !copied && copyingCount(s) > 0;
|
|
401
|
+
const steps = [
|
|
402
|
+
["Mason is running", true, ""],
|
|
403
|
+
["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
|
|
404
|
+
["A user created", user, `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
|
|
405
|
+
// the psql line is Connect's (rendered under the checklist once a table is added): its Copy copies it
|
|
406
|
+
["A first statement answered", answered, copied ? `${`<button type="button" class="small${user ? " primary" : ""}" data-copy="connect-psql">Copy the psql line</button>`} <span class="dim">then run a select on a synced table</span>` : ""],
|
|
407
|
+
];
|
|
408
|
+
const done = steps.filter(([, ok]) => ok).length;
|
|
409
|
+
return `<div class="checklist" role="group" aria-label="Getting started"><p class="checklist-title"><span class="courses" aria-hidden="true">${steps.map(([, ok]) => `<i${ok ? ' class="on"' : ""}></i>`).join("")}</span>Getting started <span class="dim">${done} of ${steps.length} done</span></p><ol>` +
|
|
410
|
+
steps.map(([what, ok, action]) => `<li class="${ok ? "step-done" : "step-todo"}"><span class="mark" aria-hidden="true">${ok ? "✓" : "○"}</span><span class="what">${esc(what)}<span class="sr">${ok ? " (done)" : " (to do)"}</span></span>${ok ? "" : action}</li>`).join("") +
|
|
411
|
+
`</ol></div>`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* THE OVERVIEW from `/api/state` and the request log's reads (`o.queries`, `o.events`, `o.requests`, `o.unsupported`, `o.usage`:
|
|
416
|
+
* each the body, or `{ failure }`; `o.now` the clock; `o.open(key)` whether a details is open): the word, the cards,
|
|
417
|
+
* the copies not finished, the facts, the stream, the statements, the recent errors. Never the engine's name.
|
|
418
|
+
*/
|
|
419
|
+
export function renderStatus(s, o = {}) {
|
|
420
|
+
const now = o.now ?? Date.now();
|
|
421
|
+
const added = s.tables.some((t) => t.added);
|
|
422
|
+
// how to connect, once a table is added: first under the cards while nothing has asked Mason anything (the last
|
|
423
|
+
// day's and week's statements, read as none), else after the facts, for the next tool
|
|
424
|
+
const first = isFirstConnect(o.queries);
|
|
425
|
+
const connect = added ? renderConnect(s.postgres, { first, served: s.served === true, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null,
|
|
426
|
+
share: renderShare(s, o.share ?? NO_SHARE, now) }) : "";
|
|
427
|
+
return renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
|
|
428
|
+
(added ? renderQueries(o.queries) : "") + (added ? renderRecentErrors(o.events, o.requests, o.unsupported) : "");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ------------------------------------------------------------------ Connect from anywhere (mason share, from the page)
|
|
432
|
+
|
|
433
|
+
/** The page's share before anything was asked: what `renderShare` and the page's model start from. */
|
|
434
|
+
export const NO_SHARE = Object.freeze({ phase: "none", choices: null, reads: null, role: null, opened: null, ended: null, failure: null });
|
|
435
|
+
|
|
436
|
+
/** The share the page holds, as `/api/state` `share` lists it (`from: "page"`), or null. */
|
|
437
|
+
export const pageShareOf = (s) => s?.share?.shares?.find((x) => x.from === "page") ?? null;
|
|
438
|
+
|
|
439
|
+
/** When a free share ends, as `mason share` says it: about an hour, and the time. */
|
|
440
|
+
const endsLine = (endsAt, now) => endsAt === null || endsAt === undefined ? "It ends when you Stop sharing, or when this page closes."
|
|
441
|
+
: `A free share ends within about an hour: at ${new Date(endsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}, in about ${Math.max(0, Math.round((Date.parse(endsAt) - now) / 60_000))} minutes. Then Connect from anywhere again gives a new address and password.`;
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* CONNECT FROM ANYWHERE, the Connect card's part (src/cli/page-share.ts; `mason share`'s, from the page): a pure function
|
|
445
|
+
* of the state (`s`) and the page's own model (`m`: `phase` none · asking · opening · open · ended · refused, the roles
|
|
446
|
+
* offered, the answer that opened it — the password is in it alone — and the failure). Nothing under `mason serve`.
|
|
447
|
+
*/
|
|
448
|
+
export function renderShare(s, m = NO_SHARE, now = Date.now()) {
|
|
449
|
+
if (s?.served === true) return "";
|
|
450
|
+
const open = pageShareOf(s);
|
|
451
|
+
const head = `<p class="connect-title share-title">Connect from anywhere</p>`;
|
|
452
|
+
const ask = button("Connect from anywhere", { shareAsk: "" }, "", { disabled: m.phase === "asking" || m.phase === "opening" });
|
|
453
|
+
const askLine = `<p class="tip" id="share-anchor">${ask} <span>For a tool in the cloud, which cannot reach this machine: a temporary public address and a password, as ${code("mason share")} opens. TLS is required of every connection.</span></p>`;
|
|
454
|
+
if (open !== null && m.phase !== "ended") {
|
|
455
|
+
const mine = m.opened !== null && m.opened.user === open.user ? m.opened : null;
|
|
456
|
+
const rows = [["Host", open.host ?? "opening…", "share-host"], ["Port", open.port === null ? "…" : String(open.port), "share-port"], ["Database", mine?.database ?? s.postgres?.database ?? "", null],
|
|
457
|
+
["User", open.user, "share-user"], ...(mine !== null ? [["Password", mine.password, "share-password"]] : []), ["SSL mode", "require", null]];
|
|
458
|
+
return `<div class="share open" role="status">${head}<p class="headline">Shared ${badge("Public", "warn")} for <time data-tick datetime="${esc(open.openedAt)}">${esc(spanText((now - Date.parse(open.openedAt)) / 1000))}</time>` +
|
|
459
|
+
`${open.role !== null ? `, reading as the role ${esc(open.role)}` : ""}: ${esc(open.reads)}</p>` +
|
|
460
|
+
`<dl class="facts">${rows.map(([k, v, id]) => `<dt>${esc(k)}</dt><dd>${id ? copyable(id, v) : esc(v)}</dd>`).join("")}</dl>` +
|
|
461
|
+
(mine !== null ? `<p class="tip">Paste it into the tool now: the password is shown this once, is kept nowhere, and ends with the share.</p>`
|
|
462
|
+
: `<p class="tip">The password was shown once, when it opened, and is kept nowhere: Stop sharing and Connect from anywhere again for a new one.</p>`) +
|
|
463
|
+
`<p class="tip">${esc(endsLine(open.endsAt, now))} It also ends within two minutes of this page closing.</p>` +
|
|
464
|
+
`<p class="tip">${button("Stop sharing", { shareStop: open.user }, "danger")}</p></div>`;
|
|
465
|
+
}
|
|
466
|
+
if (m.phase === "opening") return `<div class="share">${head}<p class="dim" aria-busy="true">Opening a temporary public address… (up to 30 s)</p></div>`;
|
|
467
|
+
const before = m.phase === "ended" && m.ended !== null
|
|
468
|
+
? `<p class="said">The share ended: ${esc(m.ended.why)}. Its address no longer answers, and its password no longer works.</p>`
|
|
469
|
+
: m.phase === "refused" && m.failure !== null ? renderFailure(m.failure) : "";
|
|
470
|
+
const question = m.phase !== "asking" ? "" : pop("share-anchor", m.choices === null ? `<p class="dim">Reading the roles…</p>` :
|
|
471
|
+
`<p>This opens a <strong>temporary public address</strong>: anyone with it and its password reads ${esc(m.reads ?? "what the role you choose reads")}. A free share ends within about an hour, and when this page closes.</p>` +
|
|
472
|
+
(m.choices.length > 0 ? `<label for="share-role">Read as the role</label> <select id="share-role">${m.choices.map((c) => `<option value="${esc(c.role)}"${c.role === m.role ? " selected" : ""}>${esc(`${c.role}: ${c.reads}${c.default ? " (the file's default)" : ""}`)}</option>`).join("")}</select>` : "") +
|
|
473
|
+
`<p>${button("Open the address", { shareOpen: "" }, "primary")} ${button("Cancel", { shareCancel: "", popClose: "" })}</p>`, { kind: "dialog", label: "Connect from anywhere" });
|
|
474
|
+
return `<div class="share">${head}${before}${askLine}${question}</div>`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Nothing has asked Mason anything yet: the last day's statements were read (`/api/queries`' body), and are none. */
|
|
478
|
+
export const isFirstConnect = (q) => q !== undefined && !("failure" in q) && q.queries.day.statements === 0 && q.queries.week.statements === 0;
|
|
479
|
+
|
|
480
|
+
/** The most entries of the unsupported SQL `/api/unsupported` answers (src/cli/run-server.ts MAX_UNSUPPORTED_LIMIT, held
|
|
481
|
+
* equal by the tests): the Overview says the first and counts the others, so it reads them all. */
|
|
482
|
+
export const UNSUPPORTED_LIMIT = 100;
|
|
483
|
+
|
|
484
|
+
/** What the page reads every 30 s beside the state: the request log's summary, the recent errors, and the last day's
|
|
485
|
+
* unsupported SQL. */
|
|
486
|
+
const SLOWER = ["/api/queries", `/api/events?failed=1&limit=${RECENT_ERRORS}`, `/api/requests?failed=1&limit=${RECENT_ERRORS}`, `/api/unsupported?limit=${UNSUPPORTED_LIMIT}`, "/api/users"];
|
|
487
|
+
|
|
488
|
+
/** A read as `renderStatus` takes it: the body, or `{ failure }` when it could not be read; undefined before any read. */
|
|
489
|
+
const asRequest = (r) => r === undefined ? undefined : r.failure ? { failure: r.failure } : r.body;
|
|
490
|
+
|
|
491
|
+
export const page = {
|
|
492
|
+
id: "status",
|
|
493
|
+
hash: "overview",
|
|
494
|
+
title: "Overview",
|
|
495
|
+
create(ctx) {
|
|
496
|
+
// the details open (a copy's whole error), by key: a poll rewrites the section, and they stay open
|
|
497
|
+
const opened = createOpenSet();
|
|
498
|
+
const monotone = createMonotone();
|
|
499
|
+
const share = createShare(ctx);
|
|
500
|
+
return {
|
|
501
|
+
model: { open: opened.open, share: share.m },
|
|
502
|
+
reads: () => [{ path: "/api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
|
|
503
|
+
actions: share.actions,
|
|
504
|
+
afterRead: () => share.afterRead(ctx.read("/api/state")?.body),
|
|
505
|
+
input: (target) => share.input(target),
|
|
506
|
+
view() {
|
|
507
|
+
const st = ctx.read("/api/state");
|
|
508
|
+
if (!st?.body) return { status: st?.failure ? "" : skeleton(4) };
|
|
509
|
+
const [queries, events, requests, unsupported, users] = SLOWER.map((p) => asRequest(ctx.read(p)));
|
|
510
|
+
return { status: renderStatus(st.body, { now: ctx.now().getTime(), queries, events, requests, unsupported, users, usage: ctx.read(HOURLY)?.body, open: opened.has, shares: monotone.of(st.body), before: monotone.before, share: share.m }) };
|
|
511
|
+
},
|
|
512
|
+
toggle: (target) => opened.toggle(target),
|
|
513
|
+
// Connect's Copy buttons, and Connect from anywhere's
|
|
514
|
+
click: (d, button, env) => share.click(d) ?? copyClick(d, button, env),
|
|
515
|
+
charts: () => sparkSpecs(ctx.read(HOURLY)?.body),
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/** What a page beats with, when the answer that opened the share does not say (src/cli/page-share.ts BEAT_MS). */
|
|
521
|
+
const BEAT_MS = 15_000;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* THE PAGE'S SIDE OF CONNECT FROM ANYWHERE (src/cli/page-share.ts has why each choice): ask once, open, BEAT while the
|
|
525
|
+
* share is open — on a timer of its own, which runs while the tab is hidden (the poll does not: the person is in their
|
|
526
|
+
* tool's tab, pasting) —, Stop sharing. The password is in `m.opened` alone, the page's memory, and is dropped the moment
|
|
527
|
+
* the share is seen ended. `ctx.timers` is a test's clock; a browser's own otherwise.
|
|
528
|
+
*/
|
|
529
|
+
export function createShare(ctx) {
|
|
530
|
+
const m = { ...NO_SHARE };
|
|
531
|
+
const timers = ctx.timers ?? globalThis;
|
|
532
|
+
let beating = null;
|
|
533
|
+
const stopBeats = () => { if (beating !== null) { timers.clearInterval(beating.id); beating = null; } };
|
|
534
|
+
const ended = (user, why) => { stopBeats(); m.opened = null; m.phase = "ended"; m.ended = { user, why }; };
|
|
535
|
+
const beat = async () => {
|
|
536
|
+
if (beating === null) return;
|
|
537
|
+
const user = beating.user;
|
|
538
|
+
const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}/beat`, json("POST"));
|
|
539
|
+
if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["/api/state"]); }
|
|
540
|
+
};
|
|
541
|
+
const startBeats = (user, everyMs = BEAT_MS) => {
|
|
542
|
+
if (beating?.user === user) return;
|
|
543
|
+
stopBeats();
|
|
544
|
+
beating = { user, id: timers.setInterval(() => { void beat(); }, everyMs) };
|
|
545
|
+
};
|
|
546
|
+
const actions = {
|
|
547
|
+
/** Connect from anywhere: what a share would read, then the question */
|
|
548
|
+
async shareAsk() {
|
|
549
|
+
Object.assign(m, NO_SHARE, { phase: "asking" });
|
|
550
|
+
const r = await ctx.ask("/api/share/choices");
|
|
551
|
+
if (m.phase !== "asking") return;
|
|
552
|
+
if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
|
|
553
|
+
m.choices = r.body.choices;
|
|
554
|
+
m.reads = r.body.reads ?? null;
|
|
555
|
+
m.role = r.body.choices.find((c) => c.default)?.role ?? r.body.choices[0]?.role ?? null;
|
|
556
|
+
},
|
|
557
|
+
shareCancel() { Object.assign(m, NO_SHARE); },
|
|
558
|
+
shareRole(role) { if (m.choices?.some((c) => c.role === role)) m.role = role; },
|
|
559
|
+
/** the person said yes: the run opens the share (confirmed), and its one answer carries the password */
|
|
560
|
+
async shareOpen() {
|
|
561
|
+
if (m.phase !== "asking" || m.choices === null) return;
|
|
562
|
+
const role = m.choices.length > 0 ? m.role : null;
|
|
563
|
+
Object.assign(m, { phase: "opening", failure: null });
|
|
564
|
+
const r = await ctx.ask("/api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
|
|
565
|
+
if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
|
|
566
|
+
Object.assign(m, { phase: "open", opened: { ...r.body, seen: false } });
|
|
567
|
+
startBeats(r.body.user, r.body.beatMs);
|
|
568
|
+
await ctx.refresh(["/api/state"]);
|
|
569
|
+
},
|
|
570
|
+
async shareStop(user) {
|
|
571
|
+
const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}`, json("DELETE"));
|
|
572
|
+
if ("failure" in r && r.failure.code !== "not_found") { m.phase = "refused"; m.failure = r.failure; return; }
|
|
573
|
+
ended(user, "Stop sharing, on this page");
|
|
574
|
+
await ctx.refresh(["/api/state"]);
|
|
575
|
+
},
|
|
576
|
+
shareBeat: beat,
|
|
577
|
+
};
|
|
578
|
+
return {
|
|
579
|
+
m,
|
|
580
|
+
actions,
|
|
581
|
+
/** after each read of the state: a share of the page's open is beaten (a reload's too); the one this page opened,
|
|
582
|
+
* once listed and now gone, ended — said with the run's why, and its password dropped */
|
|
583
|
+
afterRead(s) {
|
|
584
|
+
if (s === undefined || s === null) return;
|
|
585
|
+
const open = pageShareOf(s);
|
|
586
|
+
if (open !== null) {
|
|
587
|
+
if (m.opened?.user === open.user) m.opened.seen = true;
|
|
588
|
+
if (m.phase === "ended" && m.ended?.user === open.user) return;
|
|
589
|
+
if (m.phase === "none" || m.phase === "ended") m.phase = "open";
|
|
590
|
+
startBeats(open.user);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (m.phase === "open" && (m.opened === null || m.opened.seen)) {
|
|
594
|
+
const user = m.opened?.user ?? beating?.user ?? null;
|
|
595
|
+
ended(user, s.shareEnded !== null && s.shareEnded !== undefined && s.shareEnded.user === user ? s.shareEnded.why : "it is no longer open");
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
input(target) {
|
|
599
|
+
if (target?.id !== "share-role") return null;
|
|
600
|
+
actions.shareRole(target.value);
|
|
601
|
+
return true;
|
|
602
|
+
},
|
|
603
|
+
click(d) {
|
|
604
|
+
if (d.shareAsk !== undefined) return actions.shareAsk() ?? true;
|
|
605
|
+
if (d.shareOpen !== undefined) return actions.shareOpen();
|
|
606
|
+
if (d.shareCancel !== undefined) return actions.shareCancel() ?? true;
|
|
607
|
+
if (d.shareStop !== undefined) return actions.shareStop(d.shareStop);
|
|
608
|
+
return null;
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|