feat(admin): Administration page — Users tab + Server tab (hardware, per-user CPU/memory/home share)

/users becomes /admin (redirected), a tab shell over the existing Users view
and a new Server tab backed by GET /api/host (server/lib/host-stats.js):
hostname/OS/uptime, CPU and load, memory and swap, every real disk with
HDD/SSD, NVIDIA GPUs, and each user's live share from two /proc samples —
attributed by the member's Linux uid (S3) or, for host processes, by the
inherited AGENTHUB_LANE_ID's workspace owner. Read-only, staff-only (/host
in STAFF_ONLY_PREFIXES), comm names only. Members' tmux card moves to the
Server tab.
This commit is contained in:
2026-10-15 16:33:32 +07:00
parent 758609775f
commit b6b40b0c1b
20 changed files with 1333 additions and 22 deletions
+70
View File
@@ -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();
}
}
);