Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
@@ -0,0 +1,311 @@
/**
* Geometry math for the cost center charts: pure functions, no React / no
* JSX, easy to unit test (see test/usage-charts.test.ts). The two "last 30
* days" charts (the daily Token bar's three-segment stack, the daily cost
* line + area) share one coordinate system — canvas width, padding, the
* x()/y() mapping, SVG paths, x-axis label indices. The stacked bar's
* horizontal layout (fixed 25px bar width, spacing ≥ bar width) is computed
* by tokenBarLayout, and per-segment geometry (including per-segment hit
* bands) is produced by barSegments; there's also pie-slice geometry (each
* Agent's call count) and success-rate normalization. See chart-svg.tsx for the render skeleton.
*
* **Canvas width = the container's measured pixel width (1 canvas unit = 1
* CSS pixel)**: the SVG no longer stretches/scales via a fixed viewBox —
* a scaled-down "bar width" would be a fake pixel discounted by the
* container's width (640 units squeezed into a half-width cell becomes
* ~495px, a 0.77 factor), while requirements like "at least 25px wide" must
* land on **real display pixels**. So the canvas width is supplied by the caller after measuring the container.
*/
/** Canvas height and padding (carried over from the original TrendChart constants; width is now measured from the container, see the file header). */
export const CHART_H = 200;
export const PAD_L = 46;
export const PAD_R = 8;
export const PAD_T = 10;
export const PAD_B = 22;
/** The daily Token chart's three buckets (bottom-to-top stacking order is output → cacheWrite → cacheRead). */
export type TokenBucketKey = "cacheRead" | "cacheWrite" | "output";
/** A chart's coordinate system: canvas width w, data point count n, y-axis upper bound max, and x()/y() mapping "index / value" to canvas coordinates. */
export interface ChartGeom {
n: number;
max: number;
/** Total canvas width (= viewBox width = CSS pixel width). */
w: number;
innerW: number;
innerH: number;
step: number;
x: (i: number) => number;
y: (v: number) => number;
}
/**
* Build the coordinate system: x takes each cell's midpoint, y runs
* top-to-bottom with max as the full height. w is the canvas width (pixels).
* When `max <= 0` (no data / all zero), y always takes the baseline —
* callers already guarantee max > 0, but this is an exported public pure
* function, and without this guard a single 0 would turn the entire chart's coordinates into NaN / Infinity.
*/
export function makeGeom(n: number, max: number, w: number): ChartGeom {
const innerW = Math.max(0, w - PAD_L - PAD_R);
const innerH = CHART_H - PAD_T - PAD_B;
const step = n > 0 ? innerW / n : innerW;
return {
n,
max,
w,
innerW,
innerH,
step,
x: (i) => PAD_L + step * i + step / 2,
y: (v) => PAD_T + innerH * (1 - (max > 0 ? v / max : 0)),
};
}
/** Line path: `M x0,y0 L x1,y1 …` (identical to the original TrendChart's cost line). */
export function linePath(geom: ChartGeom, values: number[]): string {
return values.map((v, i) => `${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(v)}`).join(" ");
}
/** Area path: the line drops vertically to the baseline (y=0) at the end, then closes back along the baseline to the start; used by the cost line's fill layer. */
export function areaPath(geom: ChartGeom, values: number[]): string {
const n = values.length;
if (n === 0) return "";
const baseY = geom.y(0);
const parts: string[] = [];
for (let i = 0; i < n; i++)
parts.push(`${i === 0 ? "M" : "L"}${geom.x(i)},${geom.y(values[i]!)}`);
parts.push(`L${geom.x(n - 1)},${baseY}`);
parts.push(`L${geom.x(0)},${baseY}`);
parts.push("Z");
return parts.join(" ");
}
/** Sparse x-axis label indices: first, middle, last (labeling every point would blur together when cells are narrow and there are many points). */
export function sparseLabelIdx(n: number): number[] {
if (n <= 0) return [];
if (n === 1) return [0];
if (n === 2) return [0, 1];
return [0, Math.floor((n - 1) / 2), n - 1];
}
/** Horizontal space a single date label (`MM-DD`, fontSize 9) takes up: roughly 28px of text width plus breathing room. */
const LABEL_MIN_PX = 40;
/**
* Adaptive x-axis label indices: label more of them when each cell is wide
* enough (the stride = the number of cells needed to fit the next label).
* The Token bar chart's cells are each ≥ 2×25px, so in practice every day
* gets labeled; when cells are narrow it automatically skips a few cells between labels so they don't blur together.
*/
export function autoLabelIdx(n: number, step: number): number[] {
if (n <= 0) return [];
const stride = step > 0 ? Math.max(1, Math.ceil(LABEL_MIN_PX / step)) : n;
const idx: number[] = [];
for (let i = 0; i < n; i += stride) idx.push(i);
return idx;
}
/** Request success rate: no requests (total=0) is treated as 1 (matches the old bar's convention, avoiding 0/0). */
export function successRate(completed: number, total: number): number {
return total > 0 ? completed / total : 1;
}
// —— Daily Token: bar + three-segment stack ——
/**
* Bar width (**real CSS pixels**, since 1 canvas unit = 1 pixel): **a fixed
* value, not a minimum** — it used to be implemented as "no less than 25px",
* which made bars stretch to fill the container when there were few points
* (3 daily points could balloon to ~180px), defeating the intent of "25px
* bar width". Now it's always 25px: scroll when it doesn't fit, and give the extra space to bar spacing when it does.
*/
export const BAR_W = 25;
/**
* Minimum height of the per-segment hover hit band (canvas units = pixels,
* innerH=168): in real data, output is often under 1% of the day's total
* (sub-pixel height), and if the hit area equaled the visual rectangle it
* would be un-hoverable — highlighting down to "every segment" is this
* chart's core requirement. Widening the bar (≥25px) doesn't help the
* vertical dimension either: a sub-pixel value stays sub-pixel, so this
* floor must be kept.
* (The hit band's **width** is a separate matter: it spans the full cell horizontally, see TokenBarChart's hitLayer.)
*/
export const MIN_HIT_H = 8;
/** The Token bar chart's horizontal layout: bar width (always BAR_W), total canvas width, and whether the content overflows the container (needing horizontal scroll). */
export interface TokenBarLayout {
/** Bar width (CSS pixels): always BAR_W. */
barW: number;
/** Total canvas width (CSS pixels): fills the container, or overflows it per "bar + equal spacing". */
chartW: number;
/** Canvas is wider than the container: the caller needs horizontal scrolling to see it all. */
scroll: boolean;
}
/**
* Token bar chart horizontal layout: **bar width is always BAR_W (25 real
* pixels), bar spacing ≥ bar width**.
* - Many points (n×2×25px doesn't fit): each cell is exactly 2× the bar
* width, and the canvas overflows the container in real pixels →
* the container scrolls horizontally (no scaling, no squeezing);
* - Few points (fits): the canvas fills the container, and **all the extra
* space goes to bar spacing** — bars no longer stretch (the old
* implementation treated 25px as a floor, letting 3 daily points' bars
* balloon to ~180px), they just stand farther apart.
*/
export function tokenBarLayout(containerW: number, n: number): TokenBarLayout {
const innerW = Math.max(0, containerW - PAD_L - PAD_R);
const needed = 2 * BAR_W * n; // inner width needed to lay out n bars (bar + equal spacing)
if (needed <= innerW) return { barW: BAR_W, chartW: containerW, scroll: false };
return { barW: BAR_W, chartW: PAD_L + needed + PAD_R, scroll: true };
}
/** One segment within a bar: the visual rectangle is drawn strictly to value, the hit band is computed separately (small segments are raised to be hoverable). */
export interface BarSegment {
key: TokenBucketKey;
value: number;
/** Visual rectangle: segments sit flush against each other, total height = the day's total (no visual floor, no inflating the bar's height). */
y: number;
h: number;
/** Hit band: fills the whole bar bottom-to-top with no overlap, small segments raised to minHit. */
hitY: number;
hitH: number;
}
/**
* Hit-band height allocation (water-filling): segments below minHit are
* raised to minHit, the rest share the remaining space proportionally to
* their visual height; when the whole bar is shorter than k*minHit it
* degrades to an even split (nobody can squeeze anybody else out).
* Guarantee: the segment heights sum to total (the hit band fills the whole bar with no overlap).
*/
function hitHeights(heights: number[], total: number, minHit: number): number[] {
const k = heights.length;
if (k === 0) return [];
if (total <= k * minHit) return heights.map(() => total / k);
const small = new Set<number>();
// Each round adds at most one segment to small; total > k*minHit guarantees not every segment gets added (some segment must end up with > minHit).
for (;;) {
const rest = total - small.size * minHit;
const bigSum = heights.reduce((s, h, i) => (small.has(i) ? s : s + h), 0);
const scaled = (i: number) =>
bigSum > 0 ? (heights[i]! / bigSum) * rest : rest / (k - small.size);
const next = heights.findIndex((_, i) => !small.has(i) && scaled(i) < minHit);
if (next < 0) return heights.map((_, i) => (small.has(i) ? minHit : scaled(i)));
small.add(next);
}
}
/** Stacking order: bottom-to-top output → cacheWrite → cacheRead (matches TOKEN_COLORS' shading, darkest at the bottom). */
const STACK_ORDER: readonly TokenBucketKey[] = ["output", "cacheWrite", "cacheRead"];
/**
* A bar's three-segment stack: bottom-to-top output → cacheWrite →
* cacheRead, a zero-value bucket produces no segment (not drawn, and shouldn't be hoverable). The visual rectangle is drawn strictly to value; see hitHeights for the hit band.
*/
export function barSegments(
geom: ChartGeom,
p: { cacheRead: number; cacheWrite: number; output: number },
): BarSegment[] {
const stack = STACK_ORDER.map((key) => ({ key, value: p[key] })).filter((b) => b.value > 0);
if (stack.length === 0) return [];
// Visual rectangles: the top edge is taken from the cumulative value, so segments sit flush against each other.
const rects: Array<{ y: number; h: number }> = [];
let cum = 0;
for (const b of stack) {
const bottom = geom.y(cum);
cum += b.value;
const top = geom.y(cum);
rects.push({ y: top, h: bottom - top });
}
const base = geom.y(0);
const hits = hitHeights(
rects.map((r) => r.h),
base - geom.y(cum),
MIN_HIT_H,
);
let hitBottom = base;
return stack.map((b, i) => {
const hitH = hits[i]!;
const seg: BarSegment = {
key: b.key,
value: b.value,
y: rects[i]!.y,
h: rects[i]!.h,
hitY: hitBottom - hitH,
hitH,
};
hitBottom -= hitH;
return seg;
});
}
// —— Each Agent's call count: pie chart ——
const TAU = Math.PI * 2;
/** Path coordinates keep 2 decimal places: the path string stays short and readable, and is easy to assert on in unit tests. */
const rnd = (v: number): number => Math.round(v * 100) / 100;
/** Take a point in polar coordinates: angle is measured from 12 o'clock, clockwise-positive (SVG's y-axis points down). */
function polar(cx: number, cy: number, r: number, angle: number): [number, number] {
const a = angle - Math.PI / 2;
return [rnd(cx + r * Math.cos(a)), rnd(cy + r * Math.sin(a))];
}
/** A single pie slice. */
export interface PieSlice {
/** Index within the passed-in values (the caller uses this to look up name and color). */
index: number;
value: number;
/** Fraction of the total [0,1]. */
frac: number;
/** Start/end angle (radians, clockwise from 12 o'clock). */
start: number;
end: number;
/** The slice's path. */
path: string;
}
/**
* Slice path: `M center L start A radius … end Z`; sweep=1 means clockwise,
* large-arc=1 when spanning more than a semicircle.
* At 100% the start and end points coincide and the A command degrades into
* "draws nothing" — split into two semicircular arcs to get a full circle.
*/
function slicePath(cx: number, cy: number, r: number, start: number, end: number): string {
if (end - start >= TAU - 1e-9) {
const [tx, ty] = polar(cx, cy, r, 0);
const [bx, by] = polar(cx, cy, r, Math.PI);
return `M${tx},${ty} A${r},${r} 0 1 1 ${bx},${by} A${r},${r} 0 1 1 ${tx},${ty} Z`;
}
const [x0, y0] = polar(cx, cy, r, start);
const [x1, y1] = polar(cx, cy, r, end);
const large = end - start > Math.PI ? 1 : 0;
return `M${rnd(cx)},${rnd(cy)} L${x0},${y0} A${r},${r} 0 ${large} 1 ${x1},${y1} Z`;
}
/**
* Pie slices: laid out clockwise from 12 o'clock in the order passed in,
* each slice's angle = that value's share of the total.
* Non-positive values produce no slice (a 0-degree arc is a degenerate
* path); when the total ≤ 0, returns empty (the caller falls back to an empty state).
*/
export function pieSlices(values: number[], cx: number, cy: number, r: number): PieSlice[] {
const total = values.reduce((s, v) => s + Math.max(0, v), 0);
if (total <= 0) return [];
const slices: PieSlice[] = [];
let start = 0;
values.forEach((value, index) => {
if (value <= 0) return;
const frac = value / total;
const end = start + frac * TAU;
slices.push({ index, value, frac, start, end, path: slicePath(cx, cy, r, start, end) });
start = end;
});
return slices;
}
@@ -0,0 +1,204 @@
/**
* Shared SVG skeleton for the daily trend charts (extracted from the
* original TrendChart, reused by both the daily Token stacked bar and the
* daily cost line): 4 horizontal grid lines + y-axis ticks, x-axis dates, a
* hover vertical indicator line + a transparent hit area + a value bubble
* that follows the cursor. "Data marks" (line / area / bars) are drawn by
* the caller as children in the same x()/y() coordinate system;
* see chart-geom.ts for the coordinate math.
*
* **1 canvas unit = 1 CSS pixel**: the SVG renders at real pixel width per
* geom.w (not scaled via viewBox), so sizing requirements like "25px bar
* width" land on real display pixels. The canvas width is supplied by the
* caller after measuring the container with useChartWidth; when the canvas
* is wider than the container (e.g. the Token bar chart stretched out by its bar-width floor), the outer container scrolls horizontally, and the bubble scrolls along with the content.
*
* Two hit-granularity tiers: the default is "whole column" (the cost line —
* a column only has one value); the bar chart passes hitLayer to override
* it as "per-segment" (a column has three segments, each independently
* hoverable), in which case hover only serves as the bubble's anchor (a
* column index).
* The hover vertical line likewise has two tiers: on the line chart it's a
* necessary x-position indicator, while on the bar chart the bar itself
* already indicates the x position — an extra vertical line would just be
* noise, so the bar chart passes hoverLine={false} to turn it off.
*/
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { CHART_H, PAD_L, PAD_R, PAD_T, sparseLabelIdx, type ChartGeom } from "./chart-geom";
/** Upper bound on bubble width: clamps the bubble back inside the canvas near the right edge, so it doesn't spuriously trigger extra horizontal scroll. */
const BUBBLE_W = 160;
/**
* Measure the available width inside the chart card (CSS pixels, rounded
* down — a few stray tenths of a pixel would otherwise spawn a scrollbar out of nowhere).
* The canvas is drawn at real pixels, so the container must be measured
* first; returns 0 before it's measured (the first frame), and the caller
* skips rendering the chart at that point.
* What's measured is the **outer plain div** (not the scroll container), whose width is independent of the canvas content and won't trigger the scrollbar back and forth.
*/
export function useChartWidth(): [RefObject<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => setWidth(Math.floor(el.getBoundingClientRect().width));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width];
}
export function ChartFrame({
geom,
fmtY,
dates,
hover,
onHover,
bubble,
hitLayer,
labels,
hoverLine = true,
scrollToEnd = false,
children,
}: {
geom: ChartGeom;
/** y-axis tick formatting (abbreviated for Token, currency for cost). */
fmtY: (v: number) => string;
/** Each point's date (x-axis labels and the hit area align to this). */
dates: string[];
/** Currently hovered column index (the anchor for the vertical line and bubble). */
hover: number | null;
/** Callback for the default hit area (whole column); always called with null when the mouse leaves the whole chart. */
onHover: (i: number | null) => void;
/** Bubble content while hovering point i (omit to not show a bubble). */
bubble?: (i: number) => ReactNode;
/** Custom hit layer (per-segment hits for the bar chart): omit to use the default "whole column" transparent hit area. */
hitLayer?: ReactNode;
/** Indices for x-axis labels (omit for the default first/middle/last sparse labeling): the bar chart's cells are each wide, so it can label more via autoLabelIdx. */
labels?: number[];
/** Hover vertical indicator line (drawn by default): the bar chart turns it off — the bar itself already indicates the x position, so an extra line is just noise. */
hoverLine?: boolean;
/** Scroll to the far right by default when the canvas is wider than the container: the daily chart shows the most recent days first (scroll left for earlier ones). */
scrollToEnd?: boolean;
/** Data marks: bars / line / area, drawn between the grid and the hit area. */
children?: ReactNode;
}) {
const { x, y, w, innerH, step, max } = geom;
const gridLevels = [0, 0.25, 0.5, 0.75, 1].map((f) => max * f);
const labelIdx = labels ?? sparseLabelIdx(dates.length);
const scrollRef = useRef<HTMLDivElement>(null);
// The daily chart defaults to sitting on the most recent day (there's only
// room to scroll when the canvas is wider than the container). It
// re-snaps whenever the data or canvas width changes, without disturbing a position the user has manually scrolled to in the meantime.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!scrollToEnd || !el) return;
el.scrollLeft = el.scrollWidth - el.clientWidth;
}, [scrollToEnd, w, dates.length]);
return (
// Horizontal scroll when the canvas is wider than the container (bar
// width has a pixel floor, so 30 days won't fit in a half-width panel);
// the bubble is this container's absolutely-positioned child element and scrolls along with the content, so anchoring it to the column by pixels is enough.
<div ref={scrollRef} className="relative overflow-x-auto">
<svg
viewBox={`0 0 ${w} ${CHART_H}`}
width={w}
height={CHART_H}
className="text-gray-600 dark:text-gray-400"
role="img"
onMouseLeave={() => onHover(null)}
>
{/* Grid lines and y-axis ticks (recessive gray) */}
{gridLevels.map((v, i) => (
<g key={i}>
<line
x1={PAD_L}
x2={w - PAD_R}
y1={y(v)}
y2={y(v)}
className="stroke-gray-200 dark:stroke-gray-800"
strokeWidth={1}
/>
<text
x={PAD_L - 6}
y={y(v) + 3}
textAnchor="end"
className="fill-gray-400 dark:fill-gray-500"
fontSize={9}
>
{fmtY(v)}
</text>
</g>
))}
{/* Hover vertical indicator line (line-chart-only: the bar chart's bar itself is the x indicator, see hoverLine) */}
{hoverLine && hover !== null && dates[hover] && (
<line
x1={x(hover)}
x2={x(hover)}
y1={PAD_T}
y2={PAD_T + innerH}
className="stroke-gray-300 dark:stroke-gray-700"
strokeWidth={1}
/>
)}
{/* Data marks (provided by the caller) */}
{children}
{/* x-axis dates */}
{labelIdx.map((i) => {
const d = dates[i];
if (!d) return null;
return (
<text
key={i}
x={x(i)}
y={CHART_H - 6}
textAnchor="middle"
className="fill-gray-400 dark:fill-gray-500"
fontSize={9}
>
{d.slice(5)}
</text>
);
})}
{/* Hover hit area (larger than the mark itself): whole column by default, the bar chart swaps in hitLayer for per-segment */}
{hitLayer ??
dates.map((_, i) => (
<rect
key={`hit-${i}`}
x={PAD_L + step * i}
y={PAD_T}
width={step}
height={innerH}
fill="transparent"
className="cursor-crosshair"
onMouseEnter={() => onHover(i)}
/>
))}
</svg>
{bubble && hover !== null && dates[hover] && (
<div
className="pointer-events-none absolute top-0 rounded border border-gray-200 bg-white px-2 py-1 text-xs shadow-sm dark:border-gray-700 dark:bg-gray-900"
// Anchored near that column's left edge, clamped back inside the
// canvas (1 unit = 1 pixel, positioned directly in pixels; the
// left edge can't be negative, since the scroll container would clip off the part that sticks out).
style={{ left: `${Math.max(0, Math.min(x(hover) - 30, w - BUBBLE_W))}px` }}
>
{bubble(hover)}
</div>
)}
</div>
);
}
@@ -0,0 +1,138 @@
/**
* Server-side error view for the cost center: **a single panel** — a row of small
* stats up top (total / unexpected / expected / most common error code),
* with a recent-errors table below (time, source · error code, kind,
* message). What an error needs to answer is "what exactly went wrong" — a
* detail table is more direct than a chart here: the count alone in the stats already covers the summary.
*
* Color semantics are consistent site-wide: unexpected (500s / runtime
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
*/
import type { UsageErrors } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
import { Badge } from "../../components/ui/badge";
import { Empty } from "./usage-charts";
/** The two error categories. */
type ErrorKindKey = "unexpected" | "expected";
/** Copy: S is a runtime live binding (switching language remounts the whole tree), so it must be read at render time. */
function kindLabel(key: ErrorKindKey): string {
return key === "unexpected" ? S.usage.errorsUnexpected : S.usage.errorsExpected;
}
function kindOf(kind: string): ErrorKindKey {
return kind === "unexpected" ? "unexpected" : "expected";
}
/** A single small stat: name + value, one row side by side (not turned into a chart). */
function Stat({
label,
value,
alert,
muted,
}: {
label: string;
value: string;
/** Prominent value (unexpected errors): rose. */
alert?: boolean;
muted?: boolean;
}) {
const tone = alert
? "text-rose-600 dark:text-rose-400"
: muted
? "text-gray-500 dark:text-gray-400"
: "text-gray-900 dark:text-gray-100";
return (
<div className="flex items-baseline gap-1.5">
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
<span className={`font-mono text-sm font-semibold tabular-nums ${tone}`}>{value}</span>
</div>
);
}
/** Header cell: left-aligned, recessive gray; stickiness is handled by thead. */
function Th({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <th className={`py-1.5 pr-2 font-medium ${className}`}>{children}</th>;
}
/**
* Error panel: stats + a recent-errors table (the server already takes the top N, newest first).
* The table is table-fixed with in-cell truncation: a long message doesn't break the layout, and the full text goes into title.
*/
export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
const { total, unexpected, topCode, recent } = errors;
return (
<div>
{/* Stats: a row of small stats (unexpected is prominent, expected recedes) */}
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1.5">
<Stat label={S.usage.errorsTotal} value={String(total)} />
<Stat
label={S.usage.errorsUnexpected}
value={String(unexpected)}
alert={unexpected > 0}
muted={unexpected === 0}
/>
<Stat label={S.usage.errorsExpected} value={String(total - unexpected)} muted />
{topCode && (
<Stat
label={S.usage.errorsTopCode}
value={`${topCode.source} · ${topCode.code} ×${topCode.count}`}
/>
)}
</div>
{/* Recent-errors table */}
{recent.length === 0 ? (
<Empty text={S.usage.errorsEmpty} />
) : (
<div className="mt-2.5 max-h-72 overflow-y-auto border-t border-gray-200 dark:border-gray-800">
<table className="w-full table-fixed text-xs">
<thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500">
<tr>
<Th className="w-32">{S.usage.errorsColTime}</Th>
{/* Wide enough to fully fit the longest error code: a tool
failure's code carries the tool name (e.g. environment ·
tool_failed:exec_command), and truncating it would hide which tool failed. */}
<Th className="w-72">{S.usage.errorsColCode}</Th>
<Th className="w-20">{S.usage.errorsColKind}</Th>
<Th>{S.usage.errorsColMessage}</Th>
</tr>
</thead>
<tbody>
{recent.map((e, i) => {
const key = kindOf(e.kind);
return (
<tr
key={`${e.ts}-${i}`}
className="border-t border-gray-100 dark:border-gray-800/60"
>
<td className="py-1.5 pr-2 font-mono tabular-nums text-gray-400">
{formatDateTime(e.ts)}
</td>
<td className="py-1.5 pr-2 font-mono text-gray-500 dark:text-gray-400">
<span className="block truncate" title={`${e.source} · ${e.code}`}>
{e.source} · {e.code}
</span>
</td>
<td className="py-1.5 pr-2">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
</td>
<td className="py-1.5 text-gray-500 dark:text-gray-400">
<span className="block truncate" title={e.message}>
{e.message}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,85 @@
/**
* Daily cost trend chart (hand-drawn SVG, no chart
* library; a single accent color + gray grid, desaturated in dark mode, no
* clashing red/green): a line + a semi-transparent area layered down to the
* baseline to reinforce the trend over time, with a hover vertical line + whole-column hit area + bubble.
* The coordinate system / grid / hover logic is extracted into chart-svg.tsx's ChartFrame (shared with the daily Token bar chart).
*
* Canvas width = the container's measured pixels (1 unit = 1 pixel, see
* chart-svg): the line chart itself has no "minimum step" requirement, so it
* simply fills the container and never scrolls horizontally — but once the
* Token bar chart went full-width, this chart shares the same row, and if it
* still stretched a fixed 640-unit viewBox, its height would get capped by max-h and centered with large empty margins on both sides.
*/
import { useState } from "react";
import type { UsageTrendPoint } from "@prismshadow/penguin-server/api";
import { formatMoney } from "../../lib/format";
import type { Currency } from "../../state/theme";
import { makeGeom, linePath, areaPath } from "./chart-geom";
import { ChartFrame, useChartWidth } from "./chart-svg";
export function TrendChart({
points,
currency = "USD",
}: {
points: UsageTrendPoint[];
currency?: Currency;
}) {
const [hover, setHover] = useState<number | null>(null);
const [ref, width] = useChartWidth();
const cost = points.map((p) => p.cost ?? 0);
const max = Math.max(1e-9, ...cost);
const geom = makeGeom(points.length, max, width);
const dates = points.map((p) => p.date);
return (
<div ref={ref}>
{width > 0 && (
<ChartFrame
geom={geom}
fmtY={(v) => formatMoney(v, currency)}
dates={dates}
hover={hover}
onHover={setHover}
bubble={(i) => {
const p = points[i]!;
return (
<>
<p className="text-gray-400">{p.date}</p>
<p className="font-mono">{formatMoney(p.cost, currency)}</p>
</>
);
}}
>
<g>
{/* Area fill: the line closes down to the baseline, low opacity reinforces the trend's sense of "volume" */}
<path
d={areaPath(geom, cost)}
className="fill-current"
stroke="none"
opacity={hover !== null ? 0.06 : 0.1}
/>
<path
d={linePath(geom, cost)}
fill="none"
stroke="currentColor"
strokeWidth={2}
opacity={hover !== null ? 0.35 : 1}
/>
{points.map((p, i) => (
<circle
key={p.date}
cx={geom.x(i)}
cy={geom.y(p.cost ?? 0)}
r={hover === i ? 4 : 2.5}
className="fill-current"
opacity={hover !== null && hover !== i ? 0.25 : 1}
/>
))}
</g>
</ChartFrame>
)}
</div>
);
}
@@ -0,0 +1,392 @@
/**
* Cost center stat charts: hand-drawn SVG
* / flex, no chart library. The form follows the nature of the data —
* - AgentPieChart: each Agent's call count → a pie chart (compositional
* share; each slice's angle is that Agent's share of total calls);
* - SuccessBarChart: each Model's success rate → a horizontal progress bar
* with a 100% track (shows how far from perfect at a glance), filled with
* a single uniform color (the bar's length alone conveys magnitude, not a three-color threshold);
* - TokenBarChart: daily Token buckets → a three-segment stacked bar (one
* bar per day, bottom-to-top output → cacheWrite → cacheRead, same blue
* family, darkest at the bottom and lightest at the top; bar width fixed
* at 25 real pixels, spacing ≥ bar width, scrolls horizontally when it doesn't fit the card).
* Daily cost reuses TrendChart (line + area fill).
*
* Unified highlight interaction (a site-wide convention): highlight = fade
* out the rest. Pie slices and the legend are linked both ways; the Token
* bar is **precise down to the segment** — hovering a given day's given
* bucket lights up only that segment, and the bubble reports only that segment's value (not the whole column's total).
*/
import { useState } from "react";
import type {
UsageAgentCount,
UsageSuccessRate,
UsageTrendPoint,
} from "@prismshadow/penguin-server/api";
import { catalogEntryFor, providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { S } from "../../lib/strings";
import { humanizeTokens } from "../../lib/format";
import { TOKEN_COLORS } from "../../lib/token-colors";
import { categoryColor } from "../../lib/category-colors";
import {
makeGeom,
autoLabelIdx,
barSegments,
tokenBarLayout,
pieSlices,
successRate,
type TokenBucketKey,
} from "./chart-geom";
import { ChartFrame, useChartWidth } from "./chart-svg";
/** Empty state for a chart card (defaults to "no usage records yet"; the errors chart passes its own copy). */
export function Empty({ text }: { text?: string }) {
return <p className="py-6 text-center text-xs text-gray-400">{text ?? S.usage.empty}</p>;
}
/** Bucket name copy: S is a runtime live binding (switching language remounts the whole tree), so it must be read at render time and never cached at module scope. */
function bucketLabel(key: TokenBucketKey): string {
if (key === "cacheRead") return S.usage.colCacheRead;
if (key === "cacheWrite") return S.usage.colCacheWrite;
return S.usage.colOutput;
}
/** Highlight = fade out the rest. */
const DIM = "opacity-25";
// —— Each Agent's call count: pie chart ——
/** Pie chart canvas (square viewBox) and radius: leave a 5px margin so slice edges don't get clipped by the viewBox. */
const PIE_SIZE = 160;
const PIE_R = 75;
/**
* Each Agent's call count → a pie chart: each slice's angle = that Agent's
* share of total calls, laid out clockwise from 12 o'clock sorted by
* requests descending (re-sorted here defensively). Slices and the legend
* on the right link both ways: hovering either side lights up the other and fades out the rest.
* When a single Agent holds 100%, pieSlices degrades to a full circle (see chart-geom).
*/
export function AgentPieChart({ data }: { data: UsageAgentCount[] }) {
const [hover, setHover] = useState<number | null>(null);
const total = data.reduce((s, d) => s + d.requests, 0);
if (data.length === 0 || total <= 0) return <Empty />;
const rows = [...data].sort((a, b) => b.requests - a.requests);
const slices = pieSlices(
rows.map((d) => d.requests),
PIE_SIZE / 2,
PIE_SIZE / 2,
PIE_R,
);
const pct = (v: number) => `${Math.round((v / total) * 100)}%`;
const dim = (i: number) => (hover !== null && hover !== i ? DIM : "");
return (
<div className="flex items-center gap-3" onMouseLeave={() => setHover(null)}>
<svg
viewBox={`0 0 ${PIE_SIZE} ${PIE_SIZE}`}
className="h-40 w-40 shrink-0"
role="img"
aria-label={S.usage.chartAgentCalls}
>
{slices.map((s) => {
const d = rows[s.index]!;
return (
<path
key={d.agentId}
d={s.path}
onMouseEnter={() => setHover(s.index)}
className={`cursor-pointer ${categoryColor(s.index).fill} transition-opacity duration-150 ${dim(s.index)}`}
>
<title>{`${d.agentId} · ${d.requests} ${S.usage.requests} · ${pct(d.requests)}`}</title>
</path>
);
})}
</svg>
{/* Legend: name + count + share (hover links to the pie slice; a long agentId truncates, with the title giving the full name and total Token count) */}
<ul className="flex max-h-40 min-w-0 flex-1 flex-col gap-1 overflow-y-auto">
{rows.map((d, i) => (
<li
key={d.agentId}
onMouseEnter={() => setHover(d.requests > 0 ? i : null)}
title={`${d.agentId} · ${d.requests} ${S.usage.requests} · ${humanizeTokens(d.total)}`}
className={`flex cursor-pointer items-center gap-1.5 text-[10px] transition-opacity duration-150 ${dim(i)}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-sm ${categoryColor(i).swatch}`}
/>
<span className="min-w-0 flex-1 truncate font-mono text-gray-500 dark:text-gray-400">
{d.agentId}
</span>
<span className="shrink-0 font-mono tabular-nums text-gray-500 dark:text-gray-400">
{d.requests}
</span>
<span className="w-8 shrink-0 text-right font-mono tabular-nums text-gray-400 dark:text-gray-500">
{pct(d.requests)}
</span>
</li>
))}
</ul>
</div>
);
}
// —— Each Model's success rate: horizontal progress bar ——
/** A progress bar row's shell: label on the left + track/fill in the middle + value on the right. Fades when hovering a different row. */
function BarRow({
dimmed,
onEnter,
title,
label,
value,
children,
}: {
dimmed: boolean;
onEnter: () => void;
title: string;
label: string;
value: string;
children: React.ReactNode;
}) {
return (
<div
onMouseEnter={onEnter}
className={`flex cursor-pointer items-center gap-2 transition-opacity duration-150 ${dimmed ? DIM : ""}`}
title={title}
>
<span className="w-24 shrink-0 truncate font-mono text-[10px] text-gray-500 dark:text-gray-400">
{label}
</span>
{children}
<span className="w-10 shrink-0 text-right font-mono text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{value}
</span>
</div>
);
}
/** Row label: falls back from the catalog display name to the upstream id ((provider, modelId) paired lookup against the catalog). */
function successLabel(d: UsageSuccessRate): string {
return catalogEntryFor(d.provider, d.modelId)?.displayName ?? d.modelId;
}
/**
* Hover detail: the model's paired reference (upstream id + provider name) +
* `completed/denominator` + a failure breakdown + excluded aborted runs. The
* denominator already excludes aborted (the user clicking "stop" isn't a
* model failure), so aborted is listed as its own item and labeled "not counted".
*/
function successTitle(d: UsageSuccessRate): string {
const parts = [`${d.completed}/${d.total}`];
if (d.failed > 0) parts.push(`failed ${d.failed}`);
if (d.timeout > 0) parts.push(`timeout ${d.timeout}`);
if (d.malformed > 0) parts.push(`malformed ${d.malformed}`);
if (d.aborted > 0) parts.push(`${S.usage.successAborted} ${d.aborted}`);
const provider = providerInfo(d.provider)?.label ?? d.provider;
return `${d.modelId} · ${provider} · ${parts.join(" · ")}`;
}
/**
* Each Model's success rate → a horizontal progress bar: the 100% track
* (light gray) makes "how far from perfect" obvious at a glance, with the
* percentage shown on the right. Filled with a **single uniform color**
* (sky, the same primary color family as the Token chart): the bar's length
* alone already conveys magnitude — a three-color threshold (green/yellow/red) would just re-encode the same information and add two more colors to the page unrelated to any site-wide meaning.
*/
export function SuccessBarChart({ data }: { data: UsageSuccessRate[] }) {
const [hover, setHover] = useState<number | null>(null);
if (data.length === 0) return <Empty />;
return (
<div className="flex flex-col gap-1.5" onMouseLeave={() => setHover(null)}>
{data.map((d, i) => {
const rate = successRate(d.completed, d.total);
const pct = Math.round(rate * 100);
return (
<BarRow
// Row key is a pair: the same model_id can coexist under multiple providers, so using modelId alone would collide.
key={`${d.provider}:${d.modelId}`}
dimmed={hover !== null && hover !== i}
onEnter={() => setHover(i)}
title={successTitle(d)}
label={successLabel(d)}
value={`${pct}%`}
>
<div className="h-3 min-w-0 flex-1 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-800">
<div
className="h-full rounded-sm bg-sky-500 dark:bg-sky-400"
style={{ width: `${rate * 100}%` }}
/>
</div>
</BarRow>
);
})}
</div>
);
}
// —— Daily Token: three-segment stacked bar ——
/** The currently hovered segment: which day (column index), which bucket. */
interface SegHover {
i: number;
key: TokenBucketKey;
}
/**
* Daily Token buckets → a three-segment stacked bar (SVG, reusing
* TrendChart's coordinate system and grid), bottom-to-top output → cacheWrite → cacheRead.
*
* **Bar width fixed at 25 real pixels, spacing ≥ bar width** (see
* chart-geom's tokenBarLayout): the canvas renders at real pixels per the
* container's measured width (1 canvas unit = 1 pixel, no scaling); when 30
* days' worth of n×2×25px doesn't fit a half-width card, the canvas
* overflows and ChartFrame's container carries horizontal scroll; with few
* points the bars **never stretch** (25px is a fixed value, not a floor) —
* all the extra space goes to bar spacing, and the canvas still fills the card without a scrollbar.
*
* **Each segment is an independent, individually hoverable rect**: the hit
* layer swaps ChartFrame's whole-column hit area for a per-segment hit band
* (see chart-geom's barSegments — the hit band fills the whole bar and small
* segments have a height floor, otherwise a sub-pixel output segment would
* be un-hoverable; widening the bar doesn't help the vertical dimension
* either). Hitting a segment highlights only that segment and fades out
* everything else; the bubble reports only that segment's date/bucket
* name/Token count. When legend is passed in (legend hover), it highlights all segments of the matching bucket.
* No hover vertical line is drawn (hoverLine={false}): the bar itself already indicates the x position.
*/
export function TokenBarChart({
trend,
legend,
}: {
trend: UsageTrendPoint[];
/** The bucket currently hovered in the legend (highlights matching segments); null = none. */
legend?: TokenBucketKey | null;
}) {
const [hover, setHover] = useState<SegHover | null>(null);
// Bar width is a pixel constraint, so the container must be measured
// first (unmeasured on the first frame → width=0, at which point nothing is rendered — see the ref container below).
const [ref, width] = useChartWidth();
if (trend.length === 0) return <Empty />;
const sums = trend.map((p) => p.cacheRead + p.cacheWrite + p.output);
const max = Math.max(1, ...sums);
const { barW, chartW, scroll } = tokenBarLayout(width, trend.length);
const geom = makeGeom(trend.length, max, chartW);
const dates = trend.map((p) => p.date);
const segs = trend.map((p) => barSegments(geom, p));
// Highlight = fade out the rest: segment-level hover leaves only "that day's that bucket", legend hover leaves all segments of the matching bucket.
const dimmed = (i: number, key: TokenBucketKey) =>
(hover !== null && !(hover.i === i && hover.key === key)) || (legend != null && legend !== key);
return (
<div ref={ref}>
{width > 0 && (
<ChartFrame
geom={geom}
fmtY={(v) => humanizeTokens(Math.round(v))}
dates={dates}
hover={hover?.i ?? null}
// Each cell ≥ 50px: dates can be labeled every day (autoLabelIdx sets the density by cell width, so they never blur together).
labels={autoLabelIdx(trend.length, geom.step)}
// The bar itself indicates x position: no hover vertical line spanning the whole chart.
hoverLine={false}
// 30 days doesn't fit a half-width card (bar width fixed at 25px): defaults to sitting on the most recent day, scrolling left for earlier ones.
scrollToEnd={scroll}
// Per-segment hits go through hitLayer below; ChartFrame only calls back when the mouse leaves the whole chart (i=null).
onHover={(i) => {
if (i === null) setHover(null);
}}
bubble={(i) => {
const p = trend[i]!;
const key = hover?.key;
if (!key) return null;
return (
<>
<p className="text-gray-400">{p.date}</p>
<p className="font-mono">
{bucketLabel(key)} {humanizeTokens(p[key])}
</p>
</>
);
}}
hitLayer={trend.map((p, i) =>
segs[i]!.map((s) => (
// The hit band is as wide as the bar horizontally (empty space
// outside the bar doesn't trigger highlighting), and split by
// segment vertically with no overlap (small segments raised
// to the minimum hit height, see hitHeights). Highlighting
// clears as soon as the pointer leaves the bar — otherwise the
// previous segment's highlight would linger when moving from the bar into the empty space.
<rect
key={`hit-${p.date}-${s.key}`}
x={geom.x(i) - barW / 2}
y={s.hitY}
width={barW}
height={s.hitH}
fill="transparent"
className="cursor-pointer"
onMouseEnter={() => setHover({ i, key: s.key })}
onMouseLeave={() => setHover(null)}
/>
)),
)}
>
{trend.map((p, i) =>
segs[i]!.map((s) => (
<rect
key={`${p.date}-${s.key}`}
x={geom.x(i) - barW / 2}
y={s.y}
width={barW}
height={s.h}
fill={TOKEN_COLORS[s.key]}
className="transition-opacity duration-150"
opacity={dimmed(i, s.key) ? 0.2 : 1}
/>
)),
)}
</ChartFrame>
)}
</div>
);
}
/** Token bar chart legend (cacheRead / cacheWrite / output): hovering an item highlights matching segments (fading out the rest). */
export function TokenLegend({
active,
onHover,
}: {
active?: TokenBucketKey | null;
onHover?: (key: TokenBucketKey | null) => void;
}) {
const items: Array<[TokenBucketKey, string]> = [
["cacheRead", S.usage.colCacheRead],
["cacheWrite", S.usage.colCacheWrite],
["output", S.usage.colOutput],
];
return (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{items.map(([key, label]) => (
<button
key={key}
type="button"
onMouseEnter={() => onHover?.(key)}
onMouseLeave={() => onHover?.(null)}
className={`flex items-center gap-1 text-[10px] text-gray-500 transition-opacity duration-150 dark:text-gray-400 ${
active != null && active !== key ? "opacity-30" : ""
}`}
>
<span
className="inline-block h-2 w-3 rounded-sm"
style={{ backgroundColor: TOKEN_COLORS[key] }}
/>
{label}
</button>
))}
</div>
);
}
@@ -0,0 +1,306 @@
/**
* Cost and usage center:
* top filters for Agent / Model + a date range (controls have no external
* title, the explanation is written into the dropdown options themselves);
* three summary cards (today / last 7 days / cumulative, each stat on its own row);
* four business charts arranged two-by-two, each taking half the width — a
* row of compositional charts (each Agent's call count pie chart, each
* Model's success rate progress bar), and a row of time series (daily Token
* three-segment stacked bar, daily cost line + area): Token bar width is
* fixed at 25px, and it scrolls horizontally within the card when 30 days
* doesn't fit the half-width; below that is a full-width "errors" panel
* (stats + a recent-errors table).
* Currency follows the user's settings; a row with unconfigured pricing shows its cost as "—".
*/
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useDocumentTitle } from "../../lib/use-document-title";
import { formatMoney, humanizeTokens } from "../../lib/format";
import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog";
import { useProject } from "../../state/project";
import { useTheme } from "../../state/theme";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { Skeleton } from "../../components/ui/skeleton";
import { TrendChart } from "./trend-chart";
import type { TokenBucketKey } from "./chart-geom";
import { AgentPieChart, SuccessBarChart, TokenBarChart, TokenLegend } from "./usage-charts";
import { ErrorsPanel } from "./errors-panel";
function isoDate(d: Date): string {
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** A summary card's stat row: name on the left, value on the right — each item on its own row, so a narrow card no longer crams them into one wrapping line. */
function SummaryRow({
label,
value,
muted,
sup,
}: {
label: string;
value: string;
muted?: boolean;
/** Show a superscript marker (the cost row's "includes unpriced records" asterisk sits next to the **name**, not stuck after the number). */
sup?: boolean;
}) {
const tone = muted ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100";
return (
<div className="flex items-baseline justify-between gap-2">
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{label}
{sup && <sup className="ml-px">*</sup>}
</span>
<span className={`min-w-0 truncate font-mono text-sm font-semibold tabular-nums ${tone}`}>
{value}
</span>
</div>
);
}
/** Usage summary card: a title + Token / request count / cost each on their own row. */
function SummaryCard({
title,
bucket,
currency,
}: {
title: string;
bucket: UsageBucket;
currency: "USD" | "CNY";
}) {
return (
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
<p className="mb-1.5 text-xs font-medium text-gray-500 dark:text-gray-400">{title}</p>
<div className="space-y-0.5">
<SummaryRow label={S.usage.tokens} value={humanizeTokens(bucket.total)} />
<SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted />
{/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */}
<SummaryRow
label={S.usage.colCost}
value={formatMoney(bucket.cost, currency)}
sup={bucket.hasUncosted}
/>
</div>
</div>
);
}
/** Chart card container: title + content (bounded height, avoiding stretching the whole page and triggering extra scroll). */
function ChartCard({
title,
extra,
children,
}: {
title: string;
extra?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">{title}</p>
{extra}
</div>
{children}
</div>
);
}
export function UsagePage() {
useDocumentTitle(S.usage.title);
const { currency } = useTheme();
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
// ?agentId= deep link (from the Agents page's "cost" entry point): the URL
// parameter is the single source of truth for this filter — including
// clearing it (/usage?agentId=A → clicking nav to /usage doesn't remount,
// so the filter must be reset); manually changing the filter doesn't write
// back to the URL (consistent with the existing convention that the model
// / date filters likewise don't enter the URL — the effect never overrides a manual selection when the parameter is unchanged).
const [searchParams] = useSearchParams();
const paramAgentId = searchParams.get("agentId");
const [agentFilter, setAgentFilter] = useState<string>(paramAgentId ?? "");
// Model filtering is a **paired reference** (the same model_id can coexist
// under multiple providers); the dropdown's option value uses the
// candidate's index rather than a concatenated string — the reference is always passed as a pair, never concatenated into an id.
const [modelFilter, setModelFilter] = useState<ModelRefDto | null>(null);
useEffect(() => {
setAgentFilter(paramAgentId ?? "");
}, [paramAgentId]);
const [from, setFrom] = useState(() => {
const d = new Date();
d.setDate(d.getDate() - 29);
return isoDate(d);
});
const [to, setTo] = useState(() => isoDate(new Date()));
const [data, setData] = useState<UsageResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// The bucket currently hovered in the legend: the legend lives in the card
// header (ChartCard's extra) while the bars live inside the card, so this state is lifted to this level.
const [tokenBucket, setTokenBucket] = useState<TokenBucketKey | null>(null);
const load = useCallback(async () => {
if (!projectId) return;
setError(null);
try {
const res = await api.getUsage(projectId, {
from,
to,
// The detail table has been removed, superseded by the charts above; groupBy is still a required query parameter, fixed to group by date.
groupBy: "date",
...(agentFilter ? { agentId: agentFilter } : {}),
...(modelFilter ? { provider: modelFilter.provider, modelId: modelFilter.modelId } : {}),
});
setData(res);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
}
}, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]);
useEffect(() => {
setData(null);
void load();
}, [load]);
if (!projectId) return null;
// Model filter candidates and the currently selected item's index (the option value uses the index, avoiding concatenating an id as the key).
const modelOptions = data?.models ?? [];
const selectedModelIndex = modelFilter
? modelOptions.findIndex(
(m) => m.provider === modelFilter.provider && m.modelId === modelFilter.modelId,
)
: -1;
const modelFilterIndex = selectedModelIndex >= 0 ? String(selectedModelIndex) : "";
const summary = data?.summary;
const hasUncostedRows =
(summary?.today.hasUncosted ?? false) ||
(summary?.last7d.hasUncosted ?? false) ||
(summary?.total.hasUncosted ?? false);
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-5xl space-y-4">
{/* Top filters: controls have no external title (the explanation is written into the "all …" option), so they're baseline-centered with the page title */}
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold">{S.usage.title}</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="w-32">
<Select
size="sm"
value={agentFilter}
onChange={(e) => setAgentFilter(e.target.value)}
>
<option value="">{S.usage.filterAllAgents}</option>
{/* The deep-linked agent must still show as the selected option even if it has no usage records yet (not in agentIds) */}
{agentFilter && !(data?.agentIds ?? []).includes(agentFilter) && (
<option value={agentFilter}>{agentFilter}</option>
)}
{(data?.agentIds ?? []).map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</Select>
</div>
<div className="w-32">
<Select
size="sm"
value={modelFilterIndex}
onChange={(e) => {
const i = e.target.value;
setModelFilter(i === "" ? null : (modelOptions[Number(i)] ?? null));
}}
>
<option value="">{S.usage.filterAllModels}</option>
{modelOptions.map((m, i) => (
<option key={`${m.provider}:${m.modelId}`} value={String(i)}>
{catalogEntryFor(m.provider, m.modelId)?.displayName ?? m.modelId}
</option>
))}
</Select>
</div>
{/* Date range: a dash between the two inputs stands in for a "from/to" label */}
<div className="flex items-center gap-1.5">
<Input
size="sm"
type="date"
aria-label={S.usage.from}
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
<span className="shrink-0 text-gray-400" aria-hidden>
–
</span>
<Input
size="sm"
type="date"
aria-label={S.usage.to}
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</div>
</div>
</div>
{/* Summary cards (today / last 7 days / cumulative) */}
{data ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SummaryCard title={S.usage.today} bucket={data.summary.today} currency={currency} />
<SummaryCard title={S.usage.last7d} bucket={data.summary.last7d} currency={currency} />
<SummaryCard title={S.usage.total} bucket={data.summary.total} currency={currency} />
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
)}
{/* Four business charts two-by-two, each taking half width: a row of
compositional charts (pie chart / success rate), a row of time
series (daily Token / daily cost). Token bar width fixed at 25px, scrolls horizontally within the card when 30 days doesn't fit the half-width */}
{data ? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<ChartCard title={S.usage.chartAgentCalls}>
<AgentPieChart data={data.byAgent} />
</ChartCard>
<ChartCard title={S.usage.chartSuccessRate}>
<SuccessBarChart data={data.success} />
</ChartCard>
{/* The legend lives in the card header, the bars live inside the card: the hover-linked bucket state is lifted to this level to be shared */}
<ChartCard
title={S.usage.chartTokenTrend}
extra={<TokenLegend active={tokenBucket} onHover={setTokenBucket} />}
>
<TokenBarChart trend={data.trend} legend={tokenBucket} />
</ChartCard>
<ChartCard title={S.usage.chartCostTrend}>
<TrendChart points={data.trend} currency={currency} />
</ChartCard>
</div>
) : (
<Skeleton className="h-64" />
)}
{/* Errors (a single full-width panel: stats + a recent-errors table) */}
{data && (
<ChartCard title={S.usage.errors}>
<ErrorsPanel errors={data.errors} />
</ChartCard>
)}
{hasUncostedRows && <p className="text-xs text-gray-400">{S.usage.uncostedNote}</p>}
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</div>
);
}