@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
package/dist/src/web/app.js
CHANGED
|
@@ -1,490 +1,407 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE WEB PAGE OF `mason start` (`mason ui`; docs/WEB-UI.md, and .claude/rules/web-ui.md for whoever builds it out):
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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, 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, sourceRows, renderStatus } 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
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** A table just added (`/api/pick`'s answer, `mason add --json`'s `added` entry): the line and notes `mason add` prints. */
|
|
188
|
-
export function renderTableAdded(b) {
|
|
189
|
-
const l = addedLines(b, b.file);
|
|
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
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* THE DATABASE'S TABLES from `/api/state` `tables` (`mason tables --json`'s list): a line each — its size, its key, and
|
|
217
|
-
* added or why it cannot be added as it is, as `mason tables` prints them — with what can be done to it: Add (every
|
|
218
|
-
* column, as `mason add <table>`) and Columns… (the form) for a table not added, Name its key… for one with no primary
|
|
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.
|
|
222
|
-
*/
|
|
223
|
-
export function renderTables(s, ui = {}) {
|
|
224
|
-
const { removing = null, choosing = null, outcome = null, ticked = null } = ui;
|
|
225
|
-
if (!s.tables.length) return `<p class="dim">${esc(s.source.database)} has no table this user can read.</p>`;
|
|
226
|
-
const under = (html) => `<tr class="under"><td colspan="5">${html}</td></tr>`;
|
|
227
|
-
const line = (t) => {
|
|
228
|
-
const f = tableFacts(t);
|
|
229
|
-
const name = entryName(t.table);
|
|
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>`;
|
|
127
|
+
return list;
|
|
250
128
|
}
|
|
251
129
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
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>`;
|
|
256
133
|
|
|
257
134
|
/**
|
|
258
|
-
* THE
|
|
259
|
-
*
|
|
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.
|
|
260
138
|
*/
|
|
261
|
-
export
|
|
262
|
-
|
|
263
|
-
return `<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
`<
|
|
267
|
-
|
|
268
|
-
`<button type="button" class="danger" data-confirm-remove="${esc(u.name)}">Remove ${esc(u.name)}</button> <button type="button" data-cancel-remove>Cancel</button>`
|
|
269
|
-
: `<button type="button" data-remove="${esc(u.name)}">Remove</button>`}</td></tr>`).join("")}</tbody></table>`;
|
|
270
|
-
}
|
|
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>`;
|
|
271
146
|
|
|
272
147
|
/**
|
|
273
|
-
* A
|
|
274
|
-
*
|
|
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.
|
|
275
153
|
*/
|
|
276
|
-
export function
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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>`);
|
|
284
166
|
}
|
|
285
167
|
|
|
286
|
-
/**
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
/** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
|
|
331
|
-
const ask = async (path, init) => {
|
|
332
|
-
let res;
|
|
333
|
-
try { res = await fetchFn(path, { credentials: "same-origin", ...init }); }
|
|
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}` } }; }
|
|
178
|
+
export function createApp(fetchFn, now = () => new Date(), shown = "status") {
|
|
179
|
+
const ask = 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
|
+
const first = parseHash(shown);
|
|
183
|
+
const m = { shown: first.id, range: rangeOf(first.params.range), pollFailure: null, updatedAt: null, rangeReadAt: null };
|
|
184
|
+
const put = (path, at, r) => {
|
|
185
|
+
const was = store.get(path);
|
|
186
|
+
store.set(path, { at, body: "body" in r ? r.body : was?.body, failure: "failure" in r ? r.failure : null, stale: false });
|
|
187
|
+
if ("body" in r) m.updatedAt = now();
|
|
339
188
|
};
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const poll = async () => {
|
|
348
|
-
const [st, slow, refused] = await Promise.all([ask("/api/state"), ask("/api/slow"), ask("/api/refused")]);
|
|
349
|
-
if ("failure" in st) { m.pollFailure = st.failure; return; }
|
|
350
|
-
m.state = st.body; m.pollFailure = null; m.updatedAt = now();
|
|
351
|
-
m.requests = { slow: "failure" in slow ? slow : slow.body, refused: "failure" in refused ? refused : refused.body };
|
|
352
|
-
// a table removed or added elsewhere (mason remove, mason add) takes its confirm or its form with it
|
|
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();
|
|
189
|
+
const pages = new Map();
|
|
190
|
+
const afterRead = () => { for (const p of pages.values()) p.afterRead?.(); };
|
|
191
|
+
const refresh = async (paths) => {
|
|
192
|
+
const at = now().getTime();
|
|
193
|
+
const rs = await Promise.all(paths.map((p) => ask(p)));
|
|
194
|
+
paths.forEach((p, i) => put(p, at, rs[i]));
|
|
195
|
+
afterRead();
|
|
358
196
|
};
|
|
359
|
-
return {
|
|
197
|
+
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 }; } };
|
|
198
|
+
for (const p of PAGES) pages.set(p.id, p.create(ctx));
|
|
199
|
+
const app = {
|
|
360
200
|
model: m,
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
201
|
+
pages,
|
|
202
|
+
/** Show what a hash (or a page's id) says: the page, the range when it is a group's page, and its parameters
|
|
203
|
+
* handed to the page (`route`); `env.doc` the document, for a control outside the parts. */
|
|
204
|
+
show(hash, env = {}) {
|
|
205
|
+
const r = parseHash(hash);
|
|
206
|
+
m.shown = r.id;
|
|
207
|
+
// what is shown is read again now, whatever its rate: a range that is not Now is read once, when it is chosen —
|
|
208
|
+
// and until it is, it says no "as of" (the last one was another page's, or another range's)
|
|
209
|
+
app.markStale();
|
|
210
|
+
m.rangeReadAt = null;
|
|
211
|
+
if (PAGES.find((p) => p.id === r.id)?.parent) m.range = rangeOf(r.params.range);
|
|
212
|
+
pages.get(r.id).route?.(r.params, env);
|
|
370
213
|
},
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
214
|
+
/** Every read due at the next poll (a range chosen again, Refresh): what it has stays shown until it is answered. */
|
|
215
|
+
markStale() { for (const e of store.values()) e.stale = true; },
|
|
216
|
+
/** 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. */
|
|
217
|
+
async poll() {
|
|
218
|
+
const page = pages.get(m.shown);
|
|
219
|
+
// the sidebar's project, demo badge and version are the state's: a page that does not read it (Users) has it read
|
|
220
|
+
// once a minute beside its own, so a page opened at its own address never shows an empty sidebar
|
|
221
|
+
const pageReads = page.reads();
|
|
222
|
+
const reads = pageReads.some((r) => r.path === SHELL_STATE.path) ? pageReads : [...pageReads, SHELL_STATE];
|
|
223
|
+
const at = now().getTime();
|
|
224
|
+
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);
|
|
225
|
+
const rs = await Promise.all(due.map((r) => ask(r.path)));
|
|
226
|
+
due.forEach((r, i) => put(r.path, at, rs[i]));
|
|
227
|
+
// a range that is not Now says when it was read: its reads are the once-only ones
|
|
228
|
+
if (due.some((r) => r.everyMs === Infinity)) m.rangeReadAt = now();
|
|
229
|
+
const own = reads[0] === undefined ? undefined : store.get(reads[0].path);
|
|
230
|
+
m.pollFailure = own?.failure ?? null;
|
|
231
|
+
afterRead();
|
|
383
232
|
},
|
|
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
233
|
view() {
|
|
234
|
+
const st = store.get("/api/state")?.body;
|
|
405
235
|
return {
|
|
406
|
-
|
|
407
|
-
|
|
236
|
+
nav: renderNav(m.shown, m.range),
|
|
237
|
+
// the range control in the shown view's header row (each group page's own slot; the others empty)
|
|
238
|
+
...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) : ""])),
|
|
239
|
+
where: st ? renderWhere(st) : "",
|
|
240
|
+
// the demo database is said beside its name, so a person never takes it for their own
|
|
241
|
+
badge: st?.source?.demo ? badge("demo", "accent") : "",
|
|
242
|
+
updated: m.updatedAt ? esc(updatedText(m.updatedAt, now())) : "",
|
|
243
|
+
// the run's version, as mason status's Mason line says it (engine.version)
|
|
244
|
+
version: typeof st?.engine?.version === "string" ? `Mason ${esc(st.engine.version)}` : "",
|
|
245
|
+
// the phone's menu button: both icons, the stylesheet shows the one its aria-expanded says
|
|
246
|
+
"menu-icon": icon("menu", "when-closed") + icon("x", "when-open"),
|
|
408
247
|
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) : "",
|
|
248
|
+
...Object.assign({}, ...[...pages.values()].map((p) => p.view())),
|
|
414
249
|
};
|
|
415
250
|
},
|
|
416
251
|
};
|
|
252
|
+
// each page's actions are the app's (a name used twice is a mistake: the later page's would hide the earlier's)
|
|
253
|
+
for (const [id, p] of pages) {
|
|
254
|
+
for (const [name, f] of Object.entries(p.actions ?? {})) {
|
|
255
|
+
if (name in app) throw new Error(`the ${id} page's action ${name} is already the app's`);
|
|
256
|
+
app[name] = f;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// the tables' and the users' state, where the tests have always read it
|
|
260
|
+
const of = (id, key) => ({ get: () => pages.get(id).model[key], enumerable: true });
|
|
261
|
+
Object.defineProperties(m, { confirming: of("users", "confirming"), added: of("users", "added"), removing: of("tables", "removing"), choosing: of("tables", "choosing") });
|
|
262
|
+
return app;
|
|
417
263
|
}
|
|
418
264
|
|
|
419
265
|
// ------------------------------------------------------------------ the browser
|
|
420
266
|
|
|
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. */
|
|
267
|
+
/** Wire the app to the page: each part written from `view()`, the shown section from the hash, the buttons, forms and
|
|
268
|
+
* inputs to the shown page, and the poll while the tab is seen. */
|
|
425
269
|
export function start(doc, win) {
|
|
426
|
-
const app = createApp((path, init) => win.fetch(path, init));
|
|
270
|
+
const app = createApp((path, init) => win.fetch(path, init), () => new Date(), "status");
|
|
271
|
+
app.show(win.location.hash, { doc });
|
|
427
272
|
// what each part was last written, compared as written: the browser's innerHTML serialises attributes its own way
|
|
428
273
|
// (data-x becomes data-x=""), and a part rewritten at every poll replaces the button under a click
|
|
429
274
|
const written = new Map();
|
|
275
|
+
// a part is not rewritten while a pointer is pressed: a row whose age moves every poll would replace the button
|
|
276
|
+
// between its press and its release, and the click would be lost. At most PRESS_HOLD_MS: a press whose release the
|
|
277
|
+
// page never sees (released outside the window, a gesture the browser took over) froze every part for good
|
|
278
|
+
let pressedAt = null;
|
|
279
|
+
// the shown page's charts, drawn after each write (src/web/charts.js); a document that cannot hold one draws none
|
|
280
|
+
const charts = typeof doc.querySelectorAll === "function" && typeof win.getComputedStyle === "function" ? createCharts({ doc, win }) : null;
|
|
430
281
|
const paint = () => {
|
|
282
|
+
if (pressedAt !== null && Date.now() - pressedAt < PRESS_HOLD_MS) return;
|
|
283
|
+
pressedAt = null;
|
|
284
|
+
for (const p of PAGES) { const section = doc.getElementById(`page-${p.id}`); if (section) section.hidden = p.id !== app.model.shown; }
|
|
431
285
|
for (const [id, html] of Object.entries(app.view())) {
|
|
432
286
|
const el = doc.getElementById(id);
|
|
433
|
-
|
|
434
|
-
el
|
|
287
|
+
const was = written.get(id);
|
|
288
|
+
if (el === null || was === html) continue;
|
|
435
289
|
written.set(id, html);
|
|
290
|
+
// only the clock moved (an age, a running time: `<time data-tick>`): those texts set where they are, the part
|
|
291
|
+
// not replaced — a rewrite each second dropped what the reader had in it (a selection, the focus, a details' click)
|
|
292
|
+
if (was !== undefined && steady(was) === steady(html) && typeof el.querySelectorAll === "function") {
|
|
293
|
+
const texts = [...html.matchAll(TICK)].map((m) => m[2]);
|
|
294
|
+
const times = [...el.querySelectorAll("time[data-tick]")];
|
|
295
|
+
if (times.length === texts.length) { times.forEach((t, i) => { t.innerHTML = texts[i]; }); continue; }
|
|
296
|
+
}
|
|
297
|
+
el.innerHTML = html;
|
|
436
298
|
}
|
|
299
|
+
if (charts !== null) void charts.mount(app.pages.get(app.model.shown).charts?.() ?? {});
|
|
300
|
+
place();
|
|
301
|
+
};
|
|
302
|
+
// THE POPOVERS (a confirm, a form, an answer: `pop` of shared.js) placed beside their buttons after every write, and
|
|
303
|
+
// 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
|
|
304
|
+
// question's Cancel: never the destructive button, which Enter would press), and
|
|
305
|
+
// Escape answers it with its own Cancel (`data-pop-close`)
|
|
306
|
+
let dialogs = new Set();
|
|
307
|
+
const place = () => {
|
|
308
|
+
const now = new Set();
|
|
309
|
+
for (const el of placePops(doc, win)) {
|
|
310
|
+
if (el.getAttribute?.("role") !== "dialog") continue;
|
|
311
|
+
const key = `${el.dataset.anchor}|${el.getAttribute("aria-label") ?? ""}`;
|
|
312
|
+
now.add(key);
|
|
313
|
+
if (!dialogs.has(key)) (el.querySelector?.("input") ?? el.querySelector?.("[data-pop-close]"))?.focus?.({ preventScroll: true });
|
|
314
|
+
}
|
|
315
|
+
dialogs = now;
|
|
437
316
|
};
|
|
438
317
|
let busy = false;
|
|
318
|
+
// a poll asked while one is in flight (a Refresh, a link followed) runs when it ends, never dropped: what it was
|
|
319
|
+
// asked for may be what the one in flight did not read
|
|
320
|
+
let again = false;
|
|
439
321
|
const poll = async () => {
|
|
440
|
-
if (
|
|
322
|
+
if (doc.visibilityState !== "visible") return;
|
|
323
|
+
if (busy) { again = true; return; }
|
|
441
324
|
busy = true;
|
|
442
325
|
try { await app.poll(); } finally { busy = false; paint(); }
|
|
326
|
+
if (again) { again = false; await poll(); }
|
|
443
327
|
};
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
328
|
+
const shownPage = () => app.pages.get(app.model.shown);
|
|
329
|
+
// what a page answered an event with: the page written at once (what the action set before it asked: Syncing…), and
|
|
330
|
+
// again when it is answered
|
|
331
|
+
const act = async (r) => { paint(); await r; paint(); };
|
|
332
|
+
// THE PHONE'S MENU: the sidebar's nav as a sheet, opened by its button (shown below 640 px only). Open, the focus is
|
|
333
|
+
// on its first link; Escape, a click outside it, the focus leaving it or a link followed close it, the focus back on
|
|
334
|
+
// the button (but when the focus left by itself, to where it went)
|
|
335
|
+
const sidebar = doc.getElementById("sidebar");
|
|
336
|
+
const menuButton = doc.getElementById("menu-button");
|
|
337
|
+
let menuOpen = false;
|
|
338
|
+
const setMenu = (open, focus) => {
|
|
339
|
+
menuOpen = open;
|
|
340
|
+
if (open) sidebar?.setAttribute?.("data-open", ""); else sidebar?.removeAttribute?.("data-open");
|
|
341
|
+
menuButton?.setAttribute?.("aria-expanded", String(open));
|
|
342
|
+
if (focus) (open ? doc.getElementById("nav")?.querySelector?.("a") : menuButton)?.focus?.();
|
|
343
|
+
};
|
|
344
|
+
const inMenu = (t) => Boolean(t) && (sidebar?.contains?.(t) ?? false);
|
|
345
|
+
doc.addEventListener("keydown", (e) => {
|
|
346
|
+
if (e.key !== "Escape") return;
|
|
347
|
+
if (menuOpen) { e.preventDefault?.(); setMenu(false, true); return; }
|
|
348
|
+
const close = doc.querySelector?.(".pop.ask:not([hidden]) [data-pop-close]");
|
|
349
|
+
if (close) { e.preventDefault?.(); close.click(); }
|
|
449
350
|
});
|
|
450
|
-
|
|
451
|
-
// they are (FormData's getAll: the values of the boxes ticked, in the page's order)
|
|
351
|
+
doc.addEventListener("focusin", (e) => { if (menuOpen && !inMenu(e.target)) setMenu(false, false); });
|
|
452
352
|
doc.addEventListener("submit", async (e) => {
|
|
453
|
-
const
|
|
454
|
-
if (
|
|
353
|
+
const r = shownPage().submit?.(e.target, win.FormData) ?? null;
|
|
354
|
+
if (r === null) return;
|
|
455
355
|
e.preventDefault();
|
|
456
|
-
|
|
457
|
-
await app.addTable(f.dataset.pickForm, ticked.getAll("columns"), ticked.getAll("key"));
|
|
458
|
-
paint();
|
|
356
|
+
await act(r);
|
|
459
357
|
});
|
|
460
358
|
doc.addEventListener("click", async (e) => {
|
|
461
359
|
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
|
-
}
|
|
360
|
+
if (b?.dataset.menu !== undefined) { setMenu(!menuOpen, true); return; }
|
|
361
|
+
if (menuOpen && !inMenu(e.target)) { setMenu(false, true); return; }
|
|
362
|
+
// A DETAILS' SUMMARY: opened or closed by the click itself, never by the toggle event the browser fires after it —
|
|
363
|
+
// a repaint between the two (a pointerup's, a new row of a live log) replaced the element, and the event, fired on
|
|
364
|
+
// the old one, never reached this document: a request's + took several clicks to open. A link or a box inside a
|
|
365
|
+
// summary keeps its own click
|
|
366
|
+
const summary = b || e.target.closest?.("a, input, select, textarea, label") ? null : e.target.closest?.("summary");
|
|
367
|
+
const det = summary?.parentElement;
|
|
368
|
+
if (det?.tagName === "DETAILS") {
|
|
369
|
+
const r = shownPage().toggle?.({ id: det.id, dataset: det.dataset, open: !det.open, clicked: true }) ?? null;
|
|
370
|
+
if (r === null) return; // a details the page keeps no state of: the browser opens it, as ever
|
|
371
|
+
e.preventDefault?.();
|
|
372
|
+
// what was written there is no proof of what is shown (the element may disagree with the page's state): written again
|
|
373
|
+
for (const id of [...written.keys()]) if (doc.getElementById(id)?.contains?.(det)) written.delete(id);
|
|
374
|
+
await act(r);
|
|
482
375
|
return;
|
|
483
376
|
}
|
|
484
|
-
|
|
377
|
+
if (!b) return;
|
|
378
|
+
// Refresh beside a range that is not Now: everything shown read again
|
|
379
|
+
if (b.dataset.refreshRange !== undefined) { app.markStale(); await poll(); return; }
|
|
380
|
+
const r = shownPage().click?.(b.dataset, b, { doc, win }) ?? null;
|
|
381
|
+
if (r === null) return;
|
|
382
|
+
await act(r);
|
|
485
383
|
});
|
|
384
|
+
doc.addEventListener("input", (e) => {
|
|
385
|
+
if ((shownPage().input?.(e.target) ?? null) !== null) paint();
|
|
386
|
+
});
|
|
387
|
+
// a details opening does not bubble, so it is heard on the way down; the page reads what its opening needs at once
|
|
388
|
+
doc.addEventListener("toggle", async (e) => {
|
|
389
|
+
const r = shownPage().toggle?.(e.target) ?? null;
|
|
390
|
+
if (r === null) return;
|
|
391
|
+
await act(r);
|
|
392
|
+
}, true);
|
|
393
|
+
doc.addEventListener("pointerdown", () => { pressedAt = Date.now(); });
|
|
394
|
+
for (const type of ["pointerup", "pointercancel"]) doc.addEventListener(type, () => { pressedAt = null; win.setTimeout(paint, 0); });
|
|
395
|
+
win.addEventListener?.("hashchange", () => { if (menuOpen) setMenu(false, true); app.show(win.location.hash, { doc }); paint(); void poll(); });
|
|
396
|
+
win.addEventListener?.("scroll", place, { capture: true, passive: true });
|
|
397
|
+
win.addEventListener?.("resize", place);
|
|
398
|
+
if (charts !== null) {
|
|
399
|
+
win.addEventListener?.("resize", () => charts.resize());
|
|
400
|
+
win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => charts.retheme());
|
|
401
|
+
}
|
|
486
402
|
doc.addEventListener("visibilitychange", () => { void poll(); });
|
|
487
403
|
win.setInterval(() => { void poll(); }, POLL_MS);
|
|
404
|
+
paint();
|
|
488
405
|
void poll();
|
|
489
406
|
}
|
|
490
407
|
|