@mason-data/cli 0.10.0 → 0.11.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.
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `MASON_UI_SOCKET` (mason serve): THE WEB PAGE ON A UNIX SOCKET THE OPERATOR NAMES, beside its loopback port, for a
3
+ * process on the same machine (a supervisor, a local reverse proxy) that should reach the page without a port published
4
+ * or a `docker exec` per request.
5
+ *
6
+ * WHO CAN CONNECT is who can write the socket, so the socket is made mode 0600 in a folder only Mason's user can write
7
+ * (owned by it, no group or other write), never through a symbolic link: anything else refuses the start, with the way
8
+ * out. A path that is already there is replaced only when it is a socket of Mason's user that nobody answers on (a run
9
+ * that crashed left it); a file of any other kind, a link, another user's socket, or one a process answers on is never
10
+ * removed. The socket is removed when the run stops.
11
+ *
12
+ * WHAT A REQUEST ON IT IS TRUSTED WITH: exactly what one through `mason ui --bridge` is, because it reaches the run's own
13
+ * request handler (src/cli/run-server.ts), behind the same guard (src/cli/security.ts): its Host, the run's token or a
14
+ * tunnel's ticket and cookie, its Origin. Arriving on the socket proves nothing: no request is taken for a local
15
+ * browser, or let in at all, for where it came from.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import * as http from "node:http";
19
+ import * as net from "node:net";
20
+ import * as path from "node:path";
21
+ export const UI_SOCKET_SETTING = "MASON_UI_SOCKET";
22
+ /** The longest path a Unix socket may have: `sun_path` holds 108 bytes on Linux, 104 on macOS, each with its NUL. */
23
+ const maxSocketBytes = (platform) => platform === "darwin" ? 103 : 107;
24
+ /** How long a socket at the path has to answer before it is taken for one nobody serves: a live run answers at once. */
25
+ const PROBE_MS = 1_000;
26
+ /** The socket MASON_UI_SOCKET names, absolute; null when it is unset (or set empty); or what is wrong with it. */
27
+ export function uiSocketSetting(env, cwd, platform = process.platform) {
28
+ const set = env[UI_SOCKET_SETTING];
29
+ if (!set)
30
+ return null;
31
+ if (platform === "win32")
32
+ return { problem: `${UI_SOCKET_SETTING} is set, and Windows has no Unix socket for the page: unset it (mason ui --ssh <user@server> opens the page from your machine)` };
33
+ const file = path.resolve(cwd, set);
34
+ const bytes = Buffer.byteLength(file);
35
+ if (bytes > maxSocketBytes(platform))
36
+ return { problem: `${UI_SOCKET_SETTING}=${JSON.stringify(set)} is ${bytes} bytes as a full path, and a Unix socket's path is at most ${maxSocketBytes(platform)}: name a shorter one` };
37
+ return file;
38
+ }
39
+ const refused = (file, problem, fix) => ({ message: `${UI_SOCKET_SETTING}=${file}: ${problem}`, code: "refused", why: "the socket is how a process on this machine reaches the web page, so only Mason's user may make or replace it", fix });
40
+ export const octal = (mode) => `0${(mode & 0o777).toString(8)}`;
41
+ /** Whether a process answers on the socket at `file`: true, false (nothing listens), or the error that left it unknown. */
42
+ function answers(file) {
43
+ return new Promise((resolve) => {
44
+ const c = net.connect(file);
45
+ const timer = setTimeout(() => { c.destroy(); resolve(new Error(`it neither accepted nor refused a connection in ${PROBE_MS} ms`)); }, PROBE_MS);
46
+ c.once("connect", () => { clearTimeout(timer); c.destroy(); resolve(true); });
47
+ c.once("error", (e) => { clearTimeout(timer); resolve(e.code === "ECONNREFUSED" ? false : e); });
48
+ });
49
+ }
50
+ /**
51
+ * THE FOLDER OF A FILE ONLY MASON'S USER MAY MAKE (the page's socket, the run's file, src/cli/ui-run-file.ts): a folder,
52
+ * not a link to one, owned by Mason's user, which no group or other can write; null, or what is wrong and the way out.
53
+ * Fails closed: a folder it cannot read is refused. `what` names the file in the way out ("socket", "run file").
54
+ */
55
+ export function ownFolderProblem(file, what, uid) {
56
+ const dir = path.dirname(file);
57
+ const ownFolder = `mkdir -m 700 ${dir} as Mason's user, or name a ${what} in a folder that is only its own (the data folder's .mason/ is)`;
58
+ let d;
59
+ try {
60
+ d = fs.lstatSync(dir);
61
+ }
62
+ catch (e) {
63
+ return { problem: `its folder ${dir} cannot be read: ${e.code === "ENOENT" ? "it does not exist" : e.message}`, fix: ownFolder };
64
+ }
65
+ if (d.isSymbolicLink()) {
66
+ let real;
67
+ try {
68
+ real = fs.realpathSync(dir);
69
+ }
70
+ catch (e) {
71
+ real = `it names nothing that can be read: ${e.message}`;
72
+ }
73
+ return { problem: `its folder ${dir} is a symbolic link`, fix: `name the ${what} in the folder itself (${real}), not through a link to it` };
74
+ }
75
+ if (!d.isDirectory())
76
+ return { problem: `${dir} is not a folder`, fix: ownFolder };
77
+ if (d.uid !== uid)
78
+ return { problem: `its folder ${dir} is owned by uid ${d.uid}, not Mason's user (uid ${uid})`, fix: ownFolder };
79
+ if ((d.mode & 0o022) !== 0)
80
+ return { problem: `its folder ${dir} can be written by others than Mason's user (mode ${octal(d.mode)})`, fix: `chmod go-w ${dir}, or ${ownFolder}` };
81
+ return null;
82
+ }
83
+ /**
84
+ * The folder and the path held to the bar (the header): null when the socket may be made, after a stale one is removed
85
+ * (`said` is told), or the refusal. Fails closed: a fact it cannot read refuses.
86
+ */
87
+ export async function prepareUiSocket(file, said, uid = process.getuid()) {
88
+ const folder = ownFolderProblem(file, "socket", uid);
89
+ if (folder !== null)
90
+ return refused(file, folder.problem, folder.fix);
91
+ let f;
92
+ try {
93
+ f = fs.lstatSync(file);
94
+ }
95
+ catch (e) {
96
+ if (e.code === "ENOENT")
97
+ return null;
98
+ return refused(file, `it cannot be read: ${e.message}`, "name another path");
99
+ }
100
+ const notMine = "remove it yourself if it is not needed, or name another path";
101
+ if (f.isSymbolicLink())
102
+ return refused(file, "it is a symbolic link, and Mason never replaces one", notMine);
103
+ if (!f.isSocket())
104
+ return refused(file, `it is ${f.isDirectory() ? "a folder" : "a file"}, not a socket, and Mason removes only a socket a run of its own left`, notMine);
105
+ if (f.uid !== uid)
106
+ return refused(file, `it is a socket of uid ${f.uid}, not of Mason's user (uid ${uid})`, notMine);
107
+ const live = await answers(file);
108
+ if (live === true)
109
+ return refused(file, "a process answers on it: another Mason, or something else, serves it now", "stop that process, or name another path");
110
+ if (live instanceof Error)
111
+ return refused(file, `whether a process answers on it could not be told (${live.message})`, notMine);
112
+ try {
113
+ fs.unlinkSync(file);
114
+ }
115
+ catch (e) {
116
+ return refused(file, `the stale socket could not be removed: ${e.message}`, notMine);
117
+ }
118
+ said(`replaced the socket a run that did not stop left at ${file}`);
119
+ return null;
120
+ }
121
+ /**
122
+ * Listens on `file` (`prepareUiSocket` first) with mode 0600 from the moment it exists (the umask around the bind),
123
+ * checked after. Until `attach`, a request is answered 503, as /healthz says starting; then each one is the run's
124
+ * server's own request, behind its guard.
125
+ */
126
+ export async function listenOnUiSocket(file, said, uid = process.getuid()) {
127
+ let run = null;
128
+ const server = http.createServer((req, res) => {
129
+ if (run !== null) {
130
+ run.emit("request", req, res);
131
+ return;
132
+ }
133
+ res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Mason is starting: the page answers once it does\n");
134
+ });
135
+ const before = process.umask(0o177);
136
+ let listening;
137
+ try {
138
+ listening = new Promise((resolve) => { server.once("error", resolve); server.listen(file, () => resolve(null)); });
139
+ }
140
+ finally {
141
+ process.umask(before);
142
+ }
143
+ const err = await listening;
144
+ if (err !== null)
145
+ return refused(file, `the socket cannot be made: ${err.message}`, "name a path in a folder only Mason's user can write");
146
+ let made;
147
+ try {
148
+ fs.chmodSync(file, 0o600);
149
+ made = fs.lstatSync(file);
150
+ }
151
+ catch (e) {
152
+ server.close();
153
+ return refused(file, `the socket's mode cannot be set: ${e.message}`, "name a path in a folder only Mason's user can write");
154
+ }
155
+ if (!made.isSocket() || made.uid !== uid || (made.mode & 0o777) !== 0o600) {
156
+ server.close();
157
+ return refused(file, `the socket is not Mason's alone once made (mode ${octal(made.mode)}, uid ${made.uid})`, "name a path in a folder only Mason's user can write");
158
+ }
159
+ let closed = false;
160
+ return {
161
+ file,
162
+ attach: (r) => { run = r; },
163
+ close: () => {
164
+ if (closed)
165
+ return;
166
+ closed = true;
167
+ server.close();
168
+ server.closeAllConnections();
169
+ // the listening socket is unlinked as it closes; whatever is still at the path is removed only when it is this one
170
+ try {
171
+ const now = fs.lstatSync(file);
172
+ if (now.isSocket() && now.ino === made.ino && now.dev === made.dev)
173
+ fs.unlinkSync(file);
174
+ }
175
+ catch (e) {
176
+ if (e.code !== "ENOENT")
177
+ said(`the web page's socket ${file} could not be removed: ${e.message}`);
178
+ return;
179
+ }
180
+ said(`removed the web page's socket ${file}`);
181
+ },
182
+ };
183
+ }
@@ -497,6 +497,11 @@ table.catalog tbody tr:hover > td.act { background: var(--hover); }
497
497
  tr.removed > * { color: var(--muted); }
498
498
  form.formcard { margin: 0 0 var(--space-5); padding: var(--space-3) var(--space-4); background: var(--surface); border: 1px solid var(--frame); border-radius: var(--radius-lg); box-shadow: var(--drop); }
499
499
  form.formcard input { width: 18em; }
500
+ /* the Users form's Role: its label and select wrap together on a phone, the select shrinking to the card (a long role's
501
+ name cut in the closed box, whole in the open list), so the page never scrolls sideways */
502
+ #add-user .role-field { display: inline-flex; align-items: center; gap: var(--space-2); max-width: 100%; min-width: 0; vertical-align: middle; }
503
+ #add-user .role-field label { flex: none; }
504
+ #add-user .role-field select { flex: 0 1 auto; min-width: 0; max-width: 100%; text-overflow: ellipsis; }
500
505
  /* ---------------------------------------------------------------- Connect: the URL, its psql line, Create a user */
501
506
  /* a box whose title sits on its top edge, as a fieldset's legend does (Connect, Getting started, a chart's name) */
502
507
  .connect { background: var(--surface); border: 1px solid var(--frame); border-radius: var(--radius-lg); box-shadow: var(--drop); padding: var(--space-4) var(--space-5); margin: var(--space-6) 0 var(--space-6); }
@@ -62,7 +62,7 @@ import { page as usagePage } from "./pages/usage.js";
62
62
  import { page as syncsPage } from "./pages/syncs.js";
63
63
 
64
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 };
65
+ const SHELL_STATE = { path: "api/state", everyMs: 60_000 };
66
66
 
67
67
  /** A text that moves with the clock (an age, a running time), and a part's HTML without them: `paint` sets those
68
68
  * texts in place when nothing else changed. */
@@ -76,7 +76,7 @@ export const PRESS_HOLD_MS = 2000;
76
76
  export * from "./shared.js";
77
77
  export { copyingText, tableLine, keptAge, keptWords, sourceRows, renderStatus, renderShare, createShare } from "./pages/status.js";
78
78
  export { tableFacts, addedLines, removedReport, pickBody, columnsPath, renderTableAdded, renderTablesRemoved, renderTables } from "./pages/tables.js";
79
- export { renderUsers, renderAdded, attributesText } from "./pages/users.js";
79
+ export { renderUsers, renderAdded, renderAddUser, attributesText } from "./pages/users.js";
80
80
  export { renderRequests } from "./pages/queries.js";
81
81
  export { renderRunning, renderSessions, renderClients, renderSlowest, queriesHref } from "./pages/usage.js";
82
82
 
@@ -182,7 +182,7 @@ export function createApp(fetchFn, now = () => new Date(), shown = "status") {
182
182
  // every answer passes here: on a server's page a failure the page words itself is the tunnel's (`overTunnel`)
183
183
  const ask = async (path, init) => {
184
184
  const r = await asked(path, init);
185
- return "failure" in r && store.get("/api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
185
+ return "failure" in r && store.get("api/state")?.body?.served === true ? { failure: overTunnel(r.failure) } : r;
186
186
  };
187
187
  const first = parseHash(shown);
188
188
  const m = { shown: first.id, range: rangeOf(first.params.range), pollFailure: null, updatedAt: null, rangeReadAt: null };
@@ -236,7 +236,7 @@ export function createApp(fetchFn, now = () => new Date(), shown = "status") {
236
236
  afterRead();
237
237
  },
238
238
  view() {
239
- const st = store.get("/api/state")?.body;
239
+ const st = store.get("api/state")?.body;
240
240
  return {
241
241
  nav: renderNav(m.shown, m.range),
242
242
  // the range control in the shown view's header row (each group page's own slot; the others empty)
@@ -7,9 +7,9 @@
7
7
  <!-- no favicon request: an empty one, inline (img-src allows data:) -->
8
8
  <link rel="icon" href="data:,">
9
9
  <!-- every file Mason's own, from its allowlist (src/cli/run-server.ts PAGE_ROUTES): the fonts are app.css's, vendored -->
10
- <link rel="stylesheet" href="/app.css">
11
- <link rel="stylesheet" href="/vendor/uplot-1.6.32/uPlot.min.css">
12
- <script type="module" src="/app.js"></script>
10
+ <link rel="stylesheet" href="app.css">
11
+ <link rel="stylesheet" href="vendor/uplot-1.6.32/uPlot.min.css">
12
+ <script type="module" src="app.js"></script>
13
13
  </head>
14
14
  <body>
15
15
  <!-- the shell (src/web/app.js): the sidebar — the project, the nav, when it was updated and the version — and the page
@@ -69,11 +69,8 @@
69
69
  </section>
70
70
  <section id="page-users" aria-labelledby="users-h" hidden>
71
71
  <div class="page-head"><h2 id="users-h">Users</h2><p class="desc">A user is the name and password psql, a BI tool or your app connects to Mason with; it does not expire. The same users as <code>mason user</code>.</p></div>
72
- <form id="add-user" class="formcard" autocomplete="off">
73
- <label for="user-name">Name</label>
74
- <input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">
75
- <button type="submit" class="primary">Add a user</button>
76
- </form>
72
+ <!-- the add form is the page's (src/web/pages/users.js renderAddUser): none on a read-only page -->
73
+ <div id="add-user-box"></div>
77
74
  <div id="action"></div>
78
75
  <div id="added"></div>
79
76
  <div id="users-connect"></div>
@@ -14,7 +14,7 @@
14
14
  import { msText, agoText, bytesText, esc, firstLine, n, empty, renderFailure, rowsHtml, utcText, utcMsText, details, createOpenSet, status,
15
15
  clip, capped, TITLE_MAX, sortTh, sortedBy, nextSort, RANGES, DEFAULT_RANGE, clientText, renderConnect, copyClick, UNLISTED_STATEMENTS, statementCell, rangeEvery } from "../shared.js";
16
16
 
17
- const STATE = "/api/state";
17
+ const STATE = "api/state";
18
18
  const NEWEST_EVERY_MS = 5000;
19
19
  /** The newest page while a search is set: a search reads every statement's text of the range (7 days of a busy log,
20
20
  * ~0.6 s a read), so it is read every 30 s, not 5 — said under the log (SEARCH_NOTE). */
@@ -103,7 +103,7 @@ export function requestsPath(filters, before, range = DEFAULT_RANGE) {
103
103
  if (filters.slow) p.set("slow", "1");
104
104
  if (filters.search) p.set("search", filters.search);
105
105
  if (before) p.set("before", before);
106
- return `/api/requests?${p.toString()}`;
106
+ return `api/requests?${p.toString()}`;
107
107
  }
108
108
 
109
109
  export const page = {
@@ -156,7 +156,7 @@ export const page = {
156
156
  const s = ctx.read(STATE)?.body;
157
157
  return {
158
158
  "request-log": (log?.failure ? renderFailure(log.failure) : "") + (log?.body ? renderRequests(log.body, { paged: m.before !== null, filtered: filtered(), isOpen: opened.has, now: ctx.now().getTime(), sort: m.sort,
159
- span: m.filters.since ? "" : (RANGES[ctx.range()] ?? RANGES[DEFAULT_RANGE]).span, connect: renderConnect(s?.postgres, { first: true, table: s?.tables?.find((t) => t.added && t.copy?.phase === "done")?.table ?? null }) }) : "") +
159
+ span: m.filters.since ? "" : (RANGES[ctx.range()] ?? RANGES[DEFAULT_RANGE]).span, connect: renderConnect(s?.postgres, { first: true, table: s?.tables?.find((t) => t.added && t.copy?.phase === "done")?.table ?? null, readOnly: s?.pageReadOnly ?? null }) }) : "") +
160
160
  (log?.body && m.filters.search && m.before === null ? `<p class="dim refresh-note">${esc(SEARCH_NOTE)}</p>` : ""),
161
161
  };
162
162
  },
@@ -21,7 +21,7 @@ const LINKS = { tables: "#tables", failedTables: "#tables?show=failed", usage: "
21
21
  slow: "#observability/queries?slow=1&range=24h", failedQueries: "#observability/queries?status=refused-or-failed&range=24h", failedEvents: "#observability/syncs?failures=1&range=7d" };
22
22
  import { msText, bytesText, code, esc, fileSize, firstLine, n, percent, leftText, spanText, duration, entryName, streamRows, slotWarns, masonRow, sessionsRows, diskRows, restartsRows, utcText,
23
23
  utcMinute, oneLine, clientText, eventCells, unsupportedEntry, usagePath, POLL_MS, card, cards, details, createOpenSet, skeleton, renderConnect, copyClick,
24
- badge, button, copyable, pop, renderFailure, json } from "../shared.js";
24
+ badge, button, copyable, pop, renderFailure, json, readOnlyLine } from "../shared.js";
25
25
  import { spark, seriesOf, drawable } from "../charts.js";
26
26
 
27
27
  /** How many recent errors of each kind are shown: mason status's (src/cli/commands.ts `RECENT_ERRORS`, held equal by the tests). */
@@ -289,6 +289,8 @@ export function renderFacts(s, now = Date.now()) {
289
289
  const d = s.disk;
290
290
  rows.push(...diskRows(d).map(([k, v]) => factRow(k, v, d.freeBytes === null || (d.projectedBytes !== null && d.projectedBytes > d.freeBytes))));
291
291
  rows.push(...sessionsRows(s.sessions).map(([k, v]) => factRow(k, v)));
292
+ // `pageSocket` (mason serve with MASON_UI_SOCKET): said without its path, as the page shows no absolute path
293
+ if (typeof s.pageSocket === "string") rows.push(factRow("Web page", "served on a Unix socket on the server too (MASON_UI_SOCKET)"));
292
294
  return `<table class="facts-table"><caption>Mason</caption><tbody>${rows.join("")}</tbody></table>`;
293
295
  }
294
296
 
@@ -398,10 +400,12 @@ export function renderChecklist(s, queries, users) {
398
400
  const answered = queries.queries.week.completed > 0;
399
401
  if (copied && user && answered) return "";
400
402
  const copying = added && !copied && copyingCount(s) > 0;
403
+ // a read-only page (MASON_UI_READ_ONLY=1) sends no one to add what it would refuse to add
404
+ const looks = (s.pageReadOnly ?? null) !== null;
401
405
  const steps = [
402
406
  ["Mason is running", true, ""],
403
- ["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
404
- ["A user created", user, `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
407
+ ["A table synced", copied, copying ? `<span class="dim">syncing now</span>` : added || looks ? "" : `<a class="btn small primary" href="${LINKS.tables}">Add a table</a>`],
408
+ ["A user created", user, looks ? "" : `<a class="btn small${copied ? " primary" : ""}" href="#users">Create a user</a>`],
405
409
  // the psql line is Connect's (rendered under the checklist once a table is added): its Copy copies it
406
410
  ["A first statement answered", answered, copied ? `${`<button type="button" class="small${user ? " primary" : ""}" data-copy="connect-psql">Copy the psql line</button>`} <span class="dim">then run a select on a synced table</span>` : ""],
407
411
  ];
@@ -423,8 +427,9 @@ export function renderStatus(s, o = {}) {
423
427
  // day's and week's statements, read as none), else after the facts, for the next tool
424
428
  const first = isFirstConnect(o.queries);
425
429
  const connect = added ? renderConnect(s.postgres, { first, served: s.served === true, table: s.tables.find((t) => t.added && t.copy?.phase === "done")?.table ?? null,
426
- share: renderShare(s, o.share ?? NO_SHARE, now) }) : "";
427
- return renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
430
+ share: renderShare(s, o.share ?? NO_SHARE, now), readOnly: s.pageReadOnly ?? null }) : "";
431
+ // a read-only page (MASON_UI_READ_ONLY=1): why it offers no share, no user and no table to add, as Tables and Users say it
432
+ return readOnlyLine("Nothing is shared, added or created", s.pageReadOnly) + renderHealth(s) + renderChecklist(s, o.queries, o.users) + renderKpis(s, { ...o, now }) + (first ? connect : "") + renderProgress(s, o.open, o.shares, o.before) + renderFacts(s, now) + (first ? "" : connect) + renderStream(s, now) +
428
433
  (added ? renderQueries(o.queries) : "") + (added ? renderRecentErrors(o.events, o.requests, o.unsupported) : "");
429
434
  }
430
435
 
@@ -446,7 +451,8 @@ const endsLine = (endsAt, now) => endsAt === null || endsAt === undefined ? "It
446
451
  * offered, the answer that opened it — the password is in it alone — and the failure). Nothing under `mason serve`.
447
452
  */
448
453
  export function renderShare(s, m = NO_SHARE, now = Date.now()) {
449
- if (s?.served === true) return "";
454
+ // a server's run has no share to open, and a read-only page (MASON_UI_READ_ONLY=1) opens nothing
455
+ if (s?.served === true || (s?.pageReadOnly ?? null) !== null) return "";
450
456
  const open = pageShareOf(s);
451
457
  const head = `<p class="connect-title share-title">Connect from anywhere</p>`;
452
458
  const ask = button("Connect from anywhere", { shareAsk: "" }, "", { disabled: m.phase === "asking" || m.phase === "opening" });
@@ -483,7 +489,7 @@ export const UNSUPPORTED_LIMIT = 100;
483
489
 
484
490
  /** What the page reads every 30 s beside the state: the request log's summary, the recent errors, and the last day's
485
491
  * unsupported SQL. */
486
- const SLOWER = ["/api/queries", `/api/events?failed=1&limit=${RECENT_ERRORS}`, `/api/requests?failed=1&limit=${RECENT_ERRORS}`, `/api/unsupported?limit=${UNSUPPORTED_LIMIT}`, "/api/users"];
492
+ const SLOWER = ["api/queries", `api/events?failed=1&limit=${RECENT_ERRORS}`, `api/requests?failed=1&limit=${RECENT_ERRORS}`, `api/unsupported?limit=${UNSUPPORTED_LIMIT}`, "api/users"];
487
493
 
488
494
  /** A read as `renderStatus` takes it: the body, or `{ failure }` when it could not be read; undefined before any read. */
489
495
  const asRequest = (r) => r === undefined ? undefined : r.failure ? { failure: r.failure } : r.body;
@@ -499,12 +505,12 @@ export const page = {
499
505
  const share = createShare(ctx);
500
506
  return {
501
507
  model: { open: opened.open, share: share.m },
502
- reads: () => [{ path: "/api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
508
+ reads: () => [{ path: "api/state", everyMs: POLL_MS }, ...SLOWER.map((path) => ({ path, everyMs: 30_000 })), { path: HOURLY, everyMs: 60_000 }],
503
509
  actions: share.actions,
504
- afterRead: () => share.afterRead(ctx.read("/api/state")?.body),
510
+ afterRead: () => share.afterRead(ctx.read("api/state")?.body),
505
511
  input: (target) => share.input(target),
506
512
  view() {
507
- const st = ctx.read("/api/state");
513
+ const st = ctx.read("api/state");
508
514
  if (!st?.body) return { status: st?.failure ? "" : skeleton(4) };
509
515
  const [queries, events, requests, unsupported, users] = SLOWER.map((p) => asRequest(ctx.read(p)));
510
516
  return { status: renderStatus(st.body, { now: ctx.now().getTime(), queries, events, requests, unsupported, users, usage: ctx.read(HOURLY)?.body, open: opened.has, shares: monotone.of(st.body), before: monotone.before, share: share.m }) };
@@ -535,8 +541,8 @@ export function createShare(ctx) {
535
541
  const beat = async () => {
536
542
  if (beating === null) return;
537
543
  const user = beating.user;
538
- const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}/beat`, json("POST"));
539
- if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["/api/state"]); }
544
+ const r = await ctx.ask(`api/share/${encodeURIComponent(user)}/beat`, json("POST"));
545
+ if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["api/state"]); }
540
546
  };
541
547
  const startBeats = (user, everyMs = BEAT_MS) => {
542
548
  if (beating?.user === user) return;
@@ -547,7 +553,7 @@ export function createShare(ctx) {
547
553
  /** Connect from anywhere: what a share would read, then the question */
548
554
  async shareAsk() {
549
555
  Object.assign(m, NO_SHARE, { phase: "asking" });
550
- const r = await ctx.ask("/api/share/choices");
556
+ const r = await ctx.ask("api/share/choices");
551
557
  if (m.phase !== "asking") return;
552
558
  if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
553
559
  m.choices = r.body.choices;
@@ -561,17 +567,17 @@ export function createShare(ctx) {
561
567
  if (m.phase !== "asking" || m.choices === null) return;
562
568
  const role = m.choices.length > 0 ? m.role : null;
563
569
  Object.assign(m, { phase: "opening", failure: null });
564
- const r = await ctx.ask("/api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
570
+ const r = await ctx.ask("api/share", json("POST", { from: "page", confirmed: true, ...(role !== null ? { role } : {}) }));
565
571
  if ("failure" in r) { m.phase = "refused"; m.failure = r.failure; return; }
566
572
  Object.assign(m, { phase: "open", opened: { ...r.body, seen: false } });
567
573
  startBeats(r.body.user, r.body.beatMs);
568
- await ctx.refresh(["/api/state"]);
574
+ await ctx.refresh(["api/state"]);
569
575
  },
570
576
  async shareStop(user) {
571
- const r = await ctx.ask(`/api/share/${encodeURIComponent(user)}`, json("DELETE"));
577
+ const r = await ctx.ask(`api/share/${encodeURIComponent(user)}`, json("DELETE"));
572
578
  if ("failure" in r && r.failure.code !== "not_found") { m.phase = "refused"; m.failure = r.failure; return; }
573
579
  ended(user, "Stop sharing, on this page");
574
- await ctx.refresh(["/api/state"]);
580
+ await ctx.refresh(["api/state"]);
575
581
  },
576
582
  shareBeat: beat,
577
583
  };
@@ -15,9 +15,9 @@ import { esc, agoText, utcText, badge, empty, panel, n, sortTh, nextSort, moreRo
15
15
  const EVENTS_LIMIT = 500;
16
16
  const EVENTS_SHOWN = 100;
17
17
  /** The events of a range: the newest EVENTS_LIMIT since its start. */
18
- const eventsPath = (range = DEFAULT_RANGE) => `/api/events?limit=${EVENTS_LIMIT}&since=${RANGES[rangeOf(range)].since}`;
19
- const LOGS = "/api/logs?lines=200";
20
- const STATE = "/api/state";
18
+ const eventsPath = (range = DEFAULT_RANGE) => `api/events?limit=${EVENTS_LIMIT}&since=${RANGES[rangeOf(range)].since}`;
19
+ const LOGS = "api/logs?lines=200";
20
+ const STATE = "api/state";
21
21
 
22
22
  const th = (cols) => cols.map((c) => `<th scope="col">${esc(c)}</th>`).join("");
23
23
 
@@ -10,18 +10,18 @@
10
10
  * `copySizes` every minute; a table's columns once, when its Columns… or Name its key… form opens
11
11
  * (`GET /api/tables/<table>/columns`, `columnsPath`).
12
12
  */
13
- import { msText, bytesText, button, card, cards, code, createOpenSet, moreRows, details, empty, entryName, esc, fileSize, firstLine, json, n, panel, renderFailure, rowsHtml, spanText, duration, status, streamRows, utcText, pop, fresh, actId, ROOMS, POLL_MS } from "../shared.js";
13
+ import { msText, bytesText, button, card, cards, code, createOpenSet, moreRows, details, empty, entryName, esc, fileSize, firstLine, json, n, panel, renderFailure, rowsHtml, spanText, duration, status, streamRows, utcText, pop, fresh, actId, ROOMS, POLL_MS, readOnlyLine } from "../shared.js";
14
14
  import { copyBar, copyingCount, createMonotone, tableLine } from "./status.js";
15
15
 
16
16
  /** `/api/usage` asked for the copies' own sizes alone: the last minute's samples (none needed), and `copySizes`. */
17
- const SIZES_PATH = "/api/usage?since=1m";
17
+ const SIZES_PATH = "api/usage?since=1m";
18
18
 
19
19
  /** Where one table's columns are read, when its form opens (src/cli/run-server.ts tableColumns): never with the state,
20
20
  * which is polled every 2 s and would carry every table's (3.4 MB at 800 tables of 50 columns). */
21
21
  /** A table's sync events: Observability › Sync events narrowed to it (src/web/pages/syncs.js `route`), over the week (its
22
22
  * copy and failures are rarely in the last 15 minutes, Now's). */
23
23
  export const syncsHref = (table) => `#observability/syncs?table=${encodeURIComponent(table)}&range=7d`;
24
- export const columnsPath = (table) => `/api/tables/${encodeURIComponent(table)}/columns`;
24
+ export const columnsPath = (table) => `api/tables/${encodeURIComponent(table)}/columns`;
25
25
 
26
26
  /** A table's sync in one word (`/api/state` `tables[].sync.state`, src/cli/readiness.ts `syncStateOf`). */
27
27
  const SYNC_WORDS = { synced: "Synced", syncing: "Syncing", behind: "Behind", failed: "Failed", waiting: "Waiting" };
@@ -199,8 +199,10 @@ export function renderTables(s, ui = {}) {
199
199
  const { removing = null, choosing = null, columns = null, outcome = null, ticked = null, filter = "", show = "all", sizes, syncing = [], adding = [], shares = new Map(), before = new Map(), synced = null, open = () => false, limit = TABLE_ROWS } = ui;
200
200
  const now = ui.now ?? Date.now();
201
201
  if (!s.tables.length) return empty(`${s.source.database} has no table this user can read.`);
202
- // why no table can be added or removed from here (a server's sources file this run cannot write), or null
203
- const readOnly = s.sourcesReadOnly ?? null;
202
+ // why the page changes nothing (MASON_UI_READ_ONLY=1: not even a Sync), or null
203
+ const viewOnly = s.pageReadOnly ?? null;
204
+ // why no table can be added or removed from here (that, or a server's sources file this run cannot write), or null
205
+ const readOnly = viewOnly ?? s.sourcesReadOnly ?? null;
204
206
  const stream = s.stream ? new Map(streamRows(s.stream, now).tables) : null;
205
207
  // SEVEN NARROW COLUMNS OF FIXED WIDTHS, NOTHING LONG IN A CELL, AND NOTHING A CLICK INSERTS: a table's state is its word
206
208
  // over its copy's bar (a line kept for it when it has none); its last sync an age over how long it took; whatever is
@@ -227,7 +229,7 @@ export function renderTables(s, ui = {}) {
227
229
  // Sync now → Syncing… → Synced ✓ in the one room (ROOMS.sync), so the actions never move
228
230
  // View events: its sync events, on Observability › Sync events (a link: it goes to another page)
229
231
  ? `<a class="btn small" href="${esc(syncsHref(t.table))}">View events</a> ` +
230
- (syncing.includes(t.table) ? button("Syncing…", {}, "small", { room: ROOMS.sync, disabled: true }) : button(syncedNow ? "Synced ✓" : "Sync now", { syncTable: t.table }, "small", { room: ROOMS.sync })) + " " +
232
+ (viewOnly ? "" : syncing.includes(t.table) ? button("Syncing…", {}, "small", { room: ROOMS.sync, disabled: true }) : button(syncedNow ? "Synced ✓" : "Sync now", { syncTable: t.table }, "small", { room: ROOMS.sync })) + " " +
231
233
  // while it asks, Remove stays where it was, held down: the question beside it answers it
232
234
  (readOnly ? "" : asking ? button("Remove", {}, "danger small", { disabled: true, expanded: true }) : button("Remove", { removeTable: t.table }, "danger small"))
233
235
  // a sources file this run cannot write (`s.sourcesReadOnly`, a server's): no control that could only fail
@@ -288,7 +290,8 @@ export function renderTables(s, ui = {}) {
288
290
  const matching = filter.trim() ? `<p class="count dim">${n(shown.length)} with “${esc(filter.trim())}” in the name</p>` : "";
289
291
  const sizesFailed = added && sizes && "failure" in sizes ? `<p class="count">The synced tables' own sizes: could not read them (${esc(firstLine(sizes.failure.message))}; ${code("mason usage")} has them)</p>` : "";
290
292
  const rowsHtml_ = written.map(line).join("");
291
- return stray + (readOnly === null ? "" : `<p class="count">Tables are not added or removed from this page: ${esc(readOnly)}.</p>`) +
293
+ return stray + (viewOnly !== null ? readOnlyLine("Tables are not added, removed or synced", viewOnly)
294
+ : readOnly === null ? "" : `<p class="count">Tables are not added or removed from this page: ${esc(readOnly)}.</p>`) +
292
295
  (added ? "" : empty("No table added yet", readOnly === null ? `Add one from the list, as ${code("mason add <table>")} does.` : `The sources file names the tables to sync.`)) + matching +
293
296
  panel(`<table class="catalog"><thead><tr>${head.map(([h, c]) => `<th scope="col"${c ? ` class="${c}"` : ""}>${h}</th>`).join("")}</tr></thead>` +
294
297
  `<tbody>${rowsHtml_}</tbody></table>`) + pops.join("") + moreRows("tables", Math.min(limit, shown.length), shown.length, TABLE_ROWS) +
@@ -301,7 +304,7 @@ export const page = {
301
304
  title: "Tables",
302
305
  create(ctx) {
303
306
  const m = { removing: null, choosing: null, columns: null, outcome: null, ticked: null, held: null, heldFilter: null, heldShow: null, filter: "", show: "all", syncing: [], adding: [], synced: null, limit: TABLE_ROWS };
304
- const state = () => ctx.read("/api/state")?.body ?? null;
307
+ const state = () => ctx.read("api/state")?.body ?? null;
305
308
  const monotone = createMonotone();
306
309
  // the details open (a copy's whole error), by key: a poll rewrites the part, and they stay open
307
310
  const opened = createOpenSet();
@@ -325,12 +328,12 @@ export const page = {
325
328
  // from the click: the row says Adding… (the page paints before the answer), then the state read again shows the copy
326
329
  m.adding = [...m.adding, table];
327
330
  if (m.choosing !== table) { m.outcome = null; m.synced = null; }
328
- const r = await ctx.ask("/api/pick", json("POST", pickBody(table, columns, key)));
331
+ const r = await ctx.ask("api/pick", json("POST", pickBody(table, columns, key)));
329
332
  m.adding = m.adding.filter((x) => x !== table);
330
333
  // refused: said over the form, in its popover, written again with the boxes the person had ticked
331
334
  if ("failure" in r) { m.outcome = { table, failure: r.failure }; if (m.choosing === table) m.ticked = { columns, key }; return false; }
332
335
  m.outcome = { table, added: r.body, at: ctx.now().getTime() }; m.choosing = null; m.columns = null; hold();
333
- await ctx.refresh(["/api/state"]);
336
+ await ctx.refresh(["api/state"]);
334
337
  return true;
335
338
  },
336
339
  /** Open a table's form: its columns read now, once (the state the page polls carries none). */
@@ -348,11 +351,11 @@ export const page = {
348
351
  cancelRemoveTable() { m.removing = null; hold(); },
349
352
  /** Remove a table (`mason remove`), once asked: the answer beside its actions, then the state read again. */
350
353
  async confirmRemoveTable(table) {
351
- const r = await ctx.ask("/api/remove", json("POST", { tables: [table] }));
354
+ const r = await ctx.ask("api/remove", json("POST", { tables: [table] }));
352
355
  m.removing = null; hold();
353
356
  if ("failure" in r) { m.outcome = { table, failure: r.failure }; return false; }
354
357
  m.outcome = { table, removed: r.body, at: ctx.now().getTime() };
355
- await ctx.refresh(["/api/state"]);
358
+ await ctx.refresh(["api/state"]);
356
359
  return true;
357
360
  },
358
361
  dismissOutcome() { m.outcome = null; },
@@ -360,10 +363,10 @@ export const page = {
360
363
  async syncTable(table) {
361
364
  m.syncing = [...m.syncing, table];
362
365
  m.outcome = null; m.synced = null;
363
- const r = await ctx.ask("/api/sync", json("POST", { tables: [table] }));
366
+ const r = await ctx.ask("api/sync", json("POST", { tables: [table] }));
364
367
  m.syncing = m.syncing.filter((x) => x !== table);
365
368
  m.synced = "failure" in r ? { table, failure: r.failure } : { table, synced: r.body, at: ctx.now().getTime() };
366
- await ctx.refresh(["/api/state"]);
369
+ await ctx.refresh(["api/state"]);
367
370
  return !("failure" in r);
368
371
  },
369
372
  dismissSynced() { m.synced = null; },
@@ -377,7 +380,7 @@ export const page = {
377
380
  };
378
381
  return {
379
382
  model: m,
380
- reads: () => [{ path: "/api/state", everyMs: POLL_MS }, { path: SIZES_PATH, everyMs: 60_000 }],
383
+ reads: () => [{ path: "api/state", everyMs: POLL_MS }, { path: SIZES_PATH, everyMs: 60_000 }],
381
384
  afterRead() {
382
385
  const s = state();
383
386
  if (s === null) return;