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:
@@ -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>
|
||||
*/
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+57
-21
@@ -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,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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+798
-791
File diff suppressed because it is too large
Load Diff
+175
-96
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
+6
-2
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
@@ -117,7 +117,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -1866,6 +1866,10 @@ classDiagram
|
||||
+string pinned_at "null unless pinned"
|
||||
+number cost
|
||||
+number agent_count
|
||||
+number owner_id "server mode: workspace owner; null if none"
|
||||
+string owner_name "server mode: owner's username"
|
||||
+string owner_display_name "server mode"
|
||||
+string owner_avatar_at "server mode"
|
||||
}
|
||||
|
||||
SessionListResponse --> Session
|
||||
|
||||
+39
-11
@@ -166,20 +166,48 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
|
||||
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
|
||||
redirects there) shows it — one page, no tabs: the users table, the account
|
||||
pool, then the audit log. The machine itself is on the Dashboard's
|
||||
**System Health** tab (below).
|
||||
The table's spend column is today's (the browser's local day), with 7 and 30
|
||||
days under it. Clicking a user expands a short detail right under their row
|
||||
(`/admin?user=<id>`, `GET /api/users/:id/details`): last sign-in, last
|
||||
activity and what runs now, then spend over 30 days by AI tool and top
|
||||
models, the account pool per tool (pin or group, each account's quota left
|
||||
beside their own 7-day share of its spend), their live panes and
|
||||
workspaces, and their five newest sessions.
|
||||
- `GET /api/host` (every signed-in user — `/host` is deliberately NOT
|
||||
staff-only, so a developer also sees other users' usernames, CPU / RAM share
|
||||
and busiest process names) is the machine on the Dashboard's **System
|
||||
Health** tab — its **Machine** group, between **AgentHub** (score, storage,
|
||||
concurrency) and **Agent activity** (tokens by model, tools, subagents): the machine's hostname, OS, uptime, CPU model, cores and
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU, and each user's live
|
||||
share of it — CPU % of the whole machine (100 % = every core busy) and
|
||||
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
||||
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||
user, their busiest processes on click; their home share stays in
|
||||
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
as a share of the homes disk. A process counts for a user when it runs as
|
||||
their Linux account (S3) or, for the host account, when it inherited a
|
||||
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
|
||||
runs in their workspaces' panes; the rest is **Host account** or
|
||||
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
|
||||
per-user part. The page re-reads every 5 s while shown.
|
||||
**System**. The same 1 s sample also gives every CPU thread's busy %,
|
||||
network download/upload per interface (no `lo`), read/write per whole
|
||||
disk, and the ten busiest processes machine-wide with who they count for;
|
||||
plus `/sys/class/hwmon` temperatures, physical cores, boot time and the
|
||||
AgentHub process's own pid, Node version, uptime and RSS — laid out in the
|
||||
tab's own card style (`client/src/components/health/HostHealth.tsx`): the
|
||||
machine card (with the V8 heap from `/api/settings/info`), then CPU threads,
|
||||
temperatures / GPUs, disks with their I/O and network, then history and the
|
||||
busiest processes. Read-only (`server/lib/host-stats.js`); Linux only for
|
||||
the per-user part. The tab re-reads it every 5 s while shown.
|
||||
- `GET /api/host/history?range=1h|6h|24h` (every signed-in user) is the tab's
|
||||
**History** charts: machine CPU % and memory % (plus swap, load and each
|
||||
user's CPU/memory in the payload), one point every 30 s for 24 h. Every
|
||||
server on Linux (local or server mode) keeps it IN MEMORY from the moment it starts
|
||||
(`startHistory`, server/index.js) — a restart starts it empty, and nothing
|
||||
is written to disk.
|
||||
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
|
||||
how much disk each user's home takes (their Linux home under S3, their
|
||||
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
|
||||
@@ -916,12 +944,12 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
an older server. Until `agenthub server install-system` has installed
|
||||
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
|
||||
version until their last pane closes; the next start runs `TMUX_BIN`.
|
||||
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
- **Administration → users table, tmux column** (admin/analyst read, admin acts): each member's
|
||||
running server with its version beside the installed one (an *older than
|
||||
installed* badge), its sessions and whether an agent of theirs is working.
|
||||
**Restart** ends one member's server — every pane of theirs closes, their
|
||||
The row's **Restart** button ends one member's server — every pane of theirs closes, their
|
||||
Claude sessions resume from the workspace — and **Restart N idle members**
|
||||
does it for every outdated member with no agent working; both take the
|
||||
(above the table) does it for every outdated member with no agent working; both take the
|
||||
admin's password again and are audited per member. A server that will not
|
||||
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
@@ -999,7 +1027,7 @@ agenthub server install-system
|
||||
|
||||
A member server that a newer tmux already reached before `tmux-member` was
|
||||
installed can stay wedged: every call on that socket — `tmux-member`'s too —
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
|
||||
prints `server exited unexpectedly`. If the users table's tmux column on Administration shows it with
|
||||
no panes, stop that one server (its pid is the `ah-<name>` process running
|
||||
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
|
||||
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
|
||||
|
||||
+99
-2
@@ -71,7 +71,7 @@ tags:
|
||||
- name: Artifacts
|
||||
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
|
||||
- name: Host
|
||||
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.'
|
||||
description: 'Dashboard → System Health: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, every signed-in user.'
|
||||
- name: Locks
|
||||
description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.
|
||||
- name: Projects
|
||||
@@ -262,6 +262,21 @@ components:
|
||||
agent_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
owner_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: 'Server mode, list only: the account owning the session''s workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.'
|
||||
owner_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: That owner's username.
|
||||
owner_display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
owner_avatar_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_activity:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -1578,6 +1593,32 @@ components:
|
||||
type: string
|
||||
ws_connections:
|
||||
type: integer
|
||||
event_loop:
|
||||
type: object
|
||||
description: Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.
|
||||
properties:
|
||||
p50:
|
||||
type: number
|
||||
p99:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
activity:
|
||||
type: object
|
||||
description: 'For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller''s rows).'
|
||||
properties:
|
||||
last_event_at:
|
||||
type: string
|
||||
nullable: true
|
||||
working_agents:
|
||||
type: integer
|
||||
agents_24h:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
transcript_cache:
|
||||
type: object
|
||||
required:
|
||||
@@ -15014,6 +15055,38 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/details:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getServerUserDetails
|
||||
summary: 'One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account''s quota beside this user''s own 5 h / 7 d share. Never a token'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: tz_offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: The browser's getTimezoneOffset(), for the daily buckets
|
||||
responses:
|
||||
'200':
|
||||
description: Account details
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -17749,13 +17822,37 @@ paths:
|
||||
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
|
||||
responses:
|
||||
'200':
|
||||
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.'
|
||||
description: '`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'500':
|
||||
description: EHOST
|
||||
/api/host/history:
|
||||
get:
|
||||
tags:
|
||||
- Host
|
||||
summary: The machine's CPU/memory history since the server started
|
||||
operationId: getHostHistory
|
||||
description: 'In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.'
|
||||
parameters:
|
||||
- name: range
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- 1h
|
||||
- 6h
|
||||
- 24h
|
||||
default: 1h
|
||||
responses:
|
||||
'200':
|
||||
description: '`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
/api/lanes:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -2040,6 +2040,16 @@ describe("Transcript cache integration", () => {
|
||||
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
|
||||
});
|
||||
|
||||
it("reports event-loop delay and the last hook / agents for the health score", async () => {
|
||||
const res = await fetch("/api/settings/info");
|
||||
const loop = res.body.server.event_loop;
|
||||
for (const k of ["p50", "p99", "max"]) assert.ok(Number.isFinite(loop[k]), `event_loop.${k}`);
|
||||
const a = res.body.activity;
|
||||
assert.ok(a.last_event_at === null || typeof a.last_event_at === "string");
|
||||
assert.ok(Number.isInteger(a.working_agents));
|
||||
assert.ok(Number.isInteger(a.agents_24h.completed) && Number.isInteger(a.agents_24h.error));
|
||||
});
|
||||
|
||||
it("should evict cache entry on SessionEnd", async () => {
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* @file host-stats.test.js
|
||||
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`), the
|
||||
* /proc and /sys parsers behind its per-core, network, disk I/O and
|
||||
* temperature figures, the PSI pressure parser, and the in-memory history (`GET /api/host/history`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
|
||||
const {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
} = require("../lib/host-stats");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
@@ -68,3 +86,132 @@ test(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("parsePressure reads some/full averages and refuses a missing file", () => {
|
||||
assert.deepEqual(
|
||||
parsePressure(
|
||||
"some avg10=12.50 avg60=3.00 avg300=1.00 total=1\nfull avg10=4.25 avg60=1.00 avg300=0.50 total=1\n"
|
||||
),
|
||||
{ some10: 12.5, some60: 3, full10: 4.25 }
|
||||
);
|
||||
// The cpu file of an older kernel has no `full` line.
|
||||
assert.deepEqual(parsePressure("some avg10=1.00 avg60=2.00 avg300=0.00 total=1\n"), {
|
||||
some10: 1,
|
||||
some60: 2,
|
||||
full10: 0,
|
||||
});
|
||||
assert.equal(parsePressure(null), null);
|
||||
assert.equal(parsePressure("garbage"), null);
|
||||
});
|
||||
|
||||
test("parseCpuStat reads the machine line and every core", () => {
|
||||
const r = parseCpuStat(
|
||||
"cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 5 0 5 35 5 0 0 0\ncpu1 5 0 5 35 5 0 0 0\nintr 1 2\n"
|
||||
);
|
||||
assert.deepEqual(r.all, { total: 100, idle: 80 });
|
||||
assert.equal(r.cores.length, 2);
|
||||
assert.deepEqual(r.cores[1], { total: 50, idle: 40 });
|
||||
assert.deepEqual(parseCpuStat(""), { all: null, cores: [] });
|
||||
});
|
||||
|
||||
test("parseNetDev skips lo and reads rx/tx bytes", () => {
|
||||
const text = [
|
||||
"Inter-| Receive | Transmit",
|
||||
" face |bytes packets|bytes packets",
|
||||
" lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0",
|
||||
"enp4s0: 5000 10 0 0 0 0 0 0 2000 6 0 0 0 0 0 0",
|
||||
].join("\n");
|
||||
const m = parseNetDev(text);
|
||||
assert.equal(m.has("lo"), false);
|
||||
assert.deepEqual(m.get("enp4s0"), { rx: 5000, tx: 2000 });
|
||||
});
|
||||
|
||||
test("parseDiskstats keeps whole disks and converts sectors to bytes", () => {
|
||||
const text = [
|
||||
" 7 0 loop0 14 0 34 12 0 0 0 0 0 11 12",
|
||||
" 8 0 sda 100 0 2048 50 20 0 1024 9 0 30 40",
|
||||
" 8 1 sda1 90 0 2000 40 20 0 1000 9 0 30 40",
|
||||
].join("\n");
|
||||
const m = parseDiskstats(text, (n) => n === "sda");
|
||||
assert.deepEqual([...m.keys()], ["sda"]);
|
||||
assert.deepEqual(m.get("sda"), { read: 2048 * 512, write: 1024 * 512 });
|
||||
});
|
||||
|
||||
test("physicalCores counts distinct physical/core id pairs", () => {
|
||||
const info = [0, 1].flatMap((core) =>
|
||||
[0, 1].map((ht) => `processor\t: ${core * 2 + ht}\nphysical id\t: 0\ncore id\t\t: ${core}\n`)
|
||||
);
|
||||
assert.equal(physicalCores(info.join("\n")), 2);
|
||||
assert.equal(physicalCores(""), null);
|
||||
});
|
||||
|
||||
test("temperatures reads hwmon inputs with their labels", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hwmon-"));
|
||||
try {
|
||||
const chip = path.join(root, "hwmon0");
|
||||
fs.mkdirSync(chip);
|
||||
fs.writeFileSync(path.join(chip, "name"), "coretemp\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_input"), "45500\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_label"), "Package id 0\n");
|
||||
fs.writeFileSync(path.join(chip, "temp2_input"), "38000\n");
|
||||
assert.deepEqual(await temperatures(root), [
|
||||
{ chip: "coretemp", label: "Package id 0", celsius: 45.5 },
|
||||
{ chip: "coretemp", label: "temp2", celsius: 38 },
|
||||
]);
|
||||
assert.deepEqual(await temperatures(path.join(root, "missing")), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hostHistory filters by range and downsample averages buckets", () => {
|
||||
const now = Date.now();
|
||||
const pt = (minsAgo, cpu) => ({
|
||||
at: new Date(now - minsAgo * 60e3).toISOString(),
|
||||
cpu,
|
||||
memory: 50,
|
||||
memoryUsed: 100,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: { 3: { cpu, memory: 10 } },
|
||||
});
|
||||
__setHistory([pt(300, 90), pt(30, 10), pt(1, 20)]);
|
||||
const h1 = hostHistory("1h");
|
||||
assert.equal(h1.range, "1h");
|
||||
assert.deepEqual(
|
||||
h1.points.map((p) => p.cpu),
|
||||
[10, 20]
|
||||
);
|
||||
assert.equal(hostHistory("6h").points.length, 3);
|
||||
assert.equal(hostHistory("bogus").range, "1h");
|
||||
const d = downsample([pt(3, 10), pt(2, 20), pt(1, 30), pt(0, 40)], 2);
|
||||
assert.deepEqual(
|
||||
d.map((p) => p.cpu),
|
||||
[15, 35]
|
||||
);
|
||||
assert.equal(d[0].users[3].cpu, 15);
|
||||
__setHistory([]);
|
||||
});
|
||||
|
||||
test(
|
||||
"hostStats adds per-core, network, disk I/O, top processes and process facts",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
__setDeps({
|
||||
users: () => [],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.equal(r.cpu.perCore.length, r.cpu.cores);
|
||||
assert.ok(Array.isArray(r.network) && Array.isArray(r.diskIo));
|
||||
assert.ok(r.network.every((n) => n.name !== "lo"));
|
||||
assert.ok(r.topProcesses.length > 0 && r.topProcesses.length <= 10);
|
||||
assert.ok(r.topProcesses.every((p) => typeof p.group === "string" && !("argv" in p)));
|
||||
assert.equal(r.process.pid, process.pid);
|
||||
assert.ok(r.process.rss > 0);
|
||||
assert.ok(typeof r.bootTime === "string");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -209,6 +209,12 @@ describe("server mode: list and aggregate routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names each listed session's owner: a stamped lane, else a run's lane", async () => {
|
||||
const { sessions } = await get("/api/sessions", "boss");
|
||||
const owners = Object.fromEntries(sessions.map((s) => [s.id, s.owner_name]));
|
||||
assert.deepEqual(owners, { sA: "alice", sA2: "alice", sB: "bob", sHost: null });
|
||||
});
|
||||
|
||||
it("keeps a member out of another's rows through filters and POST", async () => {
|
||||
// A filter naming another's session is refused, not answered empty.
|
||||
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file server-mode-user-details.test.js
|
||||
* @description `GET /api/users/:id/details` — the Administration page's user
|
||||
* detail: the account, its workspaces and live panes, newest sessions, spend
|
||||
* per day / AI tool / model, the audit entries naming it, and per pool tool
|
||||
* the accounts it may run on with its own share of each. Staff only, 404 for
|
||||
* an unknown user, never a token or a password hash.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-details-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
// The quota read would go to the network.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
|
||||
const ids = {};
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
const lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)")
|
||||
.run(ids.mia).lastInsertRowid;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
|
||||
).run(lane);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sc', 'mia codex', 'completed', '/w/mia', ?, 'codex')"
|
||||
).run(lane);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
for (const [sid, tokens] of [
|
||||
["sm", 1000000],
|
||||
["sc", 500000],
|
||||
]) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hour, tokens);
|
||||
}
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, account)
|
||||
VALUES ('agenthub-lane-1', 'sm', 'interactive', '/w/mia', 'running', ?, 'claude', 'team-1')`
|
||||
).run(lane);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/users/:id/details", () => {
|
||||
it("shows staff one user's workspaces, panes, sessions, spend and pool share", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
const d = r.body;
|
||||
assert.equal(d.user.username, "mia");
|
||||
assert.equal(d.sessions.total, 2);
|
||||
assert.equal(d.sessions.active, 1);
|
||||
assert.deepEqual(
|
||||
d.workspaces.map((w) => [w.title, w.sessions, w.livePanes]),
|
||||
[["Mia", 2, 1]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.panes.map((p) => [p.id, p.account]),
|
||||
[["agenthub-lane-1", "team-1"]]
|
||||
);
|
||||
assert.deepEqual(d.recentSessions.map((s) => s.id).sort(), ["sc", "sm"]);
|
||||
assert.ok(d.recentSessions.every((s) => s.tokens > 0 && s.cost > 0));
|
||||
|
||||
assert.equal(d.usage.last30d.tokens, 1500000);
|
||||
assert.equal(d.usage.today.tokens, 1500000);
|
||||
assert.equal(d.usage.daily.length, 30);
|
||||
assert.equal(d.usage.daily.at(-1).tokens, 1500000, "today is the last bar");
|
||||
assert.deepEqual(
|
||||
d.usage.byTool.map((b) => [b.key, b.tokens, b.sessions]),
|
||||
[
|
||||
["claude", 1000000, 1],
|
||||
["codex", 500000, 1],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.usage.byModel.map((b) => b.key),
|
||||
["claude-sonnet-4-5-20250929"]
|
||||
);
|
||||
|
||||
const team = d.pool.claude.accounts.find((a) => a.name === "team-1");
|
||||
assert.equal(team.allowed, true);
|
||||
assert.equal(team.userRuns7d, 1);
|
||||
assert.equal(team.userLivePanes, 1);
|
||||
assert.equal(team.userTokens7d, 1000000, "only the hours its run held the session");
|
||||
assert.equal(d.pool.claude.pin, null);
|
||||
assert.ok(!JSON.stringify(d).includes(TOKEN), "never a token");
|
||||
assert.ok(!JSON.stringify(d).includes("scrypt"), "never a password hash");
|
||||
});
|
||||
|
||||
it("lists the audit entries naming the user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
await call("GET", "/api/sessions/sm", { cookie: boss });
|
||||
const d = (await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss })).body;
|
||||
assert.ok(d.audit.some((a) => a.reader === "boss" && a.owner === "mia"));
|
||||
});
|
||||
|
||||
it("is staff only and 404s an unknown user", async () => {
|
||||
const ana = await login("ana", "analyst-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: ana })).status, 200);
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: mia })).status, 403);
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
assert.equal((await call("GET", "/api/users/9999/details", { cookie: boss })).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* workspaces, sessions and recent spend to an admin/analyst and refuses a
|
||||
* member; an admin/analyst reading a member's session is written to the audit
|
||||
* log (`GET /api/users/audit`), while a member reading their own, or a read of
|
||||
* an ownerless session, is not.
|
||||
* an ownerless session, is not; and `GET /api/host` (System Health's machine)
|
||||
* answers every signed-in user, a developer included.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -101,6 +102,18 @@ describe("staff views", () => {
|
||||
assert.equal(member.status, 403);
|
||||
});
|
||||
|
||||
it("shows every signed-in user the machine (System Health), names included", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call("/api/host", mia);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.body.hostname);
|
||||
if (r.body.usage) {
|
||||
const g = r.body.usage.groups.find((x) => x.kind === "user" && x.username === "mia");
|
||||
assert.ok(g && "displayName" in g, "a user's group carries their display name");
|
||||
}
|
||||
assert.equal((await call("/api/host/history?range=1h", mia)).status, 200);
|
||||
});
|
||||
|
||||
it("audits a staff read of a member's session, and nothing else", async () => {
|
||||
const admin = await login("boss", "admin-pass-1");
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
|
||||
@@ -1332,6 +1332,8 @@ if (require.main === module) {
|
||||
require("./lib/omp-share")
|
||||
.start()
|
||||
.catch((err) => console.warn("omp share failed to start:", err.message));
|
||||
// System Health's CPU/memory history (in memory; Linux only, inside).
|
||||
require("./lib/host-stats").startHistory();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(listenFailure(err, PORT));
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ const STAFF_ONLY_PREFIXES = [
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
// The machine's hardware and every user's CPU/memory share.
|
||||
"/host",
|
||||
// `/host` (the machine and each user's CPU/memory share) is NOT here: the
|
||||
// Dashboard's System Health tab shows it to every signed-in user.
|
||||
];
|
||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||
|
||||
+383
-34
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file host-stats.js
|
||||
* @description The Administration page's Server tab (`GET /api/host`, staff
|
||||
* only): the machine's hardware and load, and how much of its CPU and memory
|
||||
* @description The Dashboard System Health tab's machine cards (`GET /api/host`,
|
||||
* every signed-in user): the machine's hardware and load, and how much of its CPU and memory
|
||||
* each AgentHub user is using right now. Read-only — nothing here signals,
|
||||
* renices or kills a process.
|
||||
*
|
||||
@@ -17,9 +17,22 @@
|
||||
* children carry it too); else `host` (the dashboard and everything else the
|
||||
* host account runs) or `system` (root and every other account). Only the
|
||||
* kernel's `comm` name is reported for a process — never its argv or env,
|
||||
* which can hold a prompt or a key. Every read is async, and one sample is
|
||||
* which can hold a prompt or a key. The same sample window also gives per-core
|
||||
* CPU % (`/proc/stat` cpuN), network rx/tx per interface (`/proc/net/dev`, no
|
||||
* `lo`), disk read/write per whole disk (`/proc/diskstats`), and the ten
|
||||
* busiest processes machine-wide with their group; temperatures come from
|
||||
* `/sys/class/hwmon`, physical cores from `/proc/cpuinfo`, and the dashboard's
|
||||
* own pid/Node/uptime/RSS from `process`, and pressure stall information
|
||||
* (`/proc/pressure/{cpu,memory,io}`, PSI — the share of time tasks waited on
|
||||
* that resource, i.e. how laggy the machine feels; `null` on a kernel without
|
||||
* it). Every read is async, and one sample is
|
||||
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
|
||||
* platforms get the `os` facts with `usage: null`.
|
||||
* platforms get the `os` facts with `usage: null` and empty lists.
|
||||
*
|
||||
* History (`GET /api/host/history`): `startHistory` (every server on Linux,
|
||||
* server/index.js) keeps an in-memory ring of machine CPU/memory/swap/load1
|
||||
* and per-user CPU/memory, one point every 30 s for 24 h, from the cached
|
||||
* snapshot; it starts empty at every server start and is never written to disk.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -32,6 +45,10 @@ const { execFile } = require("node:child_process");
|
||||
const SAMPLE_MS = 1000;
|
||||
const FRESH_MS = 3000;
|
||||
const TOP_PER_GROUP = 5;
|
||||
const TOP_PROCESSES = 10;
|
||||
const HISTORY_EVERY_MS = 30_000;
|
||||
const HISTORY_KEEP_MS = 24 * 3600_000;
|
||||
const HISTORY_MAX_POINTS = 360;
|
||||
// File systems that hold data; pseudo and loop mounts are left out.
|
||||
const REAL_FS = new Set([
|
||||
"ext2",
|
||||
@@ -71,13 +88,154 @@ async function memory() {
|
||||
};
|
||||
}
|
||||
|
||||
/** The machine's jiffies from `/proc/stat`'s `cpu` line: `{ total, idle }`. */
|
||||
async function cpuTimes() {
|
||||
const text = await readText("/proc/stat");
|
||||
const line = text && text.split("\n").find((l) => l.startsWith("cpu "));
|
||||
if (!line) return null;
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
/**
|
||||
* `/proc/stat`'s jiffies: the machine's `cpu` line and each `cpuN` line, each
|
||||
* `{ total, idle }` (idle = idle + iowait). `{ all: null, cores: [] }` when
|
||||
* there is no `cpu` line.
|
||||
*/
|
||||
function parseCpuStat(text) {
|
||||
const times = (line) => {
|
||||
const n = line.trim().split(/\s+/).slice(1).map(Number);
|
||||
return { total: n.reduce((a, b) => a + b, 0), idle: n[3] + (n[4] || 0) };
|
||||
};
|
||||
const lines = (text || "").split("\n");
|
||||
const all = lines.find((l) => l.startsWith("cpu "));
|
||||
return {
|
||||
all: all ? times(all) : null,
|
||||
cores: lines.filter((l) => /^cpu\d+ /.test(l)).map(times),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One `/proc/pressure/<resource>` file: `{some10, some60, full10}` — the % of
|
||||
* the last 10 s / 60 s that at least one task (`some`) or every task (`full`)
|
||||
* was stalled on it. `null` when the file is missing or unreadable.
|
||||
*/
|
||||
function parsePressure(text) {
|
||||
if (!text) return null;
|
||||
const line = (kind) => {
|
||||
const m = new RegExp(`^${kind} avg10=([\\d.]+) avg60=([\\d.]+)`, "m").exec(text);
|
||||
return m ? { avg10: Number(m[1]), avg60: Number(m[2]) } : null;
|
||||
};
|
||||
const some = line("some");
|
||||
if (!some) return null;
|
||||
return { some10: some.avg10, some60: some.avg60, full10: line("full")?.avg10 ?? 0 };
|
||||
}
|
||||
|
||||
/** PSI for cpu, memory and io; `null` when the kernel has none of them. */
|
||||
async function pressure() {
|
||||
const [cpu, memory, io] = await Promise.all(
|
||||
["cpu", "memory", "io"].map((r) => readText(`/proc/pressure/${r}`).then(parsePressure))
|
||||
);
|
||||
return cpu || memory || io ? { cpu, memory, io } : null;
|
||||
}
|
||||
|
||||
/** Busy % between two `{ total, idle }` readings. */
|
||||
const busyPct = (a, b) => {
|
||||
const total = b.total - a.total;
|
||||
return total > 0 ? round(((total - (b.idle - a.idle)) / total) * 100) : 0;
|
||||
};
|
||||
|
||||
/** `/proc/net/dev`: `Map<iface, { rx, tx }>` in bytes, `lo` left out. */
|
||||
function parseNetDev(text) {
|
||||
const out = new Map();
|
||||
for (const line of (text || "").split("\n").slice(2)) {
|
||||
const i = line.indexOf(":");
|
||||
if (i < 0) continue;
|
||||
const name = line.slice(0, i).trim();
|
||||
if (!name || name === "lo") continue;
|
||||
const f = line
|
||||
.slice(i + 1)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(Number);
|
||||
out.set(name, { rx: f[0] || 0, tx: f[8] || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/proc/diskstats`: `Map<device, { read, write }>` in bytes (sectors × 512)
|
||||
* for whole disks only (`isDisk(name)`), never loop/ram/zram/optical.
|
||||
*/
|
||||
function parseDiskstats(text, isDisk = () => true) {
|
||||
const out = new Map();
|
||||
for (const line of (text || "").split("\n")) {
|
||||
const f = line.trim().split(/\s+/);
|
||||
if (f.length < 10) continue;
|
||||
const name = f[2];
|
||||
if (/^(loop|ram|zram|sr|fd)\d*/.test(name) || !isDisk(name)) continue;
|
||||
out.set(name, { read: Number(f[5]) * 512, write: Number(f[9]) * 512 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whole disks: the names under `/sys/block` (partitions are not there). */
|
||||
function wholeDisks() {
|
||||
try {
|
||||
return new Set(fs.readdirSync("/sys/block"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-key `{a, b}` byte counters → per-second rates over `ms`. */
|
||||
function rates(m0, m1, ms, keys) {
|
||||
const sec = Math.max(0.001, ms / 1000);
|
||||
const out = [];
|
||||
for (const [name, v1] of m1) {
|
||||
const v0 = m0.get(name);
|
||||
if (!v0) continue;
|
||||
const row = { name };
|
||||
for (const k of keys) row[k] = Math.max(0, Math.round((v1[k] - v0[k]) / sec));
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperatures from `/sys/class/hwmon/*`: `[{ chip, label, celsius }]`, one
|
||||
* per `tempN_input` (label from `tempN_label`, else `tempN`). [] when none.
|
||||
*/
|
||||
async function temperatures(root = "/sys/class/hwmon") {
|
||||
let chips;
|
||||
try {
|
||||
chips = await fsp.readdir(root);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out = [];
|
||||
for (const c of chips.sort()) {
|
||||
const dir = path.join(root, c);
|
||||
const chip = ((await readText(path.join(dir, "name"))) || c).trim();
|
||||
let files;
|
||||
try {
|
||||
files = await fsp.readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files.filter((x) => /^temp\d+_input$/.test(x)).sort()) {
|
||||
const raw = Number(((await readText(path.join(dir, f))) || "").trim());
|
||||
if (!Number.isFinite(raw) || raw === 0) continue;
|
||||
const id = f.replace("_input", "");
|
||||
const label = ((await readText(path.join(dir, `${id}_label`))) || id).trim();
|
||||
out.push({ chip, label, celsius: round(raw / 1000) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Physical cores from `/proc/cpuinfo` (distinct physical id + core id), or null. */
|
||||
function physicalCores(text) {
|
||||
const seen = new Set();
|
||||
let phys = "0";
|
||||
for (const line of (text || "").split("\n")) {
|
||||
const m = /^(physical id|core id)\s*:\s*(\d+)/.exec(line);
|
||||
if (!m) continue;
|
||||
if (m[1] === "physical id") phys = m[2];
|
||||
else seen.add(`${phys}:${m[2]}`);
|
||||
}
|
||||
return seen.size || null;
|
||||
}
|
||||
|
||||
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
|
||||
@@ -269,14 +427,47 @@ let deps = {
|
||||
wait: (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
};
|
||||
|
||||
/** Per-user CPU and memory from two samples. */
|
||||
async function usage() {
|
||||
const t0 = await cpuTimes();
|
||||
const p0 = await processes();
|
||||
/** The counters one sample reads: CPU jiffies, network, disk I/O, processes. */
|
||||
async function counters(isDisk) {
|
||||
const [stat, net, io] = await Promise.all([
|
||||
readText("/proc/stat"),
|
||||
readText("/proc/net/dev"),
|
||||
readText("/proc/diskstats"),
|
||||
]);
|
||||
return {
|
||||
at: Date.now(),
|
||||
cpu: parseCpuStat(stat),
|
||||
net: parseNetDev(net),
|
||||
io: parseDiskstats(io, isDisk),
|
||||
procs: await processes(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two samples `SAMPLE_MS` apart: per-user CPU and memory (`usage`), and from
|
||||
* the same window per-core CPU, network and disk I/O rates and the busiest
|
||||
* processes machine-wide.
|
||||
*/
|
||||
async function sampleWindow(mem) {
|
||||
const disksSet = wholeDisks();
|
||||
const isDisk = (n) => !disksSet || disksSet.has(n);
|
||||
const s0 = await counters(isDisk);
|
||||
await deps.wait(SAMPLE_MS);
|
||||
const t1 = await cpuTimes();
|
||||
const p1 = await processes();
|
||||
const mem = await memory();
|
||||
const s1 = await counters(isDisk);
|
||||
const ms = s1.at - s0.at;
|
||||
const cores = s1.cpu.cores.map((c, i) => (s0.cpu.cores[i] ? busyPct(s0.cpu.cores[i], c) : 0));
|
||||
const use = await usage(s0.cpu.all, s1.cpu.all, s0.procs, s1.procs, mem);
|
||||
return {
|
||||
usage: use ? use.usage : null,
|
||||
topProcesses: use ? use.top : [],
|
||||
cores,
|
||||
network: rates(s0.net, s1.net, ms, ["rx", "tx"]),
|
||||
diskIo: rates(s0.io, s1.io, ms, ["read", "write"]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-user CPU and memory from two samples (`usage`) and the busiest processes (`top`). */
|
||||
async function usage(t0, t1, p0, p1, mem) {
|
||||
if (!t0 || !t1) return null;
|
||||
const totalDelta = Math.max(1, t1.total - t0.total);
|
||||
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
@@ -286,6 +477,7 @@ async function usage() {
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const ownerCache = new Map();
|
||||
const groups = new Map();
|
||||
const all = [];
|
||||
const group = (key, extra) => {
|
||||
if (!groups.has(key))
|
||||
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
|
||||
@@ -301,7 +493,7 @@ async function usage() {
|
||||
const member = deps.memberNameForUid(p.uid);
|
||||
if (member && byUsername.has(member)) {
|
||||
const u = byUsername.get(member);
|
||||
g = group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username });
|
||||
g = group(`user:${u.id}`, userExtra(u));
|
||||
} else if (p.uid === hostUid) {
|
||||
const laneId = await laneIdOf(pid);
|
||||
let ownerId = null;
|
||||
@@ -318,9 +510,7 @@ async function usage() {
|
||||
ownerId = ownerCache.get(laneId);
|
||||
}
|
||||
const u = ownerId != null ? byId.get(ownerId) : null;
|
||||
g = u
|
||||
? group(`user:${u.id}`, { kind: "user", userId: u.id, username: u.username })
|
||||
: group("host", { kind: "host" });
|
||||
g = u ? group(`user:${u.id}`, userExtra(u)) : group("host", { kind: "host" });
|
||||
} else {
|
||||
g = group("system", { kind: "system" });
|
||||
}
|
||||
@@ -329,6 +519,7 @@ async function usage() {
|
||||
g.memory += p.rss;
|
||||
g.processes += 1;
|
||||
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
|
||||
all.push({ pid, name: p.comm, cpu, memory: p.rss, group: g.key });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -342,9 +533,7 @@ async function usage() {
|
||||
if (!groups.has(`user:${u.id}`))
|
||||
list.push({
|
||||
key: `user:${u.id}`,
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
...userExtra(u),
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryPercent: 0,
|
||||
@@ -353,24 +542,40 @@ async function usage() {
|
||||
});
|
||||
}
|
||||
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
all.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
return {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
usage: {
|
||||
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
|
||||
sampleMs: SAMPLE_MS,
|
||||
groups: list,
|
||||
},
|
||||
top: all.slice(0, TOP_PROCESSES).map((t) => ({ ...t, cpu: round(t.cpu) })),
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
/** A user's group fields: id, username and display name (every viewer reads them). */
|
||||
const userExtra = (u) => ({
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
displayName: u.displayName ?? null,
|
||||
});
|
||||
|
||||
async function collect() {
|
||||
const linux = process.platform === "linux";
|
||||
const cpus = os.cpus();
|
||||
const [mem, diskList, gpuList, use] = await Promise.all([
|
||||
memory(),
|
||||
const mem = await memory();
|
||||
const [diskList, gpuList, win, temps, cpuinfo, psi] = await Promise.all([
|
||||
linux ? disks() : [],
|
||||
linux ? gpus() : [],
|
||||
linux ? usage() : null,
|
||||
linux ? sampleWindow(mem) : null,
|
||||
linux ? temperatures() : [],
|
||||
linux ? readText("/proc/cpuinfo") : null,
|
||||
linux ? pressure() : null,
|
||||
]);
|
||||
const pmem = process.memoryUsage();
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
@@ -381,12 +586,31 @@ async function collect() {
|
||||
arch: os.arch(),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
|
||||
bootTime: new Date(Date.now() - os.uptime() * 1000).toISOString(),
|
||||
cpu: {
|
||||
model: cpus[0]?.model?.trim() || null,
|
||||
cores: cpus.length,
|
||||
load: os.loadavg(),
|
||||
physicalCores: physicalCores(cpuinfo),
|
||||
perCore: win ? win.cores : [],
|
||||
},
|
||||
memory: mem,
|
||||
disks: diskList,
|
||||
homesMount: mountOf(diskList, deps.homesRoot()),
|
||||
gpus: gpuList,
|
||||
usage: use,
|
||||
usage: win ? win.usage : null,
|
||||
network: win ? win.network : [],
|
||||
diskIo: win ? win.diskIo : [],
|
||||
temperatures: temps,
|
||||
pressure: psi,
|
||||
topProcesses: win ? win.topProcesses : [],
|
||||
process: {
|
||||
pid: process.pid,
|
||||
node: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
rss: pmem.rss,
|
||||
heapUsed: pmem.heapUsed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,9 +633,134 @@ async function hostStats() {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
// An in-memory ring of machine-level samples, one every HISTORY_EVERY_MS for
|
||||
// HISTORY_KEEP_MS, started by every server on Linux (server/index.js). It
|
||||
// starts empty at every server start: nothing is written to disk.
|
||||
|
||||
let history = [];
|
||||
let historyTimer = null;
|
||||
|
||||
/** One history point from a snapshot. */
|
||||
function historyPoint(s) {
|
||||
const users = {};
|
||||
for (const g of s.usage?.groups || []) {
|
||||
if (g.kind === "user" && (g.cpu || g.memory))
|
||||
users[g.userId] = { cpu: g.cpu, memory: g.memory };
|
||||
}
|
||||
return {
|
||||
at: s.at,
|
||||
cpu: s.usage ? s.usage.cpu : null,
|
||||
memory: round((s.memory.used / Math.max(1, s.memory.total)) * 100),
|
||||
memoryUsed: s.memory.used,
|
||||
swapUsed: s.memory.swapUsed,
|
||||
load1: round(s.cpu.load[0] || 0),
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
/** Take one sample now (reusing a fresh snapshot) and drop what is too old. */
|
||||
async function sampleHistory() {
|
||||
try {
|
||||
const point = historyPoint(await hostStats());
|
||||
const cutoff = Date.now() - HISTORY_KEEP_MS;
|
||||
history = [...history.filter((p) => Date.parse(p.at) >= cutoff), point];
|
||||
} catch {
|
||||
/* a failed sample is a gap, never a crash */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the sampler (Linux only; idempotent). The timer never holds the process open. */
|
||||
function startHistory() {
|
||||
if (historyTimer || process.platform !== "linux") return;
|
||||
void sampleHistory();
|
||||
historyTimer = setInterval(() => void sampleHistory(), HISTORY_EVERY_MS);
|
||||
historyTimer.unref?.();
|
||||
}
|
||||
|
||||
function stopHistory() {
|
||||
if (historyTimer) clearInterval(historyTimer);
|
||||
historyTimer = null;
|
||||
}
|
||||
|
||||
const RANGES = { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 24 * 3600_000 };
|
||||
|
||||
/**
|
||||
* The points of the last `range` (`1h`, `6h`, `24h`; else 1h), averaged down
|
||||
* to at most HISTORY_MAX_POINTS. `startedAt` = the oldest point kept, so the
|
||||
* UI can say the history begins there.
|
||||
*/
|
||||
function hostHistory(range) {
|
||||
const span = RANGES[range] || RANGES["1h"];
|
||||
const since = Date.now() - span;
|
||||
const pts = history.filter((p) => Date.parse(p.at) >= since);
|
||||
return {
|
||||
range: RANGES[range] ? range : "1h",
|
||||
intervalMs: HISTORY_EVERY_MS,
|
||||
running: historyTimer != null,
|
||||
startedAt: history[0]?.at ?? null,
|
||||
points: downsample(pts, HISTORY_MAX_POINTS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Average consecutive points into at most `max` buckets (numbers only; `at` = the bucket's last). */
|
||||
function downsample(pts, max) {
|
||||
if (pts.length <= max) return pts;
|
||||
const per = Math.ceil(pts.length / max);
|
||||
const out = [];
|
||||
for (let i = 0; i < pts.length; i += per) {
|
||||
const b = pts.slice(i, i + per);
|
||||
const avg = (k) => {
|
||||
const v = b.map((p) => p[k]).filter((x) => x != null);
|
||||
return v.length ? round(v.reduce((a, x) => a + x, 0) / v.length) : null;
|
||||
};
|
||||
const users = {};
|
||||
for (const p of b)
|
||||
for (const [id, u] of Object.entries(p.users)) {
|
||||
const t = (users[id] ||= { cpu: 0, memory: 0 });
|
||||
t.cpu += u.cpu / b.length;
|
||||
t.memory += u.memory / b.length;
|
||||
}
|
||||
for (const u of Object.values(users)) {
|
||||
u.cpu = round(u.cpu);
|
||||
u.memory = Math.round(u.memory);
|
||||
}
|
||||
out.push({
|
||||
at: b[b.length - 1].at,
|
||||
cpu: avg("cpu"),
|
||||
memory: avg("memory"),
|
||||
memoryUsed: Math.round(avg("memoryUsed") ?? 0),
|
||||
swapUsed: Math.round(avg("swapUsed") ?? 0),
|
||||
load1: avg("load1"),
|
||||
users,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function __setDeps(over) {
|
||||
deps = { ...deps, ...over };
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { hostStats, mountOf, __setDeps };
|
||||
/** Tests: replace the history ring. */
|
||||
function __setHistory(points) {
|
||||
history = points;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
startHistory,
|
||||
stopHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file loop-delay.js
|
||||
* @description How long this server's event loop was held up — what an
|
||||
* operator sees as a laggy dashboard or terminal: every request, hook and
|
||||
* keystroke echo waits for it. One `monitorEventLoopDelay` histogram for the
|
||||
* whole process, sampled every 20 ms and reset each minute, so `loopDelay()`
|
||||
* describes the last full minute (the running one until the first minute
|
||||
* ends). Read by `GET /api/metrics` (Prometheus gauges) and
|
||||
* `GET /api/settings/info` (the System Health score).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { monitorEventLoopDelay } = require("perf_hooks");
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const histogram = monitorEventLoopDelay({ resolution: 20 });
|
||||
histogram.enable();
|
||||
|
||||
const read = () => ({
|
||||
p50: histogram.percentile(50),
|
||||
p99: histogram.percentile(99),
|
||||
max: histogram.max,
|
||||
});
|
||||
|
||||
let lastWindow = null; // the last full minute, reported until the next one ends
|
||||
setInterval(() => {
|
||||
lastWindow = read();
|
||||
histogram.reset();
|
||||
}, WINDOW_MS).unref();
|
||||
|
||||
/** `{p50, p99, max}` in nanoseconds over the last minute. */
|
||||
function loopDelay() {
|
||||
return lastWindow || read();
|
||||
}
|
||||
|
||||
module.exports = { loopDelay, WINDOW_MS };
|
||||
@@ -8,7 +8,9 @@
|
||||
* A Claude session counts only for the hours one of THIS account's runs held
|
||||
* it (start hour to end hour), so a session resumed on another account is
|
||||
* not booked twice; Codex has no hourly rows, so its session counts whole
|
||||
* once a run of the account started in the window, as before.
|
||||
* once a run of the account started in the window, as before. With `ownerId`
|
||||
* only the runs of that user's workspaces count — one user's share of the
|
||||
* account, for the Administration page's user detail.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,21 +21,28 @@ const tokensOf = (r) =>
|
||||
|
||||
/**
|
||||
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
|
||||
* `wholeSession`, a session's every row counts in a window its run started in.
|
||||
* `wholeSession`, a session's every row counts in a window its run started in;
|
||||
* with `ownerId`, only runs on that user's workspaces.
|
||||
*/
|
||||
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
|
||||
function accountUsage(
|
||||
tool,
|
||||
account,
|
||||
{ wholeSession = false, now = Date.now(), ownerId = null } = {}
|
||||
) {
|
||||
const { db, stmts } = require("../db");
|
||||
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
|
||||
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
|
||||
const owned = ownerId == null ? "" : "AND lane_id IN (SELECT id FROM lanes WHERE owner_id = ?)";
|
||||
const ownedArgs = ownerId == null ? [] : [ownerId];
|
||||
const runs = db
|
||||
.prepare(
|
||||
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
|
||||
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
|
||||
FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?`
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned}`
|
||||
)
|
||||
.all(tool, account, since7d);
|
||||
.all(tool, account, since7d, ...ownedArgs);
|
||||
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
|
||||
if (runs.length === 0) return out;
|
||||
const runsOf = new Map();
|
||||
@@ -41,8 +50,8 @@ function accountUsage(tool, account, { wholeSession = false, now = Date.now() }
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
|
||||
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
|
||||
AND COALESCE(ended_at, '9999') >= ?)`,
|
||||
[tool, account, since7d]
|
||||
AND COALESCE(ended_at, '9999') >= ? ${owned})`,
|
||||
[tool, account, since7d, ...ownedArgs]
|
||||
);
|
||||
const rules = stmts.listPricing.all();
|
||||
for (const [label, hours] of Object.entries(WINDOWS)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
|
||||
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
|
||||
* and the Dashboard System Health tab's machine cards (`GET /api/host` and `/api/host/history`, server/routes/host.js).
|
||||
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ const tags = [
|
||||
{
|
||||
name: "Host",
|
||||
description:
|
||||
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
|
||||
"Dashboard → System Health: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, every signed-in user.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -385,13 +385,36 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
|
||||
"`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.",
|
||||
content: { "application/json": { schema: { type: "object" } } },
|
||||
},
|
||||
500: { description: "EHOST" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/host/history": {
|
||||
get: {
|
||||
tags: ["Host"],
|
||||
summary: "The machine's CPU/memory history since the server started",
|
||||
operationId: "getHostHistory",
|
||||
description:
|
||||
"In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.",
|
||||
parameters: [
|
||||
{
|
||||
name: "range",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1h", "6h", "24h"], default: "1h" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.",
|
||||
content: { "application/json": { schema: { type: "object" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
|
||||
@@ -565,6 +565,28 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/details": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getServerUserDetails",
|
||||
summary:
|
||||
"One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account's quota beside this user's own 5 h / 7 d share. Never a token",
|
||||
parameters: [
|
||||
idParam,
|
||||
{
|
||||
name: "tz_offset",
|
||||
in: "query",
|
||||
schema: { type: "integer" },
|
||||
description: "The browser's getTimezoneOffset(), for the daily buckets",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: { description: "Account details" },
|
||||
403: err("EADMIN — a developer"),
|
||||
404: err("ENOUSER"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -247,6 +247,15 @@ function createOpenApiSpec() {
|
||||
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) `antigravity` (agy's conversation files and its opt-in hooks) or `cursor` (Cursor's agent transcripts — sessions only, no tokens).",
|
||||
},
|
||||
agent_count: { type: "integer", nullable: true },
|
||||
owner_id: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
description:
|
||||
"Server mode, list only: the account owning the session's workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.",
|
||||
},
|
||||
owner_name: { type: "string", nullable: true, description: "That owner's username." },
|
||||
owner_display_name: { type: "string", nullable: true },
|
||||
owner_avatar_at: { type: "string", format: "date-time", nullable: true },
|
||||
last_activity: { type: "string", format: "date-time", nullable: true },
|
||||
cost: { type: "number", nullable: true },
|
||||
awaiting_input_since: {
|
||||
@@ -1234,6 +1243,32 @@ function createOpenApiSpec() {
|
||||
node_version: { type: "string" },
|
||||
platform: { type: "string" },
|
||||
ws_connections: { type: "integer" },
|
||||
event_loop: {
|
||||
type: "object",
|
||||
description:
|
||||
"Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.",
|
||||
properties: {
|
||||
p50: { type: "number" },
|
||||
p99: { type: "number" },
|
||||
max: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
activity: {
|
||||
type: "object",
|
||||
description:
|
||||
"For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller's rows).",
|
||||
properties: {
|
||||
last_event_at: { type: "string", nullable: true },
|
||||
working_agents: { type: "integer" },
|
||||
agents_24h: {
|
||||
type: "object",
|
||||
properties: {
|
||||
completed: { type: "integer" },
|
||||
error: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
transcript_cache: {
|
||||
|
||||
+12
-5
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* @file host.js
|
||||
* @description `GET /api/host` — the Administration page's Server tab: the
|
||||
* machine's hardware, load and disks, and each AgentHub user's share of its
|
||||
* CPU and memory right now (server/lib/host-stats.js). Read-only; staff only
|
||||
* in server mode (`/host` is in STAFF_ONLY_PREFIXES, server/lib/auth.js).
|
||||
* @description `GET /api/host` — the Dashboard System Health tab's machine
|
||||
* cards, its per-user CPU/RAM card included: the machine's hardware, load and
|
||||
* disks, and each AgentHub user's share of its CPU and memory right now
|
||||
* (server/lib/host-stats.js); `GET /api/host/history` (`?range=1h|6h|24h`) the
|
||||
* in-memory CPU/memory history the server keeps since it started. Read-only;
|
||||
* every signed-in user (`/host` is NOT in STAFF_ONLY_PREFIXES — System Health
|
||||
* is everyone's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { hostStats } = require("../lib/host-stats");
|
||||
const { hostStats, hostHistory } = require("../lib/host-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -20,4 +23,8 @@ router.get("/", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
res.json(hostHistory(typeof req.query.range === "string" ? req.query.range : "1h"));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -29,21 +29,8 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
|
||||
|
||||
const router = Router();
|
||||
|
||||
// How long the event loop was held up — what an operator sees as a laggy
|
||||
// terminal: every keystroke's echo waits for it. Sampled every 20 ms and reset
|
||||
// each minute, so the gauges describe the last minute or so.
|
||||
const LOOP_WINDOW_MS = 60_000;
|
||||
const loopDelay = require("perf_hooks").monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
let loopWindow = null; // the last full minute, reported until the next one ends
|
||||
setInterval(() => {
|
||||
loopWindow = {
|
||||
p50: loopDelay.percentile(50),
|
||||
p99: loopDelay.percentile(99),
|
||||
max: loopDelay.max,
|
||||
};
|
||||
loopDelay.reset();
|
||||
}, LOOP_WINDOW_MS).unref();
|
||||
// Event-loop delay over the last minute (server/lib/loop-delay.js).
|
||||
const { loopDelay } = require("../lib/loop-delay");
|
||||
|
||||
// Resolved once at load — the dashboard version, surfaced as a build-info label
|
||||
// (the canonical Prometheus idiom for exposing a version string as a metric).
|
||||
@@ -116,11 +103,7 @@ router.get("/", (_req, res) => {
|
||||
[{ value: process.memoryUsage().rss }]
|
||||
);
|
||||
|
||||
const loop = loopWindow || {
|
||||
p50: loopDelay.percentile(50),
|
||||
p99: loopDelay.percentile(99),
|
||||
max: loopDelay.max,
|
||||
};
|
||||
const loop = loopDelay();
|
||||
appendMetric(
|
||||
out,
|
||||
"agenthub_event_loop_delay_seconds",
|
||||
|
||||
@@ -252,9 +252,43 @@ router.get("/", (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isServerMode()) attachOwners(rows);
|
||||
res.json({ sessions: rows, limit, offset, total });
|
||||
});
|
||||
|
||||
/**
|
||||
* Server mode: stamp each listed row with its owner — the owner of the
|
||||
* workspace the session belongs to (`sessions.lane_id`, else the lane of a
|
||||
* dashboard run holding it, as `sessionWhere` decides ownership). Only the
|
||||
* page's rows, so one small query per page.
|
||||
*/
|
||||
function attachOwners(rows) {
|
||||
if (!rows.length) return;
|
||||
const ids = rows.map((r) => r.id);
|
||||
const owners = new Map();
|
||||
for (let i = 0; i < ids.length; i += 900) {
|
||||
const chunk = ids.slice(i, i + 900);
|
||||
const found = db
|
||||
.prepare(
|
||||
`SELECT s.id AS session_id, u.id AS owner_id, u.username, u.display_name, u.avatar_at
|
||||
FROM sessions s
|
||||
JOIN lanes l ON l.id = COALESCE(s.lane_id,
|
||||
(SELECT r.lane_id FROM dashboard_runs r WHERE r.session_id = s.id ORDER BY r.rowid DESC LIMIT 1))
|
||||
JOIN users u ON u.id = l.owner_id
|
||||
WHERE s.id IN (${chunk.map(() => "?").join(",")})`
|
||||
)
|
||||
.all(...chunk);
|
||||
for (const o of found) owners.set(o.session_id, o);
|
||||
}
|
||||
for (const row of rows) {
|
||||
const o = owners.get(row.id);
|
||||
row.owner_id = o?.owner_id ?? null;
|
||||
row.owner_name = o?.username ?? null;
|
||||
row.owner_display_name = o?.display_name ?? null;
|
||||
row.owner_avatar_at = o?.avatar_at ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/facets", (req, res) => {
|
||||
const own = sessionWhere(req);
|
||||
const rows = db
|
||||
|
||||
@@ -12,6 +12,7 @@ const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../d
|
||||
const { getConnectionCount } = require("../websocket");
|
||||
const { transcriptCache } = require("./hooks");
|
||||
const { forgetHourly } = require("../lib/hourly-usage");
|
||||
const { loopDelay } = require("../lib/loop-delay");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -129,6 +130,29 @@ router.get("/info", (req, res) => {
|
||||
h1: getCount(60 * 60 * 1000),
|
||||
};
|
||||
|
||||
// What the System Health score needs beyond the machine: when a hook last
|
||||
// landed (silent hooks while agents work = a blind dashboard) and how the
|
||||
// agents that finished in the last 24 h ended.
|
||||
const lastEvent = db
|
||||
.prepare(
|
||||
`SELECT created_at FROM events WHERE ${ownEvents.where} ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.get(...ownEvents.params);
|
||||
const day = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
||||
const agentRows = db
|
||||
.prepare(
|
||||
`SELECT status, COUNT(*) AS c FROM agents
|
||||
WHERE (status = 'working' OR ended_at > ?) AND ${ownEvents.where} GROUP BY status`
|
||||
)
|
||||
.all(day, ...ownEvents.params);
|
||||
const agentCount = (st) => agentRows.find((r) => r.status === st)?.c ?? 0;
|
||||
const activity = {
|
||||
last_event_at: lastEvent?.created_at ?? null,
|
||||
working_agents: agentCount("working"),
|
||||
agents_24h: { completed: agentCount("completed"), error: agentCount("error") },
|
||||
};
|
||||
const loop = loopDelay();
|
||||
|
||||
// A developer (server mode) gets the numbers, never a path of the host's:
|
||||
// the transcript cache is keyed by EVERY user's transcript path (their
|
||||
// folders, their session ids), and the DB/settings paths are the host's.
|
||||
@@ -158,7 +182,10 @@ router.get("/info", (req, res) => {
|
||||
total_mem: os.totalmem(),
|
||||
free_mem: os.freemem(),
|
||||
cpus: os.cpus().length,
|
||||
// Event-loop delay over the last minute, in ms (server/lib/loop-delay.js).
|
||||
event_loop: { p50: loop.p50 / 1e6, p99: loop.p99 / 1e6, max: loop.max / 1e6 },
|
||||
},
|
||||
activity,
|
||||
transcript_cache: developer ? { ...cache, keys: undefined } : cache,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
* (server/lib/member-tmux.js); a restart ends that member's panes.
|
||||
*
|
||||
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
|
||||
* `GET /:id/details` everything the Administration page's user detail shows —
|
||||
* the account and its logins, its workspaces (sessions, last activity, live
|
||||
* panes), the panes running now, its newest sessions, spend per local day /
|
||||
* AI tool / model over 30 days, the audit entries naming it, and per pool
|
||||
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
|
||||
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
|
||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||
* owner groups — staff only like the rest of `/users`.
|
||||
*
|
||||
@@ -217,6 +223,250 @@ router.get("/:id", (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const DAY_MS = 864e5;
|
||||
const tokensOf = (r) =>
|
||||
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
||||
|
||||
/** `{cost, tokens}` of token rows, priced by `calculateCost`. */
|
||||
function priced(rows, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
return {
|
||||
cost: rows.length ? calculateCost(rows, rules).total_cost : 0,
|
||||
tokens: Math.round(rows.reduce((n, r) => n + tokensOf(r), 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows grouped by `keyOf`, each group priced: `[{key, cost, tokens, sessions}]`, $ first. */
|
||||
function breakdown(rows, keyOf, rules) {
|
||||
const groups = new Map();
|
||||
for (const r of rows) {
|
||||
const k = keyOf(r) || "unknown";
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push(r);
|
||||
}
|
||||
return [...groups]
|
||||
.map(([key, list]) => ({
|
||||
key,
|
||||
...priced(list, rules),
|
||||
sessions: new Set(list.map((r) => r.session_id)).size,
|
||||
}))
|
||||
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
||||
}
|
||||
|
||||
/** Per pool tool: what `uid` may run on, and their own share of each account. */
|
||||
async function poolFor(uid) {
|
||||
const pins = require("../lib/account-pins");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const { accountUsage } = require("../lib/pool-usage");
|
||||
const pools = {
|
||||
claude: require("../lib/claude-pool"),
|
||||
codex: require("../lib/codex-pool"),
|
||||
cursor: require("../lib/cursor-pool"),
|
||||
};
|
||||
await pools.claude.refreshQuotas().catch(() => undefined);
|
||||
const group = db
|
||||
.prepare(
|
||||
`SELECT g.id, g.name FROM pool_group_users u JOIN pool_groups g ON g.id = u.group_id
|
||||
WHERE u.user_id = ?`
|
||||
)
|
||||
.get(uid);
|
||||
const since7d = new Date(Date.now() - 7 * DAY_MS).toISOString();
|
||||
const used = db
|
||||
.prepare(
|
||||
`SELECT r.tool, r.account, COUNT(*) AS runs, MAX(r.started_at) AS lastAt,
|
||||
SUM(r.status IN ('running', 'spawning')) AS live
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.account IS NOT NULL AND r.started_at >= ?
|
||||
GROUP BY r.tool, r.account ORDER BY lastAt DESC`
|
||||
)
|
||||
.all(uid, since7d);
|
||||
const out = {};
|
||||
for (const tool of pins.TOOLS) {
|
||||
const pool = pools[tool];
|
||||
if (!pool) continue;
|
||||
let status = [];
|
||||
try {
|
||||
status = pool.poolStatus();
|
||||
} catch {
|
||||
status = [];
|
||||
}
|
||||
const ok = groups.allowed(uid, tool);
|
||||
const pin = pins.pinFor(uid, tool);
|
||||
const ranOn = used.filter((u) => u.tool === tool);
|
||||
const wholeSession = tool !== "claude";
|
||||
out[tool] = {
|
||||
pin,
|
||||
group: group ? { id: group.id, name: group.name } : null,
|
||||
accounts: status
|
||||
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))
|
||||
.map((a) => {
|
||||
const mine = accountUsage(tool, a.name, { wholeSession, ownerId: uid });
|
||||
const ran = ranOn.find((u) => u.account === a.name);
|
||||
return {
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
email: a.email ?? null,
|
||||
expired: Boolean(a.expired),
|
||||
allowed: ok(a.name),
|
||||
pinned: a.name === pin,
|
||||
quota: a.quota ?? null,
|
||||
quotaPercent: a.quotaPercent,
|
||||
full: a.full,
|
||||
livePanes: a.livePanes,
|
||||
spend5h: a.spend5h,
|
||||
spend7d: a.spend7d,
|
||||
userSpend5h: mine.spend5h,
|
||||
userSpend7d: mine.spend7d,
|
||||
userTokens5h: mine.tokens5h,
|
||||
userTokens7d: mine.tokens7d,
|
||||
userRuns7d: ran?.runs ?? 0,
|
||||
userLivePanes: ran?.live ?? 0,
|
||||
userLastAt: ran?.lastAt ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
router.get("/:id/details", async (req, res) => {
|
||||
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
||||
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
|
||||
try {
|
||||
const { hourlyTokenRows } = require("./pricing");
|
||||
const rules = stmts.listPricing.all();
|
||||
const uid = u.id;
|
||||
const owned = `SELECT session_id FROM (${OWNED_SESSIONS}) WHERE uid = ?`;
|
||||
const off = parseInt(req.query.tz_offset, 10);
|
||||
const offMin = Number.isFinite(off) && Math.abs(off) <= 840 ? off : 0;
|
||||
// A UTC hour (`YYYY-MM-DDTHH`) → the viewer's local date.
|
||||
const localDay = (hour) =>
|
||||
new Date(Date.parse(`${hour}:00:00Z`) - offMin * 60e3).toISOString().slice(0, 10);
|
||||
|
||||
const since30 = hourAgo(30);
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (${owned})`,
|
||||
[since30, uid]
|
||||
).filter((r) => r.date >= since30);
|
||||
const today = todayHour(offMin);
|
||||
const since7 = hourAgo(7);
|
||||
|
||||
// One bar per local day, the oldest first, days with no spend included.
|
||||
const byDay = new Map();
|
||||
for (const r of rows) {
|
||||
const d = localDay(r.date);
|
||||
if (!byDay.has(d)) byDay.set(d, []);
|
||||
byDay.get(d).push(r);
|
||||
}
|
||||
const daily = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - offMin * 60e3 - i * DAY_MS).toISOString().slice(0, 10);
|
||||
daily.push({ date: d, ...priced(byDay.get(d) || [], rules) });
|
||||
}
|
||||
|
||||
const workspaces = db
|
||||
.prepare(
|
||||
`SELECT l.id, l.title, l.cwd, l.tool, l.kind, l.branch, l.status, l.created_at AS createdAt,
|
||||
(SELECT COUNT(*) FROM (${OWNED_SESSIONS}) o JOIN sessions s ON s.id = o.session_id
|
||||
WHERE o.uid = l.owner_id AND (s.lane_id = l.id OR s.id IN
|
||||
(SELECT session_id FROM dashboard_runs WHERE lane_id = l.id))) AS sessions,
|
||||
(SELECT MAX(s.updated_at) FROM sessions s WHERE s.lane_id = l.id) AS lastActiveAt,
|
||||
(SELECT COUNT(*) FROM dashboard_runs r
|
||||
WHERE r.lane_id = l.id AND r.status IN ('running', 'spawning')) AS livePanes
|
||||
FROM lanes l WHERE l.owner_id = ? ORDER BY lastActiveAt DESC, l.id`
|
||||
)
|
||||
.all(uid);
|
||||
|
||||
const panes = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.lane_id AS laneId, l.title AS laneTitle, l.cwd AS laneCwd, r.tool,
|
||||
r.model, r.account, r.proxy, r.session_id AS sessionId, r.started_at AS startedAt,
|
||||
r.status
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.status IN ('running', 'spawning')
|
||||
ORDER BY r.started_at DESC`
|
||||
)
|
||||
.all(uid);
|
||||
|
||||
const recent = db
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, s.tool, s.model, s.status, s.cwd, s.lane_id, s.started_at,
|
||||
s.ended_at, s.updated_at AS last_activity, s.awaiting_input_since, s.awaiting_reason
|
||||
FROM sessions s WHERE s.id IN (${owned})
|
||||
ORDER BY s.updated_at DESC LIMIT 15`
|
||||
)
|
||||
.all(uid);
|
||||
if (recent.length) {
|
||||
const cost = new Map(
|
||||
breakdown(
|
||||
hourlyTokenRows(
|
||||
`WHERE s.id IN (${recent.map(() => "?").join(",")})`,
|
||||
recent.map((r) => r.id)
|
||||
),
|
||||
(r) => r.session_id,
|
||||
rules
|
||||
).map((b) => [b.key, b])
|
||||
);
|
||||
for (const r of recent) {
|
||||
r.cost = cost.get(r.id)?.cost ?? 0;
|
||||
r.tokens = cost.get(r.id)?.tokens ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const counts = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total, SUM(s.status = 'active') AS active,
|
||||
MIN(s.started_at) AS firstAt, MAX(s.updated_at) AS lastAt
|
||||
FROM sessions s WHERE s.id IN (${owned})`
|
||||
)
|
||||
.get(uid);
|
||||
|
||||
const audit = db
|
||||
.prepare(
|
||||
`SELECT a.id, a.at, a.method, a.path, a.session_id,
|
||||
r.username AS reader, o.username AS owner,
|
||||
r.display_name AS reader_display, o.display_name AS owner_display
|
||||
FROM audit_log a
|
||||
LEFT JOIN users r ON r.id = a.user_id
|
||||
LEFT JOIN users o ON o.id = a.owner_id
|
||||
WHERE a.user_id = ? OR a.owner_id = ?
|
||||
ORDER BY a.id DESC LIMIT 30`
|
||||
)
|
||||
.all(uid, uid);
|
||||
|
||||
res.json({
|
||||
user: { ...auth.publicUser(u), ...auth.loginStats(uid) },
|
||||
sessions: {
|
||||
total: counts.total || 0,
|
||||
active: counts.active || 0,
|
||||
firstAt: counts.firstAt || null,
|
||||
lastAt: counts.lastAt || null,
|
||||
},
|
||||
usage: {
|
||||
today: priced(
|
||||
rows.filter((r) => r.date >= today),
|
||||
rules
|
||||
),
|
||||
last7d: priced(
|
||||
rows.filter((r) => r.date >= since7),
|
||||
rules
|
||||
),
|
||||
last30d: priced(rows, rules),
|
||||
daily,
|
||||
byTool: breakdown(rows, (r) => r.tool || "claude", rules),
|
||||
byModel: breakdown(rows, (r) => r.model, rules),
|
||||
},
|
||||
workspaces,
|
||||
panes,
|
||||
recentSessions: recent,
|
||||
pool: await poolFor(uid),
|
||||
audit,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: e.code || "EDETAILS", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id/avatar", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user