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
+165
View File
@@ -0,0 +1,165 @@
/**
* @file MiniChart.tsx
* @description The Administration page's small single-series charts: a bar
* chart (a user's daily spend) and a line chart (System Health's CPU and
* memory history), plain SVG in the app's theme tokens. One series each — the
* card's title names it, so there is no legend — thin marks, rounded bar ends
* on the baseline, a recessive grid with a max label, and a hover tooltip
* (crosshair for the line, per bar for the bars) whose text is the caller's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, useState, type MouseEvent } from "react";
export type ChartPoint = { x: string; y: number };
const H = 120;
const PAD_TOP = 14;
const PAD_BOTTOM = 18;
/**
* @param props.points - In x order; `x` is the axis/tooltip label.
* @param props.kind - `bar` or `line`.
* @param props.format - Formats a value for the max label and the tooltip.
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
* @param props.label - The chart's accessible name.
*/
export function MiniChart({
points,
kind,
format,
max,
label,
emptyText,
}: {
points: ChartPoint[];
kind: "bar" | "line";
format: (v: number) => string;
max?: number;
label: string;
emptyText?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
const [hover, setHover] = useState<number | null>(null);
if (points.length === 0)
return (
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
{emptyText ?? "—"}
</div>
);
const W = 600;
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
const plotH = H - PAD_TOP - PAD_BOTTOM;
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
const step = W / points.length;
const xOf = (i: number) =>
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
const onMove = (e: MouseEvent<SVGSVGElement>) => {
const r = ref.current?.getBoundingClientRect();
if (!r || r.width === 0) return;
const fx = ((e.clientX - r.left) / r.width) * W;
const i =
kind === "bar"
? Math.floor(fx / step)
: Math.round((fx / W) * Math.max(1, points.length - 1));
setHover(Math.max(0, Math.min(points.length - 1, i)));
};
const h = hover != null ? points[hover] : null;
const barW = Math.max(2, Math.min(24, step - 2));
const line = points
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
.join("");
return (
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="block h-[120px] w-full"
role="img"
aria-label={label}
onMouseMove={onMove}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((f) => (
<line
key={f}
x1={0}
x2={W}
y1={yOf(top * f)}
y2={yOf(top * f)}
className="stroke-border"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
strokeDasharray={f === 0 ? undefined : "3 3"}
/>
))}
{kind === "bar" ? (
points.map((p, i) => {
const y = yOf(p.y);
const bh = Math.max(0, PAD_TOP + plotH - y);
const r = Math.min(4, barW / 2, bh);
const x = xOf(i) - barW / 2;
const base = PAD_TOP + plotH;
// Rounded top only; the base sits flat on the axis.
const d =
bh <= 0
? ""
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
return (
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
);
})
) : (
<>
<path
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
className="fill-accent-solid"
opacity={0.12}
/>
<path
d={line}
fill="none"
className="stroke-accent-solid"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
{hover != null && (
<line
x1={xOf(hover)}
x2={xOf(hover)}
y1={PAD_TOP}
y2={PAD_TOP + plotH}
className="stroke-fg-muted"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
)}
</>
)}
</svg>
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
{format(top)}
</span>
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
<span>{points[0]!.x}</span>
<span>{points[points.length - 1]!.x}</span>
</div>
{h && hover != null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
role="status"
>
<div className="text-fg-muted">{h.x}</div>
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
</div>
)}
</div>
);
}