@mason-data/cli 0.6.2 → 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 +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- 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 +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- 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 +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- 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 +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- 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 +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- 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 +9 -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/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- 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,446 @@
|
|
|
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 } from "../shared.js";
|
|
24
|
+
import { spark, seriesOf, drawable } from "../charts.js";
|
|
25
|
+
|
|
26
|
+
/** How many recent errors of each kind are shown: mason status's (src/cli/commands.ts `RECENT_ERRORS`, held equal by the tests). */
|
|
27
|
+
export const RECENT_ERRORS = 5;
|
|
28
|
+
|
|
29
|
+
/** The last 24 hours an hour a point: the cards' sparklines (Usage's read at its default range). */
|
|
30
|
+
const HOURLY = usagePath("24h");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
|
|
34
|
+
* page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
|
|
35
|
+
*/
|
|
36
|
+
export function copyingText(copied, rowsEstimate, p) {
|
|
37
|
+
const shown = copied + (p?.rowsReading ?? 0);
|
|
38
|
+
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
39
|
+
const past = estimate !== null && shown > estimate;
|
|
40
|
+
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
41
|
+
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
42
|
+
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
43
|
+
if (p === undefined) return { fraction, text: rowsText(copied) };
|
|
44
|
+
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
45
|
+
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
46
|
+
if (p.rowsPerSecond === null) {
|
|
47
|
+
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
48
|
+
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to sync`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
49
|
+
}
|
|
50
|
+
const parts = [rowsText(shown)];
|
|
51
|
+
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
52
|
+
if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
53
|
+
else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
54
|
+
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
55
|
+
if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
|
|
56
|
+
return { fraction, text: parts.join(" · ") };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
|
|
61
|
+
* its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says — a copied one with how it is kept
|
|
62
|
+
* current and how old its copy is (`keptAge`).
|
|
63
|
+
*/
|
|
64
|
+
export function tableLine(t, now = Date.now()) {
|
|
65
|
+
if (t.error) return { state: "failed", fraction: 1, what: `its first sync failed: ${firstLine(t.error)}` };
|
|
66
|
+
if (t.copy?.phase === "copying") {
|
|
67
|
+
const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
|
|
68
|
+
return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `syncing ${c.text}` : c.text };
|
|
69
|
+
}
|
|
70
|
+
if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its first sync to start" };
|
|
71
|
+
const age = keptAge(t, now);
|
|
72
|
+
// a copy the poll does not keep current (it waits for its catch-up) is a warning, as mason status paints it (paintBar)
|
|
73
|
+
if ((t.kept?.by === "poll" && t.kept.pending !== undefined) || (t.kept?.by === "replication" && (t.kept.state !== "streamed" || t.kept.notRunning !== null))) {
|
|
74
|
+
return { state: "unsynced", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""} · ${age}` };
|
|
75
|
+
}
|
|
76
|
+
return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · synced${t.copy.retired ? ` · ${t.copy.retired}` : ""}${age ? ` · ${age}` : ""}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How long ago a table was synced, as `mason status` says it on its line (src/lib/table-sync.ts `agoText`, which
|
|
80
|
+
* writes `4 s ago`, `3 days ago`; shared.js `agoText` is mason status's `ago`, `4s ago`, for a stream's times). */
|
|
81
|
+
function syncedAgo(iso, now = Date.now()) {
|
|
82
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
83
|
+
if (s < 90) return `${s} s ago`;
|
|
84
|
+
if (s < 90 * 60) return `${Math.round(s / 60)} min ago`;
|
|
85
|
+
if (s < 36 * 3600) return `${Math.round(s / 3600)} h ago`;
|
|
86
|
+
const d = Math.round(s / 86_400);
|
|
87
|
+
return `${d} day${d === 1 ? "" : "s"} ago`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How a copied table is kept current and how old its copy is (src/cli/commands.ts `keptAge`). */
|
|
91
|
+
export function keptAge(t, now = Date.now()) {
|
|
92
|
+
const k = t.kept;
|
|
93
|
+
if (k?.by === "replication") {
|
|
94
|
+
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"}`;
|
|
95
|
+
const at = `replicated, at ${k.position}, ${k.syncedAt === null ? "not synced yet" : syncedAgo(k.syncedAt, now)}`;
|
|
96
|
+
return k.notRunning === null ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
|
|
97
|
+
}
|
|
98
|
+
if (k?.by !== "poll") return null;
|
|
99
|
+
const synced = k.syncedAt === null ? "never synced" : `last synced ${syncedAgo(k.syncedAt, now)}`;
|
|
100
|
+
if (k.pending !== undefined) return `NOT kept current (${synced}): ${k.pending}`;
|
|
101
|
+
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`;
|
|
102
|
+
return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${syncedAgo(k.syncedAt, now)}`}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A COPY'S BAR, the one renderer of the Overview's "copies not finished" and the Tables page's rows: a thin rounded
|
|
107
|
+
* 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),
|
|
108
|
+
* with an explicit `role="progressbar"` and `aria-valuemin/max/now` (and a text label, `aria-valuetext`, the line's
|
|
109
|
+
* words); the track is `aria-hidden` drawing. `l` is `tableLine`'s, `fraction` the share to draw (`monotone`'s; the
|
|
110
|
+
* line's own by default): the fill's colour is the line's state (the stylesheet's `.t-<state>`: green served, the accent
|
|
111
|
+
* while it copies, amber not kept current, rose failed). A table waiting for its copy has no value: no fill but the
|
|
112
|
+
* stylesheet's quiet shimmer over the track, `aria-valuenow` and the % left out. `from`, the share at the page's last
|
|
113
|
+
* read (`monotone.before`), is kept for the callers; the fill's own transition moves it from there.
|
|
114
|
+
*/
|
|
115
|
+
export function copyBar(table, l, fraction = l.fraction, _from) {
|
|
116
|
+
const waiting = l.state === "waiting";
|
|
117
|
+
const value = Math.floor(Math.max(0, Math.min(1, fraction)) * 100);
|
|
118
|
+
return `<span class="copybar${waiting ? " indeterminate" : ""}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
|
|
119
|
+
`${waiting ? "" : ` aria-valuenow="${value}"`} aria-label="${esc(`${table}: its first sync`)}" aria-valuetext="${esc(waiting ? l.what : `${value}%, ${l.what}`)}">` +
|
|
120
|
+
`<span class="track" aria-hidden="true"><span class="fill"${waiting ? "" : ` style="width: ${value}%"`}></span></span></span>` +
|
|
121
|
+
// a copy not begun has no share yet: no "0%" at its end (its line says it waits)
|
|
122
|
+
(waiting ? "" : ` <span class="pct">${percent(fraction)}</span>`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* THE SHARES A PAGE DRAWS, NEVER BACKWARDS: for each added table that is copying, the most its share has been since the
|
|
127
|
+
* 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
|
|
128
|
+
* a re-read, too). A table that stops copying (done, failed, waiting) is forgotten, so its next copy begins at its own.
|
|
129
|
+
* `of(s)` → Map(table → share) for the tables copying in the state `s`.
|
|
130
|
+
*/
|
|
131
|
+
export function createMonotone() {
|
|
132
|
+
const seen = new Map();
|
|
133
|
+
let before = new Map();
|
|
134
|
+
return {
|
|
135
|
+
/** The shares of the read before the last `of`: each copying table's (`copyBar`'s `from`). */
|
|
136
|
+
get before() { return before; },
|
|
137
|
+
of(s) {
|
|
138
|
+
before = new Map(seen);
|
|
139
|
+
const now = new Map();
|
|
140
|
+
for (const t of s.tables) {
|
|
141
|
+
if (!t.added) continue;
|
|
142
|
+
const l = tableLine(t);
|
|
143
|
+
if (l.state === "copying") now.set(t.table, Math.max(seen.get(t.table) ?? 0, l.fraction));
|
|
144
|
+
}
|
|
145
|
+
seen.clear();
|
|
146
|
+
for (const [k, v] of now) seen.set(k, v);
|
|
147
|
+
return now;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `mason status`'s rows for the database (src/cli/commands.ts `sourceRows`): its sources file (from the project's root:
|
|
153
|
+
* the page never shows an absolute path), its URL as the run masked it, how its changes arrive. */
|
|
154
|
+
export function sourceRows(s) {
|
|
155
|
+
return [
|
|
156
|
+
...(s.source.file ? [["Source", `${s.source.shownFile ?? s.source.file} (the tables it names are added)`]] : []),
|
|
157
|
+
["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
|
|
158
|
+
: `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
|
|
159
|
+
// in a web developer's words first (each table as its sync: says, streamed as they happen); the mechanism an operator needs in parentheses
|
|
160
|
+
["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)"}` : ""} · ` +
|
|
161
|
+
(s.source.replication ? `sync: replication tables streamed as they happen (logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication}, ` +
|
|
162
|
+
`${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`
|
|
163
|
+
: s.source.demo ? "the demo database is checked this way only"
|
|
164
|
+
: "sync: replication on an entry streams that table as it happens instead")],
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** THE RUN IN ONE WORD, as `mason status` heads its last line (src/cli/readiness.ts `readinessWord`): Ready, Failed or
|
|
169
|
+
* Under way. */
|
|
170
|
+
export function readinessWord(r) {
|
|
171
|
+
return r.ready ? "Ready" : r.failed.length ? "Failed" : "Under way";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The copies in all, as mason status's `Copies` row says them (src/cli/commands.ts `totalsText`). */
|
|
175
|
+
export function totalsText(t, now = Date.now()) {
|
|
176
|
+
const last = t.syncedAt === null ? "" : `, the last synced ${duration((now - Date.parse(t.syncedAt)) / 1000)} ago`;
|
|
177
|
+
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}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The word at the top of the page: the run's readiness ("" with no table added: `renderStatus` says so, and what adds one). */
|
|
181
|
+
export function renderHealth(s) {
|
|
182
|
+
if (!s.tables.some((t) => t.added) || !s.readiness) return "";
|
|
183
|
+
const word = readinessWord(s.readiness);
|
|
184
|
+
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>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The sparklines the cards draw, by holder key: a usage sample's field each (src/web/charts.js specs). */
|
|
188
|
+
const SPARKS = { rows: ["copyRows", "Rows synced"], "copy-bytes": ["copyBytes", "The synced data's file"], statements: ["statements", "Statements"], p95: ["durationP95Ms", "p95"] };
|
|
189
|
+
|
|
190
|
+
/** The sparklines' specs from the usage answer (`/api/usage`'s body; anything else draws none): what `charts()` returns. */
|
|
191
|
+
export function sparkSpecs(usage) {
|
|
192
|
+
const samples = usage && Array.isArray(usage.samples) ? usage.samples : [];
|
|
193
|
+
return Object.fromEntries(Object.entries(SPARKS).map(([key, [field, label]]) => [key, { kind: "spark", label, ...seriesOf(samples, field) }]).filter(([, spec]) => drawable(spec)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** How many added tables are copying now (tableLine's word), the cards' "N copying". */
|
|
197
|
+
export const copyingCount = (s) => s.tables.filter((t) => t.added && tableLine(t).state === "copying").length;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* THE KPI CARDS: the copies in all (synced of added, the failed), their rows, the last sync (how long ago, and when),
|
|
201
|
+
* the copy's file (its size, or why it is unknown), and the last day's statements and their p95 from the request log
|
|
202
|
+
* (`q`: `/api/queries`' body, or `{ failure }`). Each card's sparkline is the last 24 hours (`usage`), only where there
|
|
203
|
+
* 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.
|
|
204
|
+
*/
|
|
205
|
+
export function renderKpis(s, o = {}) {
|
|
206
|
+
const now = o.now ?? Date.now();
|
|
207
|
+
const sparks = sparkSpecs(o.usage);
|
|
208
|
+
const line = (key) => sparks[key] ? spark(key, `${SPARKS[key][1]}, the last 24 hours an hour a point`) : "";
|
|
209
|
+
const list = [];
|
|
210
|
+
const t = s.totals;
|
|
211
|
+
// as renderStatus judges it: a table added (the totals count what the sources file names)
|
|
212
|
+
const added = Boolean(t) && s.tables.some((x) => x.added);
|
|
213
|
+
if (added) list.push(
|
|
214
|
+
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),
|
|
215
|
+
card("Rows", n(t.rows), "", line("rows"), "", LINKS.tables),
|
|
216
|
+
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));
|
|
217
|
+
const d = s.disk;
|
|
218
|
+
const copyBytes = s.engine.bytes ?? (d && typeof d.copyBytes === "number" ? d.copyBytes : null);
|
|
219
|
+
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));
|
|
220
|
+
const q = o.queries;
|
|
221
|
+
if (added && q !== undefined) {
|
|
222
|
+
if ("failure" in q) list.push(card("Statements, last day", "unknown", `could not read them: ${firstLine(q.failure.message)}`, "", "", LINKS.queries));
|
|
223
|
+
else {
|
|
224
|
+
const day = q.queries.day;
|
|
225
|
+
const bad = day.refused + day.failed + day.cancelled;
|
|
226
|
+
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),
|
|
227
|
+
card("p95, last day", day.durationMs === null ? "–" : msText(day.durationMs.p95),
|
|
228
|
+
day.durationMs === null ? "no statement completed" : `average ${msText(day.durationMs.avg)} · p50 ${msText(day.durationMs.p50)}`, line("p95"), "", LINKS.slow));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return cards(list);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A fact's row; `warn` marks one the reader should act on. */
|
|
235
|
+
const factRow = (k, v, warn = false) => `<tr${warn ? ` class="warn"` : ""}><th scope="row">${esc(k)}</th><td>${esc(v)}</td></tr>`;
|
|
236
|
+
|
|
237
|
+
/** The stream's lag, said once: the bytes behind the database and the age of the newest change applied. */
|
|
238
|
+
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`}`;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* THE CHANGES THE STREAM CARRIES (replication mode: `/api/state` `stream`; "" without one), a table of facts: whether it
|
|
242
|
+
* runs and why not, its position, its lag, the last batch applied (`streamRows`, held equal to `mason status`'s
|
|
243
|
+
* `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.
|
|
244
|
+
*/
|
|
245
|
+
function renderStream(s, now = Date.now()) {
|
|
246
|
+
const st = s.stream;
|
|
247
|
+
if (!st) return "";
|
|
248
|
+
const { head } = streamRows(st, now);
|
|
249
|
+
// `head` is the CLI's rows: "stream" (only when it is not running, and why), the position, the last change
|
|
250
|
+
const label = (k) => k[0].toUpperCase() + k.slice(1);
|
|
251
|
+
const rows = [...(st.running ? [factRow("Stream", "running")] : []), ...head.map(([k, v]) => factRow(label(k), v, k === "stream" || (k === "slot" && slotWarns(st.slotWal))))];
|
|
252
|
+
rows.splice(2, 0, factRow("Lag", lagText(st.lag)));
|
|
253
|
+
return `<table class="facts-table"><caption>Changes (the stream${s.source.replication ? `, slot ${esc(s.source.replication.slot)}` : ""})</caption><tbody>${rows.join("")}</tbody></table>`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* THE FACTS, ONE table of what `mason status` says beside the cards: the database and how its changes arrive (`sourceRows`), the
|
|
258
|
+
* 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
|
|
259
|
+
* (`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`).
|
|
260
|
+
*/
|
|
261
|
+
export function renderFacts(s, now = Date.now()) {
|
|
262
|
+
const rows = [...sourceRows(s).map(([k, v]) => factRow(k, v)), ...s.warnings.map((w) => factRow("Warning", w, true))];
|
|
263
|
+
const e = s.engine;
|
|
264
|
+
if (typeof e.version === "string") rows.push(factRow(...masonRow(e)));
|
|
265
|
+
rows.push(...restartsRows(e.restarts, now).map(([k, v]) => factRow(k, v, k === "Restarts")));
|
|
266
|
+
// the copies in all, their rows, the last sync and the copy's file are the cards' (`renderKpis`): a fact in ONE place
|
|
267
|
+
const d = s.disk;
|
|
268
|
+
rows.push(...diskRows(d).map(([k, v]) => factRow(k, v, d.freeBytes === null || (d.projectedBytes !== null && d.projectedBytes > d.freeBytes))));
|
|
269
|
+
rows.push(...sessionsRows(s.sessions).map(([k, v]) => factRow(k, v)));
|
|
270
|
+
return `<table class="facts-table"><caption>Mason</caption><tbody>${rows.join("")}</tbody></table>`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* THE COPIES NOT FINISHED, a bar each as `mason status` and `mason wait` print them (`tableLine`): copying, waiting,
|
|
275
|
+
* failed, or copied but not kept current — and nothing once every copy is done. With no table added, the commands'
|
|
276
|
+
* sentence and what adds one.
|
|
277
|
+
*/
|
|
278
|
+
function renderProgress(s, isOpen = () => false, shares = new Map(), before = new Map()) {
|
|
279
|
+
const picked = s.tables.filter((t) => t.added);
|
|
280
|
+
if (!picked.length) {
|
|
281
|
+
return `<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>` +
|
|
282
|
+
`<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} syncs one.</p>`;
|
|
283
|
+
}
|
|
284
|
+
// as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
|
|
285
|
+
const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
|
|
286
|
+
const lines = picked.map((t) => ({ t, l: tableLine(t) })).filter(({ l }) => l.state !== "done")
|
|
287
|
+
.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));
|
|
288
|
+
if (!lines.length) return "";
|
|
289
|
+
return `<h3>Tables not synced yet (${lines.length} of ${picked.length} added)</h3>` +
|
|
290
|
+
`<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 }) =>
|
|
291
|
+
`<tr class="t-${l.state}"><th scope="row"><code class="name" title="${esc(t.table)}">${esc(t.table)}</code></th>` +
|
|
292
|
+
`<td class="bar">${copyBar(t.table, l, shares.get(t.table) ?? l.fraction, before.get(t.table))}</td>` +
|
|
293
|
+
`<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>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* THE STATEMENTS of the last day and week (`/api/queries`, mason status's `Queries`, src/cli/commands.ts `queryLines`):
|
|
298
|
+
* how many, each outcome, the completed ones' average / p50 / p95 in ms, the rows and bytes sent; when the request log
|
|
299
|
+
* keeps fewer days than the week, that it is only what it keeps. `q` is the body, or `{ failure }`.
|
|
300
|
+
*/
|
|
301
|
+
export function renderQueries(q) {
|
|
302
|
+
if (q === undefined) return "";
|
|
303
|
+
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>`;
|
|
304
|
+
const w = q.queries;
|
|
305
|
+
const ms = msText;
|
|
306
|
+
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),
|
|
307
|
+
x.durationMs === null ? "" : ms(x.durationMs.avg), x.durationMs === null ? "" : ms(x.durationMs.p50), x.durationMs === null ? "" : ms(x.durationMs.p95),
|
|
308
|
+
n(x.rows), bytesText(x.bytesSent)].map((v) => `<td class="num">${esc(v)}</td>`).join("")}</tr>`;
|
|
309
|
+
const short = [w.day, w.week].find((x) => x.covered < x.days);
|
|
310
|
+
// eleven columns of numbers: wider than a laptop's page beside the sidebar, so the table scrolls inside its own box, never the page
|
|
311
|
+
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>` +
|
|
312
|
+
["Statements", "Completed", "Refused", "Failed", "Cancelled", "Average", "p50", "p95", "Rows sent", "Sent"].map((h) => `<th scope="col">${h}</th>`).join("") +
|
|
313
|
+
`</tr></thead><tbody>${row("Last day", w.day)}${row("Last week", w.week)}</tbody></table></div>` +
|
|
314
|
+
(short ? `<p class="dim">The request log keeps ${short.covered} day${short.covered === 1 ? "" : "s"}: MASON_HISTORY_DAYS.</p>` : "") +
|
|
315
|
+
`<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>`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A failed statement's cells, as mason status's `Query errors` line says them (src/cli/commands.ts `requestLine`): when,
|
|
319
|
+
* the outcome and SQLSTATE, how long, the rows sent, who, the statement on one line and the error's first line. */
|
|
320
|
+
export function requestCells(r) {
|
|
321
|
+
return [utcText(r.startedAt), r.outcome === "ok" ? "ok" : `${r.outcome} ${r.sqlstate ?? ""}`.trim(), msText(r.durationMs),
|
|
322
|
+
r.rows === null ? "" : `${n(r.rows)} row${r.rows === 1 ? "" : "s"}`,
|
|
323
|
+
`${clientText(r.applicationName)}${r.tokenName ? ` (token ${r.tokenName})` : ""}${r.user ? ` (user ${r.user})` : ""}`,
|
|
324
|
+
oneLine(r.query, 120) || "(no statement)", r.message ? firstLine(r.message) : ""];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 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). */
|
|
328
|
+
const cellsRow = (cells, outcomeAt = -1) => `<tr><th scope="row">${esc(cells[0])}</th>${cells.slice(1).map((c, i) => {
|
|
329
|
+
const m = i + 1 === outcomeAt ? /^(\S+) (\S+)$/.exec(c) : null;
|
|
330
|
+
return m ? `<td title="SQLSTATE ${esc(m[2])}">${esc(m[1])}</td>` : `<td>${esc(c)}</td>`;
|
|
331
|
+
}).join("")}</tr>`;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* THE UNSUPPORTED SQL OF THE LAST DAY in one line, as mason status heads it (`/api/unsupported`'s body, its default day):
|
|
335
|
+
* the most refused entry's line (`unsupportedEntry`), how many others there are, and See all (Observability › Queries
|
|
336
|
+
* lists each). Nothing when none was refused; one that could not be read says why, as mason status does.
|
|
337
|
+
*/
|
|
338
|
+
export function renderUnsupportedLine(u) {
|
|
339
|
+
if (u === undefined) return "";
|
|
340
|
+
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>`;
|
|
341
|
+
const [top, ...rest] = u.unsupported;
|
|
342
|
+
if (top === undefined) return "";
|
|
343
|
+
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>` +
|
|
344
|
+
`${rest.length ? ` (and ${n(rest.length)} other${rest.length === 1 ? "" : "s"})` : ""} <a class="see" href="${esc(LINKS.queries)}">See all</a></p>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* THE RECENT ERRORS, as mason status lists them (`recentErrors`): the sync's latest failures (`/api/events?failed=1`)
|
|
349
|
+
* and the latest statements refused, failed or cancelled (`/api/requests?failed=1`), newest last; a kind with none
|
|
350
|
+
* says so, one that could not be read says why; then the last day's unsupported SQL in a line, when there is any
|
|
351
|
+
* (`renderUnsupportedLine`). Each argument is the body, or `{ failure }`.
|
|
352
|
+
*/
|
|
353
|
+
export function renderRecentErrors(events, requests, unsupported) {
|
|
354
|
+
const part = (title, r, list, head, cells, more, href, outcomeAt) => r === undefined ? ""
|
|
355
|
+
: "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>`
|
|
356
|
+
: !list(r).length ? `<h3>${title}</h3><p class="dim">None.</p>`
|
|
357
|
+
: `<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>` +
|
|
358
|
+
`<tbody>${list(r).map((x) => cellsRow(cells(x), outcomeAt)).join("")}</tbody></table>`;
|
|
359
|
+
return part("Sync errors", events, (r) => r.events, ["At (UTC)", "What", "Table", "Why"], (e) => Object.values(eventCells(e)), "mason logs --events", LINKS.failedEvents) +
|
|
360
|
+
part("Query errors", requests, (r) => r.requests, ["Started (UTC)", "Outcome", "Took", "Rows", "Who", "Statement", "Error"], requestCells, "mason logs --requests --failed", LINKS.failedQueries, 1) +
|
|
361
|
+
renderUnsupportedLine(unsupported);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* THE FIRST-RUN CHECKLIST, while setting up is not done: Mason is running (the state answered), a table copied (`totals`:
|
|
366
|
+
* one served), a user created (`/api/users`: one not removed), a first statement answered (`/api/queries`: one completed
|
|
367
|
+
* in the week the log keeps) — each ✓ or ○, a ○ with its one action: Add a table (Tables), Create a user (Users), the
|
|
368
|
+
* psql line's Copy (Connect's, under it). "" once all four are done, and while a read it needs has not answered or
|
|
369
|
+
* could not be read: a checklist never says ○ for what it could not tell. `users` is `/api/users`' body (or `{ failure }`).
|
|
370
|
+
*/
|
|
371
|
+
export function renderChecklist(s, queries, users) {
|
|
372
|
+
if (queries === undefined || users === undefined || "failure" in queries || "failure" in users) return "";
|
|
373
|
+
const added = s.tables.some((t) => t.added);
|
|
374
|
+
const copied = (s.totals?.copied ?? 0) > 0;
|
|
375
|
+
const user = users.users.some((u) => u.removedAt == null);
|
|
376
|
+
const answered = queries.queries.week.completed > 0;
|
|
377
|
+
if (copied && user && answered) return "";
|
|
378
|
+
const copying = added && !copied && copyingCount(s) > 0;
|
|
379
|
+
const steps = [
|
|
380
|
+
["Mason is running", true, ""],
|
|
381
|
+
["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
|
|
382
|
+
["A user created", user, `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
|
|
383
|
+
// the psql line is Connect's (rendered under the checklist once a table is added): its Copy copies it
|
|
384
|
+
["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>` : ""],
|
|
385
|
+
];
|
|
386
|
+
const done = steps.filter(([, ok]) => ok).length;
|
|
387
|
+
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>` +
|
|
388
|
+
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("") +
|
|
389
|
+
`</ol></div>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* THE OVERVIEW from `/api/state` and the request log's reads (`o.queries`, `o.events`, `o.requests`, `o.unsupported`, `o.usage`:
|
|
394
|
+
* each the body, or `{ failure }`; `o.now` the clock; `o.open(key)` whether a details is open): the word, the cards,
|
|
395
|
+
* the copies not finished, the facts, the stream, the statements, the recent errors. Never the engine's name.
|
|
396
|
+
*/
|
|
397
|
+
export function renderStatus(s, o = {}) {
|
|
398
|
+
const now = o.now ?? Date.now();
|
|
399
|
+
const added = s.tables.some((t) => t.added);
|
|
400
|
+
// how to connect, once a table is added: first under the cards while nothing has asked Mason anything (the last
|
|
401
|
+
// day's and week's statements, read as none), else after the facts, for the next tool
|
|
402
|
+
const first = isFirstConnect(o.queries);
|
|
403
|
+
const connect = added ? renderConnect(s.postgres, { first, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null }) : "";
|
|
404
|
+
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) +
|
|
405
|
+
(added ? renderQueries(o.queries) : "") + (added ? renderRecentErrors(o.events, o.requests, o.unsupported) : "");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Nothing has asked Mason anything yet: the last day's statements were read (`/api/queries`' body), and are none. */
|
|
409
|
+
export const isFirstConnect = (q) => q !== undefined && !("failure" in q) && q.queries.day.statements === 0 && q.queries.week.statements === 0;
|
|
410
|
+
|
|
411
|
+
/** The most entries of the unsupported SQL `/api/unsupported` answers (src/cli/run-server.ts MAX_UNSUPPORTED_LIMIT, held
|
|
412
|
+
* equal by the tests): the Overview says the first and counts the others, so it reads them all. */
|
|
413
|
+
export const UNSUPPORTED_LIMIT = 100;
|
|
414
|
+
|
|
415
|
+
/** What the page reads every 30 s beside the state: the request log's summary, the recent errors, and the last day's
|
|
416
|
+
* unsupported SQL. */
|
|
417
|
+
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"];
|
|
418
|
+
|
|
419
|
+
/** A read as `renderStatus` takes it: the body, or `{ failure }` when it could not be read; undefined before any read. */
|
|
420
|
+
const asRequest = (r) => r === undefined ? undefined : r.failure ? { failure: r.failure } : r.body;
|
|
421
|
+
|
|
422
|
+
export const page = {
|
|
423
|
+
id: "status",
|
|
424
|
+
hash: "overview",
|
|
425
|
+
title: "Overview",
|
|
426
|
+
create(ctx) {
|
|
427
|
+
// the details open (a copy's whole error), by key: a poll rewrites the section, and they stay open
|
|
428
|
+
const opened = createOpenSet();
|
|
429
|
+
const monotone = createMonotone();
|
|
430
|
+
return {
|
|
431
|
+
model: { open: opened.open },
|
|
432
|
+
reads: () => [{ path: "/api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
|
|
433
|
+
actions: {},
|
|
434
|
+
view() {
|
|
435
|
+
const st = ctx.read("/api/state");
|
|
436
|
+
if (!st?.body) return { status: st?.failure ? "" : skeleton(4) };
|
|
437
|
+
const [queries, events, requests, unsupported, users] = SLOWER.map((p) => asRequest(ctx.read(p)));
|
|
438
|
+
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 }) };
|
|
439
|
+
},
|
|
440
|
+
toggle: (target) => opened.toggle(target),
|
|
441
|
+
// Connect's Copy buttons
|
|
442
|
+
click: (d, button, env) => copyClick(d, button, env),
|
|
443
|
+
charts: () => sparkSpecs(ctx.read(HOURLY)?.body),
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SYNCS VIEW (#observability/syncs, a view of the Observability group; `#observability/logs`, `#logs` and `#syncs`
|
|
3
|
+
* land here;
|
|
4
|
+
* docs/OBSERVABILITY.md "Pages"): the sync's events (`mason logs --events`) over the time range the Observability views
|
|
5
|
+
* share, as a timeline table NEWEST FIRST (When sorts it the other way) — narrowed in the page alone by "Failures only",
|
|
6
|
+
* a kind and a table (every kind, and every added table from `/api/state`, events or not), at most EVENTS_SHOWN rows written with
|
|
7
|
+
* "Show more" — and, behind a closed details "The run's raw log (for debugging)", the run's last log entries, newest
|
|
8
|
+
* first (`mason logs`: they carry internal tags and absolute paths, so they are not the page's content). Reads
|
|
9
|
+
* `/api/events` every 10 s while the range is Now, and `/api/logs` every 5 s ONLY WHILE the details is open (the page's own state: nothing is
|
|
10
|
+
* read when it is closed); the events once when a range other than Now is chosen. A page module (src/web/app.js has the contract). Never the engine's name.
|
|
11
|
+
*/
|
|
12
|
+
import { esc, agoText, utcText, badge, empty, panel, n, sortTh, nextSort, moreRows, readPart, eventCells, RANGES, DEFAULT_RANGE, rangeOf, rangeEvery } from "../shared.js";
|
|
13
|
+
|
|
14
|
+
/** How many of the range's events are read (the newest), and how many of those the page writes at once. */
|
|
15
|
+
const EVENTS_LIMIT = 500;
|
|
16
|
+
const EVENTS_SHOWN = 100;
|
|
17
|
+
/** The events of a range: the newest EVENTS_LIMIT since its start. */
|
|
18
|
+
const eventsPath = (range = DEFAULT_RANGE) => `/api/events?limit=${EVENTS_LIMIT}&since=${RANGES[rangeOf(range)].since}`;
|
|
19
|
+
const LOGS = "/api/logs?lines=200";
|
|
20
|
+
const STATE = "/api/state";
|
|
21
|
+
|
|
22
|
+
const th = (cols) => cols.map((c) => `<th scope="col">${esc(c)}</th>`).join("");
|
|
23
|
+
|
|
24
|
+
/** An event's kind as a badge: a failure red (with its ✗), a copy made or a failure recovered green, any other gray. */
|
|
25
|
+
const kindBadge = (e) => badge(e.failed ? `✗ ${e.kind}` : e.kind, e.failed ? "bad" : /^(copied|recovered|synced)$/.test(e.kind) ? "ok" : "");
|
|
26
|
+
|
|
27
|
+
/** The events read that pass the page's filters: failures only, a kind, a table ("" any; the sync's own is "(the sync)"). */
|
|
28
|
+
const eventsShown = (events, { failuresOnly = false, kind = "", table = "" } = {}) =>
|
|
29
|
+
events.filter((e) => (!failuresOnly || e.failed) && (!kind || e.kind === kind) && (!table || (e.table ?? "(the sync)") === table));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The sync's events as a timeline, NEWEST FIRST (`sort.dir` "asc": oldest first, as the command lists them): when (how
|
|
33
|
+
* long ago; the UTC time, as the command prints it, is the title), the kind as a badge, the table, the reason's first line
|
|
34
|
+
* and, under it, its further lines. `failuresOnly`, `kind` and `table` (the page's filters) keep the events of what was
|
|
35
|
+
* read that pass (`eventsShown`); at most `limit` rows are written. `span` the range's words; `now` the page's clock.
|
|
36
|
+
*/
|
|
37
|
+
export function renderEvents(b, { failuresOnly = false, kind = "", table = "", now = Date.now(), sort = { key: "time", dir: "desc" }, limit = EVENTS_SHOWN, span = "" } = {}) {
|
|
38
|
+
const days = `${b.keptDays} day${b.keptDays === 1 ? "" : "s"}`;
|
|
39
|
+
const within = span ? `in ${span} (the events are kept ${days}: MASON_HISTORY_DAYS)` : `in the last ${days} (MASON_HISTORY_DAYS)`;
|
|
40
|
+
const passing = eventsShown(b.events, { failuresOnly, kind, table });
|
|
41
|
+
const narrowed = failuresOnly || kind || table;
|
|
42
|
+
// a table chosen that had none (every added table is a choice, events or not): said by its name
|
|
43
|
+
if (!passing.length && table && !kind) return empty(`No ${failuresOnly ? "failure" : "event"}s for ${table} ${within}.`);
|
|
44
|
+
if (!b.events.length) return empty(`No sync event ${within}.`);
|
|
45
|
+
if (!passing.length) {
|
|
46
|
+
return empty(failuresOnly && !kind && !table ? `None of the ${b.events.length} sync event${b.events.length === 1 ? "" : "s"} shown failed.` : `None of the ${n(b.events.length)} sync event${b.events.length === 1 ? "" : "s"} read passes the filters.`);
|
|
47
|
+
}
|
|
48
|
+
const ordered = sort.dir === "asc" ? passing : [...passing].reverse();
|
|
49
|
+
const rows = ordered.slice(0, limit).map((e) => {
|
|
50
|
+
const c = eventCells(e);
|
|
51
|
+
const more = e.message.includes("\n") ? `<pre>${esc(e.message.split("\n").slice(1).join("\n"))}</pre>` : "";
|
|
52
|
+
return `<tr${e.failed ? ` class="t-failed"` : ""}><th scope="row"><time data-tick datetime="${esc(e.at)}" title="${esc(c.time)} UTC">${esc(agoText(e.at, now))}</time></th><td>${kindBadge(e)}</td>` +
|
|
53
|
+
`<td>${c.table.startsWith("(") ? `<span class="dim">${esc(c.table)}</span>` : `<code class="name" title="${esc(c.table)}">${esc(c.table)}</code>`}</td><td>${esc(c.message)}${more}</td></tr>`;
|
|
54
|
+
}).join("");
|
|
55
|
+
const count = narrowed ? `<p class="dim">${n(passing.length)} of the ${n(b.events.length)} events read.</p>` : "";
|
|
56
|
+
return count + panel(`<table class="events"><thead><tr>${sortTh("events", "time", "When", sort)}${th(["Kind", "Table", "Message"])}</tr></thead><tbody>${rows}</tbody></table>`) +
|
|
57
|
+
moreRows("events", Math.min(limit, passing.length), passing.length, EVENTS_SHOWN);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** EVERY KIND OF SYNC EVENT, in its order (src/duck/events.ts `EVENT_KINDS`, held equal by the tests): the Kind filter
|
|
61
|
+
* offers each, whether the range has one or not. */
|
|
62
|
+
export const EVENT_KINDS = ["copy_failed", "poll_failed", "catch_up_failed", "stream_failed", "source_unreachable", "restart", "copied", "recovered", "synced", "sync_changed"];
|
|
63
|
+
|
|
64
|
+
/** The kind and table filters' controls: EVERY kind (EVENT_KINDS), and EVERY added table (`/api/state` `tables`, `s`) —
|
|
65
|
+
* the sync's own "(the sync)" first — whether the range has an event of it or not, so the choices never depend on what
|
|
66
|
+
* happened to be read; a table only the events name (removed since) and the one chosen are kept too. Written into
|
|
67
|
+
* their part: the tables follow the state. */
|
|
68
|
+
export function renderEventFilters(b, { kind = "", table = "" } = {}, s = null) {
|
|
69
|
+
const events = b?.events ?? [];
|
|
70
|
+
const added = (s?.tables ?? []).filter((t) => t.added).map((t) => t.table);
|
|
71
|
+
const others = [...new Set([...events.map((e) => e.table).filter((t) => t !== null && t !== undefined && !added.includes(t)), ...(table && table !== "(the sync)" && !added.includes(table) ? [table] : [])])].sort();
|
|
72
|
+
const tables = ["(the sync)", ...[...added].sort(), ...others];
|
|
73
|
+
const kinds = [...EVENT_KINDS, ...[...new Set([...events.map((e) => e.kind), ...(kind ? [kind] : [])])].filter((k) => !EVENT_KINDS.includes(k)).sort()];
|
|
74
|
+
const select = (id, label, all, values, chosen) => `<div class="field"><label for="${id}">${esc(label)}</label><select id="${id}">` +
|
|
75
|
+
`<option value="">${esc(all)}</option>${values.map((v) => `<option value="${esc(v)}"${v === chosen ? " selected" : ""}>${esc(v)}</option>`).join("")}</select></div>`;
|
|
76
|
+
return select("syncs-kind", "Kind", "Every kind", kinds, kind) + select("syncs-table", "Table", "Every table", tables, table);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The run's last log entries as a terminal's lines, NEWEST FIRST (the debugging reader wants what just happened); an entry of several lines is one, as written. */
|
|
80
|
+
export function renderLogs(b) {
|
|
81
|
+
if (!b.lines.length) return empty("The run has logged nothing yet.");
|
|
82
|
+
return `<div class="terminal" role="log">` +
|
|
83
|
+
[...b.lines].reverse().map((l) => `<div class="log-line"><time>${esc(l.at === null || l.at === undefined ? "" : utcText(l.at))}</time><pre>${esc(l.text)}</pre></div>`).join("") + `</div>` +
|
|
84
|
+
`<p class="dim">The last ${b.lines.length} of at most ${b.kept} entries the run keeps, newest first; <code>mason logs</code> reads the log file.</p>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The raw log's details: its summary closed or open, and, open, the entries (or that they are being asked). */
|
|
88
|
+
export function renderRawLog(open, inner) {
|
|
89
|
+
return `<details id="syncs-raw"${open ? " open" : ""}><summary>The run's raw log (for debugging)</summary>${open ? inner : ""}</details>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const page = {
|
|
93
|
+
id: "syncs",
|
|
94
|
+
hash: "observability/syncs",
|
|
95
|
+
parent: "observability",
|
|
96
|
+
title: "Sync events",
|
|
97
|
+
create(ctx) {
|
|
98
|
+
const m = { rawOpen: false, failuresOnly: false, kind: "", table: "", sort: { key: "time", dir: "desc" }, limit: EVENTS_SHOWN };
|
|
99
|
+
const events = () => eventsPath(ctx.range());
|
|
100
|
+
const part = (path, render, says) => readPart(ctx, path, render, says);
|
|
101
|
+
const actions = {
|
|
102
|
+
/** The details opened or closed: the log is read from then on, or no longer. */
|
|
103
|
+
async showRawLog(open) {
|
|
104
|
+
m.rawOpen = open === true;
|
|
105
|
+
if (m.rawOpen) await ctx.refresh([LOGS]);
|
|
106
|
+
},
|
|
107
|
+
sortEvents() { m.sort = nextSort(m.sort, "time"); },
|
|
108
|
+
showMoreEvents() { m.limit += EVENTS_SHOWN; },
|
|
109
|
+
showAllEvents() { m.limit = Infinity; },
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
model: m,
|
|
113
|
+
// the state for the Table filter's choices (every added table), as often as tables are added
|
|
114
|
+
reads: () => [{ path: events(), everyMs: rangeEvery(ctx.range(), 10_000) }, { path: STATE, everyMs: 30_000 }, ...(m.rawOpen ? [{ path: LOGS, everyMs: 5000 }] : [])],
|
|
115
|
+
actions,
|
|
116
|
+
view: () => ({
|
|
117
|
+
"syncs-filters": renderEventFilters(ctx.read(events())?.body, m, ctx.read(STATE)?.body ?? null),
|
|
118
|
+
"syncs-events": part(events(), (b) => renderEvents(b, { failuresOnly: m.failuresOnly, kind: m.kind, table: m.table, now: ctx.now().getTime(), sort: m.sort, limit: m.limit,
|
|
119
|
+
span: RANGES[ctx.range()]?.span ?? "" }), false),
|
|
120
|
+
"syncs-run": renderRawLog(m.rawOpen, m.rawOpen ? part(LOGS, renderLogs, true) : ""),
|
|
121
|
+
}),
|
|
122
|
+
/** The "Failures only" box, the kind or the table chosen: the events read are filtered in the page alone. */
|
|
123
|
+
input(target) {
|
|
124
|
+
if (target?.id === "syncs-failures-only") { m.failuresOnly = target.checked === true; m.limit = EVENTS_SHOWN; return true; }
|
|
125
|
+
if (target?.id === "syncs-kind") { m.kind = target.value ?? ""; m.limit = EVENTS_SHOWN; return true; }
|
|
126
|
+
if (target?.id === "syncs-table") { m.table = target.value ?? ""; m.limit = EVENTS_SHOWN; return true; }
|
|
127
|
+
return null;
|
|
128
|
+
},
|
|
129
|
+
/** `#observability/syncs?failures=1` (where the Overview's sync errors lead): the failures only, the box ticked;
|
|
130
|
+
* `?table=<name>` (a table's View events) its events only, the Table filter showing it. */
|
|
131
|
+
route(params, { doc } = {}) {
|
|
132
|
+
if (params.failures === undefined && params.table === undefined) return null;
|
|
133
|
+
m.failuresOnly = params.failures === "1";
|
|
134
|
+
m.table = params.table ?? "";
|
|
135
|
+
m.limit = EVENTS_SHOWN;
|
|
136
|
+
const box = doc?.getElementById?.("syncs-failures-only");
|
|
137
|
+
if (box) box.checked = m.failuresOnly;
|
|
138
|
+
return true;
|
|
139
|
+
},
|
|
140
|
+
click(d) {
|
|
141
|
+
if (d.sortTable === "events") return actions.sortEvents() ?? true;
|
|
142
|
+
if (d.showMore === "events") return actions.showMoreEvents() ?? true;
|
|
143
|
+
if (d.showAll === "events") return actions.showAllEvents() ?? true;
|
|
144
|
+
return null;
|
|
145
|
+
},
|
|
146
|
+
/** A details of this section opened or closed (the shell listens to `toggle`): null when it is not the raw log's. */
|
|
147
|
+
toggle(target) {
|
|
148
|
+
if (target.id !== "syncs-raw") return null;
|
|
149
|
+
const open = target.open === true;
|
|
150
|
+
if (open === m.rawOpen) return target.clicked === true ? true : null; // a click: the element disagrees, written again
|
|
151
|
+
return actions.showRawLog(open);
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
};
|