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:
2026-10-27 16:39:38 +07:00
parent df15789982
commit 1c6dfea5b5
49 changed files with 5154 additions and 1971 deletions
+10
View File
@@ -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(
+149 -2
View File
@@ -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);
});
});
+14 -1
View File
@@ -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");
+2
View File
@@ -1332,6 +1332,8 @@ if (require.main === module) {
require("./lib/omp-share")
.start()
.catch((err) => console.warn("omp share failed to start:", err.message));
// System Health's CPU/memory history (in memory; Linux only, inside).
require("./lib/host-stats").startHistory();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
+2 -2
View File
@@ -104,8 +104,8 @@ const STAFF_ONLY_PREFIXES = [
"/alerts/rules",
"/users",
"/accounts",
// The machine's hardware and every user's CPU/memory share.
"/host",
// `/host` (the machine and each user's CPU/memory share) is NOT here: the
// Dashboard's System Health tab shows it to every signed-in user.
];
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
+383 -34
View File
@@ -1,7 +1,7 @@
/**
* @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
* @description The Dashboard System Health tab's machine cards (`GET /api/host`,
* every signed-in user): 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.
*
@@ -17,9 +17,22 @@
* 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
* which can hold a prompt or a key. The same sample window also gives per-core
* CPU % (`/proc/stat` cpuN), network rx/tx per interface (`/proc/net/dev`, no
* `lo`), disk read/write per whole disk (`/proc/diskstats`), and the ten
* busiest processes machine-wide with their group; temperatures come from
* `/sys/class/hwmon`, physical cores from `/proc/cpuinfo`, and the dashboard's
* own pid/Node/uptime/RSS from `process`, and pressure stall information
* (`/proc/pressure/{cpu,memory,io}`, PSI — the share of time tasks waited on
* that resource, i.e. how laggy the machine feels; `null` on a kernel without
* it). 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`.
* platforms get the `os` facts with `usage: null` and empty lists.
*
* History (`GET /api/host/history`): `startHistory` (every server on Linux,
* server/index.js) keeps an in-memory ring of machine CPU/memory/swap/load1
* and per-user CPU/memory, one point every 30 s for 24 h, from the cached
* snapshot; it starts empty at every server start and is never written to disk.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -32,6 +45,10 @@ const { execFile } = require("node:child_process");
const SAMPLE_MS = 1000;
const FRESH_MS = 3000;
const TOP_PER_GROUP = 5;
const TOP_PROCESSES = 10;
const HISTORY_EVERY_MS = 30_000;
const HISTORY_KEEP_MS = 24 * 3600_000;
const HISTORY_MAX_POINTS = 360;
// File systems that hold data; pseudo and loop mounts are left out.
const REAL_FS = new Set([
"ext2",
@@ -71,13 +88,154 @@ async function memory() {
};
}
/** 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) };
/**
* `/proc/stat`'s jiffies: the machine's `cpu` line and each `cpuN` line, each
* `{ total, idle }` (idle = idle + iowait). `{ all: null, cores: [] }` when
* there is no `cpu` line.
*/
function parseCpuStat(text) {
const times = (line) => {
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) };
};
const lines = (text || "").split("\n");
const all = lines.find((l) => l.startsWith("cpu "));
return {
all: all ? times(all) : null,
cores: lines.filter((l) => /^cpu\d+ /.test(l)).map(times),
};
}
/**
* One `/proc/pressure/<resource>` file: `{some10, some60, full10}` — the % of
* the last 10 s / 60 s that at least one task (`some`) or every task (`full`)
* was stalled on it. `null` when the file is missing or unreadable.
*/
function parsePressure(text) {
if (!text) return null;
const line = (kind) => {
const m = new RegExp(`^${kind} avg10=([\\d.]+) avg60=([\\d.]+)`, "m").exec(text);
return m ? { avg10: Number(m[1]), avg60: Number(m[2]) } : null;
};
const some = line("some");
if (!some) return null;
return { some10: some.avg10, some60: some.avg60, full10: line("full")?.avg10 ?? 0 };
}
/** PSI for cpu, memory and io; `null` when the kernel has none of them. */
async function pressure() {
const [cpu, memory, io] = await Promise.all(
["cpu", "memory", "io"].map((r) => readText(`/proc/pressure/${r}`).then(parsePressure))
);
return cpu || memory || io ? { cpu, memory, io } : null;
}
/** Busy % between two `{ total, idle }` readings. */
const busyPct = (a, b) => {
const total = b.total - a.total;
return total > 0 ? round(((total - (b.idle - a.idle)) / total) * 100) : 0;
};
/** `/proc/net/dev`: `Map<iface, { rx, tx }>` in bytes, `lo` left out. */
function parseNetDev(text) {
const out = new Map();
for (const line of (text || "").split("\n").slice(2)) {
const i = line.indexOf(":");
if (i < 0) continue;
const name = line.slice(0, i).trim();
if (!name || name === "lo") continue;
const f = line
.slice(i + 1)
.trim()
.split(/\s+/)
.map(Number);
out.set(name, { rx: f[0] || 0, tx: f[8] || 0 });
}
return out;
}
/**
* `/proc/diskstats`: `Map<device, { read, write }>` in bytes (sectors × 512)
* for whole disks only (`isDisk(name)`), never loop/ram/zram/optical.
*/
function parseDiskstats(text, isDisk = () => true) {
const out = new Map();
for (const line of (text || "").split("\n")) {
const f = line.trim().split(/\s+/);
if (f.length < 10) continue;
const name = f[2];
if (/^(loop|ram|zram|sr|fd)\d*/.test(name) || !isDisk(name)) continue;
out.set(name, { read: Number(f[5]) * 512, write: Number(f[9]) * 512 });
}
return out;
}
/** Whole disks: the names under `/sys/block` (partitions are not there). */
function wholeDisks() {
try {
return new Set(fs.readdirSync("/sys/block"));
} catch {
return null;
}
}
/** Per-key `{a, b}` byte counters → per-second rates over `ms`. */
function rates(m0, m1, ms, keys) {
const sec = Math.max(0.001, ms / 1000);
const out = [];
for (const [name, v1] of m1) {
const v0 = m0.get(name);
if (!v0) continue;
const row = { name };
for (const k of keys) row[k] = Math.max(0, Math.round((v1[k] - v0[k]) / sec));
out.push(row);
}
return out;
}
/**
* Temperatures from `/sys/class/hwmon/*`: `[{ chip, label, celsius }]`, one
* per `tempN_input` (label from `tempN_label`, else `tempN`). [] when none.
*/
async function temperatures(root = "/sys/class/hwmon") {
let chips;
try {
chips = await fsp.readdir(root);
} catch {
return [];
}
const out = [];
for (const c of chips.sort()) {
const dir = path.join(root, c);
const chip = ((await readText(path.join(dir, "name"))) || c).trim();
let files;
try {
files = await fsp.readdir(dir);
} catch {
continue;
}
for (const f of files.filter((x) => /^temp\d+_input$/.test(x)).sort()) {
const raw = Number(((await readText(path.join(dir, f))) || "").trim());
if (!Number.isFinite(raw) || raw === 0) continue;
const id = f.replace("_input", "");
const label = ((await readText(path.join(dir, `${id}_label`))) || id).trim();
out.push({ chip, label, celsius: round(raw / 1000) });
}
}
return out;
}
/** Physical cores from `/proc/cpuinfo` (distinct physical id + core id), or null. */
function physicalCores(text) {
const seen = new Set();
let phys = "0";
for (const line of (text || "").split("\n")) {
const m = /^(physical id|core id)\s*:\s*(\d+)/.exec(line);
if (!m) continue;
if (m[1] === "physical id") phys = m[2];
else seen.add(`${phys}:${m[2]}`);
}
return seen.size || null;
}
/** `HDD`/`SSD` for a `/dev/...` device, or null when /sys cannot tell. */
@@ -269,14 +427,47 @@ let deps = {
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();
/** The counters one sample reads: CPU jiffies, network, disk I/O, processes. */
async function counters(isDisk) {
const [stat, net, io] = await Promise.all([
readText("/proc/stat"),
readText("/proc/net/dev"),
readText("/proc/diskstats"),
]);
return {
at: Date.now(),
cpu: parseCpuStat(stat),
net: parseNetDev(net),
io: parseDiskstats(io, isDisk),
procs: await processes(),
};
}
/**
* Two samples `SAMPLE_MS` apart: per-user CPU and memory (`usage`), and from
* the same window per-core CPU, network and disk I/O rates and the busiest
* processes machine-wide.
*/
async function sampleWindow(mem) {
const disksSet = wholeDisks();
const isDisk = (n) => !disksSet || disksSet.has(n);
const s0 = await counters(isDisk);
await deps.wait(SAMPLE_MS);
const t1 = await cpuTimes();
const p1 = await processes();
const mem = await memory();
const s1 = await counters(isDisk);
const ms = s1.at - s0.at;
const cores = s1.cpu.cores.map((c, i) => (s0.cpu.cores[i] ? busyPct(s0.cpu.cores[i], c) : 0));
const use = await usage(s0.cpu.all, s1.cpu.all, s0.procs, s1.procs, mem);
return {
usage: use ? use.usage : null,
topProcesses: use ? use.top : [],
cores,
network: rates(s0.net, s1.net, ms, ["rx", "tx"]),
diskIo: rates(s0.io, s1.io, ms, ["read", "write"]),
};
}
/** Per-user CPU and memory from two samples (`usage`) and the busiest processes (`top`). */
async function usage(t0, t1, p0, p1, mem) {
if (!t0 || !t1) return null;
const totalDelta = Math.max(1, t1.total - t0.total);
const hostUid = typeof process.getuid === "function" ? process.getuid() : -1;
@@ -286,6 +477,7 @@ async function usage() {
const byId = new Map(users.map((u) => [u.id, u]));
const ownerCache = new Map();
const groups = new Map();
const all = [];
const group = (key, extra) => {
if (!groups.has(key))
groups.set(key, { key, cpu: 0, memory: 0, processes: 0, top: [], ...extra });
@@ -301,7 +493,7 @@ async function usage() {
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 });
g = group(`user:${u.id}`, userExtra(u));
} else if (p.uid === hostUid) {
const laneId = await laneIdOf(pid);
let ownerId = null;
@@ -318,9 +510,7 @@ async function usage() {
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" });
g = u ? group(`user:${u.id}`, userExtra(u)) : group("host", { kind: "host" });
} else {
g = group("system", { kind: "system" });
}
@@ -329,6 +519,7 @@ async function usage() {
g.memory += p.rss;
g.processes += 1;
g.top.push({ pid, name: p.comm, cpu, memory: p.rss });
all.push({ pid, name: p.comm, cpu, memory: p.rss, group: g.key });
})
);
@@ -342,9 +533,7 @@ async function usage() {
if (!groups.has(`user:${u.id}`))
list.push({
key: `user:${u.id}`,
kind: "user",
userId: u.id,
username: u.username,
...userExtra(u),
cpu: 0,
memory: 0,
memoryPercent: 0,
@@ -353,24 +542,40 @@ async function usage() {
});
}
list.sort((a, b) => b.cpu - a.cpu || b.memory - a.memory);
all.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,
usage: {
cpu: round(((totalDelta - (t1.idle - t0.idle)) / totalDelta) * 100),
sampleMs: SAMPLE_MS,
groups: list,
},
top: all.slice(0, TOP_PROCESSES).map((t) => ({ ...t, cpu: round(t.cpu) })),
};
}
const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */
const userExtra = (u) => ({
kind: "user",
userId: u.id,
username: u.username,
displayName: u.displayName ?? null,
});
async function collect() {
const linux = process.platform === "linux";
const cpus = os.cpus();
const [mem, diskList, gpuList, use] = await Promise.all([
memory(),
const mem = await memory();
const [diskList, gpuList, win, temps, cpuinfo, psi] = await Promise.all([
linux ? disks() : [],
linux ? gpus() : [],
linux ? usage() : null,
linux ? sampleWindow(mem) : null,
linux ? temperatures() : [],
linux ? readText("/proc/cpuinfo") : null,
linux ? pressure() : null,
]);
const pmem = process.memoryUsage();
return {
at: new Date().toISOString(),
hostname: os.hostname(),
@@ -381,12 +586,31 @@ async function collect() {
arch: os.arch(),
},
uptime: os.uptime(),
cpu: { model: cpus[0]?.model?.trim() || null, cores: cpus.length, load: os.loadavg() },
bootTime: new Date(Date.now() - os.uptime() * 1000).toISOString(),
cpu: {
model: cpus[0]?.model?.trim() || null,
cores: cpus.length,
load: os.loadavg(),
physicalCores: physicalCores(cpuinfo),
perCore: win ? win.cores : [],
},
memory: mem,
disks: diskList,
homesMount: mountOf(diskList, deps.homesRoot()),
gpus: gpuList,
usage: use,
usage: win ? win.usage : null,
network: win ? win.network : [],
diskIo: win ? win.diskIo : [],
temperatures: temps,
pressure: psi,
topProcesses: win ? win.topProcesses : [],
process: {
pid: process.pid,
node: process.version,
uptime: Math.round(process.uptime()),
rss: pmem.rss,
heapUsed: pmem.heapUsed,
},
};
}
@@ -409,9 +633,134 @@ async function hostStats() {
return inflight;
}
// ── History ────────────────────────────────────────────────────────────────
// An in-memory ring of machine-level samples, one every HISTORY_EVERY_MS for
// HISTORY_KEEP_MS, started by every server on Linux (server/index.js). It
// starts empty at every server start: nothing is written to disk.
let history = [];
let historyTimer = null;
/** One history point from a snapshot. */
function historyPoint(s) {
const users = {};
for (const g of s.usage?.groups || []) {
if (g.kind === "user" && (g.cpu || g.memory))
users[g.userId] = { cpu: g.cpu, memory: g.memory };
}
return {
at: s.at,
cpu: s.usage ? s.usage.cpu : null,
memory: round((s.memory.used / Math.max(1, s.memory.total)) * 100),
memoryUsed: s.memory.used,
swapUsed: s.memory.swapUsed,
load1: round(s.cpu.load[0] || 0),
users,
};
}
/** Take one sample now (reusing a fresh snapshot) and drop what is too old. */
async function sampleHistory() {
try {
const point = historyPoint(await hostStats());
const cutoff = Date.now() - HISTORY_KEEP_MS;
history = [...history.filter((p) => Date.parse(p.at) >= cutoff), point];
} catch {
/* a failed sample is a gap, never a crash */
}
}
/** Start the sampler (Linux only; idempotent). The timer never holds the process open. */
function startHistory() {
if (historyTimer || process.platform !== "linux") return;
void sampleHistory();
historyTimer = setInterval(() => void sampleHistory(), HISTORY_EVERY_MS);
historyTimer.unref?.();
}
function stopHistory() {
if (historyTimer) clearInterval(historyTimer);
historyTimer = null;
}
const RANGES = { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 24 * 3600_000 };
/**
* The points of the last `range` (`1h`, `6h`, `24h`; else 1h), averaged down
* to at most HISTORY_MAX_POINTS. `startedAt` = the oldest point kept, so the
* UI can say the history begins there.
*/
function hostHistory(range) {
const span = RANGES[range] || RANGES["1h"];
const since = Date.now() - span;
const pts = history.filter((p) => Date.parse(p.at) >= since);
return {
range: RANGES[range] ? range : "1h",
intervalMs: HISTORY_EVERY_MS,
running: historyTimer != null,
startedAt: history[0]?.at ?? null,
points: downsample(pts, HISTORY_MAX_POINTS),
};
}
/** Average consecutive points into at most `max` buckets (numbers only; `at` = the bucket's last). */
function downsample(pts, max) {
if (pts.length <= max) return pts;
const per = Math.ceil(pts.length / max);
const out = [];
for (let i = 0; i < pts.length; i += per) {
const b = pts.slice(i, i + per);
const avg = (k) => {
const v = b.map((p) => p[k]).filter((x) => x != null);
return v.length ? round(v.reduce((a, x) => a + x, 0) / v.length) : null;
};
const users = {};
for (const p of b)
for (const [id, u] of Object.entries(p.users)) {
const t = (users[id] ||= { cpu: 0, memory: 0 });
t.cpu += u.cpu / b.length;
t.memory += u.memory / b.length;
}
for (const u of Object.values(users)) {
u.cpu = round(u.cpu);
u.memory = Math.round(u.memory);
}
out.push({
at: b[b.length - 1].at,
cpu: avg("cpu"),
memory: avg("memory"),
memoryUsed: Math.round(avg("memoryUsed") ?? 0),
swapUsed: Math.round(avg("swapUsed") ?? 0),
load1: avg("load1"),
users,
});
}
return out;
}
function __setDeps(over) {
deps = { ...deps, ...over };
cache = null;
}
module.exports = { hostStats, mountOf, __setDeps };
/** Tests: replace the history ring. */
function __setHistory(points) {
history = points;
}
module.exports = {
hostStats,
hostHistory,
startHistory,
stopHistory,
mountOf,
parseCpuStat,
parsePressure,
parseNetDev,
parseDiskstats,
physicalCores,
temperatures,
downsample,
__setDeps,
__setHistory,
};
+36
View File
@@ -0,0 +1,36 @@
/**
* @file loop-delay.js
* @description How long this server's event loop was held up — what an
* operator sees as a laggy dashboard or terminal: every request, hook and
* keystroke echo waits for it. One `monitorEventLoopDelay` histogram for the
* whole process, sampled every 20 ms and reset each minute, so `loopDelay()`
* describes the last full minute (the running one until the first minute
* ends). Read by `GET /api/metrics` (Prometheus gauges) and
* `GET /api/settings/info` (the System Health score).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { monitorEventLoopDelay } = require("perf_hooks");
const WINDOW_MS = 60_000;
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
const read = () => ({
p50: histogram.percentile(50),
p99: histogram.percentile(99),
max: histogram.max,
});
let lastWindow = null; // the last full minute, reported until the next one ends
setInterval(() => {
lastWindow = read();
histogram.reset();
}, WINDOW_MS).unref();
/** `{p50, p99, max}` in nanoseconds over the last minute. */
function loopDelay() {
return lastWindow || read();
}
module.exports = { loopDelay, WINDOW_MS };
+16 -7
View File
@@ -8,7 +8,9 @@
* A Claude session counts only for the hours one of THIS account's runs held
* it (start hour to end hour), so a session resumed on another account is
* not booked twice; Codex has no hourly rows, so its session counts whole
* once a run of the account started in the window, as before.
* once a run of the account started in the window, as before. With `ownerId`
* only the runs of that user's workspaces count — one user's share of the
* account, for the Administration page's user detail.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,21 +21,28 @@ const tokensOf = (r) =>
/**
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
* `wholeSession`, a session's every row counts in a window its run started in.
* `wholeSession`, a session's every row counts in a window its run started in;
* with `ownerId`, only runs on that user's workspaces.
*/
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
function accountUsage(
tool,
account,
{ wholeSession = false, now = Date.now(), ownerId = null } = {}
) {
const { db, stmts } = require("../db");
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
const owned = ownerId == null ? "" : "AND lane_id IN (SELECT id FROM lanes WHERE owner_id = ?)";
const ownedArgs = ownerId == null ? [] : [ownerId];
const runs = db
.prepare(
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?`
AND COALESCE(ended_at, '9999') >= ? ${owned}`
)
.all(tool, account, since7d);
.all(tool, account, since7d, ...ownedArgs);
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
if (runs.length === 0) return out;
const runsOf = new Map();
@@ -41,8 +50,8 @@ function accountUsage(tool, account, { wholeSession = false, now = Date.now() }
const rows = hourlyTokenRows(
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?)`,
[tool, account, since7d]
AND COALESCE(ended_at, '9999') >= ? ${owned})`,
[tool, account, since7d, ...ownedArgs]
);
const rules = stmts.listPricing.all();
for (const [label, hours] of Object.entries(WINDOWS)) {
+26 -3
View File
@@ -1,6 +1,6 @@
/**
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
* and the Dashboard System Health tab's machine cards (`GET /api/host` and `/api/host/history`, server/routes/host.js).
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -14,7 +14,7 @@ const tags = [
{
name: "Host",
description:
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
"Dashboard → System Health: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, every signed-in user.",
},
];
@@ -385,13 +385,36 @@ const paths = {
responses: {
200: {
description:
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
"`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.",
content: { "application/json": { schema: { type: "object" } } },
},
500: { description: "EHOST" },
},
},
},
"/api/host/history": {
get: {
tags: ["Host"],
summary: "The machine's CPU/memory history since the server started",
operationId: "getHostHistory",
description:
"In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.",
parameters: [
{
name: "range",
in: "query",
schema: { type: "string", enum: ["1h", "6h", "24h"], default: "1h" },
},
],
responses: {
200: {
description:
"`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.",
content: { "application/json": { schema: { type: "object" } } },
},
},
},
},
};
module.exports = { tags, schemas, paths };
+22
View File
@@ -565,6 +565,28 @@ const paths = {
},
},
},
"/api/users/{id}/details": {
get: {
tags: ["Auth"],
operationId: "getServerUserDetails",
summary:
"One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account's quota beside this user's own 5 h / 7 d share. Never a token",
parameters: [
idParam,
{
name: "tz_offset",
in: "query",
schema: { type: "integer" },
description: "The browser's getTimezoneOffset(), for the daily buckets",
},
],
responses: {
200: { description: "Account details" },
403: err("EADMIN — a developer"),
404: err("ENOUSER"),
},
},
},
"/api/users/{id}": {
get: {
tags: ["Auth"],
+35
View File
@@ -247,6 +247,15 @@ function createOpenApiSpec() {
"The AI coding CLI that produced the session: `claude` (Claude Code's hooks and imported transcripts), `codex` (Codex's rollout journals and its opt-in hooks) `antigravity` (agy's conversation files and its opt-in hooks) or `cursor` (Cursor's agent transcripts — sessions only, no tokens).",
},
agent_count: { type: "integer", nullable: true },
owner_id: {
type: "integer",
nullable: true,
description:
"Server mode, list only: the account owning the session's workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.",
},
owner_name: { type: "string", nullable: true, description: "That owner's username." },
owner_display_name: { type: "string", nullable: true },
owner_avatar_at: { type: "string", format: "date-time", nullable: true },
last_activity: { type: "string", format: "date-time", nullable: true },
cost: { type: "number", nullable: true },
awaiting_input_since: {
@@ -1234,6 +1243,32 @@ function createOpenApiSpec() {
node_version: { type: "string" },
platform: { type: "string" },
ws_connections: { type: "integer" },
event_loop: {
type: "object",
description:
"Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.",
properties: {
p50: { type: "number" },
p99: { type: "number" },
max: { type: "number" },
},
},
},
},
activity: {
type: "object",
description:
"For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller's rows).",
properties: {
last_event_at: { type: "string", nullable: true },
working_agents: { type: "integer" },
agents_24h: {
type: "object",
properties: {
completed: { type: "integer" },
error: { type: "integer" },
},
},
},
},
transcript_cache: {
+12 -5
View File
@@ -1,14 +1,17 @@
/**
* @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).
* @description `GET /api/host` — the Dashboard System Health tab's machine
* cards, its per-user CPU/RAM card included: 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); `GET /api/host/history` (`?range=1h|6h|24h`) the
* in-memory CPU/memory history the server keeps since it started. Read-only;
* every signed-in user (`/host` is NOT in STAFF_ONLY_PREFIXES — System Health
* is everyone's).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { hostStats } = require("../lib/host-stats");
const { hostStats, hostHistory } = require("../lib/host-stats");
const router = Router();
@@ -20,4 +23,8 @@ router.get("/", async (_req, res) => {
}
});
router.get("/history", (req, res) => {
res.json(hostHistory(typeof req.query.range === "string" ? req.query.range : "1h"));
});
module.exports = router;
+3 -20
View File
@@ -29,21 +29,8 @@ const { calculateCost, dailyTokenRows } = require("./pricing");
const router = Router();
// How long the event loop was held up — what an operator sees as a laggy
// terminal: every keystroke's echo waits for it. Sampled every 20 ms and reset
// each minute, so the gauges describe the last minute or so.
const LOOP_WINDOW_MS = 60_000;
const loopDelay = require("perf_hooks").monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
let loopWindow = null; // the last full minute, reported until the next one ends
setInterval(() => {
loopWindow = {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
loopDelay.reset();
}, LOOP_WINDOW_MS).unref();
// Event-loop delay over the last minute (server/lib/loop-delay.js).
const { loopDelay } = require("../lib/loop-delay");
// Resolved once at load — the dashboard version, surfaced as a build-info label
// (the canonical Prometheus idiom for exposing a version string as a metric).
@@ -116,11 +103,7 @@ router.get("/", (_req, res) => {
[{ value: process.memoryUsage().rss }]
);
const loop = loopWindow || {
p50: loopDelay.percentile(50),
p99: loopDelay.percentile(99),
max: loopDelay.max,
};
const loop = loopDelay();
appendMetric(
out,
"agenthub_event_loop_delay_seconds",
+34
View File
@@ -252,9 +252,43 @@ router.get("/", (req, res) => {
}
}
if (isServerMode()) attachOwners(rows);
res.json({ sessions: rows, limit, offset, total });
});
/**
* Server mode: stamp each listed row with its owner — the owner of the
* workspace the session belongs to (`sessions.lane_id`, else the lane of a
* dashboard run holding it, as `sessionWhere` decides ownership). Only the
* page's rows, so one small query per page.
*/
function attachOwners(rows) {
if (!rows.length) return;
const ids = rows.map((r) => r.id);
const owners = new Map();
for (let i = 0; i < ids.length; i += 900) {
const chunk = ids.slice(i, i + 900);
const found = db
.prepare(
`SELECT s.id AS session_id, u.id AS owner_id, u.username, u.display_name, u.avatar_at
FROM sessions s
JOIN lanes l ON l.id = COALESCE(s.lane_id,
(SELECT r.lane_id FROM dashboard_runs r WHERE r.session_id = s.id ORDER BY r.rowid DESC LIMIT 1))
JOIN users u ON u.id = l.owner_id
WHERE s.id IN (${chunk.map(() => "?").join(",")})`
)
.all(...chunk);
for (const o of found) owners.set(o.session_id, o);
}
for (const row of rows) {
const o = owners.get(row.id);
row.owner_id = o?.owner_id ?? null;
row.owner_name = o?.username ?? null;
row.owner_display_name = o?.display_name ?? null;
row.owner_avatar_at = o?.avatar_at ?? null;
}
}
router.get("/facets", (req, res) => {
const own = sessionWhere(req);
const rows = db
+27
View File
@@ -12,6 +12,7 @@ const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../d
const { getConnectionCount } = require("../websocket");
const { transcriptCache } = require("./hooks");
const { forgetHourly } = require("../lib/hourly-usage");
const { loopDelay } = require("../lib/loop-delay");
const router = Router();
@@ -129,6 +130,29 @@ router.get("/info", (req, res) => {
h1: getCount(60 * 60 * 1000),
};
// What the System Health score needs beyond the machine: when a hook last
// landed (silent hooks while agents work = a blind dashboard) and how the
// agents that finished in the last 24 h ended.
const lastEvent = db
.prepare(
`SELECT created_at FROM events WHERE ${ownEvents.where} ORDER BY created_at DESC LIMIT 1`
)
.get(...ownEvents.params);
const day = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
const agentRows = db
.prepare(
`SELECT status, COUNT(*) AS c FROM agents
WHERE (status = 'working' OR ended_at > ?) AND ${ownEvents.where} GROUP BY status`
)
.all(day, ...ownEvents.params);
const agentCount = (st) => agentRows.find((r) => r.status === st)?.c ?? 0;
const activity = {
last_event_at: lastEvent?.created_at ?? null,
working_agents: agentCount("working"),
agents_24h: { completed: agentCount("completed"), error: agentCount("error") },
};
const loop = loopDelay();
// A developer (server mode) gets the numbers, never a path of the host's:
// the transcript cache is keyed by EVERY user's transcript path (their
// folders, their session ids), and the DB/settings paths are the host's.
@@ -158,7 +182,10 @@ router.get("/info", (req, res) => {
total_mem: os.totalmem(),
free_mem: os.freemem(),
cpus: os.cpus().length,
// Event-loop delay over the last minute, in ms (server/lib/loop-delay.js).
event_loop: { p50: loop.p50 / 1e6, p99: loop.p99 / 1e6, max: loop.max / 1e6 },
},
activity,
transcript_cache: developer ? { ...cache, keys: undefined } : cache,
});
});
+250
View File
@@ -16,6 +16,12 @@
* (server/lib/member-tmux.js); a restart ends that member's panes.
*
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
* `GET /:id/details` everything the Administration page's user detail shows —
* the account and its logins, its workspaces (sessions, last activity, live
* panes), the panes running now, its newest sessions, spend per local day /
* AI tool / model over 30 days, the audit entries naming it, and per pool
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
*
@@ -217,6 +223,250 @@ router.get("/:id", (req, res) => {
});
});
const DAY_MS = 864e5;
const tokensOf = (r) =>
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
/** `{cost, tokens}` of token rows, priced by `calculateCost`. */
function priced(rows, rules) {
const { calculateCost } = require("./pricing");
return {
cost: rows.length ? calculateCost(rows, rules).total_cost : 0,
tokens: Math.round(rows.reduce((n, r) => n + tokensOf(r), 0)),
};
}
/** Rows grouped by `keyOf`, each group priced: `[{key, cost, tokens, sessions}]`, $ first. */
function breakdown(rows, keyOf, rules) {
const groups = new Map();
for (const r of rows) {
const k = keyOf(r) || "unknown";
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(r);
}
return [...groups]
.map(([key, list]) => ({
key,
...priced(list, rules),
sessions: new Set(list.map((r) => r.session_id)).size,
}))
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
}
/** Per pool tool: what `uid` may run on, and their own share of each account. */
async function poolFor(uid) {
const pins = require("../lib/account-pins");
const groups = require("../lib/pool-groups");
const { accountUsage } = require("../lib/pool-usage");
const pools = {
claude: require("../lib/claude-pool"),
codex: require("../lib/codex-pool"),
cursor: require("../lib/cursor-pool"),
};
await pools.claude.refreshQuotas().catch(() => undefined);
const group = db
.prepare(
`SELECT g.id, g.name FROM pool_group_users u JOIN pool_groups g ON g.id = u.group_id
WHERE u.user_id = ?`
)
.get(uid);
const since7d = new Date(Date.now() - 7 * DAY_MS).toISOString();
const used = db
.prepare(
`SELECT r.tool, r.account, COUNT(*) AS runs, MAX(r.started_at) AS lastAt,
SUM(r.status IN ('running', 'spawning')) AS live
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.account IS NOT NULL AND r.started_at >= ?
GROUP BY r.tool, r.account ORDER BY lastAt DESC`
)
.all(uid, since7d);
const out = {};
for (const tool of pins.TOOLS) {
const pool = pools[tool];
if (!pool) continue;
let status = [];
try {
status = pool.poolStatus();
} catch {
status = [];
}
const ok = groups.allowed(uid, tool);
const pin = pins.pinFor(uid, tool);
const ranOn = used.filter((u) => u.tool === tool);
const wholeSession = tool !== "claude";
out[tool] = {
pin,
group: group ? { id: group.id, name: group.name } : null,
accounts: status
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))
.map((a) => {
const mine = accountUsage(tool, a.name, { wholeSession, ownerId: uid });
const ran = ranOn.find((u) => u.account === a.name);
return {
name: a.name,
kind: a.kind,
email: a.email ?? null,
expired: Boolean(a.expired),
allowed: ok(a.name),
pinned: a.name === pin,
quota: a.quota ?? null,
quotaPercent: a.quotaPercent,
full: a.full,
livePanes: a.livePanes,
spend5h: a.spend5h,
spend7d: a.spend7d,
userSpend5h: mine.spend5h,
userSpend7d: mine.spend7d,
userTokens5h: mine.tokens5h,
userTokens7d: mine.tokens7d,
userRuns7d: ran?.runs ?? 0,
userLivePanes: ran?.live ?? 0,
userLastAt: ran?.lastAt ?? null,
};
}),
};
}
return out;
}
router.get("/:id/details", async (req, res) => {
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
try {
const { hourlyTokenRows } = require("./pricing");
const rules = stmts.listPricing.all();
const uid = u.id;
const owned = `SELECT session_id FROM (${OWNED_SESSIONS}) WHERE uid = ?`;
const off = parseInt(req.query.tz_offset, 10);
const offMin = Number.isFinite(off) && Math.abs(off) <= 840 ? off : 0;
// A UTC hour (`YYYY-MM-DDTHH`) → the viewer's local date.
const localDay = (hour) =>
new Date(Date.parse(`${hour}:00:00Z`) - offMin * 60e3).toISOString().slice(0, 10);
const since30 = hourAgo(30);
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (${owned})`,
[since30, uid]
).filter((r) => r.date >= since30);
const today = todayHour(offMin);
const since7 = hourAgo(7);
// One bar per local day, the oldest first, days with no spend included.
const byDay = new Map();
for (const r of rows) {
const d = localDay(r.date);
if (!byDay.has(d)) byDay.set(d, []);
byDay.get(d).push(r);
}
const daily = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - offMin * 60e3 - i * DAY_MS).toISOString().slice(0, 10);
daily.push({ date: d, ...priced(byDay.get(d) || [], rules) });
}
const workspaces = db
.prepare(
`SELECT l.id, l.title, l.cwd, l.tool, l.kind, l.branch, l.status, l.created_at AS createdAt,
(SELECT COUNT(*) FROM (${OWNED_SESSIONS}) o JOIN sessions s ON s.id = o.session_id
WHERE o.uid = l.owner_id AND (s.lane_id = l.id OR s.id IN
(SELECT session_id FROM dashboard_runs WHERE lane_id = l.id))) AS sessions,
(SELECT MAX(s.updated_at) FROM sessions s WHERE s.lane_id = l.id) AS lastActiveAt,
(SELECT COUNT(*) FROM dashboard_runs r
WHERE r.lane_id = l.id AND r.status IN ('running', 'spawning')) AS livePanes
FROM lanes l WHERE l.owner_id = ? ORDER BY lastActiveAt DESC, l.id`
)
.all(uid);
const panes = db
.prepare(
`SELECT r.id, r.lane_id AS laneId, l.title AS laneTitle, l.cwd AS laneCwd, r.tool,
r.model, r.account, r.proxy, r.session_id AS sessionId, r.started_at AS startedAt,
r.status
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.status IN ('running', 'spawning')
ORDER BY r.started_at DESC`
)
.all(uid);
const recent = db
.prepare(
`SELECT s.id, s.name, s.tool, s.model, s.status, s.cwd, s.lane_id, s.started_at,
s.ended_at, s.updated_at AS last_activity, s.awaiting_input_since, s.awaiting_reason
FROM sessions s WHERE s.id IN (${owned})
ORDER BY s.updated_at DESC LIMIT 15`
)
.all(uid);
if (recent.length) {
const cost = new Map(
breakdown(
hourlyTokenRows(
`WHERE s.id IN (${recent.map(() => "?").join(",")})`,
recent.map((r) => r.id)
),
(r) => r.session_id,
rules
).map((b) => [b.key, b])
);
for (const r of recent) {
r.cost = cost.get(r.id)?.cost ?? 0;
r.tokens = cost.get(r.id)?.tokens ?? 0;
}
}
const counts = db
.prepare(
`SELECT COUNT(*) AS total, SUM(s.status = 'active') AS active,
MIN(s.started_at) AS firstAt, MAX(s.updated_at) AS lastAt
FROM sessions s WHERE s.id IN (${owned})`
)
.get(uid);
const audit = db
.prepare(
`SELECT a.id, a.at, a.method, a.path, a.session_id,
r.username AS reader, o.username AS owner,
r.display_name AS reader_display, o.display_name AS owner_display
FROM audit_log a
LEFT JOIN users r ON r.id = a.user_id
LEFT JOIN users o ON o.id = a.owner_id
WHERE a.user_id = ? OR a.owner_id = ?
ORDER BY a.id DESC LIMIT 30`
)
.all(uid, uid);
res.json({
user: { ...auth.publicUser(u), ...auth.loginStats(uid) },
sessions: {
total: counts.total || 0,
active: counts.active || 0,
firstAt: counts.firstAt || null,
lastAt: counts.lastAt || null,
},
usage: {
today: priced(
rows.filter((r) => r.date >= today),
rules
),
last7d: priced(
rows.filter((r) => r.date >= since7),
rules
),
last30d: priced(rows, rules),
daily,
byTool: breakdown(rows, (r) => r.tool || "claude", rules),
byModel: breakdown(rows, (r) => r.model, rules),
},
workspaces,
panes,
recentSessions: recent,
pool: await poolFor(uid),
audit,
});
} catch (e) {
res.status(500).json({ error: { code: e.code || "EDETAILS", message: e.message } });
}
});
router.get("/:id/avatar", (req, res) => {
const id = Number(req.params.id);
if (