@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.
- package/README.md +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- 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/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- 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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADDRESSES AND ADDRESS RANGES, READ BY ONE GRAMMAR: `MASON_PG_PROXY_FROM` (src/api/postgres/proxy.ts) and a `mason
|
|
3
|
+
* user`'s address ranges (src/duck/users.ts `from`) both read an entry here. An entry is an IPv4 or IPv6 address, alone
|
|
4
|
+
* (one address: /32 or /128) or with its prefix length, held to its family (`10.0.0.0/33` and `999.999.999.999/99` are
|
|
5
|
+
* refused), with no bits set past the prefix (`10.0.1.5/24` is refused with its network as the guess), and no host name
|
|
6
|
+
* (never looked up) nor zone index. An address is first held to `node:net`'s own reading (`isIPv4`, `isIPv6`), then
|
|
7
|
+
* read into its bytes here, so nothing either reading refuses is ever taken.
|
|
8
|
+
*
|
|
9
|
+
* EVERY ADDRESS IN ONE SPACE, as Mason means one: IPv6's 128 bits, an IPv4 address at its v4-mapped place
|
|
10
|
+
* (::ffff:a.b.c.d), since a v4-mapped peer or header source IS that IPv4 client (`shownAddress`). What an entry names and
|
|
11
|
+
* what a list covers are both read here, never from how an entry is spelt: `0.0.0.0/1,128.0.0.0/1` is every IPv4
|
|
12
|
+
* address, as `0.0.0.0/0` is, and `::/64` holds every v4-mapped address, so every IPv4 one too.
|
|
13
|
+
*/
|
|
14
|
+
import * as net from "node:net";
|
|
15
|
+
/** An address as Mason shows it: IPv4 dotted, IPv6 in RFC 5952's form, a v4-mapped IPv6 one as IPv4; null when it is no
|
|
16
|
+
* address (undefined, a zone index, a name). */
|
|
17
|
+
export function shownAddress(text) {
|
|
18
|
+
if (text === undefined)
|
|
19
|
+
return null;
|
|
20
|
+
const v4 = ipv4Bytes(text);
|
|
21
|
+
if (v4 !== null)
|
|
22
|
+
return Array.from(v4).join(".");
|
|
23
|
+
const v6 = ipv6Bytes(text);
|
|
24
|
+
return v6 === null ? null : ipv6Text(v6);
|
|
25
|
+
}
|
|
26
|
+
/** An IPv4 address's 4 bytes, from dotted decimal with no leading zero; null for anything else. */
|
|
27
|
+
function ipv4Bytes(text) {
|
|
28
|
+
if (!net.isIPv4(text))
|
|
29
|
+
return null;
|
|
30
|
+
const parts = text.split(".");
|
|
31
|
+
if (parts.length !== 4 || !parts.every((p) => /^(?:0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255))
|
|
32
|
+
return null;
|
|
33
|
+
return Uint8Array.from(parts.map(Number));
|
|
34
|
+
}
|
|
35
|
+
/** An IPv6 address's 16 bytes (`::` once at most, an IPv4 tail); null for anything else, a zone index included. */
|
|
36
|
+
export function ipv6Bytes(text) {
|
|
37
|
+
// node:net takes a zone index (fe80::1%eth0): an address with one names no place here
|
|
38
|
+
if (!/^[0-9A-Fa-f:.]+$/.test(text) || !net.isIPv6(text))
|
|
39
|
+
return null;
|
|
40
|
+
const halves = text.split("::");
|
|
41
|
+
if (halves.length > 2 || (halves.length === 2 && halves[0].includes(".")))
|
|
42
|
+
return null;
|
|
43
|
+
const groups = (s) => {
|
|
44
|
+
if (s === "")
|
|
45
|
+
return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
const parts = s.split(":");
|
|
48
|
+
for (const [i, p] of parts.entries()) {
|
|
49
|
+
if (i === parts.length - 1 && p.includes(".")) {
|
|
50
|
+
const v4 = ipv4Bytes(p);
|
|
51
|
+
if (v4 === null)
|
|
52
|
+
return null;
|
|
53
|
+
out.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]);
|
|
54
|
+
}
|
|
55
|
+
else if (/^[0-9A-Fa-f]{1,4}$/.test(p))
|
|
56
|
+
out.push(parseInt(p, 16));
|
|
57
|
+
else
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const head = groups(halves[0]), tail = halves.length === 2 ? groups(halves[1]) : [];
|
|
63
|
+
if (head === null || tail === null)
|
|
64
|
+
return null;
|
|
65
|
+
const n = head.length + tail.length;
|
|
66
|
+
if (halves.length === 1 ? n !== 8 : n > 7)
|
|
67
|
+
return null;
|
|
68
|
+
const all = [...head, ...new Array(8 - n).fill(0), ...tail];
|
|
69
|
+
return Uint8Array.from(all.flatMap((g) => [g >> 8, g & 0xff]));
|
|
70
|
+
}
|
|
71
|
+
/** whether 16 bytes are an IPv4 address mapped into IPv6 (::ffff:a.b.c.d) */
|
|
72
|
+
const v4Mapped = (b) => b.subarray(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff;
|
|
73
|
+
/** 16 bytes as Mason shows an IPv6 address: RFC 5952 (lower case, the longest run of two or more zero groups as `::`,
|
|
74
|
+
* the first of equal runs), and a v4-mapped one as its IPv4 address. */
|
|
75
|
+
export function ipv6Text(b) {
|
|
76
|
+
if (v4Mapped(b))
|
|
77
|
+
return Array.from(b.subarray(12, 16)).join(".");
|
|
78
|
+
const g = Array.from({ length: 8 }, (_, i) => (b[2 * i] << 8) | b[2 * i + 1]);
|
|
79
|
+
let at = -1, run = 0;
|
|
80
|
+
for (let i = 0; i < 8;) {
|
|
81
|
+
if (g[i] !== 0) {
|
|
82
|
+
i++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let j = i;
|
|
86
|
+
while (j < 8 && g[j] === 0)
|
|
87
|
+
j++;
|
|
88
|
+
if (j - i > run) {
|
|
89
|
+
at = i;
|
|
90
|
+
run = j - i;
|
|
91
|
+
}
|
|
92
|
+
i = j;
|
|
93
|
+
}
|
|
94
|
+
const hex = g.map((x) => x.toString(16));
|
|
95
|
+
return run < 2 ? hex.join(":") : `${hex.slice(0, at).join(":")}::${hex.slice(at + run).join(":")}`;
|
|
96
|
+
}
|
|
97
|
+
export const V4_START = 0xffffn << 32n;
|
|
98
|
+
export const V4_END = V4_START + (1n << 32n) - 1n;
|
|
99
|
+
export const ALL_END = (1n << 128n) - 1n;
|
|
100
|
+
const bigOf = (b) => b.reduce((n, x) => (n << 8n) | BigInt(x), 0n);
|
|
101
|
+
/** an address's place (`V4_START`), or null when it is no address */
|
|
102
|
+
function placeOf(address) {
|
|
103
|
+
const v4 = ipv4Bytes(address);
|
|
104
|
+
if (v4 !== null)
|
|
105
|
+
return V4_START | bigOf(v4);
|
|
106
|
+
const v6 = ipv6Bytes(address);
|
|
107
|
+
return v6 === null ? null : bigOf(v6);
|
|
108
|
+
}
|
|
109
|
+
/** the places an entry names */
|
|
110
|
+
export function rangeOf(c) {
|
|
111
|
+
const start = c.family === 4 ? V4_START | bigOf(c.bytes) : bigOf(c.bytes);
|
|
112
|
+
return { start, end: start + (1n << BigInt((c.family === 4 ? 32 : 128) - c.prefix)) - 1n };
|
|
113
|
+
}
|
|
114
|
+
/** Whether `address` (as `shownAddress` shows one, or in any spelling of the same address) is in one of `ranges`; never
|
|
115
|
+
* for what is no address. */
|
|
116
|
+
export function inRanges(ranges, address) {
|
|
117
|
+
const at = placeOf(address);
|
|
118
|
+
return at !== null && ranges.some((r) => r.start <= at && at <= r.end);
|
|
119
|
+
}
|
|
120
|
+
/** whether `ranges` together hold every place from `lo` to `hi` */
|
|
121
|
+
export function covers(ranges, lo, hi) {
|
|
122
|
+
let next = lo;
|
|
123
|
+
for (const r of [...ranges].sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0))) {
|
|
124
|
+
if (r.start > next)
|
|
125
|
+
return false;
|
|
126
|
+
if (r.end >= next)
|
|
127
|
+
next = r.end + 1n;
|
|
128
|
+
if (next > hi)
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return next > hi;
|
|
132
|
+
}
|
|
133
|
+
function prefixEqual(a, b, bits) {
|
|
134
|
+
for (let i = 0; i * 8 < bits; i++) {
|
|
135
|
+
const mask = bits - i * 8 >= 8 ? 0xff : (0xff << (8 - (bits - i * 8))) & 0xff;
|
|
136
|
+
if ((a[i] & mask) !== (b[i] & mask))
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/** the network `bytes` / `prefix` names, its host bits cleared */
|
|
142
|
+
function network(bytes, prefix) {
|
|
143
|
+
return bytes.map((x, i) => i * 8 >= prefix ? 0 : prefix - i * 8 >= 8 ? x : x & ((0xff << (8 - (prefix - i * 8))) & 0xff));
|
|
144
|
+
}
|
|
145
|
+
const text = (family, bytes) => family === 4 ? Array.from(bytes).join(".") : ipv6Text(bytes);
|
|
146
|
+
/** An entry as Mason writes it back: the network in `shownAddress`'s form, its prefix only when it is not one address. */
|
|
147
|
+
export const cidrText = (c) => `${text(c.family, c.bytes)}${c.prefix === (c.family === 4 ? 32 : 128) ? "" : `/${c.prefix}`}`;
|
|
148
|
+
/**
|
|
149
|
+
* One entry read, or what is wrong with it, with a did-you-mean where one is a fair guess: a guess is offered only
|
|
150
|
+
* once it is read as an entry itself, so it is never one that would be refused in turn. `everyRefused`: a prefix of 0
|
|
151
|
+
* (every address of a family) is refused with these words (a PROXY header believed from anyone); else it is taken.
|
|
152
|
+
*/
|
|
153
|
+
export function cidrOf(entry, everyRefused = null) {
|
|
154
|
+
const quoted = JSON.stringify(entry);
|
|
155
|
+
const m = /^([^/]*)(?:\/(\d{1,3}))?$/.exec(entry);
|
|
156
|
+
const v4 = m === null ? null : ipv4Bytes(m[1]), v6 = m === null || v4 !== null ? null : ipv6Bytes(m[1]);
|
|
157
|
+
if (m === null || (v4 === null && v6 === null)) {
|
|
158
|
+
return { problem: `${quoted} is not an address or a CIDR (10.0.0.0/16, 2001:db8::/32, or one address such as 10.0.4.7; a host name is not looked up)` };
|
|
159
|
+
}
|
|
160
|
+
const family = v4 !== null ? 4 : 6, bytes = (v4 ?? v6), max = family === 4 ? 32 : 128;
|
|
161
|
+
const prefix = m[2] === undefined ? max : Number(m[2]);
|
|
162
|
+
if (prefix > max)
|
|
163
|
+
return { problem: `${quoted}: an IPv${family} prefix is at most /${max}` };
|
|
164
|
+
if (family === 6 && v4Mapped(bytes) && prefix >= 96) {
|
|
165
|
+
const p4 = prefix - 96, net4 = network(bytes.subarray(12, 16), p4);
|
|
166
|
+
const meant = `${text(4, net4)}${p4 === 32 ? "" : `/${p4}`}`, read = cidrOf(meant, everyRefused);
|
|
167
|
+
return { problem: "problem" in read
|
|
168
|
+
? `${quoted} is IPv4 written as IPv6, which Mason reads as ${meant}, and ${read.problem}`
|
|
169
|
+
: `${quoted} is IPv4 written as IPv6, which Mason reads as IPv4: did you mean ${meant}?` };
|
|
170
|
+
}
|
|
171
|
+
if (prefix === 0 && everyRefused !== null)
|
|
172
|
+
return { problem: `${quoted} names every address, ${everyRefused}` };
|
|
173
|
+
const net = network(bytes, prefix);
|
|
174
|
+
if (!prefixEqual(net, bytes, max))
|
|
175
|
+
return { problem: `${quoted} has bits set past its prefix: did you mean ${text(family, net)}/${prefix}?` };
|
|
176
|
+
return { family, bytes: net, prefix };
|
|
177
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* in the same words at both entrances:
|
|
6
6
|
*
|
|
7
7
|
* off nothing after the first copy until `mason sync <table>`, which polls it once, now (the default)
|
|
8
|
-
* poll <interval> the xmin poll every <interval>: <n>s, <n>m or <n>
|
|
8
|
+
* poll <interval> the xmin poll every <interval>: <n>s, <n>m, <n>h or <n>d, a whole number, at least 1s, at most 24h
|
|
9
9
|
* replication the database's change log, through the project's ONE slot and publication (src/duck/stream.ts):
|
|
10
10
|
* the publication carries the replicated tables only, and the polled ones are polled beside it
|
|
11
11
|
*
|
|
@@ -34,11 +34,11 @@ export const BIG_TABLE_ROWS = 1_000_000;
|
|
|
34
34
|
export const COSTLY_POLL_ROWS = 10_000_000;
|
|
35
35
|
export const COSTLY_POLL_SECONDS = 60;
|
|
36
36
|
const MODES = ["off", "poll", "replication"];
|
|
37
|
-
const UNIT_SECONDS = { s: 1, m: 60, h: 3600 };
|
|
37
|
+
const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86_400 };
|
|
38
38
|
/** the units a person may write for one of ours, said back as the one to write */
|
|
39
39
|
const UNIT_WORDS = {
|
|
40
40
|
s: "s", sec: "s", secs: "s", second: "s", seconds: "s", m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
|
|
41
|
-
h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
|
|
41
|
+
h: "h", hr: "h", hrs: "h", hour: "h", hours: "h", d: "d", day: "d", days: "d",
|
|
42
42
|
};
|
|
43
43
|
const EXAMPLES = "poll 5s, poll 5m or poll 1h";
|
|
44
44
|
/** what a sync is, said where one is wrong */
|
|
@@ -52,23 +52,24 @@ export function intervalOf(seconds) {
|
|
|
52
52
|
const s = Math.max(MIN_POLL_SECONDS, Math.round(seconds));
|
|
53
53
|
return s % 3600 === 0 ? `${s / 3600}h` : s % 60 === 0 ? `${s / 60}m` : `${s}s`;
|
|
54
54
|
}
|
|
55
|
-
/** an interval (`<n>s|m|h`) read, or why it is not one, with the interval to write when there is one. ONE grammar with
|
|
56
|
-
* entrances, each worded as it is written: a `sync:`'s `poll <interval
|
|
57
|
-
* command's
|
|
55
|
+
/** an interval (`<n>s|m|h|d`) read, or why it is not one, with the interval to write when there is one. ONE grammar with
|
|
56
|
+
* two entrances, each worded as it is written: a `sync:`'s `poll <interval>` and a generated table's `target_lag:`. Its
|
|
57
|
+
* units are a command line's duration's too (`parseDuration`), which also takes a bare number of seconds: a file writes
|
|
58
|
+
* the unit, one spelling of each interval. */
|
|
58
59
|
function parseInterval(v, write = "poll ") {
|
|
59
60
|
const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(v);
|
|
60
61
|
const examples = write === "" ? "5s, 5m or 1h" : EXAMPLES;
|
|
61
|
-
const how = `an interval is <n>s, <n>m or <n>
|
|
62
|
+
const how = `an interval is <n>s, <n>m, <n>h or <n>d, a whole number, at least ${MIN_POLL_SECONDS}s (${examples})`;
|
|
63
|
+
if (/^\d+$/.test(v))
|
|
64
|
+
return { problem: `${v} has no unit: write ${write}${Number(v)}s for seconds (or m, h, d)` };
|
|
62
65
|
if (m === null)
|
|
63
66
|
return { problem: `${JSON.stringify(v)} is not an interval: ${how}` };
|
|
64
67
|
const n = Number(m[1]), word = m[2].toLowerCase();
|
|
65
68
|
if (word === "ms")
|
|
66
69
|
return { problem: `${v} is under the shortest interval, ${MIN_POLL_SECONDS}s${write === "" ? "" : " (a poll scans the table whole on your database)"}: write ${write}${MIN_POLL_SECONDS}s or longer` };
|
|
67
70
|
const unit = UNIT_WORDS[word];
|
|
68
|
-
if (unit === undefined)
|
|
69
|
-
|
|
70
|
-
return { problem: `${v}: ${word} is not a unit of an interval — ${days ? `days are hours${days}` : how}` };
|
|
71
|
-
}
|
|
71
|
+
if (unit === undefined)
|
|
72
|
+
return { problem: `${v}: ${word} is not a unit of an interval — ${how}` };
|
|
72
73
|
const seconds = n * UNIT_SECONDS[unit];
|
|
73
74
|
if (!Number.isInteger(n))
|
|
74
75
|
return { problem: `${v}: an interval is a whole number — write ${write}${intervalOf(seconds)}` };
|
|
@@ -89,10 +90,41 @@ export function parseLag(v) {
|
|
|
89
90
|
return { problem: `is ${v === null || v === undefined ? "nothing" : typeof v === "number" ? `the number ${String(v)}` : "not an interval"}: write how far behind the table may be, like 30s, 5m or 1h` };
|
|
90
91
|
return parseInterval(v.trim(), "");
|
|
91
92
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
/** How a duration on a command line is written: said where one is wrong, and `<duration>` in each usage. */
|
|
94
|
+
export const DURATION_FORMS = "a number of seconds, or <n>s, <n>m, <n>h or <n>d (90, 30s, 10m, 1h, 7d)";
|
|
95
|
+
/**
|
|
96
|
+
* A DURATION ON A COMMAND LINE, ONE GRAMMAR FOR EVERY FLAG THAT TAKES ONE (`--timeout` of mason wait, share, ui --ssh,
|
|
97
|
+
* deploy and replica create; mason usage's `--step`; the age `--since` / `--until` take, src/cli/request-filter.ts
|
|
98
|
+
* `timeOf`, which refuses a bare number as a time): a bare number is seconds, as wait, share and ui read it before; `<n>s|m|h|d`, and the words a sync's
|
|
99
|
+
* interval takes (`5 min`, `2 hours`, `7 days`), as a `sync:` interval and a `target_lag:` are written. A superset of
|
|
100
|
+
* every form each flag took before, so nothing that parsed stops parsing. The range is the flag's (`min`, `max` in
|
|
101
|
+
* seconds; over 0, unless `zero`: an age of now, `--since 0s`, and `--step 0s`, every sample, as each took it before);
|
|
102
|
+
* the caller names the flag.
|
|
103
|
+
*/
|
|
104
|
+
export function parseDuration(v, o = {}) {
|
|
105
|
+
const t = v.trim();
|
|
106
|
+
let seconds, every;
|
|
107
|
+
if (t !== "" && Number.isFinite(Number(t))) {
|
|
108
|
+
seconds = Number(t);
|
|
109
|
+
every = `${seconds}s`;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const m = /^(\d+(?:\.\d+)?)\s*([a-z]+)$/i.exec(t);
|
|
113
|
+
const unit = m === null ? undefined : UNIT_WORDS[m[2].toLowerCase()];
|
|
114
|
+
if (m === null || unit === undefined)
|
|
115
|
+
return { problem: `${JSON.stringify(v)} is not a duration: write ${DURATION_FORMS}` };
|
|
116
|
+
seconds = Number(m[1]) * UNIT_SECONDS[unit];
|
|
117
|
+
every = `${Number(m[1])}${unit}`;
|
|
118
|
+
}
|
|
119
|
+
if (o.zero === true && seconds === 0 && !Object.is(seconds, -0))
|
|
120
|
+
return { every, seconds: 0 };
|
|
121
|
+
if (!(seconds > 0))
|
|
122
|
+
return { problem: `${JSON.stringify(v)} is not a duration over 0: write ${DURATION_FORMS}` };
|
|
123
|
+
if (o.min !== undefined && seconds < o.min)
|
|
124
|
+
return { problem: `${every} is under the shortest, ${intervalOf(o.min)}` };
|
|
125
|
+
if (o.max !== undefined && seconds > o.max)
|
|
126
|
+
return { problem: `${every} is over the longest, ${intervalOf(o.max)}` };
|
|
127
|
+
return { every, seconds };
|
|
96
128
|
}
|
|
97
129
|
/**
|
|
98
130
|
* A `sync:` VALUE READ, or what is wrong with it in the words a person fixes it from (the key is the caller's to say):
|
|
@@ -186,9 +218,14 @@ function saidInTheFile(what) {
|
|
|
186
218
|
* every one given on ONE report, one coherent way out (a publication goes with replication). Null when none is given.
|
|
187
219
|
* Read from the raw command line, before the options are parsed, so a removed flag is answered as removed and never
|
|
188
220
|
* as an unknown option.
|
|
221
|
+
*
|
|
222
|
+
* `--port` IS NOW `--ui-port` (issue 729: it is the web page's and the commands' port, as MASON_UI_PORT is on a server,
|
|
223
|
+
* and read as the Postgres API's): answered on the same report with the line to run — `start`, built from the flags
|
|
224
|
+
* mason start takes alone, so nothing else of the line is repeated. `sync`: a sources-file flag was given.
|
|
189
225
|
*/
|
|
190
226
|
export function removedStartFlags(argv, top) {
|
|
191
|
-
let replication = false, poll = false, publication;
|
|
227
|
+
let replication = false, poll = false, publication, port = false;
|
|
228
|
+
const kept = [];
|
|
192
229
|
for (let i = 0; i < argv.length; i++) {
|
|
193
230
|
const a = argv[i];
|
|
194
231
|
if (a === "--")
|
|
@@ -203,10 +240,21 @@ export function removedStartFlags(argv, top) {
|
|
|
203
240
|
const v = argv[i + 1];
|
|
204
241
|
publication = v !== undefined && !v.startsWith("-") ? v : null;
|
|
205
242
|
}
|
|
243
|
+
else if (a === "--port" || a.startsWith("--port=")) {
|
|
244
|
+
const v = a === "--port" ? argv[i + 1] : a.slice("--port=".length);
|
|
245
|
+
if (a === "--port" && v !== undefined && !v.startsWith("-"))
|
|
246
|
+
i++;
|
|
247
|
+
port = true;
|
|
248
|
+
kept.push(`--ui-port ${v !== undefined && /^\d{1,5}$/.test(v) ? v : "<n>"}`);
|
|
249
|
+
}
|
|
250
|
+
else if (a === "--attach" || a === "--self-signed-tls" || a === "--json")
|
|
251
|
+
kept.push(a);
|
|
206
252
|
}
|
|
207
253
|
const given = [...(replication ? ["--replication"] : []), ...(poll ? ["--poll"] : []), ...(publication !== undefined ? ["--publication"] : [])];
|
|
254
|
+
const start = port ? ["mason start", ...kept].join(" ") : "mason start";
|
|
255
|
+
const portSaid = { message: "--port is now --ui-port", why: "it is the port of the web page and the mason commands (MASON_UI_PORT on a server), never the Postgres API's (MASON_PG_PORT)" };
|
|
208
256
|
if (given.length === 0)
|
|
209
|
-
return null;
|
|
257
|
+
return port ? { ...portSaid, fix: start, start, sync: false } : null;
|
|
210
258
|
const t = top();
|
|
211
259
|
const named = given.length === 1 ? `${given[0]} is gone` : `${given.slice(0, -1).join(", ")} and ${given.at(-1)} are gone`;
|
|
212
260
|
const pub = publication === undefined ? null : topEdit(t, "publication", [publication ?? "<name>"]);
|
|
@@ -230,8 +278,10 @@ export function removedStartFlags(argv, top) {
|
|
|
230
278
|
fix = `${edit} (${edit.startsWith("keep ") ? "every table is polled already; a table's entry may say its own"
|
|
231
279
|
: "any interval: poll 5s, poll 1h; or sync: off, the default, and mason sync <table> when you need it"})`;
|
|
232
280
|
}
|
|
233
|
-
|
|
234
|
-
|
|
281
|
+
const why = saidInTheFile({ sync: replication || poll, publication: publication !== undefined });
|
|
282
|
+
if (!port)
|
|
283
|
+
return { message: `${named}: the sources file says it now`, why, fix: `${fix}, then mason start`, start, sync: true };
|
|
284
|
+
return { message: `${named}: the sources file says it now; ${portSaid.message}`, why: `${why}; ${portSaid.why}`, fix: `${fix}, then ${start}`, start, sync: true };
|
|
235
285
|
}
|
|
236
286
|
/**
|
|
237
287
|
* `MASON_CHANGES` SET IS REFUSED, never run on a default (CLAUDE.md: a removed setting refuses the start, naming the
|
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
|
|