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
+159 -8
View File
@@ -546,6 +546,83 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
/** One pool account as one user's detail sees it: the account's totals and their share. */
export type UserPoolAccount = {
name: string;
kind: string;
email: string | null;
expired: boolean;
/** Their group/pin lets them run on it (false = they ran on it before). */
allowed: boolean;
pinned: boolean;
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
quotaPercent: number | null;
full: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
userSpend5h: number;
userSpend7d: number;
userTokens5h: number;
userTokens7d: number;
userRuns7d: number;
userLivePanes: number;
userLastAt: string | null;
};
/** `GET /api/users/:id/details` — the Administration page's user detail. */
export type UserDetails = {
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
usage: {
today: { cost: number; tokens: number };
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** 30 local days, the oldest first. */
daily: { date: string; cost: number; tokens: number }[];
byTool: UsageSlice[];
byModel: UsageSlice[];
};
workspaces: {
id: number;
title: string;
cwd: string;
tool: string;
kind: string;
branch: string | null;
status: string;
createdAt: string;
sessions: number;
lastActiveAt: string | null;
livePanes: number;
}[];
panes: {
id: string;
laneId: number;
laneTitle: string;
laneCwd: string;
tool: string;
model: string | null;
account: string | null;
proxy: string | null;
sessionId: string | null;
startedAt: string;
status: string;
}[];
recentSessions: (Session & { tokens?: number })[];
pool: Partial<
Record<
PoolTool,
{
pin: string | null;
group: { id: number; name: string } | null;
accounts: UserPoolAccount[];
}
>
>;
audit: AuditEntry[];
};
/** One member's own tmux server (`GET /api/users/tmux`). */
export interface MemberTmuxRow {
id: number;
@@ -578,6 +655,8 @@ export interface HostUsageGroup {
kind: "user" | "host" | "system";
userId?: number;
username?: string;
/** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number;
/** Resident memory, bytes (shared pages count in every process). */
@@ -587,13 +666,31 @@ export interface HostUsageGroup {
/** Busiest processes, by the kernel's short name only. */
top: { pid: number; name: string; cpu: number; memory: number }[];
}
/** One `/proc/pressure/<resource>` reading, in % of the window. */
export interface HostPressure {
some10: number;
some60: number;
full10: 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[] };
/** When the machine booted (ISO); absent from an older server. */
bootTime?: string;
cpu: {
model: string | null;
/** Logical CPUs (threads). */
cores: number;
load: number[];
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
physicalCores?: number | null;
/** Busy % of each logical CPU over the sample; [] off Linux. */
perCore?: number[];
};
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
disks: {
device: string;
@@ -609,6 +706,46 @@ export interface HostStats {
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
/** null off Linux. */
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
/** Bytes/s per network interface over the sample (`lo` left out). */
network?: { name: string; rx: number; tx: number }[];
/** Bytes/s per whole disk over the sample. */
diskIo?: { name: string; read: number; write: number }[];
/** `/sys/class/hwmon` sensors; [] when none. */
temperatures?: { chip: string; label: string; celsius: number }[];
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
* each resource; null without PSI (or off Linux). */
pressure?: {
cpu: HostPressure | null;
memory: HostPressure | null;
io: HostPressure | null;
} | null;
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
/** The dashboard's own process. */
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
}
/** One point of `GET /api/host/history`. */
export interface HostHistoryPoint {
at: string;
/** Machine CPU %, null when it could not be sampled. */
cpu: number | null;
/** Memory used, % of total. */
memory: number | null;
memoryUsed: number;
swapUsed: number;
load1: number | null;
/** Per user id: CPU % and resident bytes (users with any use only). */
users: Record<string, { cpu: number; memory: number }>;
}
export type HostHistoryRange = "1h" | "6h" | "24h";
/** `GET /api/host/history` — kept in memory since the server started. */
export interface HostHistory {
range: HostHistoryRange;
intervalMs: number;
/** False when this server keeps no history (local mode, not Linux). */
running: boolean;
startedAt: string | null;
points: HostHistoryPoint[];
}
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
@@ -1090,9 +1227,11 @@ export const api = {
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
host: {
stats: () => request<HostStats>("/host"),
history: (range: HostHistoryRange = "1h") =>
request<HostHistory>(`/host/history?range=${range}`),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
@@ -1102,6 +1241,9 @@ export const api = {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/:id/details — one account in full (admin/analyst). */
details: (id: number) =>
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
@@ -2010,10 +2152,13 @@ export const api = {
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
method: "POST",
body: JSON.stringify(body),
}),
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
"/workspaces/gc",
{
method: "POST",
body: JSON.stringify(body),
}
),
},
// ─────────────────────────────── Workflows API ──────────────────────────────
@@ -4288,7 +4433,10 @@ export const api = {
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
request<{ lane: Lane }>(`/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
/**
* POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id.
@@ -4299,7 +4447,10 @@ export const api = {
id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) =>
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`.