@mason-data/cli 0.9.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.
Files changed (56) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +295 -159
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/dev.js +21 -5
  9. package/dist/src/cli/doctor.js +14 -11
  10. package/dist/src/cli/guides.js +20 -11
  11. package/dist/src/cli/hosted.js +1 -1
  12. package/dist/src/cli/init.js +110 -25
  13. package/dist/src/cli/login.js +20 -17
  14. package/dist/src/cli/machine.js +125 -40
  15. package/dist/src/cli/main.js +51 -8
  16. package/dist/src/cli/mcp.js +15 -12
  17. package/dist/src/cli/project.js +25 -2
  18. package/dist/src/cli/registry.js +80 -35
  19. package/dist/src/cli/replica-hosted.js +761 -102
  20. package/dist/src/cli/replica.js +43 -5
  21. package/dist/src/cli/replication-mode.js +5 -0
  22. package/dist/src/cli/request-filter.js +16 -6
  23. package/dist/src/cli/run-server.js +70 -31
  24. package/dist/src/cli/secret-input.js +193 -0
  25. package/dist/src/cli/security.js +7 -2
  26. package/dist/src/cli/serve.js +119 -9
  27. package/dist/src/cli/share.js +20 -15
  28. package/dist/src/cli/source-role.js +14 -15
  29. package/dist/src/cli/sources.js +20 -3
  30. package/dist/src/cli/style.js +14 -0
  31. package/dist/src/cli/sync.js +17 -8
  32. package/dist/src/cli/token-hosted.js +30 -24
  33. package/dist/src/cli/tunnel.js +1 -1
  34. package/dist/src/cli/ui-hosted.js +163 -0
  35. package/dist/src/cli/ui-read-only.js +23 -0
  36. package/dist/src/cli/ui-run-file.js +247 -0
  37. package/dist/src/cli/ui-socket.js +183 -0
  38. package/dist/src/cli/ui-ssh.js +3 -3
  39. package/dist/src/cli/ui.js +67 -26
  40. package/dist/src/cli/usage.js +19 -15
  41. package/dist/src/cli/user-hosted.js +78 -13
  42. package/dist/src/cli/user.js +168 -50
  43. package/dist/src/duck/users.js +105 -9
  44. package/dist/src/lib/cidr.js +177 -0
  45. package/dist/src/lib/table-sync.js +69 -19
  46. package/dist/src/web/app.css +5 -0
  47. package/dist/src/web/app.js +4 -4
  48. package/dist/src/web/index.html +5 -8
  49. package/dist/src/web/pages/queries.js +3 -3
  50. package/dist/src/web/pages/status.js +23 -17
  51. package/dist/src/web/pages/syncs.js +3 -3
  52. package/dist/src/web/pages/tables.js +18 -15
  53. package/dist/src/web/pages/usage.js +16 -12
  54. package/dist/src/web/pages/users.js +45 -14
  55. package/dist/src/web/shared.js +10 -4
  56. package/package.json +6 -6
@@ -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;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { esc, n, msText, bytesText, fileSize, duration, details, createOpenSet, cards, card, empty, panel, skeleton, sortTh, sortedBy, nextSort, readPart, usagePath, utcMinute,
16
16
  RANGES, DEFAULT_RANGE, rangeOf, isLive, rangeEvery, firstLine, spanText, agoText, utcText, clientText, clip, TITLE_MAX, unsupportedEntry, renderFailure, json, pop, fresh, actId,
17
- statementCell, POLL_MS } from "../shared.js";
17
+ statementCell, POLL_MS, readOnlyLine } from "../shared.js";
18
18
  import { spark, seriesChart, seriesOf, drawable } from "../charts.js";
19
19
 
20
20
  const EVERY_MS = 60_000;
@@ -87,15 +87,15 @@ export function renderCharts(usage, range = DEFAULT_RANGE) {
87
87
  return `<div class="chart-grid">${blocks.join("")}</div>`;
88
88
  }
89
89
 
90
- const RUNNING = "/api/running";
91
- const STATE = "/api/state";
90
+ const RUNNING = "api/running";
91
+ const STATE = "api/state";
92
92
  /** Who is connected: the clients with the most sessions (or statements) shown, this many; the rest in one row. */
93
93
  const SESSIONS_TOP = 10;
94
94
  /** What a past range's statements were (GET /api/activity): read once when chosen, every 5 s while the range is Now. */
95
- export const activityPath = (range = DEFAULT_RANGE) => `/api/activity?since=${RANGES[rangeOf(range)].since}`;
95
+ export const activityPath = (range = DEFAULT_RANGE) => `api/activity?since=${RANGES[rangeOf(range)].since}`;
96
96
  const ACTIVITY_EVERY_MS = 5000;
97
97
  /** The unsupported SQL of a range (`/api/unsupported`: its 10 entries the most refused first): it says what to rewrite. */
98
- const unsupportedPath = (range = DEFAULT_RANGE) => `/api/unsupported?since=${RANGES[rangeOf(range)].since}`;
98
+ const unsupportedPath = (range = DEFAULT_RANGE) => `api/unsupported?since=${RANGES[rangeOf(range)].since}`;
99
99
  const UNSUPPORTED_EVERY_MS = 60_000;
100
100
  const plural = (count, word) => `${n(count)} ${word}${count === 1 ? "" : "s"}`;
101
101
  /** The key a running statement is asked about by: its session and its statement's number. */
@@ -166,8 +166,9 @@ export function statementCards(day, line = () => "", label = "last 24 hours", hr
166
166
  * popover beside its Cancel (`pop`: no row is inserted, so none below moves; its Cancel stays, held down); `outcome` is
167
167
  * what the last cancel answered (a Failure, or `{ cancelled, at }`), a popover beside the row it was for — or beside the
168
168
  * part when that statement has ended since — gone by itself after OUTCOME_MS at `now` (a refusal stays until dismissed).
169
+ * `readOnly`: `/api/state` `pageReadOnly` (MASON_UI_READ_ONLY=1) — no Cancel, and no column for it.
169
170
  */
170
- export function renderRunning(running, asking = null, outcome = null, now = Date.now()) {
171
+ export function renderRunning(running, asking = null, outcome = null, now = Date.now(), readOnly = null) {
171
172
  const pops = [];
172
173
  const said = fresh(outcome, now) ? outcome : null;
173
174
  let shown = false;
@@ -182,11 +183,11 @@ export function renderRunning(running, asking = null, outcome = null, now = Date
182
183
  if (said !== null && said.key === key) { shown = true; pops.push(pop(anchor, outcomeHtml(said))); }
183
184
  return `<tr><td>${r.session}</td><td>${r.statement}</td><td><span class="clip" title="${esc(r.user)}">${esc(r.user)}</span></td><td><span class="clip" title="${esc(clientText(r.applicationName))}">${esc(clientText(r.applicationName))}</span></td>` +
184
185
  `<td class="num"><time data-tick datetime="${esc(r.startedAt)}" title="started ${esc(utcText(r.startedAt))} UTC">${esc(spanText(r.runningMs / 1000))}</time></td><td class="num">${esc(plural(r.rows, "row"))} sent</td>` +
185
- `<td>${statementCell(r.query)}</td><td class="act" id="${esc(anchor)}">${asking === key ? `<button type="button" class="danger small" aria-expanded="true" disabled>Cancel</button>` : `<button type="button" class="danger small" data-ask-cancel="${esc(key)}">Cancel</button>`}</td></tr>`;
186
+ `<td>${statementCell(r.query)}</td>${readOnly !== null ? "" : `<td class="act" id="${esc(anchor)}">${asking === key ? `<button type="button" class="danger small" aria-expanded="true" disabled>Cancel</button>` : `<button type="button" class="danger small" data-ask-cancel="${esc(key)}">Cancel</button>`}</td>`}</tr>`;
186
187
  }).join("");
187
188
  const table = running.length
188
189
  ? `<div class="scroll"><table class="running"><thead><tr><th scope="col">Session</th><th scope="col" title="the statement's number in its session, as mason cancel --statement takes it">No.</th><th scope="col">User</th><th scope="col">Client</th>` +
189
- `<th scope="col" class="num">Running for</th><th scope="col">Rows</th><th scope="col">Statement</th><th scope="col"></th></tr></thead><tbody>${rows}</tbody></table></div>`
190
+ `<th scope="col" class="num">Running for</th><th scope="col">Rows</th><th scope="col">Statement</th>${readOnly !== null ? "" : `<th scope="col"></th>`}</tr></thead><tbody>${rows}</tbody></table></div>`
190
191
  : empty("No statement is running now.");
191
192
  // the statement ended since: said beside the part, which is there whatever it lists
192
193
  if (said !== null && !shown) pops.push(pop("usage-running", outcomeHtml(said)));
@@ -338,7 +339,7 @@ export const page = {
338
339
  dismissCancelled() { m.outcome = null; },
339
340
  /** POST /api/cancel { session, statement }: the statement listed, never the one the session runs next (409). */
340
341
  async confirmCancelStatement(session, statement) {
341
- const r = await ctx.ask("/api/cancel", json("POST", { session, statement }));
342
+ const r = await ctx.ask("api/cancel", json("POST", { session, statement }));
342
343
  m.asking = null;
343
344
  m.outcome = "failure" in r ? { key: keyOf(session, statement), failure: r.failure } : { key: keyOf(session, statement), cancelled: r.body.cancelled, at: ctx.now().getTime() };
344
345
  // who is connected says its running statements too: both read again, so neither says the cancelled one runs
@@ -377,16 +378,19 @@ export const page = {
377
378
  const a = ctx.read(activity());
378
379
  const running = live ? ctx.read(RUNNING)?.body : undefined;
379
380
  const sessions = live ? ctx.read(STATE)?.body?.sessions : undefined;
381
+ // a read-only page (MASON_UI_READ_ONLY=1) offers no Cancel
382
+ const readOnly = ctx.read(STATE)?.body?.pageReadOnly ?? null;
380
383
  const now = ctx.now().getTime();
381
384
  return {
382
- "usage-kpis": renderUsageKpis({ activity: a === undefined ? undefined : a.body ?? { failure: a.failure }, usage: ctx.read(samples())?.body, sessions, running: running?.running, range }),
385
+ // a read-only page (MASON_UI_READ_ONLY=1): why no statement has a Cancel, as Tables and Users say it
386
+ "usage-kpis": readOnlyLine("Statements are not cancelled", readOnly) + renderUsageKpis({ activity: a === undefined ? undefined : a.body ?? { failure: a.failure }, usage: ctx.read(samples())?.body, sessions, running: running?.running, range }),
383
387
  "usage-charts": renderCharts(ctx.read(samples())?.body, range),
384
388
  "usage-connected-h": live ? "Connected now" : `Connected in ${esc(span)}`,
385
389
  "usage-connected": live ? renderSessions(sessions, SESSIONS_TOP, range) : part(activity(), (b) => renderClients(b.clients, { range, now, total: b.clientsTotal.clients }), false),
386
390
  "usage-running-h": live ? "Running now" : `Slowest in ${esc(span)}`,
387
- "usage-running-lead": live ? `As <code>mason logs --running</code> lists them. Cancel stops one, as <code>mason cancel</code> does.`
391
+ "usage-running-lead": live ? `As <code>mason logs --running</code> lists them.${readOnly !== null ? "" : ` Cancel stops one, as <code>mason cancel</code> does.`}`
388
392
  : `The statements that took longest to complete, as the request log recorded them.`,
389
- "usage-running": live ? (running ? renderRunning(running.running, m.asking, m.outcome, now) : "") : part(activity(), (b) => renderSlowest(b.slowest, { range, now }), false),
393
+ "usage-running": live ? (running ? renderRunning(running.running, m.asking, m.outcome, now, readOnly) : "") : part(activity(), (b) => renderSlowest(b.slowest, { range, now }), false),
390
394
  unsupported: part(unsupportedPath(range), (b) => renderUnsupported(b, span), true),
391
395
  "usage-hourly": part(samples(), (b) => renderUsage(b, opened.has, m.sort), true),
392
396
  "usage-sizes": part(samples(), (b) => renderCopySizes(b.copySizes), false),
@@ -3,15 +3,15 @@
3
3
  * POST /api/users/<name>/disable and /enable, DELETE /api/users/<name>. An added user's password lives in this page's memory until the person dismisses it, never in
4
4
  * storage, a URL or a log line.
5
5
  */
6
- import { actId, badge, button, code, copyable, copyClick, duration, empty, esc, json, panel, pop, renderFailure, ROOMS, serverHostNote, status, utcMinute } from "../shared.js";
6
+ import { actId, badge, button, code, copyable, copyClick, duration, empty, esc, json, panel, pop, renderFailure, ROOMS, serverHostNote, status, utcMinute, readOnlyLine } from "../shared.js";
7
7
 
8
8
  /** The users as the page reads them: with each one's last use (`mason user list` asks the same), then every 30 s. */
9
- const USERS = "/api/users?lastUsed=1";
10
- const USERS_POST = "/api/users";
9
+ const USERS = "api/users?lastUsed=1";
10
+ const USERS_POST = "api/users";
11
11
  const USERS_EVERY_MS = 30_000;
12
12
  /** The run's state: where a tool connects (`postgres`, src/cli/pg-url.ts postgresFacts), the Connect panel's; and the
13
13
  * roles file's state (`roles`, src/cli/run-server.ts rolesState), what each user's role is held to. */
14
- const STATE = "/api/state";
14
+ const STATE = "api/state";
15
15
 
16
16
  /** A time as `mason user list` stamps it: `2026-10-27 09:10`, UTC. */
17
17
  const stamp = utcMinute;
@@ -50,17 +50,20 @@ export const attributesText = (a) => Object.entries(a).map(([k, v]) => `${k}=${t
50
50
  * user held to it is refused (57P03), said above the table as `mason user list`'s tip says it (src/cli/roles.ts
51
51
  * rolesProblemText, held equal by tests/web-page.test.ts) and in each user's role; beside the role, the user's
52
52
  * attributes (its row filters' values) and, where it lacks one its role's filters read, that its statements are refused.
53
+ * `readOnly`: `/api/state` `pageReadOnly` (MASON_UI_READ_ONLY=1) — no Disable, Enable or Remove, and the page says why.
53
54
  */
54
- export function renderUsers(list, confirming = null, now = Date.now(), refused = null, roles = null) {
55
+ export function renderUsers(list, confirming = null, now = Date.now(), refused = null, roles = null, readOnly = null) {
55
56
  const users = [...list.users.filter((u) => u.removedAt == null), ...list.users.filter((u) => u.removedAt != null)];
56
- if (!users.length) return empty("No users yet", `A user is the name and password psql, a BI tool or your app connects with. Add one above, as ${code("mason user add <name>")} does: its URL, password included, is shown once.`);
57
+ const why = readOnlyLine("Users are not added, disabled or removed", readOnly);
58
+ if (!users.length) return why + empty("No users yet", readOnly !== null ? `A user is the name and password psql, a BI tool or your app connects with, as ${code("mason user add <name>")} adds one.`
59
+ : `A user is the name and password psql, a BI tool or your app connects with. Add one above, as ${code("mason user add <name>")} does: its URL, password included, is shown once.`);
57
60
  const note = (what, text) => text ? `<p class="dim">${esc(what)}: ${esc(text)}.</p>` : "";
58
61
  const broken = roles && roles.problem != null ? roles : null;
59
62
  const brokenNote = broken ? `<p class="warn">${esc(`${broken.file ?? "mason/roles.yml"} cannot be read as it is now, so every statement of a user held to it is refused: ${broken.problem}`)}</p>` : "";
60
63
  // a Remove's or a Disable's question is a popover beside the user's actions (`pop`), never a row: no row below it moves
61
64
  const pops = [];
62
65
  const ask = askOf(confirming);
63
- return brokenNote + panel(`<table class="users"><thead><tr><th scope="col">User</th><th scope="col" class="role">Role</th><th scope="col" class="status">Status</th><th scope="col" class="added">Added</th><th scope="col" class="num last">Last used</th><th scope="col" class="act"></th></tr></thead><tbody>${users.map((u) => {
66
+ return why + brokenNote + panel(`<table class="users"><thead><tr><th scope="col">User</th><th scope="col" class="role">Role</th><th scope="col" class="status">Status</th><th scope="col" class="added">Added</th><th scope="col" class="num last">Last used</th><th scope="col" class="act"></th></tr></thead><tbody>${users.map((u) => {
64
67
  const active = u.removedAt == null;
65
68
  const last = lastUsedText(u, list.lastUsedNote, list.requestLogDays, now);
66
69
  const lastCell = !active ? "" : u.lastUsedAt != null
@@ -85,7 +88,7 @@ export function renderUsers(list, confirming = null, now = Date.now(), refused =
85
88
  `${active && !disabled && !u.live ? ` <span class="warn">does not connect: the API key was rotated since it was added</span>` : ""}</td>` +
86
89
  `<td class="mono">${esc(stamp(u.addedAt))} UTC</td><td class="num">${lastCell}</td>` +
87
90
  // Queries: its statements of the last 24 hours on Observability › Queries (a removed user's too: its past)
88
- `<td class="act" id="${esc(anchor)}"><a class="btn small" href="${esc(userQueriesHref(u.name))}" title="the statements ${esc(u.name)} sent in the last 24 hours">Queries</a>${!active ? "" : ` ${toggle(u, disabled, asking === "disable")} ${asking === "remove" ? button("Remove", {}, "danger small", { disabled: true, expanded: true })
91
+ `<td class="act" id="${esc(anchor)}"><a class="btn small" href="${esc(userQueriesHref(u.name))}" title="the statements ${esc(u.name)} sent in the last 24 hours">Queries</a>${!active || readOnly !== null ? "" : ` ${toggle(u, disabled, asking === "disable")} ${asking === "remove" ? button("Remove", {}, "danger small", { disabled: true, expanded: true })
89
92
  : button("Remove", { remove: u.name }, "danger small")}`}</td></tr>`;
90
93
  }).join("")}</tbody></table>`) + pops.join("") + note("Last used", list.lastUsedNote);
91
94
  }
@@ -107,6 +110,32 @@ export function renderUsersConnect(pg, served = false) {
107
110
  `<p class="tip">A user's name and password go in place of ${code("<user>")} and ${code("<password>")}. The password is shown once, when the user is added: lost, remove the user and add it again.</p></div>`;
108
111
  }
109
112
 
113
+ /**
114
+ * THE ADD FORM, from `/api/state` (`state`: its body, undefined until it is read): Name and Add a user, as `mason user
115
+ * add <name>` — none while the state is unread, and none under `pageReadOnly` (MASON_UI_READ_ONLY=1), where it could
116
+ * only fail. Where the roles file names roles and reads as it is (`state.roles`, `mason status --json`'s), a Role
117
+ * choice too, as `--role`: the first option is what `mason user add <name>` gives with no `--role` — the file's
118
+ * `default:`, marked and sending no role, so the user follows the default as the CLI's does; or, with no default, no
119
+ * role — then each other role, sent as `role`. The server judges the role (src/cli/user.ts roleToAdd). Written the
120
+ * same while nothing changes, so a poll never rewrites it under a name being typed.
121
+ */
122
+ export function renderAddUser(state) {
123
+ if (state === undefined || state === null || (state.pageReadOnly ?? null) !== null) return "";
124
+ return `<form id="add-user" class="formcard" autocomplete="off"><label for="user-name">Name</label>` +
125
+ `<input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">` +
126
+ roleChoice(state.roles) + `<button type="submit" class="primary">Add a user</button></form>`;
127
+ }
128
+
129
+ /** The add form's Role choice from `/api/state` `roles`: "" with no roles file, one naming none, or one that cannot be
130
+ * read now (the roles it names are not known then) */
131
+ function roleChoice(r) {
132
+ if (!r || r.file == null || r.problem != null || !Array.isArray(r.roles) || r.roles.length === 0) return "";
133
+ const dflt = r.default != null && r.roles.includes(r.default) ? r.default : null;
134
+ const first = dflt !== null ? `<option value="" selected>${esc(dflt)} (default)</option>` : `<option value="" selected>no role: its statements are refused</option>`;
135
+ return ` <span class="role-field"><label for="user-role">Role</label> <select id="user-role" name="role">${first}` +
136
+ `${r.roles.filter((n) => n !== dflt).map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("")}</select></span> `;
137
+ }
138
+
110
139
  /** A user's statements: Observability › Queries filtered to it, over the last 24 hours (Now's 15 minutes would mostly be empty). */
111
140
  export const userQueriesHref = (name) => `#observability/queries?${new URLSearchParams({ user: name, range: "24h" })}`;
112
141
 
@@ -141,9 +170,10 @@ export const page = {
141
170
  const m = { actionFailure: null, added: null, confirming: null, refused: null };
142
171
  const users = () => ctx.read(USERS)?.body ?? null;
143
172
  const actions = {
144
- async addUser(name) {
173
+ /** `role`: the Role choice's, sent as mason user add --role; "" or none: no role, as with no --role */
174
+ async addUser(name, role = "") {
145
175
  m.actionFailure = null;
146
- const r = await ctx.ask(USERS_POST, json("POST", { name }));
176
+ const r = await ctx.ask(USERS_POST, json("POST", { name, ...(role ? { role } : {}) }));
147
177
  if ("failure" in r) { m.actionFailure = r.failure; return false; }
148
178
  m.added = r.body;
149
179
  await ctx.refresh([USERS]);
@@ -157,7 +187,7 @@ export const page = {
157
187
  async toggleUser(name, verb) {
158
188
  m.actionFailure = null;
159
189
  m.refused = null;
160
- const r = await ctx.ask(`/api/users/${encodeURIComponent(name)}/${verb}`, json("POST"));
190
+ const r = await ctx.ask(`api/users/${encodeURIComponent(name)}/${verb}`, json("POST"));
161
191
  m.confirming = null;
162
192
  if ("failure" in r) { m.refused = { name, failure: r.failure }; return false; }
163
193
  await ctx.refresh([USERS]);
@@ -167,7 +197,7 @@ export const page = {
167
197
  enableUser(name) { return actions.toggleUser(name, "enable"); },
168
198
  async confirmRemove(name) {
169
199
  m.actionFailure = null;
170
- const r = await ctx.ask(`/api/users/${encodeURIComponent(name)}`, json("DELETE"));
200
+ const r = await ctx.ask(`api/users/${encodeURIComponent(name)}`, json("DELETE"));
171
201
  m.confirming = null;
172
202
  // refused: said beside the user's Remove, as its question was (nothing above the table moves it)
173
203
  if ("failure" in r) { m.refused = { name, failure: r.failure }; return false; }
@@ -192,7 +222,8 @@ export const page = {
192
222
  const u = users();
193
223
  return {
194
224
  // the roles file's state is the run's (/api/state roles): a file that cannot be read refuses every user held to it
195
- users: u ? renderUsers(u, m.confirming, ctx.now().getTime(), m.refused, ctx.read(STATE)?.body?.roles ?? null) : "",
225
+ users: u ? renderUsers(u, m.confirming, ctx.now().getTime(), m.refused, ctx.read(STATE)?.body?.roles ?? null, ctx.read(STATE)?.body?.pageReadOnly ?? null) : "",
226
+ "add-user-box": renderAddUser(ctx.read(STATE)?.body),
196
227
  "users-connect": renderUsersConnect(ctx.read(STATE)?.body?.postgres, ctx.read(STATE)?.body?.served === true),
197
228
  added: m.added ? renderAdded(m.added) : "",
198
229
  action: m.actionFailure ? renderFailure(m.actionFailure) : "",
@@ -201,7 +232,7 @@ export const page = {
201
232
  submit(form) {
202
233
  if (form.id !== "add-user") return null;
203
234
  const input = form.elements.name;
204
- return actions.addUser(input.value.trim()).then((ok) => { if (ok) input.value = ""; });
235
+ return actions.addUser(input.value.trim(), form.elements.role?.value ?? "").then((ok) => { if (ok) input.value = ""; });
205
236
  },
206
237
  click(d, button, { doc, win }) {
207
238
  if (d.remove !== undefined) return actions.askRemove(d.remove) ?? true;
@@ -65,7 +65,7 @@ export const rangeEvery = (range, everyMs) => isLive(range) ? everyMs : Infinity
65
65
  export const rangeOf = (text) => Object.hasOwn(RANGES, String(text ?? "")) ? String(text) : DEFAULT_RANGE;
66
66
  /** The usage samples of a range, its step a point (src/cli/run-server.ts `usage`): Usage's read, and the Overview's
67
67
  * sparklines (the 24 hours', by name). */
68
- export const usagePath = (range = DEFAULT_RANGE) => { const r = RANGES[rangeOf(range)]; return `/api/usage?since=${r.since}&step=${r.stepSeconds}`; };
68
+ export const usagePath = (range = DEFAULT_RANGE) => { const r = RANGES[rangeOf(range)]; return `api/usage?since=${r.since}&step=${r.stepSeconds}`; };
69
69
 
70
70
  /** How often the shown page is asked while the tab is seen (src/web/app.js polls; a page's every-poll reads use it). */
71
71
  export const POLL_MS = 2000;
@@ -481,9 +481,10 @@ export const USERS_HASH = "#users";
481
481
  * Postgres API's URL with `<user>:<password>` in the credentials' place — the line `mason start` and `mason status`
482
482
  * print — and its psql line, each with Copy, and Create a user (the Users page), whose name and password go in that
483
483
  * place. `first` (nothing has asked Mason anything yet) leads with what the URL is for. Nothing from a run that does
484
- * not say it.
484
+ * not say it. `readOnly`: `/api/state` `pageReadOnly` (MASON_UI_READ_ONLY=1) — no Create a user, whose page could only
485
+ * refuse it; the URL, psql and their Copy stay.
485
486
  */
486
- export function renderConnect(pg, { first = false, table = null, served = false, share = "" } = {}) {
487
+ export function renderConnect(pg, { first = false, table = null, served = false, share = "", readOnly = null } = {}) {
487
488
  if (!pg || typeof pg.shown !== "string") return "";
488
489
  // a first statement to type that the request log lists (one that reads a copied table: UNLISTED_STATEMENTS)
489
490
  const tryIt = first && table ? `<dt>Then try</dt><dd>${copyable("connect-try", `select count(*) from ${entryName(table)};`)} <span class="dim">it shows on Queries</span></dd>` : "";
@@ -491,9 +492,14 @@ export function renderConnect(pg, { first = false, table = null, served = false,
491
492
  (first ? `<p class="lead">Mason answers SQL over the Postgres protocol, as a read replica of your database would: point psql, a BI tool or your app's analytics code at this URL.</p>` : "") +
492
493
  `<dl class="facts"><dt>URL</dt><dd>${copyable("connect-url", pg.shown)}</dd><dt>psql</dt><dd>${copyable("connect-psql", pg.psql)}</dd>${tryIt}</dl>` +
493
494
  (served ? `<p class="tip">${serverHostNote(pg)}</p>` : "") +
494
- `<p class="tip"><a class="btn${first ? " primary" : ""}" href="${USERS_HASH}">Create a user</a> <span>Its name and password go in place of ${code("<user>:<password>")}: the Users page shows the whole URL once, as ${code("mason user add <name>")} prints it.</span></p>${share}</div>`;
495
+ (readOnly !== null ? `<p class="tip">A user's name and password go in place of ${code("<user>:<password>")}: ${code("mason user add <name>")} prints the whole URL once.</p>`
496
+ : `<p class="tip"><a class="btn${first ? " primary" : ""}" href="${USERS_HASH}">Create a user</a> <span>Its name and password go in place of ${code("<user>:<password>")}: the Users page shows the whole URL once, as ${code("mason user add <name>")} prints it.</span></p>`) + `${share}</div>`;
495
497
  }
496
498
 
499
+ /** Why a page offers no control that changes anything (`/api/state` `pageReadOnly`), in its own words — `what` is what is
500
+ * not done from it ("Tables are not added, removed or synced") — as the Tables and Users pages say it; "" when it may. */
501
+ export const readOnlyLine = (what, why) => why == null ? "" : `<p class="count">${esc(what)} from this page: ${esc(why)}.</p>`;
502
+
497
503
  /** On a server's page the Postgres URL's host is the server's own view of itself: said beside it, as a tool elsewhere needs the server's address. */
498
504
  export const serverHostNote = (pg) => `This is the address on the server: from elsewhere a tool connects to the server's own address, in place of ${code(pg.host ?? "localhost")}.`;
499
505
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mason-data/cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "mason start: point Mason at your Postgres and pick the tables to copy. Fast analytics for the database your app already has.",
5
5
  "keywords": ["mason", "analytics", "postgres", "duckdb", "cli"],
6
6
  "homepage": "https://github.com/embeddable-hq/mason-data#readme",
@@ -23,10 +23,10 @@
23
23
  "yaml": "^2.6.0"
24
24
  },
25
25
  "optionalDependencies": {
26
- "@mason-data/extensions-darwin-arm64": "0.9.0",
27
- "@mason-data/extensions-darwin-x64": "0.9.0",
28
- "@mason-data/extensions-linux-arm64": "0.9.0",
29
- "@mason-data/extensions-linux-x64": "0.9.0",
30
- "@mason-data/extensions-win32-x64": "0.9.0"
26
+ "@mason-data/extensions-darwin-arm64": "0.11.0",
27
+ "@mason-data/extensions-darwin-x64": "0.11.0",
28
+ "@mason-data/extensions-linux-arm64": "0.11.0",
29
+ "@mason-data/extensions-linux-x64": "0.11.0",
30
+ "@mason-data/extensions-win32-x64": "0.11.0"
31
31
  }
32
32
  }