Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3d6f4ca1 | |||
| 26aabd6660 | |||
| 9d07f64154 | |||
| 0f17efe3b4 |
@@ -9,7 +9,8 @@
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for; and — server mode — each user's
|
||||
* busiest processes with who each counts for (history, CPU threads and
|
||||
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
@@ -250,19 +251,23 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{t("host.load")}</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
{s.os.platform !== "win32" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
|
||||
{t("host.load")}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
@@ -274,11 +279,9 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
<HealthBar
|
||||
label={t("host.cpu")}
|
||||
value={s.usage ? s.usage.cpu : null}
|
||||
tip={t("host.cpuHelp")}
|
||||
/>
|
||||
{s.usage && (
|
||||
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
|
||||
)}
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
@@ -375,6 +378,11 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
// History, per-thread CPU and processes are read from /proc: on Windows and
|
||||
// macOS their cards would only ever be empty, so they are left out.
|
||||
const linux = stats.os.platform === "linux";
|
||||
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
|
||||
const showProcs = !!stats.usage || others.length > 0;
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
@@ -408,132 +416,136 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
{linux && (
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
{showCores && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
@@ -587,60 +599,62 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
{showProcs && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click).
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click);
|
||||
* off Linux the cards it can never fill are left out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -179,4 +180,33 @@ describe("HostHealth", () => {
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out what Windows cannot report instead of empty cards", async () => {
|
||||
api.host.stats.mockResolvedValue({
|
||||
...stats,
|
||||
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
|
||||
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
|
||||
disks: [],
|
||||
usage: null,
|
||||
});
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: false,
|
||||
points: [],
|
||||
});
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText(/win32/)).toBeTruthy();
|
||||
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
|
||||
expect(card.textContent).not.toContain("Load");
|
||||
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
|
||||
for (const id of [
|
||||
"server-history",
|
||||
"server-cores",
|
||||
"server-top-processes",
|
||||
"server-disks-table",
|
||||
])
|
||||
expect(screen.queryByTestId(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
|
||||
* offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* the catalog knows how (an admin's; Update all is anyone's — auth.js
|
||||
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
@@ -231,7 +232,8 @@ export function AiToolsSettings({
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||
* @description **Update 9router** on the AI Proxy page (every user's; beside
|
||||
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -800,7 +800,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* @description The Users page's account pool card (`GET /api/accounts`).
|
||||
* Claude Code, Codex and Cursor accounts live on the POOLS board
|
||||
* (`PoolGroups`): one column per pool with its accounts of every tool — each
|
||||
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
|
||||
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
|
||||
* tools with no pool get a tab each below it: Oh My Pi can share the host's
|
||||
* login (the same switch as Settings → AI tools,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
|
||||
* says that its users sign in themselves. An admin adds an account from the
|
||||
* header's Add account, picking its tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
@@ -17,11 +17,11 @@
|
||||
* Cursor API key — or removes one, each time with their own password again;
|
||||
* an analyst only reads. The secret only goes IN: the server never sends it
|
||||
* back. An account's Edit (pencil) picks the users who run on it
|
||||
* (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and a
|
||||
* (`PUT /api/accounts/:name/users`, from a `MemberPicker` filtered by role
|
||||
* or "no group"): their sessions skip the balancer, and a
|
||||
* Claude login goes into their home at once — no restart. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
@@ -47,10 +47,10 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -69,6 +69,8 @@ type Row = {
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
|
||||
plan?: string | null;
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
const [tab, setTab] = useState<string>("omp");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
const badge = (id: string): string | null =>
|
||||
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
const rowOf = (tool: PoolTool, name: string) =>
|
||||
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<label className="relative">
|
||||
<span className="btn-primary pointer-events-none text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
|
||||
</span>
|
||||
<select
|
||||
className="absolute inset-0 cursor-pointer opacity-0"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
|
||||
}}
|
||||
aria-label={t("pool.addAccount")}
|
||||
data-testid="pool-add-account"
|
||||
>
|
||||
<option value="">{t("pool.addAccount")}</option>
|
||||
{POOL_TOOL_LABELS.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && (
|
||||
<PoolGroups
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onChanged={() => void load()}
|
||||
renderAccount={(tool, name) => {
|
||||
const r = rowOf(tool, name);
|
||||
return r ? (
|
||||
<AccountCard
|
||||
row={r}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => setEditing(r)}
|
||||
onRemove={() => setRemoving(r)}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.otherTools")}
|
||||
</h3>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
aria-label={t("pool.otherTools")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
{current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
@@ -443,39 +399,52 @@ function AccountCard({
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
|
||||
{r.plan && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
|
||||
data-testid="pool-account-plan"
|
||||
>
|
||||
{r.plan}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
|
||||
{t(`pool.kindShort.${r.kind}`)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
title={t("pool.edit", { name: r.name })}
|
||||
aria-label={t("pool.edit", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
className="btn-icon p-1 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,68 +457,47 @@ function AccountCard({
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{metered && (
|
||||
<div className="space-y-1" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
|
||||
<span className="inline-flex items-center gap-1 text-fg-secondary">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
{metered && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
<span className="basis-full">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
className={`block h-full transition-[width] duration-500 ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
@@ -596,72 +544,6 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
@@ -777,13 +659,8 @@ function EditAccountDialog({
|
||||
|
||||
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||
const ready = changed && current.length > 0;
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setChosen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
const groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users?.map((u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-2">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => toggle(u.id, on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||
)}
|
||||
{elsewhere && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.onAccount", { account: elsewhere.account })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-1">
|
||||
<MemberPicker
|
||||
users={users}
|
||||
chosen={chosen}
|
||||
onChange={setChosen}
|
||||
groupOf={groupOf}
|
||||
grouped={(pool.groups ?? []).length > 0}
|
||||
note={(u) => {
|
||||
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||
const group = groupOf(u.id);
|
||||
return (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{[
|
||||
group && t("pool.groups.inGroup", { group }),
|
||||
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file MemberPicker.tsx
|
||||
* @description The pool dialogs' member list (an account's Edit, a group's
|
||||
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
|
||||
* count) plus "No group" for the users in no pool group, a search box, a
|
||||
* tick-all for what is shown, and one checkbox per user with their role and
|
||||
* where they are now. Pure UI: the caller owns the ticked set.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||
/** A role, every user, or the users in no pool group. */
|
||||
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
|
||||
|
||||
/** The chips' filter applied to one user. */
|
||||
export function matchesFilter(
|
||||
u: Pick<ServerUserUsage, "id" | "role">,
|
||||
filter: MemberFilter,
|
||||
groupOf: (userId: number) => string | null
|
||||
): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "nogroup") return groupOf(u.id) === null;
|
||||
return u.role === filter;
|
||||
}
|
||||
|
||||
/** A row of filter chips with counts; `nogroup` only when groups exist. */
|
||||
export function MemberFilterChips({
|
||||
users,
|
||||
filter,
|
||||
onFilter,
|
||||
groupOf,
|
||||
grouped,
|
||||
}: {
|
||||
users: Pick<ServerUserUsage, "id" | "role">[];
|
||||
filter: MemberFilter;
|
||||
onFilter: (f: MemberFilter) => void;
|
||||
groupOf: (userId: number) => string | null;
|
||||
grouped: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const chips: { id: MemberFilter; label: string }[] = [
|
||||
{ id: "all", label: t("pool.members.all") },
|
||||
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
|
||||
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
|
||||
];
|
||||
return (
|
||||
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||
{chips.map((c) => {
|
||||
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
|
||||
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
|
||||
const on = filter === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilter(c.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
on
|
||||
? "border-accent/50 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="tabular-nums opacity-70">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPicker({
|
||||
users,
|
||||
chosen,
|
||||
onChange,
|
||||
groupOf,
|
||||
grouped,
|
||||
note,
|
||||
}: {
|
||||
users: ServerUserUsage[] | null;
|
||||
chosen: Set<number>;
|
||||
onChange: (next: Set<number>) => void;
|
||||
/** The pool group a user is in now, by name, or null. */
|
||||
groupOf: (userId: number) => string | null;
|
||||
/** Whether any pool group exists (shows the "No group" chip). */
|
||||
grouped: boolean;
|
||||
/** Extra text after a user's name (where they run now). */
|
||||
note?: (u: ServerUserUsage) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (users ?? []).filter(
|
||||
(u) =>
|
||||
matchesFilter(u, filter, groupOf) &&
|
||||
(!q ||
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.displayName ?? "").toLowerCase().includes(q))
|
||||
);
|
||||
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
|
||||
const setMany = (ids: number[], on: boolean) => {
|
||||
const next = new Set(chosen);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="member-picker">
|
||||
{users && users.length > 0 && (
|
||||
<>
|
||||
<MemberFilterChips
|
||||
users={users}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
groupOf={groupOf}
|
||||
grouped={grouped}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
className="input w-full py-1 pl-7 text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pool.members.search")}
|
||||
aria-label={t("pool.members.search")}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={allShown}
|
||||
disabled={shown.length === 0}
|
||||
onChange={(on) =>
|
||||
setMany(
|
||||
shown.map((u) => u.id),
|
||||
on
|
||||
)
|
||||
}
|
||||
label={t("pool.members.tickShown", { count: shown.length })}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{users === null && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||
)}
|
||||
{users?.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||
)}
|
||||
{users && users.length > 0 && shown.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||
)}
|
||||
{shown.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 flex-1"
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -167,9 +167,11 @@ export function UserDetail({ userId }: { userId: number }) {
|
||||
·{" "}
|
||||
{p.pin
|
||||
? t("detail.poolPinned", { account: p.pin })
|
||||
: p.group
|
||||
? t("detail.poolGroup", { name: p.group.name })
|
||||
: t("detail.poolShared")}
|
||||
: p.choice
|
||||
? t("detail.poolChosen", { account: p.choice })
|
||||
: p.group
|
||||
? t("detail.poolGroup", { name: p.group.name })
|
||||
: t("detail.poolShared")}
|
||||
</span>
|
||||
</div>
|
||||
{p.accounts.map((a) => {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: one tab per AI tool — the
|
||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||
* tool; says what an empty pool means per tool, an
|
||||
* @description The Users page's account pool card: the pools board with every
|
||||
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||
* other installed tool; an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* The board moves members and accounts between pools (drag, or a column's
|
||||
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||
* splits an account off onto New pool, merges a pool by dragging its only
|
||||
* account into another, adds a saved profile to a pool, shows each account's
|
||||
* plan, and saves every change with ONE password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
@@ -129,6 +134,28 @@ const OMP = {
|
||||
gateway: { running: false },
|
||||
};
|
||||
|
||||
/** The account names a pool column holds (its move menu names others too). */
|
||||
const held = (col: HTMLElement) =>
|
||||
within(col)
|
||||
.queryAllByTestId("pool-group-account")
|
||||
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
|
||||
|
||||
/** A drag-and-drop between two elements, through one shared DataTransfer. */
|
||||
function drag(from: Element, to: Element) {
|
||||
const store: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (store[k] = v),
|
||||
getData: (k: string) => store[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(store);
|
||||
},
|
||||
effectAllowed: "",
|
||||
};
|
||||
fireEvent.dragStart(from, { dataTransfer });
|
||||
fireEvent.dragOver(to, { dataTransfer });
|
||||
fireEvent.drop(to, { dataTransfer });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
@@ -147,8 +174,8 @@ beforeEach(() => {
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
{ id: 7, username: "mia", role: "developer", disabled: false },
|
||||
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
@@ -166,11 +193,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(card(false));
|
||||
render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("cx-1")).toBeNull();
|
||||
// With no pool made yet, every account sits in the shared pool, by tool.
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
expect(shared).toHaveTextContent("Claude Code2");
|
||||
expect(shared).toHaveTextContent("Codex1");
|
||||
expect(shared).toHaveTextContent("cx-1");
|
||||
const [account, second] = screen.getAllByTestId("pool-account");
|
||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||
expect(second).toHaveTextContent("team-2");
|
||||
@@ -188,39 +219,25 @@ describe("AccountPoolCard", () => {
|
||||
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||
expect(account).toHaveTextContent("2 live");
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
// Without groups nothing is said about them on an account.
|
||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||
// An analyst reads: no add, remove or new pool.
|
||||
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||
expect(codexTab).toHaveTextContent("1");
|
||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||
await user.click(codexTab);
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("team-1")).toBeNull();
|
||||
// A pool with no account still has its tab, with what that means.
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
||||
// Pool tools have no tab any more; an installed tool without a pool does.
|
||||
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||
it("adds a Cursor API key from Add account", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("shares a saved profile from its card", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const groupCard = await screen.findByTestId("pool-group");
|
||||
expect(groupCard).toHaveTextContent("Team A");
|
||||
expect(groupCard).toHaveTextContent("leo");
|
||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
expect(teamA).toHaveTextContent("leo");
|
||||
expect(teamA).toHaveTextContent("team-1");
|
||||
await screen.findByText("mia");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||
const names = screen.getAllByLabelText("Pool name");
|
||||
await user.type(names[names.length - 1]!, "Team B");
|
||||
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||
expect(teamB).toHaveTextContent("mia");
|
||||
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||
await user.selectOptions(add, "codex|cx-1");
|
||||
await user.selectOptions(add, "claude|profile:work");
|
||||
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
// Team A did not change: nothing is sent for it.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
drag(item(shared, "team-2"), teamA);
|
||||
expect(held(teamA)).toEqual(["team-1", "team-2"]);
|
||||
// And back again: an account is in one pool, so it leaves the other.
|
||||
drag(item(teamA, "team-1"), shared);
|
||||
expect(held(teamA)).toEqual(["team-2"]);
|
||||
expect(held(shared)).toContain("team-1");
|
||||
expect(teamA).toHaveTextContent("team-2");
|
||||
await user.click(
|
||||
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||
);
|
||||
expect(shared).toHaveTextContent("leo");
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows each account's plan on its card", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
|
||||
});
|
||||
render(card(false));
|
||||
const [first, second] = await screen.findAllByTestId("pool-account");
|
||||
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
|
||||
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
|
||||
});
|
||||
|
||||
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Team B",
|
||||
users: [{ userId: 7, username: "mia" }],
|
||||
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||
await screen.findByText("mia");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
// Split: team-2 leaves the shared pool for a new pool of its own.
|
||||
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
|
||||
const pools = screen.getAllByTestId("pool-group");
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||
drag(item(teamB!, "cx-1"), teamA!);
|
||||
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||
expect(teamA).toHaveTextContent("mia");
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||
"boss-pass"
|
||||
);
|
||||
// The members moved before the emptied pool was deleted.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
|
||||
@@ -1410,5 +1410,19 @@
|
||||
"apikey": "API key",
|
||||
"credentials": "Logins"
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Pool account",
|
||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||
"automatic": "Automatic",
|
||||
"select": "{{tool}} pool account",
|
||||
"count_one": "{{count}} account you may use",
|
||||
"count_other": "{{count}} accounts you may use",
|
||||
"pinned": "An admin pinned you to {{account}}",
|
||||
"expired": "expired",
|
||||
"full": "quota full",
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||
"description": "Each pool holds accounts of every AI tool (Claude Code, Codex, Cursor) and the members who run on them. A member's new session takes the least-used account of their pool, so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"account": "Account",
|
||||
@@ -254,20 +254,20 @@
|
||||
"shareNamed": "Share {{name}} to the pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||
"new": "New group",
|
||||
"title": "Pools",
|
||||
"description": "Members in no pool, and accounts in no pool, make up the shared pool.",
|
||||
"new": "New pool",
|
||||
"empty": "No groups yet — every user shares every account.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"edit": "Edit group {{name}}",
|
||||
"delete": "Delete group {{name}}",
|
||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||
"delete": "Delete pool {{name}}",
|
||||
"deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{name}} deleted",
|
||||
"deleted": "Pool {{name}} deleted",
|
||||
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||
"name": "Group name",
|
||||
"name": "Pool name",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
@@ -278,7 +278,57 @@
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
"resetsIn": "resets in {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Members",
|
||||
"filter": "Show members",
|
||||
"all": "All",
|
||||
"noGroup": "No pool",
|
||||
"search": "Search members",
|
||||
"tickShown": "Tick all {{count}}",
|
||||
"noneShown": "No member matches."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||
"sharedHint": "Members and accounts in no pool",
|
||||
"toShared": "Send {{name}} back to the shared pool",
|
||||
"removeAccount": "Send {{name}} back to the shared pool",
|
||||
"moveHere_one": "Move {{count}} here",
|
||||
"moveHere_other": "Move {{count}} here",
|
||||
"noMembers": "No members",
|
||||
"noSharedAccounts": "No account is left for the shared pool.",
|
||||
"dropMembers": "Drop members here",
|
||||
"untitled": "New pool",
|
||||
"new": "not saved yet",
|
||||
"pick": "Pick members of {{name}}",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||
"apply": "Apply",
|
||||
"addAccount": "Move an account into {{name}}",
|
||||
"addAccountOption": "+ Move an account or saved profile here…",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||
"pending_one": "{{count}} unsaved change",
|
||||
"pending_other": "{{count}} unsaved changes",
|
||||
"needsName": "Every pool needs a name.",
|
||||
"discard": "Discard",
|
||||
"saveTitle": "Save the pools",
|
||||
"saveHint": "Type your own password once to apply every change below.",
|
||||
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"saved": "Pools saved",
|
||||
"dropAccounts": "Drag accounts here",
|
||||
"noAccounts": "No accounts",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||
"newDrop": "Drop here to split it into a pool of its own",
|
||||
"newHint": "Or drag an account or a member here to split it off"
|
||||
},
|
||||
"addAccount": "Add account",
|
||||
"otherTools": "Other AI tools"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
@@ -343,6 +393,7 @@
|
||||
"noUsage": "No spend in the last 30 days.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "pinned to {{account}}",
|
||||
"poolChosen": "chose {{account}}",
|
||||
"poolGroup": "group {{name}}",
|
||||
"poolShared": "shared pool",
|
||||
"poolNone": "No account in this tool's pool.",
|
||||
|
||||
@@ -1398,5 +1398,19 @@
|
||||
"apikey": "API key",
|
||||
"credentials": "Đăng nhập"
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Tài khoản pool",
|
||||
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
||||
"automatic": "Tự động",
|
||||
"select": "Tài khoản pool của {{tool}}",
|
||||
"count_one": "{{count}} tài khoản bạn được dùng",
|
||||
"count_other": "{{count}} tài khoản bạn được dùng",
|
||||
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "hết quota",
|
||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||
"description": "Mỗi pool chứa tài khoản của mọi AI tool (Claude Code, Codex, Cursor) và các member dùng chúng. Session mới của member chạy trên tài khoản dùng ít nhất trong pool của họ, không ai phải tự đăng nhập.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"account": "Tài khoản",
|
||||
@@ -254,20 +254,20 @@
|
||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||
"new": "Tạo nhóm",
|
||||
"title": "Pool",
|
||||
"description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.",
|
||||
"new": "Tạo pool",
|
||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||
"members_one": "{{count}} thành viên",
|
||||
"members_other": "{{count}} thành viên",
|
||||
"edit": "Sửa nhóm {{name}}",
|
||||
"delete": "Xoá nhóm {{name}}",
|
||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"delete": "Xoá pool {{name}}",
|
||||
"deleteHint": "Member và tài khoản của pool quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{name}}",
|
||||
"deleted": "Đã xoá pool {{name}}",
|
||||
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||
"name": "Tên nhóm",
|
||||
"name": "Tên pool",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
@@ -278,7 +278,57 @@
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
"resetsIn": "reset sau {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Thành viên",
|
||||
"filter": "Hiện thành viên",
|
||||
"all": "Tất cả",
|
||||
"noGroup": "Chưa có pool",
|
||||
"search": "Tìm thành viên",
|
||||
"tickShown": "Chọn cả {{count}}",
|
||||
"noneShown": "Không có thành viên nào khớp."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
|
||||
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
|
||||
"toShared": "Đưa {{name}} về pool chung",
|
||||
"removeAccount": "Đưa {{name}} về pool chung",
|
||||
"moveHere_one": "Chuyển {{count}} vào đây",
|
||||
"moveHere_other": "Chuyển {{count}} vào đây",
|
||||
"noMembers": "Chưa có thành viên",
|
||||
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
|
||||
"dropMembers": "Thả thành viên vào đây",
|
||||
"untitled": "Pool mới",
|
||||
"new": "chưa lưu",
|
||||
"pick": "Chọn thành viên cho {{name}}",
|
||||
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
|
||||
"apply": "Áp dụng",
|
||||
"addAccount": "Chuyển tài khoản vào {{name}}",
|
||||
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
|
||||
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
|
||||
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
|
||||
"pending_one": "{{count}} thay đổi chưa lưu",
|
||||
"pending_other": "{{count}} thay đổi chưa lưu",
|
||||
"needsName": "Pool nào cũng cần có tên.",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"saveTitle": "Lưu các pool",
|
||||
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
|
||||
"summaryProfile": "Chuyển profile {{name}} vào pool",
|
||||
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"saved": "Đã lưu các pool",
|
||||
"dropAccounts": "Kéo tài khoản vào đây",
|
||||
"noAccounts": "Chưa có tài khoản",
|
||||
"accountsCount_one": "{{count}} tài khoản",
|
||||
"accountsCount_other": "{{count}} tài khoản",
|
||||
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
|
||||
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
|
||||
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
|
||||
"newDrop": "Thả vào đây để tách thành pool riêng",
|
||||
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
|
||||
},
|
||||
"addAccount": "Thêm tài khoản",
|
||||
"otherTools": "AI tool khác"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
@@ -343,6 +393,7 @@
|
||||
"noUsage": "Không có chi phí trong 30 ngày qua.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "ghim vào {{account}}",
|
||||
"poolChosen": "tự chọn {{account}}",
|
||||
"poolGroup": "nhóm {{name}}",
|
||||
"poolShared": "pool dùng chung",
|
||||
"poolNone": "Pool của tool này chưa có tài khoản nào.",
|
||||
|
||||
@@ -616,6 +616,8 @@ export type UserDetails = {
|
||||
PoolTool,
|
||||
{
|
||||
pin: string | null;
|
||||
/** The user's own choice (below a pin); absent from an older server. */
|
||||
choice?: string | null;
|
||||
group: { id: number; name: string } | null;
|
||||
accounts: UserPoolAccount[];
|
||||
}
|
||||
@@ -808,6 +810,8 @@ export type PoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** Readable plan of a login ("Max 5x", "Team Premium"); null for a setup-token. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||
export type CodexPoolAccount = {
|
||||
@@ -826,6 +830,8 @@ export type CodexPoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** A ChatGPT login's plan ("Plus", "Pro", "Team"); null for an API key. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||
export type AccountPin = {
|
||||
@@ -865,6 +871,22 @@ export type PoolGroupPatch = {
|
||||
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex" | "cursor";
|
||||
/** One tool of `GET /api/me/pool-accounts`: the accounts this user may use — never a token. */
|
||||
export type MyPoolTool = {
|
||||
tool: PoolTool;
|
||||
/** An admin's pin: while set, the user cannot choose. */
|
||||
pin: string | null;
|
||||
/** The user's own choice; null = the balancer picks. */
|
||||
choice: string | null;
|
||||
accounts: {
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
expired: boolean;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
}[];
|
||||
};
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
@@ -1226,6 +1248,15 @@ export const api = {
|
||||
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
/** GET /api/me/pool-accounts — per tool, the pool accounts this account may use (server mode). */
|
||||
poolAccounts: () => request<{ tools: MyPoolTool[] }>("/me/pool-accounts"),
|
||||
/** PUT /api/me/pool-accounts — run new sessions of `tool` on `account` (`null` = automatic). */
|
||||
choosePoolAccount: (tool: PoolTool, account: string | null) =>
|
||||
request<{
|
||||
tool: PoolTool;
|
||||
choice: string | null;
|
||||
switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null;
|
||||
}>("/me/pool-accounts", { method: "PUT", body: JSON.stringify({ tool, account }) }),
|
||||
},
|
||||
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
|
||||
host: {
|
||||
@@ -4751,6 +4782,8 @@ export interface CcAccountIdentity {
|
||||
organization: string | null;
|
||||
seatTier: string | null;
|
||||
subscriptionType: string | null;
|
||||
/** Readable plan from tier + seat ("Max 5x", "Team Premium"); prefer it. */
|
||||
plan?: string | null;
|
||||
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
|
||||
* token is stored too). */
|
||||
expiresAt: number | null;
|
||||
|
||||
@@ -329,6 +329,12 @@ export const SETTINGS_INDEX: {
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "poolAccount.title",
|
||||
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
|
||||
to: "#pool-account",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* Beside it every user gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
@@ -274,7 +274,7 @@ export function AiProxyConfig() {
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||
{nineRouter && <NineRouterUpdate />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
|
||||
@@ -73,6 +73,7 @@ import { AgentInstructionsSettings } from "../components/settings/AgentInstructi
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -310,6 +311,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||
host's logins, a staff-only read (a developer would get 403s). */}
|
||||
{seesAll && <ToolProfilesSettings />}
|
||||
{/* Every user's own pick of a pool account (server mode; hides itself). */}
|
||||
<PoolAccountSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
|
||||
@@ -228,6 +228,8 @@ describe("Users page", () => {
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
// The pool board reads the user list too; count the page's own reload.
|
||||
const before = users.list.mock.calls.length;
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
@@ -236,7 +238,7 @@ describe("Users page", () => {
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.28 (rebuild client to match)
|
||||
UI build v2.8.30 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+9
-7
@@ -95,7 +95,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
|
||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||
|
||||
@@ -105,6 +105,8 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
|
||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
|
||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
@@ -2582,7 +2584,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name as the tool reports it — Claude's `subscriptionType`, Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's read from the rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; `subscriptionType` alone says `team` for both seats and keeps `pro` after an upgrade), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
|
||||
#### Analytics
|
||||
|
||||
```http
|
||||
@@ -2898,7 +2900,7 @@ Batch `status` is `running`, `done`, `partial` (finished, at least one step fail
|
||||
|
||||
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. On **macOS** the live `claudeAiOauth` is NOT that file — the `claude` CLI keeps it in the login Keychain (generic password, service `Claude Code-credentials`, account `$USER`), read/written via `/usr/bin/security`; the file is only a fallback (and is kept in step when one already exists). `AGENTHUB_CC_KEYCHAIN=0` pins the file.
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active, mismatch }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `plan` — the readable plan from tier + seat —, `expiresAt`).
|
||||
|
||||
**Who is logged in is decided by the TOKEN, not by `.claude.json`.** The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while `oauthAccount` still names another, and every request is then billed to the token's account. So `GET /profiles`, `POST /profiles`, `POST /profiles/activate` and `POST /profiles/usage` first ask Anthropic who owns the live token (`GET https://api.anthropic.com/api/oauth/profile`, `cc-usage.liveOwner`, cached per token — an access token never changes hands). `active` (and each item's `active`) is that owner; `mismatch` is `{ configEmail, configUuid }` when the config names someone else, else `null`. Offline, or with an expired live token, there is no owner and the config decides, as before.
|
||||
|
||||
@@ -3010,7 +3012,7 @@ GET /api/ai-tools/:id/config/:i { path, format, text, sha }
|
||||
PUT /api/ai-tools/:id/config/:i Body: { text, baseSha } → { sha, backup }
|
||||
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
|
||||
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
|
||||
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
|
||||
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater (any signed-in user; other /ai-tools writes are an admin's)
|
||||
GET /api/ai-tools/job The current or last job, or null
|
||||
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
|
||||
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
|
||||
@@ -3038,7 +3040,7 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
|
||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
|
||||
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
|
||||
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (any signed-in user)
|
||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||
@@ -3061,7 +3063,7 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
||||
|
||||
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||
|
||||
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, shown to every user — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`, but their `POST …/update` is in `MEMBER_WRITE_EXCEPTIONS`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
|
||||
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
|
||||
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
|
||||
@@ -3165,7 +3167,7 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
|
||||
+49
-2
@@ -109,7 +109,11 @@ start (`server/db.js`), and `member` is no longer accepted.
|
||||
`/api/settings`, `/updates`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`) — except
|
||||
the two update buttons any signed-in user may press, `POST
|
||||
/api/ai-tools/update-all` and `POST /api/ai-proxies/9router/update`
|
||||
(`MEMBER_WRITE_EXCEPTIONS`; both run only what the catalog / the fixed 9router
|
||||
job names) — plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
|
||||
host-wide parts of `/cc-config` (see
|
||||
[Your own Claude Code config](#your-own-claude-code-config)). A
|
||||
@@ -591,7 +595,7 @@ agenthub accounts pin cursor-team mia --tool cursor
|
||||
## Account pool groups
|
||||
|
||||
By default every user shares every pool account. **Groups** split the pool
|
||||
by team (Users page → **Account pool → Groups**, server mode only,
|
||||
by team (Users page → **Account pool**, where a group is called a pool, server mode only,
|
||||
`server/lib/pool-groups.js`):
|
||||
|
||||
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||
@@ -609,6 +613,49 @@ by team (Users page → **Account pool → Groups**, server mode only,
|
||||
an account takes it out of every group; deleting a group sends its members
|
||||
back to the shared pool and removes no account.
|
||||
|
||||
On the Users page a group is shown as a **pool**: the **Account pool** card
|
||||
is one board with a column for the shared pool and one per pool, each
|
||||
holding its accounts of every tool (grouped by tool, each a card with its
|
||||
plan — "Max 5x", "Team Premium", a ChatGPT plan —, live panes and quota) and
|
||||
its members. Drag an account or a member from one
|
||||
column to another — in the UI an account sits in ONE pool, so a drop moves
|
||||
it (onto the shared pool takes it off every pool). Dropping one on the
|
||||
trailing **New pool** column SPLITS it off into a new pool of its own;
|
||||
dragging a pool's ONLY account into another column MERGES that pool there —
|
||||
its members come along and the emptied pool is deleted on save. A column's **+ Move an
|
||||
account or saved profile here** menu does the same without dragging and also
|
||||
lists the saved login profiles, which are moved into the pool on save; **Pick
|
||||
members** ticks members by role (Admin / Developer / Analyst) or **No pool**
|
||||
in a dialog; **Add account** in the card's header adds a new account for a
|
||||
chosen tool (it lands in the shared pool). Nothing is sent until **Save**,
|
||||
which asks for the password once and applies every change (profiles moved
|
||||
in, then new pools created, then each changed pool set exactly, then merged
|
||||
pools deleted); a failed
|
||||
step keeps the board so a retry resumes where it stopped. Tools with no pool
|
||||
(Oh My Pi, any other installed tool) keep a tab each under **Other AI
|
||||
tools**. An account's Edit (pencil) uses the same role / No pool list.
|
||||
|
||||
### A user's own pool account
|
||||
|
||||
When the pool holds more than one account a user may use, they pick which one
|
||||
their own new sessions run on: **Settings → AI tools → Pool account**, one
|
||||
select per tool (Claude, Codex, Cursor), every role, server mode only
|
||||
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
|
||||
|
||||
- The list is only what their group allows (or the shared pool); a choice
|
||||
outside it is refused (`ENOACCOUNT`), and one later taken from the group
|
||||
falls back to the balancer.
|
||||
- **Automatic** (the default) leaves it to the balancer.
|
||||
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
|
||||
once the pin is removed.
|
||||
- A Claude login goes into the user's home at once, like an admin's switch,
|
||||
so running panes follow it; Codex and Cursor take it at the next session (a
|
||||
ChatGPT login still waits while another holds the home).
|
||||
- It is the user's own change: the login cookie only (a pane token cannot),
|
||||
audited, no password step-up. The Users page's user detail shows it as
|
||||
"chose <account>". Oh My Pi has no pool to choose from (its sharing is the
|
||||
host's one login).
|
||||
|
||||
The tab bar also shows Oh My Pi (its login sharing, below) and every other
|
||||
installed tool, which says it has no pool (Antigravity keeps its Google login
|
||||
in the system keyring; there is no key AgentHub could hand a session).
|
||||
|
||||
+139
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.28
|
||||
version: 2.8.29
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -14540,6 +14540,144 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/pool-accounts:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: The account pool as this account may use it (server mode)
|
||||
description: 'Per tool with at least one account the account''s pool group allows: those accounts (never a token), an admin''s pin and the account''s own choice. Local mode: {tools: []}.'
|
||||
responses:
|
||||
'200':
|
||||
description: Tools
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- tools
|
||||
properties:
|
||||
tools:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
- pin
|
||||
- choice
|
||||
- accounts
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
pin:
|
||||
type: string
|
||||
nullable: true
|
||||
choice:
|
||||
type: string
|
||||
nullable: true
|
||||
accounts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
nullable: true
|
||||
expired:
|
||||
type: boolean
|
||||
quotaPercent:
|
||||
type: number
|
||||
nullable: true
|
||||
full:
|
||||
type: boolean
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
put:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Run this account's new sessions of a tool on one pool account (null = automatic)
|
||||
description: Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- tool
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
enum:
|
||||
- claude
|
||||
- codex
|
||||
- cursor
|
||||
account:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Chosen
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tool:
|
||||
type: string
|
||||
choice:
|
||||
type: string
|
||||
nullable: true
|
||||
switched:
|
||||
type: string
|
||||
nullable: true
|
||||
enum:
|
||||
- switched
|
||||
- kept
|
||||
- own-login
|
||||
- expired
|
||||
- next-session
|
||||
'400':
|
||||
description: EBADTOOL
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOACCOUNT — not in the pool, or not one the account's group may use
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ELOCKED — an admin pinned this account's sessions of the tool
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/onboarded:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.28",
|
||||
"version": "2.8.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.28",
|
||||
"version": "2.8.30",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.28",
|
||||
"version": "2.8.30",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -623,6 +623,18 @@ describe("skills from files or written", () => {
|
||||
["s/.env", "K=1"],
|
||||
]);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
const word = await upload("/api/artifacts/skill/upload", [
|
||||
["w/SKILL.md", "---\ndescription: W\n---\n"],
|
||||
["w/api-token.json", "{}"],
|
||||
]);
|
||||
assert.equal(word.body.error.code, "ESECRET");
|
||||
// The word alone in a doc or a script is skill content, not a key.
|
||||
const docs = await upload("/api/artifacts/skill/upload", [
|
||||
["tokens/SKILL.md", "---\ndescription: Design tokens\n---\n"],
|
||||
["tokens/design-tokens.md", "# tokens"],
|
||||
["tokens/scripts/count_tokens.py", "print(1)"],
|
||||
]);
|
||||
assert.equal(docs.status, 201, JSON.stringify(docs.body));
|
||||
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
|
||||
description: "d",
|
||||
});
|
||||
|
||||
@@ -444,3 +444,21 @@ describe("cc-profiles — identity and token disagree", () => {
|
||||
assert.throws(() => profiles.saveProfile(undefined, { owner }), { code: "EMISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
|
||||
const { planLabel } = profiles;
|
||||
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
|
||||
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
|
||||
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
|
||||
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
|
||||
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
|
||||
assert.strictEqual(planLabel(team, { seatTier: "team_premium" }), "Team Premium");
|
||||
// `team_tier_1` names no kind; a Max-sized limit is what a premium seat gives.
|
||||
assert.strictEqual(
|
||||
planLabel(team, { seatTier: "team_tier_1", userRateLimitTier: "default_claude_max_5x" }),
|
||||
"Team Premium"
|
||||
);
|
||||
assert.strictEqual(planLabel(team, null), "Team");
|
||||
assert.strictEqual(planLabel(null, null), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @file pool-account-choice.test.js
|
||||
* @description A user's own choice of pool account (account-pins.js
|
||||
* `setChoice`/`preferredFor`, `/api/me/pool-accounts`), fake accounts only:
|
||||
* the balancer runs the user's new panes on the chosen account; an admin's pin
|
||||
* wins and locks the choice (ELOCKED); only accounts the user's group allows
|
||||
* may be chosen (ENOACCOUNT), and one later taken from the group falls back
|
||||
* to the balancer; a removed account drops its choices; the routes list no
|
||||
* token, need a login cookie and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-choice-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
let server;
|
||||
let BASE;
|
||||
let MIA;
|
||||
let LEO;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: BASE,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM pool_groups").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
db.prepare("DELETE FROM account_choices").run();
|
||||
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||
});
|
||||
|
||||
describe("pool account choice: the balancer", () => {
|
||||
it("runs the user's new panes on the account they chose, others unchanged", () => {
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||
claudePool.addAccount("one", TOKEN("o"));
|
||||
claudePool.addAccount("two", TOKEN("t"));
|
||||
pins.setChoice(MIA, "claude", "two");
|
||||
assert.equal(claudePool.pickAccount(null, MIA).name, "two");
|
||||
pins.setChoice(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a", "automatic again");
|
||||
});
|
||||
|
||||
it("lets an admin's pin win, and refuses a choice while pinned", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
pins.setPin(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ELOCKED" });
|
||||
pins.setPin(MIA, "cursor", null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b", "the choice comes back");
|
||||
});
|
||||
|
||||
it("allows only the group's accounts, and falls back once one leaves it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b", "c"]);
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ENOACCOUNT" });
|
||||
assert.throws(() => pins.setChoice(MIA, "cursor", "zz"), { code: "ENOACCOUNT" });
|
||||
pins.setChoice(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
assert.equal(pins.preferredFor(MIA, "cursor"), null);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||
});
|
||||
|
||||
it("drops a removed account's choices", () => {
|
||||
pins.setChoice(MIA, "cursor", "b");
|
||||
cursorPool.removeAccount("b");
|
||||
assert.equal(pins.choiceFor(MIA, "cursor"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pool account choice: /api/me/pool-accounts", () => {
|
||||
it("lists only the tools and accounts the user may use, never a token", async () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
const cookie = await login("mia", "member-pass-1");
|
||||
const r = await call("GET", "/api/me/pool-accounts", { cookie });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.tools.map((t) => t.tool),
|
||||
["cursor"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
r.body.tools[0].accounts.map((a) => a.name),
|
||||
["b"]
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes(KEY("b")));
|
||||
});
|
||||
|
||||
it("chooses, goes back to automatic, refuses while pinned, and is audited", async () => {
|
||||
const cookie = await login("leo", "member-pass-2");
|
||||
let r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "c" },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, "c");
|
||||
assert.equal(r.body.switched, "next-session");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "c");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "nope" },
|
||||
});
|
||||
assert.equal(r.status, 404);
|
||||
assert.equal(r.body.error.code, "ENOACCOUNT");
|
||||
r = await call("PUT", "/api/me/pool-accounts", { cookie, body: { tool: "x", account: "c" } });
|
||||
assert.equal(r.status, 400);
|
||||
pins.setPin(LEO, "cursor", "a");
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: "b" },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ELOCKED");
|
||||
pins.setPin(LEO, "cursor", null);
|
||||
r = await call("PUT", "/api/me/pool-accounts", {
|
||||
cookie,
|
||||
body: { tool: "cursor", account: null },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.choice, null);
|
||||
const audited = db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE '%pool account%'")
|
||||
.get().n;
|
||||
assert.ok(audited >= 2);
|
||||
});
|
||||
|
||||
it("needs a login", async () => {
|
||||
const r = await call("PUT", "/api/me/pool-accounts", {
|
||||
body: { tool: "cursor", account: "a" },
|
||||
});
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
});
|
||||
@@ -782,6 +782,25 @@ describe("pty-run", () => {
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
|
||||
// A named conversation: agy's `--conversation <id>`, not `--continue`.
|
||||
tmux.__reset();
|
||||
const named = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 14,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeSessionId: "70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
initialPrompt: "go on",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(named), [
|
||||
at("agy"),
|
||||
"--conversation",
|
||||
"70785283-bcc4-43a4-a1ea-02d844d5df5b",
|
||||
"-i",
|
||||
"go on",
|
||||
]);
|
||||
});
|
||||
|
||||
// A catalog tool has no "resume THIS session" argv; starting it fresh under
|
||||
@@ -792,7 +811,7 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({
|
||||
laneId: 15,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
tool: "septool",
|
||||
resumeSessionId: "abc12345",
|
||||
}),
|
||||
{ code: "EUNSUPPORTED" }
|
||||
|
||||
@@ -222,7 +222,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.strictEqual(live.plan, "Max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
|
||||
@@ -137,6 +137,47 @@ describe("server mode", () => {
|
||||
assert.equal(a.status, 404);
|
||||
});
|
||||
|
||||
it("lets any user press Update all AI tools and Update 9router, nothing else there", async () => {
|
||||
// The guard alone, called directly: a real request would start the job.
|
||||
const member = await loginAs("mia", "member-pass-1");
|
||||
const guard = (method, p) => {
|
||||
let passed = false;
|
||||
let status = null;
|
||||
const req = { method, path: p, headers: { cookie: member }, socket: {} };
|
||||
const res = {
|
||||
status(s) {
|
||||
status = s;
|
||||
return this;
|
||||
},
|
||||
json() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
auth.authGuard(req, res, () => {
|
||||
passed = true;
|
||||
});
|
||||
return passed ? "next" : status;
|
||||
};
|
||||
for (const p of [
|
||||
"/ai-tools/update-all",
|
||||
"/AI-Tools/update-all/",
|
||||
"/ai-proxies/9router/update",
|
||||
"/ai-routers/9router/update",
|
||||
]) {
|
||||
assert.equal(guard("POST", p), "next", p);
|
||||
}
|
||||
for (const [method, p] of [
|
||||
["POST", "/ai-tools/claude/update"],
|
||||
["POST", "/ai-tools/claude/install"],
|
||||
["PUT", "/ai-tools/claude"],
|
||||
["PUT", "/ai-tools/omp-sharing"],
|
||||
["DELETE", "/ai-tools/update-all"],
|
||||
["POST", "/ai-proxies/9router/update/x"],
|
||||
]) {
|
||||
assert.equal(guard(method, p), 403, `${method} ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes hooks from this machine only", async () => {
|
||||
const remote = await call("POST", "/api/hooks/event", {
|
||||
body: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
Built-in: omp and Cursor (`--resume`), Antigravity (`--conversation`: an agy session's id is its conversation id). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--conversation"
|
||||
],
|
||||
"bypass": [
|
||||
"--dangerously-skip-permissions"
|
||||
],
|
||||
|
||||
@@ -1972,6 +1972,18 @@ db.exec(`
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_choices (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
chosen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||
// may serve several groups. Accounts no group lists are the shared pool.
|
||||
|
||||
+105
-3
@@ -13,6 +13,14 @@
|
||||
*
|
||||
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||
*
|
||||
* A user may also CHOOSE an account of their own (`account_choices`, Settings
|
||||
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* balancers' question — answers the admin's pin first, then that choice. A
|
||||
* choice is only ever among the accounts the user's group may use
|
||||
* (pool-groups.js `allowed`), checked when it is made and again at every
|
||||
* spawn, so an account later taken from the group falls back to the balancer.
|
||||
* A pinned user cannot choose (`ELOCKED`): the admin's pin is the decision.
|
||||
*
|
||||
* Server mode only: in local mode there are no users and nothing is pinned.
|
||||
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
||||
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
||||
@@ -45,6 +53,95 @@ function pinFor(ownerId, tool) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The account `userId` chose for `tool` themselves, or null. */
|
||||
function choiceFor(userId, tool) {
|
||||
if (!require("./auth").isServerMode() || !Number.isInteger(userId)) return null;
|
||||
try {
|
||||
return (
|
||||
db()
|
||||
.prepare("SELECT account FROM account_choices WHERE user_id = ? AND tool = ?")
|
||||
.get(userId, tool)?.account ?? null
|
||||
);
|
||||
} catch {
|
||||
return null; // a spawn never fails over a choice
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account the balancer should prefer for `ownerId`'s new `tool` pane: the
|
||||
* admin's pin, else the owner's own choice while their group still allows it.
|
||||
*/
|
||||
function preferredFor(ownerId, tool) {
|
||||
const pin = pinFor(ownerId, tool);
|
||||
if (pin) return pin;
|
||||
const choice = choiceFor(ownerId, tool);
|
||||
if (!choice) return null;
|
||||
return require("./pool-groups").allowed(ownerId, tool)(choice) ? choice : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `userId`'s own choice of `account` for `tool`, or back to the balancer
|
||||
* (`account` null). Refused while an admin pins them (`ELOCKED`), and for an
|
||||
* account not in the pool or not their group's (`ENOACCOUNT`).
|
||||
*/
|
||||
function setChoice(userId, tool, account) {
|
||||
validTool(tool);
|
||||
if (!Number.isInteger(userId)) throw err("ENOUSER", `no user ${userId}`);
|
||||
if (pinFor(userId, tool)) {
|
||||
throw err("ELOCKED", `an admin has pinned your ${tool} sessions to one account`);
|
||||
}
|
||||
if (account == null || account === "") {
|
||||
db().prepare("DELETE FROM account_choices WHERE user_id = ? AND tool = ?").run(userId, tool);
|
||||
return null;
|
||||
}
|
||||
const name = String(account);
|
||||
if (!poolOf(tool).hasAccount(name) || !require("./pool-groups").allowed(userId, tool)(name)) {
|
||||
throw err("ENOACCOUNT", `no ${tool} account ${name} you may use`);
|
||||
}
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO account_choices (user_id, tool, account) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
|
||||
chosen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
)
|
||||
.run(userId, tool, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings → AI tools → Pool account shows `userId`: per tool with at
|
||||
* least one account they may use, those accounts (never a token), the admin's
|
||||
* pin and their own choice.
|
||||
*/
|
||||
function myAccounts(userId) {
|
||||
const groups = require("./pool-groups");
|
||||
const out = [];
|
||||
for (const tool of TOOLS) {
|
||||
let status = [];
|
||||
try {
|
||||
status = poolOf(tool).poolStatus();
|
||||
} catch {
|
||||
status = [];
|
||||
}
|
||||
const ok = groups.allowed(userId, tool);
|
||||
const pin = pinFor(userId, tool);
|
||||
const accounts = status
|
||||
.filter((a) => ok(a.name))
|
||||
.map((a) => ({
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
email: a.email ?? null,
|
||||
expired: Boolean(a.expired),
|
||||
quotaPercent: a.quotaPercent ?? null,
|
||||
full: Boolean(a.full),
|
||||
}));
|
||||
if (accounts.length === 0) continue;
|
||||
const choice = choiceFor(userId, tool);
|
||||
out.push({ tool, pin, choice: choice && ok(choice) ? choice : null, accounts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
|
||||
function listPins(tool) {
|
||||
return db()
|
||||
@@ -120,23 +217,24 @@ function setAccountUsers(tool, account, userIds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a pin take effect now where it can: a Claude login is written into
|
||||
* Make a pin — or the user's own choice — take effect now where it can: a Claude login is written into
|
||||
* the user's home at once (claude-pool `switchHome`), so their running panes
|
||||
* move to it without a restart. Anything else goes in at the next spawn.
|
||||
* Answers what happened (see `switchHome`).
|
||||
*/
|
||||
async function applyPin(userId, tool) {
|
||||
const pin = pinFor(userId, tool);
|
||||
const pin = preferredFor(userId, tool);
|
||||
if (!pin || tool !== "claude") return "next-session";
|
||||
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
|
||||
const member = user ? require("./run-as").accountForUser(user.username) : null;
|
||||
return poolOf(tool).switchHome(member, pin);
|
||||
}
|
||||
|
||||
/** A removed account takes its pins — and its place in every group — with it. */
|
||||
/** A removed account takes its pins, choices — and its place in every group — with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||
db().prepare("DELETE FROM account_choices WHERE tool = ? AND account = ?").run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing pinned */
|
||||
}
|
||||
@@ -147,6 +245,10 @@ module.exports = {
|
||||
TOOLS,
|
||||
poolOf,
|
||||
pinFor,
|
||||
choiceFor,
|
||||
preferredFor,
|
||||
setChoice,
|
||||
myAccounts,
|
||||
listPins,
|
||||
setPin,
|
||||
setAccountUsers,
|
||||
|
||||
+12
-3
@@ -76,9 +76,18 @@ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
||||
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
||||
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||
// A skill may not carry a login or a key: refused, never copied.
|
||||
const SECRET_NAME_RE =
|
||||
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
|
||||
// A skill may not carry a login or a key: refused, never copied. A key file's
|
||||
// name alone proves it; a word like "token" does not — `design-tokens.md` or
|
||||
// `tokenizer.py` is skill content — so the word rule skips docs and source.
|
||||
const SECRET_FILE_RE =
|
||||
/(^\.env(\..*)?$|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|^\.?netrc$|^\.credentials\.json$|^\.git-credentials$)/i;
|
||||
const SECRET_WORD_RE = /(credential|secret|password|token)/i;
|
||||
const CONTENT_EXT_RE =
|
||||
/\.(md|mdx|markdown|rst|html?|css|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|bash|zsh|ps1|lua|sql|svg|png|jpe?g|gif|webp)$/i;
|
||||
const SECRET_NAME_RE = {
|
||||
test: (name) =>
|
||||
SECRET_FILE_RE.test(name) || (SECRET_WORD_RE.test(name) && !CONTENT_EXT_RE.test(name)),
|
||||
};
|
||||
|
||||
const root = () => path.join(getDataDir(), "artifacts");
|
||||
const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
|
||||
+25
-3
@@ -13,7 +13,8 @@
|
||||
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
|
||||
* its own bearer token), and everything else only with a valid login.
|
||||
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
|
||||
* configuration — are an `admin`'s; an `analyst` and a `developer` write
|
||||
* configuration — are an `admin`'s (bar `MEMBER_WRITE_EXCEPTIONS`: Update
|
||||
* all AI tools and Update 9router); an `analyst` and a `developer` write
|
||||
* only their own rows (`scope.js`).
|
||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||
* admin on the Users page (`server/routes/users.js`), where every change
|
||||
@@ -80,11 +81,27 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/accounts",
|
||||
// Alert rules and budgets fire for every user's sessions.
|
||||
"/alerts/rules",
|
||||
// Stopping, updating and restarting the host's 9router (both mounts).
|
||||
// Stopping and restarting the host's 9router (both mounts) — its Update is
|
||||
// in MEMBER_WRITE_EXCEPTIONS.
|
||||
"/ai-proxies/9router",
|
||||
"/ai-routers/9router",
|
||||
];
|
||||
|
||||
/**
|
||||
* The writes under `ADMIN_WRITE_PREFIXES` every signed-in user may still make,
|
||||
* matched exactly (method + lower-cased path, a trailing slash ignored): the
|
||||
* two "bring the host's tools up to date" buttons. Each runs only what a
|
||||
* release catalog file / the fixed 9router job names — nothing from the
|
||||
* request — so letting a member press it cannot widen what it runs. Install,
|
||||
* a single tool's update, enable/default and every other `/ai-tools` write
|
||||
* stay an admin's.
|
||||
*/
|
||||
const MEMBER_WRITE_EXCEPTIONS = new Set([
|
||||
"POST /ai-tools/update-all",
|
||||
"POST /ai-proxies/9router/update",
|
||||
"POST /ai-routers/9router/update",
|
||||
]);
|
||||
|
||||
/**
|
||||
* API prefixes a developer may not reach at all, reads included: the host's own
|
||||
* configuration and logs, and every view across all users (export, metrics,
|
||||
@@ -738,7 +755,11 @@ function authGuard(req, res, next) {
|
||||
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
|
||||
}
|
||||
// An analyst writes like a developer: only their own rows (scope.js).
|
||||
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
|
||||
if (
|
||||
user.role !== "admin" &&
|
||||
ADMIN_WRITE_PREFIXES.some(under) &&
|
||||
!MEMBER_WRITE_EXCEPTIONS.has(`${req.method} ${lp.replace(/\/+$/, "")}`)
|
||||
) {
|
||||
return deny(res, 403, "EADMIN", "only an admin may change this");
|
||||
}
|
||||
return next();
|
||||
@@ -771,6 +792,7 @@ module.exports = {
|
||||
COOKIE,
|
||||
ROLES,
|
||||
ADMIN_WRITE_PREFIXES,
|
||||
MEMBER_WRITE_EXCEPTIONS,
|
||||
STAFF_ONLY_PREFIXES,
|
||||
apiPath,
|
||||
newPaneToken,
|
||||
|
||||
@@ -179,6 +179,47 @@ function readActiveAuth() {
|
||||
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
|
||||
}
|
||||
|
||||
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
||||
|
||||
/**
|
||||
* The plan's name as the account's owner knows it. `subscriptionType` alone is
|
||||
* too coarse — it says `team` for both seat kinds, and it is written at sign-in,
|
||||
* so an account upgraded from Pro to Max keeps saying `pro` — while the rate
|
||||
* limit tier (`default_claude_max_5x`) and the seat (`team_standard`) carry the
|
||||
* real one. "Max 5x", "Team Premium", "Team Standard", else the type itself.
|
||||
*/
|
||||
function planLabel(oauth, account) {
|
||||
const sub = String(oauth?.subscriptionType || "").toLowerCase();
|
||||
// The token's tier, else the user's own: on a Team account the token may
|
||||
// carry the organization's tier while the member's seat is the larger one.
|
||||
const tier = `${oauth?.rateLimitTier || ""} ${account?.userRateLimitTier || ""}`;
|
||||
const seat = String(account?.seatTier || "").toLowerCase();
|
||||
const max = /max_(\d+)x/i.exec(tier);
|
||||
const org = String(account?.organizationType || "");
|
||||
const kind =
|
||||
sub === "team" || sub === "enterprise"
|
||||
? sub
|
||||
: /team/.test(org) || seat.startsWith("team")
|
||||
? "team"
|
||||
: /enterprise/.test(org)
|
||||
? "enterprise"
|
||||
: null;
|
||||
if (kind) {
|
||||
// A premium seat lifts the member onto a Max-sized limit; the seat name
|
||||
// says so for `team_premium`, the tier for `team_tier_1`.
|
||||
const level = /premium/.test(seat)
|
||||
? "Premium"
|
||||
: /standard/.test(seat)
|
||||
? "Standard"
|
||||
: max
|
||||
? "Premium"
|
||||
: null;
|
||||
return [cap(kind), level].filter(Boolean).join(" ");
|
||||
}
|
||||
if (max) return `Max ${max[1]}x`;
|
||||
return sub ? cap(sub) : null;
|
||||
}
|
||||
|
||||
/** Token-free description of an account, safe to send to the browser. */
|
||||
function identityOf({ oauth, account }) {
|
||||
return {
|
||||
@@ -188,6 +229,8 @@ function identityOf({ oauth, account }) {
|
||||
organization: (account && account.organizationName) || null,
|
||||
seatTier: (account && account.seatTier) || null,
|
||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||
/** The readable plan (`planLabel`) — what the UI shows. */
|
||||
plan: planLabel(oauth, account),
|
||||
/** When the stored access token expires; a switch still works after that
|
||||
* (the refresh token is stored too), it just refreshes on first use. */
|
||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||
@@ -544,6 +587,7 @@ module.exports = {
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
planLabel,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -435,6 +435,8 @@ function poolStatus() {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
|
||||
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
|
||||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||
addedAt: a.addedAt,
|
||||
expiresAt: expiresAt(a),
|
||||
@@ -507,9 +509,9 @@ function pick(member, ownerId = null) {
|
||||
// in their home stays: one .credentials.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
// while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||
// A pinned (or the owner's own chosen) token goes in by env and wins
|
||||
// outright; a pinned login waits while another login holds this home.
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "claude");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
|
||||
@@ -245,6 +245,7 @@ function poolStatus() {
|
||||
return {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
plan: a.kind === "chatgpt" ? planOf(a.auth) : null,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
...usageOf(a.name),
|
||||
@@ -285,6 +286,19 @@ function identity(auth) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The ChatGPT plan its id_token names, title-cased ("Plus", "Pro", "Team"), or null. */
|
||||
function planOf(auth) {
|
||||
try {
|
||||
const c = JSON.parse(
|
||||
Buffer.from(String(auth.tokens.id_token).split(".")[1], "base64url").toString("utf8")
|
||||
);
|
||||
const p = c[AUTH_CLAIM]?.chatgpt_plan_type;
|
||||
return typeof p === "string" && p ? p.charAt(0).toUpperCase() + p.slice(1) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `disk` (a member's auth.json) may replace pool account `a`: AgentHub
|
||||
* placed `a` in that home itself (`placedIn`), its `last_refresh` is no later
|
||||
@@ -362,9 +376,9 @@ function pick(member, ownerId = null) {
|
||||
// in their home stays: one auth.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "codex");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||
// waits while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||
// A pinned (or the owner's own chosen) API key goes in by env and wins
|
||||
// outright; a pinned ChatGPT login waits while another login holds this home.
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "codex");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
|
||||
if (live) return live;
|
||||
|
||||
@@ -176,6 +176,16 @@ function readChunk(file, offset, size) {
|
||||
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
|
||||
}
|
||||
|
||||
/** A regular file, by `stat`: `existsSync` is access(2), blind to the
|
||||
* CAP_DAC_READ_SEARCH that reaches a member's 0700 CODEX_HOME (S3). */
|
||||
function isFile(p) {
|
||||
try {
|
||||
return fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
|
||||
async function parentTotals(file) {
|
||||
const keys = new Set();
|
||||
@@ -498,7 +508,7 @@ function ingestCodexUsage({
|
||||
if (walkedAll) return null;
|
||||
const known = threadPath.get(id)?.path;
|
||||
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
|
||||
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
|
||||
if (known && known.startsWith(root) && isFile(known)) return known;
|
||||
walkedAll = true;
|
||||
for (const f of allRolloutFiles(home)) {
|
||||
const m = FILE_NAME.exec(path.basename(f.path));
|
||||
|
||||
@@ -118,7 +118,7 @@ function pick(ownerId = null) {
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||||
const usable = allAccounts().filter((a) => allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||||
const pin = require("./account-pins").preferredFor(ownerId, "cursor");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned) return pinned;
|
||||
return usable
|
||||
|
||||
+4
-4
@@ -36,7 +36,7 @@
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name as the tool reports it: Claude's `subscriptionType`,
|
||||
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
|
||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||
* `expiresAt`, when its stored access token runs out — Claude's
|
||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
||||
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
@@ -142,7 +142,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
plan: oauth.subscriptionType || null,
|
||||
plan: planLabel(oauth, account),
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
@@ -172,7 +172,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
async function profileClaude(item) {
|
||||
const who = {
|
||||
account: item.email || item.displayName || null,
|
||||
plan: item.subscriptionType || null,
|
||||
plan: item.plan || item.subscriptionType || null,
|
||||
expiresAt: iso(item.expiresAt),
|
||||
};
|
||||
let creds = null;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||
* - GET/PUT /api/me/pool-accounts (Auth) — the account's own pick of a pool account per tool
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
@@ -243,6 +244,103 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/pool-accounts": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "The account pool as this account may use it (server mode)",
|
||||
description:
|
||||
"Per tool with at least one account the account's pool group allows: those accounts (never a token), an admin's pin and the account's own choice. Local mode: {tools: []}.",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tools",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tools"],
|
||||
properties: {
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["tool", "pin", "choice", "accounts"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
pin: { type: "string", nullable: true },
|
||||
choice: { type: "string", nullable: true },
|
||||
accounts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: { type: "string" },
|
||||
email: { type: "string", nullable: true },
|
||||
expired: { type: "boolean" },
|
||||
quotaPercent: { type: "number", nullable: true },
|
||||
full: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: err("EAUTH"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
summary: "Run this account's new sessions of a tool on one pool account (null = automatic)",
|
||||
description:
|
||||
"Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["tool"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
account: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Chosen",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string" },
|
||||
choice: { type: "string", nullable: true },
|
||||
switched: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
enum: ["switched", "kept", "own-login", "expired", "next-session"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADTOOL"),
|
||||
401: err("EAUTH"),
|
||||
403: err("ELOCALMODE"),
|
||||
404: err("ENOACCOUNT — not in the pool, or not one the account's group may use"),
|
||||
409: err("ELOCKED — an admin pinned this account's sessions of the tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/me/onboarded": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -88,7 +88,7 @@ router.get("/profiles", (req, res) => {
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
email: p.email,
|
||||
subscriptionType: p.subscriptionType,
|
||||
subscriptionType: p.plan || p.subscriptionType,
|
||||
active: p.active,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -112,11 +112,10 @@ router.get("/9router/update", async (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/ai-proxies/9router/update — start the job; 202 with it. Poll the
|
||||
// GET above for its steps. Admin only (also in auth.js ADMIN_WRITE_PREFIXES).
|
||||
router.post("/9router/update", sameOriginGuard, async (req, res) => {
|
||||
// GET above for its steps. Any signed-in user (auth.js MEMBER_WRITE_EXCEPTIONS):
|
||||
// the job is fixed, nothing from the request reaches it.
|
||||
router.post("/9router/update", sameOriginGuard, async (_req, res) => {
|
||||
try {
|
||||
if (req.user && req.user.role !== "admin")
|
||||
throw Object.assign(new Error("only an admin may update 9router"), { code: "EADMIN" });
|
||||
res.status(202).json(await nineRouterUpdate.start());
|
||||
} catch (e) {
|
||||
return fail(res, e);
|
||||
|
||||
@@ -30,6 +30,12 @@
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
|
||||
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
|
||||
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
|
||||
* `/pool-accounts` (server mode) is the account pool as this account may use
|
||||
* it (server/lib/account-pins.js `myAccounts`): GET lists, per tool, the
|
||||
* accounts its group allows — never a token — with the admin's pin and its
|
||||
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
|
||||
* while an admin pins it (`ELOCKED`) or for an account it may not use
|
||||
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
* the theme in their own Claude Code settings, written as them.
|
||||
@@ -188,6 +194,10 @@ const ACCOUNT_STATUS = {
|
||||
ETOOBIG: 413,
|
||||
EREAUTH: 403,
|
||||
ETOOMANY: 429,
|
||||
EBADTOOL: 400,
|
||||
ENOUSER: 404,
|
||||
ENOACCOUNT: 404,
|
||||
ELOCKED: 409,
|
||||
};
|
||||
|
||||
function accountView(userId) {
|
||||
@@ -316,6 +326,50 @@ router.delete(
|
||||
})
|
||||
);
|
||||
|
||||
// ── The account pool: the user's own choice of account per tool ───────────
|
||||
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
/** Every tool with an account this user may use: those accounts, the admin's pin, their choice. */
|
||||
router.get("/pool-accounts", (req, res) => {
|
||||
if (!auth.isServerMode()) return res.json({ tools: [] });
|
||||
const user = req.user || auth.userFromRequest(req);
|
||||
if (!user) return unauth(res);
|
||||
try {
|
||||
res.json({ tools: pins.myAccounts(user.id) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* `{tool, account}` — run this user's new sessions of `tool` on `account`
|
||||
* (`null`: the balancer picks). A Claude login goes into their home at once,
|
||||
* like an admin's switch (`switched`); anything else at the next session.
|
||||
*/
|
||||
router.put(
|
||||
"/pool-accounts",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const choice = pins.setChoice(user.id, body.tool, body.account ?? null);
|
||||
let switched = null;
|
||||
if (choice) {
|
||||
try {
|
||||
switched = await pins.applyPin(user.id, body.tool);
|
||||
} catch (e) {
|
||||
console.warn(`[me] switch ${user.username} → ${choice}: ${e.message}`);
|
||||
switched = "next-session";
|
||||
}
|
||||
}
|
||||
return {
|
||||
out: { tool: body.tool, choice, switched },
|
||||
action: choice
|
||||
? `own ${body.tool} pool account ${choice}`
|
||||
: `own ${body.tool} pool account: automatic`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// An avatar past its limit reaches here as express.raw's 413.
|
||||
router.use((e, _req, res, next) => {
|
||||
if (e && e.type === "entity.too.large")
|
||||
|
||||
@@ -35,6 +35,20 @@ const {
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Whether `p` is a regular file, by `stat`. Not `fs.existsSync`: that is
|
||||
* access(2), which checks the REAL uid with capabilities cleared, so it says
|
||||
* "no" for a member's journal behind a 0700 folder (`~/.omp/agent`, S3) that
|
||||
* the server itself reads through its CAP_DAC_READ_SEARCH.
|
||||
*/
|
||||
function isFile(p) {
|
||||
try {
|
||||
return fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// JSONL entry types the transcript reader turns into renderable messages.
|
||||
// `user`/`assistant` are the conversation. `custom-title` is the metadata line
|
||||
// written by /rename, `claude -n`, and the picker's Ctrl+R — surfaced as an
|
||||
@@ -864,7 +878,7 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
? require("../lib/cursor-usage").transcriptPath(req.params.id)
|
||||
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
|
||||
const list =
|
||||
file && fs.existsSync(file)
|
||||
file && isFile(file)
|
||||
? [
|
||||
{
|
||||
id: "main",
|
||||
@@ -1142,7 +1156,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
getSnapshotTranscriptPath(req.params.id);
|
||||
}
|
||||
|
||||
if (!jsonlPath || !fs.existsSync(jsonlPath)) {
|
||||
if (!jsonlPath || !isFile(jsonlPath)) {
|
||||
return res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
|
||||
}
|
||||
|
||||
|
||||
@@ -292,10 +292,13 @@ async function poolFor(uid) {
|
||||
}
|
||||
const ok = groups.allowed(uid, tool);
|
||||
const pin = pins.pinFor(uid, tool);
|
||||
const choice = pins.choiceFor(uid, tool);
|
||||
const ranOn = used.filter((u) => u.tool === tool);
|
||||
const wholeSession = tool !== "claude";
|
||||
out[tool] = {
|
||||
pin,
|
||||
// The user's own pick (Settings → AI tools → Pool account), below a pin.
|
||||
choice,
|
||||
group: group ? { id: group.id, name: group.name } : null,
|
||||
accounts: status
|
||||
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))
|
||||
|
||||
Reference in New Issue
Block a user