@mason-data/cli 0.6.2 → 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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  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 +1290 -223
  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/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,335 +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, two 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
- * 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.
8
15
  *
9
- * Plain JavaScript, an ES module, no framework and no build step: the server serves this file as it is, under a
10
- * `default-src 'self'` policy. The renderers are PURE FUNCTIONS OF THE JSON, returning HTML strings (every value
11
- * escaped), exported so tests/web-page.test.ts runs them under node; `createApp` holds what the page knows and does,
12
- * over a `fetch` it is handed; `start` (a browser only) wires them to the document. The page never holds the run's
13
- * 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.
14
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";
15
63
 
16
- // ------------------------------------------------------------------ helpers, DOM-free
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 };
17
66
 
18
- /** A value as HTML text: every character that means something in HTML escaped. */
19
- export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
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");
20
71
 
21
- const n = (x) => x.toLocaleString("en-US");
22
- const firstLine = (s) => s.split("\n")[0].trim();
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;
23
74
 
24
- /** 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). */
25
- export function duration(seconds) {
26
- const s = Math.max(1, Math.round(seconds));
27
- if (s < 90) return `${s} s`;
28
- const m = Math.round(s / 60);
29
- return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
30
- }
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";
31
82
 
32
- /** A share (0 to 1) as the commands print it beside a bar: whole percent, rounded down (`12%`). */
33
- export const percent = (share) => `${Math.floor(Math.max(0, Math.min(1, share)) * 100)}%`;
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];
34
85
 
35
- /** The time a copy has left, as the commands print it: `~12 min left`; "" when nothing is measured yet. */
36
- export const leftText = (secondsLeft) => secondsLeft === null || secondsLeft === undefined ? "" : `~${duration(secondsLeft)} left`;
86
+ /** The groups a parent stands for: its title, the heading of its pages in the nav. */
87
+ const GROUPS = { observability: { title: "Observability" } };
37
88
 
38
- /** A table as the commands name it: `orders` in public, else `schema.table` (src/cli/sources.ts `entryName`). */
39
- export const entryName = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
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" };
40
91
 
41
- /**
42
- * What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
43
- * page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
44
- */
45
- export function copyingText(copied, rowsEstimate, p) {
46
- const shown = copied + (p?.rowsReading ?? 0);
47
- const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
48
- const past = estimate !== null && shown > estimate;
49
- const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
50
- const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
51
- const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
52
- if (p === undefined) return { fraction, text: rowsText(copied) };
53
- const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
54
- const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
55
- if (p.rowsPerSecond === null) {
56
- const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
57
- return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to copy`}` : `${rowsText(copied)} · resumed, ${first}` };
58
- }
59
- const parts = [rowsText(shown)];
60
- const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
61
- if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
62
- else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
63
- parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
64
- if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
65
- return { fraction, text: parts.join(" · ") };
66
- }
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" };
67
94
 
68
- /**
69
- * AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
70
- * its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says.
71
- */
72
- export function tableLine(t) {
73
- if (t.error) return { state: "failed", fraction: 1, what: `its copy failed: ${firstLine(t.error)}` };
74
- if (t.copy?.phase === "copying") {
75
- const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
76
- return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `copying ${c.text}` : c.text };
77
- }
78
- if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its copy to start" };
79
- if (t.unsynced) {
80
- 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` };
81
- }
82
- return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied${t.copy.retired ? ` · ${t.copy.retired}` : ""}` };
83
- }
84
-
85
- /** `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. */
86
- export function sourceRows(s) {
87
- return [
88
- ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
89
- ["Database", `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
90
- ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
91
- `(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
92
- : `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · mason start --replication reads the database's change log instead`],
93
- ];
94
- }
95
-
96
- // ------------------------------------------------------------------ the renderers: JSON in, HTML out
97
-
98
- const rowsHtml = (rows) => `<dl class="facts">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>`;
99
- const code = (s) => `<code>${esc(s)}</code>`;
95
+ const hashOfPage = (p) => p.hash ?? p.id;
100
96
 
101
97
  /**
102
- * A FAILURE AS THE CLI SAYS IT (src/cli/command.ts `failureText`): what is wrong after a ✗, then `why` and `fix` as
103
- * rows, then Next: each command and what it does. `f` is the server's `failure` ({ code, message, why?, fix?, next }).
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`).
104
100
  */
105
- export function renderFailure(f) {
106
- const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
107
- const next = f.next ?? [];
108
- return `<div class="failure" role="alert"><p class="message"><span class="mark" aria-hidden="true">✗</span> ${esc(f.message)}</p>` +
109
- (rows.length ? `<dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl>` : "") +
110
- (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>` : "") +
111
- "</div>";
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 };
112
108
  }
113
109
 
114
- /** How many of `xs` were in the last day, or why they could not be read. */
115
- const countLine = (label, r, count, tail) => "failure" in r
116
- ? `<p class="count">${esc(label)}: could not read them (${esc(firstLine(r.failure.message))}; ${code("mason logs")} has the detail)</p>`
117
- : `<p class="count">${esc(label)}: <strong>${n(count(r))}</strong> in the last day${tail(r)}</p>`;
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}` : ""}`;
117
+ }
118
118
 
119
- /**
120
- * THE STATUS SECTION from `/api/state` (and the counts of `/api/slow`, `/api/refused`; each `{ failure }` when it could
121
- * not be read): what `mason status` prints — the database, each added table's copy (its bar, percent and the line
122
- * `mason wait` shows), the warnings, the slow and refused counts, and readiness. Never the engine's name.
123
- */
124
- export function renderStatus(s, requests = {}) {
125
- const parts = [rowsHtml([...sourceRows(s), ...s.warnings.map((w) => ["Warning", w])])];
126
- const picked = s.tables.filter((t) => t.added);
127
- if (!picked.length) {
128
- parts.push(`<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>`,
129
- `<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} copies one.</p>`);
130
- return parts.join("");
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] });
131
126
  }
132
- // as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
133
- const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
134
- const lines = picked.map((t) => ({ t, l: tableLine(t) }))
135
- .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));
136
- parts.push(`<h3>Tables (${picked.length} of ${s.tables.length} added)</h3>`,
137
- `<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 }) =>
138
- `<tr class="t-${l.state}"><th scope="row">${code(t.table)}</th>` +
139
- `<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>` +
140
- `<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>`);
141
- if (requests.slow) parts.push(countLine("Slow queries", requests.slow, (r) => r.slow.length, (r) => ` (${n(r.slowMs / 1000)} s or more)`));
142
- if (requests.refused) parts.push(countLine("Refused queries", requests.refused, (r) => r.total ?? r.refused.length, () => ""));
143
- if (requests.slow || requests.refused) parts.push(`<p class="dim">${code("mason status")} lists them; ${code("mason logs --requests")} every one.</p>`);
144
- const r = s.readiness;
145
- if (r) {
146
- if (r.ready) parts.push(`<p class="readiness ok">Ready: every added table is copied and served.</p>`);
147
- 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>`);
148
- else parts.push(`<p class="readiness">Under way:</p><ul class="bullets">${r.waiting.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
149
- }
150
- return parts.join("");
127
+ return list;
151
128
  }
152
129
 
153
- /** The heading's line: the database the run copies, and its sources file's name when that is another. */
154
- export function renderWhere(s) {
155
- return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
156
- }
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>`;
157
133
 
158
134
  /**
159
- * THE USERS from `/api/users` (`mason user list --json`'s shape): a line each — its name, when it was added, whether it
160
- * 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.
161
138
  */
162
- export function renderUsers(list, confirming = null) {
163
- if (!list.users.length) return `<p class="dim">No users yet.</p>`;
164
- 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) =>
165
- `<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>` +
166
- `<td class="dim">${esc(u.addedAt.slice(0, 16).replace("T", " "))} UTC · id ${esc(u.id)}</td>` +
167
- `<td class="act">${confirming === u.name
168
- ? `<span class="confirm">Remove ${esc(u.name)}? It connects no more, and the sessions it holds end at their next statement.</span> ` +
169
- `<button type="button" class="danger" data-confirm-remove="${esc(u.name)}">Remove ${esc(u.name)}</button> <button type="button" data-cancel-remove>Cancel</button>`
170
- : `<button type="button" data-remove="${esc(u.name)}">Remove</button>`}</td></tr>`).join("")}</tbody></table>`;
171
- }
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>`;
172
146
 
173
147
  /**
174
- * A USER JUST ADDED (`mason user add --json`'s shape): its password, shown this once, in a box with a copy button,
175
- * 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.
176
153
  */
177
- export function renderAdded(a) {
178
- const rows = [["url", a.url, "added-url"], ["user", a.user], ["password", a.password, "added-password"], ["database", a.database],
179
- ...(a.host !== undefined ? [["host", a.host], ["port", String(a.port)]] : []), ...(a.sslmode && a.sslmode !== "disable" ? [["ssl mode", a.sslmode]] : [])]
180
- .filter(([, v]) => v !== undefined);
181
- return `<div class="added" role="status"><p class="headline">The user ${esc(a.user)} added: its password is shown this once</p>` +
182
- `<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>` +
183
- `<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>` +
184
- `<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>`);
185
166
  }
186
167
 
187
- /**
188
- * WHY A REQUEST TO THE RUN FAILED, as a Failure: the server's own `failure` when it sent one; else what the status
189
- * means here — 403, a cookie this run does not take (the run started again, with a new token); no answer at all, the
190
- * run stopped. `status` null: fetch itself failed, `text` its error.
191
- */
192
- export function failureOf(status, text) {
193
- if (status !== null) {
194
- let body = null;
195
- // the guard answers its refusals as plain text, not JSON: read as text below
196
- try { body = JSON.parse(text); } catch { body = null; }
197
- if (body && typeof body.failure === "object" && body.failure !== null) return body.failure;
198
- if (status === 403 || status === 421) {
199
- return { code: "refused", message: "this page's cookie is not the running Mason's", why: text.trim() || `HTTP ${status}`,
200
- 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",
201
- next: [{ command: "mason ui", what: "this page, for the Mason that is running" }] };
202
- }
203
- 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" }] };
204
- }
205
- return { code: "unreachable", message: "Mason does not answer at this address", why: `the run stopped, or runs on another port now (${text})`,
206
- fix: "mason start in the project's folder, then mason ui opens this page again: each run has its own address and token",
207
- 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)}` : ""}`;
208
171
  }
209
172
 
210
- /** The last-updated line: when the page last heard from the run, in the reader's time. */
211
- export const updatedText = (at) => `Updated ${at.toLocaleTimeString("en-GB")}`;
212
-
213
- // ------------------------------------------------------------------ what the page knows and does
214
-
215
173
  /**
216
- * THE PAGE'S STATE AND ACTIONS, over `fetch` (the browser's, or a test's): `poll` reads the status and the users,
217
- * `addUser` / `askRemove` / `cancelRemove` / `confirmRemove` / `dismissAdded` are the users' buttons; `view()` is the
218
- * 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.
219
177
  */
220
- export function createApp(fetchFn, now = () => new Date()) {
221
- const m = { state: null, requests: {}, users: null, usersFailure: null, pollFailure: null, actionFailure: null, added: null, confirming: null, updatedAt: null };
222
- /** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
223
- const ask = async (path, init) => {
224
- let res;
225
- try { res = await fetchFn(path, { credentials: "same-origin", ...init }); }
226
- catch (e) { return { failure: failureOf(null, e?.message ?? String(e)) }; }
227
- const text = await res.text();
228
- if (!res.ok) return { failure: failureOf(res.status, text) };
229
- try { return { body: JSON.parse(text) }; }
230
- 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();
231
188
  };
232
- const readUsers = async () => {
233
- const u = await ask("/api/users");
234
- if ("failure" in u) { m.usersFailure = u.failure; return; }
235
- m.users = u.body; m.usersFailure = null;
236
- if (m.confirming !== null && !m.users.users.some((x) => x.name === m.confirming)) m.confirming = null;
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();
237
196
  };
238
- const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
239
- 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 = {
240
200
  model: m,
241
- async poll() {
242
- const [st, slow, refused] = await Promise.all([ask("/api/state"), ask("/api/slow"), ask("/api/refused")]);
243
- if ("failure" in st) { m.pollFailure = st.failure; return; }
244
- m.state = st.body; m.pollFailure = null; m.updatedAt = now();
245
- m.requests = { slow: "failure" in slow ? slow : slow.body, refused: "failure" in refused ? refused : refused.body };
246
- await readUsers();
247
- },
248
- async addUser(name) {
249
- m.actionFailure = null;
250
- const r = await ask("/api/users", json("POST", { name }));
251
- if ("failure" in r) { m.actionFailure = r.failure; return false; }
252
- m.added = r.body;
253
- await readUsers();
254
- 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);
255
213
  },
256
- askRemove(name) { m.confirming = name; m.actionFailure = null; },
257
- cancelRemove() { m.confirming = null; },
258
- async confirmRemove(name) {
259
- m.actionFailure = null;
260
- const r = await ask(`/api/users/${encodeURIComponent(name)}`, json("DELETE"));
261
- m.confirming = null;
262
- if ("failure" in r) { m.actionFailure = r.failure; return false; }
263
- await readUsers();
264
- 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();
265
232
  },
266
- dismissAdded() { m.added = null; },
267
233
  view() {
234
+ const st = store.get("/api/state")?.body;
268
235
  return {
269
- where: m.state ? renderWhere(m.state) : "",
270
- 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"),
271
247
  poll: m.pollFailure ? renderFailure(m.pollFailure) : "",
272
- status: m.state ? renderStatus(m.state, m.requests) : m.pollFailure ? "" : `<p class="dim">Asking Mason…</p>`,
273
- users: m.usersFailure ? renderFailure(m.usersFailure) : m.users ? renderUsers(m.users, m.confirming) : "",
274
- added: m.added ? renderAdded(m.added) : "",
275
- action: m.actionFailure ? renderFailure(m.actionFailure) : "",
248
+ ...Object.assign({}, ...[...pages.values()].map((p) => p.view())),
276
249
  };
277
250
  },
278
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;
279
263
  }
280
264
 
281
265
  // ------------------------------------------------------------------ the browser
282
266
 
283
- /** How often the status is asked while the tab is seen. */
284
- export const POLL_MS = 2000;
285
-
286
- /** 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. */
287
269
  export function start(doc, win) {
288
- 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 });
289
272
  // what each part was last written, compared as written: the browser's innerHTML serialises attributes its own way
290
273
  // (data-x becomes data-x=""), and a part rewritten at every poll replaces the button under a click
291
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;
292
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; }
293
285
  for (const [id, html] of Object.entries(app.view())) {
294
286
  const el = doc.getElementById(id);
295
- if (el === null || written.get(id) === html) continue;
296
- el.innerHTML = html;
287
+ const was = written.get(id);
288
+ if (el === null || was === html) continue;
297
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;
298
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;
299
316
  };
300
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;
301
321
  const poll = async () => {
302
- if (busy || doc.visibilityState !== "visible") return;
322
+ if (doc.visibilityState !== "visible") return;
323
+ if (busy) { again = true; return; }
303
324
  busy = true;
304
325
  try { await app.poll(); } finally { busy = false; paint(); }
326
+ if (again) { again = false; await poll(); }
327
+ };
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?.();
305
343
  };
306
- doc.getElementById("add-user").addEventListener("submit", async (e) => {
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(); }
350
+ });
351
+ doc.addEventListener("focusin", (e) => { if (menuOpen && !inMenu(e.target)) setMenu(false, false); });
352
+ doc.addEventListener("submit", async (e) => {
353
+ const r = shownPage().submit?.(e.target, win.FormData) ?? null;
354
+ if (r === null) return;
307
355
  e.preventDefault();
308
- const input = e.target.elements.name;
309
- if (await app.addUser(input.value.trim())) input.value = "";
310
- paint();
356
+ await act(r);
311
357
  });
312
358
  doc.addEventListener("click", async (e) => {
313
359
  const b = e.target.closest?.("button");
314
- if (!b) return;
315
- if (b.dataset.remove !== undefined) app.askRemove(b.dataset.remove);
316
- else if (b.dataset.cancelRemove !== undefined) app.cancelRemove();
317
- else if (b.dataset.confirmRemove !== undefined) await app.confirmRemove(b.dataset.confirmRemove);
318
- else if (b.dataset.dismissAdded !== undefined) app.dismissAdded();
319
- else if (b.dataset.copy !== undefined) {
320
- const text = doc.getElementById(b.dataset.copy)?.textContent ?? "";
321
- try { await win.navigator.clipboard.writeText(text); b.textContent = "Copied"; }
322
- catch (err) {
323
- // the clipboard refused (a browser setting): the text is selected, for the reader's own copy
324
- win.getSelection()?.selectAllChildren(doc.getElementById(b.dataset.copy));
325
- b.textContent = `Select and copy it (${err?.message ?? err})`;
326
- }
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);
327
375
  return;
328
376
  }
329
- 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);
383
+ });
384
+ doc.addEventListener("input", (e) => {
385
+ if ((shownPage().input?.(e.target) ?? null) !== null) paint();
330
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
+ }
331
402
  doc.addEventListener("visibilitychange", () => { void poll(); });
332
403
  win.setInterval(() => { void poll(); }, POLL_MS);
404
+ paint();
333
405
  void poll();
334
406
  }
335
407