b6b40b0c1b
/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.
418 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|