1c6dfea5b5
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.
166 lines
5.4 KiB
TypeScript
166 lines
5.4 KiB
TypeScript
/**
|
|
* @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>
|
|
);
|
|
}
|