@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,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", "generated_failed", "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
|
+
};
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TABLES PAGE (#tables): the database's tables in ONE table, as `mason tables` lists them. An added table shows its
|
|
3
|
+
* copy's sync — state, rows, last sync and its age, how long it took, the copy's own size, and in replication mode its
|
|
4
|
+
* stream state — and its error, with Sync (POST /api/sync, `mason sync`) and Remove (POST /api/remove, `mason remove`);
|
|
5
|
+
* one not added shows its rows and key and why it cannot be added as it is, with Add, Columns… and Name its key…
|
|
6
|
+
* (POST /api/pick, `mason add`). A search box filters by name, and All / Added / Failed / Not added by state
|
|
7
|
+
* (`#tables?show=failed`, where the Overview's card leads). With hundreds of tables the page writes the first
|
|
8
|
+
* TABLE_ROWS that pass and "Show N more" / "Show all" (the search is over all of them, at once); a long name is cut with
|
|
9
|
+
* an ellipsis, its whole the title. Reads `/api/state` every poll (lean: no table's columns) and `/api/usage`'s
|
|
10
|
+
* `copySizes` every minute; a table's columns once, when its Columns… or Name its key… form opens
|
|
11
|
+
* (`GET /api/tables/<table>/columns`, `columnsPath`).
|
|
12
|
+
*/
|
|
13
|
+
import { msText, bytesText, button, card, cards, code, createOpenSet, moreRows, details, empty, entryName, esc, fileSize, firstLine, json, n, panel, renderFailure, rowsHtml, spanText, duration, status, streamRows, utcText, pop, fresh, actId, ROOMS, POLL_MS } from "../shared.js";
|
|
14
|
+
import { copyBar, copyingCount, createMonotone, tableLine } from "./status.js";
|
|
15
|
+
|
|
16
|
+
/** `/api/usage` asked for the copies' own sizes alone: the last minute's samples (none needed), and `copySizes`. */
|
|
17
|
+
const SIZES_PATH = "/api/usage?since=1m";
|
|
18
|
+
|
|
19
|
+
/** Where one table's columns are read, when its form opens (src/cli/run-server.ts tableColumns): never with the state,
|
|
20
|
+
* which is polled every 2 s and would carry every table's (3.4 MB at 800 tables of 50 columns). */
|
|
21
|
+
/** A table's sync events: Observability › Sync events narrowed to it (src/web/pages/syncs.js `route`), over the week (its
|
|
22
|
+
* copy and failures are rarely in the last 15 minutes, Now's). */
|
|
23
|
+
export const syncsHref = (table) => `#observability/syncs?table=${encodeURIComponent(table)}&range=7d`;
|
|
24
|
+
export const columnsPath = (table) => `/api/tables/${encodeURIComponent(table)}/columns`;
|
|
25
|
+
|
|
26
|
+
/** A table's sync in one word (`/api/state` `tables[].sync.state`, src/cli/readiness.ts `syncStateOf`). */
|
|
27
|
+
const SYNC_WORDS = { synced: "Synced", syncing: "Syncing", behind: "Behind", failed: "Failed", waiting: "Waiting" };
|
|
28
|
+
|
|
29
|
+
/** Whether a table's name has the search box's text (any case; nothing typed: every table). */
|
|
30
|
+
const nameMatches = (table, filter) => !filter || table.toLowerCase().includes(filter.trim().toLowerCase());
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* WHAT A SYNC DID, as `mason sync` prints it from `POST /api/sync`'s answer (src/cli/sync.ts `syncedReport`, held equal
|
|
34
|
+
* by the tests): the rows written and deleted, a line per table, and what happens next to each table synced (`syncedTips`:
|
|
35
|
+
* an `off` one stays as of this sync). A table whose sync is replication was not polled: its line says where the stream
|
|
36
|
+
* is (`streamed`), and a sync of those alone has nothing to sync.
|
|
37
|
+
*/
|
|
38
|
+
export function syncedReport(b, picked, tables) {
|
|
39
|
+
const streamed = b.streamed ?? [];
|
|
40
|
+
const streamedRows = streamed.map((t) => [t.table, `${t.state}${t.why ? `: ${t.why}` : ""} · the stream has applied the database's changes through ${t.position}`]);
|
|
41
|
+
if (b.tables.length === 0 && streamed.length > 0) {
|
|
42
|
+
return { headline: `Nothing to sync: logical replication keeps ${picked.join(", ")} current.`, details: streamedRows, tips: [] };
|
|
43
|
+
}
|
|
44
|
+
const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
|
|
45
|
+
const streamedNames = new Set(streamed.map((t) => t.table));
|
|
46
|
+
const polled = picked.filter((t) => !streamedNames.has(t));
|
|
47
|
+
return {
|
|
48
|
+
headline: `Synced ${polled.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted${streamedRows.length ? `; logical replication keeps ${streamedRows.map((r) => r[0]).join(", ")} current` : ""}.`,
|
|
49
|
+
details: [...b.tables.map((t) => [t.table, `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${msText(t.ms)}`]), ...streamedRows],
|
|
50
|
+
tips: syncedTips(tables),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What happens to each table synced after now, by its sync (src/cli/sync.ts `syncedTips`): an `off` table stays as of
|
|
55
|
+
* this sync until the next one, a polled one is polled again after its interval. */
|
|
56
|
+
function syncedTips(tables) {
|
|
57
|
+
return tables.flatMap((t) => {
|
|
58
|
+
const k = t.kept;
|
|
59
|
+
if (k?.by !== "poll") return [];
|
|
60
|
+
return [k.sync.mode === "off" ? `${t.table}'s sync is off: it stays as of this sync until the next mason sync ${entryName(t.table)}`
|
|
61
|
+
: `${t.table} is polled every ${k.sync.every}: its next poll is an interval from this one`];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A sync's answer said as `mason sync` says it, with Dismiss. `s`: the state, for the tables not kept current. */
|
|
66
|
+
function renderSynced(b, table, s) {
|
|
67
|
+
const r = syncedReport(b, [table], s.tables.filter((t) => t.table === table));
|
|
68
|
+
return `<div class="done"><p class="headline">${esc(r.headline)}</p>${rowsHtml(r.details)}${r.tips.map((x) => `<p class="tip">Tip: ${esc(x)}</p>`).join("")}` +
|
|
69
|
+
`${button("Dismiss", { dismissSynced: "", popClose: "" }, "small")}</div>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The filter beside the search box: which of the database's tables are listed. */
|
|
73
|
+
export const SHOWS = { all: "All", added: "Added", failed: "Failed", "not-added": "Not added" };
|
|
74
|
+
|
|
75
|
+
/** How many of the tables that pass the filters the page writes at once; "Show N more" writes as many again. */
|
|
76
|
+
const TABLE_ROWS = 100;
|
|
77
|
+
|
|
78
|
+
/** Whether an added table's copy or sync failed (the Failed filter, the Overview's failed card). */
|
|
79
|
+
const failedTable = (t) => t.added && (t.sync?.state === "failed" || Boolean(t.error));
|
|
80
|
+
|
|
81
|
+
/** What brought a copy current, in a web developer's words (`/api/state` `tables[].sync.syncedBy` keeps the mechanism's
|
|
82
|
+
* name for an operator: copy, poll, catch-up, stream). */
|
|
83
|
+
export const SYNCED_BY = { copy: "read whole", poll: "changes checked", "catch-up": "changes checked", stream: "streamed" };
|
|
84
|
+
/** How long a sync took, as the commands say it (`spanText`) but for one under a tenth of a second, which is not worth a
|
|
85
|
+
* digit: `<0.1 s`; what did it is the cell's title (`tookTitle`: the column stays narrow enough for Sync and Remove). */
|
|
86
|
+
export const tookText = (y) => y.syncSeconds < 0.1 ? "<0.1 s" : spanText(y.syncSeconds);
|
|
87
|
+
export const tookTitle = (y) => SYNCED_BY[y.syncedBy] ?? String(y.syncedBy);
|
|
88
|
+
|
|
89
|
+
/** A table's facts as `mason tables` says them on its line (src/cli/commands.ts `tableFacts`): its size, its key, and
|
|
90
|
+
* added or why it cannot be added as it is (null: neither). */
|
|
91
|
+
export function tableFacts(t) {
|
|
92
|
+
return { size: t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`, key: t.key.length ? `key ${t.key.join(", ")}` : "no key",
|
|
93
|
+
state: t.added ? "added" : t.reason ? `cannot be added as it is: ${t.reason}` : null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What `mason add` prints for a table added, from `/api/pick`'s answer (src/cli/commands.ts `addedLines`): its line,
|
|
97
|
+
* then its notes — the key added to the columns asked, the secret-looking columns left out. */
|
|
98
|
+
export function addedLines(b, file) {
|
|
99
|
+
const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
|
|
100
|
+
const s = b.skippedSecrets;
|
|
101
|
+
const name = entryName(b.table);
|
|
102
|
+
const sync = b.bigOff ? `sync: off written on its entry: it has more than 1,000,000 rows, and each poll scans it whole on your database — ` +
|
|
103
|
+
`mason add ${name} --sync "poll 1h" polls it hourly; mason sync ${name} syncs it once, now; mason add ${name} --sync replication keeps it current from the change log`
|
|
104
|
+
: b.notReplicated ? `sync: poll 5m written on its entry, not the file's replication: ${b.notReplicated} — ` +
|
|
105
|
+
`mason add ${name} --sync "poll 1h" polls it hourly; mason add ${name} --sync off reads it only by mason sync ${name}`
|
|
106
|
+
: b.sync.value === "off" ? `sync: off (${b.sync.from === "entry" ? "its entry's" : b.sync.from === "file" ? "the file's" : "the default"}): after its first sync it is read again only by mason sync ${name} — ` +
|
|
107
|
+
`mason add ${name} --sync "poll 5m" polls it every 5 minutes`
|
|
108
|
+
: b.sync.value === "replication" ? `sync: replication (${b.sync.from === "entry" ? "its entry's" : "the file's"}): kept current from the database's change log after its first sync — ` +
|
|
109
|
+
`mason add ${name} --sync "poll 5m" polls it instead`
|
|
110
|
+
: `sync: ${b.sync.value} (${b.sync.from === "entry" ? "its entry's" : "the file's"}): polled every ${b.sync.value.slice("poll ".length)} after its first sync — mason add ${name} --sync off stops that`;
|
|
111
|
+
return { line: `Added ${b.table} to ${file}: ${copies}${b.key ? `, keyed by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`, notes: [
|
|
112
|
+
sync,
|
|
113
|
+
...(b.pollCost !== null ? [b.pollCost] : []),
|
|
114
|
+
...(b.addedKey.length ? [`key added: ${b.addedKey.join(", ")} (a synced table always has its key)`] : []),
|
|
115
|
+
...(s.length ? [`left out ${s.join(", ")}: ${s.length === 1 ? "it looks" : "they look"} like ${s.length === 1 ? "a secret" : "secrets"}, ` +
|
|
116
|
+
`which anyone with a user's password could read (--include ${s[0]} adds it)`] : []),
|
|
117
|
+
] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What `mason remove` prints for the tables removed (src/cli/commands.ts `removedReport`). */
|
|
121
|
+
export function removedReport(removed, file) {
|
|
122
|
+
return { headline: `Removed ${removed.join(", ")}`, details: [["from", file]],
|
|
123
|
+
tips: [`Mason stops syncing ${removed.length === 1 ? "it" : "them"} and drops ${removed.length === 1 ? "its synced data" : "their synced data"}`] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `POST /api/pick`'s body for a table added: the columns ticked as `--columns`, the key's as `--key`, each left out when
|
|
128
|
+
* none is ticked (none: every column, as `mason add <table>` copies; no key: its primary key). What the lists may hold
|
|
129
|
+
* is the server's to judge (src/cli/run-server.ts `pick`), never the page's.
|
|
130
|
+
*/
|
|
131
|
+
export const pickBody = (table, columns = [], key = []) => ({ table, picked: true, ...(columns.length ? { columns } : {}), ...(key.length ? { key } : {}) });
|
|
132
|
+
|
|
133
|
+
/** The sources file as the commands name it, from the project's root (`/api/state` `source.shownFile`), when it is the
|
|
134
|
+
* one the state names: the page never shows an absolute path. */
|
|
135
|
+
const shownFileOf = (s, file) => s?.source?.file === file && typeof s.source.shownFile === "string" ? s.source.shownFile : file;
|
|
136
|
+
|
|
137
|
+
/** A table just added (`/api/pick`'s answer, `mason add --json`'s `added` entry): the line and notes `mason add` prints,
|
|
138
|
+
* the sources file named as `file` (as `mason add` names it: from the project's root). */
|
|
139
|
+
export function renderTableAdded(b, file = b.file) {
|
|
140
|
+
const l = addedLines(b, file);
|
|
141
|
+
return `<div class="done"><p class="headline">${esc(l.line)}</p>${l.notes.map((x) => `<p class="note">${esc(x)}</p>`).join("")}` +
|
|
142
|
+
`${button("Dismiss", { dismissOutcome: "", popClose: "" }, "small")}</div>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Tables just removed (`/api/remove`'s answer, `mason remove --json`'s shape): what `mason remove` prints, the file named
|
|
146
|
+
* as `file`. */
|
|
147
|
+
export function renderTablesRemoved(b, file = b.file) {
|
|
148
|
+
const r = removedReport(b.removed, file);
|
|
149
|
+
return `<div class="done"><p class="headline">${esc(r.headline)}</p>${rowsHtml(r.details)}${r.tips.map((x) => `<p class="tip">Tip: ${esc(x)}</p>`).join("")}` +
|
|
150
|
+
`${button("Dismiss", { dismissOutcome: "", popClose: "" }, "small")}</div>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tick = (name, c, on) => `<label class="tick"><input type="checkbox" name="${name}" value="${esc(c.name)}"${on ? " checked" : ""}> ${code(c.name)} <span class="dim">${esc(c.type)}</span></label>`;
|
|
154
|
+
|
|
155
|
+
/** The form that adds a table with the columns ticked (`--columns`) and, for one with no primary key, its key (`--key`).
|
|
156
|
+
* The ticks live in the document while the person ticks (the tables part is held still meanwhile: the page's `held`);
|
|
157
|
+
* `ticked`, what a refused submit sent, writes them back when the refusal is said over the form, in its popover. `cols` is what
|
|
158
|
+
* `GET /api/tables/<table>/columns` answered for it — { table, columns } or { table, failure } — or null while it is read. */
|
|
159
|
+
function pickForm(t, cols, ticked = null) {
|
|
160
|
+
const name = entryName(t.table);
|
|
161
|
+
if (cols?.table !== t.table) return `<p class="dim">Reading ${esc(name)}'s columns…</p>${button("Cancel", { cancelChoose: "", popClose: "" }, "small")}`;
|
|
162
|
+
if ("failure" in cols) return `${renderFailure(cols.failure)}${button("Cancel", { cancelChoose: "", popClose: "" }, "small")}`;
|
|
163
|
+
const boxes = (field) => cols.columns.map((c) => tick(field, c, ticked?.[field]?.includes(c.name) ?? false)).join("");
|
|
164
|
+
return `<form class="pick" data-pick-form="${esc(t.table)}" autocomplete="off">` +
|
|
165
|
+
(t.key.length ? "" : `<fieldset><legend>Its key: the columns that tell its rows apart, as ${code(`mason add ${name} --key`)}</legend>${boxes("key")}</fieldset>`) +
|
|
166
|
+
`<fieldset><legend>Its columns, as ${code(`mason add ${name} --columns`)} (none ticked: as ${code(`mason add ${name}`)}, every column)</legend>${boxes("columns")}</fieldset>` +
|
|
167
|
+
`<button type="submit" class="primary small">Add ${esc(name)}</button> ${button("Cancel", { cancelChoose: "", popClose: "" }, "small")}</form>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The cards over the list: the tables added of all, those synced, those failed and the copies' rows (`/api/state`
|
|
171
|
+
* `totals`, which `mason status --json` also carries); none before the state, or with no table to count. */
|
|
172
|
+
export function renderTablesCards(s) {
|
|
173
|
+
const t = s.totals;
|
|
174
|
+
if (!t || !s.tables.length) return "";
|
|
175
|
+
const added = s.tables.filter((x) => x.added).length;
|
|
176
|
+
return cards([
|
|
177
|
+
card("Tables added", `${n(added)} of ${n(s.tables.length)}`, "in the database"),
|
|
178
|
+
card("Synced", n(t.copied), `of ${n(added)} added${copyingCount(s) ? `, ${n(copyingCount(s))} syncing` : ""}`),
|
|
179
|
+
card("Failed", n(t.failed), `of ${n(added)} added`, "", t.failed ? "bad" : ""),
|
|
180
|
+
card("Rows", n(t.rows), "in the synced tables"),
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* THE DATABASE'S TABLES from `/api/state` `tables` (`mason tables --json`'s list), ONE table in a card: a row each — its
|
|
186
|
+
* name in mono, its state as a dot and the word (an added table's sync: Synced / Syncing / Failed / Waiting, else Not
|
|
187
|
+
* added), its rows, its key, an added table's last sync as its age (the UTC time in a title), how long it took and the
|
|
188
|
+
* copy's own size (`ui.sizes`: `/api/usage`'s body or `{ failure }`), its stream state in replication mode, and a note —
|
|
189
|
+
* its error as a red line (the whole error in a details, `ui.open(key)` whether it is open), or why it cannot be added as
|
|
190
|
+
* it is (`tableFacts`, as `mason tables` prints it). What can be done: Sync and Remove (asking first, `ui.removing`) for an
|
|
191
|
+
* added table; Add (every column, as `mason add <table>`) and Columns… (the form) for one not added; Name its key… for
|
|
192
|
+
* one with no primary key. `ui.choosing` is the table whose form is open (`ui.ticked` its boxes ticked: { columns, key };
|
|
193
|
+
* `ui.columns` its columns as read when it opened: { table, columns } | { table, failure }, null while read);
|
|
194
|
+
* `ui.outcome` what the last add or remove answered ({ table, added } | { table, removed } | { table, failure }) and
|
|
195
|
+
* `ui.synced` what the last sync did, each a popover beside its table's actions (`at`: when, gone after OUTCOME_MS at `ui.now`); `ui.syncing` the tables whose sync is asked; `ui.filter` the
|
|
196
|
+
* search box's text and `ui.show` the state filter (a key of SHOWS).
|
|
197
|
+
*/
|
|
198
|
+
export function renderTables(s, ui = {}) {
|
|
199
|
+
const { removing = null, choosing = null, columns = null, outcome = null, ticked = null, filter = "", show = "all", sizes, syncing = [], adding = [], shares = new Map(), before = new Map(), synced = null, open = () => false, limit = TABLE_ROWS } = ui;
|
|
200
|
+
const now = ui.now ?? Date.now();
|
|
201
|
+
if (!s.tables.length) return empty(`${s.source.database} has no table this user can read.`);
|
|
202
|
+
// why no table can be added or removed from here (a server's sources file this run cannot write), or null
|
|
203
|
+
const readOnly = s.sourcesReadOnly ?? null;
|
|
204
|
+
const stream = s.stream ? new Map(streamRows(s.stream, now).tables) : null;
|
|
205
|
+
// SEVEN NARROW COLUMNS OF FIXED WIDTHS, NOTHING LONG IN A CELL, AND NOTHING A CLICK INSERTS: a table's state is its word
|
|
206
|
+
// over its copy's bar (a line kept for it when it has none); its last sync an age over how long it took; whatever is
|
|
207
|
+
// said at length — the words `mason wait` prints, an error, why it cannot be added, its stream state — is ITS NOTE, a
|
|
208
|
+
// line of its own under the row, across every column, written for every row (empty when it says nothing), so a note
|
|
209
|
+
// that comes or goes moves no row. A question (Remove), a form (Columns…) and what an action answered are POPOVERS
|
|
210
|
+
// beside the row's actions (`pop`), written after the table: a click moves no other row and no column
|
|
211
|
+
// (`table-layout: fixed`, the stylesheet's widths by the header's classes; a button whose label changes keeps its room).
|
|
212
|
+
const head = [["Table", "name"], ["State", "state"], ["Rows", "num rows"], ["Key", "key"], ["Last sync", "num last"], ["Size", "num size"], ["", "act"]];
|
|
213
|
+
const sizeOf = new Map(sizes && !("failure" in sizes) ? sizes.copySizes.map((c) => [c.table, c.bytes]) : []);
|
|
214
|
+
const under = (html, cls = "") => `<tr class="under${cls ? ` ${cls}` : ""}"><td colspan="${head.length}"><div class="under-body">${html}</div></td></tr>`;
|
|
215
|
+
const cell = (v, cls = "", title = "") => `<td${cls ? ` class="${cls}"` : ""}${title ? ` title="${esc(title)}"` : ""}>${esc(v)}</td>`;
|
|
216
|
+
// the popovers of the rows written, in their order: after the table, each anchored to its row's actions
|
|
217
|
+
const pops = [];
|
|
218
|
+
const line = (t) => {
|
|
219
|
+
const f = tableFacts(t);
|
|
220
|
+
const name = entryName(t.table);
|
|
221
|
+
const y = t.sync;
|
|
222
|
+
const adding_ = adding.includes(t.table);
|
|
223
|
+
const asking = removing === t.table;
|
|
224
|
+
const anchor = actId("tables", t.table);
|
|
225
|
+
const syncedNow = synced?.table === t.table && !("failure" in synced) && fresh(synced, now);
|
|
226
|
+
const act = adding_ ? button("Adding…", {}, "primary small", { disabled: true }) : t.added
|
|
227
|
+
// Sync now → Syncing… → Synced ✓ in the one room (ROOMS.sync), so the actions never move
|
|
228
|
+
// View events: its sync events, on Observability › Sync events (a link: it goes to another page)
|
|
229
|
+
? `<a class="btn small" href="${esc(syncsHref(t.table))}">View events</a> ` +
|
|
230
|
+
(syncing.includes(t.table) ? button("Syncing…", {}, "small", { room: ROOMS.sync, disabled: true }) : button(syncedNow ? "Synced ✓" : "Sync now", { syncTable: t.table }, "small", { room: ROOMS.sync })) + " " +
|
|
231
|
+
// while it asks, Remove stays where it was, held down: the question beside it answers it
|
|
232
|
+
(readOnly ? "" : asking ? button("Remove", {}, "danger small", { disabled: true, expanded: true }) : button("Remove", { removeTable: t.table }, "danger small"))
|
|
233
|
+
// a sources file this run cannot write (`s.sourcesReadOnly`, a server's): no control that could only fail
|
|
234
|
+
: readOnly ? ""
|
|
235
|
+
: t.reason === null
|
|
236
|
+
? `${button("Add", { addTable: t.table }, "primary small")} ${choosing === t.table ? button("Columns…", {}, "small", { disabled: true, expanded: true }) : button("Columns…", { choose: t.table }, "small")}`
|
|
237
|
+
: t.key.length ? "" : choosing === t.table ? button("Name its key…", {}, "small", { disabled: true, expanded: true }) : button("Name its key…", { choose: t.table }, "small");
|
|
238
|
+
// a failed copy's rows so far are none: the copy it serves meanwhile is said by its error and mason status
|
|
239
|
+
const rows = t.added && t.copy && t.copy.phase !== "failed" ? n(t.copy.rowsCopied) : f.size;
|
|
240
|
+
// EVERY ADDED TABLE HAS ITS BAR, as `mason status` and `mason wait` print one for each (src/cli/commands.ts
|
|
241
|
+
// `tableLines`): full at 100% once copied, red when its copy failed, amber when it is not kept current, moving while
|
|
242
|
+
// it copies, the indeterminate shimmer while it waits — the Overview's renderer (`copyBar`), its words the line's;
|
|
243
|
+
// a table with none keeps its line, empty, so the row is as tall either way
|
|
244
|
+
const l = t.added && !adding_ ? tableLine(t) : null;
|
|
245
|
+
const bar = l ? `<span class="copyline t-${l.state}">${copyBar(t.table, l, shares.get(t.table) ?? l.fraction, before.get(t.table))}</span>` : `<span class="copyline none" aria-hidden="true"></span>`;
|
|
246
|
+
const state = adding_ ? status("warn", "Adding…") : t.added ? (y ? status(undefined, SYNC_WORDS[y.state]) : "") : status("unknown", "Not added");
|
|
247
|
+
const took = t.added && y?.syncSeconds != null ? `<span class="took" title="${esc(tookTitle(y))}">took ${esc(tookText(y))}</span>` : "";
|
|
248
|
+
const last = t.added && y?.syncedAt && y?.ageSeconds != null
|
|
249
|
+
? `<td class="num"><span title="${esc(`${utcText(y.syncedAt)} UTC`)}">${esc(`${duration(y.ageSeconds)} ago`)}</span>${took}</td>` : `<td class="num">${took}</td>`;
|
|
250
|
+
// what is said at length, its note under the row: the words the CLI prints after a bar that is not full and current,
|
|
251
|
+
// an error (its first line; the whole in a details), why it cannot be added as it is, its stream state (replication mode)
|
|
252
|
+
const said_ = adding_ ? `<span class="dim">adding it to the sources file…</span>`
|
|
253
|
+
: t.added ? [
|
|
254
|
+
l && l.state !== "done" && !t.error ? `<span class="what-copy t-${l.state}">${esc(l.what)}</span>` : "",
|
|
255
|
+
t.error ? `<span class="err">${esc(firstLine(t.error))}</span>${t.error.includes("\n") ? details(`error:${t.table}`, "the whole error", `<pre>${esc(t.error)}</pre>`, open(`error:${t.table}`)) : ""}` : "",
|
|
256
|
+
stream && stream.get(t.table) ? `<span class="stream dim">Stream: ${esc(stream.get(t.table))}</span>` : "",
|
|
257
|
+
].filter(Boolean).join("")
|
|
258
|
+
: f.state ? `<span class="dim">${esc(f.state)}</span>` : "";
|
|
259
|
+
const note = under(said_, "note");
|
|
260
|
+
if (asking) {
|
|
261
|
+
pops.push(pop(anchor, `<p class="confirm">Remove ${esc(name)}? ${esc(removedReport([t.table], "").tips[0])}.</p>` +
|
|
262
|
+
`<p class="pop-actions">${button(`Remove ${name}`, { confirmRemoveTable: t.table }, "danger small")} ${button("Cancel", { cancelRemoveTable: "", popClose: "" }, "small")}</p>`, { kind: "dialog", label: `Remove ${name}?` }));
|
|
263
|
+
}
|
|
264
|
+
// a refused add with its form open: the refusal is said in the form's popover, over the boxes as they were ticked
|
|
265
|
+
const formOpen = choosing === t.table && !t.added;
|
|
266
|
+
const refusedHere = outcome?.table === t.table && "failure" in outcome;
|
|
267
|
+
if (formOpen) pops.push(pop(anchor, (refusedHere ? renderFailure(outcome.failure) : "") + pickForm(t, columns, ticked), { kind: "dialog", label: `Add ${name}` }));
|
|
268
|
+
if (outcome?.table === t.table && fresh(outcome, now) && !(formOpen && refusedHere)) {
|
|
269
|
+
pops.push(pop(anchor, "failure" in outcome ? `${renderFailure(outcome.failure)}${button("Dismiss", { dismissOutcome: "", popClose: "" }, "small")}`
|
|
270
|
+
: "added" in outcome ? renderTableAdded(outcome.added, shownFileOf(s, outcome.added.file)) : renderTablesRemoved(outcome.removed, shownFileOf(s, outcome.removed.file))));
|
|
271
|
+
}
|
|
272
|
+
if (synced?.table === t.table && fresh(synced, now)) {
|
|
273
|
+
pops.push(pop(anchor, "failure" in synced ? `${renderFailure(synced.failure)}${button("Dismiss", { dismissSynced: "", popClose: "" }, "small")}` : renderSynced(synced.synced, t.table, s)));
|
|
274
|
+
}
|
|
275
|
+
return `<tr class="${t.added ? `c-added s-${y?.state ?? "unknown"}` : t.reason ? "c-cannot" : "c-not"}${said_ ? " has-note" : ""}"><th scope="row" class="name"><code class="name" title="${esc(t.table)}">${esc(t.table)}</code></th>` +
|
|
276
|
+
`<td class="state">${state}${bar}</td>${cell(rows, "num")}${cell(f.key, "mono", f.key)}${last}` +
|
|
277
|
+
`${t.added ? (sizeOf.has(t.table) ? cell(bytesText(sizeOf.get(t.table)), "num") : cell("—", "num", "not measured yet: a synced table's size is measured every 10 min")) : cell("", "num")}` +
|
|
278
|
+
`<td class="act" id="${esc(anchor)}">${act}</td></tr>` + note;
|
|
279
|
+
};
|
|
280
|
+
const added = s.tables.filter((t) => t.added).length;
|
|
281
|
+
// a refusal for a table the list does not have (dropped since the run read the catalog): said above the list
|
|
282
|
+
const stray = outcome !== null && "failure" in outcome && !s.tables.some((t) => t.table === outcome.table) ? renderFailure(outcome.failure) : "";
|
|
283
|
+
// the search box's text and the state filter: the tables that pass both (the one a form or a confirm is open on stays, whatever they say)
|
|
284
|
+
const passes = (t) => (show === "added" ? t.added : show === "failed" ? failedTable(t) : show === "not-added" ? !t.added : true) && nameMatches(t.table, filter);
|
|
285
|
+
const shown = s.tables.filter((t) => passes(t) || t.table === choosing || t.table === removing);
|
|
286
|
+
// at most `limit` rows written (the one a form or a confirm is open on always): a database of 800 tables costs 100 rows
|
|
287
|
+
const written = shown.filter((t, i) => i < limit || t.table === choosing || t.table === removing);
|
|
288
|
+
const matching = filter.trim() ? `<p class="count dim">${n(shown.length)} with “${esc(filter.trim())}” in the name</p>` : "";
|
|
289
|
+
const sizesFailed = added && sizes && "failure" in sizes ? `<p class="count">The synced tables' own sizes: could not read them (${esc(firstLine(sizes.failure.message))}; ${code("mason usage")} has them)</p>` : "";
|
|
290
|
+
const rowsHtml_ = written.map(line).join("");
|
|
291
|
+
return stray + (readOnly === null ? "" : `<p class="count">Tables are not added or removed from this page: ${esc(readOnly)}.</p>`) +
|
|
292
|
+
(added ? "" : empty("No table added yet", readOnly === null ? `Add one from the list, as ${code("mason add <table>")} does.` : `The sources file names the tables to sync.`)) + matching +
|
|
293
|
+
panel(`<table class="catalog"><thead><tr>${head.map(([h, c]) => `<th scope="col"${c ? ` class="${c}"` : ""}>${h}</th>`).join("")}</tr></thead>` +
|
|
294
|
+
`<tbody>${rowsHtml_}</tbody></table>`) + pops.join("") + moreRows("tables", Math.min(limit, shown.length), shown.length, TABLE_ROWS) +
|
|
295
|
+
(!shown.length ? `<p class="dim">No ${show === "added" ? "added " : show === "failed" ? "failed " : show === "not-added" ? "table that is not added, " : ""}table${filter.trim() ? `'s name has “${esc(filter.trim())}”` : ""}.</p>` : "") + sizesFailed +
|
|
296
|
+
(added ? `<p class="dim">A synced table's own size is measured every 10 minutes (${code("mason usage")}).</p>` : "");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const page = {
|
|
300
|
+
id: "tables",
|
|
301
|
+
title: "Tables",
|
|
302
|
+
create(ctx) {
|
|
303
|
+
const m = { removing: null, choosing: null, columns: null, outcome: null, ticked: null, held: null, heldFilter: null, heldShow: null, filter: "", show: "all", syncing: [], adding: [], synced: null, limit: TABLE_ROWS };
|
|
304
|
+
const state = () => ctx.read("/api/state")?.body ?? null;
|
|
305
|
+
const monotone = createMonotone();
|
|
306
|
+
// the details open (a copy's whole error), by key: a poll rewrites the part, and they stay open
|
|
307
|
+
const opened = createOpenSet();
|
|
308
|
+
// WHILE A FORM IS OPEN OR A REMOVE ASKS, THE TABLES ARE RENDERED FROM THE STATE THEY WERE OPENED OVER (`held`): the
|
|
309
|
+
// run reads the catalog again every few seconds, and a live database's row estimates move, so a part written from
|
|
310
|
+
// each poll's state would be rewritten under the person — the form's ticks gone, the confirm's button replaced under
|
|
311
|
+
// the click. So the list is FROZEN for as long as a form stays open or a Remove asks — however long that is, with no
|
|
312
|
+
// refresh in between (its rows and its added marks may be minutes old) — and is written from the latest state again
|
|
313
|
+
// once they Add, Cancel or confirm.
|
|
314
|
+
const hold = () => {
|
|
315
|
+
const held = m.choosing !== null || m.removing !== null;
|
|
316
|
+
m.heldFilter = held ? m.held === null ? m.filter : m.heldFilter : null;
|
|
317
|
+
m.heldShow = held ? m.held === null ? m.show : m.heldShow : null;
|
|
318
|
+
m.held = held ? m.held ?? state() : null;
|
|
319
|
+
if (m.choosing === null) m.ticked = null;
|
|
320
|
+
};
|
|
321
|
+
const actions = {
|
|
322
|
+
/** Add a table (`mason add`): the answer beside its actions, then the state read again — the Overview shows its copy starting. */
|
|
323
|
+
async addTable(table, columns = [], key = []) {
|
|
324
|
+
if (m.adding.includes(table)) return false;
|
|
325
|
+
// from the click: the row says Adding… (the page paints before the answer), then the state read again shows the copy
|
|
326
|
+
m.adding = [...m.adding, table];
|
|
327
|
+
if (m.choosing !== table) { m.outcome = null; m.synced = null; }
|
|
328
|
+
const r = await ctx.ask("/api/pick", json("POST", pickBody(table, columns, key)));
|
|
329
|
+
m.adding = m.adding.filter((x) => x !== table);
|
|
330
|
+
// refused: said over the form, in its popover, written again with the boxes the person had ticked
|
|
331
|
+
if ("failure" in r) { m.outcome = { table, failure: r.failure }; if (m.choosing === table) m.ticked = { columns, key }; return false; }
|
|
332
|
+
m.outcome = { table, added: r.body, at: ctx.now().getTime() }; m.choosing = null; m.columns = null; hold();
|
|
333
|
+
await ctx.refresh(["/api/state"]);
|
|
334
|
+
return true;
|
|
335
|
+
},
|
|
336
|
+
/** Open a table's form: its columns read now, once (the state the page polls carries none). */
|
|
337
|
+
async choose(table) {
|
|
338
|
+
// a question opened takes the last answer away: two popovers never stand on one another
|
|
339
|
+
m.choosing = table; m.removing = null; m.ticked = null; m.columns = null; m.outcome = null; m.synced = null; hold();
|
|
340
|
+
const r = await ctx.ask(columnsPath(table));
|
|
341
|
+
// closed, or another table's opened, while it was read: that answer is not this form's
|
|
342
|
+
if (m.choosing !== table) return false;
|
|
343
|
+
m.columns = "failure" in r ? { table, failure: r.failure } : { table, columns: r.body.columns };
|
|
344
|
+
return true;
|
|
345
|
+
},
|
|
346
|
+
cancelChoose() { m.choosing = null; m.columns = null; hold(); },
|
|
347
|
+
askRemoveTable(table) { m.removing = table; m.choosing = null; m.outcome = null; m.synced = null; hold(); },
|
|
348
|
+
cancelRemoveTable() { m.removing = null; hold(); },
|
|
349
|
+
/** Remove a table (`mason remove`), once asked: the answer beside its actions, then the state read again. */
|
|
350
|
+
async confirmRemoveTable(table) {
|
|
351
|
+
const r = await ctx.ask("/api/remove", json("POST", { tables: [table] }));
|
|
352
|
+
m.removing = null; hold();
|
|
353
|
+
if ("failure" in r) { m.outcome = { table, failure: r.failure }; return false; }
|
|
354
|
+
m.outcome = { table, removed: r.body, at: ctx.now().getTime() };
|
|
355
|
+
await ctx.refresh(["/api/state"]);
|
|
356
|
+
return true;
|
|
357
|
+
},
|
|
358
|
+
dismissOutcome() { m.outcome = null; },
|
|
359
|
+
/** Sync a table now (`mason sync <table>`): the answer beside its Sync, then the state read again. */
|
|
360
|
+
async syncTable(table) {
|
|
361
|
+
m.syncing = [...m.syncing, table];
|
|
362
|
+
m.outcome = null; m.synced = null;
|
|
363
|
+
const r = await ctx.ask("/api/sync", json("POST", { tables: [table] }));
|
|
364
|
+
m.syncing = m.syncing.filter((x) => x !== table);
|
|
365
|
+
m.synced = "failure" in r ? { table, failure: r.failure } : { table, synced: r.body, at: ctx.now().getTime() };
|
|
366
|
+
await ctx.refresh(["/api/state"]);
|
|
367
|
+
return !("failure" in r);
|
|
368
|
+
},
|
|
369
|
+
dismissSynced() { m.synced = null; },
|
|
370
|
+
/** The search box's text: kept here, so every poll writes the lists filtered by it. */
|
|
371
|
+
filterTables(text) { m.filter = text; m.limit = TABLE_ROWS; },
|
|
372
|
+
/** The state filter beside it (a key of SHOWS; anything else is All). */
|
|
373
|
+
showTables(which) { m.show = Object.hasOwn(SHOWS, which) ? which : "all"; m.limit = TABLE_ROWS; },
|
|
374
|
+
/** Write TABLE_ROWS more of the tables that pass, or all of them. */
|
|
375
|
+
showMoreTables() { m.limit += TABLE_ROWS; },
|
|
376
|
+
showAllTables() { m.limit = Infinity; },
|
|
377
|
+
};
|
|
378
|
+
return {
|
|
379
|
+
model: m,
|
|
380
|
+
reads: () => [{ path: "/api/state", everyMs: POLL_MS }, { path: SIZES_PATH, everyMs: 60_000 }],
|
|
381
|
+
afterRead() {
|
|
382
|
+
const s = state();
|
|
383
|
+
if (s === null) return;
|
|
384
|
+
// a table removed or added elsewhere (mason remove, mason add) takes its confirm or its form with it
|
|
385
|
+
const added = (table) => s.tables.some((t) => t.table === table && t.added);
|
|
386
|
+
if (m.removing !== null && !added(m.removing)) m.removing = null;
|
|
387
|
+
if (m.choosing !== null && added(m.choosing)) m.choosing = null;
|
|
388
|
+
hold();
|
|
389
|
+
},
|
|
390
|
+
actions,
|
|
391
|
+
view() {
|
|
392
|
+
const s = m.held ?? state();
|
|
393
|
+
const sizes = ctx.read(SIZES_PATH);
|
|
394
|
+
// held still while a form is open or a Remove asks, the search box's text and the state filter included: nothing moves under the person
|
|
395
|
+
const latest = state();
|
|
396
|
+
return {
|
|
397
|
+
"tables-cards": latest ? renderTablesCards(latest) : "",
|
|
398
|
+
tables: s ? renderTables(s, { removing: m.removing, choosing: m.choosing, columns: m.columns, outcome: m.outcome, ticked: m.ticked, now: ctx.now().getTime(),
|
|
399
|
+
filter: m.held ? m.heldFilter : m.filter, show: m.held ? m.heldShow : m.show, syncing: m.syncing, adding: m.adding, shares: monotone.of(latest ?? s), before: monotone.before, synced: m.synced, open: opened.has, limit: m.limit,
|
|
400
|
+
sizes: sizes === undefined ? undefined : sizes.failure ? { failure: sizes.failure } : sizes.body }) : "",
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
submit(form, FormData) {
|
|
404
|
+
// a table's form is written into the tables part: the ticked boxes sent as they are (FormData's getAll: the
|
|
405
|
+
// values of the boxes ticked, in the page's order)
|
|
406
|
+
if (form.dataset?.pickForm === undefined) return null;
|
|
407
|
+
const ticked = new FormData(form);
|
|
408
|
+
return actions.addTable(form.dataset.pickForm, ticked.getAll("columns"), ticked.getAll("key"));
|
|
409
|
+
},
|
|
410
|
+
/** `#tables?show=failed`: the state filter as the hash says (the select beside the search box shows it too). */
|
|
411
|
+
route(params, { doc } = {}) {
|
|
412
|
+
if (params.show === undefined) return;
|
|
413
|
+
actions.showTables(params.show);
|
|
414
|
+
const select = doc?.getElementById?.("tables-show");
|
|
415
|
+
if (select) select.value = m.show;
|
|
416
|
+
},
|
|
417
|
+
click(d) {
|
|
418
|
+
if (d.showMore === "tables") return actions.showMoreTables() ?? true;
|
|
419
|
+
if (d.showAll === "tables") return actions.showAllTables() ?? true;
|
|
420
|
+
if (d.addTable !== undefined) return actions.addTable(d.addTable);
|
|
421
|
+
if (d.choose !== undefined) return actions.choose(d.choose);
|
|
422
|
+
if (d.cancelChoose !== undefined) return actions.cancelChoose() ?? true;
|
|
423
|
+
if (d.removeTable !== undefined) return actions.askRemoveTable(d.removeTable) ?? true;
|
|
424
|
+
if (d.cancelRemoveTable !== undefined) return actions.cancelRemoveTable() ?? true;
|
|
425
|
+
if (d.confirmRemoveTable !== undefined) return actions.confirmRemoveTable(d.confirmRemoveTable);
|
|
426
|
+
if (d.dismissOutcome !== undefined) return actions.dismissOutcome() ?? true;
|
|
427
|
+
if (d.syncTable !== undefined) return actions.syncTable(d.syncTable);
|
|
428
|
+
if (d.dismissSynced !== undefined) return actions.dismissSynced() ?? true;
|
|
429
|
+
return null;
|
|
430
|
+
},
|
|
431
|
+
toggle: (target) => opened.toggle(target),
|
|
432
|
+
input(target) {
|
|
433
|
+
if (target.id === "tables-show") return actions.showTables(target.value) ?? true;
|
|
434
|
+
if (target.id !== "tables-filter") return null;
|
|
435
|
+
return actions.filterTables(target.value) ?? true;
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
},
|
|
439
|
+
};
|