mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
dcf92b44ba
commit
ef694bc584
20 changed files with 406 additions and 215 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>[] = [
|
||||
|
|
|
|||
|
|
@ -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>[] = [
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue