Files
AgentHub/client/src/components/health/HostHealth.tsx
T
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00

727 lines
27 KiB
TypeScript

/**
* @file HostHealth.tsx
* @description The machine AgentHub runs on, as part of the Dashboard's
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
* signed-in user sees all of it). In the tab's own card style:
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
* CPU / memory history (`GET /api/host/history`, kept in memory since the
* server started); then CPU (every thread as a compact cell, temperatures,
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
* Read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
ArrowDown,
ArrowUp,
Cpu,
HardDrive,
ListTree,
Network,
Server,
Thermometer,
Users,
} from "lucide-react";
import {
api,
type HostHistory,
type HostHistoryRange,
type HostStats,
type HostUsageGroup,
} from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Tip } from "../Tip";
import { MiniChart } from "../admin/MiniChart";
import { UserAvatar } from "../users/UserAvatar";
const POLL_MS = 5000;
const HISTORY_POLL_MS = 30000;
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
export type HostState = {
stats: HostStats | null;
error: string | null;
reload: () => Promise<void>;
};
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
const [stats, setStats] = useState<HostStats | null>(null);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(
() =>
api.host
.stats()
.then((s) => {
setStats(s);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
useEffect(() => {
if (!enabled) return;
void reload();
if (!pollMs) return;
const id = setInterval(() => {
if (document.visibilityState === "visible") void reload();
}, pollMs);
return () => clearInterval(id);
}, [enabled, pollMs, reload]);
return { stats, error, reload };
}
/** A thin bar, amber past 75 %, red past 90 %. */
export function Meter({ value, label }: { value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
return (
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
>
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
</div>
);
}
/** The System Health tab's own bar: h-2, green / amber / red by level. */
function HealthBar({
label,
value,
tip,
warn = 70,
bad = 90,
}: {
label: string;
value: number | null;
tip?: string;
warn?: number;
bad?: number;
}) {
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
return (
<Tip block raw={tip}>
<div className="space-y-1">
<div className="flex justify-between text-[10px]">
<span className="text-fg-muted">{label}</span>
<span className="text-fg-secondary font-mono">
{value == null ? "—" : `${Math.round(v)}%`}
</span>
</div>
<div
role="meter"
aria-label={label}
aria-valuenow={Math.round(v)}
aria-valuemin={0}
aria-valuemax={100}
className="w-full bg-surface-3 rounded-full h-2"
>
<div
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
style={{ width: `${v}%` }}
/>
</div>
</div>
</Tip>
);
}
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
const v = Math.max(0, Math.min(100, value));
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
return (
<div className="space-y-0.5">
<div className="flex justify-between text-[10px] font-mono">
<span className="text-fg-muted">{n}</span>
<span className={tone}>{Math.round(v)}%</span>
</div>
<Meter value={v} label={label} />
</div>
);
}
function CardHead({
icon: Icon,
tone,
title,
right,
}: {
icon: ComponentType<{ className?: string }>;
tone: string;
title: string;
right?: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
</div>
{right}
</div>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
{children}
</span>
</div>
);
}
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
}
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
export type HeapInfo = {
heapUsed: number;
heapTotal: number;
heapLimit?: number;
external: number;
};
/**
* The tab's first card: the machine and the AgentHub process on it.
* `heap` comes from the tab's settings read.
*/
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const s = host.stats;
const mem = s?.memory;
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
return (
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
<CardHead
icon={Server}
tone="text-status-success"
title={t("host.title")}
right={
s && (
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
{s.hostname}
</span>
)
}
/>
{!s ? (
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
) : (
<>
<div className="space-y-2.5">
<Row label={t("host.os")}>
{s.os.distro ?? s.os.platform} · {s.os.release}
</Row>
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
<Row label={t("host.cpuModel")}>
<Tip raw={s.cpu.model ?? undefined}>
<span>
{s.cpu.physicalCores
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
: ""}
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
</span>
</Tip>
</Row>
{s.os.platform !== "win32" && (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("host.load")}
</span>
<div className="flex gap-1 ml-auto">
{s.cpu.load.slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
)}
{s.process && (
<Row label={t("host.agenthub")}>
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
<span>
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
</span>
</Tip>
</Row>
)}
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
{s.usage && (
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
)}
<HealthBar
label={t("host.memory")}
value={mem ? pct(mem.used, mem.total) : null}
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
/>
{mem && mem.swapTotal > 0 && (
<HealthBar
label={t("host.swap")}
value={pct(mem.swapUsed, mem.swapTotal)}
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
/>
)}
{heap && (
<HealthBar
label={t("health.heap")}
value={heapPct}
warn={60}
bad={85}
tip={t("health.heapTip", {
pct: (heapPct ?? 0).toFixed(1),
used: formatBytes(heap.heapUsed),
limit: formatBytes(heapMax),
allocated: formatBytes(heap.heapTotal),
external: formatBytes(heap.external),
})}
/>
)}
</div>
</>
)}
</div>
);
}
/** The tab's machine group: host + history, CPU / disks / processes, users. */
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
const { t } = useTranslation("dashboard");
const { stats } = host;
const [range, setRange] = useState<HostHistoryRange>("1h");
const [history, setHistory] = useState<HostHistory | null>(null);
// The user tile whose busiest processes are shown.
const [open, setOpen] = useState<string | null>(null);
const loadHistory = useCallback(
() =>
api.host
.history(range)
.then(setHistory)
.catch(() => setHistory(null)),
[range]
);
useEffect(() => {
void loadHistory();
const id = setInterval(() => {
if (document.visibilityState === "visible") void loadHistory();
}, HISTORY_POLL_MS);
return () => clearInterval(id);
}, [loadHistory]);
// Same tree as the full render below, so the card is not remounted once stats arrive.
if (!stats)
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
</div>
</>
);
const groups = stats.usage?.groups ?? [];
const groupName = (g: HostUsageGroup) => {
if (g.kind === "host") return t("host.hostGroup");
if (g.kind === "system") return t("host.systemGroup");
return g.displayName || g.username || "";
};
const groupByKey = new Map(groups.map((g) => [g.key, g]));
const ownerOf = (key: string) => {
const g = groupByKey.get(key);
if (g) return groupName(g);
if (key === "host") return t("host.hostGroup");
if (key === "system") return t("host.systemGroup");
return key;
};
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
const timeLabel = (iso: string) =>
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const histPoints = (key: "cpu" | "memory") =>
(history?.points ?? [])
.filter((p) => p[key] != null)
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
const perCore = stats.cpu.perCore ?? [];
const network = stats.network ?? [];
const temps = stats.temperatures ?? [];
const topProcs = stats.topProcesses ?? [];
const others = groups.filter((g) => g.kind !== "user");
const people = groups.filter((g) => g.kind === "user");
// History, per-thread CPU and processes are read from /proc: on Windows and
// macOS their cards would only ever be empty, so they are left out.
const linux = stats.os.platform === "linux";
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
const showProcs = !!stats.usage || others.length > 0;
// A whole disk's I/O goes on the first mounted file system that lives on it
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
// mounted gets a row of its own.
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
const diskRows = stats.disks.map((d) => {
const base = d.device.split("/").pop() ?? "";
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
const io = key ? ioLeft.get(key) : undefined;
if (key) ioLeft.delete(key);
return { disk: d, io };
});
const loneIo = [...ioLeft.values()];
const rate = (down?: number, up?: number) => (
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
<span className="inline-flex items-center gap-0.5">
<ArrowDown className="w-2.5 h-2.5" />
{down == null ? "—" : perSec(down)}
</span>
<span className="inline-flex items-center gap-0.5">
<ArrowUp className="w-2.5 h-2.5" />
{up == null ? "—" : perSec(up)}
</span>
</span>
);
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<HostCard host={host} heap={heap} />
{/* CPU and memory over time. */}
{linux && (
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
<CardHead
icon={Activity}
tone="text-accent"
title={t("host.historyTitle")}
right={
<div
role="group"
aria-label={t("host.range")}
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
>
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
range === r
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{t(`host.range${r}`)}
</button>
))}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
<MiniChart
kind="line"
points={histPoints("cpu")}
max={100}
format={fmtPct}
label={t("host.historyCpu")}
emptyText={t("host.historyNone")}
/>
</div>
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
<MiniChart
kind="line"
points={histPoints("memory")}
max={100}
format={fmtPct}
label={t("host.historyMemory")}
emptyText={t("host.historyNone")}
/>
</div>
</div>
{history?.startedAt && (
<p className="text-[10px] text-fg-muted">
{t("host.historyHelp", {
s: history.intervalMs / 1000,
at: formatDateTime(history.startedAt),
})}
</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
{showCores && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
</div>
{temps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
{s.label} <span className="text-fg-muted">· {s.chip}</span>
</span>
<span
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
s.celsius >= 90
? "bg-status-danger/20 text-status-danger"
: s.celsius >= 75
? "bg-status-warning/20 text-status-warning"
: "bg-surface-3 text-fg-secondary"
}`}
>
{s.celsius.toFixed(0)} °C
</span>
</div>
))}
</div>
)}
{stats.gpus.length > 0 && (
<div className="border-t border-border/40 pt-3 space-y-3">
{stats.gpus.map((g, i) => (
<HealthBar
key={i}
label={g.name}
value={g.utilization}
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
/>
))}
</div>
)}
</div>
)}
{/* Disks (with their I/O) and network interfaces. */}
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
<CardHead
icon={HardDrive}
tone="text-syntax-builtin"
title={t("host.storageNetwork")}
/>
<div className="space-y-3" data-testid="server-disk-io">
{diskRows.map(({ disk: d, io }) => (
<div key={d.device} className="space-y-1">
<HealthBar
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
value={pct(d.used, d.total)}
warn={75}
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
/>
<div className="flex justify-between gap-2">
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(d.free)} {t("host.free")}
</span>
{io && rate(io.read, io.write)}
</div>
</div>
))}
{loneIo.map((io) => (
<div key={io.name} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
<span className="ml-auto">{rate(io.read, io.write)}</span>
</div>
))}
</div>
{network.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-network"
>
{network.map((n) => (
<div key={n.name} className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
<Network className="w-3 h-3 text-fg-muted" />
<span className="font-mono">{n.name}</span>
</span>
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
</div>
))}
</div>
)}
</div>
)}
{/* The busiest processes, and what belongs to no user. */}
{showProcs && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
<CardHead
icon={ListTree}
tone="text-syntax-function"
title={t("host.topProcesses")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
</span>
}
/>
{!stats.usage ? (
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
) : (
<div className="space-y-2">
{topProcs.map((p) => (
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-mono text-fg-secondary">{p.name}</span>
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
</span>
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(p.memory)}
</span>
<span
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
>
{fmtPct(p.cpu)}
</span>
</div>
</Tip>
))}
</div>
)}
{others.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-1.5"
data-testid="server-usage-table"
>
{others.map((g) => (
<div key={g.key} className="flex items-center gap-3 text-[10px]">
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
{groupName(g)}
</span>
<span className="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Each user's live share of the machine (server mode). */}
{people.length > 0 && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
<CardHead
icon={Users}
tone="text-syntax-string"
title={t("host.usersTitle")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.usersCount", { n: people.length })}
</span>
}
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
{people.map((g) => {
const name = groupName(g);
const isOpen = open === g.key;
return (
<div key={g.key} className="space-y-2" data-testid="server-user">
<button
type="button"
onClick={() => setOpen(isOpen ? null : g.key)}
disabled={!g.top.length}
aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
>
<UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
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>
)}
</>
);
}