@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT EVERY PAGE OF THE WEB UI SHARES (src/web/app.js is the shell; each page is a module of src/web/pages/): the
|
|
3
|
+
* helpers that say a value as the commands say it, a Failure as the CLI renders it, and the one way a page asks the
|
|
4
|
+
* run (`createAsk`). DOM-free, so tests/web-page.test.ts runs them under node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// the nav's icons: Lucide's, vendored whole, one module per icon, each its SVG as a string (src/web/vendor/README.md)
|
|
8
|
+
import layoutDashboard from "./vendor/lucide-1.56.0/layout-dashboard.mjs";
|
|
9
|
+
import tableIcon from "./vendor/lucide-1.56.0/table.mjs";
|
|
10
|
+
import usersIcon from "./vendor/lucide-1.56.0/users.mjs";
|
|
11
|
+
import squareTerminal from "./vendor/lucide-1.56.0/square-terminal.mjs";
|
|
12
|
+
import chartColumn from "./vendor/lucide-1.56.0/chart-column.mjs";
|
|
13
|
+
import logsIcon from "./vendor/lucide-1.56.0/logs.mjs";
|
|
14
|
+
import menuIcon from "./vendor/lucide-1.56.0/menu.mjs";
|
|
15
|
+
import xIcon from "./vendor/lucide-1.56.0/x.mjs";
|
|
16
|
+
|
|
17
|
+
/** The icons the page draws, by Lucide's name: each an inline SVG in `currentColor` (its size and stroke the stylesheet's). */
|
|
18
|
+
export const ICONS = { "layout-dashboard": layoutDashboard, table: tableIcon, users: usersIcon, "square-terminal": squareTerminal,
|
|
19
|
+
"chart-column": chartColumn, logs: logsIcon, menu: menuIcon, x: xIcon };
|
|
20
|
+
|
|
21
|
+
/** An icon, inline and decorative (the label beside it, or its control's own label, is what a reader hears):
|
|
22
|
+
* `icon("table")`. A name it does not know is no icon. */
|
|
23
|
+
export const icon = (name, cls = "") => Object.hasOwn(ICONS, name)
|
|
24
|
+
? `<span class="icon${cls ? ` ${cls}` : ""}" aria-hidden="true">${ICONS[name].trim()}</span>` : "";
|
|
25
|
+
|
|
26
|
+
/** A value as HTML text: every character that means something in HTML escaped. */
|
|
27
|
+
export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* THE TIME RANGES the Observability pages share (`?range=` of their hash; Now unless it says): each its label, the
|
|
31
|
+
* age every endpoint's `since` takes (src/cli/request-filter.ts `timeOf`), and the usage samples' step — a minute a
|
|
32
|
+
* point for the hour, an hour for the day, six for the week (60, 24 and 28 points: a chart readable at each).
|
|
33
|
+
*/
|
|
34
|
+
export const RANGES = {
|
|
35
|
+
// NOW is the one that follows what happens (`live`): its reads every few seconds, the last 15 minutes a minute a
|
|
36
|
+
// point; every other range is read once when chosen and stays as it was then (Refresh reads it again)
|
|
37
|
+
now: { label: "Now", span: "the last 15 minutes", since: "15m", stepSeconds: 60, days: 0, live: true },
|
|
38
|
+
"1h": { label: "Last hour", span: "the last hour", since: "1h", stepSeconds: 60, days: 0 },
|
|
39
|
+
"24h": { label: "24 hours", span: "the last 24 hours", since: "24h", stepSeconds: 3600, days: 1 },
|
|
40
|
+
"7d": { label: "7 days", span: "the last 7 days", since: "7d", stepSeconds: 21_600, days: 7 },
|
|
41
|
+
// past the week, offered only where the run keeps that much (`rangeOffered`): a day a point, 30 and 90 of them
|
|
42
|
+
"30d": { label: "30 days", span: "the last 30 days", since: "30d", stepSeconds: 86_400, days: 30, longer: true },
|
|
43
|
+
"90d": { label: "90 days", span: "the last 90 days", since: "90d", stepSeconds: 86_400, days: 90, longer: true },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* WHETHER A RANGE CAN BE CHOSEN, by the days the run keeps (`/api/state` `engine.keptDays`, MASON_HISTORY_DAYS):
|
|
48
|
+
* the hour, the day and the week always (a log that keeps fewer says so where it shows them); a longer one only when
|
|
49
|
+
* the run keeps at least its days — else it is shown disabled, with why and the setting that keeps it (`why`). A run
|
|
50
|
+
* that has not said how long it keeps (`keptDays` undefined) offers no longer one: never a range it could not tell.
|
|
51
|
+
*/
|
|
52
|
+
export function rangeOffered(key, keptDays) {
|
|
53
|
+
const r = RANGES[key];
|
|
54
|
+
if (!r.longer) return { offered: true, shown: true };
|
|
55
|
+
if (typeof keptDays !== "number") return { offered: false, shown: false };
|
|
56
|
+
if (keptDays >= r.days) return { offered: true, shown: true };
|
|
57
|
+
return { offered: false, shown: true, why: `Mason keeps ${keptDays} day${keptDays === 1 ? "" : "s"} of history: MASON_HISTORY_DAYS=${r.days} keeps ${r.days}` };
|
|
58
|
+
}
|
|
59
|
+
export const DEFAULT_RANGE = "now";
|
|
60
|
+
/** Whether a range follows what happens (Now) or is read once when chosen (every other). */
|
|
61
|
+
export const isLive = (range) => RANGES[rangeOf(range)].live === true;
|
|
62
|
+
/** How often a read of a range is asked: `everyMs` while it is live, once (when chosen, or on Refresh) when it is not. */
|
|
63
|
+
export const rangeEvery = (range, everyMs) => isLive(range) ? everyMs : Infinity;
|
|
64
|
+
/** A range's key as the hash gave it: one of RANGES, else the default. */
|
|
65
|
+
export const rangeOf = (text) => Object.hasOwn(RANGES, String(text ?? "")) ? String(text) : DEFAULT_RANGE;
|
|
66
|
+
/** The usage samples of a range, its step a point (src/cli/run-server.ts `usage`): Usage's read, and the Overview's
|
|
67
|
+
* sparklines (the 24 hours', by name). */
|
|
68
|
+
export const usagePath = (range = DEFAULT_RANGE) => { const r = RANGES[rangeOf(range)]; return `/api/usage?since=${r.since}&step=${r.stepSeconds}`; };
|
|
69
|
+
|
|
70
|
+
/** How often the shown page is asked while the tab is seen (src/web/app.js polls; a page's every-poll reads use it). */
|
|
71
|
+
export const POLL_MS = 2000;
|
|
72
|
+
|
|
73
|
+
/** A statement as a cell: one line, truncated by the page's stylesheet as well, the whole of it (up to TITLE_MAX) its title. */
|
|
74
|
+
export const statementCell = (query) => query === "" ? `<span class="dim">(no statement)</span>` : `<code class="stmt" title="${esc(clip(query, TITLE_MAX))}">${esc(oneLine(query))}</code>`;
|
|
75
|
+
|
|
76
|
+
/** A count as the commands print it: `1,234`. */
|
|
77
|
+
export const n = (x) => x.toLocaleString("en-US");
|
|
78
|
+
/** A text's first line, trimmed: a reason's headline (its further lines are its detail). */
|
|
79
|
+
export const firstLine = (s) => s.split("\n")[0].trim();
|
|
80
|
+
/** Something to type, as the CLI colours it. */
|
|
81
|
+
export const code = (s) => `<code>${esc(s)}</code>`;
|
|
82
|
+
/** Rows of facts, `[label, value]` each, as `mason status` lists its rows. */
|
|
83
|
+
export const rowsHtml = (rows) => `<dl class="facts">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>`;
|
|
84
|
+
|
|
85
|
+
/** A span of seconds as a person reads it: `45 s`, `12 min`, `2 h 5 min` (src/cli/readiness.ts `duration`, held equal by the tests). */
|
|
86
|
+
export function duration(seconds) {
|
|
87
|
+
const s = Math.max(1, Math.round(seconds));
|
|
88
|
+
if (s < 90) return `${s} s`;
|
|
89
|
+
const m = Math.round(s / 60);
|
|
90
|
+
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A duration in milliseconds as a person reads it: `512 ms`, `13.7 s`, `2 min 5 s`, `1 h 4 min` (src/cli/readiness.ts
|
|
94
|
+
* `msText`, held equal by the tests): every statement's, sync's and percentile's time on the page. */
|
|
95
|
+
export function msText(ms) {
|
|
96
|
+
const v = Math.max(0, ms);
|
|
97
|
+
if (Math.round(v) < 1000) return `${Math.round(v)} ms`;
|
|
98
|
+
const tenths = Math.round(v / 100);
|
|
99
|
+
if (tenths < 600) return `${(tenths / 10).toFixed(1)} s`;
|
|
100
|
+
const s = Math.round(v / 1000);
|
|
101
|
+
if (s < 3600) return `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ""}`;
|
|
102
|
+
const m = Math.round(s / 60);
|
|
103
|
+
return `${Math.floor(m / 60)} h${m % 60 ? ` ${m % 60} min` : ""}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A span of seconds as the stream's lines say it: `0.3 s` under ten seconds, then `duration` (src/cli/commands.ts `spanText`). */
|
|
107
|
+
export const spanText = (seconds) => seconds < 10 ? `${Math.max(0, seconds).toFixed(1)} s` : duration(seconds);
|
|
108
|
+
|
|
109
|
+
/** A file's size as mason status and mason usage say it (src/cli/commands.ts `fileSize`). */
|
|
110
|
+
export const fileSize = (b) => b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GiB` : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MiB` : `${(b / 1024).toFixed(1)} KiB`;
|
|
111
|
+
|
|
112
|
+
/** Bytes as mason status's queries say them: B under a KiB, then `fileSize` (src/cli/commands.ts `bytesText`). */
|
|
113
|
+
export const bytesText = (b) => b < 1024 ? `${n(b)} B` : fileSize(b);
|
|
114
|
+
|
|
115
|
+
/** An ISO time (as `toISOString` writes it) in UTC, to its first `length` characters, the T a space. */
|
|
116
|
+
const utcCut = (length) => (iso) => iso.slice(0, length).replace("T", " ");
|
|
117
|
+
/** An ISO time as the commands' lines print it: `2026-10-27 09:04:00` (UTC). */
|
|
118
|
+
export const utcText = utcCut(19);
|
|
119
|
+
/** The same to the millisecond: `2026-10-27 09:04:05.123` (UTC) — a statement's start and end, where a second is too
|
|
120
|
+
* coarse to tell two apart or read how long one took. */
|
|
121
|
+
export const utcMsText = utcCut(23);
|
|
122
|
+
/** The same to the minute, as the commands' lists print it: `2026-10-27 09:04` (UTC). */
|
|
123
|
+
export const utcMinute = utcCut(16);
|
|
124
|
+
|
|
125
|
+
/** A statement on one line, as mason logs says it (src/cli/commands.ts `oneLine`): whitespace collapsed, cut with an ellipsis. */
|
|
126
|
+
export const oneLine = (sql, max = 120) => {
|
|
127
|
+
const line = String(sql ?? "").replace(/\s+/g, " ").trim();
|
|
128
|
+
return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
|
|
129
|
+
};
|
|
130
|
+
/** How a statement's client is said (src/cli/commands.ts `runningLine`, `requestLine`): its application_name, else the note. */
|
|
131
|
+
export const clientText = (applicationName) => applicationName || "(no application_name)";
|
|
132
|
+
|
|
133
|
+
/** A sync event's cells as `mason logs --events` and mason status's `Sync errors` say them (src/cli/commands.ts
|
|
134
|
+
* `eventLine`): when, kind, table, the reason's first line. */
|
|
135
|
+
export const eventCells = (e) => ({ time: utcText(e.at), kind: e.kind, table: e.table ?? "(the sync)", message: firstLine(e.message) });
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* AN ENTRY OF THE UNSUPPORTED SQL (`/api/unsupported`'s `unsupported[]`: { gap, title, runs, users, clients, last,
|
|
139
|
+
* example }) in mason status's three lines (src/cli/commands.ts `unsupportedLines`, held equal by the tests): what they
|
|
140
|
+
* used (the entry's title), how many statements and who sent them; when the latest was and it on one line (`example`,
|
|
141
|
+
* cut as the CLI cuts it); and `how`, where the way out is (the page cannot link into the repository: it names it). The
|
|
142
|
+
* entry's id (`gap`) is only the title's text, never in a line.
|
|
143
|
+
*/
|
|
144
|
+
export function unsupportedEntry(g) {
|
|
145
|
+
const who = [...(g.users.length ? [`as ${g.users.join(", ")}`] : []), ...(g.clients.length ? [`from ${g.clients.join(", ")}`] : [])];
|
|
146
|
+
const title = g.title ?? "SQL this Mason no longer lists";
|
|
147
|
+
return { headline: `${title}: ${n(g.runs)} statement${g.runs === 1 ? "" : "s"}${who.length ? ` ${who.join(" ")}` : ""}`,
|
|
148
|
+
last: `last ${utcMinute(g.last)}`, how: `How to rewrite it: see docs/SQL-SUPPORT.md › ${title}`, id: g.gap, example: oneLine(g.example, 100) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A share (0 to 1) as the commands print it beside a bar: whole percent, rounded down (`12%`). */
|
|
152
|
+
export const percent = (share) => `${Math.floor(Math.max(0, Math.min(1, share)) * 100)}%`;
|
|
153
|
+
|
|
154
|
+
/** The time a copy has left, as the commands print it: `~12 min left`; "" when nothing is measured yet. */
|
|
155
|
+
export const leftText = (secondsLeft) => secondsLeft === null || secondsLeft === undefined ? "" : `~${duration(secondsLeft)} left`;
|
|
156
|
+
|
|
157
|
+
/** `1,204 changes (1,000 inserts, 200 updates, 4 deletes)`: a table's counts as the stream's lines say them (src/duck/stream.ts `countsText`). */
|
|
158
|
+
export function countsText(c) {
|
|
159
|
+
const total = c.inserts + c.updates + c.deletes + c.truncates;
|
|
160
|
+
const parts = [[c.inserts, "insert"], [c.updates, "update"], [c.deletes, "delete"], [c.truncates, "truncate"]]
|
|
161
|
+
.filter(([k]) => k > 0).map(([k, w]) => `${n(k)} ${w}${k === 1 ? "" : "s"}`);
|
|
162
|
+
return `${n(total)} change${total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* THE WAL THE SLOT HOLDS ON THE DATABASE, in a line (src/cli/commands.ts `slotText`, held equal by the tests): how much,
|
|
167
|
+
* Postgres's status of it, the cap — or that nothing caps it — and a slot Postgres invalidated. `w` is `stream.slotWal`:
|
|
168
|
+
* { walHeldBytes, walStatus, safeWalSizeBytes, at } or { error }; `since(iso)` is its age in seconds.
|
|
169
|
+
*/
|
|
170
|
+
export function slotText(w, since) {
|
|
171
|
+
if ("error" in w) return `the WAL it holds could not be read: ${w.error.split("\n")[0]}`;
|
|
172
|
+
const read = `(read ${spanText(since(w.at))} ago)`;
|
|
173
|
+
if (w.walStatus === "lost") return `lost: Postgres invalidated it (it held more WAL than max_slot_wal_keep_size), so Mason makes it again and catches the synced tables up by one poll ${read}`;
|
|
174
|
+
const cap = w.safeWalSizeBytes === null
|
|
175
|
+
? "max_slot_wal_keep_size is unset, so nothing caps it: while nothing reads the slot it holds WAL for ever, until the database's disk fills (set max_slot_wal_keep_size on the database)"
|
|
176
|
+
: w.safeWalSizeBytes <= 0 ? "past max_slot_wal_keep_size: Postgres invalidates it at its next checkpoint, and Mason makes it again"
|
|
177
|
+
: `${fileSize(w.safeWalSizeBytes)} more before Postgres invalidates it (max_slot_wal_keep_size)`;
|
|
178
|
+
return `holds ${fileSize(w.walHeldBytes)} of the database's WAL${w.walStatus === null ? "" : ` (${w.walStatus})`} · ${cap} ${read}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether the slot's line is a warning: the WAL could not be read, Postgres invalidated it, it is past its cap or nothing caps it. */
|
|
182
|
+
export const slotWarns = (w) => "error" in w || w.walStatus === "lost" || w.walStatus === "unreserved" || w.safeWalSizeBytes === null || w.safeWalSizeBytes <= 0;
|
|
183
|
+
|
|
184
|
+
/** `Mason` in mason status (src/cli/commands.ts `masonRow`): the run's version and how long it has been up. The CLI adds the
|
|
185
|
+
* run's pid, which the state does not carry: the tests hold the rest equal. */
|
|
186
|
+
export const masonRow = (e) => ["Mason", `${e.version} · one process${e.startedAt ? ` · up ${spanText(e.uptimeSeconds ?? 0)}, since ${e.startedAt.slice(0, 19).replace("T", " ")} UTC` : ""}`];
|
|
187
|
+
|
|
188
|
+
/** `Sessions` in mason status (src/cli/commands.ts `sessionsRows`). */
|
|
189
|
+
export function sessionsRows(s) {
|
|
190
|
+
if (s === undefined) return [];
|
|
191
|
+
if ("error" in s) return [["Sessions", `could not be read: ${s.error.split("\n")[0]}`]];
|
|
192
|
+
if (s.open === 0) return [["Sessions", "none open"]];
|
|
193
|
+
const running = s.byClient.reduce((a, c) => a + c.running, 0);
|
|
194
|
+
const who = s.byClient.slice(0, 3).map((c) => `${c.open} ${c.user === "" ? "by a token" : `as ${c.user}`}${c.client === "" ? "" : ` (${c.client})`}`);
|
|
195
|
+
const others = s.byClient.length - who.length;
|
|
196
|
+
return [["Sessions", `${s.open} open, ${running} running a statement · ${who.join(", ")}${others > 0 ? `, and ${others} other client${others === 1 ? "" : "s"}` : ""} (mason logs --running lists the statements)`]];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `Disk` in mason status (src/cli/commands.ts `diskRows`), but for the path: the page never shows an absolute path, so
|
|
200
|
+
* it says "where the copy is" for ` in <path>` (the tests hold the rest equal). */
|
|
201
|
+
export function diskRows(d) {
|
|
202
|
+
if (d === undefined) return [];
|
|
203
|
+
const adds = d.projectedBytes === null ? "" : ` · the first syncs running are projected to add about ${fileSize(d.projectedBytes)} more`;
|
|
204
|
+
if (d.freeBytes === null) return [["Disk", `${d.error ?? "the free space could not be read"}${adds}`]];
|
|
205
|
+
return [["Disk", `${fileSize(d.freeBytes)} free where the synced data is${adds}${d.projectedBytes !== null && d.projectedBytes > d.freeBytes ? " — more than is free: the sync stops before the disk fills" : ""}`]];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `Restarts` in mason status (src/cli/commands.ts `restartsRows`; its `ago` is `agoText`): none, no row. */
|
|
209
|
+
export function restartsRows(r, now = Date.now()) {
|
|
210
|
+
if (r === undefined) return [];
|
|
211
|
+
if ("error" in r) return [["Restarts", r.error]];
|
|
212
|
+
if (r.last === null) return [];
|
|
213
|
+
return [["Restarts", `${r.count}, the last ${agoText(r.last.at, now)}: ${r.last.why}, after ${r.last.upSeconds} s up (mason logs --start has its last lines)`]];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A time as `mason status` says how long ago (src/cli/commands.ts `ago`). */
|
|
217
|
+
export function agoText(iso, now = Date.now()) {
|
|
218
|
+
const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
219
|
+
return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* WHERE THE STREAM IS, as `mason status` says it (src/cli/commands.ts `streamLines`, held equal by the tests): `head`
|
|
224
|
+
* the stream's own rows (not running and why, its position against the database's, the last change), `tables` a row
|
|
225
|
+
* per added table — streamed, being caught up, not streamed yet or left out, and why. `st` is `/api/state` `stream`.
|
|
226
|
+
*/
|
|
227
|
+
export function streamRows(st, now = Date.now()) {
|
|
228
|
+
const head = [], tables = [];
|
|
229
|
+
const since = (iso) => (now - Date.parse(iso)) / 1000;
|
|
230
|
+
if (!st.running) {
|
|
231
|
+
head.push(["stream", st.failure === null ? "not running yet: it starts once the slot is made"
|
|
232
|
+
: `not running: ${st.failure.error.split("\n")[0]} · it starts again in ${spanText(Math.max(0, -since(st.failure.retryAt)))}`]);
|
|
233
|
+
}
|
|
234
|
+
const db = "lsn" in st.database
|
|
235
|
+
? `the database is at ${st.database.lsn}: ${n(st.lag.bytes ?? 0)} bytes behind (read ${spanText(since(st.database.at))} ago)`
|
|
236
|
+
: `the database's position could not be read: ${st.database.error.split("\n")[0]}`;
|
|
237
|
+
head.push(["position", `${st.position} · ${db}`]);
|
|
238
|
+
head.push(["slot", slotText(st.slotWal, since)]);
|
|
239
|
+
head.push(["last change", st.lastBatch === null ? "none applied this run"
|
|
240
|
+
: `committed ${spanText(since(st.lastBatch.committedAt))} ago, applied ${spanText((Date.parse(st.lastBatch.appliedAt) - Date.parse(st.lastBatch.committedAt)) / 1000)} after its commit`]);
|
|
241
|
+
const total = (t) => t.applied.inserts + t.applied.updates + t.applied.deletes + t.applied.truncates;
|
|
242
|
+
for (const t of st.tables) {
|
|
243
|
+
const counts = ` · ${countsText(t.applied)} this run`;
|
|
244
|
+
const c = t.catchUp;
|
|
245
|
+
tables.push([t.table, t.state === "streamed" ? `streamed${counts}`
|
|
246
|
+
: t.state === "catching-up" && c !== undefined
|
|
247
|
+
? `catching up by one poll ${c.from ? `from ${utcMinute(c.from)} UTC` : t.why?.replace(/^caught up by one poll /, "") ?? ""}: ` +
|
|
248
|
+
`${c.phase === "queued" ? (c.retryAt ? `its last catch-up failed, tried again after ${c.retryAt.slice(11, 16)} UTC (mason logs says why)` : "waiting its turn") : c.phase === "polling" ? "polling" : "applying what the stream held meanwhile"}` +
|
|
249
|
+
`${c.startedAt ? `, for ${spanText(since(c.startedAt))}` : ""} · the stream holds ${n(c.held)} change${c.held === 1 ? "" : "s"} of it meanwhile${total(t) ? counts : ""}`
|
|
250
|
+
: t.state === "left-out" ? `left out: ${t.why ?? ""}`
|
|
251
|
+
: `not streamed yet: ${t.why ?? ""}${total(t) ? counts : ""}`]);
|
|
252
|
+
}
|
|
253
|
+
return { head, tables };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A table as the commands name it: `orders` in public, else `schema.table` (src/cli/sources.ts `entryName`). */
|
|
257
|
+
export const entryName = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A FAILURE AS THE CLI SAYS IT (src/cli/command.ts `failureText`): what is wrong after a ✗, then `why` and `fix` as
|
|
261
|
+
* rows, then Next: each command and what it does. `f` is the server's `failure` ({ code, message, why?, fix?, next }).
|
|
262
|
+
*/
|
|
263
|
+
export function renderFailure(f) {
|
|
264
|
+
const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
|
|
265
|
+
const next = f.next ?? [];
|
|
266
|
+
return `<div class="failure" role="alert"><p class="message"><span class="mark" aria-hidden="true">✗</span> ${esc(f.message)}</p>` +
|
|
267
|
+
(rows.length ? `<dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl>` : "") +
|
|
268
|
+
(next.length ? `<p class="next-head">Next:</p><ul class="next">${next.map((s) => `<li>${code(s.command)}${s.what ? ` <span>${esc(s.what)}</span>` : ""}</li>`).join("")}</ul>` : "") +
|
|
269
|
+
"</div>";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* WHY A REQUEST TO THE RUN FAILED, as a Failure: the server's own `failure` when it sent one; else what the status
|
|
274
|
+
* means here — 403, a cookie this run does not take (the run started again, with a new token); no answer at all, the
|
|
275
|
+
* run stopped. `status` null: fetch itself failed, `text` its error.
|
|
276
|
+
*/
|
|
277
|
+
export function failureOf(status, text) {
|
|
278
|
+
if (status !== null) {
|
|
279
|
+
let body = null;
|
|
280
|
+
// the guard answers its refusals as plain text, not JSON: read as text below
|
|
281
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
282
|
+
if (body && typeof body.failure === "object" && body.failure !== null) return body.failure;
|
|
283
|
+
if (status === 403 || status === 421) {
|
|
284
|
+
return { code: "refused", message: "this page's cookie is not the running Mason's", why: text.trim() || `HTTP ${status}`,
|
|
285
|
+
fix: "Mason was started again since this page was opened, and each run has its own token: mason ui, in the project's folder, opens this page again",
|
|
286
|
+
next: [{ command: "mason ui", what: "this page, for the Mason that is running" }] };
|
|
287
|
+
}
|
|
288
|
+
return { code: "failed", message: (body && typeof body.error === "string" ? body.error : text.trim()) || `HTTP ${status}`, next: [{ command: "mason logs", what: "the run's log: what it was doing" }] };
|
|
289
|
+
}
|
|
290
|
+
return { code: "unreachable", message: "Mason does not answer at this address", why: `the run stopped, or runs on another port now (${text})`,
|
|
291
|
+
fix: "mason start in the project's folder, then mason ui opens this page again: each run has its own address and token",
|
|
292
|
+
next: [{ command: "mason status", what: "whether Mason runs, and why it stopped" }, { command: "mason start", what: "start it" }, { command: "mason ui", what: "this page, for the Mason that is running" }] };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A part of a page written from one read: its body rendered, its Failure said where it shows when `says` (not the
|
|
296
|
+
* page's first read's: that is the banner), or that it is being asked. */
|
|
297
|
+
export function readPart(ctx, path, render, says) {
|
|
298
|
+
const r = ctx.read(path);
|
|
299
|
+
if (r === undefined) return `<p class="dim">Asking Mason…</p>`;
|
|
300
|
+
return (r.failure && says ? renderFailure(r.failure) : "") + (r.body === undefined ? "" : render(r.body));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One request to the run over `fetchFn`: `{ body }`, its JSON, or `{ failure }` when it was refused or nothing answered. */
|
|
304
|
+
export function createAsk(fetchFn) {
|
|
305
|
+
return async (path, init) => {
|
|
306
|
+
let res;
|
|
307
|
+
try { res = await fetchFn(path, { credentials: "same-origin", ...init }); }
|
|
308
|
+
catch (e) { return { failure: failureOf(null, e?.message ?? String(e)) }; }
|
|
309
|
+
const text = await res.text();
|
|
310
|
+
if (!res.ok) return { failure: failureOf(res.status, text) };
|
|
311
|
+
try { return { body: JSON.parse(text) }; }
|
|
312
|
+
catch (e) { return { failure: { code: "failed", message: `the run's answer to ${path} is not JSON: ${e.message}` } }; }
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A request's init carrying JSON: `json("POST", { tables })`. */
|
|
317
|
+
export const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
318
|
+
|
|
319
|
+
/** The last-updated line: how long ago the page last heard from the run (`Updated 3 s ago`), so a run that stopped
|
|
320
|
+
* answering is seen ageing. `now` is the page's clock. */
|
|
321
|
+
export const updatedText = (at, now = new Date()) => `Updated ${duration(Math.max(0, (now.getTime() - at.getTime()) / 1000))} ago`;
|
|
322
|
+
|
|
323
|
+
// ------------------------------------------------------------------ the components (src/web/app.css styles them; docs/WEB-UI.md "Presentation")
|
|
324
|
+
// Each returns HTML from values it escapes; a part that is already HTML (a chart's holder, a body) is said so by its name.
|
|
325
|
+
|
|
326
|
+
/** What a word of the commands' says about a state, as the dot's colour: ok, warn, bad or unknown. */
|
|
327
|
+
export function statusKind(word) {
|
|
328
|
+
const w = String(word ?? "").toLowerCase();
|
|
329
|
+
if (/^(ready|synced|done|copied|completed|ok|streamed|running|live|added)\b/.test(w)) return "ok";
|
|
330
|
+
if (/^(failed|error|refused|lost|not running|stopped)\b/.test(w)) return "bad";
|
|
331
|
+
if (/^(under way|syncing|copying|waiting|catching up|not streamed|not kept current|unsynced|cancelled|warning|left out|pending)\b/.test(w)) return "warn";
|
|
332
|
+
return "unknown";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const KINDS = new Set(["ok", "warn", "bad", "unknown"]);
|
|
336
|
+
const kindOf = (k) => KINDS.has(k) ? k : "unknown";
|
|
337
|
+
|
|
338
|
+
/** A state as a small coloured dot and its word — never the colour alone: `status("ok", "Synced")`; the kind from the
|
|
339
|
+
* word when it is omitted (`status(undefined, "Failed")`). */
|
|
340
|
+
export const status = (kind, word) => `<span class="indicator ${kindOf(kind ?? statusKind(word))}"><span class="dot" aria-hidden="true"></span>${esc(word)}</span>`;
|
|
341
|
+
|
|
342
|
+
/** A small label: `badge("demo")`; `kind` one of accent, ok, warn, bad. */
|
|
343
|
+
export const badge = (text, kind = "") => `<span class="badge${kind ? ` ${esc(kind)}` : ""}">${esc(text)}</span>`;
|
|
344
|
+
|
|
345
|
+
/** A KPI card: its label small and muted, its value large, a sub-line, and a chart's holder (`spark(...)` of
|
|
346
|
+
* src/web/charts.js: HTML) under it; `kind` warn or bad colours the value of one to act on. With `href` (a hash of the
|
|
347
|
+
* page: `#observability/queries`) the whole card is a link into the view that lists what it counts (its arrow is the
|
|
348
|
+
* stylesheet's, so a reader of the text reads the card the same). */
|
|
349
|
+
export function card(label, value, sub = "", chartHtml = "", kind = "", href = "") {
|
|
350
|
+
const tag = href ? "a" : "div";
|
|
351
|
+
return `<${tag} class="card${kind === "warn" || kind === "bad" ? ` ${kind}` : ""}${href ? " link" : ""}"${href ? ` href="${esc(href)}"` : ""}>` +
|
|
352
|
+
`<p class="card-label">${esc(label)}</p><p class="card-value">${esc(value)}</p>` +
|
|
353
|
+
`${sub === "" ? "" : `<p class="card-sub">${esc(sub)}</p>`}${chartHtml}</${tag}>`;
|
|
354
|
+
}
|
|
355
|
+
/** The cards in a grid (each `card(...)`'s HTML), its class saying how many (`cards n6`), so the stylesheet lays every
|
|
356
|
+
* count out without a card alone on a row: six in one row or two of three, five as three over two. */
|
|
357
|
+
export const cards = (list) => list.length ? `<div class="cards n${list.length}">${list.join("")}</div>` : "";
|
|
358
|
+
|
|
359
|
+
/** A table (or any wide HTML) in a hairline card that scrolls sideways on a phone, never the page. */
|
|
360
|
+
export const panel = (html) => `<div class="panel">${html}</div>`;
|
|
361
|
+
|
|
362
|
+
/** A button: `button("Sync", { sync: "public.orders" }, "primary")` → data-sync="public.orders"; `variant` primary,
|
|
363
|
+
* danger or "" (secondary), with " small" for a compact one in a row. */
|
|
364
|
+
export function button(label, data = {}, variant = "", { room = "", disabled = false, expanded = false } = {}) {
|
|
365
|
+
const attrs = Object.entries(data).map(([k, v]) => ` data-${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${esc(v)}"`).join("");
|
|
366
|
+
return `<button type="button"${variant ? ` class="${esc(variant)}"` : ""}${attrs}${room ? ` data-room="${esc(room)}"` : ""}${expanded ? ` aria-expanded="true"` : ""}${disabled ? " disabled" : ""}>${esc(label)}</button>`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A BUTTON WHOSE LABEL CHANGES KEEPS ITS WIDTH: `room` is its longest label (Sync → Syncing… → Synced ✓, Copy → Copied),
|
|
371
|
+
* written as `data-room` and laid by the stylesheet as an invisible line of no height under the label
|
|
372
|
+
* (`button[data-room]::after`), so the button is as wide as its widest word whichever it says — and its row and its
|
|
373
|
+
* column never move when it changes. The rooms of the buttons whose label changes, by what they do (a user's Disable
|
|
374
|
+
* becomes Enable once it is disabled: one room, the longer).
|
|
375
|
+
*/
|
|
376
|
+
export const ROOMS = { sync: "Syncing…", add: "Adding…", copy: "Press ⌘C", disable: "Disable" };
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A POPOVER ANCHORED TO WHAT ASKED FOR IT — a confirm, a form, what an action answered — so nothing is inserted into a
|
|
380
|
+
* table and no row below moves (docs/WEB-UI.md "Presentation"): written into its part AFTER the table (never a row of
|
|
381
|
+
* it), `position: fixed` and placed by `placePops` beside its anchor (`anchor`: an element's id, a row's actions cell)
|
|
382
|
+
* after each write, and again on a scroll or a resize. `kind`: `dialog` (a question or a form: Escape presses its
|
|
383
|
+
* `data-pop-close` button) or `status` (an answer: read out, and gone by itself — the page's OUTCOME_MS — unless it is a
|
|
384
|
+
* refusal). `label` names a dialog for a screen reader.
|
|
385
|
+
*/
|
|
386
|
+
export const pop = (anchor, html, { kind = "status", label = "" } = {}) =>
|
|
387
|
+
`<div class="pop ${kind === "dialog" ? "ask" : "said"}" data-anchor="${esc(anchor)}" role="${kind === "dialog" ? "dialog" : "status"}"${label ? ` aria-label="${esc(label)}"` : ""}>${html}</div>`;
|
|
388
|
+
|
|
389
|
+
/** How long what an action answered stays beside its button before it goes by itself (a refusal stays until dismissed). */
|
|
390
|
+
export const OUTCOME_MS = 8000;
|
|
391
|
+
|
|
392
|
+
/** Whether an answer recorded at `at` (ms) is still shown at `now`: a refusal always (until dismissed), else OUTCOME_MS. */
|
|
393
|
+
export const fresh = (o, now) => o !== null && o !== undefined && ("failure" in o || o.at === undefined || now - o.at < OUTCOME_MS);
|
|
394
|
+
|
|
395
|
+
/** An element's id for a row's actions cell (a popover's anchor): unique in the document, whatever the name holds. */
|
|
396
|
+
export const actId = (page, key) => `act-${page}-${key}`;
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* EVERY POPOVER PLACED BESIDE ITS ANCHOR (the shell calls it after each write, a scroll and a resize): under the anchor,
|
|
400
|
+
* its right edge on the anchor's — above it when there is no room below — and kept inside the window with a 16 px
|
|
401
|
+
* gutter. `position: fixed`, so a card that scrolls sideways (`.panel`) never clips it. A popover whose anchor is not in
|
|
402
|
+
* the document is hidden. Returns the popovers placed, for the shell to focus a new dialog's first button.
|
|
403
|
+
*/
|
|
404
|
+
export function placePops(doc, win) {
|
|
405
|
+
const placed = [];
|
|
406
|
+
if (typeof doc?.querySelectorAll !== "function") return placed;
|
|
407
|
+
const gutter = 16, gap = 4;
|
|
408
|
+
const vw = win.innerWidth ?? 0, vh = win.innerHeight ?? 0;
|
|
409
|
+
for (const el of doc.querySelectorAll(".pop[data-anchor]")) {
|
|
410
|
+
const a = doc.getElementById(el.dataset.anchor);
|
|
411
|
+
if (a === null || typeof a.getBoundingClientRect !== "function") { el.hidden = true; continue; }
|
|
412
|
+
el.hidden = false;
|
|
413
|
+
const r = a.getBoundingClientRect();
|
|
414
|
+
const w = el.offsetWidth, h = el.offsetHeight;
|
|
415
|
+
const left = Math.max(gutter, Math.min(r.right - w, vw - gutter - w));
|
|
416
|
+
const below = r.bottom + gap;
|
|
417
|
+
const top = below + h <= vh - gutter || r.top - gap - h < gutter ? below : r.top - gap - h;
|
|
418
|
+
el.style.left = `${Math.round(left)}px`;
|
|
419
|
+
el.style.top = `${Math.round(top)}px`;
|
|
420
|
+
placed.push(el);
|
|
421
|
+
}
|
|
422
|
+
return placed;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A text with its Copy button (`copyClick` copies what the `<code id>` holds): a URL, a psql line, a password. */
|
|
426
|
+
export const copyable = (id, text) =>
|
|
427
|
+
`<span class="copyrow"><code id="${esc(id)}">${esc(text)}</code> ${button("Copy", { copy: id }, "small", { room: ROOMS.copy })}</span>`;
|
|
428
|
+
|
|
429
|
+
/** How long a Copy button says Copied (or what to press) before it says Copy again. */
|
|
430
|
+
export const COPIED_MS = 2000;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A COPY BUTTON CLICKED (`data-copy`, a `copyable`'s), on whichever page: its code's text to the clipboard, the button
|
|
434
|
+
* then saying Copied for COPIED_MS and Copy again. When the clipboard refuses (no permission, an insecure origin, a
|
|
435
|
+
* browser setting), the text is selected and copied as a selection is (`execCommand("copy")`); when that fails too, it
|
|
436
|
+
* stays selected and the button says what to press (in its room: `ROOMS.copy`). null for a button that is no Copy, so a page's `click` asks it first.
|
|
437
|
+
*/
|
|
438
|
+
export function copyClick(d, button, { doc, win }) {
|
|
439
|
+
if (d.copy === undefined) return null;
|
|
440
|
+
const el = doc.getElementById(d.copy);
|
|
441
|
+
const text = el?.textContent ?? "";
|
|
442
|
+
const say = (word) => {
|
|
443
|
+
button.textContent = word;
|
|
444
|
+
if (button.dataset) button.dataset.copied = "";
|
|
445
|
+
win.setTimeout?.(() => { button.textContent = "Copy"; if (button.dataset) delete button.dataset.copied; if ("title" in button) button.title = ""; }, COPIED_MS);
|
|
446
|
+
};
|
|
447
|
+
return Promise.resolve().then(() => win.navigator.clipboard.writeText(text)).then(() => say("Copied"), () => {
|
|
448
|
+
// no clipboard: the text selected, and copied as a selection is (what a person's own Copy would do)
|
|
449
|
+
const selection = win.getSelection?.();
|
|
450
|
+
if (el && selection) selection.selectAllChildren(el);
|
|
451
|
+
let copied = false;
|
|
452
|
+
try { copied = doc.execCommand?.("copy") === true; } catch { copied = false; } // refused outright: said below, the text left selected
|
|
453
|
+
// what to press, in the button's own room (ROOMS.copy: it never grows); the whole sentence its title
|
|
454
|
+
const mac = /Mac|iPhone|iPad/.test(win.navigator.platform ?? win.navigator.userAgent ?? "");
|
|
455
|
+
if (!copied && "title" in button) button.title = `Selected: press ${mac ? "⌘C" : "Ctrl+C"} to copy it`;
|
|
456
|
+
say(copied ? "Copied" : mac ? "Press ⌘C" : "Ctrl+C");
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** What the request log does not list (src/cli/commands.ts `UNLISTED_STATEMENTS`, held equal by the tests): a statement
|
|
461
|
+
* that reads no synced table is the catalog's to answer, and not recorded — so a `select 1` never shows on Queries. */
|
|
462
|
+
export const UNLISTED_STATEMENTS = "A statement that reads no synced table (select 1, SET, SHOW, a BI tool's catalog queries) is answered but not listed";
|
|
463
|
+
|
|
464
|
+
/** The hash of the page that makes a user (src/web/app.js PAGES): where Connect's Create a user leads. */
|
|
465
|
+
export const USERS_HASH = "#users";
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* HOW A TOOL CONNECTS (`/api/state` `postgres`, as `mason status --json` says it; src/cli/pg-url.ts postgresFacts): the
|
|
469
|
+
* Postgres API's URL with `<user>:<password>` in the credentials' place — the line `mason start` and `mason status`
|
|
470
|
+
* print — and its psql line, each with Copy, and Create a user (the Users page), whose name and password go in that
|
|
471
|
+
* place. `first` (nothing has asked Mason anything yet) leads with what the URL is for. Nothing from a run that does
|
|
472
|
+
* not say it.
|
|
473
|
+
*/
|
|
474
|
+
export function renderConnect(pg, { first = false, table = null } = {}) {
|
|
475
|
+
if (!pg || typeof pg.shown !== "string") return "";
|
|
476
|
+
// a first statement to type that the request log lists (one that reads a copied table: UNLISTED_STATEMENTS)
|
|
477
|
+
const tryIt = first && table ? `<dt>Then try</dt><dd>${copyable("connect-try", `select count(*) from ${entryName(table)};`)} <span class="dim">it shows on Queries</span></dd>` : "";
|
|
478
|
+
return `<div class="connect${first ? " first" : ""}"><p class="connect-title">${first ? "Connect your first tool" : "Connect"}</p>` +
|
|
479
|
+
(first ? `<p class="lead">Mason answers SQL over the Postgres protocol, as a read replica of your database would: point psql, a BI tool or your app's analytics code at this URL.</p>` : "") +
|
|
480
|
+
`<dl class="facts"><dt>URL</dt><dd>${copyable("connect-url", pg.shown)}</dd><dt>psql</dt><dd>${copyable("connect-psql", pg.psql)}</dd>${tryIt}</dl>` +
|
|
481
|
+
`<p class="tip"><a class="btn${first ? " primary" : ""}" href="${USERS_HASH}">Create a user</a> <span>Its name and password go in place of ${code("<user>:<password>")}: the Users page shows the whole URL once, as ${code("mason user add <name>")} prints it.</span></p></div>`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Nothing to show, quietly: a title and a line under it (`hintHtml`: HTML, a command in code()). */
|
|
485
|
+
export const empty = (title, hintHtml = "") => `<div class="empty"><p class="empty-title">${esc(title)}</p>${hintHtml ? `<p>${hintHtml}</p>` : ""}</div>`;
|
|
486
|
+
|
|
487
|
+
/** Being asked: the words a reader (and a test) reads, over grey bars the size of what will come. */
|
|
488
|
+
export const skeleton = (lines = 3, what = "Asking Mason…") => `<div class="loading" aria-busy="true"><p class="dim">${esc(what)}</p>` +
|
|
489
|
+
Array.from({ length: lines }, (_, i) => `<span class="skeleton${i === lines - 1 ? " half" : ""}"></span>`).join("") + "</div>";
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* A <details> that STAYS OPEN across a refresh: a poll rewrites the part it is in, so whether it is open is the page's
|
|
493
|
+
* state (`createOpenSet`), keyed by a stable id (`key`: a request's id, a table's name), never the element's.
|
|
494
|
+
* `summary` is text, `bodyHtml` HTML. `details(key, "More", body, opened.has(key))`.
|
|
495
|
+
*/
|
|
496
|
+
export const details = (key, summary, bodyHtml, open = false, cls = "") =>
|
|
497
|
+
`<details${cls ? ` class="${esc(cls)}"` : ""} data-open="${esc(key)}"${open ? " open" : ""}><summary>${esc(summary)}</summary>${bodyHtml}</details>`;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The open <details> of a page, by key: `has(key)` for the renderer; `toggle(target)` is the page's `toggle` (the shell
|
|
501
|
+
* hears every details open or close): null when it is no keyed details or nothing changed (the toggle a rewrite causes
|
|
502
|
+
* itself), else true, and the shell writes the page again.
|
|
503
|
+
*/
|
|
504
|
+
export function createOpenSet() {
|
|
505
|
+
const open = new Set();
|
|
506
|
+
return {
|
|
507
|
+
open,
|
|
508
|
+
has: (key) => open.has(String(key)),
|
|
509
|
+
toggle(target) {
|
|
510
|
+
const key = target?.dataset?.open;
|
|
511
|
+
if (typeof key !== "string" || key === "") return null;
|
|
512
|
+
const now = target.open === true;
|
|
513
|
+
// nothing changed: the toggle a rewrite causes itself — but a click asked for what is already the state, so the
|
|
514
|
+
// element shown disagrees with it, and is written again
|
|
515
|
+
if (now === open.has(key)) return target.clicked === true ? true : null;
|
|
516
|
+
if (now) open.add(key); else open.delete(key);
|
|
517
|
+
return true;
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ------------------------------------------------------------------ large data: what the page writes is bounded
|
|
523
|
+
|
|
524
|
+
/** A text cut to `max` characters with an ellipsis (a title, a cell): the page never writes a 64 KiB statement into an
|
|
525
|
+
* attribute or a row; the whole of it is behind its details, itself capped (`capped`). */
|
|
526
|
+
export const clip = (s, max) => { const t = String(s ?? ""); return t.length <= max ? t : `${t.slice(0, Math.max(0, max - 1))}…`; };
|
|
527
|
+
|
|
528
|
+
/** The most of a text a title attribute carries (a statement's, an error's). */
|
|
529
|
+
export const TITLE_MAX = 1000;
|
|
530
|
+
|
|
531
|
+
/** A text as a details shows it, at most `max` characters: `{ text, cut }`, `cut` what is said after it when it was cut
|
|
532
|
+
* ("the first 16.0 KiB of 64.0 KiB: … has it whole"; `whole` names where). */
|
|
533
|
+
export function capped(s, max, whole) {
|
|
534
|
+
const t = String(s ?? "");
|
|
535
|
+
if (t.length <= max) return { text: t, cut: "" };
|
|
536
|
+
return { text: t.slice(0, max), cut: `the first ${fileSize(max)} of ${fileSize(t.length)}: ${whole} has it whole` };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** "Showing 100 of 800" and the buttons that write more (`data-show-more`, `data-show-all` = `key`): under a list the page
|
|
540
|
+
* writes at most `shown` rows of. "" when every row is shown. */
|
|
541
|
+
export function moreRows(key, shown, total, step) {
|
|
542
|
+
if (shown >= total) return "";
|
|
543
|
+
return `<div class="more"><p class="dim">Showing ${n(shown)} of ${n(total)}.</p>` +
|
|
544
|
+
`${button(`Show ${n(Math.min(step, total - shown))} more`, { showMore: key }, "small")} ${button(`Show all ${n(total)}`, { showAll: key }, "small")}</div>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ------------------------------------------------------------------ sorting a table by a column
|
|
548
|
+
|
|
549
|
+
/** A sort: `{ key, dir }`, dir "asc" or "desc". The column clicked again turns it round; another starts at `first`. */
|
|
550
|
+
export const nextSort = (sort, key, first = "desc") => sort.key === key ? { key, dir: sort.dir === "asc" ? "desc" : "asc" } : { key, dir: first };
|
|
551
|
+
|
|
552
|
+
/** A sortable column's header: a button (`data-sort-table`, `data-sort-key`) whose arrow and `aria-sort` say the order. */
|
|
553
|
+
export function sortTh(table, key, label, sort, cls = "") {
|
|
554
|
+
const on = sort.key === key;
|
|
555
|
+
const aria = on ? (sort.dir === "asc" ? "ascending" : "descending") : "none";
|
|
556
|
+
return `<th scope="col" aria-sort="${aria}"${cls ? ` class="${esc(cls)}"` : ""}><button type="button" class="sort${on ? " on" : ""}" data-sort-table="${esc(table)}" data-sort-key="${esc(key)}">` +
|
|
557
|
+
`${esc(label)}<span class="arrow" aria-hidden="true">${on ? (sort.dir === "asc" ? "↑" : "↓") : "↕"}</span></button></th>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A list sorted by `value(item)` (a number or a string; null last whatever the order), stably, without changing it. */
|
|
561
|
+
export function sortedBy(list, value, dir) {
|
|
562
|
+
const sign = dir === "asc" ? 1 : -1;
|
|
563
|
+
return list.map((x, i) => ({ x, i, v: value(x) })).sort((a, b) => {
|
|
564
|
+
if (a.v === null || a.v === undefined) return b.v === null || b.v === undefined ? a.i - b.i : 1;
|
|
565
|
+
if (b.v === null || b.v === undefined) return -1;
|
|
566
|
+
return (a.v < b.v ? -1 : a.v > b.v ? 1 : 0) * sign || a.i - b.i;
|
|
567
|
+
}).map((e) => e.x);
|
|
568
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Third-party files the web page vendors
|
|
2
|
+
|
|
3
|
+
Mason runs on machines without internet, so the page loads nothing from elsewhere: each dependency below is copied
|
|
4
|
+
here WHOLE (its built file(s) and its licence), pinned to an exact version, and served by Mason from its allowlist
|
|
5
|
+
(`PAGE_ROUTES`, src/cli/run-server.ts). None is fetched at run time, and none is changed. Mason itself is Apache-2.0
|
|
6
|
+
(the root `LICENSE`); these files keep their own licences.
|
|
7
|
+
|
|
8
|
+
| Folder | Package | Version | Licence | Files | Size |
|
|
9
|
+
|---|---|---|---|---|---|
|
|
10
|
+
| `geist-1.7.2/` | [`geist`](https://www.npmjs.com/package/geist) (Geist Sans and Geist Mono, by Vercel with basement.studio) | 1.7.2 | SIL Open Font License 1.1 (`LICENSE.txt`) | `Geist-Variable.woff2`, `GeistMono-Variable.woff2` (the variable fonts, `dist/fonts/geist-sans/` and `dist/fonts/geist-mono/`) | 68 KiB, 70 KiB |
|
|
11
|
+
| `zilla-slab-5.3.0/` | [`@fontsource/zilla-slab`](https://www.npmjs.com/package/@fontsource/zilla-slab) (Zilla Slab, by Typotheque for the Mozilla Foundation) | 5.3.0 | SIL Open Font License 1.1 (`LICENSE.txt`, the package's `LICENSE`) | `zilla-slab-latin-700-normal.woff2` (`files/`: the bold, Latin only — the titles and the big numbers) | 26 KiB |
|
|
12
|
+
| `lucide-1.56.0/` | [`lucide-static`](https://www.npmjs.com/package/lucide-static) (Lucide's icons, by Lucide Icons and contributors) | 1.56.0 | ISC (`LICENSE.txt`, the package's `LICENSE`) | `layout-dashboard.mjs`, `table.mjs`, `users.mjs`, `square-terminal.mjs`, `chart-column.mjs`, `logs.mjs`, `menu.mjs`, `x.mjs` (`dist/esm/icons/`: each exports its 24-unit SVG as a string; the sidebar's nav and the phone's menu button) | 5 KiB in all |
|
|
13
|
+
| `uplot-1.6.32/` | [`uplot`](https://www.npmjs.com/package/uplot) (by Leon Sorokin) | 1.6.32 | MIT (`LICENSE.txt`, the package's `LICENSE`) | `uPlot.esm.js`, `uPlot.min.css` (`dist/`) | 142 KiB, 2 KiB |
|
|
14
|
+
|
|
15
|
+
About 313 KiB in all, served only when the page asks for them (the fonts by app.css's `@font-face`, uPlot by
|
|
16
|
+
src/web/charts.js once a chart is drawn, the icons by src/web/shared.js's imports). Only the icons the page draws are
|
|
17
|
+
copied, each module whole; its source map (`//# sourceMappingURL`) is not, so a browser's developer tools may ask for it
|
|
18
|
+
and get a 404.
|
|
19
|
+
|
|
20
|
+
## Updating one
|
|
21
|
+
|
|
22
|
+
1. `npm pack <package>@<exact version>` in a temporary folder; unpack it.
|
|
23
|
+
2. Copy only the files above and the licence into a NEW folder named `<package>-<version>/`; delete the old one and the tarball.
|
|
24
|
+
3. Change the routes in `PAGE_ROUTES`, the paths in app.css / charts.js / index.html, and this table.
|
|
25
|
+
4. `npm run check`: tests/web-page.test.ts holds every file here to a route and a licence beside it.
|
|
Binary file
|
|
Binary file
|