diff --git a/client/src/App.tsx b/client/src/App.tsx index aa67d41b..e985b3b1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> - + } /> + {/* The Users page became Administration's first tab. */} + } /> !(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); diff --git a/client/src/components/admin/ServerPanel.tsx b/client/src/components/admin/ServerPanel.tsx new file mode 100644 index 00000000..fde3238f --- /dev/null +++ b/client/src/components/admin/ServerPanel.tsx @@ -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ĩ + */ + +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)} + +
+ +
+ ))} +
+ )} + + +
+ ); +} diff --git a/client/src/components/users/MemberTmuxCard.tsx b/client/src/components/users/MemberTmuxCard.tsx index ffc7482e..c68c04b0 100644 --- a/client/src/components/users/MemberTmuxCard.tsx +++ b/client/src/components/users/MemberTmuxCard.tsx @@ -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. diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 032b678d..da0e8553 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -115,6 +115,7 @@ export const i18nReady = i18n "splash", "lanes", "users", + "admin", "onboarding", "artifacts", ], diff --git a/client/src/i18n/locales/en/admin.json b/client/src/i18n/locales/en/admin.json new file mode 100644 index 00000000..d01dc8bd --- /dev/null +++ b/client/src/i18n/locales/en/admin.json @@ -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" + } +} diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 492f1c94..5a307098 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -87,5 +87,6 @@ "sidebar": "Sidebar", "now": "now", "users": "Users", + "admin": "Administration", "files": "Files" } diff --git a/client/src/i18n/locales/vi/admin.json b/client/src/i18n/locales/vi/admin.json new file mode 100644 index 00000000..3ef16b24 --- /dev/null +++ b/client/src/i18n/locales/vi/admin.json @@ -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" + } +} diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 0bccadb7..9d3fa8fd 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -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" } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 7e00e3d4..3fb0ae6e 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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:`, `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("/me/avatar", { method: "DELETE" }), }, + /** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */ + host: { + stats: () => request("/host"), + }, // ───────────────────────── Server mode staff views (routes/users.js) ───────────────────────── users: { /** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */ diff --git a/client/src/pages/Administration.tsx b/client/src/pages/Administration.tsx new file mode 100644 index 00000000..bc4aeec4 --- /dev/null +++ b/client/src/pages/Administration.tsx @@ -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ĩ + */ +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 ; + + 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 ( +
+
+
+ +
+
+

{t("title")}

+

{t("description")}

+
+
+ setParams(id === first.id ? {} : { tab: id }, { replace: true })} + tabs={tabs} + > + {active.id === "server" ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx index c27d3b19..d93e87c6 100644 --- a/client/src/pages/Users.tsx +++ b/client/src/pages/Users.tsx @@ -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() {
-
- -
+ {!embedded && ( +
+ +
+ )}
-

{t("title")}

+ {/* Under Administration's tab bar the tab already names the page. */} +

+ {t("title")} +

{t("description")}

@@ -346,7 +351,6 @@ export function Users() { })()} -
diff --git a/client/src/pages/__tests__/Administration.test.tsx b/client/src/pages/__tests__/Administration.test.tsx new file mode 100644 index 00000000..cf5ed728 --- /dev/null +++ b/client/src/pages/__tests__/Administration.test.tsx @@ -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ĩ + */ + +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>(); + 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( + + + } /> + } /> + + + ); + +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(); + }); +}); diff --git a/docs/API.md b/docs/API.md index 21278378..a3219ed4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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: {: {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: {: {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. diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 1618708b..d019cfc2 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -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-` process running `tmux -S //.agenthub/tmux/sock`) with `sudo kill -9 `; the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`. diff --git a/server/__tests__/host-stats.test.js b/server/__tests__/host-stats.test.js new file mode 100644 index 00000000..02f56021 --- /dev/null +++ b/server/__tests__/host-stats.test.js @@ -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ĩ + */ + +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(); + } + } +); diff --git a/server/index.js b/server/index.js index 7248ea52..3fd4045b 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/lib/auth.js b/server/lib/auth.js index f2b26773..a1422d8b 100644 --- a/server/lib/auth.js +++ b/server/lib/auth.js @@ -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"]; diff --git a/server/lib/host-stats.js b/server/lib/host-stats.js new file mode 100644 index 00000000..ad081eac --- /dev/null +++ b/server/lib/host-stats.js @@ -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//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-` 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ĩ + */ + +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`. `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 }; diff --git a/server/routes/host.js b/server/routes/host.js new file mode 100644 index 00000000..aa6e8a49 --- /dev/null +++ b/server/routes/host.js @@ -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ĩ + */ + +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;