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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user