Compare commits

..

6 Commits

Author SHA1 Message Date
nntrivi2001 9e3d6f4ca1 chore: v2.8.30 — account pool as one board of pools, real Claude plan names 2026-10-28 11:47:19 +07:00
nntrivi2001 26aabd6660 feat(pool): one board of pools with split/merge by drag; plan names from tier and seat
- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
2026-10-28 11:47:19 +07:00
nntrivi2001 9d07f64154 chore: v2.8.29 — skills with "token" in a doc name upload, System Health hides empty cards off Linux 2026-10-28 10:41:53 +07:00
nntrivi2001 0f17efe3b4 feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
2026-10-28 10:41:53 +07:00
nntrivi2001 4999325210 chore: v2.8.28 — System Health grouped, a health score that sees lag 2026-10-27 16:40:01 +07:00
nntrivi2001 1c6dfea5b5 feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
2026-10-27 16:39:38 +07:00
87 changed files with 7911 additions and 2656 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @file SessionToolBadge — names the AI coding CLI that produced a session
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
* list and session header look as before for every Claude row.
* its AI Tools label). Renders nothing for a Claude session, so the session
* header looks as before for every Claude row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
+165
View File
@@ -0,0 +1,165 @@
/**
* @file MiniChart.tsx
* @description The Administration page's small single-series charts: a bar
* chart (a user's daily spend) and a line chart (System Health's CPU and
* memory history), plain SVG in the app's theme tokens. One series each — the
* card's title names it, so there is no legend — thin marks, rounded bar ends
* on the baseline, a recessive grid with a max label, and a hover tooltip
* (crosshair for the line, per bar for the bars) whose text is the caller's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState, type MouseEvent } from "react";
export type ChartPoint = { x: string; y: number };
const H = 120;
const PAD_TOP = 14;
const PAD_BOTTOM = 18;
/**
* @param props.points - In x order; `x` is the axis/tooltip label.
* @param props.kind - `bar` or `line`.
* @param props.format - Formats a value for the max label and the tooltip.
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
* @param props.label - The chart's accessible name.
*/
export function MiniChart({
points,
kind,
format,
max,
label,
emptyText,
}: {
points: ChartPoint[];
kind: "bar" | "line";
format: (v: number) => string;
max?: number;
label: string;
emptyText?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
const [hover, setHover] = useState<number | null>(null);
if (points.length === 0)
return (
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
{emptyText ?? "—"}
</div>
);
const W = 600;
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
const plotH = H - PAD_TOP - PAD_BOTTOM;
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
const step = W / points.length;
const xOf = (i: number) =>
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
const onMove = (e: MouseEvent<SVGSVGElement>) => {
const r = ref.current?.getBoundingClientRect();
if (!r || r.width === 0) return;
const fx = ((e.clientX - r.left) / r.width) * W;
const i =
kind === "bar"
? Math.floor(fx / step)
: Math.round((fx / W) * Math.max(1, points.length - 1));
setHover(Math.max(0, Math.min(points.length - 1, i)));
};
const h = hover != null ? points[hover] : null;
const barW = Math.max(2, Math.min(24, step - 2));
const line = points
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
.join("");
return (
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="block h-[120px] w-full"
role="img"
aria-label={label}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((f) => (
<line
key={f}
x1={0}
x2={W}
y1={yOf(top * f)}
y2={yOf(top * f)}
className="stroke-border"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
strokeDasharray={f === 0 ? undefined : "3 3"}
/>
))}
{kind === "bar" ? (
points.map((p, i) => {
const y = yOf(p.y);
const bh = Math.max(0, PAD_TOP + plotH - y);
const r = Math.min(4, barW / 2, bh);
const x = xOf(i) - barW / 2;
const base = PAD_TOP + plotH;
// Rounded top only; the base sits flat on the axis.
const d =
bh <= 0
? ""
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
return (
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
);
})
) : (
<>
<path
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
className="fill-accent-solid"
opacity={0.12}
/>
<path
d={line}
fill="none"
className="stroke-accent-solid"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
{hover != null && (
<line
x1={xOf(hover)}
x2={xOf(hover)}
y1={PAD_TOP}
y2={PAD_TOP + plotH}
className="stroke-fg-muted"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
</>
)}
</svg>
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
{format(top)}
</span>
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
<span>{points[0]!.x}</span>
<span>{points[points.length - 1]!.x}</span>
</div>
{h && hover != null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
role="status"
>
<div className="text-fg-muted">{h.x}</div>
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
</div>
)}
</div>
);
}
-417
View File
@@ -1,417 +0,0 @@
/**
* @file ServerPanel.tsx
* @description The Administration page's Server tab: the machine AgentHub
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
* machine, resident memory, process count, the busiest processes by name, and
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
* at once. Read-only — the only action here is the members' tmux card
* (`MemberTmuxCard`, an admin's restart).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronDown,
ChevronRight,
Cpu,
HardDrive,
MemoryStick,
MonitorCog,
RefreshCw,
Server,
} from "lucide-react";
import {
api,
type HostStats,
type HostUsageGroup,
type ServerUserUsage,
type UserStorage,
} from "../../lib/api";
import { formatBytes } from "../../lib/format";
import { useSpin } from "../../hooks/useSpin";
import { StatCard } from "../StatCard";
import { UserAvatar } from "../users/UserAvatar";
import { MemberTmuxCard } from "../users/MemberTmuxCard";
const POLL_MS = 5000;
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** A thin bar, amber past 75 %, red past 90 %. */
function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
}
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("admin");
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set());
const load = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
const id = setInterval(() => {
if (document.visibilityState === "visible") void load();
}, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Names, pictures and home sizes: read once, they change slowly.
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
api.users
.storage()
.then((r) => setStorage(r.storage))
.catch(() => setStorage({}));
}, []);
const byId = new Map((users ?? []).map((u) => [u.id, u]));
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const loading = !stats && !error;
const mem = stats?.memory;
const toggle = (key: string) =>
setOpen((s) => {
const n = new Set(s);
if (n.has(key)) n.delete(key);
else n.add(key);
return n;
});
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("server.hostGroup");
if (g.kind === "system") return t("server.systemGroup");
const u = g.userId != null ? byId.get(g.userId) : undefined;
return u?.displayName || g.username || "";
};
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<div className="space-y-6" data-testid="server-panel">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<Server className="w-4 h-4 text-accent" />
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
</h2>
{stats && (
<p className="mt-0.5 text-xs text-fg-muted">
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
{" · "}
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
</p>
)}
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
{error && (
<div
role="alert"
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">{error}</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label={t("server.cpu")}
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
icon={Cpu}
trend={
stats
? t("server.cores", {
n: stats.cpu.cores,
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
})
: undefined
}
raw={stats?.cpu.model ?? undefined}
help={t("server.cpuHelp")}
loading={loading}
/>
<StatCard
label={t("server.memory")}
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
icon={MemoryStick}
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.swap")}
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
icon={MonitorCog}
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
loading={loading}
/>
<StatCard
label={t("server.homesDisk")}
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
icon={HardDrive}
trend={
homesDisk
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
: undefined
}
help={t("server.homesDiskHelp")}
loading={loading}
/>
</div>
<div className="card overflow-x-auto">
<div className="px-5 pt-4 pb-2">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
<p className="text-xs text-fg-muted">
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
</p>
</div>
{stats && !stats.usage ? (
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
) : (
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.who")}</th>
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
<th className={`${th} text-right`}>{t("server.processes")}</th>
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading && (
<tr>
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
{t("server.sampling")}
</td>
</tr>
)}
{stats?.usage?.groups.map((g) => {
const u = g.userId != null ? byId.get(g.userId) : undefined;
const home = g.userId != null && storage ? storage[g.userId] : undefined;
const isOpen = open.has(g.key);
const name = groupName(g);
return (
<Fragment key={g.key}>
<tr className="hover:bg-surface-4/40 transition-colors">
<td className="px-5 py-3">
<button
type="button"
onClick={() => toggle(g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex items-center gap-2 text-left disabled:cursor-default"
>
{g.top.length ? (
isOpen ? (
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
)
) : (
<span className="w-3.5" />
)}
{g.kind === "user" ? (
<UserAvatar
name={name}
avatarAt={u?.avatarAt}
userId={g.userId}
className="h-6 w-6 text-[10px]"
/>
) : (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
<Server className="w-3.5 h-3.5 text-fg-muted" />
</span>
)}
<span className="min-w-0">
<span className="block text-sm text-fg-primary truncate">{name}</span>
{g.kind === "user" && u?.displayName && (
<span className="block text-[11px] text-fg-muted">{g.username}</span>
)}
</span>
</button>
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(g.memory)}{" "}
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
</div>
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{g.processes}
</td>
<td className="px-5 py-3">
{home ? (
<>
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(home.bytes)}
{homesDisk && (
<span className="text-fg-muted">
{" "}
({fmtPct(pct(home.bytes, homesDisk.total))})
</span>
)}
</div>
{homesDisk && (
<Meter
value={pct(home.bytes, homesDisk.total)}
label={`${name} ${t("server.home")}`}
/>
)}
</>
) : (
<span className="text-sm text-fg-muted">—</span>
)}
</td>
</tr>
{isOpen &&
g.top.map((p) => (
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{fmtPct(p.cpu)}
</td>
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
{formatBytes(p.memory)}
</td>
<td colSpan={2} />
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
)}
</div>
{stats && stats.disks.length > 0 && (
<div className="card overflow-x-auto">
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
{t("server.disks")}
</h3>
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("server.mount")}</th>
<th className={th}>{t("server.device")}</th>
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
<th className={`${th} text-right`}>{t("server.free")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{stats.disks.map((d) => (
<tr key={d.device}>
<td className="px-5 py-3 text-sm text-fg-primary">
<span className="font-mono">{d.mount}</span>
{d.mount === stats.homesMount && (
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
)}
</td>
<td className="px-5 py-3 text-xs text-fg-secondary">
<span className="font-mono">{d.device}</span> · {d.type}
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
</td>
<td className="px-5 py-3">
<div className="text-sm text-fg-primary tabular-nums">
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
</div>
<Meter value={pct(d.used, d.total)} label={d.mount} />
</td>
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
{formatBytes(d.free)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{stats && stats.gpus.length > 0 && (
<div className="card p-5 space-y-3">
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
{stats.gpus.map((g, i) => (
<div key={i} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-fg-primary">{g.name}</span>
<span className="text-fg-muted tabular-nums">
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
{formatBytes(g.memoryTotal)}
</span>
</div>
<Meter value={g.utilization} label={g.name} />
</div>
))}
</div>
)}
<MemberTmuxCard isAdmin={isAdmin} />
</div>
);
}
+721
View File
@@ -0,0 +1,721 @@
/**
* @file HostHealth.tsx
* @description The machine AgentHub runs on, as part of the Dashboard's
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
* signed-in user sees all of it). In the tab's own card style:
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
* 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 (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.
* Read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
ArrowDown,
ArrowUp,
Cpu,
HardDrive,
ListTree,
Network,
Server,
Thermometer,
Users,
} from "lucide-react";
import {
api,
type HostHistory,
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
export type HostState = {
stats: HostStats | null;
error: string | null;
reload: () => Promise<void>;
};
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
useEffect(() => {
if (!enabled) return;
void reload();
if (!pollMs) return;
const id = setInterval(() => {
if (document.visibilityState === "visible") void reload();
}, pollMs);
return () => clearInterval(id);
}, [enabled, pollMs, reload]);
return { stats, error, reload };
}
/** A thin bar, amber past 75 %, red past 90 %. */
export function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
/** The System Health tab's own bar: h-2, green / amber / red by level. */
function HealthBar({
label,
value,
tip,
warn = 70,
bad = 90,
}: {
label: string;
value: number | null;
tip?: string;
warn?: number;
bad?: number;
}) {
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
return (
<Tip block raw={tip}>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{label}</span>
<span className="text-fg-secondary font-mono">
{value == null ? "—" : `${Math.round(v)}%`}
</span>
</div>
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="w-full bg-surface-3 rounded-full h-2"
>
<div
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
style={{ width: `${v}%` }}
/>
</div>
</div>
</Tip>
);
}
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
return (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-fg-muted">{n}</span>
<span className={tone}>{Math.round(v)}%</span>
</div>
<Meter value={v} label={label} />
</div>
);
}
function CardHead({
icon: Icon,
tone,
title,
right,
}: {
icon: ComponentType<{ className?: string }>;
tone: string;
title: string;
right?: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
</div>
{right}
</div>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
{children}
</span>
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
}
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
export type HeapInfo = {
heapUsed: number;
heapTotal: number;
heapLimit?: number;
external: number;
};
/**
* The tab's first card: the machine and the AgentHub process on it.
* `heap` comes from the tab's settings read.
*/
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const s = host.stats;
const mem = s?.memory;
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
return (
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
<CardHead
icon={Server}
tone="text-status-success"
title={t("host.title")}
right={
s && (
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
{s.hostname}
</span>
)
}
/>
{!s ? (
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
) : (
<>
<div className="space-y-2.5">
<Row label={t("host.os")}>
{s.os.distro ?? s.os.platform} · {s.os.release}
</Row>
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
<Row label={t("host.cpuModel")}>
<Tip raw={s.cpu.model ?? undefined}>
<span>
{s.cpu.physicalCores
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
: ""}
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
</span>
</Tip>
</Row>
{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>
)}
{s.process && (
<Row label={t("host.agenthub")}>
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
<span>
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
</span>
</Tip>
</Row>
)}
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
{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}
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
/>
{mem && mem.swapTotal > 0 && (
<HealthBar
label={t("host.swap")}
value={pct(mem.swapUsed, mem.swapTotal)}
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
/>
)}
{heap && (
<HealthBar
label={t("health.heap")}
value={heapPct}
warn={60}
bad={85}
tip={t("health.heapTip", {
pct: (heapPct ?? 0).toFixed(1),
used: formatBytes(heap.heapUsed),
limit: formatBytes(heapMax),
allocated: formatBytes(heap.heapTotal),
external: formatBytes(heap.external),
})}
/>
)}
</div>
</>
)}
</div>
);
}
/** The tab's machine group: host + history, CPU / disks / processes, users. */
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const { stats } = host;
const [range, setRange] = useState<HostHistoryRange>("1h");
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
const loadHistory = useCallback(
() =>
api.host
.history(range)
.then(setHistory)
.catch(() => setHistory(null)),
[range]
);
useEffect(() => {
void loadHistory();
const id = setInterval(() => {
if (document.visibilityState === "visible") void loadHistory();
}, HISTORY_POLL_MS);
return () => clearInterval(id);
}, [loadHistory]);
// Same tree as the full render below, so the card is not remounted once stats arrive.
if (!stats)
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
</div>
</>
);
const groups = stats.usage?.groups ?? [];
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("host.hostGroup");
if (g.kind === "system") return t("host.systemGroup");
return g.displayName || g.username || "";
};
const groupByKey = new Map(groups.map((g) => [g.key, g]));
const ownerOf = (key: string) => {
const g = groupByKey.get(key);
if (g) return groupName(g);
if (key === "host") return t("host.hostGroup");
if (key === "system") return t("host.systemGroup");
return key;
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const histPoints = (key: "cpu" | "memory") =>
(history?.points ?? [])
.filter((p) => p[key] != null)
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
const perCore = stats.cpu.perCore ?? [];
const network = stats.network ?? [];
const temps = stats.temperatures ?? [];
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
// mounted gets a row of its own.
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
const diskRows = stats.disks.map((d) => {
const base = d.device.split("/").pop() ?? "";
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
const io = key ? ioLeft.get(key) : undefined;
if (key) ioLeft.delete(key);
return { disk: d, io };
});
const loneIo = [...ioLeft.values()];
const rate = (down?: number, up?: number) => (
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
<span className="inline-flex items-center gap-0.5">
<ArrowDown className="w-2.5 h-2.5" />
{down == null ? "—" : perSec(down)}
</span>
<span className="inline-flex items-center gap-0.5">
<ArrowUp className="w-2.5 h-2.5" />
{up == null ? "—" : perSec(up)}
</span>
</span>
);
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
{/* CPU and memory over time. */}
{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>
{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 empty:hidden">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
{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.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>
{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) && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
<CardHead
icon={HardDrive}
tone="text-syntax-builtin"
title={t("host.storageNetwork")}
/>
<div className="space-y-3" data-testid="server-disk-io">
{diskRows.map(({ disk: d, io }) => (
<div key={d.device} className="space-y-1">
<HealthBar
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
value={pct(d.used, d.total)}
warn={75}
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
/>
<div className="flex justify-between gap-2">
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(d.free)} {t("host.free")}
</span>
{io && rate(io.read, io.write)}
</div>
</div>
))}
{loneIo.map((io) => (
<div key={io.name} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
<span className="ml-auto">{rate(io.read, io.write)}</span>
</div>
))}
</div>
{network.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-network"
>
{network.map((n) => (
<div key={n.name} className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
<Network className="w-3 h-3 text-fg-muted" />
<span className="font-mono">{n.name}</span>
</span>
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
</div>
))}
</div>
)}
</div>
)}
{/* The busiest processes, and what belongs to no user. */}
{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="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Each user's live share of the machine (server mode). */}
{people.length > 0 && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
<CardHead
icon={Users}
tone="text-syntax-string"
title={t("host.usersTitle")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.usersCount", { n: people.length })}
</span>
}
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
{people.map((g) => {
const name = groupName(g);
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{t("host.processCount", { n: g.processes })}
</span>
</button>
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
{isOpen && (
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
{g.top.map((p) => (
<li
key={p.pid}
className="flex justify-between gap-2 text-[10px] font-mono"
>
<span className="truncate text-fg-secondary">
{p.name} <span className="text-fg-muted">#{p.pid}</span>
</span>
<span className="flex-shrink-0 text-fg-muted">
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,212 @@
/**
* @file HostHealth.test.tsx
* @description The machine on the Dashboard's System Health tab
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
* rows under it show every CPU thread, temperatures, each disk with its I/O
* 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);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { HostStats } from "../../../lib/api";
const GB = 1024 ** 3;
const stats: HostStats = {
at: "2026-10-15T00:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
uptime: 90000,
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
disks: [
{
device: "/dev/sdb2",
mount: "/",
type: "ext4",
kind: "SSD",
total: 200 * GB,
used: 50 * GB,
free: 150 * GB,
},
{
device: "/dev/sda1",
mount: "/data",
type: "ext4",
kind: "HDD",
total: 500 * GB,
used: 0,
free: 500 * GB,
},
],
homesMount: "/",
gpus: [],
usage: {
cpu: 40,
sampleMs: 1000,
groups: [
{
key: "user:2",
kind: "user",
userId: 2,
username: "mia",
cpu: 25,
memory: 2 * GB,
memoryPercent: 12.5,
processes: 3,
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
},
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
],
},
};
const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null },
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api };
});
import { HostSections, useHostStats } from "../HostHealth";
function Tab() {
const host = useHostStats();
return (
<HostSections
host={host}
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
/>
);
}
const full: HostStats = {
...stats,
usage: {
...stats.usage!,
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
},
bootTime: "2026-10-14T00:00:00Z",
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
diskIo: [{ name: "sda", read: 4096, write: 0 }],
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
};
beforeEach(() => {
vi.clearAllMocks();
api.host.stats.mockResolvedValue(full);
api.host.history.mockResolvedValue({
range: "1h",
intervalMs: 30000,
running: true,
startedAt: "2026-10-15T00:00:00Z",
points: [
{
at: "2026-10-15T00:00:00Z",
cpu: 30,
memory: 50,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
{
at: "2026-10-15T00:00:30Z",
cpu: 40,
memory: 52,
memoryUsed: 0,
swapUsed: 0,
load1: 1,
users: {},
},
],
});
});
describe("HostHealth", () => {
it("names the machine and the AgentHub process, with bars", async () => {
render(<Tab />);
const card = await screen.findByTestId("server-facts");
expect(await within(card).findByText("box")).toBeTruthy();
expect(card.textContent).toContain("Ubuntu 26.04");
expect(card.textContent).toContain("1.0 GB");
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"40"
);
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
"50"
);
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
});
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
render(<Tab />);
expect(await screen.findByTestId("server-history")).toBeTruthy();
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
const disks = screen.getByTestId("server-disk-io").textContent;
expect(disks).toContain("/data · HDD");
expect(disks).toContain("4.0 KB/s");
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
const top = screen.getByTestId("server-top-processes");
expect(await within(top).findByText("Mia")).toBeTruthy();
const other = screen.getByTestId("server-usage-table");
expect(within(other).queryByText("Mia")).toBeNull();
expect(within(other).getByText(/^Host account/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
expect(api.host.history).toHaveBeenLastCalledWith("24h");
});
it("shows each user's live CPU and RAM, their processes on click", async () => {
render(<Tab />);
const tile = await screen.findByTestId("server-user");
expect(tile.textContent).toContain("Mia");
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
"25"
);
expect(tile.textContent).toContain("2.0 GB");
expect(within(tile).queryByText("claude")).toBeNull();
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();
});
});
+156 -289
View File
@@ -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,18 +69,20 @@ 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. */
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
/**
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
* passed is empty again (the reading predates it), as the balancer treats it.
*/
function windowsOf(
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
export function windowsOf(
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
): QuotaWindow[] {
const q = a.quota as
| {
@@ -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>
);
}
@@ -558,7 +506,7 @@ function AccountCard({
* One window of an account's quota, shown as what is LEFT (100% = untouched,
* 0% = used up), with when it resets.
*/
function QuotaBar({ window: w }: { window: QuotaWindow }) {
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
const { t } = useTranslation("users");
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
const resets = untilText(w.resetsAt);
@@ -568,7 +516,7 @@ 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 @@ 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>
);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* @file MemberTmux.tsx
* @description Each member's own tmux server as part of the Administration
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
* older than the installed one, its sessions, whether an agent of theirs is
* working — and `MemberTmuxBar` above the table, so an admin sees who still
* runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
export function useMemberTmux(enabled = true) {
const [data, setData] = useState<MemberTmux | null>(null);
const reload = useCallback(
() =>
api.users
.tmux()
.then(setData)
.catch(() => setData(null)),
[]
);
useEffect(() => {
if (enabled) void reload();
}, [enabled, reload]);
return { data, reload };
}
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
const { t } = useTranslation("users");
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
return (
<div className="text-sm text-fg-secondary">
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
<div
className="text-[11px] text-fg-muted"
title={
row.startedAt
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
: undefined
}
>
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
</div>
</div>
);
}
/**
* Above the users table: the installed tmux, the not-yet-installed tmux-member
* warning, and (an admin) "Restart N idle members". Nothing without S3.
*/
export function MemberTmuxBar({
data,
isAdmin,
onRestart,
}: {
data: MemberTmux | null;
isAdmin: boolean;
onRestart: (target: "idle") => void;
}) {
const { t } = useTranslation("users");
if (!data?.available) return null;
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
return (
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<TerminalSquare className="w-3.5 h-3.5" />
{t("tmux.description", { version: data.installed ?? "?" })}
</span>
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
{isAdmin && idle.length > 0 && (
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
</button>
)}
</div>
);
}
/** An admin's restart of one member's tmux, or of every idle outdated one. */
export function TmuxRestartDialog({
target,
data,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
data: MemberTmux | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
@@ -1,267 +0,0 @@
/**
* @file MemberTmuxCard.tsx
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
* beside the installed one, its sessions, whether an agent of theirs is
* working — so an admin sees who still runs an old tmux after an upgrade.
* An admin restarts one member's server, or every idle outdated one at once
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
* time with their own password again; a restart ends that member's panes,
* and the next start runs the installed tmux. An analyst only reads.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
? t(`tmux.errors.${err.code}`)
: err.message;
}
const BADGE = "badge ml-1.5 py-0 text-[10px]";
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [data, setData] = useState<MemberTmux | null>(null);
const [error, setError] = useState<string | null>(null);
// A member row = restart that one; "idle" = every idle outdated member.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const load = useCallback(
() =>
api.users
.tmux()
.then((r) => {
setData(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
void load();
}, [load]);
if (!data?.available) return null;
const rows = data.users.filter((u) => u.running);
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="member-tmux">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<TerminalSquare className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
<p className="text-xs text-fg-muted">
{t("tmux.description", { version: data.installed ?? "?" })}
</p>
</div>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("tmux.refresh")}
aria-label={t("tmux.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && idleOutdated.length > 0 && (
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
<RotateCcw className="w-4 h-4" />{" "}
{t("tmux.restartIdle", { count: idleOutdated.length })}
</button>
)}
</div>
{!data.matched && (
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
{t("tmux.notMatched")}
</p>
)}
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{rows.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("tmux.user")}</th>
<th className={th}>{t("tmux.version")}</th>
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
<th className={th}>{t("tmux.agents")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((u) => (
<tr key={u.id}>
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
{u.startedAt && (
<div className="text-[11px] text-fg-muted">
{t("tmux.since", {
at: formatDateTime(new Date(u.startedAt).toISOString()),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions ?? "—"}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<button
type="button"
onClick={() => setRestarting(u)}
title={t("tmux.restart", { name: u.username })}
aria-label={t("tmux.restart", { name: u.username })}
className="btn-icon"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{restarting && (
<RestartDialog
target={restarting}
idle={idleOutdated}
onClose={() => setRestarting(null)}
onDone={(msg) => {
toast.success(msg);
setRestarting(null);
void load();
}}
/>
)}
</section>
);
}
function RestartDialog({
target,
idle,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
idle: MemberTmuxRow[];
onClose: () => void;
onDone: (message: string) => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const one = target === "idle" ? null : target;
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
if (one) {
await api.users.tmuxRestart(one.id, current);
onDone(t("tmux.done", { name: one.username }));
} else {
const r = await api.users.tmuxRestartIdle(current);
// A server that would not stop needs root: say which, the rest went.
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
}
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
message={
one
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
name: one.username,
count: one.sessions ?? 0,
})
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
}
confirmLabel={t("tmux.restartButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
/**
* @file UserDetail.tsx
* @description One account, opened IN PLACE under its row of the
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
* The row already shows identity, sessions, spend, home and tmux, so
* this keeps only what the row cannot: one summary line (last sign-in, last
* activity, active sessions and live panes), then three short columns — spend
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
* group, each account's quota left beside this user's own 7-day share), and
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
* the row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Play, RefreshCw } from "lucide-react";
import { api, type PoolTool, type UserDetails } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { SessionStatusBadge } from "../StatusBadge";
import { MiniChart } from "../admin/MiniChart";
import { QuotaBar, windowsOf } from "./AccountPoolCard";
const POOL_LABEL: Record<PoolTool, string> = {
claude: "Claude Code",
codex: "Codex",
cursor: "Cursor CLI",
};
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
export function UserDetail({ userId }: { userId: number }) {
const { t } = useTranslation("users");
const { label: toolLabel } = useAiTools();
const [data, setData] = useState<UserDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
() =>
api.users
.details(userId)
.then((d) => {
setData(d);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[userId]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
setData(null);
void load();
}, [load]);
if (error)
return (
<div
role="alert"
data-testid="user-detail"
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("detail.failed")}: {error}
</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
);
if (!data)
return (
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
{t("common:loading")}
</p>
);
const u = data.user;
const total30 = data.usage.last30d.cost;
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
);
return (
<div className="space-y-4" data-testid="user-detail">
{/* One line of what the row does not show. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
<Inline label={t("detail.lastLogin")}>
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
</Inline>
<Inline label={t("detail.lastSession")}>
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
</Inline>
<Inline label={t("detail.activeLabel")}>
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
</Inline>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost ml-auto py-1 text-xs"
>
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Spend: the month at a glance, then where it went. */}
<Panel title={t("detail.spendDaily")}>
<MiniChart
kind="bar"
label={t("detail.spendDaily")}
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
format={(v) => fmtCost(v)}
/>
{data.usage.byTool.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
) : (
<ul className="space-y-1.5 pt-1">
{data.usage.byTool.map((r) => (
<ShareRow
key={r.key}
label={
<span className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: r.key, label: toolLabel(r.key) }}
className="h-4 w-4"
/>
{toolLabel(r.key)}
</span>
}
cost={r.cost}
total={total30}
/>
))}
{data.usage.byModel.slice(0, 3).map((r) => (
<ShareRow
key={`m:${r.key}`}
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
cost={r.cost}
total={total30}
/>
))}
</ul>
)}
</Panel>
{/* Pool: what they may run on, and how much of it is theirs. */}
<Panel title={t("detail.pool")}>
{pools.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
) : (
pools.map((tool) => {
const p = data.pool[tool]!;
return (
<div key={tool} className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
<span className="text-fg-muted">
·{" "}
{p.pin
? t("detail.poolPinned", { account: p.pin })
: 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) => {
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
return (
<div
key={a.name}
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
data-testid="user-pool-account"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium text-fg-primary">
{a.name}
{a.expired && (
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
)}
{!a.allowed && (
<span className="ml-1.5 text-fg-muted">
{t("detail.poolNotAllowed")}
</span>
)}
</span>
{tool !== "cursor" && (
<span
className="flex-shrink-0 tabular-nums text-fg-secondary"
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
>
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
{a.spend7d > 0 && (
<span className="text-fg-muted"> ({pctText(share)})</span>
)}
</span>
)}
</div>
{tool !== "cursor" &&
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
</div>
);
})}
</div>
);
})
)}
</Panel>
{/* Now: live panes and the workspaces they own. */}
<Panel title={t("detail.nowTitle")}>
{data.panes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
) : (
<ul className="space-y-1.5">
{data.panes.map((p) => (
<li key={p.id} className="text-xs">
<Link
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
>
<Play className="h-3 w-3 text-status-success" />
{p.laneTitle || p.laneCwd}
</Link>
<span className="text-fg-muted">
{" "}
· {toolLabel(p.tool)}
{p.account ? ` · ${p.account}` : ""}
</span>
</li>
))}
</ul>
)}
{data.workspaces.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
{data.workspaces.map((w) => (
<Link
key={w.id}
to={`/workspace?lane=${w.id}`}
title={w.cwd}
className="badge py-0.5 text-[11px] hover:text-accent"
>
{w.livePanes > 0 && (
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{w.title || w.cwd.split("/").pop()}
<span className="ml-1 text-fg-muted">{w.sessions}</span>
</Link>
))}
</div>
)}
</Panel>
</div>
{data.recentSessions.length > 0 && (
<Panel title={t("detail.recentTitle")}>
<ul className="divide-y divide-border">
{data.recentSessions.slice(0, 5).map((s) => (
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
<SessionStatusBadge
status={effectiveSessionStatus(s)}
reason={sessionAwaitingReason(s)}
/>
<Link
to={`/sessions/${s.id}`}
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
>
{s.name || s.id.slice(0, 8)}
</Link>
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
{formatDateTime(s.last_activity || s.started_at)}
</span>
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
{s.cost ? fmtCost(s.cost) : "—"}
</span>
</li>
))}
</ul>
</Panel>
)}
</div>
);
}
function Inline({ label, children }: { label: string; children: ReactNode }) {
return (
<span>
<span className="text-fg-muted">{label}: </span>
<span className="text-fg-primary">{children}</span>
</span>
);
}
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="card p-3 space-y-2.5 min-w-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
{children}
</section>
);
}
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
const share = total > 0 ? (cost / total) * 100 : 0;
return (
<li className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
<span className="flex-shrink-0 tabular-nums text-fg-primary">
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
</span>
</div>
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
<span
className="block h-full rounded-full bg-accent-solid"
style={{ width: `${Math.min(100, share)}%` }}
/>
</span>
</li>
);
}
@@ -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(
@@ -1,18 +1,20 @@
/**
* @file MemberTmuxCard.test.tsx
* @description The Users page's members' tmux card: hidden without one Linux
* user per account, lists each running member server with its version and a
* badge when it is older than the installed tmux, warns until tmux-member is
* installed, and an admin restarts one member — or every idle outdated one,
* the working member left out — only with their own password typed; an
* analyst sees no restart control.
* @file MemberTmux.test.tsx
* @description Each member's tmux server as part of the users table
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
* user per account, names the installed tmux and warns until tmux-member is
* installed; a row's cell shows the version with a badge when it is older than
* the installed tmux, and "—" for a member with no server; an admin restarts
* one member — or every idle outdated one, the working member left out — only
* with their own password typed; an analyst gets no "Restart idle".
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { MemberTmux } from "../../../lib/api";
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
const users = vi.hoisted(() => ({
tmux: vi.fn(),
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
});
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { MemberTmuxCard } from "../MemberTmuxCard";
import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
function Harness({ isAdmin }: { isAdmin: boolean }) {
const { data, reload } = useMemberTmux();
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
return (
<>
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
{data?.available &&
data.users.map((u) => (
<div key={u.id} data-testid={`row-${u.username}`}>
<span>{u.username}</span>
<TmuxCell row={u} />
{isAdmin && u.running && (
<button type="button" onClick={() => setTarget(u)}>
Restart {u.username}'s tmux
</button>
)}
</div>
))}
{target && (
<TmuxRestartDialog
target={target}
data={data}
onClose={() => setTarget(null)}
onDone={() => {
setTarget(null);
void reload();
}}
/>
)}
</>
);
}
const DATA: MemberTmux = {
available: true,
@@ -52,33 +88,33 @@ beforeEach(() => {
users.tmux.mockResolvedValue(DATA);
});
describe("MemberTmuxCard", () => {
it("renders nothing without one Linux user per account", async () => {
describe("MemberTmux", () => {
it("shows no bar without one Linux user per account", async () => {
users.tmux.mockResolvedValue({ available: false, users: [] });
const { container } = render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId("member-tmux")).toBeNull();
});
it("lists running servers and marks the ones older than the installed tmux", async () => {
render(<MemberTmuxCard isAdmin={false} />);
expect(await screen.findByText("mia")).toBeInTheDocument();
expect(screen.queryByText("bob")).toBeNull();
it("marks the servers older than the installed tmux, and a member with none", async () => {
render(<Harness isAdmin={false} />);
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
expect(screen.getAllByText("older than installed")).toHaveLength(2);
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
expect(screen.getByTestId("row-bob").textContent).toContain("—");
// An analyst reads only.
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
});
it("warns until tmux-member is installed", async () => {
users.tmux.mockResolvedValue({ ...DATA, matched: false });
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
});
it("restarts one member only with the admin's password", async () => {
users.tmuxRestart.mockResolvedValue({});
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
const go = screen.getByRole("button", { name: "Restart" });
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
it("restarts every idle outdated member at once, the working one left out", async () => {
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
render(<MemberTmuxCard isAdmin />);
render(<Harness isAdmin />);
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Your password"), "pw");
+1 -34
View File
@@ -1,37 +1,4 @@
{
"title": "Administration",
"description": "Accounts and the server AgentHub runs on.",
"tabs": {
"users": "Users",
"server": "Server"
},
"server": {
"title": "Server",
"uptime": "up {{v}}",
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cores": "{{n}} cores · load {{load}}",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"homesDisk": "Homes disk",
"homesDiskHelp": "The disk that holds the users' home folders.",
"free": "free",
"usageTitle": "Usage by user",
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"who": "User",
"processes": "Processes",
"home": "Home",
"sampling": "Sampling…",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"disks": "Disks",
"mount": "Mount",
"device": "Device",
"used": "Used",
"homesBadge": "homes",
"gpus": "GPUs"
}
"description": "Accounts and the server AgentHub runs on."
}
+95 -17
View File
@@ -30,14 +30,6 @@
"since": "waiting {{ago}}"
},
"health": {
"runtime": "Runtime",
"cores_one": "{{count}} core",
"cores_other": "{{count}} cores",
"uptime": "Uptime",
"cpu": "CPU (1/5/15m)",
"nodeRss": "Node RSS",
"hostMemory": "Host Memory",
"hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
"heap": "V8 Heap",
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
"storage": "Storage",
@@ -50,12 +42,7 @@
"events": "Events",
"score": "Health Score",
"formula": "Formula",
"formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
"scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
"cache": "Cache",
"cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
"errors": "Errors",
"errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
"cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
"compact": "Compact",
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
"saved": "Saved",
@@ -65,9 +52,10 @@
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
"noModelData": "No model data",
"concurrency": "Concurrency",
"intervals_one": "{{count}} interval",
"intervals_other": "{{count}} intervals",
"intervals_one": "{{count}} agent type",
"intervals_other": "{{count}} agent types",
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
"laneTip": "{{name}}\n{{count}} agents\nRuns {{start}}–{{end}}% of the session (avg)",
"noConcurrencyData": "No concurrency data",
"peak": "Peak",
"peakTip": "Peak: {{count}} sessions running simultaneously.",
@@ -99,11 +87,101 @@
"synchronous": "Synchronous",
"autoVacuum": "Auto-vacuum",
"foreignKeys": "Foreign keys",
"busyTimeout": "Busy timeout"
"busyTimeout": "Busy timeout",
"groupApp": "AgentHub",
"groupAppHint": "How the dashboard itself is doing: score, database, sessions at once",
"groupMachine": "Machine",
"groupMachineHint": "The computer AgentHub runs on: CPU, memory, disks, processes",
"groupActivity": "Agent activity",
"groupActivityHint": "What the agents used: tokens by model, tools, subagents",
"scoreFormula": "100 minus what each check costs:",
"scoreResult": "= {{score}} / 100",
"check": {
"loop": "AgentHub response",
"cpu": "CPU wait",
"memory": "Memory wait",
"io": "Disk wait",
"ram": "RAM in use",
"disk": "Data disk",
"heap": "V8 heap",
"hooks": "Last hook",
"agents": "Agent errors 24h"
},
"checkTip": {
"loop": "How long AgentHub's event loop was held up (p99 over the last minute). Every page, hook and terminal keystroke waits this long.",
"cpu": "CPU pressure (Linux PSI, last 10 s): share of time tasks waited for a free CPU — the machine feels slow above a few %.",
"cpuLoad": "Load per core (1 min) — this machine has no PSI, so CPU wait is estimated from load.",
"memory": "Memory pressure (Linux PSI, last 10 s): share of time tasks stalled reclaiming or swapping memory.",
"io": "Disk I/O pressure (Linux PSI, last 10 s): share of time tasks waited on the disk — a slow or busy HDD shows here.",
"ram": "Memory in use (total minus available).",
"disk": "How full the disk holding AgentHub's database is ({{mount}}).",
"heap": "AgentHub's V8 heap against its limit.",
"hooks": "Minutes since the last hook event, counted only while an agent is working: silence then means the dashboard stopped seeing it.",
"agents": "Agents that ended in the last 24 h with an error (errors / finished; needs at least 5)."
},
"checkNa": "Not measured here — costs nothing.",
"checkThresholds": "Amber from {{warn}}{{unit}}, red from {{bad}}{{unit}}",
"checkPenalty": "Costs {{n}} of up to {{max}} points",
"hooksMissing": "not installed",
"hooksIdle": "idle",
"minutesAgo": "{{n}} min ago",
"verdict": {
"ok": "Healthy",
"warn": "Degraded",
"bad": "Unhealthy"
},
"allOk": "Every check is fine",
"badCount_one": "{{count}} red",
"badCount_other": "{{count}} red",
"warnCount_one": "{{count}} amber",
"warnCount_other": "{{count}} amber",
"lagGroup": "Lag",
"systemGroup": "Capacity & signals"
},
"errorBoundary": {
"title": "Something went wrong",
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
"reload": "Reload"
},
"host": {
"uptimeDays": "{{d}} d {{h}} h",
"uptimeHours": "{{h}} h {{m}} min",
"cpu": "CPU",
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
"memory": "Memory",
"swap": "Swap",
"free": "free",
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
"hostGroup": "Host account (AgentHub and other host processes)",
"systemGroup": "System (root and other accounts)",
"homesBadge": "homes",
"historyTitle": "History",
"historyHelp": "Kept in memory, one point every {{s}} s, since the server started at {{at}}.",
"historyCpu": "CPU (% of the machine)",
"historyMemory": "Memory used (%)",
"range": "Range",
"range1h": "1 h",
"range6h": "6 h",
"range24h": "24 h",
"coreN": "CPU {{n}}",
"threads": "{{n}} threads",
"physical": "{{n}} cores",
"perSecond": "{{v}}/s",
"temperatures": "Temperatures",
"topProcesses": "Busiest processes",
"cpuModel": "CPU",
"storageNetwork": "Disks & network",
"processCount": "{{n}} processes",
"title": "Server",
"os": "System",
"uptime": "Uptime",
"load": "Load (1/5/15 min)",
"agenthubTip": "pid {{pid}} · Node {{node}}",
"sampling": "Sampling…",
"sampled": "{{s}} s sample",
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
"agenthub": "AgentHub",
"usersTitle": "Users · CPU / RAM now",
"usersCount": "{{n}} users"
}
}
+6
View File
@@ -18,6 +18,12 @@
"tableAgents": "Agents",
"tableCost": "Cost",
"tableDirectory": "Directory",
"tableUser": "User",
"tableTool": "AI Tool",
"tableActions": "Actions",
"noOwner": "No owner",
"moreActions": "More actions",
"viewDetails": "View details",
"defaultName": "Session ",
"detail": {
"waitingBanner": {
+14
View File
@@ -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"
}
}
+92 -11
View File
@@ -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",
@@ -315,7 +365,9 @@
"doneIdle": "Restarted: {{names}}",
"errors": {
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
}
},
"sessionsCount_one": "{{count}} session",
"sessionsCount_other": "{{count}} sessions"
},
"rowMenu": {
"openHome": "Open home in Files",
@@ -331,5 +383,34 @@
"digit": "A digit",
"symbol": "A symbol (e.g. ! - _ @)",
"username": "Does not contain the username"
}
},
"detail": {
"open": "Open {{name}}'s details",
"lastLogin": "Last sign-in",
"lastSession": "Last activity",
"never": "never",
"spendDaily": "Spend · last 30 days",
"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.",
"poolNotAllowed": "no longer allowed",
"poolTotal7d": "Account total 7 days: {{cost}}",
"noPanes": "Nothing running.",
"recentTitle": "Recent sessions",
"failed": "Could not load this user",
"activeLabel": "Running now",
"activeValue": "{{sessions}} sessions · {{panes}} panes",
"nowTitle": "Running now",
"poolTheirs7d": "theirs 7 d {{cost}}"
},
"activity": "Activity",
"spend": "Spend today",
"spendSub": "7 d {{week}} · 30 d {{month}}",
"sessionsN": "{{n}} sessions",
"workspacesN": "{{n}} workspaces",
"tmuxCol": "tmux"
}
+1 -34
View File
@@ -1,37 +1,4 @@
{
"title": "Quản trị",
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
"tabs": {
"users": "Người dùng",
"server": "Máy chủ"
},
"server": {
"title": "Máy chủ",
"uptime": "đã chạy {{v}}",
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cores": "{{n}} lõi · tải {{load}}",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"homesDisk": "Ổ chứa thư mục người dùng",
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
"free": "trống",
"usageTitle": "Mức sử dụng theo người dùng",
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"who": "Người dùng",
"processes": "Tiến trình",
"home": "Thư mục home",
"sampling": "Đang lấy mẫu…",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"disks": "Ổ đĩa",
"mount": "Điểm gắn",
"device": "Thiết bị",
"used": "Đã dùng",
"homesBadge": "home",
"gpus": "GPU"
}
"description": "Tài khoản và máy chủ AgentHub đang chạy."
}
+95 -17
View File
@@ -30,14 +30,6 @@
"since": "đang chờ {{ago}}"
},
"health": {
"runtime": "Môi trường chạy",
"cores_one": "{{count}} lõi",
"cores_other": "{{count}} lõi",
"uptime": "Thời gian chạy",
"cpu": "CPU (1/5/15 phút)",
"nodeRss": "Bộ nhớ Node (RSS)",
"hostMemory": "Bộ nhớ máy",
"hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
"heap": "Heap V8",
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
"storage": "Lưu trữ",
@@ -50,12 +42,7 @@
"events": "Sự kiện",
"score": "Điểm sức khỏe",
"formula": "Công thức",
"formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
"scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
"cache": "Cache",
"cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
"errors": "Lỗi",
"errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
"cacheTip": "Tỉ lệ trúng cache transcript: {{pct}}% (chỉ để xem, không tính điểm — cache trống lại mỗi lần server khởi động)",
"compact": "Nén",
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
"saved": "Tiết kiệm",
@@ -65,9 +52,10 @@
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
"noModelData": "Chưa có dữ liệu mô hình",
"concurrency": "Đồng thời",
"intervals_one": "{{count}} khoảng",
"intervals_other": "{{count}} khoảng",
"intervals_one": "{{count}} loại agent",
"intervals_other": "{{count}} loại agent",
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
"laneTip": "{{name}}\n{{count}} agent\nChạy từ {{start}}–{{end}}% thời lượng phiên (trung bình)",
"noConcurrencyData": "Chưa có dữ liệu đồng thời",
"peak": "Đỉnh",
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
@@ -99,11 +87,101 @@
"synchronous": "Đồng bộ",
"autoVacuum": "Tự dọn dẹp",
"foreignKeys": "Khóa ngoại",
"busyTimeout": "Thời gian chờ khóa"
"busyTimeout": "Thời gian chờ khóa",
"groupApp": "AgentHub",
"groupAppHint": "Tình trạng của chính dashboard: điểm, cơ sở dữ liệu, số phiên đồng thời",
"groupMachine": "Máy chủ",
"groupMachineHint": "Máy đang chạy AgentHub: CPU, bộ nhớ, ổ đĩa, tiến trình",
"groupActivity": "Hoạt động agent",
"groupActivityHint": "Agent đã dùng gì: token theo model, công cụ, subagent",
"scoreFormula": "100 trừ đi phần mỗi mục bị trừ:",
"scoreResult": "= {{score}} / 100",
"check": {
"loop": "Phản hồi AgentHub",
"cpu": "Chờ CPU",
"memory": "Chờ bộ nhớ",
"io": "Chờ ổ đĩa",
"ram": "RAM đang dùng",
"disk": "Ổ chứa dữ liệu",
"heap": "V8 heap",
"hooks": "Hook gần nhất",
"agents": "Agent lỗi 24h"
},
"checkTip": {
"loop": "Event loop của AgentHub bị nghẽn bao lâu (p99 trong phút vừa qua). Mọi trang, hook và phím gõ trong terminal đều phải chờ chừng ấy.",
"cpu": "Áp lực CPU (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ CPU rảnh — vài % trở lên là máy bắt đầu chậm.",
"cpuLoad": "Tải mỗi lõi (1 phút) — máy này không có PSI nên ước lượng chờ CPU từ load.",
"memory": "Áp lực bộ nhớ (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ bị treo để thu hồi hoặc swap bộ nhớ.",
"io": "Áp lực I/O ổ đĩa (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ ổ đĩa — HDD chậm hoặc bận sẽ hiện ở đây.",
"ram": "Bộ nhớ đang dùng (tổng trừ phần còn khả dụng).",
"disk": "Mức đầy của ổ chứa cơ sở dữ liệu AgentHub ({{mount}}).",
"heap": "V8 heap của AgentHub so với giới hạn.",
"hooks": "Số phút từ hook gần nhất, chỉ tính khi có agent đang chạy: im lặng lúc đó nghĩa là dashboard không còn thấy agent.",
"agents": "Agent kết thúc với lỗi trong 24 giờ qua (lỗi / đã kết thúc; cần ít nhất 5)."
},
"checkNa": "Không đo được trên máy này — không trừ điểm.",
"checkThresholds": "Vàng từ {{warn}}{{unit}}, đỏ từ {{bad}}{{unit}}",
"checkPenalty": "Trừ {{n}} / tối đa {{max}} điểm",
"hooksMissing": "chưa cài",
"hooksIdle": "nghỉ",
"minutesAgo": "{{n}} phút trước",
"verdict": {
"ok": "Khỏe",
"warn": "Suy giảm",
"bad": "Có vấn đề"
},
"allOk": "Mọi mục đều ổn",
"badCount_one": "{{count}} đỏ",
"badCount_other": "{{count}} đỏ",
"warnCount_one": "{{count}} vàng",
"warnCount_other": "{{count}} vàng",
"lagGroup": "Độ trễ",
"systemGroup": "Tài nguyên & tín hiệu"
},
"errorBoundary": {
"title": "Đã xảy ra lỗi",
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
"reload": "Tải lại"
},
"host": {
"uptimeDays": "{{d}} ngày {{h}} giờ",
"uptimeHours": "{{h}} giờ {{m}} phút",
"cpu": "CPU",
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
"memory": "Bộ nhớ",
"swap": "Swap",
"free": "trống",
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
"systemGroup": "Hệ thống (root và tài khoản khác)",
"homesBadge": "home",
"historyTitle": "Lịch sử",
"historyHelp": "Lưu trong bộ nhớ, mỗi {{s}} giây một điểm, từ khi máy chủ khởi động lúc {{at}}.",
"historyCpu": "CPU (% toàn máy)",
"historyMemory": "Bộ nhớ đã dùng (%)",
"range": "Khoảng thời gian",
"range1h": "1 giờ",
"range6h": "6 giờ",
"range24h": "24 giờ",
"coreN": "CPU {{n}}",
"threads": "{{n}} luồng",
"physical": "{{n}} lõi",
"perSecond": "{{v}}/giây",
"temperatures": "Nhiệt độ",
"topProcesses": "Tiến trình nặng nhất",
"cpuModel": "CPU",
"storageNetwork": "Ổ đĩa & mạng",
"processCount": "{{n}} tiến trình",
"title": "Máy chủ",
"os": "Hệ điều hành",
"uptime": "Thời gian chạy",
"load": "Tải (1/5/15 phút)",
"agenthubTip": "pid {{pid}} · Node {{node}}",
"sampling": "Đang lấy mẫu…",
"sampled": "mẫu {{s}} giây",
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
"agenthub": "AgentHub",
"usersTitle": "Người dùng · CPU / RAM hiện tại",
"usersCount": "{{n}} người dùng"
}
}
+6
View File
@@ -18,6 +18,12 @@
"tableAgents": "Agent",
"tableCost": "Chi phí",
"tableDirectory": "Thư mục",
"tableUser": "Người dùng",
"tableTool": "AI Tool",
"tableActions": "Thao tác",
"noOwner": "Không có chủ",
"moreActions": "Thao tác khác",
"viewDetails": "Xem chi tiết",
"defaultName": "Phiên ",
"detail": {
"waitingBanner": {
+14
View File
@@ -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"
}
}
+92 -11
View File
@@ -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",
@@ -315,7 +365,9 @@
"doneIdle": "Đã khởi động lại: {{names}}",
"errors": {
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
}
},
"sessionsCount_one": "{{count}} session",
"sessionsCount_other": "{{count}} session"
},
"rowMenu": {
"openHome": "Mở home trong Files",
@@ -331,5 +383,34 @@
"digit": "Có chữ số",
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
"username": "Không chứa tên đăng nhập"
}
},
"detail": {
"open": "Xem chi tiết {{name}}",
"lastLogin": "Đăng nhập gần nhất",
"lastSession": "Hoạt động gần nhất",
"never": "chưa bao giờ",
"spendDaily": "Chi phí · 30 ngày qua",
"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.",
"poolNotAllowed": "không còn được dùng",
"poolTotal7d": "Tổng tài khoản 7 ngày: {{cost}}",
"noPanes": "Không có gì đang chạy.",
"recentTitle": "Session gần đây",
"failed": "Không tải được thông tin người dùng",
"activeLabel": "Đang chạy",
"activeValue": "{{sessions}} session · {{panes}} pane",
"nowTitle": "Đang chạy",
"poolTheirs7d": "của họ 7 ngày {{cost}}"
},
"activity": "Hoạt động",
"spend": "Chi phí hôm nay",
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
"sessionsN": "{{n}} session",
"workspacesN": "{{n}} workspace",
"tmuxCol": "tmux"
}
@@ -0,0 +1,153 @@
/**
* @file healthScore.test.ts
* @description Tests for lib/healthScore.ts — the System Health score: a calm
* machine scores 100, lag (event loop, CPU / memory / I/O pressure) pulls it
* down hard, a check without data costs nothing, CPU falls back to load per
* core without PSI, the data disk is the one holding the database, and hooks
* count only while an agent works.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import type { HostStats } from "../api";
import { healthChecks, healthScore, scoreLevel, type HealthInput } from "../healthScore";
const GB = 1024 ** 3;
const NOW = Date.parse("2026-10-27T10:00:00Z");
const host = (over: Partial<HostStats> = {}): HostStats => ({
at: "2026-10-27T10:00:00Z",
hostname: "box",
os: { platform: "linux", release: "7", distro: null, arch: "x64" },
uptime: 1000,
cpu: { model: null, cores: 4, load: [0.5, 0.5, 0.5] },
memory: { total: 16 * GB, available: 12 * GB, used: 4 * GB, swapTotal: 0, swapUsed: 0 },
disks: [
{ device: "/dev/a", mount: "/", type: "ext4", kind: "SSD", total: 100, used: 10, free: 90 },
{ device: "/dev/b", mount: "/data", type: "ext4", kind: "HDD", total: 100, used: 99, free: 1 },
],
homesMount: null,
gpus: [],
usage: null,
pressure: {
cpu: { some10: 1, some60: 1, full10: 0 },
memory: { some10: 0, some60: 0, full10: 0 },
io: { some10: 0, some60: 0, full10: 0 },
},
...over,
});
const calm = (over: Partial<HealthInput> = {}): HealthInput => ({
eventLoop: { p50: 10, p99: 20, max: 40 },
heap: { heapUsed: 100, heapTotal: 200, heapLimit: 1000 },
hooksInstalled: true,
activity: {
last_event_at: "2026-10-27T09:59:00Z",
working_agents: 1,
agents_24h: { completed: 50, error: 0 },
},
dbPath: "/home/me/.claude/agenthub/dashboard.db",
host: host(),
now: NOW,
...over,
});
const byKey = (input: HealthInput) => new Map(healthChecks(input).map((c) => [c.key, c]));
describe("healthScore", () => {
it("scores a calm machine 100 with every check ok", () => {
const checks = healthChecks(calm());
expect(healthScore(checks)).toBe(100);
expect(checks.every((c) => c.level === "ok")).toBe(true);
expect(scoreLevel(100)).toBe("ok");
});
it("drops the score when the machine lags", () => {
const lagging = calm({
eventLoop: { p50: 50, p99: 1200, max: 2000 },
host: host({
pressure: {
cpu: { some10: 70, some60: 50, full10: 0 },
memory: { some10: 0, some60: 0, full10: 0 },
io: { some10: 60, some60: 40, full10: 30 },
},
}),
});
const c = byKey(lagging);
expect(c.get("loop")?.level).toBe("bad");
expect(c.get("cpu")?.level).toBe("bad");
expect(c.get("io")?.level).toBe("bad");
const score = healthScore([...c.values()]);
expect(score).toBe(100 - 25 - 20 - 20);
expect(scoreLevel(score)).toBe("bad");
});
it("charges part of the points between amber and red", () => {
const c = byKey(calm({ eventLoop: { p50: 10, p99: 550, max: 600 } })).get("loop");
expect(c?.level).toBe("warn");
expect(c?.penalty).toBe(12.5);
});
it("costs nothing for what was not measured, and estimates CPU from load without PSI", () => {
const c = byKey(
calm({
eventLoop: undefined,
host: host({ pressure: null, cpu: { model: null, cores: 2, load: [5, 1, 1] } }),
})
);
expect(c.get("loop")?.level).toBe("na");
expect(c.get("memory")?.level).toBe("na");
expect(c.get("cpu")?.detail?.source).toBe("load");
expect(c.get("cpu")?.value).toBe(250);
expect(c.get("cpu")?.level).toBe("bad");
});
it("reads the disk that holds the database", () => {
const c = byKey(calm({ dbPath: "/data/agenthub/dashboard.db" })).get("disk");
expect(c?.detail?.mount).toBe("/data");
expect(c?.level).toBe("bad");
expect(byKey(calm({ dbPath: null })).get("disk")?.detail?.mount).toBe("/");
});
it("counts silent hooks only while an agent works; missing hooks are red", () => {
const silent = calm({
activity: {
last_event_at: "2026-10-27T09:20:00Z",
working_agents: 2,
agents_24h: { completed: 0, error: 0 },
},
});
expect(byKey(silent).get("hooks")?.level).toBe("bad");
const idle = calm({
activity: {
last_event_at: "2026-10-27T05:00:00Z",
working_agents: 0,
agents_24h: { completed: 0, error: 0 },
},
});
expect(byKey(idle).get("hooks")).toMatchObject({ level: "ok", penalty: 0 });
expect(byKey(calm({ hooksInstalled: false })).get("hooks")).toMatchObject({
level: "bad",
penalty: 30,
});
});
it("rates agent errors only with enough finished agents", () => {
const few = calm({
activity: {
last_event_at: null,
working_agents: 0,
agents_24h: { completed: 1, error: 2 },
},
});
expect(byKey(few).get("agents")?.level).toBe("na");
const many = calm({
activity: {
last_event_at: null,
working_agents: 0,
agents_24h: { completed: 6, error: 4 },
},
});
expect(byKey(many).get("agents")).toMatchObject({ level: "bad", value: 40 });
});
});
+192 -8
View File
@@ -546,6 +546,85 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
/** One pool account as one user's detail sees it: the account's totals and their share. */
export type UserPoolAccount = {
name: string;
kind: string;
email: string | null;
expired: boolean;
/** Their group/pin lets them run on it (false = they ran on it before). */
allowed: boolean;
pinned: boolean;
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
quotaPercent: number | null;
full: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
userSpend5h: number;
userSpend7d: number;
userTokens5h: number;
userTokens7d: number;
userRuns7d: number;
userLivePanes: number;
userLastAt: string | null;
};
/** `GET /api/users/:id/details` — the Administration page's user detail. */
export type UserDetails = {
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
usage: {
today: { cost: number; tokens: number };
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** 30 local days, the oldest first. */
daily: { date: string; cost: number; tokens: number }[];
byTool: UsageSlice[];
byModel: UsageSlice[];
};
workspaces: {
id: number;
title: string;
cwd: string;
tool: string;
kind: string;
branch: string | null;
status: string;
createdAt: string;
sessions: number;
lastActiveAt: string | null;
livePanes: number;
}[];
panes: {
id: string;
laneId: number;
laneTitle: string;
laneCwd: string;
tool: string;
model: string | null;
account: string | null;
proxy: string | null;
sessionId: string | null;
startedAt: string;
status: string;
}[];
recentSessions: (Session & { tokens?: number })[];
pool: Partial<
Record<
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[];
}
>
>;
audit: AuditEntry[];
};
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
@@ -578,6 +657,8 @@ export interface HostUsageGroup {
kind: "user" | "host" | "system";
userId?: number;
username?: string;
/** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
@@ -587,13 +668,31 @@ export interface HostUsageGroup {
/** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[];
}
/** One `/proc/pressure/<resource>` reading, in % of the window. */
export interface HostPressure {
some10: number;
some60: number;
full10: number;
}
/** `GET /api/host` — hardware, load, disks and per-user usage. */
export interface HostStats {
at: string;
hostname: string;
os: { platform: string; release: string; distro: string | null; arch: string };
uptime: number;
cpu: { model: string | null; cores: number; load: number[] };
/** When the machine booted (ISO); absent from an older server. */
bootTime?: string;
cpu: {
model: string | null;
/** Logical CPUs (threads). */
cores: number;
load: number[];
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
physicalCores?: number | null;
/** Busy % of each logical CPU over the sample; [] off Linux. */
perCore?: number[];
};
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: {
device: string;
@@ -609,6 +708,46 @@ export interface HostStats {
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
/** Bytes/s per network interface over the sample (`lo` left out). */
network?: { name: string; rx: number; tx: number }[];
/** Bytes/s per whole disk over the sample. */
diskIo?: { name: string; read: number; write: number }[];
/** `/sys/class/hwmon` sensors; [] when none. */
temperatures?: { chip: string; label: string; celsius: number }[];
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
* each resource; null without PSI (or off Linux). */
pressure?: {
cpu: HostPressure | null;
memory: HostPressure | null;
io: HostPressure | null;
} | null;
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
/** The dashboard's own process. */
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
}
/** One point of `GET /api/host/history`. */
export interface HostHistoryPoint {
at: string;
/** Machine CPU %, null when it could not be sampled. */
cpu: number | null;
/** Memory used, % of total. */
memory: number | null;
memoryUsed: number;
swapUsed: number;
load1: number | null;
/** Per user id: CPU % and resident bytes (users with any use only). */
users: Record<string, { cpu: number; memory: number }>;
}
export type HostHistoryRange = "1h" | "6h" | "24h";
/** `GET /api/host/history` — kept in memory since the server started. */
export interface HostHistory {
range: HostHistoryRange;
intervalMs: number;
/** False when this server keeps no history (local mode, not Linux). */
running: boolean;
startedAt: string | null;
points: HostHistoryPoint[];
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
@@ -671,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 = {
@@ -689,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 = {
@@ -728,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;
@@ -1089,10 +1248,21 @@ 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 — the Server tab: hardware and each user's CPU/memory share (staff). */
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
host: {
stats: () => request<HostStats>("/host"),
history: (range: HostHistoryRange = "1h") =>
request<HostHistory>(`/host/history?range=${range}`),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
@@ -1102,6 +1272,9 @@ export const api = {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
@@ -2010,10 +2183,13 @@ export const api = {
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
method: "POST",
body: JSON.stringify(body),
}),
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
"/workspaces/gc",
{
method: "POST",
body: JSON.stringify(body),
}
),
},
// ─────────────────────────────── Workflows API ──────────────────────────────
@@ -4288,7 +4464,10 @@ export const api = {
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
request<{ lane: Lane }>(`/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
/**
* POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id.
@@ -4299,7 +4478,10 @@ export const api = {
id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) =>
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`.
@@ -4600,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;
+213
View File
@@ -0,0 +1,213 @@
/**
* @file healthScore.ts
* @description The Dashboard System Health score: a list of checks, each read
* against two thresholds (`warnAt` → amber, `badAt` → red) and costing up to
* `max` points as it climbs between them; the score is 100 minus every
* deduction (never below 0). Most of the weight is on what makes the machine
* or the dashboard feel slow — the event loop's p99 delay
* (`/api/settings/info` `server.event_loop`) and Linux pressure stall
* information for CPU, memory and disk I/O (`/api/host` `pressure`; the CPU
* falls back to load per core without PSI) — then RAM, the data disk, the V8
* heap, hooks going silent while agents work, and agent errors in the last
* 24 h. A check whose input is missing is `na` and costs nothing. Pure — the
* card in `Dashboard.tsx` only renders the result.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { HostStats } from "./api";
export type CheckLevel = "ok" | "warn" | "bad" | "na";
export type CheckKey =
"loop" | "cpu" | "memory" | "io" | "ram" | "disk" | "heap" | "hooks" | "agents";
export interface HealthCheck {
key: CheckKey;
/** "lag" checks are what a user feels as a slow machine or dashboard. */
group: "lag" | "system";
/** The measured value in `unit`; null when not measured. */
value: number | null;
unit: "ms" | "%" | "min";
level: CheckLevel;
warnAt: number;
badAt: number;
/** Points this check took off the score. */
penalty: number;
/** The most it can take. */
max: number;
/** Extra facts for the row (e.g. which mount, how many agents). */
detail?: Record<string, string | number>;
}
export interface HealthInput {
/** `server.event_loop` (ms); absent on an older server. */
eventLoop?: { p50: number; p99: number; max: number };
heap: { heapUsed: number; heapTotal: number; heapLimit?: number };
hooksInstalled: boolean;
activity?: {
last_event_at: string | null;
working_agents: number;
agents_24h: { completed: number; error: number };
};
/** The database file, to find the disk it lives on; null for a developer. */
dbPath: string | null;
host: HostStats | null;
now?: number;
}
/** Fewest finished agents before an error rate means anything. */
const MIN_AGENTS = 5;
function check(
key: CheckKey,
group: HealthCheck["group"],
value: number | null,
unit: HealthCheck["unit"],
warnAt: number,
badAt: number,
max: number,
detail?: HealthCheck["detail"]
): HealthCheck {
if (value == null || !Number.isFinite(value)) {
return { key, group, value: null, unit, level: "na", warnAt, badAt, penalty: 0, max, detail };
}
const level: CheckLevel = value >= badAt ? "bad" : value >= warnAt ? "warn" : "ok";
const t = Math.max(0, Math.min(1, (value - warnAt) / (badAt - warnAt)));
return {
key,
group,
value,
unit,
level,
warnAt,
badAt,
penalty: Math.round(max * t * 10) / 10,
max,
detail,
};
}
/** The mount holding `path`: the longest mount point that prefixes it, else `/`. */
function diskOf(host: HostStats, path: string | null) {
const disks = host.disks ?? [];
if (path) {
const hit = disks
.filter((d) => path === d.mount || path.startsWith(d.mount === "/" ? "/" : `${d.mount}/`))
.sort((a, b) => b.mount.length - a.mount.length)[0];
if (hit) return hit;
}
return disks.find((d) => d.mount === "/") ?? disks[0] ?? null;
}
export function healthChecks(input: HealthInput): HealthCheck[] {
const { host } = input;
const now = input.now ?? Date.now();
const psi = host?.pressure ?? null;
const out: HealthCheck[] = [];
// ── What lag is made of ──
out.push(check("loop", "lag", input.eventLoop?.p99 ?? null, "ms", 100, 1000, 25));
if (psi?.cpu) {
out.push(check("cpu", "lag", psi.cpu.some10, "%", 20, 60, 20, { source: "psi" }));
} else {
const load =
host && host.cpu.cores > 0 ? ((host.cpu.load[0] ?? 0) / host.cpu.cores) * 100 : null;
out.push(check("cpu", "lag", load, "%", 100, 200, 20, { source: "load" }));
}
out.push(check("memory", "lag", psi?.memory?.some10 ?? null, "%", 5, 25, 25));
out.push(check("io", "lag", psi?.io?.some10 ?? null, "%", 15, 50, 20));
// ── Capacity and the dashboard's own signals ──
const mem = host?.memory;
out.push(
check(
"ram",
"system",
mem && mem.total > 0 ? (mem.used / mem.total) * 100 : null,
"%",
90,
98,
10
)
);
const disk = host ? diskOf(host, input.dbPath) : null;
out.push(
check(
"disk",
"system",
disk && disk.total > 0 ? (disk.used / disk.total) * 100 : null,
"%",
90,
98,
15,
disk ? { mount: disk.mount } : undefined
)
);
const heapMax = input.heap.heapLimit || input.heap.heapTotal;
out.push(
check(
"heap",
"system",
heapMax > 0 ? (input.heap.heapUsed / heapMax) * 100 : null,
"%",
70,
90,
15
)
);
// Hooks: not installed is always red; installed, they matter only while an
// agent works — then minutes of silence mean the dashboard went blind.
const a = input.activity;
if (!input.hooksInstalled) {
out.push({
key: "hooks",
group: "system",
value: null,
unit: "min",
level: "bad",
warnAt: 10,
badAt: 30,
penalty: 30,
max: 30,
detail: { state: "missing" },
});
} else if (!a || a.working_agents === 0 || !a.last_event_at) {
out.push({
...check("hooks", "system", null, "min", 10, 30, 30, { state: "idle" }),
level: "ok",
});
} else {
const age = Math.max(0, (now - Date.parse(a.last_event_at)) / 60000);
out.push(
check("hooks", "system", age, "min", 10, 30, 30, {
state: "working",
working: a.working_agents,
})
);
}
const done = a ? a.agents_24h.completed + a.agents_24h.error : 0;
out.push(
check(
"agents",
"system",
a && done >= MIN_AGENTS ? (a.agents_24h.error / done) * 100 : null,
"%",
10,
30,
10,
a ? { error: a.agents_24h.error, total: done } : undefined
)
);
return out;
}
export function healthScore(checks: HealthCheck[]): number {
const lost = checks.reduce((s, c) => s + c.penalty, 0);
return Math.max(0, Math.min(100, Math.round((100 - lost) * 10) / 10));
}
/** Green from 90, amber from 70, red below. */
export function scoreLevel(score: number): Exclude<CheckLevel, "na"> {
return score >= 90 ? "ok" : score >= 70 ? "warn" : "bad";
}
+6
View File
@@ -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" },
+9
View File
@@ -746,6 +746,15 @@ export interface Session {
/** ISO time the user pinned the session to the top of its workspace's
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
pinned_at?: string | null;
/** Server mode, `GET /api/sessions` only: the account owning the session's
* workspace (null when no workspace owns it). Absent in local mode. */
owner_id?: number | null;
/** That owner's username (`users.username`). */
owner_name?: string | null;
/** That owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
/** When that owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
}
/**
+16 -37
View File
@@ -1,42 +1,32 @@
/**
* @file Administration.tsx
* @description The Administration page (`/admin`, a sidebar item; `/users`
* redirects here), server mode only and only for an admin or analyst: a tab
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
* (the machine's hardware and each user's share of it, `ServerPanel`).
* Local mode and a developer are sent home, like the Users page always did.
* redirects here), server mode only and only for an admin or analyst — ONE
* page, no tabs: ONE users table holding every per-user account fact (account,
* spend, home, tmux; each row expands in
* place into that user's detail), the account pool, then the audit log — all
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
* Dashboard's System Health tab (`HostHealth.tsx`). Local mode and a developer are sent home, like the
* Users page always did.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ComponentType } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { ShieldCheck } from "lucide-react";
import { useAuth } from "../components/AuthGate";
import { ServerPanel } from "../components/admin/ServerPanel";
import { useHostStats } from "../components/health/HostHealth";
import { Users } from "./Users";
interface AdminTab {
id: string;
label: string;
Icon: ComponentType<{ className?: string }>;
}
export function Administration() {
const { t } = useTranslation("admin");
const auth = useAuth();
const [params, setParams] = useSearchParams();
const me = auth.state.mode === "server" ? auth.state.user : null;
const staff = !!me && me.role !== "developer";
// Read once, for each home's share of the homes disk. The machine and each
// user's live CPU / RAM are on the Dashboard's System Health tab.
const host = useHostStats(staff, 0);
if (!me || me.role === "developer") return <Navigate to="/" replace />;
const tabs: AdminTab[] = [
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
{ id: "server", label: t("tabs.server"), Icon: Server },
];
const first = tabs[0]!;
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
if (!staff) return <Navigate to="/" replace />;
return (
<div className="animate-fade-in space-y-6">
@@ -49,18 +39,7 @@ export function Administration() {
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
<PageTabs
ariaLabel={t("title")}
activeId={active.id}
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
tabs={tabs}
>
{active.id === "server" ? (
<ServerPanel isAdmin={me.role === "admin"} />
) : (
<Users embedded />
)}
</PageTabs>
<Users embedded host={host} />
</div>
);
}
+2 -2
View File
@@ -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}
File diff suppressed because it is too large Load Diff
+175 -96
View File
@@ -1,6 +1,6 @@
/**
* @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; columns include the AI tool and (server-mode admins) the owning user, and the last column holds the row's actions (open in workspace, `SessionRowMenu` — also on right-click — and details). Sessions are updated in real-time based on events received from the event bus.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -66,6 +66,8 @@ import {
Server,
Download,
AlertCircle,
MoreHorizontal,
SquareArrowOutUpRight,
} from "lucide-react";
import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
@@ -80,7 +82,8 @@ import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { AiToolIcon } from "../components/AiToolIcon";
import { UserAvatar } from "../components/users/UserAvatar";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu";
@@ -91,12 +94,16 @@ import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10;
const TH =
"px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider whitespace-nowrap";
const ACTION_ICON =
"inline-flex items-center justify-center w-7 h-7 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const rowCtx = useContextMenu<Session>();
const { t } = useTranslation("sessions");
const { tools } = useAiTools();
const { tools, label: toolLabel } = useAiTools();
const { lanes } = useLanes();
// Filters, sort and page live in the URL (replace, no history spam), so a
// reload, the back button and a shared link all land on the same view.
@@ -185,7 +192,11 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const { seesAll, isAdmin, local } = usePermissions();
// Who owns each session: an admin's column only, and only where there are
// accounts (server mode; the API sends no owner in local mode).
const showUser = isAdmin && !local;
const columns = showUser ? 10 : 9;
const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources
@@ -581,31 +592,19 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
role="region"
aria-label={t("tableRegion")}
>
<table className="w-full min-w-[800px]">
<table className="w-full min-w-[960px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableSession")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableStatus")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableLastActive")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
<tr className="border-b border-border text-left bg-surface-2/40">
<th className={TH}>{t("tableSession")}</th>
{showUser && <th className={TH}>{t("tableUser")}</th>}
<th className={TH}>{t("tableTool")}</th>
<th className={TH}>{t("tableStatus")}</th>
<th className={TH}>{t("tableLastActive")}</th>
<th className={`${TH} text-right`}>{t("tableDuration")}</th>
<th className={`${TH} text-right`}>{t("tableAgents")}</th>
<th className={`${TH} text-right`}>{t("tableCost")}</th>
<th className={TH}>{t("tableDirectory")}</th>
<th className={`${TH} text-right`}>{t("tableActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -613,94 +612,174 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton
key={`sk-${i}`}
columns={8}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
columns={columns}
widths={[
"w-40",
...(showUser ? ["w-24"] : []),
"w-20",
"w-20",
"w-28",
"w-16",
"w-8",
"w-14",
"w-40",
"w-20",
]}
/>
))
: null}
{paged.map((session) => (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
{paged.map((session) => {
const toolId = session.tool || "claude";
// A live dashboard pane holds it: open that pane; else its
// workspace's replay, when a workspace owns it.
const live = runs.some((r) => r.sessionId === session.id);
const href = workspaceHref(session, runs);
const owner = session.owner_display_name || session.owner_name;
return (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-3/60 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 max-w-[22rem]">
<div className="flex items-center gap-2 min-w-0">
<Link
to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
title={session.name || undefined}
className="truncate text-sm font-medium text-fg-primary hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
>
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
className="inline-flex flex-shrink-0 items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{(() => {
// A live dashboard pane holds it: open that pane.
const run = runs.find((r) => r.sessionId === session.id);
const href = run ? workspaceHref(session, runs) : null;
return href ? (
<Link
to={href}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div>
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
</p>
</div>
</td>
<td className="px-5 py-4">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-5 py-4 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
</td>
</tr>
))}
</td>
{showUser && (
<td className="px-4 py-3">
{owner ? (
<div
className="flex items-center gap-2 min-w-0"
title={session.owner_name || undefined}
>
<UserAvatar
name={owner}
userId={session.owner_id ?? undefined}
avatarAt={session.owner_avatar_at}
className="h-6 w-6 text-[10px]"
/>
<span className="truncate text-sm text-fg-secondary max-w-[9rem]">
{owner}
</span>
</div>
) : (
<span className="text-xs text-fg-muted" title={t("noOwner")}>
-
</span>
)}
</td>
)}
<td className="px-4 py-3">
<div
className="flex items-center gap-2"
title={t("toolBadgeTitle", { tool: toolLabel(toolId) })}
>
<AiToolIcon
tool={{ id: toolId, label: toolLabel(toolId) }}
className="h-6 w-6"
/>
<span className="text-sm text-fg-secondary whitespace-nowrap">
{toolLabel(toolId)}
</span>
</div>
</td>
<td className="px-4 py-3">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-4 py-3 text-sm text-fg-secondary whitespace-nowrap">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right whitespace-nowrap">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right tabular-nums">
{session.agent_count ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-4 py-3 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{href &&
(live ? (
<Link
to={href}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-2 py-1 rounded-md transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-3 h-3" />
{t("openInWorkspace")}
</Link>
) : (
<Link
to={href}
className={ACTION_ICON}
title={t("menu.inWorkspace")}
aria-label={t("menu.inWorkspace")}
>
<SquareArrowOutUpRight className="w-4 h-4" />
</Link>
))}
<button
type="button"
onClick={(e) => rowCtx.open(e, session)}
className={ACTION_ICON}
title={t("moreActions")}
aria-label={t("moreActions")}
aria-haspopup="menu"
>
<MoreHorizontal className="w-4 h-4" />
</button>
<Link
to={`/sessions/${session.id}`}
className={ACTION_ICON}
title={t("viewDetails")}
aria-label={t("viewDetails")}
tabIndex={-1}
>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
+3
View File
@@ -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. */}
+252 -111
View File
@@ -1,38 +1,56 @@
/**
* @file Users.tsx
* @description The Users tab of the Administration page (`/admin`, which
* @description The users section of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the tokens/$ it used today (the
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
* role, state, workspaces, sessions, the $ it used today (the browser's local
* day) with 7 and 30 days under it (`GET /api/users`),
* how much disk its home folder takes and its share of the homes disk (from
* `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on
* the Dashboard's System Health tab)
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
* server says `measuring`; Refresh re-measures), and — one Linux user per
* account — its own tmux server (`MemberTmux.tsx`: version, outdated badge,
* sessions, agents; an admin's restart in the row's actions and "Restart idle"
* above the table): every per-user fact in ONE table. Also the audit log
* (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
* on (`AccountPoolCard`).
* Every change asks for the admin's OWN password again (the
* server refuses without it); an analyst only reads. A row shows the display
* name and email under the username when set, and "New password due" while
* the account still has a password an admin gave; right-clicking a row offers
* Edit, Open home (in /files), and copying the username or email.
* Edit, Open home (in /files), and copying the username or email. Clicking a
* row (or its name) expands that account in full right under it —
* `UserDetail`, one at a time, kept in `?user=<id>` so a reload or a shared
* link lands on it. `children` go between the account pool and the audit log.
* Laid out like the other list pages: icon header, a row of totals
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import {
Fragment,
useCallback,
useEffect,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
ChevronRight,
DollarSign,
Eye,
FolderOpen,
MessagesSquare,
Pencil,
RefreshCw,
RotateCcw,
ScrollText,
UserPlus,
Users as UsersIcon,
@@ -41,6 +59,7 @@ import {
api,
type AuditEntry,
type AuthUser,
type MemberTmuxRow,
type ServerUserUsage,
type UserStorage,
} from "../lib/api";
@@ -53,6 +72,15 @@ import { ConfirmModal } from "../components/ConfirmModal";
import { StatCard } from "../components/StatCard";
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
import { AccountPoolCard } from "../components/users/AccountPoolCard";
import { UserDetail } from "../components/users/UserDetail";
import { UserAvatar } from "../components/users/UserAvatar";
import {
MemberTmuxBar,
TmuxCell,
TmuxRestartDialog,
useMemberTmux,
} from "../components/users/MemberTmux";
import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth";
import { TableRowSkeleton } from "../components/Skeleton";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { copyText } from "../lib/clipboard";
@@ -60,7 +88,16 @@ import { copyText } from "../lib/clipboard";
type Role = AuthUser["role"];
const ROLES: Role[] = ["admin", "analyst", "developer"];
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
export function Users({
embedded = false,
host,
children,
}: {
embedded?: boolean;
/** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */
host?: HostState;
children?: ReactNode;
} = {}) {
const { t } = useTranslation("users");
const auth = useAuth();
const me = auth.state.mode === "server" ? auth.state.user : null;
@@ -75,6 +112,24 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
const tmux = useMemberTmux(!!me && me.role !== "developer");
// A member's tmux restart: one member, or every idle outdated one.
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
const [params, setParams] = useSearchParams();
const detailId = Number(params.get("user")) || null;
const openDetail = useCallback(
(id: number | null) =>
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (id == null) next.delete("user");
else next.set("user", String(id));
return next;
},
{ replace: true }
),
[setParams]
);
const loadStorage = useCallback(
(remeasure = false) =>
@@ -108,7 +163,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
);
// The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers.
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
const reloadHost = host?.reload;
const reloadTmux = tmux.reload;
const [spinning, refresh] = useSpin(
useCallback(
() => Promise.all([load(true), reloadTmux(), reloadHost?.()]),
[load, reloadTmux, reloadHost]
)
);
useEffect(() => {
if (me && me.role !== "developer") void load();
@@ -127,7 +189,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
const sum = (pick: (u: ServerUserUsage) => number) =>
(users ?? []).reduce((n, u) => n + pick(u), 0);
const active = (users ?? []).filter((u) => !u.disabled).length;
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const th = "px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
const stats = host?.stats ?? null;
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
const showTmux = !!tmux.data?.available;
const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r]));
const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0);
return (
<div className="animate-fade-in space-y-6">
@@ -139,10 +206,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</div>
)}
<div>
{/* Under Administration's tab bar the tab already names the page. */}
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
{t("title")}
</h1>
{/* On the Administration page this is one section of it: a section heading. */}
{embedded ? (
<h2 className="text-base font-semibold text-fg-primary">{t("title")}</h2>
) : (
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
)}
<p className="text-xs text-fg-muted">{t("description")}</p>
</div>
</div>
@@ -213,18 +282,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
</p>
)}
<MemberTmuxBar data={tmux.data} isAdmin={isAdmin} onRestart={setRestarting} />
<div className="card overflow-x-auto">
<table className="w-full min-w-[960px]" data-testid="users-table">
<table className="w-full min-w-[1040px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("today")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
<th className={th}>{t("activity")}</th>
<th className={`${th} text-right`}>{t("spend")}</th>
<th className={`${th} w-[12%]`}>{t("storage")}</th>
{showTmux && <th className={th}>{t("tmuxCol")}</th>}
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
@@ -234,87 +303,140 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 8 : 7}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
columns={columns}
widths={["w-36", "w-20", "w-24", "w-16", "w-24", "w-24", "w-16", "w-10"].slice(
0,
columns
)}
/>
))}
{users?.map((u) => {
const self = u.id === me.id;
const open = detailId === u.id;
const toggle = () => openDetail(open ? null : u.id);
const tmuxRow = tmuxById.get(u.id);
const home = storage ? (storage[u.id] ?? null) : undefined;
return (
<tr
key={u.id}
onContextMenu={(e) => rowMenu.open(e, u)}
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-5 py-3">
<div className="flex items-center gap-3 min-w-0">
<span
aria-hidden
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
>
{(u.displayName || u.username).slice(0, 2)}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-fg-primary">
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
<Fragment key={u.id}>
<tr
onContextMenu={(e) => rowMenu.open(e, u)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
toggle();
}}
className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3 min-w-0">
<ChevronRight
aria-hidden
className={`w-4 h-4 flex-shrink-0 text-fg-muted transition-transform ${open ? "rotate-90" : ""}`}
/>
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
className="h-8 w-8 text-xs"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<div className="min-w-0">
<button
type="button"
onClick={toggle}
aria-expanded={open}
title={t("detail.open", { name: u.displayName || u.username })}
className="block max-w-full truncate text-left text-sm font-medium text-fg-primary hover:text-accent"
>
{u.displayName || u.username}
{u.displayName && (
<span className="ml-1.5 font-normal text-fg-muted">
@{u.username}
</span>
)}
</button>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
)}
</div>
{u.email && (
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
{self && (
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
{t("you")}
</span>
)}
{u.disabled && (
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("disabled")}
</span>
)}
{!u.disabled && u.mustChangePassword && (
<span
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
title={t("mustChangeHint")}
data-testid="user-must-change"
>
{t("mustChange")}
</span>
)}
</div>
</td>
<td className="px-5 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.workspaces}
</td>
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
storage={storage ? (storage[u.id] ?? null) : undefined}
measuring={measuring}
/>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
<td className="px-4 py-3">
<span
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
title={t(`roleHint.${u.role}`)}
>
{t(`roles.${u.role}`)}
</span>
</td>
<td className="px-4 py-3 text-sm tabular-nums">
<div className="text-fg-primary">{t("sessionsN", { n: u.sessions })}</div>
<div className="text-[11px] text-fg-muted">
{t("workspacesN", { n: u.workspaces })}
</div>
</td>
<td
className="px-4 py-3 text-right tabular-nums"
title={t("tokens", { n: fmt((u.today ?? { tokens: 0 }).tokens) })}
>
<div className="text-sm text-fg-primary">
{fmtCost((u.today ?? { cost: 0 }).cost)}
</div>
<div className="whitespace-nowrap text-[11px] text-fg-muted">
{t("spendSub", {
week: fmtCost(u.last7d.cost),
month: fmtCost(u.last30d.cost),
})}
</div>
</td>
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
{showTmux && (
<td className="px-4 py-3">
<TmuxCell row={tmuxRow} />
</td>
)}
{isAdmin && (
<td className="px-4 py-2 text-right whitespace-nowrap">
{tmuxRow?.running && (
<RowAction
label={t("tmux.restart", { name: u.username })}
onClick={() => setRestarting(tmuxRow)}
>
<RotateCcw className="h-4 w-4" />
</RowAction>
)}
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
<Pencil className="h-4 w-4" />
</RowAction>
</td>
)}
</tr>
{open && (
<tr className="bg-surface-2/40">
<td colSpan={columns} className="px-5 py-4 cursor-default">
<UserDetail userId={u.id} />
</td>
</tr>
)}
</tr>
</Fragment>
);
})}
</tbody>
@@ -335,6 +457,9 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
onClose={rowMenu.close}
testId="users-row-menu"
>
<MenuItem onSelect={pick(() => openDetail(detailId === u.id ? null : u.id))}>
{t("detail.open", { name: u.displayName || u.username })}
</MenuItem>
{isAdmin && (
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
)}
@@ -364,6 +489,8 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
<AccountPoolCard isAdmin={isAdmin} />
{children}
<section className="card">
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
<ScrollText className="w-4 h-4 text-fg-muted" />
@@ -417,6 +544,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
}}
/>
)}
{restarting && (
<TmuxRestartDialog
target={restarting}
data={tmux.data}
onClose={() => setRestarting(null)}
onDone={(message) => {
toast.success(message);
setRestarting(null);
void tmux.reload();
}}
/>
)}
{editing && (
<UserEditDialog
user={editing}
@@ -434,47 +573,49 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
);
}
/** A period's cost, with its tokens underneath. */
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
const { t } = useTranslation("users");
return (
<td className="px-5 py-3 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
</td>
);
}
/**
* A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own).
*/
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
function StorageCell({
storage,
measuring,
disk,
}: {
storage?: UserStorage;
measuring: boolean;
/** The disk holding the homes: the home is also shown as a share of it. */
disk: { total: number } | null;
}) {
const { t } = useTranslation("users");
if (!storage) {
const pending = storage === undefined || measuring;
return (
<td
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
className="px-4 py-3 text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")}
>
{pending ? t("storageMeasuring") : "—"}
</td>
);
}
const share = disk ? pct(storage.bytes, disk.total) : null;
return (
<td
className="px-5 py-3 text-right tabular-nums"
title={storage.partial ? t("storagePartial") : undefined}
className="px-4 py-3 tabular-nums"
title={
storage.partial
? t("storagePartial")
: t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })
}
>
<div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)}
{share != null && <span className="text-[11px] text-fg-muted"> ({fmtPct(share)})</span>}
</div>
<div className="text-[11px] text-fg-muted">
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
</div>
{share != null && <Meter value={share} label={t("storage")} />}
</td>
);
}
@@ -1,14 +1,14 @@
/**
* @file Administration.test.tsx
* @description The Administration page (`/admin`): local mode and a developer
* are sent home; staff land on the Users tab; the Server tab shows the
* machine's hardware, every disk, and each user's CPU/memory/home share from
* `GET /api/host`, with a user's busiest processes on click.
* are sent home; staff see ONE page, no tabs, and no machine section (that is
* the Dashboard's System Health tab now, each user's live CPU / RAM included)
* — the users table carries each user's home share (`GET /api/host`) and tmux.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, HostStats } from "../../lib/api";
@@ -73,7 +73,7 @@ const api = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
tmux: vi.fn(async () => ({ available: false, users: [] })),
tmux: vi.fn(),
avatarUrl: () => null,
},
}));
@@ -137,6 +137,7 @@ beforeEach(() => {
],
});
api.users.audit.mockResolvedValue({ entries: [] });
api.users.tmux.mockResolvedValue({ available: false, users: [] });
api.users.storage.mockResolvedValue({
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
measuring: false,
@@ -153,27 +154,40 @@ describe("Administration", () => {
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
});
it("opens on the Users tab", async () => {
it("shows the users on one page, with no tabs and no machine section", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin");
expect(await screen.findByTestId("users-table")).toBeTruthy();
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
expect(screen.queryByTestId("server-facts")).toBeNull();
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
});
it("shows hardware and each user's share on the Server tab", async () => {
it("puts each user's home and tmux in the users table, but not their CPU", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
renderAt("/admin?tab=server");
const table = await screen.findByTestId("server-usage-table");
expect(await screen.findByText("box")).toBeTruthy();
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
const mia = await within(table).findByText("Mia");
const row = mia.closest("tr")!;
expect(row.textContent).toContain("25%");
expect(row.textContent).toContain("(13%)");
// 20 GB home on the 200 GB homes disk.
expect(row.textContent).toContain("10%");
expect(within(table).queryByText("claude")).toBeNull();
fireEvent.click(mia.closest("button")!);
expect(within(table).getByText("claude")).toBeTruthy();
api.users.tmux.mockResolvedValue({
available: true,
installed: "3.8",
matched: true,
users: [
{
id: 2,
username: "mia",
running: true,
version: "3.6",
sessions: 2,
busy: 1,
outdated: true,
},
],
});
renderAt("/admin");
const users = await screen.findByTestId("users-table");
const row = (await within(users).findByRole("button", { name: /^Mia/ })).closest("tr")!;
// 20 GB home on the 200 GB homes disk (the host read, once).
await waitFor(() => expect(row.textContent).toContain("(10%)"));
expect(row.textContent).not.toContain("25%");
expect(row.textContent).toContain("older than installed");
expect(within(row).getByRole("button", { name: "Restart mia's tmux" })).toBeTruthy();
});
});
+77 -2
View File
@@ -6,7 +6,9 @@
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
* separate role/password/workspace/disable buttons any more), and sees the
* display name and email under a username; right-clicking a row opens that
* user's home in Files (an admin) — an analyst's menu only copies.
* user's home in Files (an admin) — an analyst's menu only copies; clicking a
* user's name expands their detail under the row (`?user=`), with spend and
* pool share, and a second click folds it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -41,6 +43,8 @@ const users = vi.hoisted(() => ({
setPassword: vi.fn(),
assignWorkspace: vi.fn(),
get: vi.fn(),
details: vi.fn(),
avatarUrl: vi.fn(),
// No S3 here: the members' tmux card stays hidden.
tmux: vi.fn(async () => ({ available: false, users: [] })),
}));
@@ -96,7 +100,76 @@ beforeEach(() => {
}));
});
const details = {
user: {
id: 2,
username: "mia",
role: "developer",
disabled: false,
displayName: "Mia Tran",
logins: 1,
lastLoginAt: null,
},
sessions: { total: 3, active: 1, firstAt: null, lastAt: null },
usage: {
today: { cost: 1, tokens: 10 },
last7d: { cost: 2, tokens: 20 },
last30d: { cost: 4, tokens: 40 },
daily: [{ date: "2026-10-27", cost: 4, tokens: 40 }],
byTool: [{ key: "codex", cost: 4, tokens: 40, sessions: 1 }],
byModel: [{ key: "gpt-5.5", cost: 4, tokens: 40, sessions: 1 }],
},
workspaces: [],
panes: [],
recentSessions: [],
pool: {
claude: {
pin: "team-1",
group: null,
accounts: [
{
name: "team-1",
kind: "token",
email: null,
expired: false,
allowed: true,
pinned: true,
quota: null,
quotaPercent: null,
full: false,
livePanes: 0,
spend5h: 0,
spend7d: 10,
userSpend5h: 0,
userSpend7d: 2.5,
userTokens5h: 0,
userTokens7d: 99,
userRuns7d: 2,
userLivePanes: 0,
userLastAt: null,
},
],
},
},
audit: [],
};
describe("Users page", () => {
it("expands a user's detail under their row, and folds it again", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.details.mockResolvedValue(details);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "mia" }));
const pool = await screen.findByTestId("user-pool-account");
expect(users.details).toHaveBeenCalledWith(2);
const page = screen.getByTestId("user-detail");
expect(within(screen.getByTestId("users-table")).getByTestId("user-detail")).toBe(page);
expect(within(pool).getByText("team-1")).toBeTruthy();
expect(within(pool).getByText(/25%/)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "mia" }));
await waitFor(() => expect(screen.queryByTestId("user-detail")).toBeNull());
});
it("sends local mode and a member home", async () => {
authState = { mode: "local" };
renderPage();
@@ -155,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(
@@ -163,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.27 (rebuild client to match)
UI build v2.8.30 (rebuild client to match)
</span>
</p>
</div>
+15 -9
View File
@@ -89,13 +89,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`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` |
@@ -117,7 +119,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
`GET /api/settings/info` includes `server.version` (the running dashboard release). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
```mermaid
sequenceDiagram
@@ -1866,6 +1868,10 @@ classDiagram
+string pinned_at "null unless pinned"
+number cost
+number agent_count
+number owner_id "server mode: workspace owner; null if none"
+string owner_name "server mode: owner's username"
+string owner_display_name "server mode"
+string owner_avatar_at "server mode"
}
SessionListResponse --> Session
@@ -2578,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
@@ -2894,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.
@@ -3006,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
@@ -3034,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
@@ -3057,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.
@@ -3161,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.
+88 -13
View File
@@ -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
@@ -166,20 +170,48 @@ Workspaces made before server mode, or by the host account, have none.
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
**Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it.
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
redirects there) shows it — one page, no tabs: the users table, the account
pool, then the audit log. The machine itself is on the Dashboard's
**System Health** tab (below).
The table's spend column is today's (the browser's local day), with 7 and 30
days under it. Clicking a user expands a short detail right under their row
(`/admin?user=<id>`, `GET /api/users/:id/details`): last sign-in, last
activity and what runs now, then spend over 30 days by AI tool and top
models, the account pool per tool (pin or group, each account's quota left
beside their own 7-day share of its spend), their live panes and
workspaces, and their five newest sessions.
- `GET /api/host` (every signed-in user — `/host` is deliberately NOT
staff-only, so a developer also sees other users' usernames, CPU / RAM share
and busiest process names) is the machine on the Dashboard's **System
Health** tab — its **Machine** group, between **AgentHub** (score, storage,
concurrency) and **Agent activity** (tokens by model, tools, subagents): the machine's hostname, OS, uptime, CPU model, cores and
load, memory and swap, every real disk (HDD or SSD, how full; the one
holding the users' homes is marked), any NVIDIA GPU, and each user's live
share of it — CPU % of the whole machine (100 % = every core busy) and
holding the users' homes is marked), any NVIDIA GPU. Each user's live
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
user, their busiest processes on click; their home share stays in
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
resident memory over a 1 s `/proc` sample, a process count, the busiest
processes by their kernel name (never argv or env), and their home's size
as a share of the homes disk. A process counts for a user when it runs as
their Linux account (S3) or, for the host account, when it inherited a
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
runs in their workspaces' panes; the rest is **Host account** or
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
per-user part. The page re-reads every 5 s while shown.
**System**. The same 1 s sample also gives every CPU thread's busy %,
network download/upload per interface (no `lo`), read/write per whole
disk, and the ten busiest processes machine-wide with who they count for;
plus `/sys/class/hwmon` temperatures, physical cores, boot time and the
AgentHub process's own pid, Node version, uptime and RSS — laid out in the
tab's own card style (`client/src/components/health/HostHealth.tsx`): the
machine card (with the V8 heap from `/api/settings/info`), then CPU threads,
temperatures / GPUs, disks with their I/O and network, then history and the
busiest processes. Read-only (`server/lib/host-stats.js`); Linux only for
the per-user part. The tab re-reads it every 5 s while shown.
- `GET /api/host/history?range=1h|6h|24h` (every signed-in user) is the tab's
**History** charts: machine CPU % and memory % (plus swap, load and each
user's CPU/memory in the payload), one point every 30 s for 24 h. Every
server on Linux (local or server mode) keeps it IN MEMORY from the moment it starts
(`startHistory`, server/index.js) — a restart starts it empty, and nothing
is written to disk.
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
how much disk each user's home takes (their Linux home under S3, their
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
@@ -563,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,
@@ -581,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).
@@ -916,12 +991,12 @@ a pane really is. Each case below is refused or bounded, with a test in
an older server. Until `agenthub server install-system` has installed
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
version until their last pane closes; the next start runs `TMUX_BIN`.
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
- **Administration → users table, tmux column** (admin/analyst read, admin acts): each member's
running server with its version beside the installed one (an *older than
installed* badge), its sessions and whether an agent of theirs is working.
**Restart** ends one member's server — every pane of theirs closes, their
The row's **Restart** button ends one member's server — every pane of theirs closes, their
Claude sessions resume from the workspace — and **Restart N idle members**
does it for every outdated member with no agent working; both take the
(above the table) does it for every outdated member with no agent working; both take the
admin's password again and are audited per member. A server that will not
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
- **A member's huge or sparse files.** Transcripts and journals are read line
@@ -999,7 +1074,7 @@ agenthub server install-system
A member server that a newer tmux already reached before `tmux-member` was
installed can stay wedged: every call on that socket — `tmux-member`'s too —
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
prints `server exited unexpectedly`. If the users table's tmux column on Administration shows it with
no panes, stop that one server (its pid is the `ah-<name>` process running
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
+238 -3
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.27
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ĩ
@@ -71,7 +71,7 @@ tags:
- name: Artifacts
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
- name: Host
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.'
description: 'Dashboard → System Health: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, every signed-in user.'
- name: Locks
description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.
- name: Projects
@@ -262,6 +262,21 @@ components:
agent_count:
type: integer
nullable: true
owner_id:
type: integer
nullable: true
description: 'Server mode, list only: the account owning the session''s workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.'
owner_name:
type: string
nullable: true
description: That owner's username.
owner_display_name:
type: string
nullable: true
owner_avatar_at:
type: string
format: date-time
nullable: true
last_activity:
type: string
format: date-time
@@ -1578,6 +1593,32 @@ components:
type: string
ws_connections:
type: integer
event_loop:
type: object
description: Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.
properties:
p50:
type: number
p99:
type: number
max:
type: number
activity:
type: object
description: 'For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller''s rows).'
properties:
last_event_at:
type: string
nullable: true
working_agents:
type: integer
agents_24h:
type: object
properties:
completed:
type: integer
error:
type: integer
transcript_cache:
type: object
required:
@@ -14499,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:
@@ -15014,6 +15193,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/details:
get:
tags:
- Auth
operationId: getServerUserDetails
summary: 'One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account''s quota beside this user''s own 5 h / 7 d share. Never a token'
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: tz_offset
in: query
schema:
type: integer
description: The browser's getTimezoneOffset(), for the daily buckets
responses:
'200':
description: Account details
'403':
description: EADMIN — a developer
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}:
get:
tags:
@@ -17749,13 +17960,37 @@ paths:
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
responses:
'200':
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.'
description: '`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.'
content:
application/json:
schema:
type: object
'500':
description: EHOST
/api/host/history:
get:
tags:
- Host
summary: The machine's CPU/memory history since the server started
operationId: getHostHistory
description: 'In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.'
parameters:
- name: range
in: query
schema:
type: string
enum:
- 1h
- 6h
- 24h
default: 1h
responses:
'200':
description: '`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.'
content:
application/json:
schema:
type: object
/api/lanes:
get:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.27",
"version": "2.8.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.27",
"version": "2.8.30",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.27",
"version": "2.8.30",
"bin": {
"agenthub": "bin/agenthub.js"
},
+10
View File
@@ -2040,6 +2040,16 @@ describe("Transcript cache integration", () => {
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
});
it("reports event-loop delay and the last hook / agents for the health score", async () => {
const res = await fetch("/api/settings/info");
const loop = res.body.server.event_loop;
for (const k of ["p50", "p99", "max"]) assert.ok(Number.isFinite(loop[k]), `event_loop.${k}`);
const a = res.body.activity;
assert.ok(a.last_event_at === null || typeof a.last_event_at === "string");
assert.ok(Number.isInteger(a.working_agents));
assert.ok(Number.isInteger(a.agents_24h.completed) && Number.isInteger(a.agents_24h.error));
});
it("should evict cache entry on SessionEnd", async () => {
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
fs.writeFileSync(
+12
View File
@@ -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",
});
+18
View File
@@ -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);
});
+149 -2
View File
@@ -1,13 +1,31 @@
/**
* @file host-stats.test.js
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
* snapshot and per-user CPU/memory attribution (`GET /api/host`), the
* /proc and /sys parsers behind its per-core, network, disk I/O and
* temperature figures, the PSI pressure parser, and the in-memory history (`GET /api/host/history`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
const {
hostStats,
hostHistory,
mountOf,
parseCpuStat,
parsePressure,
parseNetDev,
parseDiskstats,
physicalCores,
temperatures,
downsample,
__setDeps,
__setHistory,
} = require("../lib/host-stats");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const linux = process.platform === "linux";
@@ -68,3 +86,132 @@ test(
}
}
);
test("parsePressure reads some/full averages and refuses a missing file", () => {
assert.deepEqual(
parsePressure(
"some avg10=12.50 avg60=3.00 avg300=1.00 total=1\nfull avg10=4.25 avg60=1.00 avg300=0.50 total=1\n"
),
{ some10: 12.5, some60: 3, full10: 4.25 }
);
// The cpu file of an older kernel has no `full` line.
assert.deepEqual(parsePressure("some avg10=1.00 avg60=2.00 avg300=0.00 total=1\n"), {
some10: 1,
some60: 2,
full10: 0,
});
assert.equal(parsePressure(null), null);
assert.equal(parsePressure("garbage"), null);
});
test("parseCpuStat reads the machine line and every core", () => {
const r = parseCpuStat(
"cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 5 0 5 35 5 0 0 0\ncpu1 5 0 5 35 5 0 0 0\nintr 1 2\n"
);
assert.deepEqual(r.all, { total: 100, idle: 80 });
assert.equal(r.cores.length, 2);
assert.deepEqual(r.cores[1], { total: 50, idle: 40 });
assert.deepEqual(parseCpuStat(""), { all: null, cores: [] });
});
test("parseNetDev skips lo and reads rx/tx bytes", () => {
const text = [
"Inter-| Receive | Transmit",
" face |bytes packets|bytes packets",
" lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0",
"enp4s0: 5000 10 0 0 0 0 0 0 2000 6 0 0 0 0 0 0",
].join("\n");
const m = parseNetDev(text);
assert.equal(m.has("lo"), false);
assert.deepEqual(m.get("enp4s0"), { rx: 5000, tx: 2000 });
});
test("parseDiskstats keeps whole disks and converts sectors to bytes", () => {
const text = [
" 7 0 loop0 14 0 34 12 0 0 0 0 0 11 12",
" 8 0 sda 100 0 2048 50 20 0 1024 9 0 30 40",
" 8 1 sda1 90 0 2000 40 20 0 1000 9 0 30 40",
].join("\n");
const m = parseDiskstats(text, (n) => n === "sda");
assert.deepEqual([...m.keys()], ["sda"]);
assert.deepEqual(m.get("sda"), { read: 2048 * 512, write: 1024 * 512 });
});
test("physicalCores counts distinct physical/core id pairs", () => {
const info = [0, 1].flatMap((core) =>
[0, 1].map((ht) => `processor\t: ${core * 2 + ht}\nphysical id\t: 0\ncore id\t\t: ${core}\n`)
);
assert.equal(physicalCores(info.join("\n")), 2);
assert.equal(physicalCores(""), null);
});
test("temperatures reads hwmon inputs with their labels", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hwmon-"));
try {
const chip = path.join(root, "hwmon0");
fs.mkdirSync(chip);
fs.writeFileSync(path.join(chip, "name"), "coretemp\n");
fs.writeFileSync(path.join(chip, "temp1_input"), "45500\n");
fs.writeFileSync(path.join(chip, "temp1_label"), "Package id 0\n");
fs.writeFileSync(path.join(chip, "temp2_input"), "38000\n");
assert.deepEqual(await temperatures(root), [
{ chip: "coretemp", label: "Package id 0", celsius: 45.5 },
{ chip: "coretemp", label: "temp2", celsius: 38 },
]);
assert.deepEqual(await temperatures(path.join(root, "missing")), []);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("hostHistory filters by range and downsample averages buckets", () => {
const now = Date.now();
const pt = (minsAgo, cpu) => ({
at: new Date(now - minsAgo * 60e3).toISOString(),
cpu,
memory: 50,
memoryUsed: 100,
swapUsed: 0,
load1: 1,
users: { 3: { cpu, memory: 10 } },
});
__setHistory([pt(300, 90), pt(30, 10), pt(1, 20)]);
const h1 = hostHistory("1h");
assert.equal(h1.range, "1h");
assert.deepEqual(
h1.points.map((p) => p.cpu),
[10, 20]
);
assert.equal(hostHistory("6h").points.length, 3);
assert.equal(hostHistory("bogus").range, "1h");
const d = downsample([pt(3, 10), pt(2, 20), pt(1, 30), pt(0, 40)], 2);
assert.deepEqual(
d.map((p) => p.cpu),
[15, 35]
);
assert.equal(d[0].users[3].cpu, 15);
__setHistory([]);
});
test(
"hostStats adds per-core, network, disk I/O, top processes and process facts",
{ skip: !linux },
async () => {
__setDeps({
users: () => [],
memberNameForUid: () => null,
laneOwner: () => null,
homesRoot: () => "/",
wait: () => new Promise((r) => setTimeout(r, 20)),
});
const r = await hostStats();
assert.equal(r.cpu.perCore.length, r.cpu.cores);
assert.ok(Array.isArray(r.network) && Array.isArray(r.diskIo));
assert.ok(r.network.every((n) => n.name !== "lo"));
assert.ok(r.topProcesses.length > 0 && r.topProcesses.length <= 10);
assert.ok(r.topProcesses.every((p) => typeof p.group === "string" && !("argv" in p)));
assert.equal(r.process.pid, process.pid);
assert.ok(r.process.rss > 0);
assert.ok(typeof r.bootTime === "string");
}
);
@@ -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);
});
});
+20 -1
View File
@@ -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" }
+1 -1
View File
@@ -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"]
+41
View File
@@ -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: {},
@@ -209,6 +209,12 @@ describe("server mode: list and aggregate routes", () => {
}
});
it("names each listed session's owner: a stamped lane, else a run's lane", async () => {
const { sessions } = await get("/api/sessions", "boss");
const owners = Object.fromEntries(sessions.map((s) => [s.id, s.owner_name]));
assert.deepEqual(owners, { sA: "alice", sA2: "alice", sB: "bob", sHost: null });
});
it("keeps a member out of another's rows through filters and POST", async () => {
// A filter naming another's session is refused, not answered empty.
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
@@ -0,0 +1,163 @@
/**
* @file server-mode-user-details.test.js
* @description `GET /api/users/:id/details` — the Administration page's user
* detail: the account, its workspaces and live panes, newest sessions, spend
* per day / AI tool / model, the audit entries naming it, and per pool tool
* the accounts it may run on with its own share of each. Staff only, 404 for
* an unknown user, never a token or a password hash.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = 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(), "server-mode-user-details-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.CODEX_HOME = path.join(TMP, ".codex");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
// The quota read would go to the network.
require("../lib/cc-usage").fetchUsage = async () => ({});
let server;
let BASE;
const BOSS_PASS = "admin-pass-1";
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
const ids = {};
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", ...(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}`;
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
await auth.createUser("ana", "analyst-pass-1", "analyst");
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
const lane = db
.prepare("INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)")
.run(ids.mia).lastInsertRowid;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
).run(lane);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sc', 'mia codex', 'completed', '/w/mia', ?, 'codex')"
).run(lane);
const hour = new Date().toISOString().slice(0, 13);
for (const [sid, tokens] of [
["sm", 1000000],
["sc", 500000],
]) {
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES (?, 'claude-sonnet-4-5-20250929', ?)`
).run(sid, tokens);
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(sid, hour, tokens);
}
const boss = await login("boss", BOSS_PASS);
const add = await call("POST", "/api/accounts", {
cookie: boss,
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
});
assert.equal(add.status, 201);
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, account)
VALUES ('agenthub-lane-1', 'sm', 'interactive', '/w/mia', 'running', ?, 'claude', 'team-1')`
).run(lane);
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("GET /api/users/:id/details", () => {
it("shows staff one user's workspaces, panes, sessions, spend and pool share", async () => {
const boss = await login("boss", BOSS_PASS);
const r = await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss });
assert.equal(r.status, 200, JSON.stringify(r.body));
const d = r.body;
assert.equal(d.user.username, "mia");
assert.equal(d.sessions.total, 2);
assert.equal(d.sessions.active, 1);
assert.deepEqual(
d.workspaces.map((w) => [w.title, w.sessions, w.livePanes]),
[["Mia", 2, 1]]
);
assert.deepEqual(
d.panes.map((p) => [p.id, p.account]),
[["agenthub-lane-1", "team-1"]]
);
assert.deepEqual(d.recentSessions.map((s) => s.id).sort(), ["sc", "sm"]);
assert.ok(d.recentSessions.every((s) => s.tokens > 0 && s.cost > 0));
assert.equal(d.usage.last30d.tokens, 1500000);
assert.equal(d.usage.today.tokens, 1500000);
assert.equal(d.usage.daily.length, 30);
assert.equal(d.usage.daily.at(-1).tokens, 1500000, "today is the last bar");
assert.deepEqual(
d.usage.byTool.map((b) => [b.key, b.tokens, b.sessions]),
[
["claude", 1000000, 1],
["codex", 500000, 1],
]
);
assert.deepEqual(
d.usage.byModel.map((b) => b.key),
["claude-sonnet-4-5-20250929"]
);
const team = d.pool.claude.accounts.find((a) => a.name === "team-1");
assert.equal(team.allowed, true);
assert.equal(team.userRuns7d, 1);
assert.equal(team.userLivePanes, 1);
assert.equal(team.userTokens7d, 1000000, "only the hours its run held the session");
assert.equal(d.pool.claude.pin, null);
assert.ok(!JSON.stringify(d).includes(TOKEN), "never a token");
assert.ok(!JSON.stringify(d).includes("scrypt"), "never a password hash");
});
it("lists the audit entries naming the user", async () => {
const boss = await login("boss", BOSS_PASS);
await call("GET", "/api/sessions/sm", { cookie: boss });
const d = (await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss })).body;
assert.ok(d.audit.some((a) => a.reader === "boss" && a.owner === "mia"));
});
it("is staff only and 404s an unknown user", async () => {
const ana = await login("ana", "analyst-pass-1");
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: ana })).status, 200);
const mia = await login("mia", "member-pass-1");
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: mia })).status, 403);
const boss = await login("boss", BOSS_PASS);
assert.equal((await call("GET", "/api/users/9999/details", { cookie: boss })).status, 404);
});
});
+14 -1
View File
@@ -4,7 +4,8 @@
* workspaces, sessions and recent spend to an admin/analyst and refuses a
* member; an admin/analyst reading a member's session is written to the audit
* log (`GET /api/users/audit`), while a member reading their own, or a read of
* an ownerless session, is not.
* an ownerless session, is not; and `GET /api/host` (System Health's machine)
* answers every signed-in user, a developer included.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -101,6 +102,18 @@ describe("staff views", () => {
assert.equal(member.status, 403);
});
it("shows every signed-in user the machine (System Health), names included", async () => {
const mia = await login("mia", "member-pass-1");
const r = await call("/api/host", mia);
assert.equal(r.status, 200);
assert.ok(r.body.hostname);
if (r.body.usage) {
const g = r.body.usage.groups.find((x) => x.kind === "user" && x.username === "mia");
assert.ok(g && "displayName" in g, "a user's group carries their display name");
}
assert.equal((await call("/api/host/history?range=1h", mia)).status, 200);
});
it("audits a staff read of a member's session, and nothing else", async () => {
const admin = await login("boss", "admin-pass-1");
const mia = await login("mia", "member-pass-1");
+1 -1
View File
@@ -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)
+3
View File
@@ -15,6 +15,9 @@
"resume": [
"--continue"
],
"resumeById": [
"--conversation"
],
"bypass": [
"--dangerously-skip-permissions"
],
+12
View File
@@ -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.
+2
View File
@@ -1332,6 +1332,8 @@ if (require.main === module) {
require("./lib/omp-share")
.start()
.catch((err) => console.warn("omp share failed to start:", err.message));
// System Health's CPU/memory history (in memory; Linux only, inside).
require("./lib/host-stats").startHistory();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
+105 -3
View File
@@ -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
View File
@@ -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");
+27 -5
View File
@@ -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,
@@ -104,8 +121,8 @@ const STAFF_ONLY_PREFIXES = [
"/alerts/rules",
"/users",
"/accounts",
// The machine's hardware and every user's CPU/memory share.
"/host",
// `/host` (the machine and each user's CPU/memory share) is NOT here: the
// Dashboard's System Health tab shows it to every signed-in user.
];
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
@@ -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,
+44
View File
@@ -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,
+5 -3
View File
@@ -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;
+17 -3
View File
@@ -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;
+11 -1
View File
@@ -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));
+1 -1
View File
@@ -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
+383 -34
View File
@@ -1,7 +1,7 @@
/**
* @file host-stats.js
* @description The Administration page's Server tab (`GET /api/host`, staff
* only): the machine's hardware and load, and how much of its CPU and memory
* @description The Dashboard System Health tab's machine cards (`GET /api/host`,
* every signed-in user): the machine's hardware and load, and how much of its CPU and memory
* each AgentHub user is using right now. Read-only — nothing here signals,
* renices or kills a process.
*
@@ -17,9 +17,22 @@
* children carry it too); else `host` (the dashboard and everything else the
* host account runs) or `system` (root and every other account). Only the
* kernel's `comm` name is reported for a process — never its argv or env,
* which can hold a prompt or a key. Every read is async, and one sample is
* which can hold a prompt or a key. The same sample window also gives per-core
* CPU % (`/proc/stat` cpuN), network rx/tx per interface (`/proc/net/dev`, no
* `lo`), disk read/write per whole disk (`/proc/diskstats`), and the ten
* busiest processes machine-wide with their group; temperatures come from
* `/sys/class/hwmon`, physical cores from `/proc/cpuinfo`, and the dashboard's
* own pid/Node/uptime/RSS from `process`, and pressure stall information
* (`/proc/pressure/{cpu,memory,io}`, PSI — the share of time tasks waited on
* that resource, i.e. how laggy the machine feels; `null` on a kernel without
* it). Every read is async, and one sample is
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
* platforms get the `os` facts with `usage: null`.
* platforms get the `os` facts with `usage: null` and empty lists.
*
* History (`GET /api/host/history`): `startHistory` (every server on Linux,
* server/index.js) keeps an in-memory ring of machine CPU/memory/swap/load1
* and per-user CPU/memory, one point every 30 s for 24 h, from the cached
* snapshot; it starts empty at every server start and is never written to disk.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,6 +45,10 @@ const { execFile } = require("node:child_process");
const SAMPLE_MS = 1000;
const FRESH_MS = 3000;
const TOP_PER_GROUP = 5;
const TOP_PROCESSES = 10;
const HISTORY_EVERY_MS = 30_000;
const HISTORY_KEEP_MS = 24 * 3600_000;
const HISTORY_MAX_POINTS = 360;
// File systems that hold data; pseudo and loop mounts are left out.
const REAL_FS = new Set([
"ext2",
@@ -71,13 +88,154 @@ async function memory() {
};
}
/** The machine's jiffies from `/proc/stat`'s `cpu` line: `{ total, idle }`. */
async function cpuTimes() {
const text = await readText("/proc/stat");
const line = text && text.split("\n").find((l) => l.startsWith("cpu "));
if (!line) return null;
const n = line.trim().split(/\s+/).slice(1).map(Number);
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
/**
* `/proc/stat`'s jiffies: the machine's `cpu` line and each `cpuN` line, each
* `{ total, idle }` (idle = idle + iowait). `{ all: null, cores: [] }` when
* there is no `cpu` line.
*/
function parseCpuStat(text) {
const times = (line) => {
const n = line.trim().split(/\s+/).slice(1).map(Number);
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
};
const lines = (text || "").split("\n");
const all = lines.find((l) => l.startsWith("cpu "));
return {
all: all ? times(all) : null,
cores: lines.filter((l) => /^cpu\d+ /.test(l)).map(times),
};
}
/**
* One `/proc/pressure/<resource>` file: `{some10, some60, full10}` — the % of
* the last 10 s / 60 s that at least one task (`some`) or every task (`full`)
* was stalled on it. `null` when the file is missing or unreadable.
*/
function parsePressure(text) {
if (!text) return null;
const line = (kind) => {
const m = new RegExp(`^${kind} avg10=([\\d.]+) avg60=([\\d.]+)`, "m").exec(text);
return m ? { avg10: Number(m[1]), avg60: Number(m[2]) } : null;
};
const some = line("some");
if (!some) return null;
return { some10: some.avg10, some60: some.avg60, full10: line("full")?.avg10 ?? 0 };
}
/** PSI for cpu, memory and io; `null` when the kernel has none of them. */
async function pressure() {
const [cpu, memory, io] = await Promise.all(
["cpu", "memory", "io"].map((r) => readText(`/proc/pressure/${r}`).then(parsePressure))
);
return cpu || memory || io ? { cpu, memory, io } : null;
}
/** Busy % between two `{ total, idle }` readings. */
const busyPct = (a, b) => {
const total = b.total - a.total;
return total > 0 ? round(((total - (b.idle - a.idle)) / total) * 100) : 0;
};
/** `/proc/net/dev`: `Map<iface, { rx, tx }>` in bytes, `lo` left out. */
function parseNetDev(text) {
const out = new Map();
for (const line of (text || "").split("\n").slice(2)) {
const i = line.indexOf(":");
if (i < 0) continue;
const name = line.slice(0, i).trim();
if (!name || name === "lo") continue;
const f = line
.slice(i + 1)
.trim()
.split(/\s+/)
.map(Number);
out.set(name, { rx: f[0] || 0, tx: f[8] || 0 });
}
return out;
}
/**
* `/proc/diskstats`: `Map<device, { read, write }>` in bytes (sectors × 512)
* for whole disks only (`isDisk(name)`), never loop/ram/zram/optical.
*/
function parseDiskstats(text, isDisk = () => true) {
const out = new Map();
for (const line of (text || "").split("\n")) {
const f = line.trim().split(/\s+/);
if (f.length < 10) continue;
const name = f[2];
if (/^(loop|ram|zram|sr|fd)\d*/.test(name) || !isDisk(name)) continue;
out.set(name, { read: Number(f[5]) * 512, write: Number(f[9]) * 512 });
}
return out;
}
/** Whole disks: the names under `/sys/block` (partitions are not there). */
function wholeDisks() {
try {
return new Set(fs.readdirSync("/sys/block"));
} catch {
return null;
}
}
/** Per-key `{a, b}` byte counters → per-second rates over `ms`. */
function rates(m0, m1, ms, keys) {
const sec = Math.max(0.001, ms / 1000);
const out = [];
for (const [name, v1] of m1) {
const v0 = m0.get(name);
if (!v0) continue;
const row = { name };
for (const k of keys) row[k] = Math.max(0, Math.round((v1[k] - v0[k]) / sec));
out.push(row);
}
return out;
}
/**
* Temperatures from `/sys/class/hwmon/*`: `[{ chip, label, celsius }]`, one
* per `tempN_input` (label from `tempN_label`, else `tempN`). [] when none.
*/
async function temperatures(root = "/sys/class/hwmon") {
let chips;
try {
chips = await fsp.readdir(root);
} catch {
return [];
}
const out = [];
for (const c of chips.sort()) {
const dir = path.join(root, c);
const chip = ((await readText(path.join(dir, "name"))) || c).trim();
let files;
try {
files = await fsp.readdir(dir);
} catch {
continue;
}
for (const f of files.filter((x) => /^temp\d+_input$/.test(x)).sort()) {
const raw = Number(((await readText(path.join(dir, f))) || "").trim());
if (!Number.isFinite(raw) || raw === 0) continue;
const id = f.replace("_input", "");
const label = ((await readText(path.join(dir, `${id}_label`))) || id).trim();
out.push({ chip, label, celsius: round(raw / 1000) });
}
}
return out;
}
/** Physical cores from `/proc/cpuinfo` (distinct physical id + core id), or null. */
function physicalCores(text) {
const seen = new Set();
let phys = "0";
for (const line of (text || "").split("\n")) {
const m = /^(physical id|core id)\s*:\s*(\d+)/.exec(line);
if (!m) continue;
if (m[1] === "physical id") phys = m[2];
else seen.add(`${phys}:${m[2]}`);
}
return seen.size || null;
}
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
@@ -269,14 +427,47 @@ let deps = {
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
};
/** Per-user CPU and memory from two samples. */
async function usage() {
const t0 = await cpuTimes();
const p0 = await processes();
/** The counters one sample reads: CPU jiffies, network, disk I/O, processes. */
async function counters(isDisk) {
const [stat, net, io] = await Promise.all([
readText("/proc/stat"),
readText("/proc/net/dev"),
readText("/proc/diskstats"),
]);
return {
at: Date.now(),
cpu: parseCpuStat(stat),
net: parseNetDev(net),
io: parseDiskstats(io, isDisk),
procs: await processes(),
};
}
/**
* Two samples `SAMPLE_MS` apart: per-user CPU and memory (`usage`), and from
* the same window per-core CPU, network and disk I/O rates and the busiest
* processes machine-wide.
*/
async function sampleWindow(mem) {
const disksSet = wholeDisks();
const isDisk = (n) => !disksSet || disksSet.has(n);
const s0 = await counters(isDisk);
await deps.wait(SAMPLE_MS);
const t1 = await cpuTimes();
const p1 = await processes();
const mem = await memory();
const s1 = await counters(isDisk);
const ms = s1.at - s0.at;
const cores = s1.cpu.cores.map((c, i) => (s0.cpu.cores[i] ? busyPct(s0.cpu.cores[i], c) : 0));
const use = await usage(s0.cpu.all, s1.cpu.all, s0.procs, s1.procs, mem);
return {
usage: use ? use.usage : null,
topProcesses: use ? use.top : [],
cores,
network: rates(s0.net, s1.net, ms, ["rx", "tx"]),
diskIo: rates(s0.io, s1.io, ms, ["read", "write"]),
};
}
/** Per-user CPU and memory from two samples (`usage`) and the busiest processes (`top`). */
async function usage(t0, t1, p0, p1, mem) {
if (!t0 || !t1) return null;
const totalDelta = Math.max(1, t1.total - t0.total);
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
@@ -286,6 +477,7 @@ async function usage() {
const byId = new Map(users.map((u) => [u.id, u]));
const ownerCache = new Map();
const groups = new Map();
const all = [];
const group = (key, extra) => {
if (!groups.has(key))
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
@@ -301,7 +493,7 @@ async function usage() {
const member = deps.memberNameForUid(p.uid);
if (member && byUsername.has(member)) {
const u = byUsername.get(member);
g = group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username });
g = group(`user:${u.id}`, userExtra(u));
} else if (p.uid === hostUid) {
const laneId = await laneIdOf(pid);
let ownerId = null;
@@ -318,9 +510,7 @@ async function usage() {
ownerId = ownerCache.get(laneId);
}
const u = ownerId != null ? byId.get(ownerId) : null;
g = u
? group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username })
: group("host", { kind: "host" });
g = u ? group(`user:${u.id}`, userExtra(u)) : group("host", { kind: "host" });
} else {
g = group("system", { kind: "system" });
}
@@ -329,6 +519,7 @@ async function usage() {
g.memory += p.rss;
g.processes += 1;
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
all.push({ pid, name: p.comm, cpu, memory: p.rss, group: g.key });
})
);
@@ -342,9 +533,7 @@ async function usage() {
if (!groups.has(`user:${u.id}`))
list.push({
key: `user:${u.id}`,
kind: "user",
userId: u.id,
username: u.username,
...userExtra(u),
cpu: 0,
memory: 0,
memoryPercent: 0,
@@ -353,24 +542,40 @@ async function usage() {
});
}
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
all.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
return {
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
sampleMs: SAMPLE_MS,
groups: list,
usage: {
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
sampleMs: SAMPLE_MS,
groups: list,
},
top: all.slice(0, TOP_PROCESSES).map((t) => ({ ...t, cpu: round(t.cpu) })),
};
}
const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */
const userExtra = (u) => ({
kind: "user",
userId: u.id,
username: u.username,
displayName: u.displayName ?? null,
});
async function collect() {
const linux = process.platform === "linux";
const cpus = os.cpus();
const [mem, diskList, gpuList, use] = await Promise.all([
memory(),
const mem = await memory();
const [diskList, gpuList, win, temps, cpuinfo, psi] = await Promise.all([
linux ? disks() : [],
linux ? gpus() : [],
linux ? usage() : null,
linux ? sampleWindow(mem) : null,
linux ? temperatures() : [],
linux ? readText("/proc/cpuinfo") : null,
linux ? pressure() : null,
]);
const pmem = process.memoryUsage();
return {
at: new Date().toISOString(),
hostname: os.hostname(),
@@ -381,12 +586,31 @@ async function collect() {
arch: os.arch(),
},
uptime: os.uptime(),
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
bootTime: new Date(Date.now() - os.uptime() * 1000).toISOString(),
cpu: {
model: cpus[0]?.model?.trim() || null,
cores: cpus.length,
load: os.loadavg(),
physicalCores: physicalCores(cpuinfo),
perCore: win ? win.cores : [],
},
memory: mem,
disks: diskList,
homesMount: mountOf(diskList, deps.homesRoot()),
gpus: gpuList,
usage: use,
usage: win ? win.usage : null,
network: win ? win.network : [],
diskIo: win ? win.diskIo : [],
temperatures: temps,
pressure: psi,
topProcesses: win ? win.topProcesses : [],
process: {
pid: process.pid,
node: process.version,
uptime: Math.round(process.uptime()),
rss: pmem.rss,
heapUsed: pmem.heapUsed,
},
};
}
@@ -409,9 +633,134 @@ async function hostStats() {
return inflight;
}
// ── History ────────────────────────────────────────────────────────────────
// An in-memory ring of machine-level samples, one every HISTORY_EVERY_MS for
// HISTORY_KEEP_MS, started by every server on Linux (server/index.js). It
// starts empty at every server start: nothing is written to disk.
let history = [];
let historyTimer = null;
/** One history point from a snapshot. */
function historyPoint(s) {
const users = {};
for (const g of s.usage?.groups || []) {
if (g.kind === "user" && (g.cpu || g.memory))
users[g.userId] = { cpu: g.cpu, memory: g.memory };
}
return {
at: s.at,
cpu: s.usage ? s.usage.cpu : null,
memory: round((s.memory.used / Math.max(1, s.memory.total)) * 100),
memoryUsed: s.memory.used,
swapUsed: s.memory.swapUsed,
load1: round(s.cpu.load[0] || 0),
users,
};
}
/** Take one sample now (reusing a fresh snapshot) and drop what is too old. */
async function sampleHistory() {
try {
const point = historyPoint(await hostStats());
const cutoff = Date.now() - HISTORY_KEEP_MS;
history = [...history.filter((p) => Date.parse(p.at) >= cutoff), point];
} catch {
/* a failed sample is a gap, never a crash */
}
}
/** Start the sampler (Linux only; idempotent). The timer never holds the process open. */
function startHistory() {
if (historyTimer || process.platform !== "linux") return;
void sampleHistory();
historyTimer = setInterval(() => void sampleHistory(), HISTORY_EVERY_MS);
historyTimer.unref?.();
}
function stopHistory() {
if (historyTimer) clearInterval(historyTimer);
historyTimer = null;
}
const RANGES = { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 24 * 3600_000 };
/**
* The points of the last `range` (`1h`, `6h`, `24h`; else 1h), averaged down
* to at most HISTORY_MAX_POINTS. `startedAt` = the oldest point kept, so the
* UI can say the history begins there.
*/
function hostHistory(range) {
const span = RANGES[range] || RANGES["1h"];
const since = Date.now() - span;
const pts = history.filter((p) => Date.parse(p.at) >= since);
return {
range: RANGES[range] ? range : "1h",
intervalMs: HISTORY_EVERY_MS,
running: historyTimer != null,
startedAt: history[0]?.at ?? null,
points: downsample(pts, HISTORY_MAX_POINTS),
};
}
/** Average consecutive points into at most `max` buckets (numbers only; `at` = the bucket's last). */
function downsample(pts, max) {
if (pts.length <= max) return pts;
const per = Math.ceil(pts.length / max);
const out = [];
for (let i = 0; i < pts.length; i += per) {
const b = pts.slice(i, i + per);
const avg = (k) => {
const v = b.map((p) => p[k]).filter((x) => x != null);
return v.length ? round(v.reduce((a, x) => a + x, 0) / v.length) : null;
};
const users = {};
for (const p of b)
for (const [id, u] of Object.entries(p.users)) {
const t = (users[id] ||= { cpu: 0, memory: 0 });
t.cpu += u.cpu / b.length;
t.memory += u.memory / b.length;
}
for (const u of Object.values(users)) {
u.cpu = round(u.cpu);
u.memory = Math.round(u.memory);
}
out.push({
at: b[b.length - 1].at,
cpu: avg("cpu"),
memory: avg("memory"),
memoryUsed: Math.round(avg("memoryUsed") ?? 0),
swapUsed: Math.round(avg("swapUsed") ?? 0),
load1: avg("load1"),
users,
});
}
return out;
}
function __setDeps(over) {
deps = { ...deps, ...over };
cache = null;
}
module.exports = { hostStats, mountOf, __setDeps };
/** Tests: replace the history ring. */
function __setHistory(points) {
history = points;
}
module.exports = {
hostStats,
hostHistory,
startHistory,
stopHistory,
mountOf,
parseCpuStat,
parsePressure,
parseNetDev,
parseDiskstats,
physicalCores,
temperatures,
downsample,
__setDeps,
__setHistory,
};
+36
View File
@@ -0,0 +1,36 @@
/**
* @file loop-delay.js
* @description How long this server's event loop was held up — what an
* operator sees as a laggy dashboard or terminal: every request, hook and
* keystroke echo waits for it. One `monitorEventLoopDelay` histogram for the
* whole process, sampled every 20 ms and reset each minute, so `loopDelay()`
* describes the last full minute (the running one until the first minute
* ends). Read by `GET /api/metrics` (Prometheus gauges) and
* `GET /api/settings/info` (the System Health score).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { monitorEventLoopDelay } = require("perf_hooks");
const WINDOW_MS = 60_000;
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
const read = () => ({
p50: histogram.percentile(50),
p99: histogram.percentile(99),
max: histogram.max,
});
let lastWindow = null; // the last full minute, reported until the next one ends
setInterval(() => {
lastWindow = read();
histogram.reset();
}, WINDOW_MS).unref();
/** `{p50, p99, max}` in nanoseconds over the last minute. */
function loopDelay() {
return lastWindow || read();
}
module.exports = { loopDelay, WINDOW_MS };
+16 -7
View File
@@ -8,7 +8,9 @@
* A Claude session counts only for the hours one of THIS account's runs held
* it (start hour to end hour), so a session resumed on another account is
* not booked twice; Codex has no hourly rows, so its session counts whole
* once a run of the account started in the window, as before.
* once a run of the account started in the window, as before. With `ownerId`
* only the runs of that user's workspaces count — one user's share of the
* account, for the Administration page's user detail.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,21 +21,28 @@ const tokensOf = (r) =>
/**
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
* `wholeSession`, a session's every row counts in a window its run started in.
* `wholeSession`, a session's every row counts in a window its run started in;
* with `ownerId`, only runs on that user's workspaces.
*/
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
function accountUsage(
tool,
account,
{ wholeSession = false, now = Date.now(), ownerId = null } = {}
) {
const { db, stmts } = require("../db");
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
const owned = ownerId == null ? "" : "AND lane_id IN (SELECT id FROM lanes WHERE owner_id = ?)";
const ownedArgs = ownerId == null ? [] : [ownerId];
const runs = db
.prepare(
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?`
AND COALESCE(ended_at, '9999') >= ? ${owned}`
)
.all(tool, account, since7d);
.all(tool, account, since7d, ...ownedArgs);
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
if (runs.length === 0) return out;
const runsOf = new Map();
@@ -41,8 +50,8 @@ function accountUsage(tool, account, { wholeSession = false, now = Date.now() }
const rows = hourlyTokenRows(
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?)`,
[tool, account, since7d]
AND COALESCE(ended_at, '9999') >= ? ${owned})`,
[tool, account, since7d, ...ownedArgs]
);
const rules = stmts.listPricing.all();
for (const [label, hours] of Object.entries(WINDOWS)) {
+4 -4
View File
@@ -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;
+26 -3
View File
@@ -1,6 +1,6 @@
/**
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
* and the Dashboard System Health tab's machine cards (`GET /api/host` and `/api/host/history`, server/routes/host.js).
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,7 +14,7 @@ const tags = [
{
name: "Host",
description:
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
"Dashboard → System Health: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, every signed-in user.",
},
];
@@ -385,13 +385,36 @@ const paths = {
responses: {
200: {
description:
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
"`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.",
content: { "application/json": { schema: { type: "object" } } },
},
500: { description: "EHOST" },
},
},
},
"/api/host/history": {
get: {
tags: ["Host"],
summary: "The machine's CPU/memory history since the server started",
operationId: "getHostHistory",
description:
"In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.",
parameters: [
{
name: "range",
in: "query",
schema: { type: "string", enum: ["1h", "6h", "24h"], default: "1h" },
},
],
responses: {
200: {
description:
"`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.",
content: { "application/json": { schema: { type: "object" } } },
},
},
},
},
};
module.exports = { tags, schemas, paths };
+120
View File
@@ -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"],
@@ -565,6 +663,28 @@ const paths = {
},
},
},
"/api/users/{id}/details": {
get: {
tags: ["Auth"],
operationId: "getServerUserDetails",
summary:
"One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account's quota beside this user's own 5 h / 7 d share. Never a token",
parameters: [
idParam,
{
name: "tz_offset",
in: "query",
schema: { type: "integer" },
description: "The browser's getTimezoneOffset(), for the daily buckets",
},
],
responses: {
200: { description: "Account details" },
403: err("EADMIN — a developer"),
404: err("ENOUSER"),
},
},
},
"/api/users/{id}": {
get: {
tags: ["Auth"],
+35
View File
@@ -247,6 +247,15 @@ function createOpenApiSpec() {
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) `antigravity` (agy's conversation files and its opt-in hooks) or `cursor` (Cursor's agent transcripts — sessions only, no tokens).",
},
agent_count: { type: "integer", nullable: true },
owner_id: {
type: "integer",
nullable: true,
description:
"Server mode, list only: the account owning the session's workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.",
},
owner_name: { type: "string", nullable: true, description: "That owner's username." },
owner_display_name: { type: "string", nullable: true },
owner_avatar_at: { type: "string", format: "date-time", nullable: true },
last_activity: { type: "string", format: "date-time", nullable: true },
cost: { type: "number", nullable: true },
awaiting_input_since: {
@@ -1234,6 +1243,32 @@ function createOpenApiSpec() {
node_version: { type: "string" },
platform: { type: "string" },
ws_connections: { type: "integer" },
event_loop: {
type: "object",
description:
"Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.",
properties: {
p50: { type: "number" },
p99: { type: "number" },
max: { type: "number" },
},
},
},
},
activity: {
type: "object",
description:
"For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller's rows).",
properties: {
last_event_at: { type: "string", nullable: true },
working_agents: { type: "integer" },
agents_24h: {
type: "object",
properties: {
completed: { type: "integer" },
error: { type: "integer" },
},
},
},
},
transcript_cache: {
+1 -1
View File
@@ -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,
})),
});
+3 -4
View File
@@ -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);
+12 -5
View File
@@ -1,14 +1,17 @@
/**
* @file host.js
* @description `GET /api/host` — the Administration page's Server tab: the
* machine's hardware, load and disks, and each AgentHub user's share of its
* CPU and memory right now (server/lib/host-stats.js). Read-only; staff only
* in server mode (`/host` is in STAFF_ONLY_PREFIXES, server/lib/auth.js).
* @description `GET /api/host` — the Dashboard System Health tab's machine
* cards, its per-user CPU/RAM card included: the machine's hardware, load and
* disks, and each AgentHub user's share of its CPU and memory right now
* (server/lib/host-stats.js); `GET /api/host/history` (`?range=1h|6h|24h`) the
* in-memory CPU/memory history the server keeps since it started. Read-only;
* every signed-in user (`/host` is NOT in STAFF_ONLY_PREFIXES — System Health
* is everyone's).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { hostStats } = require("../lib/host-stats");
const { hostStats, hostHistory } = require("../lib/host-stats");
const router = Router();
@@ -20,4 +23,8 @@ router.get("/", async (_req, res) => {
}
});
router.get("/history", (req, res) => {
res.json(hostHistory(typeof req.query.range === "string" ? req.query.range : "1h"));
});
module.exports = router;
+54
View File
@@ -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")
+3 -20
View File
@@ -29,21 +29,8 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
const router = Router();
// How long the event loop was held up — what an operator sees as a laggy
// terminal: every keystroke's echo waits for it. Sampled every 20 ms and reset
// each minute, so the gauges describe the last minute or so.
const LOOP_WINDOW_MS = 60_000;
const loopDelay = require("perf_hooks").monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
let loopWindow = null; // the last full minute, reported until the next one ends
setInterval(() => {
loopWindow = {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
loopDelay.reset();
}, LOOP_WINDOW_MS).unref();
// Event-loop delay over the last minute (server/lib/loop-delay.js).
const { loopDelay } = require("../lib/loop-delay");
// Resolved once at load — the dashboard version, surfaced as a build-info label
// (the canonical Prometheus idiom for exposing a version string as a metric).
@@ -116,11 +103,7 @@ router.get("/", (_req, res) => {
[{ value: process.memoryUsage().rss }]
);
const loop = loopWindow || {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
const loop = loopDelay();
appendMetric(
out,
"agenthub_event_loop_delay_seconds",
+50 -2
View File
@@ -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
@@ -252,9 +266,43 @@ router.get("/", (req, res) => {
}
}
if (isServerMode()) attachOwners(rows);
res.json({ sessions: rows, limit, offset, total });
});
/**
* Server mode: stamp each listed row with its owner — the owner of the
* workspace the session belongs to (`sessions.lane_id`, else the lane of a
* dashboard run holding it, as `sessionWhere` decides ownership). Only the
* page's rows, so one small query per page.
*/
function attachOwners(rows) {
if (!rows.length) return;
const ids = rows.map((r) => r.id);
const owners = new Map();
for (let i = 0; i < ids.length; i += 900) {
const chunk = ids.slice(i, i + 900);
const found = db
.prepare(
`SELECT s.id AS session_id, u.id AS owner_id, u.username, u.display_name, u.avatar_at
FROM sessions s
JOIN lanes l ON l.id = COALESCE(s.lane_id,
(SELECT r.lane_id FROM dashboard_runs r WHERE r.session_id = s.id ORDER BY r.rowid DESC LIMIT 1))
JOIN users u ON u.id = l.owner_id
WHERE s.id IN (${chunk.map(() => "?").join(",")})`
)
.all(...chunk);
for (const o of found) owners.set(o.session_id, o);
}
for (const row of rows) {
const o = owners.get(row.id);
row.owner_id = o?.owner_id ?? null;
row.owner_name = o?.username ?? null;
row.owner_display_name = o?.display_name ?? null;
row.owner_avatar_at = o?.avatar_at ?? null;
}
}
router.get("/facets", (req, res) => {
const own = sessionWhere(req);
const rows = db
@@ -830,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",
@@ -1108,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 });
}
+27
View File
@@ -12,6 +12,7 @@ const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../d
const { getConnectionCount } = require("../websocket");
const { transcriptCache } = require("./hooks");
const { forgetHourly } = require("../lib/hourly-usage");
const { loopDelay } = require("../lib/loop-delay");
const router = Router();
@@ -129,6 +130,29 @@ router.get("/info", (req, res) => {
h1: getCount(60 * 60 * 1000),
};
// What the System Health score needs beyond the machine: when a hook last
// landed (silent hooks while agents work = a blind dashboard) and how the
// agents that finished in the last 24 h ended.
const lastEvent = db
.prepare(
`SELECT created_at FROM events WHERE ${ownEvents.where} ORDER BY created_at DESC LIMIT 1`
)
.get(...ownEvents.params);
const day = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
const agentRows = db
.prepare(
`SELECT status, COUNT(*) AS c FROM agents
WHERE (status = 'working' OR ended_at > ?) AND ${ownEvents.where} GROUP BY status`
)
.all(day, ...ownEvents.params);
const agentCount = (st) => agentRows.find((r) => r.status === st)?.c ?? 0;
const activity = {
last_event_at: lastEvent?.created_at ?? null,
working_agents: agentCount("working"),
agents_24h: { completed: agentCount("completed"), error: agentCount("error") },
};
const loop = loopDelay();
// A developer (server mode) gets the numbers, never a path of the host's:
// the transcript cache is keyed by EVERY user's transcript path (their
// folders, their session ids), and the DB/settings paths are the host's.
@@ -158,7 +182,10 @@ router.get("/info", (req, res) => {
total_mem: os.totalmem(),
free_mem: os.freemem(),
cpus: os.cpus().length,
// Event-loop delay over the last minute, in ms (server/lib/loop-delay.js).
event_loop: { p50: loop.p50 / 1e6, p99: loop.p99 / 1e6, max: loop.max / 1e6 },
},
activity,
transcript_cache: developer ? { ...cache, keys: undefined } : cache,
});
});
+253
View File
@@ -16,6 +16,12 @@
* (server/lib/member-tmux.js); a restart ends that member's panes.
*
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
* `GET /:id/details` everything the Administration page's user detail shows —
* the account and its logins, its workspaces (sessions, last activity, live
* panes), the panes running now, its newest sessions, spend per local day /
* AI tool / model over 30 days, the audit entries naming it, and per pool
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
*
@@ -217,6 +223,253 @@ router.get("/:id", (req, res) => {
});
});
const DAY_MS = 864e5;
const tokensOf = (r) =>
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
/** `{cost, tokens}` of token rows, priced by `calculateCost`. */
function priced(rows, rules) {
const { calculateCost } = require("./pricing");
return {
cost: rows.length ? calculateCost(rows, rules).total_cost : 0,
tokens: Math.round(rows.reduce((n, r) => n + tokensOf(r), 0)),
};
}
/** Rows grouped by `keyOf`, each group priced: `[{key, cost, tokens, sessions}]`, $ first. */
function breakdown(rows, keyOf, rules) {
const groups = new Map();
for (const r of rows) {
const k = keyOf(r) || "unknown";
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(r);
}
return [...groups]
.map(([key, list]) => ({
key,
...priced(list, rules),
sessions: new Set(list.map((r) => r.session_id)).size,
}))
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
}
/** Per pool tool: what `uid` may run on, and their own share of each account. */
async function poolFor(uid) {
const pins = require("../lib/account-pins");
const groups = require("../lib/pool-groups");
const { accountUsage } = require("../lib/pool-usage");
const pools = {
claude: require("../lib/claude-pool"),
codex: require("../lib/codex-pool"),
cursor: require("../lib/cursor-pool"),
};
await pools.claude.refreshQuotas().catch(() => undefined);
const group = db
.prepare(
`SELECT g.id, g.name FROM pool_group_users u JOIN pool_groups g ON g.id = u.group_id
WHERE u.user_id = ?`
)
.get(uid);
const since7d = new Date(Date.now() - 7 * DAY_MS).toISOString();
const used = db
.prepare(
`SELECT r.tool, r.account, COUNT(*) AS runs, MAX(r.started_at) AS lastAt,
SUM(r.status IN ('running', 'spawning')) AS live
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.account IS NOT NULL AND r.started_at >= ?
GROUP BY r.tool, r.account ORDER BY lastAt DESC`
)
.all(uid, since7d);
const out = {};
for (const tool of pins.TOOLS) {
const pool = pools[tool];
if (!pool) continue;
let status = [];
try {
status = pool.poolStatus();
} catch {
status = [];
}
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))
.map((a) => {
const mine = accountUsage(tool, a.name, { wholeSession, ownerId: uid });
const ran = ranOn.find((u) => u.account === a.name);
return {
name: a.name,
kind: a.kind,
email: a.email ?? null,
expired: Boolean(a.expired),
allowed: ok(a.name),
pinned: a.name === pin,
quota: a.quota ?? null,
quotaPercent: a.quotaPercent,
full: a.full,
livePanes: a.livePanes,
spend5h: a.spend5h,
spend7d: a.spend7d,
userSpend5h: mine.spend5h,
userSpend7d: mine.spend7d,
userTokens5h: mine.tokens5h,
userTokens7d: mine.tokens7d,
userRuns7d: ran?.runs ?? 0,
userLivePanes: ran?.live ?? 0,
userLastAt: ran?.lastAt ?? null,
};
}),
};
}
return out;
}
router.get("/:id/details", async (req, res) => {
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
try {
const { hourlyTokenRows } = require("./pricing");
const rules = stmts.listPricing.all();
const uid = u.id;
const owned = `SELECT session_id FROM (${OWNED_SESSIONS}) WHERE uid = ?`;
const off = parseInt(req.query.tz_offset, 10);
const offMin = Number.isFinite(off) && Math.abs(off) <= 840 ? off : 0;
// A UTC hour (`YYYY-MM-DDTHH`) → the viewer's local date.
const localDay = (hour) =>
new Date(Date.parse(`${hour}:00:00Z`) - offMin * 60e3).toISOString().slice(0, 10);
const since30 = hourAgo(30);
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (${owned})`,
[since30, uid]
).filter((r) => r.date >= since30);
const today = todayHour(offMin);
const since7 = hourAgo(7);
// One bar per local day, the oldest first, days with no spend included.
const byDay = new Map();
for (const r of rows) {
const d = localDay(r.date);
if (!byDay.has(d)) byDay.set(d, []);
byDay.get(d).push(r);
}
const daily = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - offMin * 60e3 - i * DAY_MS).toISOString().slice(0, 10);
daily.push({ date: d, ...priced(byDay.get(d) || [], rules) });
}
const workspaces = db
.prepare(
`SELECT l.id, l.title, l.cwd, l.tool, l.kind, l.branch, l.status, l.created_at AS createdAt,
(SELECT COUNT(*) FROM (${OWNED_SESSIONS}) o JOIN sessions s ON s.id = o.session_id
WHERE o.uid = l.owner_id AND (s.lane_id = l.id OR s.id IN
(SELECT session_id FROM dashboard_runs WHERE lane_id = l.id))) AS sessions,
(SELECT MAX(s.updated_at) FROM sessions s WHERE s.lane_id = l.id) AS lastActiveAt,
(SELECT COUNT(*) FROM dashboard_runs r
WHERE r.lane_id = l.id AND r.status IN ('running', 'spawning')) AS livePanes
FROM lanes l WHERE l.owner_id = ? ORDER BY lastActiveAt DESC, l.id`
)
.all(uid);
const panes = db
.prepare(
`SELECT r.id, r.lane_id AS laneId, l.title AS laneTitle, l.cwd AS laneCwd, r.tool,
r.model, r.account, r.proxy, r.session_id AS sessionId, r.started_at AS startedAt,
r.status
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.status IN ('running', 'spawning')
ORDER BY r.started_at DESC`
)
.all(uid);
const recent = db
.prepare(
`SELECT s.id, s.name, s.tool, s.model, s.status, s.cwd, s.lane_id, s.started_at,
s.ended_at, s.updated_at AS last_activity, s.awaiting_input_since, s.awaiting_reason
FROM sessions s WHERE s.id IN (${owned})
ORDER BY s.updated_at DESC LIMIT 15`
)
.all(uid);
if (recent.length) {
const cost = new Map(
breakdown(
hourlyTokenRows(
`WHERE s.id IN (${recent.map(() => "?").join(",")})`,
recent.map((r) => r.id)
),
(r) => r.session_id,
rules
).map((b) => [b.key, b])
);
for (const r of recent) {
r.cost = cost.get(r.id)?.cost ?? 0;
r.tokens = cost.get(r.id)?.tokens ?? 0;
}
}
const counts = db
.prepare(
`SELECT COUNT(*) AS total, SUM(s.status = 'active') AS active,
MIN(s.started_at) AS firstAt, MAX(s.updated_at) AS lastAt
FROM sessions s WHERE s.id IN (${owned})`
)
.get(uid);
const audit = db
.prepare(
`SELECT a.id, a.at, a.method, a.path, a.session_id,
r.username AS reader, o.username AS owner,
r.display_name AS reader_display, o.display_name AS owner_display
FROM audit_log a
LEFT JOIN users r ON r.id = a.user_id
LEFT JOIN users o ON o.id = a.owner_id
WHERE a.user_id = ? OR a.owner_id = ?
ORDER BY a.id DESC LIMIT 30`
)
.all(uid, uid);
res.json({
user: { ...auth.publicUser(u), ...auth.loginStats(uid) },
sessions: {
total: counts.total || 0,
active: counts.active || 0,
firstAt: counts.firstAt || null,
lastAt: counts.lastAt || null,
},
usage: {
today: priced(
rows.filter((r) => r.date >= today),
rules
),
last7d: priced(
rows.filter((r) => r.date >= since7),
rules
),
last30d: priced(rows, rules),
daily,
byTool: breakdown(rows, (r) => r.tool || "claude", rules),
byModel: breakdown(rows, (r) => r.model, rules),
},
workspaces,
panes,
recentSessions: recent,
pool: await poolFor(uid),
audit,
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EDETAILS", message: e.message } });
}
});
router.get("/:id/avatar", (req, res) => {
const id = Number(req.params.id);
if (