fix(ui): address usage redesign review findings (#45229)

* fix(ui): drill into an agent on the tags it was counted from, not every version

* test(ui): assert agent drill-in tags do not double count

* fix(ui): key chart series and totals so model names and repeated dates cannot collide

* test(ui): cover series key collisions and cross-year totals

* fix(ui): give stacked chart series display labels and a date tick formatter

* fix(ui): pass series labels and ISO dates to the top models chart

* fix(ui): color leaderboard rows by series label

* fix(ui): count agent coverage across all agents before the top ten

* fix(ui): key the flat cost series separately from model names

* fix(ui): show top agents only on the unfiltered admin global view

* fix(ui): skip the tag summary fetch when top agents is hidden

* fix(ui): build segmented controls on radio group for arrow key support

* test(ui): cover segmented control keyboard navigation

* chore(ui): drop descriptive styling comments

* chore(ui): drop descriptive styling comment from team user spend card

* chore(ui): drop descriptive styling comments from top model view

* chore(ui): drop descriptive palette comment

* chore(ui): drop descriptive styling comments from top key view

* chore(ui): drop descriptive comment from activity metrics

* chore(ui): drop descriptive layout comments from user agent activity
This commit is contained in:
ishaan-berri 2026-10-07 18:43:15 -07:00 • committed by GitHub
parent dcf92b44ba
commit ef694bc584
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 406 additions and 215 deletions

View file

@ -8,7 +8,7 @@ const isoDay = (date: Date) => {
};
/** Per-tag spend, tokens and requests for a date range, including the `User-Agent:` tags agents are read from. */
export const useTagSummary = (startTime: Date | null, endTime: Date | null) => {
export const useTagSummary = (startTime: Date | null, endTime: Date | null, enabled = true) => {
const { accessToken } = useAuthorized();
return $api.useQuery(
"get",
@ -22,7 +22,7 @@ export const useTagSummary = (startTime: Date | null, endTime: Date | null) => {
},
},
{
enabled: Boolean(accessToken && startTime && endTime),
enabled: enabled && Boolean(accessToken && startTime && endTime),
select: (data) => data.results,
},
);

View file

@ -59,7 +59,6 @@ export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProp
return keys;
}, [chartData]);
// Same palette as the Overview / Model Leaderboard stacked chart
const colors = useMemo(() => categories.map((_, i) => stackedUsageColor(i)), [categories]);
return (

View file

@ -36,6 +36,7 @@ import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } fro
import {
bucketSeries,
bucketTotals,
labelForDate,
dailyTotals,
formatMetricValue,
type Granularity,
@ -66,15 +67,16 @@ const SCALE_OPTIONS = [
{ value: "linear", label: "Linear" },
{ value: "log", label: "Log" },
] as const satisfies readonly { value: StackedUsageScale; label: string }[];
/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */
const QUIET_HEADER = { headerClassName: "font-normal" };
const FLAT_COST_KEY = "flat_cost";
/** Stacks reserved-capacity flat cost on top of the per-model spend, so each bar is the day's full cost. */
const withFlatCost = (series: Series, results: readonly DailyData[]): Series => {
const flatByDate = new Map(results.map((day) => [day.date, day.metrics.flat_cost ?? 0]));
return {
data: series.data.map((day) => ({ ...day, [FLAT_COST_SERIES]: flatByDate.get(day.date) ?? 0 })),
keys: [...series.keys, FLAT_COST_SERIES],
data: series.data.map((day) => ({ ...day, [FLAT_COST_KEY]: flatByDate.get(day.date) ?? 0 })),
keys: [...series.keys, FLAT_COST_KEY],
labels: [...series.labels, FLAT_COST_SERIES],
colors: [...series.colors, FLAT_COST_COLOR],
};
};
@ -418,7 +420,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const trend = trendKey ? (
<Sparkline data={dailyTrend} dataKey={trendKey} color={BRAND} className="h-12" />
) : undefined;
// The lead tile spans the row on narrow screens so five tiles never leave an empty cell.
const span = index === 0 ? "col-span-2 lg:col-span-1" : undefined;
if (!tile.expandable) {
return (
@ -499,11 +500,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<StackedUsageChart
data={chartSeries.data}
series={chartSeries.keys}
labels={chartSeries.labels}
colors={chartSeries.colors}
xKey="label"
xKey="date"
xLabel={(date) => labelForDate(chartSeries, date)}
scale={scale}
format={chartFormat}
totalFor={(label) => chartTotals.get(label)}
totalFor={(date) => chartTotals.get(date)}
className="h-[320px]"
/>
</div>
@ -657,7 +660,6 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</AlertDescription>
</Alert>
)}
{/* A compact toolbar row: the header's own bottom margin and stacked label give way to the grid gap. */}
<div className="max-w-full [&>div:first-child]:mb-0 [&>div:first-child_label]:sr-only">
<UsageExportHeader
dateValue={dateValue}

View file

@ -26,7 +26,6 @@ interface SpendByProviderProps {
providerSpend: ProviderSpendData[];
}
/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */
const QUIET_HEADER = { headerClassName: "font-normal" };
const columns: ColumnDef<ProviderSpendData>[] = [

View file

@ -28,7 +28,6 @@ interface TeamUserSpendCardProps {
teamIds: string[];
}
/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */
const QUIET_HEADER = { headerClassName: "font-normal" };
const columns: ColumnDef<TeamUserSpendRow>[] = [

View file

@ -31,9 +31,7 @@ const VIEW_OPTIONS = [
{ value: "chart", label: "Chart View" },
] as const satisfies readonly { value: ViewMode; label: string }[];
/** Brand blue, matching the single-series charts on the usage overview. */
const BAR_COLOR = "#2b3fd6";
/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */
const QUIET_HEADER = { headerClassName: "font-normal" };
function ModelName({ name }: { name: string | undefined }) {

View file

@ -188,16 +188,17 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
enabled: dailyActivityRequest !== null,
deps: [accessToken, startTime, endTime, effectiveUserId],
});
// Per-day tag activity for the Top agents chart; each day's User-Agent tags are folded into agents.
// Not user-scoped: tags are deployment-wide, the same as the User Agent Activity view.
// Tag data is deployment-wide with no per-user dimension, so Top agents only appears where the
// rest of the page is deployment-wide too: an admin's global view with no user selected.
const showTopAgents = isAdmin && usageView === "global" && effectiveUserId === null;
const tagDailyRequest = useMemo<DailyActivityRequest | null>(
() => (accessToken && startTime && endTime ? { accessToken, startTime, endTime, entityIds: null } : null),
[accessToken, startTime, endTime],
);
const { data: tagDailyRaw, loading: tagDailyLoading } = useAggregatedDailyActivity({
fetch: () => ENTITY_API.tag.aggregated(tagDailyRequest as DailyActivityRequest),
enabled: tagDailyRequest !== null && canViewTagUsage,
deps: [accessToken, startTime, endTime, canViewTagUsage],
enabled: tagDailyRequest !== null && showTopAgents,
deps: [accessToken, startTime, endTime, showTopAgents],
});
const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]);
const [agentActivityTags, setAgentActivityTags] = useState<readonly string[] | undefined>(undefined);
@ -257,7 +258,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
() => rollUpBreakdown(userSpendData.results, "providers").map(({ key, ...row }) => ({ provider: key, ...row })),
[userSpendData.results],
);
const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime);
const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime, showTopAgents);
// Calculate top API keys from the breakdown data
const topKeys = useMemo<TopKeyItem[]>(
@ -414,13 +415,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
) : null
}
topAgents={
canViewTagUsage ? (
showTopAgents ? (
<TopAgents
rows={tagSummary}
daily={tagDaily}
loading={tagSummaryLoading || tagDailyLoading}
totalTokens={totals.tokens}
onOpenAgent={isAdmin ? openAgentActivity : undefined}
onOpenAgent={openAgentActivity}
/>
) : null
}

View file

@ -123,8 +123,8 @@ export function Leaderboard({
columns?: 1 | 2;
dense?: boolean;
}) {
const colorOf = (key: string) => {
const index = series.keys.indexOf(key);
const colorOf = (name: string) => {
const index = series.labels.indexOf(name);
return index === -1 ? OTHER_COLOR : series.colors[index];
};
const value = (row: LeaderRow) => rankValue(row, metric);

View file

@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React, { useState } from "react";
import { describe, expect, it } from "vitest";
import { Segmented } from "./Primitives";
const OPTIONS = [
{ value: "spend", label: "Spend" },
{ value: "tokens", label: "Tokens" },
{ value: "requests", label: "Requests" },
] as const;
function Harness() {
const [value, setValue] = useState<(typeof OPTIONS)[number]["value"]>("spend");
return <Segmented label="Metric" value={value} options={OPTIONS} onChange={setValue} />;
}
describe("Segmented", () => {
it("is a radio group with one tab stop that moves selection with the arrow keys", async () => {
const user = userEvent.setup();
render(<Harness />);
expect(screen.getByRole("radiogroup", { name: "Metric" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Spend" })).toBeChecked();
await user.tab();
expect(screen.getByRole("radio", { name: "Spend" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: "Tokens" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Tokens" })).toHaveFocus();
// A single tab stop: tabbing again leaves the group rather than visiting each option.
await user.tab();
expect(screen.getByRole("radio", { name: "Requests" })).not.toHaveFocus();
});
it("selects an option on click", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByRole("radio", { name: "Requests" }));
expect(screen.getByRole("radio", { name: "Requests" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Spend" })).not.toBeChecked();
});
});

View file

@ -1,6 +1,8 @@
"use client";
import React, { type ReactNode } from "react";
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { Area, AreaChart } from "recharts";
import type { LucideIcon } from "lucide-react";
import { ChartContainer } from "@/components/ui/chart";
@ -10,7 +12,6 @@ import type { SeriesDay } from "./overviewData";
const EMPTY_CONFIG = {};
/** Every overview card shares this inset so titles, numbers and chart edges line up down the page. */
export const PANEL_INSET_X = "px-5";
export function Panel({
@ -111,25 +112,22 @@ export function Segmented<T extends string>({
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex items-center rounded-lg bg-muted p-0.5">
<RadioGroupPrimitive
aria-label={label}
value={value}
onValueChange={(next) => onChange(next as T)}
className="inline-flex items-center rounded-lg bg-muted p-0.5"
>
{options.map((option) => (
<button
<RadioPrimitive.Root
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
value === option.value
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
value={option.value}
className="rounded-md px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-checked:bg-background data-checked:text-foreground data-checked:shadow-xs"
>
{option.label}
</button>
</RadioPrimitive.Root>
))}
</div>
</RadioGroupPrimitive>
);
}

View file

@ -8,6 +8,7 @@ import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import {
bucketTotals,
labelForDate,
formatCompact,
formatCompactUsd,
formatMetricValue,
@ -57,13 +58,12 @@ function ClaudeSpark() {
);
}
function AgentMark({ agent, size = "md" }: { agent: AgentRow; size?: "sm" | "md" }) {
function AgentGlyph({ agent }: { agent: AgentRow }) {
if (agent.id === "claude-code") return <ClaudeSpark />;
if (agent.logo) return <Logo src={agent.logo} label={agent.label} className="size-5" />;
const monogram = MONOGRAMS[agent.id];
let mark: React.ReactNode;
if (agent.id === "claude-code") mark = <ClaudeSpark />;
else if (agent.logo) mark = <Logo src={agent.logo} label={agent.label} className="size-5" />;
else if (monogram)
mark = (
if (monogram) {
return (
<span
className={cn(
"flex size-full items-center justify-center text-xs font-semibold tracking-tight",
@ -73,15 +73,14 @@ function AgentMark({ agent, size = "md" }: { agent: AgentRow; size?: "sm" | "md"
{monogram.text}
</span>
);
else mark = <span className="text-sm font-semibold uppercase text-muted-foreground">{agent.label.charAt(0)}</span>;
}
return <span className="text-sm font-semibold uppercase text-muted-foreground">{agent.label.charAt(0)}</span>;
}
function AgentMark({ agent }: { agent: AgentRow }) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-background",
size === "sm" ? "size-6 [&_svg]:size-4" : "size-9",
)}
>
{mark}
<span className="inline-flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg border bg-background">
<AgentGlyph agent={agent} />
</span>
);
}
@ -137,6 +136,12 @@ function AgentItem({
);
}
function TopAgentsBody({ loading, empty, children }: { loading: boolean; empty: boolean; children: React.ReactNode }) {
if (loading) return <ChartSkeleton className="mx-5 h-64 w-auto" />;
if (empty) return <p className="py-10 text-center text-xs text-muted-foreground">No agent traffic in this range</p>;
return <>{children}</>;
}
/**
* The agents and clients driving traffic, read from the proxy's `User-Agent:` tags: a daily stacked
* chart (like Top models) over a ranked list. Named agents lead the list; SDKs and scripts follow,
@ -159,43 +164,51 @@ export default function TopAgents({
onOpenAgent?: (agent: AgentRow) => void;
}) {
const [metric, setMetric] = useState<UsageMetric>("tokens");
const agents = useMemo(() => {
const all = topAgents(rows ?? []);
return [...all.filter((agent) => agent.kind === "agent"), ...all.filter((agent) => agent.kind === "sdk")].slice(
0,
ROWS,
);
}, [rows]);
const allAgents = useMemo(() => topAgents(rows ?? []), [rows]);
const agents = useMemo(
() =>
[
...allAgents.filter((agent) => agent.kind === "agent"),
...allAgents.filter((agent) => agent.kind === "sdk"),
].slice(0, ROWS),
[allAgents],
);
const series = useMemo(() => seriesBy(agentDailyData(daily), "models", metric, CHART_SERIES), [daily, metric]);
const totalsByDay = useMemo(() => bucketTotals(series), [series]);
const colorOf = (label: string) => {
const index = series.keys.indexOf(label);
const index = series.labels.indexOf(label);
return index === -1 ? undefined : series.colors[index];
};
const totalShare = agents.reduce((sum, agent) => sum + agent.tokens, 0);
const coverage = totalTokens > 0 ? Math.min(totalShare / totalTokens, 1) : 1;
const attributedTokens = allAgents.reduce((sum, agent) => sum + agent.tokens, 0);
const coverage = totalTokens > 0 ? Math.min(attributedTokens / totalTokens, 1) : 1;
const subtitle =
coverage < COVERAGE_NOTE_BELOW
? `Daily usage by agent. ${Math.round(coverage * 100)}% of tokens came with a User-Agent; the rest is unattributed`
: "Daily usage by agent, from each request's User-Agent";
const half = Math.ceil(agents.length / 2);
let body: React.ReactNode;
if (loading) body = <ChartSkeleton className="mx-5 h-64 w-auto" />;
else if (agents.length === 0)
body = <p className="py-10 text-center text-xs text-muted-foreground">No agent traffic in this range</p>;
else
body = (
<>
return (
<Panel
icon={Bot}
title="Top agents"
subtitle={subtitle}
testId="top-agents"
action={<Segmented label="Agent metric" value={metric} options={METRICS} onChange={setMetric} />}
bodyClassName="px-0 pt-4 pb-0"
>
<TopAgentsBody loading={loading} empty={agents.length === 0}>
<div className="px-2 pb-2">
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="label"
xKey="date"
xLabel={(date) => labelForDate(series, date)}
format={(value) => formatMetricValue(value, metric)}
totalFor={(label) => totalsByDay.get(label)}
totalFor={(date) => totalsByDay.get(date)}
className="h-64"
/>
</div>
@ -215,19 +228,7 @@ export default function TopAgents({
</ol>
))}
</div>
</>
);
return (
<Panel
icon={Bot}
title="Top agents"
subtitle={subtitle}
testId="top-agents"
action={<Segmented label="Agent metric" value={metric} options={METRICS} onChange={setMetric} />}
bodyClassName="px-0 pt-4 pb-0"
>
{body}
</TopAgentsBody>
</Panel>
);
}

View file

@ -13,6 +13,7 @@ import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/c
import {
bucketSeries,
bucketTotals,
labelForDate,
dailyTotals,
formatCompact,
formatLatency,
@ -32,13 +33,9 @@ interface UsageOverviewProps {
loading: boolean;
requestCountsPending: boolean;
budget: number | null;
/** The existing Top Virtual Keys table, rendered as-is so key drill-in keeps working. */
topKeys: ReactNode;
/** The gateway-by-endpoint chart, only present for admins with gateway counts. */
gatewayByEndpoint: ReactNode;
/** Top agents read from User-Agent tags; rendered after the models, the next thing people look for. */
topAgents: ReactNode;
/** Spend by provider with its zero/unknown filters. */
providerBreakdown: ReactNode;
}
@ -139,7 +136,6 @@ export default function UsageOverview({
}
bodyClassName="px-0 pt-4 pb-0"
>
{/* The chart's axis gutter supplies the rest of the inset, so its plot edge lines up with the title. */}
<div className="px-2 pb-2">
{loading ? (
<ChartSkeleton className="mx-3 h-[380px] w-auto" />
@ -147,11 +143,13 @@ export default function UsageOverview({
<StackedUsageChart
data={series.data}
series={series.keys}
labels={series.labels}
colors={series.colors}
xKey="label"
xKey="date"
xLabel={(date) => labelForDate(series, date)}
scale={scale}
format={format}
totalFor={(label) => totalsByBucket.get(label)}
totalFor={(date) => totalsByBucket.get(date)}
/>
)}
</div>

View file

@ -132,14 +132,24 @@ describe("topAgents", () => {
expect(agent.users).toBe(8);
});
it("records every raw tag an agent was built from, for the drill-in filter", () => {
it("drills in on exactly the tags it counted, so a request carrying bare and versioned tags is not counted twice", () => {
const [agent] = topAgents([
row("User-Agent: claude-cli", 10, 100),
row("User-Agent: claude-cli/2.1.263 (external, cli)", 10, 100),
row("User-Agent: claude-code", 2, 20),
]);
expect([...agent.tags].sort()).toEqual(["User-Agent: claude-cli", "User-Agent: claude-code"]);
expect(agent).toMatchObject({ spend: 12, tokens: 120 });
});
it("drills in on every versioned tag when a product has no bare rollup", () => {
const [agent] = topAgents([
row("User-Agent: opencode/1.18.29 ai-sdk", 5, 50),
row("User-Agent: opencode/1.18.30 ai-sdk", 7, 70),
]);
expect([...agent.tags].sort()).toEqual([
"User-Agent: claude-cli",
"User-Agent: claude-cli/2.1.263 (external, cli)",
"User-Agent: opencode/1.18.29 ai-sdk",
"User-Agent: opencode/1.18.30 ai-sdk",
]);
});
});

View file

@ -210,56 +210,66 @@ export const userAgentProduct = (tag: string): string => {
const familyFor = (product: string): AgentFamily | null =>
FAMILIES.find((family) => family.match.test(product)) ?? null;
const isBareTag = (row: TagSummaryRow, product: string) =>
row.tag.trim().replace(USER_AGENT_PREFIX, "").trim() === product;
/**
* Collapses `/tag/summary` rows into one row per agent. The endpoint reports a bare product tag
* (`claude-cli`) as the rollup of its versioned children (`claude-cli/2.1.263 ...`), so a product
* that has a bare row is counted from that row alone; summing both would double-count. Products
* with only versioned rows are summed across them. Unrecognised products keep their own name.
* The rows that count toward a product. `/tag/summary` reports a bare tag (`claude-cli`) as the
* rollup of its versioned children, and a request carries both, so a bare row alone stands for the
* product. These same rows become the agent's drill-in filter, keeping it from double-counting.
*/
const contributingRows = (product: string, rows: readonly TagSummaryRow[]): readonly TagSummaryRow[] => {
const bare = rows.find((row) => isBareTag(row, product));
return bare ? [bare] : rows;
};
const agentFor = (product: string, rows: readonly TagSummaryRow[]): AgentRow => {
const family = familyFor(product);
return {
id: family?.id ?? `ua:${product.toLowerCase()}`,
label: family?.label ?? product,
description: family?.description ?? "Custom client",
kind: family?.kind ?? "sdk",
logo: family?.logo ?? null,
spend: rows.reduce((sum, row) => sum + (row.total_spend || 0), 0),
tokens: rows.reduce((sum, row) => sum + (row.total_tokens || 0), 0),
requests: rows.reduce((sum, row) => sum + (row.total_requests || 0), 0),
// Users overlap across a family's products, so the largest is the honest lower bound.
users: Math.max(0, ...rows.map((row) => row.unique_users || 0)),
tags: rows.map((row) => row.tag),
};
};
const mergeAgents = (a: AgentRow, b: AgentRow): AgentRow => ({
...a,
spend: a.spend + b.spend,
tokens: a.tokens + b.tokens,
requests: a.requests + b.requests,
users: Math.max(a.users, b.users),
tags: [...a.tags, ...b.tags],
});
/**
* One row per agent from `/tag/summary`. Products are keyed case-sensitively: prod reports `python`
* and `Python` as distinct products, and folding them let one rollup overwrite the other.
*/
export const topAgents = (rows: readonly TagSummaryRow[]): AgentRow[] => {
const byProduct = new Map<string, { bare: TagSummaryRow | null; versioned: TagSummaryRow[] }>();
for (const row of rows) {
if (!isUserAgentTag(row.tag)) continue;
// Keyed case-sensitively: prod reports `python` ($61K) and `Python` ($1) as distinct products,
// and folding them together let the small one's rollup overwrite the large one's.
const key = userAgentProduct(row.tag);
if (!key) continue;
const entry = byProduct.get(key) ?? { bare: null, versioned: [] };
if (row.tag.trim().replace(USER_AGENT_PREFIX, "").trim() === key) entry.bare = row;
else entry.versioned.push(row);
byProduct.set(key, entry);
}
const agents = new Map<string, AgentRow>();
for (const [key, { bare, versioned }] of byProduct) {
const source = bare ? [bare] : versioned;
const family = familyFor(key);
const id = family?.id ?? `ua:${key.toLowerCase()}`;
const row = agents.get(id) ?? {
id,
label: family?.label ?? (bare ? userAgentProduct(bare.tag) : userAgentProduct(versioned[0].tag)),
description: family?.description ?? "Custom client",
kind: family?.kind ?? "sdk",
logo: family?.logo ?? null,
spend: 0,
tokens: 0,
requests: 0,
users: 0,
tags: [],
};
// Every tag the agent was seen under, so a drill-in filter catches all its versions.
row.tags.push(...(bare ? [bare.tag] : []), ...versioned.map((item) => item.tag));
for (const item of source) {
row.spend += item.total_spend || 0;
row.tokens += item.total_tokens || 0;
row.requests += item.total_requests || 0;
// Users can overlap across a family's products, so take the largest rather than a sum that would overstate.
row.users = Math.max(row.users, item.unique_users || 0);
}
agents.set(id, row);
}
return [...agents.values()]
const userAgentRows = rows.filter((row) => isUserAgentTag(row.tag) && userAgentProduct(row.tag));
const products = [...new Set(userAgentRows.map((row) => userAgentProduct(row.tag)))];
const perProduct = products.map((product) =>
agentFor(
product,
contributingRows(
product,
userAgentRows.filter((row) => userAgentProduct(row.tag) === product),
),
),
);
const byAgent = perProduct.reduce<ReadonlyMap<string, AgentRow>>((acc, agent) => {
const existing = acc.get(agent.id);
return new Map([...acc, [agent.id, existing ? mergeAgents(existing, agent) : agent]]);
}, new Map());
return [...byAgent.values()]
.filter((agent) => agent.spend > 0 || agent.tokens > 0 || agent.requests > 0)
.sort((a, b) => b.tokens - a.tokens || b.spend - a.spend);
};

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { DailyData } from "@/components/UsagePage/types";
import { bucketSeries, bucketTotals, labelForDate, rollUpBreakdown, seriesBy } from "./overviewData";
const metrics = (spend: number) => ({
spend,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: spend * 10,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const day = (date: string, total: number, models: Record<string, number>): DailyData =>
({
date,
metrics: metrics(total),
breakdown: {
models: {},
model_groups: Object.fromEntries(
Object.entries(models).map(([name, spend]) => [
name,
{ metrics: metrics(spend), metadata: {}, api_key_breakdown: {} },
]),
),
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
}) as unknown as DailyData;
describe("seriesBy", () => {
it("keeps a model named date, label or Other from overwriting the day's own fields or the remainder", () => {
const series = seriesBy([day("2026-10-01", 10, { date: 3, label: 2, Other: 4 })], "model_groups", "spend");
const [point] = series.data;
expect(point.date).toBe("2026-10-01");
expect(point.label).toBe("Oct 1");
const valueOf = (name: string) => point[series.keys[series.labels.indexOf(name)]];
expect(valueOf("Other")).toBe(4);
expect(valueOf("date")).toBe(3);
expect(valueOf("label")).toBe(2);
// The real remainder (10 - 9) is a separate trailing series, not merged into the model named "Other".
expect(series.labels).toHaveLength(4);
expect(point[series.keys[3]]).toBeCloseTo(1);
});
it("folds everything past the top N into a trailing Other in slate", () => {
const series = seriesBy([day("2026-10-01", 10, { a: 5, b: 3, c: 2 })], "model_groups", "spend", 2);
expect(series.labels).toEqual(["a", "b", "Other"]);
expect(series.colors[2]).toBe("#94a3b8");
});
});
describe("bucketTotals", () => {
it("keys totals by ISO date, so the same day in two years does not collide", () => {
const series = seriesBy([day("2025-01-01", 5, { a: 5 }), day("2026-01-01", 9, { a: 9 })], "model_groups", "spend");
const totals = bucketTotals(series);
expect(totals.get("2025-01-01")).toBe(5);
expect(totals.get("2026-01-01")).toBe(9);
expect(labelForDate(series, "2025-01-01")).toBe("Jan 1");
});
});
describe("bucketSeries", () => {
it("sums days into 7-day buckets anchored on the first day", () => {
const series = seriesBy(
[day("2026-10-01", 1, { a: 1 }), day("2026-10-03", 2, { a: 2 }), day("2026-10-09", 4, { a: 4 })],
"model_groups",
"spend",
);
const weekly = bucketSeries(series, "week");
expect(weekly.data.map((point) => point.date)).toEqual(["2026-10-01", "2026-10-08"]);
expect(bucketTotals(weekly).get("2026-10-01")).toBe(3);
expect(bucketTotals(weekly).get("2026-10-08")).toBe(4);
});
});
describe("rollUpBreakdown", () => {
it("sums a dimension across days without changing the input", () => {
const days = [day("2026-10-01", 3, { a: 3 }), day("2026-10-02", 2, { a: 2 })];
const before = JSON.stringify(days);
expect(rollUpBreakdown(days, "model_groups")).toEqual([
expect.objectContaining({ key: "a", spend: 5, requests: 2 }),
]);
expect(JSON.stringify(days)).toBe(before);
});
});

View file

@ -15,27 +15,28 @@ interface RankedRow extends Record<string, unknown> {
tokens: number;
}
const EMPTY_RANKED = { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 } as const;
/** Sums one breakdown dimension across every day, highest spend first. */
export const rollUpBreakdown = (results: readonly DailyData[], dimension: BreakdownDimension): RankedRow[] => {
const totals = new Map<string, RankedRow>();
for (const day of results) {
for (const [key, entry] of Object.entries(day.breakdown[dimension] ?? {})) {
const row = totals.get(key) ?? {
const entries = results.flatMap((day) => Object.entries(day.breakdown[dimension] ?? {}));
const totals = entries.reduce<ReadonlyMap<string, RankedRow>>((acc, [key, entry]) => {
const row = acc.get(key) ?? { key, ...EMPTY_RANKED };
return new Map([
...acc,
[
key,
spend: 0,
requests: 0,
successful_requests: 0,
failed_requests: 0,
tokens: 0,
};
row.spend += entry.metrics.spend;
row.requests += entry.metrics.api_requests;
row.successful_requests += entry.metrics.successful_requests || 0;
row.failed_requests += entry.metrics.failed_requests || 0;
row.tokens += entry.metrics.total_tokens;
totals.set(key, row);
}
}
{
...row,
spend: row.spend + entry.metrics.spend,
requests: row.requests + entry.metrics.api_requests,
successful_requests: row.successful_requests + (entry.metrics.successful_requests || 0),
failed_requests: row.failed_requests + (entry.metrics.failed_requests || 0),
tokens: row.tokens + entry.metrics.total_tokens,
},
],
]);
}, new Map());
return [...totals.values()].sort((a, b) => b.spend - a.spend);
};
@ -43,16 +44,24 @@ export const OTHER_COLOR = "#94a3b8";
const OTHER_SERIES = "Other";
export interface SeriesDay extends Record<string, unknown> {
/** ISO date, the chart's category key: unique across years, unlike its display label. */
date: string;
label: string;
}
/**
* `keys` are internal (`s0`, `s1`, ...) so a series named `date`, `label` or `Other` cannot
* collide with the day's own fields; `labels` carry the display names in the same order.
*/
export interface Series {
data: SeriesDay[];
keys: string[];
labels: string[];
colors: string[];
}
const seriesKey = (index: number) => `s${index}`;
const formatDayLabel = (date: string): string => {
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return date;
@ -76,9 +85,6 @@ export const rankValue = (row: RankedRow, metric: UsageMetric): number => {
return row.requests;
};
const seriesColor = (key: string, index: number): string =>
key === OTHER_SERIES ? OTHER_COLOR : stackedUsageColor(index);
/**
* Daily `metric` stacked by the top `top` keys of a dimension, the rest folded
* into "Other" so every bar still sums to the day's total.
@ -94,26 +100,28 @@ export const seriesBy = (
.sort((a, b) => rankValue(b, metric) - rankValue(a, metric))
.slice(0, top)
.map((row) => row.key);
const leaderSet = new Set(leaders);
let hasOther = false;
const data = sortByDate(results).map((day) => {
const point: SeriesDay = { date: day.date, label: formatDayLabel(day.date) };
for (const key of leaders) point[key] = 0;
let other = metricOf(day.metrics, metric);
for (const [key, entry] of Object.entries(day.breakdown[dimension] ?? {})) {
if (!leaderSet.has(key)) continue;
const value = metricOf(entry.metrics, metric);
point[key] = value;
other -= value;
}
const days = sortByDate(results).map((day) => {
const values = leaders.map((leader) => {
const entry = day.breakdown[dimension]?.[leader];
return entry ? metricOf(entry.metrics, metric) : 0;
});
const named = values.reduce((sum, value) => sum + value, 0);
// Rounding in the per-key sums leaves dust; only a real remainder earns a segment.
const remainder = other > 1e-6 ? other : 0;
point[OTHER_SERIES] = remainder;
if (remainder > 0) hasOther = true;
return point;
const remainder = metricOf(day.metrics, metric) - named;
return { day, values, other: remainder > 1e-6 ? remainder : 0 };
});
const keys = hasOther || leaders.length === 0 ? [...leaders, OTHER_SERIES] : leaders;
return { data, keys, colors: keys.map((key, i) => seriesColor(key, i)) };
const hasOther = days.some(({ other }) => other > 0) || leaders.length === 0;
const labels = hasOther ? [...leaders, OTHER_SERIES] : leaders;
const keys = labels.map((_, index) => seriesKey(index));
const data = days.map(({ day, values, other }) => ({
date: day.date,
label: formatDayLabel(day.date),
...Object.fromEntries((hasOther ? [...values, other] : values).map((value, index) => [keys[index], value])),
}));
const colors = labels.map((label, index) =>
hasOther && index === labels.length - 1 ? OTHER_COLOR : stackedUsageColor(index),
);
return { data, keys, labels, colors };
};
export type Granularity = "day" | "week";
@ -124,20 +132,31 @@ const DAY_MS = 86_400_000;
export const bucketSeries = (series: Series, granularity: Granularity): Series => {
if (granularity === "day" || series.data.length === 0) return series;
const origin = Date.parse(`${series.data[0].date}T00:00:00Z`);
const buckets = new Map<number, SeriesDay>();
for (const day of series.data) {
const index = Math.floor((Date.parse(`${day.date}T00:00:00Z`) - origin) / (7 * DAY_MS));
const start = new Date(origin + index * 7 * DAY_MS).toISOString().slice(0, 10);
const bucket = buckets.get(index) ?? { date: start, label: `Wk of ${formatDayLabel(start)}` };
for (const key of series.keys) bucket[key] = Number(bucket[key] ?? 0) + Number(day[key] ?? 0);
buckets.set(index, bucket);
}
return { ...series, data: [...buckets.values()] };
const bucketStart = (date: string) => {
const index = Math.floor((Date.parse(`${date}T00:00:00Z`) - origin) / (7 * DAY_MS));
return new Date(origin + index * 7 * DAY_MS).toISOString().slice(0, 10);
};
const starts = [...new Set(series.data.map((day) => bucketStart(day.date)))];
const data = starts.map((start) => {
const members = series.data.filter((day) => bucketStart(day.date) === start);
return {
date: start,
label: `Wk of ${formatDayLabel(start)}`,
...Object.fromEntries(
series.keys.map((key) => [key, members.reduce((sum, day) => sum + Number(day[key] ?? 0), 0)]),
),
};
});
return { ...series, data };
};
/** Bar totals per bucket label, for the "· Total" tooltip header. */
export const bucketTotals = (series: Series): Map<string, number> =>
new Map(series.data.map((day) => [day.label, series.keys.reduce((sum, key) => sum + Number(day[key] ?? 0), 0)]));
/** Bar totals keyed by ISO date, so two years' "Jan 1" in one range never overwrite each other. */
export const bucketTotals = (series: Series): ReadonlyMap<string, number> =>
new Map(series.data.map((day) => [day.date, series.keys.reduce((sum, key) => sum + Number(day[key] ?? 0), 0)]));
/** Display label for a bucket's ISO date, for chart ticks and tooltip headers. */
export const labelForDate = (series: Series, date: string): string =>
series.data.find((day) => day.date === date)?.label ?? date;
export interface LeaderRow extends RankedRow {
share: number;
@ -145,6 +164,12 @@ export interface LeaderRow extends RankedRow {
delta: number | null;
}
const shareIn = (rows: readonly RankedRow[], metric: UsageMetric) => {
const total = rows.reduce((sum, row) => sum + rankValue(row, metric), 0);
const byKey = new Map(rows.map((row) => [row.key, rankValue(row, metric)]));
return { total, of: (key: string) => (total > 0 ? ((byKey.get(key) ?? 0) / total) * 100 : 0) };
};
/** Share of `metric` per key, plus how that share moved between the two halves of the range. */
export const leaderboard = (
results: readonly DailyData[],
@ -155,14 +180,8 @@ export const leaderboard = (
const rows = rollUpBreakdown(days, dimension).sort((a, b) => rankValue(b, metric) - rankValue(a, metric));
const grand = rows.reduce((sum, row) => sum + rankValue(row, metric), 0);
const half = Math.floor(days.length / 2);
const shareIn = (slice: readonly DailyData[]) => {
const sliceRows = rollUpBreakdown(slice, dimension);
const total = sliceRows.reduce((sum, row) => sum + rankValue(row, metric), 0);
const byKey = new Map(sliceRows.map((row) => [row.key, rankValue(row, metric)]));
return { total, of: (key: string) => (total > 0 ? ((byKey.get(key) ?? 0) / total) * 100 : 0) };
};
const earlier = shareIn(days.slice(0, half));
const later = shareIn(days.slice(half));
const earlier = shareIn(rollUpBreakdown(days.slice(0, half), dimension), metric);
const later = shareIn(rollUpBreakdown(days.slice(half), dimension), metric);
const comparable = half > 0 && earlier.total > 0 && later.total > 0;
return rows
.filter((row) => rankValue(row, metric) > 0)

View file

@ -21,9 +21,7 @@ const VIEW_OPTIONS = [
{ value: "chart", label: "Chart View" },
] as const satisfies readonly { value: ViewMode; label: string }[];
/** Brand blue, matching the single-series charts on the usage overview. */
const BAR_COLOR = "#2b3fd6";
/** Quiet headers: muted, regular weight, so the rows carry the emphasis. */
const QUIET_HEADER = { headerClassName: "font-normal" };
export interface TopKeyItem {

View file

@ -55,7 +55,6 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp
/>
);
/** Quiet series key for a panel header: small swatch and readable name. */
const SeriesKey = ({ categories, colors }: { categories: readonly string[]; colors: readonly ChartColor[] }) => (
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
{categories.map((category, idx) => (

View file

@ -27,12 +27,16 @@ export interface StackedUsageChartProps {
data: readonly Record<string, unknown>[];
/** Series keys, bottom of the stack first. */
series: readonly string[];
/** Display names for `series`, same order; defaults to the keys. */
labels?: readonly string[];
/** Overrides the palette per series, e.g. to grey out an "Other" bucket. */
colors?: readonly string[];
xKey: string;
/** Turns an `xKey` value into its tick and tooltip text, e.g. an ISO date into "Oct 3". */
xLabel?: (value: string) => string;
format: (value: number) => string;
/** Appended to the tooltip header as "· Total …" for the hovered bucket. */
totalFor?: (label: string) => number | undefined;
/** Appended to the tooltip header as "· Total …" for the hovered bucket, looked up by its `xKey` value. */
totalFor?: (xValue: string) => number | undefined;
totalLabel?: string;
scale?: StackedUsageScale;
className?: string;
@ -67,8 +71,10 @@ export function StackedUsageTooltipRow({
export function StackedUsageChart({
data,
series,
labels,
colors,
xKey,
xLabel = String,
format,
totalFor,
totalLabel = "Total",
@ -76,15 +82,27 @@ export function StackedUsageChart({
className,
}: StackedUsageChartProps) {
const fill = (index: number) => colors?.[index] ?? stackedUsageColor(index);
const nameOf = (index: number) => labels?.[index] ?? series[index];
const config = Object.fromEntries(
series.map((key, index) => [key, { label: key, color: fill(index) }]),
series.map((key, index) => [key, { label: nameOf(index), color: fill(index) }]),
) satisfies ChartConfig;
const tooltipHeader = (_label: unknown, payload: readonly { payload?: Record<string, unknown> }[]) => {
const xValue = String(payload?.[0]?.payload?.[xKey] ?? "");
const total = totalFor?.(xValue);
return total === undefined ? xLabel(xValue) : `${xLabel(xValue)} · ${totalLabel} ${format(total)}`;
};
return (
<ChartContainer config={config} className={cn("aspect-auto h-[380px] w-full", className)}>
<BarChart data={[...data]} margin={{ left: 8, right: 8 }} barCategoryGap="15%" maxBarSize={64}>
<CartesianGrid vertical={false} />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} minTickGap={48} />
<XAxis
dataKey={xKey}
tickLine={false}
axisLine={false}
minTickGap={48}
tickFormatter={(value) => xLabel(String(value))}
/>
<YAxis
scale={scale}
domain={scale === "log" ? [1, "auto"] : [0, "auto"]}
@ -97,18 +115,27 @@ export function StackedUsageChart({
content={
<ChartTooltipContent
className="min-w-48"
labelFormatter={(label) => {
const total = totalFor?.(String(label));
return total === undefined ? String(label) : `${label} · ${totalLabel} ${format(total)}`;
}}
formatter={(value, name, item) => (
<StackedUsageTooltipRow value={Number(value)} name={String(name)} color={item.color} format={format} />
labelFormatter={tooltipHeader}
formatter={(value, _name, item) => (
<StackedUsageTooltipRow
value={Number(value)}
name={nameOf(series.indexOf(String(item.dataKey)))}
color={item.color}
format={format}
/>
)}
/>
}
/>
{series.map((key, index) => (
<Bar key={key} dataKey={key} stackId="usage" fill={fill(index)} isAnimationActive={false} />
<Bar
key={key}
dataKey={key}
name={nameOf(index)}
stackId="usage"
fill={fill(index)}
isAnimationActive={false}
/>
))}
</BarChart>
</ChartContainer>

View file

@ -491,7 +491,6 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
</div>
}
>
{/* Date range is controlled by the parent page header */}
{summaryLoading ? (
<ChartSkeleton className="h-32" />
) : (
@ -519,7 +518,6 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
/>
);
})}
{/* Fill remaining slots if less than 4 agents */}
{Array.from({ length: Math.max(0, SUMMARY_SLOTS - summaryRows.length) }).map((_, index) => (
<SummaryCell
key={`empty-${index}`}