/** * @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ĩ */ 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 (
); } function uptimeText(t: (k: string, o?: Record) => 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(null); const [error, setError] = useState(null); const [users, setUsers] = useState(null); const [storage, setStorage] = useState | null>(null); const [open, setOpen] = useState>(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 (

{stats?.hostname ?? t("server.title")}

{stats && (

{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")} {" · "} {t("server.uptime", { v: uptimeText(t, stats.uptime) })}

)}
{error && (
{error}
)}
l.toFixed(2)).join(" "), }) : undefined } raw={stats?.cpu.model ?? undefined} help={t("server.cpuHelp")} loading={loading} />

{t("server.usageTitle")}

{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}

{stats && !stats.usage ? (

{t("server.usageUnavailable")}

) : ( {loading && ( )} {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 ( {isOpen && g.top.map((p) => ( ))} ); })}
{t("server.who")} {t("server.cpu")} {t("server.memory")} {t("server.processes")} {t("server.home")}
{t("server.sampling")}
{fmtPct(g.cpu)}
{formatBytes(g.memory)}{" "} ({fmtPct(g.memoryPercent)})
{g.processes} {home ? ( <>
{formatBytes(home.bytes)} {homesDisk && ( {" "} ({fmtPct(pct(home.bytes, homesDisk.total))}) )}
{homesDisk && ( )} ) : ( — )}
{p.name} #{p.pid} {fmtPct(p.cpu)} {formatBytes(p.memory)}
)}
{stats && stats.disks.length > 0 && (

{t("server.disks")}

{stats.disks.map((d) => ( ))}
{t("server.mount")} {t("server.device")} {t("server.used")} {t("server.free")}
{d.mount} {d.mount === stats.homesMount && ( {t("server.homesBadge")} )} {d.device} · {d.type} {d.kind && {d.kind}}
{formatBytes(d.used)} / {formatBytes(d.total)}{" "} ({fmtPct(pct(d.used, d.total))})
{formatBytes(d.free)}
)} {stats && stats.gpus.length > 0 && (

{t("server.gpus")}

{stats.gpus.map((g, i) => (
{g.name} {fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "} {formatBytes(g.memoryTotal)}
))}
)}
); }