Merge branch 'feat/admin-console': Administration page (Users + Server tabs)
This commit is contained in:
+7
-3
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const Administration = lazy(() =>
|
||||
import("./pages/Administration").then((m) => ({ default: m.Administration }))
|
||||
);
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
@@ -237,13 +239,15 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
path="admin"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
<Administration />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
{/* The Users page became Administration's first tab. */}
|
||||
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -479,11 +479,11 @@ export function Sidebar({
|
||||
{[
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
// Server mode: Administration (users, server), for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Users page's tmux card (one Linux user per account only,
|
||||
* @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.
|
||||
|
||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -87,5 +87,6 @@
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"admin": "Quản trị",
|
||||
"files": "Quản lý file"
|
||||
}
|
||||
|
||||
@@ -569,6 +569,46 @@ export interface MemberTmux {
|
||||
users: MemberTmuxRow[];
|
||||
}
|
||||
|
||||
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
|
||||
export interface HostUsageGroup {
|
||||
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
|
||||
key: string;
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
memory: number;
|
||||
memoryPercent: number;
|
||||
processes: number;
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: 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[] };
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
mount: string;
|
||||
type: string;
|
||||
kind: "HDD" | "SSD" | null;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
}[];
|
||||
/** The mount holding the users' homes, or null. */
|
||||
homesMount: string | null;
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
export type UserStorage = {
|
||||
bytes: number;
|
||||
@@ -918,6 +958,10 @@ 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). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-11
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* @description The Users tab 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 in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
@@ -9,9 +10,9 @@
|
||||
* 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`), and (one Linux user per account) each member's own
|
||||
* tmux server beside the installed version, restartable by an admin
|
||||
* (`MemberTmuxCard`). Every change asks for the admin's OWN password again (the
|
||||
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* 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; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
@@ -51,7 +52,6 @@ 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 { MemberTmuxCard } from "../components/users/MemberTmuxCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -59,7 +59,7 @@ import { copyText } from "../lib/clipboard";
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export function Users() {
|
||||
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
@@ -132,11 +132,16 @@ export function Users() {
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* 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>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,7 +351,6 @@ export function Users() {
|
||||
})()}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @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.
|
||||
* @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 { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, HostStats } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
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() },
|
||||
users: {
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
storage: vi.fn(),
|
||||
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||
avatarUrl: () => null,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Administration } from "../Administration";
|
||||
|
||||
const renderAt = (url: string) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<Routes>
|
||||
<Route path="/admin" element={<Administration />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
api.host.stats.mockResolvedValue(stats);
|
||||
api.users.list.mockResolvedValue({
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: "boss",
|
||||
role: "admin",
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
displayName: "Mia",
|
||||
disabled: false,
|
||||
workspaces: 1,
|
||||
sessions: 2,
|
||||
last7d: { cost: 0, tokens: 0 },
|
||||
last30d: { cost: 0, tokens: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
api.users.audit.mockResolvedValue({ entries: [] });
|
||||
api.users.storage.mockResolvedValue({
|
||||
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
|
||||
measuring: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Administration", () => {
|
||||
it("sends local mode and a developer home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "developer" } };
|
||||
renderAt("/admin");
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens on the Users tab", 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");
|
||||
});
|
||||
|
||||
it("shows hardware and each user's share on the Server tab", 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();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -85,7 +85,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` included) with its workspaces, sessions and 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), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`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` included) with its workspaces, sessions and 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 (`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}`), `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), `POST /api/users/:id/password` (`{password}`; that user's logins end) `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). 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.
|
||||
|
||||
|
||||
+17
-3
@@ -153,7 +153,21 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
Users page in the sidebar shows it, with the audit log under it.
|
||||
**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
|
||||
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
|
||||
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.
|
||||
- `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
|
||||
@@ -700,7 +714,7 @@ 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`.
|
||||
- **Users → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
- **Administration → Server → Members' tmux** (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
|
||||
@@ -783,7 +797,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 Users → Members' tmux shows it with
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux 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`.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @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`).
|
||||
* @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 linux = process.platform === "linux";
|
||||
|
||||
test("mountOf picks the longest mount point holding the path", () => {
|
||||
const list = [{ mount: "/" }, { mount: "/srv" }, { mount: "/srv/users" }];
|
||||
assert.equal(mountOf(list, "/srv/users/alice"), "/srv/users");
|
||||
assert.equal(mountOf(list, "/srv/usersx"), "/srv");
|
||||
assert.equal(mountOf(list, "/home/x"), "/");
|
||||
assert.equal(mountOf(list, null), null);
|
||||
});
|
||||
|
||||
test("hostStats reports hardware and attributes processes by uid", { skip: !linux }, async () => {
|
||||
const me = process.getuid();
|
||||
__setDeps({
|
||||
users: () => [
|
||||
{ id: 7, username: "zed" },
|
||||
{ id: 8, username: "idle" },
|
||||
],
|
||||
memberNameForUid: (uid) => (uid === me ? "zed" : null),
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.ok(r.cpu.cores > 0);
|
||||
assert.ok(r.memory.total > 0 && r.memory.used >= 0);
|
||||
assert.ok(Array.isArray(r.disks));
|
||||
const zed = r.usage.groups.find((g) => g.key === "user:7");
|
||||
assert.ok(zed.processes > 0, "this test's own process belongs to zed");
|
||||
assert.ok(zed.memory > 0);
|
||||
assert.ok(zed.top.every((t) => typeof t.name === "string" && !("argv" in t)));
|
||||
const idle = r.usage.groups.find((g) => g.key === "user:8");
|
||||
assert.deepEqual([idle.cpu, idle.memory, idle.processes], [0, 0, 0]);
|
||||
assert.equal(
|
||||
r.usage.groups.find((g) => g.key === "host"),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
"a host process with a workspace's AGENTHUB_LANE_ID counts for its owner",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const child = spawn("sleep", ["5"], { env: { ...process.env, AGENTHUB_LANE_ID: "4242" } });
|
||||
try {
|
||||
__setDeps({
|
||||
users: () => [{ id: 3, username: "own" }],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: (id) => (id === 4242 ? 3 : null),
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
const own = r.usage.groups.find((g) => g.key === "user:3");
|
||||
assert.ok(own.top.some((t) => t.pid === child.pid));
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -129,6 +129,7 @@ const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
const { authGuard, isServerMode, hasAdmin } = require("./lib/auth");
|
||||
const { scopeGuard } = require("./lib/scope");
|
||||
@@ -179,6 +180,7 @@ function createApp() {
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/accounts", accountsRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/host", hostRouter);
|
||||
|
||||
app.use("/api/sessions", sessionsRouter);
|
||||
app.use("/api/agents", agentsRouter);
|
||||
|
||||
@@ -89,6 +89,8 @@ const STAFF_ONLY_PREFIXES = [
|
||||
"/alerts/rules",
|
||||
"/users",
|
||||
"/accounts",
|
||||
// The machine's hardware and every user's CPU/memory share.
|
||||
"/host",
|
||||
];
|
||||
// The run form's `/` autocomplete lists commands and skills.
|
||||
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* @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
|
||||
* each AgentHub user is using right now. Read-only — nothing here signals,
|
||||
* renices or kills a process.
|
||||
*
|
||||
* Hardware comes from `os` plus `/proc` (meminfo, stat, mounts) and `statfs`
|
||||
* per real mounted file system (HDD/SSD from `/sys/block/<dev>/queue/rotational`);
|
||||
* a GPU only when `nvidia-smi` answers within 3 s. Usage is two `/proc`
|
||||
* samples `SAMPLE_MS` apart: a process's CPU % is its utime+stime delta over
|
||||
* the whole machine's jiffies delta (100 % = every core busy), its memory its
|
||||
* RSS. A process is attributed, in order, to: the AgentHub user of its uid
|
||||
* (`ah-<name>` under S3, `memberNameForUid`); for a process of the host
|
||||
* account, the OWNER of the workspace named by its inherited
|
||||
* `AGENTHUB_LANE_ID` (every pane gets it through tmux `-e`, so an agent's
|
||||
* 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
|
||||
* shared by concurrent callers and cached for `FRESH_MS`. Linux only: other
|
||||
* platforms get the `os` facts with `usage: null`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const fsp = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
const SAMPLE_MS = 1000;
|
||||
const FRESH_MS = 3000;
|
||||
const TOP_PER_GROUP = 5;
|
||||
// File systems that hold data; pseudo and loop mounts are left out.
|
||||
const REAL_FS = new Set([
|
||||
"ext2",
|
||||
"ext3",
|
||||
"ext4",
|
||||
"xfs",
|
||||
"btrfs",
|
||||
"zfs",
|
||||
"f2fs",
|
||||
"vfat",
|
||||
"exfat",
|
||||
"ntfs",
|
||||
"ntfs3",
|
||||
"fuseblk",
|
||||
"nfs",
|
||||
"nfs4",
|
||||
"cifs",
|
||||
]);
|
||||
|
||||
const readText = (file) => fsp.readFile(file, "utf8").catch(() => null);
|
||||
|
||||
/** `/proc/meminfo` in bytes: total, available, swap. */
|
||||
async function memory() {
|
||||
const text = await readText("/proc/meminfo");
|
||||
const kb = (key) => {
|
||||
const m = text && new RegExp(`^${key}:\\s+(\\d+)`, "m").exec(text);
|
||||
return m ? Number(m[1]) * 1024 : null;
|
||||
};
|
||||
const total = kb("MemTotal") ?? os.totalmem();
|
||||
const available = kb("MemAvailable") ?? os.freemem();
|
||||
return {
|
||||
total,
|
||||
available,
|
||||
used: total - available,
|
||||
swapTotal: kb("SwapTotal") ?? 0,
|
||||
swapUsed: (kb("SwapTotal") ?? 0) - (kb("SwapFree") ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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) };
|
||||
}
|
||||
|
||||
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
|
||||
function diskKind(device) {
|
||||
if (!device.startsWith("/dev/")) return null;
|
||||
let name = path.basename(device);
|
||||
try {
|
||||
name = path.basename(fs.realpathSync(device));
|
||||
} catch {
|
||||
/* a mapper name that does not resolve: try it as is */
|
||||
}
|
||||
// A partition's queue lives on its parent: sda1 → sda, nvme0n1p2 → nvme0n1.
|
||||
const candidates = [name, name.replace(/p?\d+$/, "")];
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const v = fs.readFileSync(`/sys/block/${c}/queue/rotational`, "utf8").trim();
|
||||
return v === "1" ? "HDD" : "SSD";
|
||||
} catch {
|
||||
/* next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every real mounted file system, one row per device. */
|
||||
async function disks() {
|
||||
const text = await readText("/proc/mounts");
|
||||
if (!text) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const [device, rawMount, type] = line.split(" ");
|
||||
if (!device || !REAL_FS.has(type) || seen.has(device)) continue;
|
||||
if (device.startsWith("/dev/loop")) continue;
|
||||
const mount = rawMount.replace(/\\040/g, " ");
|
||||
if (mount.startsWith("/snap/") || mount.startsWith("/boot/efi")) continue;
|
||||
seen.add(device);
|
||||
try {
|
||||
const st = await fsp.statfs(mount);
|
||||
const total = st.blocks * st.bsize;
|
||||
if (!total) continue;
|
||||
const free = st.bavail * st.bsize;
|
||||
out.push({
|
||||
device,
|
||||
mount,
|
||||
type,
|
||||
kind: diskKind(device),
|
||||
total,
|
||||
used: total - st.bfree * st.bsize,
|
||||
free,
|
||||
});
|
||||
} catch {
|
||||
/* unreadable mount: skip */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The mount (from `list`) holding `p`: the longest mount point that prefixes it. */
|
||||
function mountOf(list, p) {
|
||||
if (!p) return null;
|
||||
let best = null;
|
||||
for (const d of list) {
|
||||
const m = d.mount === "/" ? "/" : `${d.mount}/`;
|
||||
if ((p === d.mount || p.startsWith(m)) && (!best || d.mount.length > best.mount.length))
|
||||
best = d;
|
||||
}
|
||||
return best ? best.mount : null;
|
||||
}
|
||||
|
||||
/** `nvidia-smi` GPUs, or [] when there is none (or it does not answer in 3 s). */
|
||||
function gpus() {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"nvidia-smi",
|
||||
[
|
||||
"--query-gpu=name,utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
{ timeout: 3000, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve([]);
|
||||
resolve(
|
||||
String(stdout)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
const [name, util, used, total] = l.split(",").map((s) => s.trim());
|
||||
return {
|
||||
name,
|
||||
utilization: Number(util) || 0,
|
||||
memoryUsed: (Number(used) || 0) * 1024 * 1024,
|
||||
memoryTotal: (Number(total) || 0) * 1024 * 1024,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function distro() {
|
||||
try {
|
||||
const m = /^PRETTY_NAME="?([^"\n]*)"?/m.exec(fs.readFileSync("/etc/os-release", "utf8"));
|
||||
return m ? m[1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE = 4096;
|
||||
|
||||
/**
|
||||
* One pass over `/proc`: `Map<pid, { uid, comm, ticks, rss }>`. `stat`'s comm
|
||||
* is in parentheses and may itself hold spaces or `)`, so the fields are read
|
||||
* after the LAST `)`.
|
||||
*/
|
||||
async function processes() {
|
||||
const out = new Map();
|
||||
let names;
|
||||
try {
|
||||
names = await fsp.readdir("/proc");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
await Promise.all(
|
||||
names
|
||||
.filter((n) => /^\d+$/.test(n))
|
||||
.map(async (pid) => {
|
||||
try {
|
||||
const [stat, st] = await Promise.all([
|
||||
fsp.readFile(`/proc/${pid}/stat`, "utf8"),
|
||||
fsp.stat(`/proc/${pid}`),
|
||||
]);
|
||||
const close = stat.lastIndexOf(")");
|
||||
const comm = stat.slice(stat.indexOf("(") + 1, close);
|
||||
const f = stat.slice(close + 2).split(" ");
|
||||
// After comm: state(0) ppid(1) … utime(11) stime(12) … rss(21).
|
||||
out.set(Number(pid), {
|
||||
uid: st.uid,
|
||||
comm,
|
||||
ticks: Number(f[11]) + Number(f[12]),
|
||||
rss: Number(f[21]) * PAGE,
|
||||
});
|
||||
} catch {
|
||||
/* exited meanwhile */
|
||||
}
|
||||
})
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The `AGENTHUB_LANE_ID` a host process inherited, or null. */
|
||||
async function laneIdOf(pid) {
|
||||
try {
|
||||
const env = await fsp.readFile(`/proc/${pid}/environ`, "utf8");
|
||||
const m = /(?:^|\0)AGENTHUB_LANE_ID=(\d+)(?:\0|$)/.exec(env);
|
||||
return m ? Number(m[1]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Defaults wired to the real modules; a test swaps them (`__setDeps`). */
|
||||
let deps = {
|
||||
memberNameForUid: (uid) => require("./run-as").memberNameForUid(uid),
|
||||
laneOwner: (laneId) => {
|
||||
const { db } = require("../db");
|
||||
const row = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
return row ? row.owner_id : null;
|
||||
},
|
||||
users: () => {
|
||||
try {
|
||||
return require("./auth").listUsers();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
homesRoot: () => {
|
||||
try {
|
||||
const linux = require("./linux-accounts");
|
||||
if (linux.s3Enabled()) return linux.serverConf().usersRoot;
|
||||
} catch {
|
||||
/* no S3 */
|
||||
}
|
||||
return process.env.AGENTHUB_USERS_ROOT || os.homedir();
|
||||
},
|
||||
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();
|
||||
await deps.wait(SAMPLE_MS);
|
||||
const t1 = await cpuTimes();
|
||||
const p1 = await processes();
|
||||
const mem = await memory();
|
||||
if (!t0 || !t1) return null;
|
||||
const totalDelta = Math.max(1, t1.total - t0.total);
|
||||
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
|
||||
const users = deps.users();
|
||||
const byUsername = new Map(users.map((u) => [u.username, u]));
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const ownerCache = new Map();
|
||||
const groups = new Map();
|
||||
const group = (key, extra) => {
|
||||
if (!groups.has(key))
|
||||
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
|
||||
return groups.get(key);
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
[...p1].map(async ([pid, p]) => {
|
||||
const before = p0.get(pid);
|
||||
// A process born between the samples: all its ticks are in the window.
|
||||
const ticks = Math.max(0, p.ticks - (before ? before.ticks : 0));
|
||||
let g = null;
|
||||
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 });
|
||||
} else if (p.uid === hostUid) {
|
||||
const laneId = await laneIdOf(pid);
|
||||
let ownerId = null;
|
||||
if (laneId != null) {
|
||||
if (!ownerCache.has(laneId)) {
|
||||
let o = null;
|
||||
try {
|
||||
o = deps.laneOwner(laneId);
|
||||
} catch {
|
||||
o = null;
|
||||
}
|
||||
ownerCache.set(laneId, o);
|
||||
}
|
||||
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" });
|
||||
} else {
|
||||
g = group("system", { kind: "system" });
|
||||
}
|
||||
const cpu = (ticks / totalDelta) * 100;
|
||||
g.cpu += cpu;
|
||||
g.memory += p.rss;
|
||||
g.processes += 1;
|
||||
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
|
||||
})
|
||||
);
|
||||
|
||||
const list = [...groups.values()].map((g) => {
|
||||
g.top.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
|
||||
g.top = g.top.slice(0, TOP_PER_GROUP).map((t) => ({ ...t, cpu: round(t.cpu) }));
|
||||
return { ...g, cpu: round(g.cpu), memoryPercent: round((g.memory / mem.total) * 100) };
|
||||
});
|
||||
// Every user appears, idle ones with zeros, so the table is the whole roster.
|
||||
for (const u of users) {
|
||||
if (!groups.has(`user:${u.id}`))
|
||||
list.push({
|
||||
key: `user:${u.id}`,
|
||||
kind: "user",
|
||||
userId: u.id,
|
||||
username: u.username,
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
memoryPercent: 0,
|
||||
processes: 0,
|
||||
top: [],
|
||||
});
|
||||
}
|
||||
list.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,
|
||||
};
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
async function collect() {
|
||||
const linux = process.platform === "linux";
|
||||
const cpus = os.cpus();
|
||||
const [mem, diskList, gpuList, use] = await Promise.all([
|
||||
memory(),
|
||||
linux ? disks() : [],
|
||||
linux ? gpus() : [],
|
||||
linux ? usage() : null,
|
||||
]);
|
||||
return {
|
||||
at: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
os: {
|
||||
platform: process.platform,
|
||||
release: os.release(),
|
||||
distro: linux ? distro() : null,
|
||||
arch: os.arch(),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
|
||||
memory: mem,
|
||||
disks: diskList,
|
||||
homesMount: mountOf(diskList, deps.homesRoot()),
|
||||
gpus: gpuList,
|
||||
usage: use,
|
||||
};
|
||||
}
|
||||
|
||||
let cache = null; // { at, value }
|
||||
let inflight = null;
|
||||
|
||||
/** The current snapshot: cached for `FRESH_MS`, one sample shared by every caller. */
|
||||
async function hostStats() {
|
||||
if (cache && Date.now() - cache.at < FRESH_MS) return cache.value;
|
||||
if (!inflight) {
|
||||
inflight = collect()
|
||||
.then((value) => {
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function __setDeps(over) {
|
||||
deps = { ...deps, ...over };
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { hostStats, mountOf, __setDeps };
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @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).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { hostStats } = require("../lib/host-stats");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (_req, res) => {
|
||||
try {
|
||||
res.json(await hostStats());
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: { code: "EHOST", message: e.message } });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user