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.
This commit is contained in:
2026-10-27 16:39:38 +07:00
parent df15789982
commit 1c6dfea5b5
49 changed files with 5154 additions and 1971 deletions
+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>
);
}
+707
View File
@@ -0,0 +1,707 @@
/**
* @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; 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>
<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">
<HealthBar
label={t("host.cpu")}
value={s.usage ? s.usage.cpu : null}
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");
// 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. */}
<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">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
</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. */}
<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,182 @@
/**
* @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).
* @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();
});
});
@@ -72,15 +72,15 @@ type Row = {
};
/** 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
| {
@@ -558,7 +558,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);
+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>
);
}
+331
View File
@@ -0,0 +1,331 @@
/**
* @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.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,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": {
+32 -2
View File
@@ -315,7 +315,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 +333,33 @@
"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}}",
"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": {
+32 -2
View File
@@ -315,7 +315,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 +333,33 @@
"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}}",
"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 });
});
});
+159 -8
View File
@@ -546,6 +546,83 @@ 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;
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 +655,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 +666,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 +706,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. */
@@ -1090,9 +1227,11 @@ export const api = {
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
/** 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 +1241,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 +2152,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 +4433,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 +4447,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`.
+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";
}
+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>
);
}
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>
+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();
});
});
+74 -1
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();