@join-pantheon/cli 0.23.1 → 0.23.2
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/relay.js +281 -186
- package/dist/worker.js +2 -0
- package/package.json +1 -1
package/dist/relay.js
CHANGED
|
@@ -76,41 +76,59 @@ function fingerprint_(from, act, body, goal, ref) {
|
|
|
76
76
|
return createHash("sha256").update([from, act, goal ?? "", ref ?? "", body].join("\u0000")).digest("hex").slice(0, 32);
|
|
77
77
|
}
|
|
78
78
|
const ADMIN_CSS = `<style>
|
|
79
|
-
:root{--bg:#0b0c0e;--card:#
|
|
80
|
-
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--line:#
|
|
81
|
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
79
|
+
:root{--bg:#0b0c0e;--card:#14161a;--card2:#1b1e24;--line:#262a30;--fg:#e7e9ec;--dim:#9aa1a9;--accent:#5b9dff;--warn:#e8b45c;--bad:#ef8b80;--ok:#7bd39a;color-scheme:dark}
|
|
80
|
+
@media(prefers-color-scheme:light){:root{--bg:#f6f7f9;--card:#fff;--card2:#f1f3f6;--line:#e3e6ea;--fg:#1a1c1f;--dim:#59616e;--accent:#2563eb;--warn:#b45309;--bad:#b91c1c;--ok:#15803d;color-scheme:light}}
|
|
81
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
82
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
83
|
+
header{position:sticky;top:0;z-index:5;background:var(--bg);padding:14px 28px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
|
84
|
+
h1{font-size:17px;margin:0;font-weight:650}.dim{color:var(--dim)}.meta{color:var(--dim);font-size:12.5px}
|
|
85
|
+
.links{margin-left:auto;display:flex;gap:14px;font-size:13px;flex-wrap:wrap}
|
|
86
|
+
nav.jump{display:flex;gap:4px;flex-wrap:wrap;padding:8px 22px;border-bottom:1px solid var(--line)}
|
|
87
|
+
nav.jump a{color:var(--dim);font-size:13px;padding:5px 10px;border-radius:6px}nav.jump a:hover{background:var(--card2);color:var(--fg);text-decoration:none}
|
|
88
|
+
main{padding:0 28px 48px;max-width:1560px}
|
|
89
|
+
section{margin-top:34px;scroll-margin-top:70px}
|
|
90
|
+
.sec{display:flex;align-items:baseline;gap:12px;margin:0 0 10px;flex-wrap:wrap}.sec h2{font-size:16px;margin:0;font-weight:650}.sec .count{color:var(--dim);font-size:13px}
|
|
91
|
+
.sec input[type=search]{margin-left:auto;font:13px system-ui;padding:6px 10px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg);width:240px}
|
|
92
|
+
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:12px;margin-top:22px}
|
|
93
|
+
.kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px 12px}
|
|
94
|
+
.kpi .l{color:var(--dim);font-size:11.5px;letter-spacing:.04em;text-transform:uppercase;font-weight:600}
|
|
95
|
+
.kpi .v{font-size:26px;font-weight:650;font-variant-numeric:tabular-nums;line-height:1.2;margin:6px 0 3px}
|
|
96
|
+
.kpi .v small{font-size:13.5px;color:var(--dim);font-weight:500;margin-left:5px}
|
|
97
|
+
.kpi .s{color:var(--dim);font-size:12.5px}.kpi.warn .v{color:var(--warn)}.kpi.bad .v{color:var(--bad)}
|
|
90
98
|
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px;background:var(--card)}
|
|
91
|
-
table{border-collapse:
|
|
92
|
-
th
|
|
93
|
-
th{color:var(--
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
.
|
|
99
|
+
table{border-collapse:separate;border-spacing:0;width:100%;font-variant-numeric:tabular-nums}
|
|
100
|
+
th{position:sticky;top:0;z-index:1;background:var(--card2);color:var(--dim);font-weight:600;font-size:12px;text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap}
|
|
101
|
+
th.group{text-align:center;color:var(--fg);border-left:1px solid var(--line);font-weight:650}th.first{border-left:1px solid var(--line)}
|
|
102
|
+
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top;white-space:nowrap}
|
|
103
|
+
tr:last-child>td{border-bottom:0}tbody tr:hover>td{background:var(--card2)}
|
|
104
|
+
td.num,th.num{text-align:right}td.wrapc{white-space:normal;min-width:280px;max-width:760px;overflow-wrap:anywhere}
|
|
105
|
+
.cell b{font-weight:600}.sub{display:block;color:var(--dim);font-size:12px;margin-top:2px;white-space:nowrap}
|
|
106
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
|
107
|
+
.pill{display:inline-block;font-size:11px;line-height:1.7;padding:0 7px;border-radius:999px;border:1px solid var(--line);color:var(--dim);white-space:nowrap;vertical-align:middle}
|
|
108
|
+
.pill.ok{color:var(--ok);border-color:var(--ok)}.pill.bad{color:var(--bad);border-color:var(--bad)}.pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
|
|
109
|
+
.inl{display:inline}.act{font:12px system-ui;padding:4px 9px;border-radius:6px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer}
|
|
97
110
|
.act:hover{border-color:var(--accent)}.act.danger{color:var(--bad)}.act.danger:hover{border-color:var(--bad)}
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
.note{color:var(--dim);font-size:
|
|
101
|
-
|
|
102
|
-
:focus-visible{outline:2px solid var(--accent);outline-offset:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
107
|
-
.
|
|
108
|
-
.
|
|
109
|
-
.
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
111
|
+
td.actions{text-align:right;white-space:nowrap}td.actions .inl+.inl,td.actions .act+.act{margin-left:6px}
|
|
112
|
+
input[type=number],input[type=text],input[type=search],select{font:13px system-ui;padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:var(--bg);color:var(--fg)}input[type=number]{width:74px}
|
|
113
|
+
.note{color:var(--dim);font-size:12.5px;margin:8px 0 0;max-width:84ch}
|
|
114
|
+
.done{margin:16px 28px 0;padding:10px 14px;border:1px solid var(--ok);color:var(--ok);border-radius:10px;background:var(--card)}
|
|
115
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}input::placeholder{color:var(--dim);opacity:1}
|
|
116
|
+
details.limits>summary{cursor:pointer;color:var(--accent);font-size:12px;list-style:none;display:inline}details.limits>summary::-webkit-details-marker{display:none}
|
|
117
|
+
.limitform{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;background:var(--card2);padding:8px 10px;border-radius:8px;margin-top:8px}
|
|
118
|
+
.limitform label{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim)}.limitform label.chk{flex-direction:row;align-items:center;gap:6px;padding-bottom:7px}
|
|
119
|
+
.filters{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin:0 0 12px}
|
|
120
|
+
.filters label{display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--dim)}.filters label.chk{flex-direction:row;align-items:center;gap:6px;padding-bottom:7px}
|
|
121
|
+
.more{font:12px system-ui;color:var(--accent);background:none;border:0;padding:0;cursor:pointer;white-space:nowrap}
|
|
122
|
+
tr.detail>td{background:var(--card2);white-space:normal;padding:14px 16px 16px}.body{white-space:pre-wrap;max-width:92ch;font-size:13.5px;line-height:1.6}
|
|
123
|
+
.kv{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;font-size:12.5px;color:var(--dim);margin-top:12px;max-width:92ch}.kv b{color:var(--fg);font-weight:500;overflow-wrap:anywhere}
|
|
124
|
+
.pager{display:flex;justify-content:space-between;margin:12px 0}
|
|
125
|
+
.empty{padding:22px;color:var(--dim)}
|
|
126
|
+
.tone-warn{color:var(--warn)}.tone-bad{color:var(--bad)}.tone-ok{color:var(--ok)}
|
|
127
|
+
.crumbs a{color:var(--dim)}
|
|
128
|
+
@media(max-width:700px){header,nav.jump{padding-left:16px;padding-right:16px}main{padding:0 16px 40px}.sec input[type=search]{width:100%;margin-left:0}.done{margin:12px 16px 0}}
|
|
113
129
|
</style>`;
|
|
130
|
+
const clipText = (v, max) => { const t = v.replace(/\s+/g, " ").trim(); if (t.length <= max)
|
|
131
|
+
return t; const cut = t.slice(0, max - 1); const sp = cut.lastIndexOf(" "); return `${(sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd()}…`; };
|
|
114
132
|
const escHtml = (v) => String(v ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
115
133
|
const fmtWhen = (t) => t ? new Date(t).toISOString().slice(0, 19).replace("T", " ") : "—";
|
|
116
134
|
const fmtAgo = (t) => {
|
|
@@ -868,11 +886,22 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
868
886
|
const target = A.accountById(id);
|
|
869
887
|
if (!target)
|
|
870
888
|
return this.json(res, 404, { error: "no such account" });
|
|
871
|
-
|
|
872
|
-
if (
|
|
889
|
+
let raw;
|
|
890
|
+
if (body.clear)
|
|
891
|
+
raw = null;
|
|
892
|
+
else if (body.json !== undefined)
|
|
893
|
+
raw = body.json.trim() || null;
|
|
894
|
+
else {
|
|
895
|
+
const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
|
|
896
|
+
if (!lim)
|
|
897
|
+
return this.json(res, 400, { error: "spaces must be 0 or more and people per space 1 or more" });
|
|
898
|
+
raw = JSON.stringify(lim);
|
|
899
|
+
}
|
|
900
|
+
if (!A.setLimits(target.id, raw))
|
|
873
901
|
return this.json(res, 400, { error: 'custom limits must look like {"spaces":3,"members":5,"wake":true}' });
|
|
874
|
-
|
|
875
|
-
|
|
902
|
+
const lim = raw ? parseLimits(JSON.parse(raw)) : null;
|
|
903
|
+
A.audit({ event: "admin_set_limits", target: target.username, detail: raw ?? "(cleared)", ip });
|
|
904
|
+
return done(lim ? `${target.username} now has custom limits: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people per space · unattended workers ${lim.wake ? "on" : "off"}` : `${target.username} is back on plain ${target.plan} limits`);
|
|
876
905
|
}
|
|
877
906
|
case "set_plan_limits": {
|
|
878
907
|
if (!PLAN_NAME.test(id))
|
|
@@ -989,90 +1018,139 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
989
1018
|
const done = url?.searchParams.get("done");
|
|
990
1019
|
const rows = (arr) => arr.join("");
|
|
991
1020
|
const form = (action, id, label, extra = "", cls = "") => `<form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value="${action}"><input type=hidden name=id value="${esc(id)}">${extra}<button class="act ${cls}">${label}</button></form>`;
|
|
1021
|
+
const paid = Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0);
|
|
1022
|
+
const beating = sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length;
|
|
1023
|
+
const obj24 = countActs(act.by_act_24h, ["OBJECT"]), esc24 = countActs(act.by_act_24h, ESCALATIONS), del24 = countActs(act.by_act_24h, ["DELIVERED"]);
|
|
1024
|
+
const staleBackup = !lastBackupPull || Date.now() - lastBackupPull.ts > 36 * 3_600_000;
|
|
1025
|
+
const GROUPS = [
|
|
1026
|
+
["Talk", ["NOTE", "ASK", "ANSWER"]], ["Agreements", ["PROPOSE", "ACCEPT", "OBJECT", "CONTRACT", "COMMIT"]],
|
|
1027
|
+
["Delivery", ["DELIVERED"]], ["Needs a human", ["ESCALATE", "HUMAN_REQUIRED"]],
|
|
1028
|
+
["Housekeeping", MESSAGE_ACTS.filter((x) => !["NOTE", "ASK", "ANSWER", "PROPOSE", "ACCEPT", "OBJECT", "CONTRACT", "COMMIT", "DELIVERED", "ESCALATE", "HUMAN_REQUIRED"].includes(x))],
|
|
1029
|
+
];
|
|
1030
|
+
const cols = GROUPS.flatMap(([, xs]) => xs);
|
|
1031
|
+
const actRow = (label, win, by, refused) => `<tr><td class=dim>${label}</td>${cols.map((x, i) => `<td class="num${GROUPS.some(([, xs]) => xs[0] === x) && i > 0 ? " first" : ""}" data-act="${x}" data-window="${win}">${by[x] ?? 0}</td>`).join("")}<td class="num first${refused ? " tone-warn" : ""}">${refused}</td></tr>`;
|
|
1032
|
+
const limitsOf = (a) => A.limitsFor(a);
|
|
1033
|
+
const limitText = (l) => `${fmtLimit(l.spaces)} space${l.spaces === 1 ? "" : "s"} · ${fmtLimit(l.members)} ${l.members === 1 ? "person" : "people"} per space · unattended workers ${l.wake ? "on" : "off"}`;
|
|
992
1034
|
const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin</title>
|
|
993
1035
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
994
1036
|
${ADMIN_CSS}
|
|
995
1037
|
<header>
|
|
996
1038
|
<h1>pantheon · admin</h1>
|
|
997
|
-
<span class=
|
|
998
|
-
<span
|
|
1039
|
+
<span class=meta>${esc(this.opts.host ?? "relay")} · pkg ${VERSION} · up ${upMin < 60 ? upMin + "m" : Math.round(upMin / 60) + "h"}</span>
|
|
1040
|
+
<span class=links><a href="/admin/export?table=audit&format=csv">export audit CSV</a><a href="/admin/export?table=accounts&format=json">accounts JSON</a><a href="/admin/backup">download backup</a><a href="/admin/logout">sign out</a></span>
|
|
999
1041
|
</header>
|
|
1042
|
+
<nav class=jump aria-label="Sections"><a href="#s-activity">Activity</a><a href="#s-spaces">Spaces</a><a href="#s-accounts">Accounts</a><a href="#s-plans">Plans</a><a href="#s-sessions">Sessions</a><a href="#s-blocks">Blocked</a><a href="#s-codes">Codes</a><a href="#s-audit">Audit log</a></nav>
|
|
1000
1043
|
${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
1001
|
-
<main
|
|
1002
|
-
<
|
|
1003
|
-
<div class=
|
|
1004
|
-
<div class=
|
|
1005
|
-
<div class=
|
|
1006
|
-
<div class=
|
|
1007
|
-
<div class=
|
|
1008
|
-
<div class=
|
|
1009
|
-
<div class="
|
|
1010
|
-
<div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
|
|
1011
|
-
<div class="stat ${!lastBackupPull || Date.now() - lastBackupPull.ts > 36 * 3_600_000 ? "warn" : ""}"><b>${lastBackupPull ? ago(lastBackupPull.ts) : "never"}</b><span>last off-host backup pull</span></div>
|
|
1012
|
-
<div class=stat><b>${c.audit}</b><span>audit events</span></div>
|
|
1013
|
-
<div class=stat><b>${act.messages_24h}<span class=dim style="font-size:14px"> / ${act.messages}</span></b><span>messages today / retained</span></div>
|
|
1014
|
-
<div class=stat><b>${act.senders_24h.length}<span class=dim style="font-size:14px"> · ${act.sessions_24h} sessions</span></b><span>delegates active today</span></div>
|
|
1015
|
-
<div class="stat ${countActs(act.by_act_24h, ["OBJECT"]) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ["OBJECT"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["OBJECT"])}</span></b><span>objections today / retained</span></div>
|
|
1016
|
-
<div class="stat ${countActs(act.by_act_24h, ESCALATIONS) ? "warn" : ""}"><b>${countActs(act.by_act_24h, ESCALATIONS)}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ESCALATIONS)}</span></b><span>escalations today / retained</span></div>
|
|
1017
|
-
<div class=stat><b>${countActs(act.by_act_24h, ["DELIVERED"])}<span class=dim style="font-size:14px"> / ${countActs(act.by_act, ["DELIVERED"])}</span></b><span>deliveries today / retained</span></div>
|
|
1018
|
-
<div class="stat ${refused24 ? "warn" : ""}"><b data-refused="${refusedAll}">${refused24}<span class=dim style="font-size:14px"> / ${refusedAll}</span></b><span>refused by the relay today / total</span></div>
|
|
1044
|
+
<main>
|
|
1045
|
+
<div class=kpis>
|
|
1046
|
+
<div class=kpi><div class=l>People</div><div class=v>${c.accounts}<small>accounts</small></div><div class=s>${c.devices} devices · ${paid} on a paid plan</div></div>
|
|
1047
|
+
<div class=kpi><div class=l>Teams</div><div class=v>${spaces.filter((s) => s.live).length}<small>/ ${spaces.length} live</small></div><div class=s>capacity ${this.limits.maxSpaces} spaces</div></div>
|
|
1048
|
+
<div class=kpi><div class=l>Connections</div><div class=v>${beating}<small>/ ${c.live_sessions} open</small></div><div class=s>${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours</div></div>
|
|
1049
|
+
<div class=kpi><div class=l>Messages today</div><div class=v>${act.messages_24h}<small>/ ${act.messages} retained</small></div><div class=s>${act.senders_24h.length} delegates active today · ${act.sessions_24h} sessions</div></div>
|
|
1050
|
+
<div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${del24} deliver${del24 === 1 ? "y" : "ies"}</div></div>
|
|
1051
|
+
<div class="kpi${refused24 ? " warn" : ""}"><div class=l>Refused by the relay</div><div class=v><span data-refused="${refusedAll}">${refused24}</span><small>today / ${refusedAll} total</small></div><div class=s>wrong turn, missing reference, foreign repo, plan or rate limit</div></div>
|
|
1052
|
+
<div class="kpi${staleBackup ? " warn" : ""}"><div class=l>Last backup</div><div class=v>${lastBackupPull ? ago(lastBackupPull.ts) : "never"}<small>off-host</small></div><div class=s>${snapshots.length} snapshots · ${emailsHour}/${emailCap} emails this hour · ${c.audit} audit events</div></div>
|
|
1019
1053
|
</div>
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
<
|
|
1054
|
+
|
|
1055
|
+
<section id=s-activity>
|
|
1056
|
+
<div class=sec><h2>Activity</h2><span class=count>every space · today and retained history</span></div>
|
|
1057
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-activity>
|
|
1058
|
+
<thead>
|
|
1059
|
+
<tr><th></th>${GROUPS.map(([g, xs]) => `<th class=group colspan=${xs.length}>${g}</th>`).join("")}<th class="group">Refused</th></tr>
|
|
1060
|
+
<tr><th></th>${cols.map((x, i) => `<th class="num${GROUPS.some(([, xs]) => xs[0] === x) && i > 0 ? " first" : ""}">${x}</th>`).join("")}<th class="num first">by relay</th></tr>
|
|
1061
|
+
</thead>
|
|
1062
|
+
<tbody>${actRow("today", "24h", act.by_act_24h, refused24)}${actRow("retained", "all", act.by_act, refusedAll)}</tbody>
|
|
1025
1063
|
</table></div>
|
|
1026
|
-
<p class=note>Messages are acts delegates sent;
|
|
1064
|
+
<p class=note>Messages are acts delegates sent; arrivals and departures are not counted. Retained is what each space's ledger still holds: the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts plus everything in an open goal.</p>
|
|
1065
|
+
</section>
|
|
1027
1066
|
|
|
1028
|
-
|
|
1029
|
-
<
|
|
1030
|
-
<
|
|
1031
|
-
<tr><th
|
|
1032
|
-
|
|
1033
|
-
|
|
1067
|
+
<section id=s-spaces>
|
|
1068
|
+
<div class=sec><h2>Spaces</h2><span class=count>${spaces.length} · click a space for its goals, members and every message</span><input type=search aria-label="Filter spaces" placeholder="Filter spaces" data-filter="t-spaces"></div>
|
|
1069
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-spaces>
|
|
1070
|
+
<thead><tr><th>space</th><th>people</th><th>activity today</th><th>needs a look</th><th>goals</th><th>last activity</th><th class=num>on disk</th><th></th></tr></thead>
|
|
1071
|
+
<tbody>${rows(spaces.map((s) => {
|
|
1072
|
+
const o = owners.get(s.name) ? acctById.get(owners.get(s.name)) : undefined;
|
|
1073
|
+
const obj = s.activity.by_act_24h.OBJECT ?? 0, ref = refusalsSince(s.refusals, dayAgo);
|
|
1074
|
+
return `<tr><td class=cell><a href="/admin/space?name=${encodeURIComponent(s.name)}"><code>${esc(s.name)}</code></a> <span class="pill ${s.live ? "ok" : ""}">${s.live ? "live" : "sleeping"}</span><span class=sub>${o ? `owner <code>${esc(o.username)}</code> <span class=pill data-plan="${esc(o.plan)}">${esc(o.plan)}</span>` : "no owner on record — no plan limits apply"}</span></td>`
|
|
1075
|
+
+ `<td class=cell><b>${s.members}</b> member${s.members === 1 ? "" : "s"}<span class=sub>${s.online} online</span></td>`
|
|
1076
|
+
+ `<td class=cell><b>${s.activity.messages_24h}</b> message${s.activity.messages_24h === 1 ? "" : "s"}<span class=sub>${s.activity.senders_24h.length} delegate${s.activity.senders_24h.length === 1 ? "" : "s"} · ${s.acts} acts retained</span></td>`
|
|
1077
|
+
+ `<td class="cell${obj || ref ? " tone-warn" : ""}"><b>${obj}</b> objection${obj === 1 ? "" : "s"} · <b>${ref}</b> refused<span class=sub>${s.live ? `${s.obligations} owed right now` : "asleep"} · ${s.activity.by_act.OBJECT ?? 0} objections, ${s.refusals?.total ?? 0} refused all time</span></td>`
|
|
1078
|
+
+ `<td class=cell><b>${s.open_goals}</b> open<span class=sub>${s.goals} total</span></td>`
|
|
1079
|
+
+ `<td class=dim title="${when(s.last)}">${ago(s.last)}</td><td class="num dim">${kb(s.bytes)}</td>`
|
|
1080
|
+
+ `<td class=actions>${form("delete_space", s.name, "delete", `<input type=hidden name=confirm value="">`, "danger")}</td></tr>`;
|
|
1081
|
+
}))}${spaces.length ? "" : "<tr><td colspan=8 class=dim>no spaces yet</td></tr>"}</tbody>
|
|
1034
1082
|
</table></div>
|
|
1035
|
-
<p class=note>Sleeping spaces are on disk but idle
|
|
1083
|
+
<p class=note>Sleeping spaces are on disk but idle and reload on the next connect. Delete is irreversible and disconnects every member of that space.</p>
|
|
1084
|
+
</section>
|
|
1036
1085
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1086
|
+
<section id=s-accounts>
|
|
1087
|
+
<div class=sec><h2>Accounts</h2><span class=count>${accounts.length}</span><input type=search aria-label="Filter accounts" placeholder="Filter accounts" data-filter="t-accounts"></div>
|
|
1088
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-accounts>
|
|
1089
|
+
<thead><tr><th>account</th><th>status</th><th>plan</th><th>limits</th><th class=num>spaces</th><th class=num>limit hits</th><th class=num>devices</th><th>created</th><th></th></tr></thead>
|
|
1090
|
+
<tbody>${rows(accounts.map((a) => {
|
|
1091
|
+
const lim = limitsOf(a);
|
|
1092
|
+
const custom = !!a.limits_json;
|
|
1093
|
+
return `<tr><td class=cell><code>${esc(a.username)}</code><span class=sub>${esc(a.email)}</span></td>`
|
|
1094
|
+
+ `<td><span class="pill ${a.status === "suspended" ? "bad" : "ok"}">${esc(a.status)}</span></td>`
|
|
1095
|
+
+ `<td><form method=POST action="/admin/action" class=inl onsubmit="return confirmAct(this)"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan><input type=hidden name=id value="${esc(a.id)}"><select name=plan aria-label="Plan for ${esc(a.username)}">${planOptions(a.plan)}</select> <button class=act>set</button></form></td>`
|
|
1096
|
+
+ `<td class=cell>${custom ? `<span class="pill warn">custom</span> ` : ""}${limitText(lim)}<details class=limits><summary>${custom ? "edit custom limits" : "set custom limits"}</summary>`
|
|
1097
|
+
+ `<form method=POST action="/admin/action" class=limitform><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_limits><input type=hidden name=id value="${esc(a.id)}">`
|
|
1098
|
+
+ `<label>spaces<input type=number name=spaces min=0 value="${lim.spaces}" aria-label="Spaces for ${esc(a.username)}"></label>`
|
|
1099
|
+
+ `<label>people per space<input type=number name=members min=1 value="${lim.members}" aria-label="People per space for ${esc(a.username)}"></label>`
|
|
1100
|
+
+ `<label class=chk><input type=checkbox name=wake value=on${lim.wake ? " checked" : ""}> unattended workers</label>`
|
|
1101
|
+
+ `<button class=act>save</button>${custom ? `<button class=act name=clear value=1>back to ${esc(a.plan)} plan</button>` : ""}</form></details></td>`
|
|
1102
|
+
+ `<td class=num>${ownedCounts.get(a.id) ?? 0}<span class=dim> / ${fmtLimit(lim.spaces)}</span></td>`
|
|
1103
|
+
+ `<td class=num>${limitHits.get(a.id) ? `<span class="pill warn">${limitHits.get(a.id)}</span>` : "<span class=dim>0</span>"}</td>`
|
|
1104
|
+
+ `<td class=num>${devByAcct.get(a.id) ?? 0}</td><td class=dim>${when(a.created_at)}</td>`
|
|
1105
|
+
+ `<td class=actions>${a.status === "suspended" ? form("reinstate", a.id, "reinstate") : form("suspend", a.id, "suspend", "", "danger")} ${form("block", a.email, "block email", "", "danger")}</td></tr>`;
|
|
1106
|
+
}))}${accounts.length ? "" : "<tr><td colspan=9 class=dim>no accounts yet</td></tr>"}</tbody>
|
|
1041
1107
|
</table></div>
|
|
1042
|
-
<p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated
|
|
1108
|
+
<p class=note>Suspending signs the account out everywhere and refuses its handshakes until reinstated; its messages stay in the ledgers. A plan or limits change takes effect at the account's next space, invite or admit; nothing they already have is removed. Limit hits count the times the relay refused them for a plan limit.</p>
|
|
1109
|
+
</section>
|
|
1043
1110
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
<tr><
|
|
1111
|
+
<section id=s-plans>
|
|
1112
|
+
<div class=sec><h2>Plans</h2><span class=count>limits the relay enforces · the owner's plan governs a space</span></div>
|
|
1113
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-plans>
|
|
1114
|
+
<thead><tr><th>plan</th><th class=num>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr></thead>
|
|
1115
|
+
<tbody>${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td class=num>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces aria-label="Maximum spaces" type=number min=0 value="${l.spaces}"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" type=number min=1 value="${l.members}"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td class=actions><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
|
|
1116
|
+
<tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" type=text name=id aria-label="New plan name" placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required style="width:150px"></td><td class="num dim">—</td><td><input form="plan-new" name=spaces aria-label="Maximum spaces" type=number min=0 value=3></td><td><input form="plan-new" name=members aria-label="Maximum people per space" type=number min=1 value=5></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td class=actions><button form="plan-new" class=act>add plan</button></td></tr></tbody>
|
|
1048
1117
|
</table></div>
|
|
1049
|
-
<p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward
|
|
1118
|
+
<p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward people per space, so nobody mints five at once. Every change is audited.</p>
|
|
1119
|
+
</section>
|
|
1050
1120
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1121
|
+
<section id=s-sessions>
|
|
1122
|
+
<div class=sec><h2>Live connections</h2><span class=count>${sessions.filter((s) => !s.revoked_at).length} open · ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours</span><input type=search aria-label="Filter sessions" placeholder="Filter connections" data-filter="t-sessions"></div>
|
|
1123
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-sessions>
|
|
1124
|
+
<thead><tr><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th>session</th><th></th></tr></thead>
|
|
1125
|
+
<tbody>${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill warn" title="no heartbeat for 15+ minutes — usually a socket that died with a restart">stale</span>' : ""}</td><td><code class=dim>${esc(s.id)}</code></td><td class=actions>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}</tbody>
|
|
1055
1126
|
</table></div>
|
|
1056
|
-
<p class=note>One row per open connection
|
|
1127
|
+
<p class=note>One row per open connection: an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes. Stale means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
|
|
1128
|
+
</section>
|
|
1057
1129
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1130
|
+
<section id=s-blocks>
|
|
1131
|
+
<div class=sec><h2>Blocked emails and domains</h2><span class=count>${blocks.length}</span></div>
|
|
1132
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-blocks>
|
|
1133
|
+
<thead><tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr></thead>
|
|
1134
|
+
<tbody>${rows(blocks.map((b) => `<tr><td><code>${esc(b.pattern)}</code></td><td>${esc(b.kind)}</td><td class=dim>${when(b.created_at)}</td><td class=actions>${form("unblock", b.pattern, "unblock")}</td></tr>`))}${blocks.length ? "" : "<tr><td colspan=4 class=dim>nothing blocked</td></tr>"}</tbody>
|
|
1062
1135
|
</table></div>
|
|
1063
|
-
<form method=POST action="/admin/action" class=
|
|
1136
|
+
<form method=POST action="/admin/action" class=filters style="margin-top:10px"><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=block><label>email or domain<input type=text name=id aria-label="Email or domain to block" placeholder="someone@spam.example or spam.example" required style="width:320px"></label><button class=act>block</button></form>
|
|
1064
1137
|
<p class=note>A blocked email or domain cannot sign up or sign in. Existing accounts are not touched; suspend those separately.</p>
|
|
1138
|
+
</section>
|
|
1065
1139
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1140
|
+
<section id=s-codes>
|
|
1141
|
+
<div class=sec><h2>Recent verification codes</h2><span class=count>support: "I never got my code"</span></div>
|
|
1142
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-verifs>
|
|
1143
|
+
<thead><tr><th>email</th><th>purpose</th><th>requested</th><th>expires</th><th class=num>wrong tries</th><th>state</th></tr></thead>
|
|
1144
|
+
<tbody>${rows(verifs.map((v) => `<tr><td>${esc(v.email)}</td><td>${esc(v.purpose)}</td><td class=dim>${when(v.created_at)}</td><td class=dim>${when(v.expires_at)}</td><td class=num>${v.attempts}</td><td><span class="pill ${v.expires_at < Date.now() ? "bad" : "ok"}">${v.expires_at < Date.now() ? "expired" : "pending"}</span></td></tr>`))}${verifs.length ? "" : "<tr><td colspan=6 class=dim>no pending codes</td></tr>"}</tbody>
|
|
1070
1145
|
</table></div>
|
|
1071
1146
|
<p class=note>A row here means the relay generated a code and handed it to the email provider. No row means the request never reached the relay. Codes themselves are never stored.</p>
|
|
1147
|
+
</section>
|
|
1072
1148
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1149
|
+
<section id=s-audit>
|
|
1150
|
+
<div class=sec><h2>Audit log</h2><span class=count>latest ${audit.length}</span><input type=search aria-label="Filter audit" placeholder="Filter audit log" data-filter="t-audit"></div>
|
|
1151
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-audit>
|
|
1152
|
+
<thead><tr><th>time</th><th>event</th><th>space</th><th>target</th><th>detail</th><th>ip</th></tr></thead>
|
|
1153
|
+
<tbody>${rows(audit.map((e) => `<tr><td class=dim>${when(e.ts)}</td><td>${e.event.startsWith("admin_") ? `<span class="pill info">${esc(e.event)}</span>` : esc(e.event)}</td><td>${esc(e.space) || ""}</td><td><code>${esc(e.target) || ""}</code></td><td class="dim wrapc">${esc(e.detail) || ""}</td><td class=dim>${esc(e.ip) || ""}</td></tr>`))}${audit.length ? "" : "<tr><td colspan=6 class=dim>nothing yet</td></tr>"}</tbody>
|
|
1076
1154
|
</table></div>
|
|
1077
1155
|
</section>
|
|
1078
1156
|
</main>
|
|
@@ -1084,8 +1162,8 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1084
1162
|
result.setAttribute("role", "status");
|
|
1085
1163
|
table.parentElement.insertAdjacentElement("afterend", result);
|
|
1086
1164
|
inp.addEventListener("input", function () {
|
|
1087
|
-
var q = inp.value.trim().toLowerCase(), rowsEl = table.querySelectorAll("tr"), shown = 0;
|
|
1088
|
-
for (var i =
|
|
1165
|
+
var q = inp.value.trim().toLowerCase(), rowsEl = table.querySelectorAll("tbody tr"), shown = 0;
|
|
1166
|
+
for (var i = 0; i < rowsEl.length; i++) {
|
|
1089
1167
|
var match = (!q || rowsEl[i].textContent.toLowerCase().indexOf(q) > -1);
|
|
1090
1168
|
var empty = rowsEl[i].querySelector("td[colspan]");
|
|
1091
1169
|
rowsEl[i].hidden = !match;
|
|
@@ -1133,31 +1211,35 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1133
1211
|
return `/admin/space?${p.toString()}`;
|
|
1134
1212
|
};
|
|
1135
1213
|
const pill = (x) => `<span class="pill ${ACT_TONE[x]}">${x}</span>`;
|
|
1136
|
-
const goalTitle = (id
|
|
1137
|
-
|
|
1138
|
-
|
|
1214
|
+
const goalTitle = (id, short = false) => id
|
|
1215
|
+
? `<a href="${link({ goal: id, before: undefined })}" title="${esc(id)}">${esc(short ? clipText(state.goals.get(id)?.title ?? id, 48) : state.goals.get(id)?.title ?? id)}</a>`
|
|
1216
|
+
: "<span class=dim>—</span>";
|
|
1217
|
+
const firstLine = (s) => s.split("\n").find((l) => l.trim()) ?? "";
|
|
1218
|
+
const rowsHtml = feedRows.map((r) => {
|
|
1219
|
+
const meta = (r.meta ?? {});
|
|
1220
|
+
const kv = [["Message ID", `<code>${esc(r.id)}</code>`]];
|
|
1221
|
+
if (r.ref)
|
|
1222
|
+
kv.push(["Reply to", `<code>${esc(r.ref)}</code>`]);
|
|
1223
|
+
if (r.goal)
|
|
1224
|
+
kv.push(["Goal", `${goalTitle(r.goal)} <code class=dim>${esc(r.goal)}</code>`]);
|
|
1139
1225
|
if (r.role)
|
|
1140
|
-
|
|
1226
|
+
kv.push(["Session role", esc(r.role)]);
|
|
1141
1227
|
const agent = sp?.members.get(r.from)?.agent;
|
|
1142
1228
|
if (agent)
|
|
1143
|
-
|
|
1229
|
+
kv.push(["Agent", esc(agent)]);
|
|
1144
1230
|
if (r.summary)
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
for (const k of ["branch", "commit", "repo_origin", "decision_domains", "defers_to", "affects"])
|
|
1231
|
+
kv.push(["Summary", esc(r.summary)]);
|
|
1232
|
+
for (const k of ["branch", "commit", "remote", "base", "repo_origin", "decision_domains", "defers_to", "affects", "authority", "unverified_domains"])
|
|
1148
1233
|
if (meta[k] !== undefined)
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
<div class=message-heading><a href="${link({ member: r.from, before: undefined })}"><strong>${esc(r.from)}</strong></a><span class=dim>to ${r.to?.length ? r.to.map((t) => esc(t)).join(", ") : "everyone"}</span>${pill(r.act)}<time datetime="${new Date(r.ts).toISOString()}" title="${when(r.ts)} UTC">${ago(r.ts)} · #${r.seq}</time></div>
|
|
1159
|
-
${r.goal ? `<div class=message-goal>${goalTitle(r.goal)}</div>` : ""}
|
|
1160
|
-
${said(r)}${metaLine(r)}</article>`).join("");
|
|
1234
|
+
kv.push([k.replace(/_/g, " "), esc(typeof meta[k] === "string" ? meta[k] : JSON.stringify(meta[k]))]);
|
|
1235
|
+
const gist = clipText(r.summary ?? firstLine(r.body), 150);
|
|
1236
|
+
return `<tr><td class="num dim">#${r.seq}</td><td class=dim title="${when(r.ts)} UTC">${ago(r.ts)}</td>`
|
|
1237
|
+
+ `<td class=cell><a href="${link({ member: r.from, before: undefined })}"><code>${esc(r.from)}</code></a><span class=sub>→ ${r.to?.length ? r.to.map((t) => esc(t)).join(", ") : "everyone"}${r.role && r.role !== "interactive" ? ` · ${esc(r.role)}` : ""}</span></td>`
|
|
1238
|
+
+ `<td>${pill(r.act)}</td><td class=dim>${r.goal ? goalTitle(r.goal, true) : "—"}</td>`
|
|
1239
|
+
+ `<td class=wrapc>${esc(gist) || "<span class=dim>(empty message)</span>"}</td>`
|
|
1240
|
+
+ `<td class=actions><button type=button class=more aria-expanded=false onclick="toggleRow(this)"><span class=rm>Read full message</span><span class=rl hidden>Show less</span></button></td></tr>`
|
|
1241
|
+
+ `<tr class=detail hidden><td colspan=7><div class=body>${esc(r.body) || "<span class=dim>(empty message)</span>"}</div><div class=kv>${kv.map(([k, v]) => `<span>${k}</span><b>${v}</b>`).join("")}</div></td></tr>`;
|
|
1242
|
+
}).join("");
|
|
1161
1243
|
const entries = allow.all();
|
|
1162
1244
|
const openInvites = allow.openInvites();
|
|
1163
1245
|
const csrfTok = this.adminCsrf();
|
|
@@ -1171,94 +1253,107 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
|
|
|
1171
1253
|
const refusals = state.refusals;
|
|
1172
1254
|
const refusalRows = [...refusals.recent].reverse().map((r) => `<tr><td class=dim>${when(r.ts)}</td><td><code>${esc(r.member)}</code></td><td>${esc(r.act)}</td><td><span class="pill bad">${esc(r.code)}</span></td></tr>`).join("");
|
|
1173
1255
|
const byCode = Object.entries(refusals.by_code).sort((x, y) => y[1] - x[1]).map(([c, n]) => `<code>${esc(c)}</code> ${n}`).join(" · ");
|
|
1174
|
-
const
|
|
1256
|
+
const goals = [...state.goals.values()];
|
|
1257
|
+
const open = goals.filter((g) => g.state !== "CLOSED").length;
|
|
1258
|
+
const lastIn = new Map();
|
|
1259
|
+
for (const r of records)
|
|
1260
|
+
if (r.goal)
|
|
1261
|
+
lastIn.set(r.goal, Math.max(lastIn.get(r.goal) ?? 0, r.ts));
|
|
1262
|
+
const owedIn = new Map();
|
|
1263
|
+
if (sp)
|
|
1264
|
+
for (const o of sp.obligations.values())
|
|
1265
|
+
if (o.goal)
|
|
1266
|
+
owedIn.set(o.goal, (owedIn.get(o.goal) ?? 0) + 1);
|
|
1267
|
+
const goalRows = goals.sort((x, y) => (x.state === "CLOSED" ? 1 : 0) - (y.state === "CLOSED" ? 1 : 0) || (lastIn.get(y.id) ?? 0) - (lastIn.get(x.id) ?? 0)).map((g) => `<tr><td class="cell wrapc"><a href="${link({ goal: g.id, before: undefined })}">${esc(g.title)}</a><span class=sub><code>${esc(g.id)}</code></span></td>`
|
|
1268
|
+
+ `<td><span class="pill ${g.state === "OPEN" ? "ok" : g.state === "CLOSED" ? "dim" : "warn"}">${esc(g.state)}</span></td>`
|
|
1269
|
+
+ `<td class=num>${g.acts}<span class=dim> / ${g.budget}</span></td><td>${g.members.map((m) => `<code>${esc(m)}</code>`).join(", ")}</td>`
|
|
1270
|
+
+ `<td class="num${owedIn.get(g.id) ? " tone-warn" : ""}">${sp ? owedIn.get(g.id) ?? 0 : "<span class=dim>—</span>"}</td><td class=dim title="${when(lastIn.get(g.id) ?? null)}">${ago(lastIn.get(g.id) ?? null)}</td></tr>`).join("");
|
|
1175
1271
|
const options = MESSAGE_ACTS.map((x) => `<option value="${x}"${q.act === x ? " selected" : ""}>${x}</option>`).join("");
|
|
1272
|
+
const obj24 = a.by_act_24h.OBJECT ?? 0, esc24 = countActs(a.by_act_24h, ESCALATIONS), ref24 = refusalsSince(refusals, dayAgo);
|
|
1176
1273
|
const html = `<!doctype html><html lang=en><meta charset=utf-8><title>pantheon · admin · ${esc(name)}</title>
|
|
1177
1274
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
1178
1275
|
${ADMIN_CSS}
|
|
1179
|
-
<style>
|
|
1180
|
-
.conversation{max-width:960px;margin:0 auto}
|
|
1181
|
-
.message{padding:20px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}
|
|
1182
|
-
.message-heading{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px}
|
|
1183
|
-
.message-heading time{margin-left:auto;color:var(--dim);font-size:12px}
|
|
1184
|
-
.message-goal{margin:6px 0 12px}.message-body,.preview{white-space:pre-wrap;max-width:75ch}
|
|
1185
|
-
.message-body{margin-top:12px}.message-content{margin-top:12px}
|
|
1186
|
-
.message-content>summary{cursor:pointer;list-style:none}.preview{display:block;color:var(--fg)}
|
|
1187
|
-
.read-more,.read-less{display:block;color:var(--accent);padding:8px 0}
|
|
1188
|
-
.read-less,.message-content[open] .read-more,.message-content[open] .preview{display:none}
|
|
1189
|
-
.message-content[open] .read-less{display:block}
|
|
1190
|
-
.message-meta{color:var(--dim);font-size:12px;margin-top:12px}.message-meta summary{cursor:pointer;padding:6px 0}
|
|
1191
|
-
.message-meta>div{max-width:75ch;white-space:pre-wrap}
|
|
1192
|
-
.filters label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dim)}
|
|
1193
|
-
.filters .presence{flex-direction:row;align-items:center}.filters input[type=checkbox]{min-height:0}
|
|
1194
|
-
.pagination{display:flex;justify-content:space-between;gap:16px;margin:20px 0}
|
|
1195
|
-
.empty{padding:24px 0}.empty p{color:var(--dim)}
|
|
1196
|
-
@media(max-width:600px){.filters label{flex:1 1 140px}.filters input,.filters select{width:100%;min-width:0}.filters .presence{flex-basis:100%}.filters input[type=checkbox]{width:auto}.message-heading time{margin-left:0;width:100%}}
|
|
1197
|
-
.pill.warn{color:var(--warn);border-color:var(--warn)}.pill.info{color:var(--accent);border-color:var(--accent)}.pill.dim{color:var(--dim)}
|
|
1198
|
-
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 10px}
|
|
1199
|
-
.filters input,.filters select{font:13px system-ui;padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card);color:var(--fg)}
|
|
1200
|
-
.crumbs a{color:var(--dim);text-decoration:none}.crumbs a:hover{color:var(--accent)}
|
|
1201
|
-
td a{text-decoration:none}td a:hover{text-decoration:underline}
|
|
1202
|
-
</style>
|
|
1203
1276
|
<header>
|
|
1204
1277
|
<h1><span class=crumbs><a href="/admin">admin</a> › </span>${esc(name)} <span class="pill ${sp ? "ok" : ""}">${sp ? "live" : "sleeping"}</span></h1>
|
|
1205
|
-
<span class=
|
|
1206
|
-
<span
|
|
1278
|
+
<span class=meta>${esc(allow.peekId() ?? "no id yet")} · ${records.length} acts retained${a.first ? ` since ${when(a.first)}` : ""}</span>
|
|
1279
|
+
<span class=links><a href="/admin">← dashboard</a><a href="/admin/logout">sign out</a></span>
|
|
1207
1280
|
</header>
|
|
1208
|
-
<
|
|
1209
|
-
|
|
1210
|
-
|
|
1281
|
+
<nav class=jump aria-label="Sections"><a href="#s-goals">Goals</a><a href="#conversation">Messages</a><a href="#s-team">Team & diagnostics</a></nav>
|
|
1282
|
+
${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchParams.get("done"))}</div>` : ""}
|
|
1283
|
+
<main>
|
|
1284
|
+
<div class=kpis>
|
|
1285
|
+
<div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => m.sessions.size).length : 0} online now · ${openInvites.length} unused invite${openInvites.length === 1 ? "" : "s"}</div></div>
|
|
1286
|
+
<div class=kpi><div class=l>Goals</div><div class=v>${open}<small>/ ${goals.length} open</small></div><div class=s>${sp ? `${sp.obligations.size} obligation${sp.obligations.size === 1 ? "" : "s"} owed right now` : "asleep — reloads on the next connect"}</div></div>
|
|
1287
|
+
<div class=kpi><div class=l>Messages today</div><div class=v>${a.messages_24h}<small>/ ${a.messages} retained</small></div><div class=s>${a.senders_24h.length} delegate${a.senders_24h.length === 1 ? "" : "s"} active today · ${a.sessions_24h} sessions</div></div>
|
|
1288
|
+
<div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${a.by_act_24h.DELIVERED ?? 0} deliver${(a.by_act_24h.DELIVERED ?? 0) === 1 ? "y" : "ies"}</div></div>
|
|
1289
|
+
<div class="kpi${ref24 ? " warn" : ""}"><div class=l>Refused by the relay</div><div class=v>${ref24}<small>today / ${refusals.total} total</small></div><div class=s>${byCode || "nothing refused yet"}</div></div>
|
|
1290
|
+
</div>
|
|
1291
|
+
|
|
1292
|
+
<section id=s-goals>
|
|
1293
|
+
<div class=sec><h2>Goals</h2><span class=count>${goals.length} · open first, most recent activity first · click a goal to see only its messages</span></div>
|
|
1294
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-goals>
|
|
1295
|
+
<thead><tr><th>goal</th><th>state</th><th class=num>acts / budget</th><th>members</th><th class=num>owed</th><th>last activity</th></tr></thead>
|
|
1296
|
+
<tbody>${goalRows || "<tr><td colspan=6 class=dim>no goals yet</td></tr>"}</tbody>
|
|
1297
|
+
</table></div>
|
|
1298
|
+
</section>
|
|
1299
|
+
|
|
1300
|
+
<section id=conversation>
|
|
1301
|
+
<div class=sec><h2>Messages</h2><span class=count>${feedRows.length} shown${filtered ? " · filtered" : ""} · newest first</span></div>
|
|
1211
1302
|
<form class=filters method=GET action="/admin/space"><input type=hidden name=name value="${esc(name)}">
|
|
1212
|
-
<label>
|
|
1213
|
-
<label>
|
|
1214
|
-
<label>
|
|
1215
|
-
<label>
|
|
1216
|
-
<label class=
|
|
1217
|
-
<button class=act>Apply filters</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}">Clear filters</a>` : ""}
|
|
1303
|
+
<label>type<select name=act><option value="">All types</option>${options}</select></label>
|
|
1304
|
+
<label>member<input type=text name=member placeholder="Any member" value="${esc(q.member)}" size=12></label>
|
|
1305
|
+
<label>goal<input type=text name=goal placeholder="Any goal ID" value="${esc(q.goal)}" size=16></label>
|
|
1306
|
+
<label>search<input type=search name=q placeholder="Find words or phrases" value="${esc(q.q)}" size=26></label>
|
|
1307
|
+
<label class=chk><input type=checkbox name=presence value=1${q.presence ? " checked" : ""}> include arrivals and departures</label>
|
|
1308
|
+
<button class=act>Apply filters</button>${filtered ? `<a href="/admin/space?name=${encodeURIComponent(name)}" style="padding-bottom:7px">Clear filters</a>` : ""}
|
|
1218
1309
|
</form>
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1310
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-messages>
|
|
1311
|
+
<thead><tr><th class=num>#</th><th>when</th><th>from → to</th><th>act</th><th>goal</th><th>what was said</th><th></th></tr></thead>
|
|
1312
|
+
<tbody>${rowsHtml || `<tr><td colspan=7 class=empty>${filtered ? "No messages match these filters. Try a different search or clear the filters." : "No messages yet. They appear here when this team's agents start working together."}</td></tr>`}</tbody>
|
|
1313
|
+
</table></div>
|
|
1314
|
+
<nav class=pager aria-label="Message pages">${q.before ? `<a href="${link({ before: undefined })}">← Latest messages</a>` : "<span></span>"}${next !== null ? `<a href="${link({ before: next })}">Older messages →</a>` : ""}</nav>
|
|
1315
|
+
<p class=note>Retained history only: the relay keeps the most recent ${LIMITS.ledgerKeepRecent.toLocaleString()} acts of a space plus everything in an open goal. Messages are shown as sent; nothing is edited or summarised. Read full message shows the whole text with its ids and delivery references.</p>
|
|
1222
1316
|
</section>
|
|
1223
|
-
|
|
1224
|
-
<
|
|
1225
|
-
<div class=
|
|
1226
|
-
<
|
|
1227
|
-
<div class=
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
<div class=stat><b>${a.by_act_24h.DELIVERED ?? 0}<span class=dim style="font-size:14px"> / ${a.by_act.DELIVERED ?? 0}</span></b><span>deliveries today / retained</span></div>
|
|
1231
|
-
<div class="stat ${refusalsSince(refusals, dayAgo) ? "warn" : ""}"><b>${refusalsSince(refusals, dayAgo)}<span class=dim style="font-size:14px"> / ${refusals.total}</span></b><span>refused today / total</span></div>
|
|
1232
|
-
<div class=stat><b>${open}<span class=dim style="font-size:14px"> / ${state.goals.size}</span></b><span>goals open / total</span></div>
|
|
1233
|
-
${sp ? `<div class=stat><b>${sp.obligations.size}</b><span>obligations owed</span></div>` : ""}
|
|
1234
|
-
</div>
|
|
1235
|
-
<section>
|
|
1236
|
-
<h2>Members <span class=dim>(${entries.filter((e) => e.admitted).length} admitted)</span></h2><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1237
|
-
<tr><th scope=col>member</th><th scope=col>state</th><th scope=col>may wake</th><th scope=col>online</th><th scope=col>last seen</th></tr>
|
|
1238
|
-
${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}
|
|
1317
|
+
|
|
1318
|
+
<section id=s-team>
|
|
1319
|
+
<div class=sec><h2>Team details and diagnostics</h2><span class=count>${entries.length} member${entries.length === 1 ? "" : "s"} · ${open} open goal${open === 1 ? "" : "s"}</span></div>
|
|
1320
|
+
<h3 style="font-size:14px;margin:14px 0 8px">Members</h3>
|
|
1321
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1322
|
+
<thead><tr><th>member</th><th>state</th><th>may wake</th><th>online</th><th>last seen</th></tr></thead>
|
|
1323
|
+
<tbody>${memberRows || "<tr><td colspan=5 class=dim>nobody yet</td></tr>"}</tbody>
|
|
1239
1324
|
</table></div>
|
|
1240
1325
|
|
|
1241
|
-
<
|
|
1242
|
-
|
|
1243
|
-
|
|
1326
|
+
<h3 style="font-size:14px;margin:22px 0 8px">Open invites <span class=dim>(${openInvites.length} unused)</span></h3>
|
|
1327
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1328
|
+
<thead><tr><th>token</th><th>minted by</th><th>expires</th><th>may wake</th><th></th></tr></thead>
|
|
1329
|
+
<tbody>${openInvites.map((i) => `<tr><td><code>…${esc(i.token.slice(-6))}</code></td><td><code>${esc(i.created_by)}</code></td><td class=dim>${when(i.expires_at)}</td><td>${i.may_wake ? "yes" : "no"}</td><td class=actions>${revokeForm(i.token)}</td></tr>`).join("") || "<tr><td colspan=5 class=dim>none open</td></tr>"}</tbody>
|
|
1244
1330
|
</table></div>
|
|
1245
1331
|
<p class=note>An unused invite counts toward the plan's people limit until it expires. Revoking makes the link inert immediately; members see theirs with <code>pantheon invite --list</code> and cancel with <code>--revoke</code>.</p>
|
|
1246
|
-
${repos.length ? `<
|
|
1332
|
+
${repos.length ? `<h3 style="font-size:14px;margin:22px 0 8px">Repos this team works from</h3><div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table><thead><tr><th>origin</th><th>fingerprint</th><th>added by</th><th>when</th></tr></thead><tbody>${repos.map((r) => `<tr><td>${esc(r.origin ?? "(no origin)")}</td><td><code>${esc(r.fingerprint)}</code></td><td><code>${esc(r.added_by)}</code></td><td class=dim>${when(r.at)}</td></tr>`).join("")}</tbody></table></div>` : ""}
|
|
1247
1333
|
|
|
1248
|
-
<
|
|
1249
|
-
|
|
1250
|
-
<tr><
|
|
1251
|
-
<tr><td class=dim>
|
|
1334
|
+
<h3 style="font-size:14px;margin:22px 0 8px">Acts <span class=dim>(today · retained)</span></h3>
|
|
1335
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-acts>
|
|
1336
|
+
<thead><tr><th></th>${MESSAGE_ACTS.map((x) => `<th class=num>${x}</th>`).join("")}</tr></thead>
|
|
1337
|
+
<tbody><tr><td class=dim>today</td>${MESSAGE_ACTS.map((x) => `<td class=num data-act="${x}" data-window="24h">${a.by_act_24h[x] ?? 0}</td>`).join("")}</tr>
|
|
1338
|
+
<tr><td class=dim>retained</td>${MESSAGE_ACTS.map((x) => `<td class=num data-act="${x}" data-window="all">${a.by_act[x] ?? 0}</td>`).join("")}</tr></tbody>
|
|
1252
1339
|
</table></div>
|
|
1253
1340
|
|
|
1254
|
-
<
|
|
1255
|
-
|
|
1256
|
-
|
|
1341
|
+
<h3 style="font-size:14px;margin:22px 0 8px">Refused by the relay <span class=dim>(latest ${refusals.recent.length} of ${refusals.total})</span></h3>
|
|
1342
|
+
<div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table>
|
|
1343
|
+
<thead><tr><th>when</th><th>member</th><th>request</th><th>reason</th></tr></thead>
|
|
1344
|
+
<tbody>${refusalRows || "<tr><td colspan=4 class=dim>nothing refused</td></tr>"}</tbody>
|
|
1257
1345
|
</table></div>
|
|
1258
1346
|
${byCode ? `<p class=note>All time: ${byCode}</p>` : ""}
|
|
1259
|
-
|
|
1260
1347
|
</section>
|
|
1261
|
-
</
|
|
1348
|
+
</main>
|
|
1349
|
+
<script>
|
|
1350
|
+
function toggleRow(btn) {
|
|
1351
|
+
var row = btn.closest("tr"), detail = row.nextElementSibling, open = detail.hasAttribute("hidden");
|
|
1352
|
+
if (open) detail.removeAttribute("hidden"); else detail.setAttribute("hidden", "");
|
|
1353
|
+
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1354
|
+
btn.querySelector(".rm").hidden = open; btn.querySelector(".rl").hidden = !open;
|
|
1355
|
+
}
|
|
1356
|
+
</script>`;
|
|
1262
1357
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
1263
1358
|
res.end(html);
|
|
1264
1359
|
}
|
package/dist/worker.js
CHANGED
|
@@ -690,6 +690,8 @@ export class Worker {
|
|
|
690
690
|
this.opts.log(`! self-upgrade failed: ${e.message}`);
|
|
691
691
|
}
|
|
692
692
|
if (result.ok && !result.alreadyCurrent && result.to === result.from) {
|
|
693
|
+
delete policy.attempts.self_upgrade;
|
|
694
|
+
savePolicy(policy, home);
|
|
693
695
|
this.opts.log(`* self-upgrade: the registry does not serve ${relayPkg} yet (installed ${result.to}); will check again`);
|
|
694
696
|
return;
|
|
695
697
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|