@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.
- package/dist/src/cli/commands.js +6 -3
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/project.js +24 -1
- package/dist/src/cli/run-server.js +46 -30
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +117 -7
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
|
@@ -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
|
+
}
|
package/dist/src/web/app.css
CHANGED
|
@@ -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); }
|
package/dist/src/web/app.js
CHANGED
|
@@ -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: "
|
|
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("
|
|
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("
|
|
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)
|
package/dist/src/web/index.html
CHANGED
|
@@ -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="
|
|
11
|
-
<link rel="stylesheet" href="
|
|
12
|
-
<script type="module" src="
|
|
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
|
-
|
|
73
|
-
|
|
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 = "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = ["
|
|
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: "
|
|
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("
|
|
510
|
+
afterRead: () => share.afterRead(ctx.read("api/state")?.body),
|
|
505
511
|
input: (target) => share.input(target),
|
|
506
512
|
view() {
|
|
507
|
-
const st = ctx.read("
|
|
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(
|
|
539
|
-
if ("failure" in r && r.failure.code === "not_found" && beating?.user === user) { ended(user, r.failure.message.replace(/^.*?; it ended: /, "")); await ctx.refresh(["
|
|
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("
|
|
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("
|
|
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(["
|
|
574
|
+
await ctx.refresh(["api/state"]);
|
|
569
575
|
},
|
|
570
576
|
async shareStop(user) {
|
|
571
|
-
const r = await ctx.ask(
|
|
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(["
|
|
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) =>
|
|
19
|
-
const LOGS = "
|
|
20
|
-
const 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 = "
|
|
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) =>
|
|
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
|
|
203
|
-
const
|
|
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 + (
|
|
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("
|
|
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("
|
|
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(["
|
|
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("
|
|
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(["
|
|
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("
|
|
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(["
|
|
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: "
|
|
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;
|