@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
package/dist/src/web/app.js
CHANGED
|
@@ -1,490 +1,412 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE WEB PAGE OF `mason
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* THE WEB PAGE OF A RUN (`mason ui`, and `mason ui --ssh` for a server's; docs/WEB-UI.md, and .claude/rules/web-ui.md for whoever builds it out):
|
|
3
|
+
* the SHELL — a left sidebar (the project, then a nav of links with icons: Overview · Tables · Users, and the
|
|
4
|
+
* Observability group: Queries · Usage · Logs; at its foot when it was updated and the version; an icon rail on a narrow
|
|
5
|
+
* window, a menu on a phone) and one section per page, the one shown chosen by the URL's hash (`#overview`, the
|
|
6
|
+
* default; an unknown hash is the Overview; `#observability/queries?range=24h`, the shared time range of the three) —
|
|
7
|
+
* over the pages, each an ES module of src/web/pages/ rendered from the run's command server's JSON
|
|
8
|
+
* (src/cli/run-server.ts) in the words the commands print.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* Plain JavaScript, ES modules, no framework and no build step: the server serves each file as it is (PAGE_ROUTES),
|
|
11
|
+
* under a `default-src 'self'` policy. The renderers are PURE FUNCTIONS OF THE JSON, returning HTML strings (every
|
|
12
|
+
* value escaped), exported so tests/web-page.test.ts runs them under node; `createApp` holds what the page knows and
|
|
13
|
+
* does, over a `fetch` it is handed; `start` (a browser only) wires them to the document. The page never holds the
|
|
14
|
+
* run's token: the browser's cookie carries it (src/cli/security.ts); of the address it reads the hash alone.
|
|
9
15
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* THE PAGE MODULE CONTRACT (src/web/pages/<id>.js), so a page is built without touching the others:
|
|
17
|
+
*
|
|
18
|
+
* export const page = { id, title, hash?, parent?, create(ctx) }
|
|
19
|
+
* id its section (<section id="page-<id>"> in index.html) and its route (/pages/<id>.js); its hash too, unless
|
|
20
|
+
* hash says another (`overview`, `observability/queries`): the address it is shown at
|
|
21
|
+
* title its link in the nav (under its `parent`'s group, `observability`, whose pages share the time range:
|
|
22
|
+
* ctx.range()); its icon is the shell's (NAV_ICONS)
|
|
23
|
+
* create(ctx) → {
|
|
24
|
+
* reads() [{ path, everyMs }]: what it reads WHILE SHOWN, each at its own rate (everyMs ≤ POLL_MS: every
|
|
25
|
+
* poll); a path is the store's key, so a path built from the page's own state (a filter, a page
|
|
26
|
+
* of results) is read as that state changes. The FIRST is the page's own: its failure is the
|
|
27
|
+
* banner at the top (`poll`); the page renders the others' failures itself.
|
|
28
|
+
* view() { <element id>: html }: the parts of its section it writes, from ctx.read and its own state
|
|
29
|
+
* actions { name: fn }: what its buttons do, merged onto the app (the tests drive them); each re-reads
|
|
30
|
+
* what it changed with ctx.refresh([path])
|
|
31
|
+
* afterRead?() after a poll stored its reads: drop what the new data made stale (a confirm for a row gone)
|
|
32
|
+
* click?(dataset, button, { doc, win }) a button of its section clicked: null when not its, else a value or a
|
|
33
|
+
* promise (the shell writes the page again once it settles)
|
|
34
|
+
* submit?(form, FormData) a form submitted: null when not its, else a promise (the shell prevents the default)
|
|
35
|
+
* input?(target) an input changed: null when not its
|
|
36
|
+
* toggle?(target) a <details> opened or closed: null when not its, else a value or a promise (as `click`);
|
|
37
|
+
* a details that must stay open across a refresh is `details(key, …)` of shared.js, its open set
|
|
38
|
+
* `createOpenSet()` in the page's state, whose `toggle` this is. `target` is the element (its
|
|
39
|
+
* toggle event) or, for a click on its summary, `{ id, dataset, open, clicked: true }`: `open`
|
|
40
|
+
* what the click asks for, BEFORE the element changes; clicked, a details the page keeps is
|
|
41
|
+
* answered (true) even when its state already says so, the shell writing it again
|
|
42
|
+
* charts?() { <holder key>: spec }: the charts drawn in its holders after each write (src/web/charts.js; a
|
|
43
|
+
* progressive addition: every number a chart draws is also text)
|
|
44
|
+
* route?(params, { doc }) the hash showed it with these parameters (`#tables?show=failed`: { show: "failed" }):
|
|
45
|
+
* it takes the ones it knows into its state (and a control outside its parts, through `doc`)
|
|
46
|
+
* model? its state, for the tests
|
|
47
|
+
* }
|
|
48
|
+
* ctx = { ask(path, init) → { body } | { failure }, read(path) → { body, failure } | undefined (body: the last
|
|
49
|
+
* one read; failure: this read's, else null), refresh(paths) → Promise (read them now), now() → Date,
|
|
50
|
+
* range() → a key of RANGES: the time range the Observability pages share (from the hash) }
|
|
51
|
+
*
|
|
52
|
+
* A page is registered by its import and its place in PAGES below, its route in PAGE_ROUTES, and its section in
|
|
53
|
+
* index.html.
|
|
15
54
|
*/
|
|
55
|
+
import { esc, badge, icon, createAsk, overTunnel, renderFailure, updatedText, placePops, RANGES, DEFAULT_RANGE, rangeOf, rangeOffered, isLive, POLL_MS } from "./shared.js";
|
|
56
|
+
import { createCharts } from "./charts.js";
|
|
57
|
+
import { page as statusPage } from "./pages/status.js";
|
|
58
|
+
import { page as tablesPage } from "./pages/tables.js";
|
|
59
|
+
import { page as queriesPage } from "./pages/queries.js";
|
|
60
|
+
import { page as usersPage } from "./pages/users.js";
|
|
61
|
+
import { page as usagePage } from "./pages/usage.js";
|
|
62
|
+
import { page as syncsPage } from "./pages/syncs.js";
|
|
16
63
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/** A value as HTML text: every character that means something in HTML escaped. */
|
|
20
|
-
export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
64
|
+
/** What the shell itself reads when the page shown does not: the state (the sidebar's project, badge and version). */
|
|
65
|
+
const SHELL_STATE = { path: "/api/state", everyMs: 60_000 };
|
|
21
66
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export function duration(seconds) {
|
|
27
|
-
const s = Math.max(1, Math.round(seconds));
|
|
28
|
-
if (s < 90) return `${s} s`;
|
|
29
|
-
const m = Math.round(s / 60);
|
|
30
|
-
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
31
|
-
}
|
|
67
|
+
/** A text that moves with the clock (an age, a running time), and a part's HTML without them: `paint` sets those
|
|
68
|
+
* texts in place when nothing else changed. */
|
|
69
|
+
const TICK = /(<time data-tick[^>]*>)([^<]*)(<\/time>)/g;
|
|
70
|
+
const steady = (html) => html.replace(TICK, "$1$3");
|
|
32
71
|
|
|
33
|
-
/**
|
|
34
|
-
export const
|
|
72
|
+
/** The longest a pointer's press holds the page's writes (a click is well under it): see `pressedAt`. */
|
|
73
|
+
export const PRESS_HOLD_MS = 2000;
|
|
35
74
|
|
|
36
|
-
|
|
37
|
-
export
|
|
75
|
+
// every renderer the tests import from here, wherever it lives now
|
|
76
|
+
export * from "./shared.js";
|
|
77
|
+
export { copyingText, tableLine, keptAge, keptWords, sourceRows, renderStatus, renderShare, createShare } from "./pages/status.js";
|
|
78
|
+
export { tableFacts, addedLines, removedReport, pickBody, columnsPath, renderTableAdded, renderTablesRemoved, renderTables } from "./pages/tables.js";
|
|
79
|
+
export { renderUsers, renderAdded, attributesText } from "./pages/users.js";
|
|
80
|
+
export { renderRequests } from "./pages/queries.js";
|
|
81
|
+
export { renderRunning, renderSessions, renderClients, renderSlowest, queriesHref } from "./pages/usage.js";
|
|
38
82
|
|
|
39
|
-
/**
|
|
40
|
-
export const
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
|
|
44
|
-
* page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
|
|
45
|
-
*/
|
|
46
|
-
export function copyingText(copied, rowsEstimate, p) {
|
|
47
|
-
const shown = copied + (p?.rowsReading ?? 0);
|
|
48
|
-
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
49
|
-
const past = estimate !== null && shown > estimate;
|
|
50
|
-
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
51
|
-
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
52
|
-
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
53
|
-
if (p === undefined) return { fraction, text: rowsText(copied) };
|
|
54
|
-
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
55
|
-
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
56
|
-
if (p.rowsPerSecond === null) {
|
|
57
|
-
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
58
|
-
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to copy`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
59
|
-
}
|
|
60
|
-
const parts = [rowsText(shown)];
|
|
61
|
-
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
62
|
-
if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
63
|
-
else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
64
|
-
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
65
|
-
if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
|
|
66
|
-
return { fraction, text: parts.join(" · ") };
|
|
67
|
-
}
|
|
83
|
+
/** The pages, in the nav's order: a page with a `parent` is in its parent's group. */
|
|
84
|
+
export const PAGES = [statusPage, tablesPage, usersPage, queriesPage, usagePage, syncsPage];
|
|
68
85
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
* its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says.
|
|
72
|
-
*/
|
|
73
|
-
export function tableLine(t) {
|
|
74
|
-
if (t.error) return { state: "failed", fraction: 1, what: `its copy failed: ${firstLine(t.error)}` };
|
|
75
|
-
if (t.copy?.phase === "copying") {
|
|
76
|
-
const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
|
|
77
|
-
return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `copying ${c.text}` : c.text };
|
|
78
|
-
}
|
|
79
|
-
if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its copy to start" };
|
|
80
|
-
if (t.unsynced) {
|
|
81
|
-
return { state: "unsynced", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied, NOT kept current (${t.unsynced.reason}${t.unsynced.asOf ? `; as of ${t.unsynced.asOf.slice(0, 16).replace("T", " ")} UTC` : ""}) — mason sync ${entryName(t.table)}, or mason start --replication` };
|
|
82
|
-
}
|
|
83
|
-
return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied${t.copy.retired ? ` · ${t.copy.retired}` : ""}` };
|
|
84
|
-
}
|
|
86
|
+
/** The groups a parent stands for: its title, the heading of its pages in the nav. */
|
|
87
|
+
const GROUPS = { observability: { title: "Observability" } };
|
|
85
88
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
export function tableFacts(t) {
|
|
89
|
-
return { size: t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`, key: t.key.length ? `key ${t.key.join(", ")}` : "no key",
|
|
90
|
-
state: t.added ? "added" : t.reason ? `cannot be added as it is: ${t.reason}` : null };
|
|
91
|
-
}
|
|
89
|
+
/** Each page's icon in the nav (Lucide's names, src/web/shared.js ICONS): the shell's, so a page module stays its own. */
|
|
90
|
+
export const NAV_ICONS = { status: "layout-dashboard", tables: "table", users: "users", queries: "square-terminal", usage: "chart-column", syncs: "logs" };
|
|
92
91
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
export function addedLines(b, file) {
|
|
96
|
-
const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
|
|
97
|
-
const s = b.skippedSecrets;
|
|
98
|
-
return { line: `Added ${b.table} to ${file}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`, notes: [
|
|
99
|
-
...(b.addedKey.length ? [`key added: ${b.addedKey.join(", ")} (a copy always has its key)`] : []),
|
|
100
|
-
...(s.length ? [`left out ${s.join(", ")}: ${s.length === 1 ? "it looks" : "they look"} like ${s.length === 1 ? "a secret" : "secrets"}, ` +
|
|
101
|
-
`which anyone with a user's password could read (--include ${s[0]} adds it)`] : []),
|
|
102
|
-
] };
|
|
103
|
-
}
|
|
92
|
+
/** The hashes of before the Observability group, and the group's own: each lands on the view it was (`#status` the Overview). */
|
|
93
|
+
const ALIASES = { status: "status", queries: "queries", usage: "usage", logs: "syncs", syncs: "syncs", "observability/logs": "syncs", observability: "queries" };
|
|
104
94
|
|
|
105
|
-
|
|
106
|
-
export function removedReport(removed, file) {
|
|
107
|
-
return { headline: `Removed ${removed.join(", ")}`, details: [["from", file]],
|
|
108
|
-
tips: [`Mason stops copying ${removed.length === 1 ? "it" : "them"} and drops ${removed.length === 1 ? "its copy" : "their copies"}`] };
|
|
109
|
-
}
|
|
95
|
+
const hashOfPage = (p) => p.hash ?? p.id;
|
|
110
96
|
|
|
111
97
|
/**
|
|
112
|
-
*
|
|
113
|
-
* none
|
|
114
|
-
* is the server's to judge (src/cli/run-server.ts `pick`), never the page's.
|
|
98
|
+
* WHAT A HASH SHOWS: the page (`#tables`, `#observability/usage`, an old one — `#status`, `#queries` — the view it was;
|
|
99
|
+
* none, or one it does not know, the Overview) and the parameters after its `?` (`range=7d`, `show=failed`).
|
|
115
100
|
*/
|
|
116
|
-
export
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
: `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
|
|
124
|
-
["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
|
|
125
|
-
`(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
|
|
126
|
-
: `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · ${s.source.demo ? "the demo database is read by polling only" : "mason start --replication reads the database's change log instead"}`],
|
|
127
|
-
];
|
|
101
|
+
export function parseHash(hash) {
|
|
102
|
+
const raw = String(hash ?? "").replace(/^#/, "");
|
|
103
|
+
const at = raw.indexOf("?");
|
|
104
|
+
const where = at < 0 ? raw : raw.slice(0, at);
|
|
105
|
+
const params = Object.fromEntries(new URLSearchParams(at < 0 ? "" : raw.slice(at + 1)));
|
|
106
|
+
const page = PAGES.find((p) => hashOfPage(p) === where)?.id ?? ALIASES[where] ?? "status";
|
|
107
|
+
return { id: page, params };
|
|
128
108
|
}
|
|
129
109
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
* rows, then Next: each command and what it does. `f` is the server's `failure` ({ code, message, why?, fix?, next }).
|
|
138
|
-
*/
|
|
139
|
-
export function renderFailure(f) {
|
|
140
|
-
const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
|
|
141
|
-
const next = f.next ?? [];
|
|
142
|
-
return `<div class="failure" role="alert"><p class="message"><span class="mark" aria-hidden="true">✗</span> ${esc(f.message)}</p>` +
|
|
143
|
-
(rows.length ? `<dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl>` : "") +
|
|
144
|
-
(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>` : "") +
|
|
145
|
-
"</div>";
|
|
110
|
+
/** A page's address with its parameters (`hashOf("queries", { range: "7d" })` → `#observability/queries?range=7d`); the
|
|
111
|
+
* default range is left out, so the plain address is Now. */
|
|
112
|
+
export function hashOf(id, params = {}) {
|
|
113
|
+
const p = PAGES.find((x) => x.id === id) ?? PAGES[0];
|
|
114
|
+
const q = new URLSearchParams(Object.entries(params).filter(([k, v]) => v !== undefined && v !== "" && !(k === "range" && v === DEFAULT_RANGE)));
|
|
115
|
+
const qs = q.toString();
|
|
116
|
+
return `#${hashOfPage(p)}${qs ? `?${qs}` : ""}`;
|
|
146
117
|
}
|
|
147
118
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
* not be read): what `mason status` prints — the database, each added table's copy (its bar, percent and the line
|
|
156
|
-
* `mason wait` shows), the warnings, the slow and refused counts, and readiness. Never the engine's name.
|
|
157
|
-
*/
|
|
158
|
-
export function renderStatus(s, requests = {}) {
|
|
159
|
-
const parts = [rowsHtml([...sourceRows(s), ...s.warnings.map((w) => ["Warning", w])])];
|
|
160
|
-
const picked = s.tables.filter((t) => t.added);
|
|
161
|
-
if (!picked.length) {
|
|
162
|
-
parts.push(`<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>`,
|
|
163
|
-
`<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} copies one.</p>`);
|
|
164
|
-
return parts.join("");
|
|
165
|
-
}
|
|
166
|
-
// as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
|
|
167
|
-
const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
|
|
168
|
-
const lines = picked.map((t) => ({ t, l: tableLine(t) }))
|
|
169
|
-
.sort((a, b) => rank[a.l.state] - rank[b.l.state] || (a.t.copy?.updatedAt ?? "").localeCompare(b.t.copy?.updatedAt ?? "") || a.t.table.localeCompare(b.t.table));
|
|
170
|
-
parts.push(`<h3>Tables (${picked.length} of ${s.tables.length} added)</h3>`,
|
|
171
|
-
`<table class="tables"><thead><tr><th scope="col">Table</th><th scope="col">Copy</th><th scope="col"></th></tr></thead><tbody>${lines.map(({ t, l }) =>
|
|
172
|
-
`<tr class="t-${l.state}"><th scope="row">${code(t.table)}</th>` +
|
|
173
|
-
`<td class="bar"><progress max="100" value="${Math.floor(Math.max(0, Math.min(1, l.fraction)) * 100)}"></progress> <span class="pct">${percent(l.fraction)}</span></td>` +
|
|
174
|
-
`<td class="what">${esc(l.what)}${t.error && t.error.includes("\n") ? `<details><summary>the whole error</summary><pre>${esc(t.error)}</pre></details>` : ""}</td></tr>`).join("")}</tbody></table>`);
|
|
175
|
-
if (requests.slow) parts.push(countLine("Slow queries", requests.slow, (r) => r.slow.length, (r) => ` (${n(r.slowMs / 1000)} s or more)`));
|
|
176
|
-
if (requests.refused) parts.push(countLine("Refused queries", requests.refused, (r) => r.total ?? r.refused.length, () => ""));
|
|
177
|
-
if (requests.slow || requests.refused) parts.push(`<p class="dim">${code("mason status")} lists them; ${code("mason logs --requests")} every one.</p>`);
|
|
178
|
-
const r = s.readiness;
|
|
179
|
-
if (r) {
|
|
180
|
-
if (r.ready) parts.push(`<p class="readiness ok">Ready: every added table is copied and served.</p>`);
|
|
181
|
-
else if (r.failed.length) parts.push(`<p class="readiness bad">Failed:</p><ul class="bullets">${r.failed.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
|
|
182
|
-
else parts.push(`<p class="readiness">Under way:</p><ul class="bullets">${r.waiting.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
|
|
119
|
+
/** The nav's entries, in order: a page of its own, or a group holding its pages (`pages`), at the place of its first. */
|
|
120
|
+
export function tabs() {
|
|
121
|
+
const list = [];
|
|
122
|
+
for (const p of PAGES) {
|
|
123
|
+
if (!p.parent) { list.push({ id: p.id, title: p.title, pages: [p.id] }); continue; }
|
|
124
|
+
const tab = list.find((t) => t.id === p.parent);
|
|
125
|
+
if (tab) tab.pages.push(p.id); else list.push({ id: p.parent, title: GROUPS[p.parent]?.title ?? p.parent, pages: [p.id] });
|
|
183
126
|
}
|
|
184
|
-
return
|
|
127
|
+
return list;
|
|
185
128
|
}
|
|
186
129
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return `<div class="done" role="status"><p class="headline">${esc(l.line)}</p>${l.notes.map((x) => `<p class="note">${esc(x)}</p>`).join("")}` +
|
|
191
|
-
`<button type="button" data-dismiss-outcome>Dismiss</button></div>`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Tables just removed (`/api/remove`'s answer, `mason remove --json`'s shape): what `mason remove` prints. */
|
|
195
|
-
export function renderTablesRemoved(b) {
|
|
196
|
-
const r = removedReport(b.removed, b.file);
|
|
197
|
-
return `<div class="done" role="status"><p class="headline">${esc(r.headline)}</p>${rowsHtml(r.details)}${r.tips.map((x) => `<p class="tip">Tip: ${esc(x)}</p>`).join("")}` +
|
|
198
|
-
`<button type="button" data-dismiss-outcome>Dismiss</button></div>`;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
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>`;
|
|
202
|
-
|
|
203
|
-
/** The form that adds a table with the columns ticked (`--columns`) and, for one with no primary key, its key (`--key`).
|
|
204
|
-
* The ticks live in the document while the person ticks (the tables part is held still meanwhile: `createApp`);
|
|
205
|
-
* `ticked`, what a refused submit sent, writes them back when the refusal is said under the form. */
|
|
206
|
-
function pickForm(t, ticked = null) {
|
|
207
|
-
const name = entryName(t.table);
|
|
208
|
-
const boxes = (field) => t.columns.map((c) => tick(field, c, ticked?.[field]?.includes(c.name) ?? false)).join("");
|
|
209
|
-
return `<form class="pick" data-pick-form="${esc(t.table)}" autocomplete="off">` +
|
|
210
|
-
(t.key.length ? "" : `<fieldset><legend>Its key: the columns that tell its rows apart, as ${code(`mason add ${name} --key`)}</legend>${boxes("key")}</fieldset>`) +
|
|
211
|
-
`<fieldset><legend>Its columns, as ${code(`mason add ${name} --columns`)} (none ticked: as ${code(`mason add ${name}`)}, every column)</legend>${boxes("columns")}</fieldset>` +
|
|
212
|
-
`<button type="submit">Add ${esc(name)}</button> <button type="button" data-cancel-choose>Cancel</button></form>`;
|
|
213
|
-
}
|
|
130
|
+
/** One link of the nav: its icon and its label (the label is the link's name; on the icon rail, its tooltip). */
|
|
131
|
+
const navLink = (p, shown, href) =>
|
|
132
|
+
`<a href="${esc(href)}"${p.id === shown ? ` aria-current="page"` : ""}>${icon(NAV_ICONS[p.id] ?? "")}<span class="label">${esc(p.title)}</span></a>`;
|
|
214
133
|
|
|
215
134
|
/**
|
|
216
|
-
* THE
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* key, Remove (asking first, `ui.removing`) for an added one. `ui.choosing` is the table whose form is open (`ui.ticked`
|
|
220
|
-
* its boxes ticked: { columns, key }); `ui.outcome` what the last add or remove answered ({ table, added } |
|
|
221
|
-
* { table, removed } | { table, failure }), under its table.
|
|
135
|
+
* THE SIDEBAR'S NAV: a link per page, each with its icon, the page shown marked as the current page; a group (the
|
|
136
|
+
* Observability views) is a heading over its pages' links, always shown, each keeping the range they share — so the
|
|
137
|
+
* hash is the state, and a link followed keeps the time range chosen.
|
|
222
138
|
*/
|
|
223
|
-
export
|
|
224
|
-
const
|
|
225
|
-
if (
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const act = t.added
|
|
231
|
-
? removing === t.table
|
|
232
|
-
? `<span class="confirm">Remove ${esc(name)}? ${esc(removedReport([t.table], "").tips[0])}.</span> ` +
|
|
233
|
-
`<button type="button" class="danger" data-confirm-remove-table="${esc(t.table)}">Remove ${esc(name)}</button> <button type="button" data-cancel-remove-table>Cancel</button>`
|
|
234
|
-
: `<button type="button" data-remove-table="${esc(t.table)}">Remove</button>`
|
|
235
|
-
: t.reason === null
|
|
236
|
-
? `<button type="button" data-add-table="${esc(t.table)}">Add</button> <button type="button" data-choose="${esc(t.table)}">Columns…</button>`
|
|
237
|
-
: t.key.length ? "" : `<button type="button" data-choose="${esc(t.table)}">Name its key…</button>`;
|
|
238
|
-
const said = outcome?.table !== t.table ? ""
|
|
239
|
-
: under("failure" in outcome ? renderFailure(outcome.failure) : "added" in outcome ? renderTableAdded(outcome.added) : renderTablesRemoved(outcome.removed));
|
|
240
|
-
return `<tr class="${t.added ? "c-added" : t.reason ? "c-cannot" : "c-not"}"><th scope="row">${code(t.table)}</th>` +
|
|
241
|
-
`<td class="num">${esc(f.size)}</td><td>${esc(f.key)}</td><td class="state">${esc(f.state ?? "")}</td><td class="act">${act}</td></tr>` +
|
|
242
|
-
(choosing === t.table && !t.added ? under(pickForm(t, ticked)) : "") + said;
|
|
243
|
-
};
|
|
244
|
-
const added = s.tables.filter((t) => t.added).length;
|
|
245
|
-
// a refusal for a table the list does not have (dropped since the run read the catalog): said above the list
|
|
246
|
-
const stray = outcome !== null && "failure" in outcome && !s.tables.some((t) => t.table === outcome.table) ? renderFailure(outcome.failure) : "";
|
|
247
|
-
return `${stray}<p class="count">${n(added)} of ${n(s.tables.length)} added</p>` +
|
|
248
|
-
`<table class="catalog"><thead><tr><th scope="col">Table</th><th scope="col">Rows</th><th scope="col">Key</th><th scope="col"></th><th scope="col"></th></tr></thead>` +
|
|
249
|
-
`<tbody>${s.tables.map(line).join("")}</tbody></table>`;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** The heading's line: the database the run copies, and its sources file's name when that is another. */
|
|
253
|
-
export function renderWhere(s) {
|
|
254
|
-
return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
|
|
255
|
-
}
|
|
139
|
+
export const renderNav = (shown, range = DEFAULT_RANGE) => `<ul>${tabs().map((t) => {
|
|
140
|
+
const own = PAGES.find((p) => p.id === t.id);
|
|
141
|
+
if (own) return `<li>${navLink(own, shown, hashOf(own.id))}</li>`;
|
|
142
|
+
const pages = t.pages.map((id) => PAGES.find((p) => p.id === id));
|
|
143
|
+
return `<li class="group"><p class="group-title" id="nav-${esc(t.id)}">${esc(t.title)}</p><ul aria-labelledby="nav-${esc(t.id)}">` +
|
|
144
|
+
pages.map((p) => `<li>${navLink(p, shown, hashOf(p.id, { range }))}</li>`).join("") + `</ul></li>`;
|
|
145
|
+
}).join("")}</ul>`;
|
|
256
146
|
|
|
257
147
|
/**
|
|
258
|
-
* THE
|
|
259
|
-
*
|
|
148
|
+
* THE TIME RANGE OF A GROUP'S PAGE (the Observability views'), written beside the view's title in ONE header row (its
|
|
149
|
+
* section's `range-<id>`; the group's name is the sidebar's heading): a segmented control — each a link to the same view
|
|
150
|
+
* at that range, so the hash is the state and Back goes to the range before — Now first, the one that follows what
|
|
151
|
+
* happens (said "Live" beside it); any other is read once when chosen, so beside it are when it was read (`asOf`, a
|
|
152
|
+
* Date) and Refresh, which reads it again. "" for a page of its own.
|
|
260
153
|
*/
|
|
261
|
-
export function
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
154
|
+
export function renderSubnav(shown, range = DEFAULT_RANGE, keptDays = undefined, asOf = null) {
|
|
155
|
+
const p = PAGES.find((x) => x.id === shown);
|
|
156
|
+
if (!p?.parent) return "";
|
|
157
|
+
const r = rangeOf(range);
|
|
158
|
+
// a range past what the run keeps is shown disabled, saying why and what keeps it (the one chosen by its hash stays a link)
|
|
159
|
+
return `<div class="range" role="group" aria-label="Time range">${Object.entries(RANGES).map(([k, v]) => {
|
|
160
|
+
const o = rangeOffered(k, keptDays);
|
|
161
|
+
if (!o.shown && k !== r) return "";
|
|
162
|
+
if (!o.offered && k !== r) return `<span class="off" aria-disabled="true" title="${esc(o.why)}">${esc(v.label)}<span class="sr"> (${esc(o.why)})</span></span>`;
|
|
163
|
+
return `<a href="${esc(hashOf(shown, { range: k }))}"${k === r ? ` aria-current="true"` : ""}${v.live ? ` title="follows what happens: read every few seconds"` : ""}>${esc(v.label)}</a>`;
|
|
164
|
+
}).join("")}</div>` + (isLive(r) ? `<span class="live" title="read every few seconds"><span class="live-dot" aria-hidden="true"></span>Live</span>`
|
|
165
|
+
: `<span class="as-of">${asOf ? `<span class="dim" title="read once when chosen; Now follows what happens">as of ${esc(asOf.toISOString().slice(11, 19))} UTC</span> ` : ""}<button type="button" class="small" data-refresh-range>Refresh</button></span>`);
|
|
270
166
|
}
|
|
271
167
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
*/
|
|
276
|
-
export function renderAdded(a) {
|
|
277
|
-
const rows = [["url", a.url, "added-url"], ["user", a.user], ["password", a.password, "added-password"], ["database", a.database],
|
|
278
|
-
...(a.host !== undefined ? [["host", a.host], ["port", String(a.port)]] : []), ...(a.sslmode && a.sslmode !== "disable" ? [["ssl mode", a.sslmode]] : [])]
|
|
279
|
-
.filter(([, v]) => v !== undefined);
|
|
280
|
-
return `<div class="added" role="status"><p class="headline">The user ${esc(a.user)} added: its password is shown this once</p>` +
|
|
281
|
-
`<dl class="facts">${rows.map(([k, v, id]) => `<dt>${esc(k)}</dt><dd>${id ? `<code id="${id}">${esc(v)}</code> <button type="button" data-copy="${id}">Copy</button>` : esc(v)}</dd>`).join("")}</dl>` +
|
|
282
|
-
`<p class="tip">Paste it into the tool now: Mason keeps no copy of the password, and this page will not show it again. Lost, remove the user and add it again.</p>` +
|
|
283
|
-
`<button type="button" data-dismiss-added>I have copied it</button></div>`;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* WHY A REQUEST TO THE RUN FAILED, as a Failure: the server's own `failure` when it sent one; else what the status
|
|
288
|
-
* means here — 403, a cookie this run does not take (the run started again, with a new token); no answer at all, the
|
|
289
|
-
* run stopped. `status` null: fetch itself failed, `text` its error.
|
|
290
|
-
*/
|
|
291
|
-
export function failureOf(status, text) {
|
|
292
|
-
if (status !== null) {
|
|
293
|
-
let body = null;
|
|
294
|
-
// the guard answers its refusals as plain text, not JSON: read as text below
|
|
295
|
-
try { body = JSON.parse(text); } catch { body = null; }
|
|
296
|
-
if (body && typeof body.failure === "object" && body.failure !== null) return body.failure;
|
|
297
|
-
if (status === 403 || status === 421) {
|
|
298
|
-
return { code: "refused", message: "this page's cookie is not the running Mason's", why: text.trim() || `HTTP ${status}`,
|
|
299
|
-
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",
|
|
300
|
-
next: [{ command: "mason ui", what: "this page, for the Mason that is running" }] };
|
|
301
|
-
}
|
|
302
|
-
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" }] };
|
|
303
|
-
}
|
|
304
|
-
return { code: "unreachable", message: "Mason does not answer at this address", why: `the run stopped, or runs on another port now (${text})`,
|
|
305
|
-
fix: "mason start in the project's folder, then mason ui opens this page again: each run has its own address and token",
|
|
306
|
-
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" }] };
|
|
168
|
+
/** The heading's line: the database the run copies, and its sources file's name when that is another. */
|
|
169
|
+
function renderWhere(s) {
|
|
170
|
+
return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
|
|
307
171
|
}
|
|
308
172
|
|
|
309
|
-
/** The last-updated line: when the page last heard from the run, in the reader's time. */
|
|
310
|
-
export const updatedText = (at) => `Updated ${at.toLocaleTimeString("en-GB")}`;
|
|
311
|
-
|
|
312
|
-
// ------------------------------------------------------------------ what the page knows and does
|
|
313
|
-
|
|
314
173
|
/**
|
|
315
|
-
* THE PAGE'S STATE AND ACTIONS, over `fetch` (the browser's, or a test's): `
|
|
316
|
-
*
|
|
317
|
-
* `
|
|
318
|
-
* tables'; `view()` is the HTML of each part of the page, from what it knows now. Nothing here touches a document.
|
|
174
|
+
* THE PAGE'S STATE AND ACTIONS, over `fetch` (the browser's, or a test's): `show(id)` chooses the page shown, `poll`
|
|
175
|
+
* reads ITS reads that are due (no other page's), each page's actions are the app's own (addUser, addTable, …), and
|
|
176
|
+
* `view()` is the HTML of every part of the page, from what it knows now. Nothing here touches a document.
|
|
319
177
|
*/
|
|
320
|
-
export function createApp(fetchFn, now = () => new Date()) {
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
// each poll's state would be rewritten under the person — the form's ticks gone, the confirm's button replaced under
|
|
326
|
-
// 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
|
|
327
|
-
// refresh in between (its rows and its added marks may be minutes old; the Status part above is written from each
|
|
328
|
-
// poll as ever) — and is written from the latest state again once they Add, Cancel or confirm.
|
|
329
|
-
const hold = () => { m.held = m.choosing !== null || m.removing !== null ? m.held ?? m.state : null; if (m.choosing === null) m.ticked = null; };
|
|
330
|
-
/** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
|
|
178
|
+
export function createApp(fetchFn, now = () => new Date(), shown = "status") {
|
|
179
|
+
const asked = createAsk(fetchFn);
|
|
180
|
+
// what was read, by path: the last body, this read's failure (null when it answered), when it was asked
|
|
181
|
+
const store = new Map();
|
|
182
|
+
// every answer passes here: on a server's page a failure the page words itself is the tunnel's (`overTunnel`)
|
|
331
183
|
const ask = async (path, init) => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
catch (e) { return { failure: failureOf(null, e?.message ?? String(e)) }; }
|
|
335
|
-
const text = await res.text();
|
|
336
|
-
if (!res.ok) return { failure: failureOf(res.status, text) };
|
|
337
|
-
try { return { body: JSON.parse(text) }; }
|
|
338
|
-
catch (e) { return { failure: { code: "failed", message: `the run's answer to ${path} is not JSON: ${e.message}` } }; }
|
|
184
|
+
const r = await asked(path, init);
|
|
185
|
+
return "failure" in r && store.get("/api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
|
|
339
186
|
};
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
187
|
+
const first = parseHash(shown);
|
|
188
|
+
const m = { shown: first.id, range: rangeOf(first.params.range), pollFailure: null, updatedAt: null, rangeReadAt: null };
|
|
189
|
+
const put = (path, at, r) => {
|
|
190
|
+
const was = store.get(path);
|
|
191
|
+
store.set(path, { at, body: "body" in r ? r.body : was?.body, failure: "failure" in r ? r.failure : null, stale: false });
|
|
192
|
+
if ("body" in r) m.updatedAt = now();
|
|
345
193
|
};
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const added = (table) => m.state.tables.some((t) => t.table === table && t.added);
|
|
354
|
-
if (m.removing !== null && !added(m.removing)) m.removing = null;
|
|
355
|
-
if (m.choosing !== null && added(m.choosing)) m.choosing = null;
|
|
356
|
-
hold();
|
|
357
|
-
await readUsers();
|
|
194
|
+
const pages = new Map();
|
|
195
|
+
const afterRead = () => { for (const p of pages.values()) p.afterRead?.(); };
|
|
196
|
+
const refresh = async (paths) => {
|
|
197
|
+
const at = now().getTime();
|
|
198
|
+
const rs = await Promise.all(paths.map((p) => ask(p)));
|
|
199
|
+
paths.forEach((p, i) => put(p, at, rs[i]));
|
|
200
|
+
afterRead();
|
|
358
201
|
};
|
|
359
|
-
return {
|
|
202
|
+
const ctx = { ask, now, refresh, range: () => m.range, read: (path) => { const e = store.get(path); return e === undefined ? undefined : { body: e.body, failure: e.failure }; } };
|
|
203
|
+
for (const p of PAGES) pages.set(p.id, p.create(ctx));
|
|
204
|
+
const app = {
|
|
360
205
|
model: m,
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
206
|
+
pages,
|
|
207
|
+
/** Show what a hash (or a page's id) says: the page, the range when it is a group's page, and its parameters
|
|
208
|
+
* handed to the page (`route`); `env.doc` the document, for a control outside the parts. */
|
|
209
|
+
show(hash, env = {}) {
|
|
210
|
+
const r = parseHash(hash);
|
|
211
|
+
m.shown = r.id;
|
|
212
|
+
// what is shown is read again now, whatever its rate: a range that is not Now is read once, when it is chosen —
|
|
213
|
+
// and until it is, it says no "as of" (the last one was another page's, or another range's)
|
|
214
|
+
app.markStale();
|
|
215
|
+
m.rangeReadAt = null;
|
|
216
|
+
if (PAGES.find((p) => p.id === r.id)?.parent) m.range = rangeOf(r.params.range);
|
|
217
|
+
pages.get(r.id).route?.(r.params, env);
|
|
370
218
|
},
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
219
|
+
/** Every read due at the next poll (a range chosen again, Refresh): what it has stays shown until it is answered. */
|
|
220
|
+
markStale() { for (const e of store.values()) e.stale = true; },
|
|
221
|
+
/** Read the shown page's reads that are due: those at the poll's rate every time, the slower ones once their time is up. */
|
|
222
|
+
async poll() {
|
|
223
|
+
const page = pages.get(m.shown);
|
|
224
|
+
// the sidebar's project, demo badge and version are the state's: a page that does not read it (Users) has it read
|
|
225
|
+
// once a minute beside its own, so a page opened at its own address never shows an empty sidebar
|
|
226
|
+
const pageReads = page.reads();
|
|
227
|
+
const reads = pageReads.some((r) => r.path === SHELL_STATE.path) ? pageReads : [...pageReads, SHELL_STATE];
|
|
228
|
+
const at = now().getTime();
|
|
229
|
+
const due = reads.filter((r) => r.everyMs <= POLL_MS || !store.has(r.path) || store.get(r.path).stale || at - store.get(r.path).at >= r.everyMs - POLL_MS / 2);
|
|
230
|
+
const rs = await Promise.all(due.map((r) => ask(r.path)));
|
|
231
|
+
due.forEach((r, i) => put(r.path, at, rs[i]));
|
|
232
|
+
// a range that is not Now says when it was read: its reads are the once-only ones
|
|
233
|
+
if (due.some((r) => r.everyMs === Infinity)) m.rangeReadAt = now();
|
|
234
|
+
const own = reads[0] === undefined ? undefined : store.get(reads[0].path);
|
|
235
|
+
m.pollFailure = own?.failure ?? null;
|
|
236
|
+
afterRead();
|
|
383
237
|
},
|
|
384
|
-
dismissOutcome() { m.outcome = null; },
|
|
385
|
-
async addUser(name) {
|
|
386
|
-
m.actionFailure = null;
|
|
387
|
-
const r = await ask("/api/users", json("POST", { name }));
|
|
388
|
-
if ("failure" in r) { m.actionFailure = r.failure; return false; }
|
|
389
|
-
m.added = r.body;
|
|
390
|
-
await readUsers();
|
|
391
|
-
return true;
|
|
392
|
-
},
|
|
393
|
-
askRemove(name) { m.confirming = name; m.actionFailure = null; },
|
|
394
|
-
cancelRemove() { m.confirming = null; },
|
|
395
|
-
async confirmRemove(name) {
|
|
396
|
-
m.actionFailure = null;
|
|
397
|
-
const r = await ask(`/api/users/${encodeURIComponent(name)}`, json("DELETE"));
|
|
398
|
-
m.confirming = null;
|
|
399
|
-
if ("failure" in r) { m.actionFailure = r.failure; return false; }
|
|
400
|
-
await readUsers();
|
|
401
|
-
return true;
|
|
402
|
-
},
|
|
403
|
-
dismissAdded() { m.added = null; },
|
|
404
238
|
view() {
|
|
239
|
+
const st = store.get("/api/state")?.body;
|
|
405
240
|
return {
|
|
406
|
-
|
|
407
|
-
|
|
241
|
+
nav: renderNav(m.shown, m.range),
|
|
242
|
+
// the range control in the shown view's header row (each group page's own slot; the others empty)
|
|
243
|
+
...Object.fromEntries(PAGES.filter((p) => p.parent).map((p) => [`range-${p.id}`, p.id === m.shown ? renderSubnav(m.shown, m.range, st?.engine?.keptDays, m.rangeReadAt) : ""])),
|
|
244
|
+
where: st ? renderWhere(st) : "",
|
|
245
|
+
// the demo database is said beside its name, so a person never takes it for their own
|
|
246
|
+
badge: st?.source?.demo ? badge("demo", "accent") : "",
|
|
247
|
+
updated: m.updatedAt ? esc(updatedText(m.updatedAt, now())) : "",
|
|
248
|
+
// the run's version, as mason status's Mason line says it (engine.version)
|
|
249
|
+
version: typeof st?.engine?.version === "string" ? `Mason ${esc(st.engine.version)}` : "",
|
|
250
|
+
// the phone's menu button: both icons, the stylesheet shows the one its aria-expanded says
|
|
251
|
+
"menu-icon": icon("menu", "when-closed") + icon("x", "when-open"),
|
|
408
252
|
poll: m.pollFailure ? renderFailure(m.pollFailure) : "",
|
|
409
|
-
|
|
410
|
-
tables: m.state ? renderTables(m.held ?? m.state, { removing: m.removing, choosing: m.choosing, outcome: m.outcome, ticked: m.ticked }) : "",
|
|
411
|
-
users: m.usersFailure ? renderFailure(m.usersFailure) : m.users ? renderUsers(m.users, m.confirming) : "",
|
|
412
|
-
added: m.added ? renderAdded(m.added) : "",
|
|
413
|
-
action: m.actionFailure ? renderFailure(m.actionFailure) : "",
|
|
253
|
+
...Object.assign({}, ...[...pages.values()].map((p) => p.view())),
|
|
414
254
|
};
|
|
415
255
|
},
|
|
416
256
|
};
|
|
257
|
+
// each page's actions are the app's (a name used twice is a mistake: the later page's would hide the earlier's)
|
|
258
|
+
for (const [id, p] of pages) {
|
|
259
|
+
for (const [name, f] of Object.entries(p.actions ?? {})) {
|
|
260
|
+
if (name in app) throw new Error(`the ${id} page's action ${name} is already the app's`);
|
|
261
|
+
app[name] = f;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// the tables' and the users' state, where the tests have always read it
|
|
265
|
+
const of = (id, key) => ({ get: () => pages.get(id).model[key], enumerable: true });
|
|
266
|
+
Object.defineProperties(m, { confirming: of("users", "confirming"), added: of("users", "added"), removing: of("tables", "removing"), choosing: of("tables", "choosing") });
|
|
267
|
+
return app;
|
|
417
268
|
}
|
|
418
269
|
|
|
419
270
|
// ------------------------------------------------------------------ the browser
|
|
420
271
|
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
/** Wire the app to the page: each part written from `view()`, the buttons and the form, the poll while the tab is seen. */
|
|
272
|
+
/** Wire the app to the page: each part written from `view()`, the shown section from the hash, the buttons, forms and
|
|
273
|
+
* inputs to the shown page, and the poll while the tab is seen. */
|
|
425
274
|
export function start(doc, win) {
|
|
426
|
-
const app = createApp((path, init) => win.fetch(path, init));
|
|
275
|
+
const app = createApp((path, init) => win.fetch(path, init), () => new Date(), "status");
|
|
276
|
+
app.show(win.location.hash, { doc });
|
|
427
277
|
// what each part was last written, compared as written: the browser's innerHTML serialises attributes its own way
|
|
428
278
|
// (data-x becomes data-x=""), and a part rewritten at every poll replaces the button under a click
|
|
429
279
|
const written = new Map();
|
|
280
|
+
// a part is not rewritten while a pointer is pressed: a row whose age moves every poll would replace the button
|
|
281
|
+
// between its press and its release, and the click would be lost. At most PRESS_HOLD_MS: a press whose release the
|
|
282
|
+
// page never sees (released outside the window, a gesture the browser took over) froze every part for good
|
|
283
|
+
let pressedAt = null;
|
|
284
|
+
// the shown page's charts, drawn after each write (src/web/charts.js); a document that cannot hold one draws none
|
|
285
|
+
const charts = typeof doc.querySelectorAll === "function" && typeof win.getComputedStyle === "function" ? createCharts({ doc, win }) : null;
|
|
430
286
|
const paint = () => {
|
|
287
|
+
if (pressedAt !== null && Date.now() - pressedAt < PRESS_HOLD_MS) return;
|
|
288
|
+
pressedAt = null;
|
|
289
|
+
for (const p of PAGES) { const section = doc.getElementById(`page-${p.id}`); if (section) section.hidden = p.id !== app.model.shown; }
|
|
431
290
|
for (const [id, html] of Object.entries(app.view())) {
|
|
432
291
|
const el = doc.getElementById(id);
|
|
433
|
-
|
|
434
|
-
el
|
|
292
|
+
const was = written.get(id);
|
|
293
|
+
if (el === null || was === html) continue;
|
|
435
294
|
written.set(id, html);
|
|
295
|
+
// only the clock moved (an age, a running time: `<time data-tick>`): those texts set where they are, the part
|
|
296
|
+
// not replaced — a rewrite each second dropped what the reader had in it (a selection, the focus, a details' click)
|
|
297
|
+
if (was !== undefined && steady(was) === steady(html) && typeof el.querySelectorAll === "function") {
|
|
298
|
+
const texts = [...html.matchAll(TICK)].map((m) => m[2]);
|
|
299
|
+
const times = [...el.querySelectorAll("time[data-tick]")];
|
|
300
|
+
if (times.length === texts.length) { times.forEach((t, i) => { t.innerHTML = texts[i]; }); continue; }
|
|
301
|
+
}
|
|
302
|
+
el.innerHTML = html;
|
|
436
303
|
}
|
|
304
|
+
if (charts !== null) void charts.mount(app.pages.get(app.model.shown).charts?.() ?? {});
|
|
305
|
+
place();
|
|
306
|
+
};
|
|
307
|
+
// THE POPOVERS (a confirm, a form, an answer: `pop` of shared.js) placed beside their buttons after every write, and
|
|
308
|
+
// again on a scroll or a resize; a question or a form that has just opened takes the focus (a form's first box, a
|
|
309
|
+
// question's Cancel: never the destructive button, which Enter would press), and
|
|
310
|
+
// Escape answers it with its own Cancel (`data-pop-close`)
|
|
311
|
+
let dialogs = new Set();
|
|
312
|
+
const place = () => {
|
|
313
|
+
const now = new Set();
|
|
314
|
+
for (const el of placePops(doc, win)) {
|
|
315
|
+
if (el.getAttribute?.("role") !== "dialog") continue;
|
|
316
|
+
const key = `${el.dataset.anchor}|${el.getAttribute("aria-label") ?? ""}`;
|
|
317
|
+
now.add(key);
|
|
318
|
+
if (!dialogs.has(key)) (el.querySelector?.("input") ?? el.querySelector?.("[data-pop-close]"))?.focus?.({ preventScroll: true });
|
|
319
|
+
}
|
|
320
|
+
dialogs = now;
|
|
437
321
|
};
|
|
438
322
|
let busy = false;
|
|
323
|
+
// a poll asked while one is in flight (a Refresh, a link followed) runs when it ends, never dropped: what it was
|
|
324
|
+
// asked for may be what the one in flight did not read
|
|
325
|
+
let again = false;
|
|
439
326
|
const poll = async () => {
|
|
440
|
-
if (
|
|
327
|
+
if (doc.visibilityState !== "visible") return;
|
|
328
|
+
if (busy) { again = true; return; }
|
|
441
329
|
busy = true;
|
|
442
330
|
try { await app.poll(); } finally { busy = false; paint(); }
|
|
331
|
+
if (again) { again = false; await poll(); }
|
|
443
332
|
};
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
333
|
+
const shownPage = () => app.pages.get(app.model.shown);
|
|
334
|
+
// what a page answered an event with: the page written at once (what the action set before it asked: Syncing…), and
|
|
335
|
+
// again when it is answered
|
|
336
|
+
const act = async (r) => { paint(); await r; paint(); };
|
|
337
|
+
// THE PHONE'S MENU: the sidebar's nav as a sheet, opened by its button (shown below 640 px only). Open, the focus is
|
|
338
|
+
// on its first link; Escape, a click outside it, the focus leaving it or a link followed close it, the focus back on
|
|
339
|
+
// the button (but when the focus left by itself, to where it went)
|
|
340
|
+
const sidebar = doc.getElementById("sidebar");
|
|
341
|
+
const menuButton = doc.getElementById("menu-button");
|
|
342
|
+
let menuOpen = false;
|
|
343
|
+
const setMenu = (open, focus) => {
|
|
344
|
+
menuOpen = open;
|
|
345
|
+
if (open) sidebar?.setAttribute?.("data-open", ""); else sidebar?.removeAttribute?.("data-open");
|
|
346
|
+
menuButton?.setAttribute?.("aria-expanded", String(open));
|
|
347
|
+
if (focus) (open ? doc.getElementById("nav")?.querySelector?.("a") : menuButton)?.focus?.();
|
|
348
|
+
};
|
|
349
|
+
const inMenu = (t) => Boolean(t) && (sidebar?.contains?.(t) ?? false);
|
|
350
|
+
doc.addEventListener("keydown", (e) => {
|
|
351
|
+
if (e.key !== "Escape") return;
|
|
352
|
+
if (menuOpen) { e.preventDefault?.(); setMenu(false, true); return; }
|
|
353
|
+
const close = doc.querySelector?.(".pop.ask:not([hidden]) [data-pop-close]");
|
|
354
|
+
if (close) { e.preventDefault?.(); close.click(); }
|
|
449
355
|
});
|
|
450
|
-
|
|
451
|
-
// they are (FormData's getAll: the values of the boxes ticked, in the page's order)
|
|
356
|
+
doc.addEventListener("focusin", (e) => { if (menuOpen && !inMenu(e.target)) setMenu(false, false); });
|
|
452
357
|
doc.addEventListener("submit", async (e) => {
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
358
|
+
const r = shownPage().submit?.(e.target, win.FormData) ?? null;
|
|
359
|
+
if (r === null) return;
|
|
455
360
|
e.preventDefault();
|
|
456
|
-
|
|
457
|
-
await app.addTable(f.dataset.pickForm, ticked.getAll("columns"), ticked.getAll("key"));
|
|
458
|
-
paint();
|
|
361
|
+
await act(r);
|
|
459
362
|
});
|
|
460
363
|
doc.addEventListener("click", async (e) => {
|
|
461
364
|
const b = e.target.closest?.("button");
|
|
462
|
-
if (
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
476
|
-
|
|
477
|
-
catch (err) {
|
|
478
|
-
// the clipboard refused (a browser setting): the text is selected, for the reader's own copy
|
|
479
|
-
win.getSelection()?.selectAllChildren(doc.getElementById(b.dataset.copy));
|
|
480
|
-
b.textContent = `Select and copy it (${err?.message ?? err})`;
|
|
481
|
-
}
|
|
365
|
+
if (b?.dataset.menu !== undefined) { setMenu(!menuOpen, true); return; }
|
|
366
|
+
if (menuOpen && !inMenu(e.target)) { setMenu(false, true); return; }
|
|
367
|
+
// A DETAILS' SUMMARY: opened or closed by the click itself, never by the toggle event the browser fires after it —
|
|
368
|
+
// a repaint between the two (a pointerup's, a new row of a live log) replaced the element, and the event, fired on
|
|
369
|
+
// the old one, never reached this document: a request's + took several clicks to open. A link or a box inside a
|
|
370
|
+
// summary keeps its own click
|
|
371
|
+
const summary = b || e.target.closest?.("a, input, select, textarea, label") ? null : e.target.closest?.("summary");
|
|
372
|
+
const det = summary?.parentElement;
|
|
373
|
+
if (det?.tagName === "DETAILS") {
|
|
374
|
+
const r = shownPage().toggle?.({ id: det.id, dataset: det.dataset, open: !det.open, clicked: true }) ?? null;
|
|
375
|
+
if (r === null) return; // a details the page keeps no state of: the browser opens it, as ever
|
|
376
|
+
e.preventDefault?.();
|
|
377
|
+
// what was written there is no proof of what is shown (the element may disagree with the page's state): written again
|
|
378
|
+
for (const id of [...written.keys()]) if (doc.getElementById(id)?.contains?.(det)) written.delete(id);
|
|
379
|
+
await act(r);
|
|
482
380
|
return;
|
|
483
381
|
}
|
|
484
|
-
|
|
382
|
+
if (!b) return;
|
|
383
|
+
// Refresh beside a range that is not Now: everything shown read again
|
|
384
|
+
if (b.dataset.refreshRange !== undefined) { app.markStale(); await poll(); return; }
|
|
385
|
+
const r = shownPage().click?.(b.dataset, b, { doc, win }) ?? null;
|
|
386
|
+
if (r === null) return;
|
|
387
|
+
await act(r);
|
|
485
388
|
});
|
|
389
|
+
doc.addEventListener("input", (e) => {
|
|
390
|
+
if ((shownPage().input?.(e.target) ?? null) !== null) paint();
|
|
391
|
+
});
|
|
392
|
+
// a details opening does not bubble, so it is heard on the way down; the page reads what its opening needs at once
|
|
393
|
+
doc.addEventListener("toggle", async (e) => {
|
|
394
|
+
const r = shownPage().toggle?.(e.target) ?? null;
|
|
395
|
+
if (r === null) return;
|
|
396
|
+
await act(r);
|
|
397
|
+
}, true);
|
|
398
|
+
doc.addEventListener("pointerdown", () => { pressedAt = Date.now(); });
|
|
399
|
+
for (const type of ["pointerup", "pointercancel"]) doc.addEventListener(type, () => { pressedAt = null; win.setTimeout(paint, 0); });
|
|
400
|
+
win.addEventListener?.("hashchange", () => { if (menuOpen) setMenu(false, true); app.show(win.location.hash, { doc }); paint(); void poll(); });
|
|
401
|
+
win.addEventListener?.("scroll", place, { capture: true, passive: true });
|
|
402
|
+
win.addEventListener?.("resize", place);
|
|
403
|
+
if (charts !== null) {
|
|
404
|
+
win.addEventListener?.("resize", () => charts.resize());
|
|
405
|
+
win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => charts.retheme());
|
|
406
|
+
}
|
|
486
407
|
doc.addEventListener("visibilitychange", () => { void poll(); });
|
|
487
408
|
win.setInterval(() => { void poll(); }, POLL_MS);
|
|
409
|
+
paint();
|
|
488
410
|
void poll();
|
|
489
411
|
}
|
|
490
412
|
|