Files
AgentHub/client/src/components/admin/ServerPanel.tsx
T
nntrivi2001 b6b40b0c1b feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)
/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
2026-10-15 16:33:32 +07:00

418 lines
17 KiB
TypeScript

/**
* @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>
);
}