@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.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -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
- * one page, three sections, rendered from the run's command server's JSON (src/cli/run-server.ts) in the words the
4
- * commands print —
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
- * STATUS GET /api/state, /api/slow, /api/refused: what `mason status` prints, polled every 2 s while the tab is seen
7
- * TABLES GET /api/state `tables`, POST /api/pick, POST /api/remove: what `mason tables`, `mason add` and `mason remove` do
8
- * USERS GET /api/users, POST /api/users, DELETE /api/users/<name>: what `mason user list|add|remove` does
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
- * Plain JavaScript, an ES module, no framework and no build step: the server serves this file as it is, under a
11
- * `default-src 'self'` policy. The renderers are PURE FUNCTIONS OF THE JSON, returning HTML strings (every value
12
- * escaped), exported so tests/web-page.test.ts runs them under node; `createApp` holds what the page knows and does,
13
- * over a `fetch` it is handed; `start` (a browser only) wires them to the document. The page never holds the run's
14
- * token: the browser's cookie carries it (src/cli/security.ts).
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
- // ------------------------------------------------------------------ helpers, DOM-free
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
- const n = (x) => x.toLocaleString("en-US");
23
- const firstLine = (s) => s.split("\n")[0].trim();
24
-
25
- /** A span of seconds as a person reads it: `45 s`, `12 min`, `2 h 5 min` (src/cli/readiness.ts `duration`, held equal by the tests). */
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
- /** A share (0 to 1) as the commands print it beside a bar: whole percent, rounded down (`12%`). */
34
- export const percent = (share) => `${Math.floor(Math.max(0, Math.min(1, share)) * 100)}%`;
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
- /** The time a copy has left, as the commands print it: `~12 min left`; "" when nothing is measured yet. */
37
- export const leftText = (secondsLeft) => secondsLeft === null || secondsLeft === undefined ? "" : `~${duration(secondsLeft)} left`;
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
- /** A table as the commands name it: `orders` in public, else `schema.table` (src/cli/sources.ts `entryName`). */
40
- export const entryName = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
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
- * AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
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
- /** A table's facts as `mason tables` says them on its line (src/cli/commands.ts `tableFacts`): its size, its key, and
87
- * added or why it cannot be added as it is (null: neither). */
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
- /** What `mason add` prints for a table added, from `/api/pick`'s answer (src/cli/commands.ts `addedLines`): its line,
94
- * then its notes — the key added to the columns asked, the secret-looking columns left out. */
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
- /** What `mason remove` prints for the tables removed (src/cli/commands.ts `removedReport`). */
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
- * `POST /api/pick`'s body for a table added: the columns ticked as `--columns`, the key's as `--key`, each left out when
113
- * none is ticked (none: every column, as `mason add <table>` copies; no key: its primary key). What the lists may hold
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 const pickBody = (table, columns = [], key = []) => ({ table, picked: true, ...(columns.length ? { columns } : {}), ...(key.length ? { key } : {}) });
117
-
118
- /** `mason status`'s rows for the database (src/cli/commands.ts `sourceRows`): its sources file, its URL as the run masked it, how its changes arrive. */
119
- export function sourceRows(s) {
120
- return [
121
- ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
122
- ["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
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
- // ------------------------------------------------------------------ the renderers: JSON in, HTML out
131
-
132
- const rowsHtml = (rows) => `<dl class="facts">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>`;
133
- const code = (s) => `<code>${esc(s)}</code>`;
134
-
135
- /**
136
- * A FAILURE AS THE CLI SAYS IT (src/cli/command.ts `failureText`): what is wrong after a ✗, then `why` and `fix` as
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
- /** How many of `xs` were in the last day, or why they could not be read. */
149
- const countLine = (label, r, count, tail) => "failure" in r
150
- ? `<p class="count">${esc(label)}: could not read them (${esc(firstLine(r.failure.message))}; ${code("mason logs")} has the detail)</p>`
151
- : `<p class="count">${esc(label)}: <strong>${n(count(r))}</strong> in the last day${tail(r)}</p>`;
152
-
153
- /**
154
- * THE STATUS SECTION from `/api/state` (and the counts of `/api/slow`, `/api/refused`; each `{ failure }` when it could
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 parts.join("");
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
- /** 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
- }
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 USERS from `/api/users` (`mason user list --json`'s shape): a line each — its name, when it was added, whether it
259
- * connects — with a Remove button; the one being removed asks first (`confirming`, its name).
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 function renderUsers(list, confirming = null) {
262
- if (!list.users.length) return `<p class="dim">No users yet.</p>`;
263
- return `<table class="users"><thead><tr><th scope="col">User</th><th scope="col">Added</th><th scope="col"></th></tr></thead><tbody>${list.users.map((u) =>
264
- `<tr><th scope="row">${code(u.name)}${u.live ? "" : ` <span class="warn">does not connect: the API key was rotated since it was added</span>`}</th>` +
265
- `<td class="dim">${esc(u.addedAt.slice(0, 16).replace("T", " "))} UTC · id ${esc(u.id)}</td>` +
266
- `<td class="act">${confirming === u.name
267
- ? `<span class="confirm">Remove ${esc(u.name)}? It connects no more, and the sessions it holds end at their next statement.</span> ` +
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 USER JUST ADDED (`mason user add --json`'s shape): its password, shown this once, in a box with a copy button,
274
- * beside the URL to paste — the rows `mason user add` prints. Mason keeps no copy, so it is never shown again.
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 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>`;
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
- * 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): `poll` reads the status and the users,
316
- * `addUser` / `askRemove` / `cancelRemove` / `confirmRemove` / `dismissAdded` are the users' buttons, `addTable` /
317
- * `choose` / `cancelChoose` / `askRemoveTable` / `cancelRemoveTable` / `confirmRemoveTable` / `dismissOutcome` the
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 m = { state: null, requests: {}, users: null, usersFailure: null, pollFailure: null, actionFailure: null, added: null, confirming: null, updatedAt: null,
322
- removing: null, choosing: null, outcome: null, ticked: null, held: null };
323
- // WHILE A FORM IS OPEN OR A REMOVE ASKS, THE TABLES ARE RENDERED FROM THE STATE THEY WERE OPENED OVER (`held`): the
324
- // run reads the catalog again every few seconds, and a live database's row estimates move, so a part written from
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. */
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 readUsers = async () => {
341
- const u = await ask("/api/users");
342
- if ("failure" in u) { m.usersFailure = u.failure; return; }
343
- m.users = u.body; m.usersFailure = null;
344
- if (m.confirming !== null && !m.users.users.some((x) => x.name === m.confirming)) m.confirming = null;
345
- };
346
- const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
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
- poll,
362
- /** Add a table (`mason add`): the answer under its line, then the state read again — the Status shows its copy starting. */
363
- async addTable(table, columns = [], key = []) {
364
- const r = await ask("/api/pick", json("POST", pickBody(table, columns, key)));
365
- // refused: said under the form, which is written again with the boxes the person had ticked
366
- if ("failure" in r) { m.outcome = { table, failure: r.failure }; if (m.choosing === table) m.ticked = { columns, key }; return false; }
367
- m.outcome = { table, added: r.body }; m.choosing = null; hold();
368
- await poll();
369
- return true;
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
- choose(table) { m.choosing = table; m.removing = null; m.ticked = null; hold(); },
372
- cancelChoose() { m.choosing = null; hold(); },
373
- askRemoveTable(table) { m.removing = table; m.choosing = null; hold(); },
374
- cancelRemoveTable() { m.removing = null; hold(); },
375
- /** Remove a table (`mason remove`), once asked: the answer under its line, then the state read again. */
376
- async confirmRemoveTable(table) {
377
- const r = await ask("/api/remove", json("POST", { tables: [table] }));
378
- m.removing = null; hold();
379
- if ("failure" in r) { m.outcome = { table, failure: r.failure }; return false; }
380
- m.outcome = { table, removed: r.body };
381
- await poll();
382
- return true;
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
- where: m.state ? renderWhere(m.state) : "",
407
- updated: m.updatedAt ? esc(updatedText(m.updatedAt)) : "",
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
- status: m.state ? renderStatus(m.state, m.requests) : m.pollFailure ? "" : `<p class="dim">Asking Mason…</p>`,
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
- /** How often the status is asked while the tab is seen. */
422
- export const POLL_MS = 2000;
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
- if (el === null || written.get(id) === html) continue;
434
- el.innerHTML = html;
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 (busy || doc.visibilityState !== "visible") return;
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
- doc.getElementById("add-user").addEventListener("submit", async (e) => {
445
- e.preventDefault();
446
- const input = e.target.elements.name;
447
- if (await app.addUser(input.value.trim())) input.value = "";
448
- paint();
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
- // a table's form is written into the tables part: its submit is caught where it bubbles to, the ticked boxes sent as
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 f = e.target;
454
- if (f.dataset?.pickForm === undefined) return;
353
+ const r = shownPage().submit?.(e.target, win.FormData) ?? null;
354
+ if (r === null) return;
455
355
  e.preventDefault();
456
- const ticked = new win.FormData(f);
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 (!b) return;
463
- if (b.dataset.remove !== undefined) app.askRemove(b.dataset.remove);
464
- else if (b.dataset.cancelRemove !== undefined) app.cancelRemove();
465
- else if (b.dataset.confirmRemove !== undefined) await app.confirmRemove(b.dataset.confirmRemove);
466
- else if (b.dataset.dismissAdded !== undefined) app.dismissAdded();
467
- else if (b.dataset.addTable !== undefined) await app.addTable(b.dataset.addTable);
468
- else if (b.dataset.choose !== undefined) app.choose(b.dataset.choose);
469
- else if (b.dataset.cancelChoose !== undefined) app.cancelChoose();
470
- else if (b.dataset.removeTable !== undefined) app.askRemoveTable(b.dataset.removeTable);
471
- else if (b.dataset.cancelRemoveTable !== undefined) app.cancelRemoveTable();
472
- else if (b.dataset.confirmRemoveTable !== undefined) await app.confirmRemoveTable(b.dataset.confirmRemoveTable);
473
- else if (b.dataset.dismissOutcome !== undefined) app.dismissOutcome();
474
- else if (b.dataset.copy !== undefined) {
475
- const text = doc.getElementById(b.dataset.copy)?.textContent ?? "";
476
- try { await win.navigator.clipboard.writeText(text); b.textContent = "Copied"; }
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
- paint();
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