feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.
The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.
Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
This commit is contained in:
@@ -2040,6 +2040,16 @@ describe("Transcript cache integration", () => {
|
||||
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
|
||||
});
|
||||
|
||||
it("reports event-loop delay and the last hook / agents for the health score", async () => {
|
||||
const res = await fetch("/api/settings/info");
|
||||
const loop = res.body.server.event_loop;
|
||||
for (const k of ["p50", "p99", "max"]) assert.ok(Number.isFinite(loop[k]), `event_loop.${k}`);
|
||||
const a = res.body.activity;
|
||||
assert.ok(a.last_event_at === null || typeof a.last_event_at === "string");
|
||||
assert.ok(Number.isInteger(a.working_agents));
|
||||
assert.ok(Number.isInteger(a.agents_24h.completed) && Number.isInteger(a.agents_24h.error));
|
||||
});
|
||||
|
||||
it("should evict cache entry on SessionEnd", async () => {
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* @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`).
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`), the
|
||||
* /proc and /sys parsers behind its per-core, network, disk I/O and
|
||||
* temperature figures, the PSI pressure parser, and the in-memory history (`GET /api/host/history`).
|
||||
* @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 {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
} = require("../lib/host-stats");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
@@ -68,3 +86,132 @@ test(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("parsePressure reads some/full averages and refuses a missing file", () => {
|
||||
assert.deepEqual(
|
||||
parsePressure(
|
||||
"some avg10=12.50 avg60=3.00 avg300=1.00 total=1\nfull avg10=4.25 avg60=1.00 avg300=0.50 total=1\n"
|
||||
),
|
||||
{ some10: 12.5, some60: 3, full10: 4.25 }
|
||||
);
|
||||
// The cpu file of an older kernel has no `full` line.
|
||||
assert.deepEqual(parsePressure("some avg10=1.00 avg60=2.00 avg300=0.00 total=1\n"), {
|
||||
some10: 1,
|
||||
some60: 2,
|
||||
full10: 0,
|
||||
});
|
||||
assert.equal(parsePressure(null), null);
|
||||
assert.equal(parsePressure("garbage"), null);
|
||||
});
|
||||
|
||||
test("parseCpuStat reads the machine line and every core", () => {
|
||||
const r = parseCpuStat(
|
||||
"cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 5 0 5 35 5 0 0 0\ncpu1 5 0 5 35 5 0 0 0\nintr 1 2\n"
|
||||
);
|
||||
assert.deepEqual(r.all, { total: 100, idle: 80 });
|
||||
assert.equal(r.cores.length, 2);
|
||||
assert.deepEqual(r.cores[1], { total: 50, idle: 40 });
|
||||
assert.deepEqual(parseCpuStat(""), { all: null, cores: [] });
|
||||
});
|
||||
|
||||
test("parseNetDev skips lo and reads rx/tx bytes", () => {
|
||||
const text = [
|
||||
"Inter-| Receive | Transmit",
|
||||
" face |bytes packets|bytes packets",
|
||||
" lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0",
|
||||
"enp4s0: 5000 10 0 0 0 0 0 0 2000 6 0 0 0 0 0 0",
|
||||
].join("\n");
|
||||
const m = parseNetDev(text);
|
||||
assert.equal(m.has("lo"), false);
|
||||
assert.deepEqual(m.get("enp4s0"), { rx: 5000, tx: 2000 });
|
||||
});
|
||||
|
||||
test("parseDiskstats keeps whole disks and converts sectors to bytes", () => {
|
||||
const text = [
|
||||
" 7 0 loop0 14 0 34 12 0 0 0 0 0 11 12",
|
||||
" 8 0 sda 100 0 2048 50 20 0 1024 9 0 30 40",
|
||||
" 8 1 sda1 90 0 2000 40 20 0 1000 9 0 30 40",
|
||||
].join("\n");
|
||||
const m = parseDiskstats(text, (n) => n === "sda");
|
||||
assert.deepEqual([...m.keys()], ["sda"]);
|
||||
assert.deepEqual(m.get("sda"), { read: 2048 * 512, write: 1024 * 512 });
|
||||
});
|
||||
|
||||
test("physicalCores counts distinct physical/core id pairs", () => {
|
||||
const info = [0, 1].flatMap((core) =>
|
||||
[0, 1].map((ht) => `processor\t: ${core * 2 + ht}\nphysical id\t: 0\ncore id\t\t: ${core}\n`)
|
||||
);
|
||||
assert.equal(physicalCores(info.join("\n")), 2);
|
||||
assert.equal(physicalCores(""), null);
|
||||
});
|
||||
|
||||
test("temperatures reads hwmon inputs with their labels", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hwmon-"));
|
||||
try {
|
||||
const chip = path.join(root, "hwmon0");
|
||||
fs.mkdirSync(chip);
|
||||
fs.writeFileSync(path.join(chip, "name"), "coretemp\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_input"), "45500\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_label"), "Package id 0\n");
|
||||
fs.writeFileSync(path.join(chip, "temp2_input"), "38000\n");
|
||||
assert.deepEqual(await temperatures(root), [
|
||||
{ chip: "coretemp", label: "Package id 0", celsius: 45.5 },
|
||||
{ chip: "coretemp", label: "temp2", celsius: 38 },
|
||||
]);
|
||||
assert.deepEqual(await temperatures(path.join(root, "missing")), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hostHistory filters by range and downsample averages buckets", () => {
|
||||
const now = Date.now();
|
||||
const pt = (minsAgo, cpu) => ({
|
||||
at: new Date(now - minsAgo * 60e3).toISOString(),
|
||||
cpu,
|
||||
memory: 50,
|
||||
memoryUsed: 100,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: { 3: { cpu, memory: 10 } },
|
||||
});
|
||||
__setHistory([pt(300, 90), pt(30, 10), pt(1, 20)]);
|
||||
const h1 = hostHistory("1h");
|
||||
assert.equal(h1.range, "1h");
|
||||
assert.deepEqual(
|
||||
h1.points.map((p) => p.cpu),
|
||||
[10, 20]
|
||||
);
|
||||
assert.equal(hostHistory("6h").points.length, 3);
|
||||
assert.equal(hostHistory("bogus").range, "1h");
|
||||
const d = downsample([pt(3, 10), pt(2, 20), pt(1, 30), pt(0, 40)], 2);
|
||||
assert.deepEqual(
|
||||
d.map((p) => p.cpu),
|
||||
[15, 35]
|
||||
);
|
||||
assert.equal(d[0].users[3].cpu, 15);
|
||||
__setHistory([]);
|
||||
});
|
||||
|
||||
test(
|
||||
"hostStats adds per-core, network, disk I/O, top processes and process facts",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
__setDeps({
|
||||
users: () => [],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.equal(r.cpu.perCore.length, r.cpu.cores);
|
||||
assert.ok(Array.isArray(r.network) && Array.isArray(r.diskIo));
|
||||
assert.ok(r.network.every((n) => n.name !== "lo"));
|
||||
assert.ok(r.topProcesses.length > 0 && r.topProcesses.length <= 10);
|
||||
assert.ok(r.topProcesses.every((p) => typeof p.group === "string" && !("argv" in p)));
|
||||
assert.equal(r.process.pid, process.pid);
|
||||
assert.ok(r.process.rss > 0);
|
||||
assert.ok(typeof r.bootTime === "string");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -209,6 +209,12 @@ describe("server mode: list and aggregate routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("names each listed session's owner: a stamped lane, else a run's lane", async () => {
|
||||
const { sessions } = await get("/api/sessions", "boss");
|
||||
const owners = Object.fromEntries(sessions.map((s) => [s.id, s.owner_name]));
|
||||
assert.deepEqual(owners, { sA: "alice", sA2: "alice", sB: "bob", sHost: null });
|
||||
});
|
||||
|
||||
it("keeps a member out of another's rows through filters and POST", async () => {
|
||||
// A filter naming another's session is refused, not answered empty.
|
||||
assert.equal((await call("GET", "/api/events?session_id=sB", { as: "alice" })).status, 404);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @file server-mode-user-details.test.js
|
||||
* @description `GET /api/users/:id/details` — the Administration page's user
|
||||
* detail: the account, its workspaces and live panes, newest sessions, spend
|
||||
* per day / AI tool / model, the audit entries naming it, and per pool tool
|
||||
* the accounts it may run on with its own share of each. Staff only, 404 for
|
||||
* an unknown user, never a token or a password hash.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-details-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, ".codex");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
// The quota read would go to the network.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
|
||||
const ids = {};
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
const lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, title, tool, owner_id) VALUES ('/w/mia', 'Mia', 'claude', ?)")
|
||||
.run(ids.mia).lastInsertRowid;
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sm', 'mia work', 'active', '/w/mia', ?)"
|
||||
).run(lane);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sc', 'mia codex', 'completed', '/w/mia', ?, 'codex')"
|
||||
).run(lane);
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
for (const [sid, tokens] of [
|
||||
["sm", 1000000],
|
||||
["sc", 500000],
|
||||
]) {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens)
|
||||
VALUES (?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, tokens);
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
|
||||
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
|
||||
).run(sid, hour, tokens);
|
||||
}
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, account)
|
||||
VALUES ('agenthub-lane-1', 'sm', 'interactive', '/w/mia', 'running', ?, 'claude', 'team-1')`
|
||||
).run(lane);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("GET /api/users/:id/details", () => {
|
||||
it("shows staff one user's workspaces, panes, sessions, spend and pool share", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
const d = r.body;
|
||||
assert.equal(d.user.username, "mia");
|
||||
assert.equal(d.sessions.total, 2);
|
||||
assert.equal(d.sessions.active, 1);
|
||||
assert.deepEqual(
|
||||
d.workspaces.map((w) => [w.title, w.sessions, w.livePanes]),
|
||||
[["Mia", 2, 1]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.panes.map((p) => [p.id, p.account]),
|
||||
[["agenthub-lane-1", "team-1"]]
|
||||
);
|
||||
assert.deepEqual(d.recentSessions.map((s) => s.id).sort(), ["sc", "sm"]);
|
||||
assert.ok(d.recentSessions.every((s) => s.tokens > 0 && s.cost > 0));
|
||||
|
||||
assert.equal(d.usage.last30d.tokens, 1500000);
|
||||
assert.equal(d.usage.today.tokens, 1500000);
|
||||
assert.equal(d.usage.daily.length, 30);
|
||||
assert.equal(d.usage.daily.at(-1).tokens, 1500000, "today is the last bar");
|
||||
assert.deepEqual(
|
||||
d.usage.byTool.map((b) => [b.key, b.tokens, b.sessions]),
|
||||
[
|
||||
["claude", 1000000, 1],
|
||||
["codex", 500000, 1],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
d.usage.byModel.map((b) => b.key),
|
||||
["claude-sonnet-4-5-20250929"]
|
||||
);
|
||||
|
||||
const team = d.pool.claude.accounts.find((a) => a.name === "team-1");
|
||||
assert.equal(team.allowed, true);
|
||||
assert.equal(team.userRuns7d, 1);
|
||||
assert.equal(team.userLivePanes, 1);
|
||||
assert.equal(team.userTokens7d, 1000000, "only the hours its run held the session");
|
||||
assert.equal(d.pool.claude.pin, null);
|
||||
assert.ok(!JSON.stringify(d).includes(TOKEN), "never a token");
|
||||
assert.ok(!JSON.stringify(d).includes("scrypt"), "never a password hash");
|
||||
});
|
||||
|
||||
it("lists the audit entries naming the user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
await call("GET", "/api/sessions/sm", { cookie: boss });
|
||||
const d = (await call("GET", `/api/users/${ids.mia}/details`, { cookie: boss })).body;
|
||||
assert.ok(d.audit.some((a) => a.reader === "boss" && a.owner === "mia"));
|
||||
});
|
||||
|
||||
it("is staff only and 404s an unknown user", async () => {
|
||||
const ana = await login("ana", "analyst-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: ana })).status, 200);
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
assert.equal((await call("GET", `/api/users/${ids.mia}/details`, { cookie: mia })).status, 403);
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
assert.equal((await call("GET", "/api/users/9999/details", { cookie: boss })).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* workspaces, sessions and recent spend to an admin/analyst and refuses a
|
||||
* member; an admin/analyst reading a member's session is written to the audit
|
||||
* log (`GET /api/users/audit`), while a member reading their own, or a read of
|
||||
* an ownerless session, is not.
|
||||
* an ownerless session, is not; and `GET /api/host` (System Health's machine)
|
||||
* answers every signed-in user, a developer included.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -101,6 +102,18 @@ describe("staff views", () => {
|
||||
assert.equal(member.status, 403);
|
||||
});
|
||||
|
||||
it("shows every signed-in user the machine (System Health), names included", async () => {
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
const r = await call("/api/host", mia);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.body.hostname);
|
||||
if (r.body.usage) {
|
||||
const g = r.body.usage.groups.find((x) => x.kind === "user" && x.username === "mia");
|
||||
assert.ok(g && "displayName" in g, "a user's group carries their display name");
|
||||
}
|
||||
assert.equal((await call("/api/host/history?range=1h", mia)).status, 200);
|
||||
});
|
||||
|
||||
it("audits a staff read of a member's session, and nothing else", async () => {
|
||||
const admin = await login("boss", "admin-pass-1");
|
||||
const mia = await login("mia", "member-pass-1");
|
||||
|
||||
Reference in New Issue
Block a user