@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.
- package/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/web/app.js
CHANGED
|
@@ -1,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
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* the SHELL — a left sidebar (the project, then a nav of links with icons: Overview · Tables · Users, and the
|
|
4
|
+
* Observability group: Queries · Usage · Logs; at its foot when it was updated and the version; an icon rail on a narrow
|
|
5
|
+
* window, a menu on a phone) and one section per page, the one shown chosen by the URL's hash (`#overview`, the
|
|
6
|
+
* default; an unknown hash is the Overview; `#observability/queries?range=24h`, the shared time range of the three) —
|
|
7
|
+
* over the pages, each an ES module of src/web/pages/ rendered from the run's command server's JSON
|
|
8
|
+
* (src/cli/run-server.ts) in the words the commands print.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
const
|
|
72
|
+
/** The longest a pointer's press holds the page's writes (a click is well under it): see `pressedAt`. */
|
|
73
|
+
export const PRESS_HOLD_MS = 2000;
|
|
23
74
|
|
|
24
|
-
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
33
|
-
export const
|
|
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
|
|
36
|
-
|
|
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
|
-
/**
|
|
39
|
-
export const
|
|
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
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
160
|
-
*
|
|
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
|
|
163
|
-
|
|
164
|
-
return `<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`<
|
|
168
|
-
|
|
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
|
|
175
|
-
*
|
|
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
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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): `
|
|
217
|
-
*
|
|
218
|
-
* HTML of
|
|
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
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
284
|
-
|
|
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
|
-
|
|
296
|
-
el
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
|