feat(ui): redesign usage page with stacked model and agent charts (#45221)

* feat(ui): add shared StackedUsageChart from the model leaderboard chart

* test(ui): cover StackedUsageChart palette and tooltip rows

* feat(ui): export StackedUsageChart from shared charts

* refactor(ui): render model leaderboard chart with StackedUsageChart

* feat(ui): add usage overview series, leaderboard and totals helpers

* feat(ui): add Panel, Stat, Segmented and Sparkline usage primitives

* feat(ui): map model names to provider logos

* feat(ui): add model leaderboard rows with provider logo tiles

* feat(ui): redesign the usage overview tab

* feat(ui): group user-agent tags into named agents with logos

* test(ui): cover agent grouping against prod-shaped user-agent tags

* feat(ui): add top agents chart and table to usage overview

* feat(ui): add useTagSummary hook for tag spend and tokens

* feat(ui): wire redesigned overview, top agents and provider spend into usage

* test(ui): update usage page tests for the redesigned overview

* feat(ui): compact usage view selector with a dropdown that fits its content

* test(ui): update usage view selector tests

* feat(ui): show provider spend as a share bar over the provider table

* test(ui): assert provider share segments instead of the donut

* feat(ui): restyle team, org, tag and customer usage to match the overview

* test(ui): update entity usage tests for the restyled views

* style(ui): restyle team user spend card

* style(ui): restyle top model view

* test(ui): update top model view tests

* style(ui): restyle endpoint activity tab

* style(ui): label endpoint bars Successful and Failed

* test(ui): update endpoint bar chart tests

* style(ui): restyle endpoint line chart

* test(ui): update endpoint line chart tests

* style(ui): quiet endpoint table header

* style(ui): align usage AI chat panel typography

* style(ui): restyle key activity tab with the overview stat strip

* style(ui): replace top key toggle buttons with segmented controls

* test(ui): update top key view tests

* style(ui): restyle model and MCP activity tabs

* test(ui): update activity metrics tests

* feat(ui): restyle user agent activity and accept an initial agent filter

* test(ui): update user agent activity tests

* style(ui): outline export button in usage headers
This commit is contained in:
ishaan-berri 2026-10-07 18:18:42 -07:00 • committed by GitHub
parent 0e076008e1
commit 5410752f43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3549 additions and 2077 deletions

View file

@ -0,0 +1,29 @@
import { $api } from "@/lib/http/api";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const isoDay = (date: Date) => {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
};
/** 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) => {
const { accessToken } = useAuthorized();
return $api.useQuery(
"get",
"/tag/summary",
{
params: {
query: {
start_date: startTime ? isoDay(startTime) : "",
end_date: endTime ? isoDay(endTime) : "",
},
},
},
{
enabled: Boolean(accessToken && startTime && endTime),
select: (data) => data.results,
},
);
};

View file

@ -2,8 +2,8 @@
import { Page } from "@/components/shared/Page";
import React from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { ArrowDownRight, ArrowUpRight, BarChart3, Minus } from "lucide-react";
import { StackedUsageChart } from "@/components/shared/charts";
import { apiClient } from "@/components/networking";
import { extractErrorMessage } from "@/utils/errorUtils";
@ -11,7 +11,6 @@ import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { PageHeader, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@ -26,18 +25,6 @@ import {
RankedModel,
} from "./modelInsightsData";
const PALETTE = [
"#ec4899",
"#a855f7",
"#f59e0b",
"#3b82f6",
"#10b981",
"#ef4444",
"#14b8a6",
"#84cc16",
"#6366f1",
"#f97316",
];
const SCALES = ["linear", "log"] as const;
const GRANULARITIES = ["day", "week"] as const;
const GRANULARITY_LABELS: Record<Granularity, string> = { day: "Daily", week: "Weekly" };
@ -143,10 +130,6 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
);
}
const chartConfig = Object.fromEntries(
models.map((model, index) => [model, { label: model, color: PALETTE[index % PALETTE.length] }]),
) satisfies ChartConfig;
return (
<Page>
<PageHeader>
@ -198,38 +181,15 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[380px] w-full aspect-auto">
<BarChart data={series} margin={{ left: 8, right: 8 }} barCategoryGap="15%" maxBarSize={64}>
<CartesianGrid vertical={false} />
<XAxis dataKey="date" tickLine={false} axisLine={false} minTickGap={48} />
<YAxis
scale={scale}
domain={scale === "log" ? [1, "auto"] : [0, "auto"]}
allowDataOverflow
tickLine={false}
axisLine={false}
tickFormatter={(value) => formatMetric(Number(value), shown)}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(label) =>
`${label} · Gateway total ${formatMetric(bucketTotals.get(String(label)) ?? 0, shown)}`
}
/>
}
/>
{models.map((model, index) => (
<Bar
key={model}
dataKey={model}
stackId="usage"
fill={PALETTE[index % PALETTE.length]}
isAnimationActive={false}
/>
))}
</BarChart>
</ChartContainer>
<StackedUsageChart
data={series}
series={models}
xKey="date"
scale={scale}
format={(value) => formatMetric(value, shown)}
totalLabel="Gateway total"
totalFor={(label) => bucketTotals.get(label) ?? 0}
/>
</CardContent>
</Card>

View file

@ -56,10 +56,12 @@ const EndpointUsage: React.FC<EndpointUsageProps> = ({ userSpendData }) => {
}, [userSpendData]);
return (
<div className="space-y-4">
<div className="grid gap-3">
<div className="grid gap-3 xl:grid-cols-2">
<EndpointUsageBarChart endpointData={endpointData} />
<EndpointUsageLineChart dailyData={userSpendData} />
</div>
<EndpointUsageTable endpointData={endpointData} />
<EndpointUsageBarChart endpointData={endpointData} />
<EndpointUsageLineChart dailyData={userSpendData} />
</div>
);
};

View file

@ -30,18 +30,20 @@ describe("EndpointUsageBarChart", () => {
renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(screen.getByText("Success vs Failed Requests by Endpoint")).toBeInTheDocument();
expect(screen.getByText("Successful Requests")).toBeInTheDocument();
expect(screen.getByText("Failed Requests")).toBeInTheDocument();
expect(screen.getByText("Successful")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText("160")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument();
});
it("renders stacked green and red bars per endpoint", () => {
it("renders stacked brand-blue and red bars per endpoint", () => {
const { container } = renderWithProviders(<EndpointUsageBarChart endpointData={endpointData} />);
expect(container.querySelectorAll(".recharts-bar")).toHaveLength(2);
const rectangles = Array.from(container.querySelectorAll("path.recharts-rectangle"));
expect(rectangles).toHaveLength(4);
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-green-500, #22c55e)", "var(--color-red-500, #ef4444)"]));
expect(fills).toEqual(new Set(["#2b3fd6", "#ef4444"]));
const xPositions = rectangles.map((rect) => rect.getAttribute("d")?.split(",")[0]);
expect(new Set(xPositions).size).toBe(2);

View file

@ -1,53 +1,69 @@
import React from "react";
import { BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartColumnStacked } from "lucide-react";
import { BarChart, CustomTooltip } from "@/components/shared/charts";
import { MetricWithMetadata } from "@/components/UsagePage/types";
import { Panel } from "../../overview/Primitives";
interface EndpointUsageBarChartProps {
endpointData?: Record<string, MetricWithMetadata>;
}
const SUCCESS_COLOR = "#2b3fd6";
const FAILED_COLOR = "#ef4444";
const CATEGORIES = ["Successful", "Failed"] as const;
const COLORS = [SUCCESS_COLOR, FAILED_COLOR] as const;
const valueFormatter = (value: number) => value.toLocaleString();
const EndpointUsageBarChart: React.FC<EndpointUsageBarChartProps> = ({ endpointData }) => {
// Transform endpoint data into chart format
const chartData = React.useMemo(() => {
return Object.entries(endpointData || {}).map(([endpoint, data]) => ({
endpoint,
"metrics.successful_requests": data.metrics.successful_requests,
"metrics.failed_requests": data.metrics.failed_requests,
metrics: {
successful_requests: data.metrics.successful_requests,
failed_requests: data.metrics.failed_requests,
},
Successful: data.metrics.successful_requests,
Failed: data.metrics.failed_requests,
}));
}, [endpointData]);
const valueFormatter = (value: number) => value.toLocaleString();
const totals = React.useMemo(
() =>
chartData.reduce(
(acc, row) => ({ Successful: acc.Successful + row.Successful, Failed: acc.Failed + row.Failed }),
{ Successful: 0, Failed: 0 },
),
[chartData],
);
return (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-base font-semibold">Success vs Failed Requests by Endpoint</CardTitle>
<CustomLegend
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
/>
<Panel
icon={ChartColumnStacked}
title="Success vs Failed Requests by Endpoint"
action={
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
{CATEGORIES.map((category, i) => (
<span key={category} className="flex items-center gap-1.5">
<span aria-hidden="true" className="size-2 rounded-[2px]" style={{ backgroundColor: COLORS[i] }} />
<span>{category}</span>
<span className="tabular-nums text-foreground">{totals[category].toLocaleString()}</span>
</span>
))}
</div>
</CardHeader>
<CardContent>
<BarChart
data={chartData}
index="endpoint"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
stack={true}
yAxisWidth={60}
/>
</CardContent>
</Card>
}
>
<BarChart
className="h-64"
data={chartData}
index="endpoint"
categories={CATEGORIES}
colors={COLORS}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
stack={true}
maxBarSize={48}
yAxisWidth={56}
/>
</Panel>
);
};

View file

@ -2,6 +2,7 @@ import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { DailyData, MetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import EndpointUsageLineChart from "./EndpointUsageLineChart";
const spendMetrics = (apiRequests: number): SpendMetrics => ({
@ -53,13 +54,13 @@ describe("EndpointUsageLineChart", () => {
expect(screen.getByText("Endpoint Usage Trends")).toBeInTheDocument();
});
it("renders one line per endpoint with the tremor palette strokes", () => {
it("renders one line per endpoint with the stacked usage palette strokes", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const curves = Array.from(container.querySelectorAll("path.recharts-line-curve"));
expect(curves).toHaveLength(2);
expect(new Set(curves.map((curve) => curve.getAttribute("stroke")))).toEqual(
new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]),
new Set([STACKED_USAGE_PALETTE[0], STACKED_USAGE_PALETTE[1]]),
);
});
@ -87,7 +88,7 @@ describe("EndpointUsageLineChart", () => {
expect(screen.getAllByText(/^\d,\d{3}$/).length).toBeGreaterThan(0);
});
it("draws smooth natural curves", () => {
it("draws smooth curves that never overshoot below zero (monotone)", () => {
const { container } = renderWithProviders(<EndpointUsageLineChart dailyData={dailyData} />);
const path = container.querySelector("path.recharts-line-curve")?.getAttribute("d") ?? "";

View file

@ -1,7 +1,8 @@
import { useMemo } from "react";
import { LineChart, type ChartColor } from "@/components/shared/charts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartLine } from "lucide-react";
import { LineChart, stackedUsageColor } from "@/components/shared/charts";
import { DailyData } from "@/components/UsagePage/types";
import { Panel } from "../../overview/Primitives";
interface EndpointUsageLineChartProps {
dailyData?: { results: DailyData[] };
@ -58,41 +59,25 @@ export function EndpointUsageLineChart({ dailyData }: EndpointUsageLineChartProp
return keys;
}, [chartData]);
// Tremor color palette for multiple lines
const colors: readonly ChartColor[] = [
"blue",
"cyan",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"red",
"orange",
];
// Same palette as the Overview / Model Leaderboard stacked chart
const colors = useMemo(() => categories.map((_, i) => stackedUsageColor(i)), [categories]);
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-base font-semibold">Endpoint Usage Trends</CardTitle>
</CardHeader>
<CardContent>
<LineChart
className="h-80"
data={chartData}
index="date"
categories={categories}
colors={colors.slice(0, categories.length)}
valueFormatter={(value) => value.toLocaleString()}
showLegend={true}
showGridLines={true}
yAxisWidth={60}
connectNulls={true}
curveType="natural"
/>
</CardContent>
</Card>
<Panel icon={ChartLine} title="Endpoint Usage Trends">
<LineChart
className="h-64"
data={chartData}
index="date"
categories={categories}
colors={colors}
valueFormatter={(value) => value.toLocaleString()}
showLegend={true}
showGridLines={true}
yAxisWidth={56}
connectNulls={true}
curveType="monotone"
/>
</Panel>
);
}

View file

@ -1,5 +1,8 @@
import React from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { Route } from "lucide-react";
import { cn } from "@/lib/cva.config";
import { Panel } from "../../overview/Primitives";
import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
@ -41,7 +44,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
{
header: "Endpoint",
accessorKey: "endpoint",
cell: ({ row }) => <span className="font-medium">{row.original.endpoint}</span>,
cell: ({ row }) => <span className="font-mono text-xs text-foreground">{row.original.endpoint}</span>,
},
{
header: "Successful / Failed",
@ -54,18 +57,20 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
const totalPercentage = successPercentage + failurePercentage;
return (
<div className="flex items-center space-x-3">
<div className="flex-1 relative">
<div className="flex items-center gap-3">
<div className="relative min-w-16 flex-1">
<Meter value={successPercentage} max={totalPercentage || 100} aria-label="Successful requests">
<MeterTrack className={failurePercentage > 0 ? "bg-destructive" : undefined}>
<MeterIndicator className="bg-success" />
<MeterTrack className={failurePercentage > 0 ? "h-1 bg-destructive/70" : "h-1"}>
<MeterIndicator className="bg-[#2b3fd6]" />
</MeterTrack>
</Meter>
</div>
<div className="flex items-center space-x-2 text-sm min-w-[100px]">
<span className="text-success font-medium">{record.successful_requests.toLocaleString()}</span>
<div className="flex min-w-[100px] items-center gap-1.5 text-xs tabular-nums">
<span className="text-foreground">{record.successful_requests.toLocaleString()}</span>
<span className="text-muted-foreground">/</span>
<span className="text-destructive font-medium">{record.failed_requests.toLocaleString()}</span>
<span className={record.failed_requests > 0 ? "text-destructive" : "text-muted-foreground"}>
{record.failed_requests.toLocaleString()}
</span>
</div>
</div>
);
@ -75,7 +80,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
header: "Total Request",
accessorKey: "api_requests",
meta: { numeric: true },
cell: ({ row }) => row.original.api_requests.toLocaleString(),
cell: ({ row }) => <span className="tabular-nums">{row.original.api_requests.toLocaleString()}</span>,
},
{
header: "Success Rate",
@ -86,13 +91,10 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
const successRateStr = value.toFixed(2);
return (
<span
className={
value >= 95
? "text-success font-medium"
: value >= 80
? "text-warning font-medium"
: "text-destructive font-medium"
}
className={cn(
"tabular-nums",
value >= 95 ? "text-foreground" : value >= 80 ? "text-warning" : "text-destructive",
)}
>
{successRateStr}%
</span>
@ -103,7 +105,7 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
header: "Total Tokens",
accessorKey: "total_tokens",
meta: { numeric: true },
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
cell: ({ row }) => <span className="tabular-nums">{row.original.total_tokens.toLocaleString()}</span>,
},
{
header: "Spend",
@ -114,13 +116,23 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
];
return (
<DataTable
columns={columns}
data={dataSource}
getRowId={(row) => row.key}
noDataMessage="No endpoint usage data"
size="compact"
/>
<Panel
icon={Route}
title="Endpoints"
action={
<span className="text-xs text-muted-foreground tabular-nums">
{dataSource.length.toLocaleString()} {dataSource.length === 1 ? "endpoint" : "endpoints"}
</span>
}
>
<DataTable
columns={columns}
data={dataSource}
getRowId={(row) => row.key}
noDataMessage="No endpoint usage data"
size="compact"
/>
</Panel>
);
};

View file

@ -563,7 +563,8 @@ describe("EntityUsage", () => {
expect(spendElements.length).toBeGreaterThan(0);
});
expect(screen.getByText("1,000")).toBeInTheDocument(); // Total Requests
// Scoped to the active Cost tab: the keep-mounted Key Activity tab shows the same totals.
expect(within(screen.getByRole("tabpanel")).getByText("1,000")).toBeInTheDocument(); // Total Requests
});
it("should render with team entity type and call team API", async () => {
@ -683,7 +684,7 @@ describe("EntityUsage", () => {
fireEvent.click(keyActivityTab);
});
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
expect(within(screen.getByRole("tabpanel")).getByRole("heading", { name: "Overall Usage" })).toBeInTheDocument();
});
it("loads key pages separately from the aggregate using the current entity scope", async () => {
@ -806,9 +807,10 @@ describe("EntityUsage", () => {
});
expect(await screen.findByText("Tag Spend Overview")).toBeInTheDocument();
expect(await screen.findByText("$0.00")).toBeInTheDocument();
expect(screen.getByText("Total Spend")).toBeInTheDocument();
expect(screen.getAllByText("0")[0]).toBeInTheDocument();
const costTab = screen.getByRole("tabpanel");
expect(await within(costTab).findByText("$0.00")).toBeInTheDocument();
expect(within(costTab).getByText("Total Spend")).toBeInTheDocument();
expect(within(costTab).getAllByText("0")[0]).toBeInTheDocument();
});
it("should display Model Activity tab for non-agent entity types", async () => {
@ -1093,31 +1095,26 @@ describe("EntityUsage", () => {
});
});
it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => {
it("renders the stacked daily spend chart, the per-entity table, and the provider share bar with a $ total", async () => {
const { container } = render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
// The fixture carries no model breakdown, so the day's spend stacks as a single "Other" segment.
await waitFor(() => {
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(1);
});
expect(container.querySelector("path.recharts-rectangle")).toHaveAttribute("fill", "#94a3b8");
const barFills = new Set(
Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")),
);
expect(barFills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
expect(screen.getAllByText("Jan 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0);
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(1);
const sectors = container.querySelectorAll(".recharts-pie-sector path");
expect(sectors).toHaveLength(1);
expect(sectors[0]).toHaveAttribute("fill", "var(--color-cyan-500, #06b6d4)");
const centerLabels = Array.from(container.querySelectorAll("text.fill-foreground")).map((text) => text.textContent);
expect(centerLabels).toContain("$100.50");
const segments = screen.getAllByTestId("provider-share-segment");
expect(segments).toHaveLength(1);
expect(segments[0]).toHaveStyle({ backgroundColor: "rgb(236, 72, 153)" });
expect(screen.getByTestId("provider-spend-total")).toHaveTextContent("$100.50");
});
it("should label the chart with user_email metadata instead of the raw UUID (LIT-3889)", async () => {

View file

@ -1,32 +1,24 @@
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { BarChart, DonutChart } from "@/components/shared/charts";
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import {
getProviderSpend,
getTopAgents,
getTopAPIKeys,
getTopModels,
type ProviderSpendRow,
} from "./entityUsageAggregations";
import { getProviderSpend, getTopAgents, getTopAPIKeys, getTopModels } from "./entityUsageAggregations";
import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary";
import { MoneyCell } from "@/components/shared/table_cells";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { hasCapability, type Capability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { ChevronDown, ChevronRight, Info } from "lucide-react";
import { Bot, Boxes, ChevronDown, ChevronRight, ExternalLink, Info, KeyRound, Layers, Server } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import React, { type ReactNode, useCallback, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import UserDropdown from "@/components/common_components/UserDropdown";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import { UsageExportHeader } from "@/components/EntityUsageExport";
import type { EntityType } from "@/components/EntityUsageExport/types";
import { Logo } from "@/components/molecules/logo/Logo";
import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity";
import { ENTITY_API } from "./entityFetchFns";
import {
@ -35,28 +27,76 @@ import {
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
import { EntityMetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import type { DailyData, EntityMetricWithMetadata } from "@/components/UsagePage/types";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
import ModelViewToggle, { ModelViewType } from "../ModelViewToggle";
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "../overview/BreakdownChart";
import {
bucketSeries,
bucketTotals,
dailyTotals,
formatMetricValue,
type Granularity,
type Series,
type UsageMetric,
} from "../overview/overviewData";
import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives";
import TopModelView from "./TopModelView";
import TeamUserSpendCard from "./TeamUserSpendCard";
import { ProviderSpendBreakdown } from "./SpendByProvider";
interface EntityMetrics {
metrics: {
spend: number;
prompt_tokens: number;
completion_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
total_tokens: number;
successful_requests: number;
failed_requests: number;
api_requests: number;
const BRAND = "#2b3fd6";
const FLAT_COST_SERIES = "Flat cost";
const FLAT_COST_COLOR = "#8b5cf6";
const METRIC_TITLE: Record<UsageMetric, string> = { spend: "Spend", tokens: "Tokens", requests: "Requests" };
/** Summary tiles that carry a sparkline, keyed by tile title, valued by the dailyTotals field. */
const TILE_TRENDS: Readonly<Record<string, string>> = {
"Total Spend": "spend",
"Total Cost": "spend",
"Total Requests": "requests",
"Total Tokens": "tokens",
};
const GRANULARITY_OPTIONS = [
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
] as const satisfies readonly { value: Granularity; label: string }[];
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" };
/** 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],
colors: [...series.colors, FLAT_COST_COLOR],
};
metadata: Record<string, any>;
};
function ShareBar({ value, max }: { value: number; max: number }) {
return (
<div aria-hidden="true" className="h-1 w-full min-w-16 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full opacity-80"
style={{ width: `${max > 0 ? (value / max) * 100 : 0}%`, backgroundColor: BRAND }}
/>
</div>
);
}
function StatCell({ children, trend, className }: { children: ReactNode; trend?: ReactNode; className?: string }) {
return (
<div className={cn("flex min-w-0 flex-col justify-between gap-3 bg-card px-4 pt-3.5 pb-3", className)}>
<div className="min-w-0">{children}</div>
{trend}
</div>
);
}
export interface EntityList {
@ -267,19 +307,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
return filterDataByTags(result);
};
const entityRows = getEntityBreakdown().filter((entity) => entity.metrics.spend > 0);
const maxEntitySpend = Math.max(...entityRows.map((entity) => entity.metrics.spend), 0);
const getProcessedEntityBreakdownForChart = () => {
const data = getEntityBreakdown();
const topEntities = data.slice(0, 5);
return topEntities.map((e) => ({
...e,
metadata: {
...e.metadata,
alias_display:
e.metadata.alias && e.metadata.alias.length > 15 ? `${e.metadata.alias.slice(0, 15)}...` : e.metadata.alias,
},
}));
};
const [breakdown, setBreakdown] = useState<BreakdownState>({ metric: "spend", dimension: "model_groups" });
const [granularity, setGranularity] = useState<Granularity>("day");
const [scale, setScale] = useState<StackedUsageScale>("linear");
const breakdownDimension = modelBreakdownKey;
const { series: dailySeries, ranking } = useBreakdown(
spendData.results,
{ metric: breakdown.metric, dimension: breakdownDimension },
8,
);
const getFilterLabel = (entityType: string) => {
return `Filter by ${entityType}`;
@ -307,359 +346,251 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
[selectedTags, teams],
);
const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]);
const dailyTrend = useMemo(() => dailyTotals(spendData.results), [spendData.results]);
const stackFlatCost = showFlatCost && breakdown.metric === "spend";
const chartSeries = useMemo(() => {
const series = stackFlatCost ? withFlatCost(dailySeries, spendData.results) : dailySeries;
return bucketSeries(series, granularity);
}, [stackFlatCost, dailySeries, spendData.results, granularity]);
const chartTotals = useMemo(() => bucketTotals(chartSeries), [chartSeries]);
const entityBreakdownColumns = useMemo<ColumnDef<EntityMetricWithMetadata>[]>(
() => [
{
header: capitalizedEntityLabel,
accessorKey: "metadata.alias",
cell: ({ row }) => row.original.metadata.alias,
meta: QUIET_HEADER,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.metadata.alias}</span>,
},
{
header: "Share",
id: "share",
meta: { ...QUIET_HEADER, className: "w-40" },
cell: ({ row }) => <ShareBar value={row.original.metrics.spend} max={maxEntitySpend} />,
},
{
header: "Spend",
accessorKey: "metrics.spend",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => <MoneyCell value={row.original.metrics.spend} decimals={4} />,
},
{
header: "Successful",
accessorKey: "metrics.successful_requests",
meta: { numeric: true, className: "text-success" },
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
cell: ({ row }) => row.original.metrics.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "metrics.failed_requests",
meta: { numeric: true, className: "text-destructive" },
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
cell: ({ row }) => row.original.metrics.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "metrics.total_tokens",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.metrics.total_tokens.toLocaleString(),
},
],
[capitalizedEntityLabel],
);
const providerSpendColumns = useMemo<ColumnDef<ProviderSpendRow>[]>(
() => [
{
header: "Provider",
accessorKey: "provider",
cell: ({ row }) => (
<div className="flex items-center space-x-2">
{row.original.provider && <Logo provider={row.original.provider} className="size-4" />}
<span>{row.original.provider}</span>
</div>
),
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success" },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive" },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "tokens",
meta: { numeric: true },
cell: ({ row }) => row.original.tokens.toLocaleString(),
},
],
[],
[capitalizedEntityLabel, maxEntitySpend],
);
const chev = "size-3 text-muted-foreground";
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
<ShadcnCard
key={title}
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
const tileLabel = ({ title, tooltip, expandable }: SummaryTile) => (
<>
<span>{title}</span>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-3.5 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</>
);
const renderSummaryTile = (tile: SummaryTile, index: number) => {
const trendKey = TILE_TRENDS[tile.title];
const stat = <Stat label={tileLabel(tile)} value={<span className={tile.className}>{tile.value}</span>} />;
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 (
<StatCell key={tile.title} trend={trend} className={span}>
{stat}
</StatCell>
);
}
return (
<div
key={tile.title}
role="button"
tabIndex={0}
aria-expanded={showCostBreakdown}
className={cn("flex cursor-pointer transition-colors [&>div]:flex-1 hover:[&>div]:bg-accent/50", span)}
onClick={() => setShowCostBreakdown(!showCostBreakdown)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setShowCostBreakdown(!showCostBreakdown);
}
}}
>
<StatCell trend={trend}>{stat}</StatCell>
</div>
);
};
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];
const summaryTiles = [...buildSummaryTiles(spendData.metadata, showFlatCost), ...breakdownTiles];
const summaryTiles = buildSummaryTiles(spendData.metadata, showFlatCost);
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
const chartFormat = (value: number) => formatMetricValue(value, breakdown.metric);
const costPanel = loading ? (
<ChartLoader />
) : (
<div className="grid grid-cols-2 gap-2 w-full">
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">{capitalizedEntityLabel} Spend Overview</h3>
<div className="grid grid-cols-5 gap-4 mt-4">{summaryTiles.map(renderSummaryTile)}</div>
</CardContent>
</ShadcnCard>
</div>
<div className="grid gap-3">
<section className="grid gap-2">
<h2 className="text-sm font-medium text-foreground">{capitalizedEntityLabel} Spend Overview</h2>
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-border lg:grid-cols-5">
{summaryTiles.map(renderSummaryTile)}
</div>
{breakdownTiles.length > 0 && (
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border bg-border">
{breakdownTiles.map((tile) => (
<StatCell key={tile.title}>
<Stat label={tileLabel(tile)} value={<span className={tile.className}>{tile.value}</span>} />
</StatCell>
))}
</div>
)}
</section>
{/* Daily Spend Chart */}
<div className="col-span-2">
<ShadcnCard>
<CardHeader>
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={[...spendData.results]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((row) => ({
...row,
"Request cost": row.metrics.spend ?? 0,
"Flat cost": row.metrics.flat_cost ?? 0,
}))}
index="date"
categories={showFlatCost ? ["Request cost", "Flat cost"] : ["metrics.spend"]}
colors={showFlatCost ? ["cyan", "violet"] : ["cyan"]}
stack={showFlatCost}
valueFormatter={valueFormatterSpend}
yAxisWidth={100}
showLegend={showFlatCost}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
const entityCount = Object.keys(data.breakdown.entities || {}).length;
const requestSpend = data.metrics.spend ?? 0;
const flatCost = data.metrics.flat_cost ?? 0;
return (
<div className="bg-card p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
{showFlatCost ? (
<>
<p className="text-info">Request cost: ${formatNumberWithCommas(requestSpend, 2)}</p>
<p className="text-violet-500">Flat cost: ${formatNumberWithCommas(flatCost, 2)}</p>
<p className="font-semibold">
Total cost: ${formatNumberWithCommas(requestSpend + flatCost, 2)}
</p>
</>
) : (
<p className="text-info">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
)}
<p className="text-muted-foreground">Total Requests: {data.metrics.api_requests}</p>
<p className="text-muted-foreground">Successful: {data.metrics.successful_requests}</p>
<p className="text-muted-foreground">Failed: {data.metrics.failed_requests}</p>
<p className="text-muted-foreground">Total Tokens: {data.metrics.total_tokens}</p>
<p className="text-muted-foreground">
Total {capitalizedEntityLabel}s: {entityCount}
</p>
<div className="mt-2 border-t pt-2">
<p className="font-semibold">Spend by {capitalizedEntityLabel}:</p>
{Object.entries(data.breakdown.entities || {})
.sort(([, a], [, b]) => {
const spendA = (a as EntityMetrics).metrics.spend;
const spendB = (b as EntityMetrics).metrics.spend;
return spendB - spendA;
})
.slice(0, 5)
.map(([entity, entityData]) => {
const metrics = entityData as EntityMetrics;
return (
<p key={entity} className="text-sm text-muted-foreground">
{getEntityLabel(entity, metrics.metadata)}: $
{formatNumberWithCommas(metrics.metrics.spend, 2)}
</p>
);
})}
{entityCount > 5 && (
<p className="text-sm text-muted-foreground italic">...and {entityCount - 5} more</p>
)}
</div>
</div>
);
}}
<section className="rounded-xl border bg-card">
<header className="flex flex-wrap items-start justify-between gap-3 px-5 pt-4">
<div>
<h3 className="text-sm font-medium text-foreground">
{METRIC_TITLE[breakdown.metric]} by {modelViewType === "groups" ? "model" : "deployment"}
</h3>
<p className="text-xs text-muted-foreground">
{granularity === "day" ? "Daily" : "Weekly"} {breakdown.metric}, top 8 stacked
{stackFlatCost ? ", flat cost on top" : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<BreakdownControls state={breakdown} onChange={setBreakdown} showDimension={false} />
<Segmented
label="Bucket size"
value={granularity}
options={GRANULARITY_OPTIONS}
onChange={setGranularity}
/>
</CardContent>
</ShadcnCard>
</div>
{/* Entity Breakdown Section */}
<div className="col-span-2">
<ShadcnCard>
<CardContent className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium text-foreground">Spend Per {capitalizedEntityLabel}</h3>
<p className="text-xs text-muted-foreground">Showing Top 5 by Spend</p>
<div className="flex items-center text-sm text-muted-foreground">
<span>Get Started by Tracking cost per {capitalizedEntityLabel} </span>
<a
href="https://docs.litellm.ai/docs/proxy/enterprise#spend-tracking"
className="text-info hover:text-info/80 ml-1"
>
here
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<BarChart
className="mt-4 h-52"
data={getProcessedEntityBreakdownForChart()}
index="metadata.alias_display"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
showLegend={false}
yAxisWidth={150}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-card p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.metadata.alias}</p>
<p className="text-info">Spend: ${formatNumberWithCommas(data.metrics.spend, 4)}</p>
<p className="text-muted-foreground">Requests: {data.metrics.api_requests.toLocaleString()}</p>
<p className="text-success">Successful: {data.metrics.successful_requests.toLocaleString()}</p>
<p className="text-destructive">Failed: {data.metrics.failed_requests.toLocaleString()}</p>
<p className="text-muted-foreground">Tokens: {data.metrics.total_tokens.toLocaleString()}</p>
</div>
);
}}
/>
</div>
<div>
<DataTable
columns={entityBreakdownColumns}
data={getEntityBreakdown().filter((entity) => entity.metrics.spend > 0)}
getRowId={(row) => row.metadata.id}
maxBodyHeight={208}
noDataMessage={`No ${entityType} spend data`}
size="compact"
/>
</div>
</div>
</CardContent>
</ShadcnCard>
</div>
{entityType === "team" && (
<div className="col-span-2">
<TeamUserSpendCard
accessToken={accessToken}
startTime={startTime}
endTime={endTime}
teamIds={userSpendTeamIds}
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
</div>
</header>
<div className="px-3 pt-4 pb-2">
<StackedUsageChart
data={chartSeries.data}
series={chartSeries.keys}
colors={chartSeries.colors}
xKey="label"
scale={scale}
format={chartFormat}
totalFor={(label) => chartTotals.get(label)}
className="h-[320px]"
/>
</div>
<div className="border-t px-5 py-3">
<Leaderboard
ranking={ranking}
series={dailySeries}
metric={breakdown.metric}
dimension={breakdownDimension}
columns={2}
limit={8}
/>
</div>
</section>
<Panel
icon={Layers}
title={`Spend Per ${capitalizedEntityLabel}`}
action={
<a
href="https://docs.litellm.ai/docs/proxy/enterprise#spend-tracking"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Track cost per {entityType}
<ExternalLink aria-hidden="true" className="size-3" />
</a>
}
>
<DataTable
columns={entityBreakdownColumns}
data={entityRows}
getRowId={(row) => row.metadata.id}
maxBodyHeight={280}
noDataMessage={`No ${entityType} spend data`}
size="compact"
/>
</Panel>
{entityType === "team" && (
<TeamUserSpendCard
accessToken={accessToken}
startTime={startTime}
endTime={endTime}
teamIds={userSpendTeamIds}
/>
)}
{/* Top API Keys */}
<div>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys</h3>
<TopKeyView
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</CardContent>
</ShadcnCard>
</div>
{/* Top Models */}
<div>
<ShadcnCard>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">
{entityType === "agent" ? "Top Agents" : modelViewTitle}
</h3>
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<TopModelView
topModels={getTopModels(spendData.results, modelBreakdownKey, topModelsLimit)}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</CardContent>
</ShadcnCard>
<div className="grid gap-3 lg:grid-cols-2">
<Panel icon={KeyRound} title="Top Virtual Keys">
<TopKeyView
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Panel>
<Panel
icon={Boxes}
title={entityType === "agent" ? "Top Agents" : modelViewTitle}
action={<ModelViewToggle value={modelViewType} onChange={setModelViewType} />}
>
<TopModelView
topModels={getTopModels(spendData.results, modelBreakdownKey, topModelsLimit)}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</Panel>
</div>
{showAgentBreakdown && (
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Agents Driving Spend</h3>
{agentLoading ? (
<ChartLoader />
) : (
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
)}
</CardContent>
</ShadcnCard>
</div>
<Panel icon={Bot} title="Top Agents Driving Spend">
{agentLoading ? (
<ChartLoader />
) : (
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
)}
</Panel>
)}
{/* Spend by Provider */}
<div className="col-span-2">
<ShadcnCard>
<CardContent className="flex flex-col space-y-4">
<h3 className="text-lg font-medium text-foreground">Provider Usage</h3>
<div className="grid grid-cols-2">
<div>
<DonutChart
className="mt-4 h-40"
data={providerSpend}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]}
showLabel
startAngle={90}
endAngle={-270}
/>
</div>
<div>
<DataTable
columns={providerSpendColumns}
data={providerSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</div>
</div>
</CardContent>
</ShadcnCard>
</div>
<Panel icon={Server} title="Provider Usage">
<ProviderSpendBreakdown rows={providerSpend} />
</Panel>
</div>
);
@ -670,7 +601,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
label: entityType === "agent" ? "Request / Token Consumption" : "Model Activity",
content: (
<>
<div className="flex justify-end mt-2 mb-4">
<div className="mb-3 flex justify-end">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics
@ -709,9 +640,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
];
return (
<div style={{ width: "100%" }} className="relative">
<div className="relative grid w-full gap-3">
{failed && (
<Alert variant="error" className="mb-2">
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
@ -719,32 +650,35 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</Alert>
)}
{showAgentBreakdown && agentFailed && (
<Alert variant="error" className="mb-2">
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
<UsageExportHeader
dateValue={dateValue}
entityType={entityType}
onExport={(exportType, format) =>
request
? api.exportRows(request, exportType, format)
: Promise.reject(new Error("Select a date range to export"))
}
showFilters={filterSlot === undefined && entityList !== null}
filterSlot={filterSlot}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
teams={teamList}
/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">
{/* 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}
entityType={entityType}
onExport={(exportType, format) =>
request
? api.exportRows(request, exportType, format)
: Promise.reject(new Error("Select a date range to export"))
}
showFilters={filterSlot === undefined && entityList !== null}
filterSlot={filterSlot}
filterLabel={getFilterLabel(entityType)}
filterPlaceholder={getFilterPlaceholder(entityType)}
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
teams={teamList}
/>
</div>
<Tabs defaultValue={tabs[0].key} className="gap-3">
<TabsList variant="line">
{tabs.map(({ key, label }) => (
<TabsTrigger key={key} value={key} className="flex-none px-3">
{label}

View file

@ -216,21 +216,19 @@ describe("SpendByProvider", () => {
expect(screen.queryByText("unknown")).not.toBeInTheDocument();
});
it("renders one cyan donut sector per visible provider with the $ total as center label", () => {
const { container } = render(
<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />,
);
it("renders one share segment per visible provider, largest first, in the stacked usage palette with the $ total", () => {
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />);
const sectors = container.querySelectorAll(".recharts-pie-sector path");
expect(sectors).toHaveLength(2);
const fills = new Set(Array.from(sectors).map((sector) => sector.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
const centerLabels = Array.from(container.querySelectorAll("text.fill-foreground")).map((text) => text.textContent);
expect(centerLabels).toContain("$351.25");
const segments = screen.getAllByTestId("provider-share-segment");
expect(segments).toHaveLength(2);
// anthropic ($200.75) outranks openai ($150.50), so it takes the first palette color.
expect(segments.map((segment) => segment.getAttribute("title"))).toEqual(["anthropic · 57.2%", "openai · 42.8%"]);
expect(segments[0]).toHaveStyle({ backgroundColor: "rgb(236, 72, 153)" });
expect(segments[1]).toHaveStyle({ backgroundColor: "rgb(168, 85, 247)" });
expect(screen.getByTestId("provider-spend-total")).toHaveTextContent("$351.25");
});
it("adds the unknown provider slice and updates the center total when Show Unknown is on", () => {
it("adds the unknown provider segment and updates the total when Show Unknown is on", () => {
const providerSpendWithUnknown = [
{
provider: "openai",
@ -249,17 +247,15 @@ describe("SpendByProvider", () => {
tokens: 1000,
},
];
const { container } = render(
<SpendByProvider loading={false} isDateChanging={false} providerSpend={providerSpendWithUnknown} />,
);
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={providerSpendWithUnknown} />);
expect(container.querySelectorAll(".recharts-pie-sector path")).toHaveLength(1);
expect(container.querySelector("text.fill-foreground")?.textContent).toBe("$150.50");
expect(screen.getAllByTestId("provider-share-segment")).toHaveLength(1);
expect(screen.getByTestId("provider-spend-total")).toHaveTextContent("$150.50");
fireEvent.click(screen.getAllByRole("switch")[1]);
expect(container.querySelectorAll(".recharts-pie-sector path")).toHaveLength(2);
expect(container.querySelector("text.fill-foreground")?.textContent).toBe("$200.50");
expect(screen.getAllByTestId("provider-share-segment")).toHaveLength(2);
expect(screen.getByTestId("provider-spend-total")).toHaveTextContent("$200.50");
});
it("should include all providers with spend greater than zero by default", () => {

View file

@ -1,15 +1,15 @@
import { DonutChart } from "@/components/shared/charts";
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Info } from "lucide-react";
import { Info, Server } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { useState } from "react";
import React, { useId, useState } from "react";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Panel } from "../overview/Primitives";
type ProviderSpendData = {
provider: string;
@ -26,43 +26,133 @@ 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>[] = [
{
header: "Provider",
accessorKey: "provider",
meta: QUIET_HEADER,
cell: ({ row }) => (
<div className="flex items-center space-x-2">
{row.original.provider && <ProviderLogo provider={row.original.provider} className="size-4" />}
<span>{row.original.provider}</span>
<div className="flex min-w-0 items-center gap-2">
{row.original.provider && (
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border bg-background">
<ProviderLogo provider={row.original.provider} className="size-3.5 rounded-[3px]" />
</span>
)}
<span className="truncate font-medium text-foreground">{row.original.provider}</span>
</div>
),
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={2} />,
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success" },
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive" },
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "tokens",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.tokens.toLocaleString(),
},
];
/**
* One stacked bar of each provider's share of spend over the per-provider table. Reads at a
* glance where a donut does not, and keeps sub-cent providers legible. Shared with the entity
* usage views.
*/
export function ProviderSpendBreakdown({ rows }: { rows: readonly ProviderSpendData[] }) {
const sorted = [...rows].sort((a, b) => b.spend - a.spend);
const total = sorted.reduce((sum, row) => sum + row.spend, 0);
const shareOf = (spend: number) => (total > 0 ? (spend / total) * 100 : 0);
const colorOf = (index: number) => STACKED_USAGE_PALETTE[index % STACKED_USAGE_PALETTE.length];
const visible = sorted.filter((row) => row.spend > 0);
return (
<div className="grid gap-4">
{visible.length > 0 && (
<div className="grid gap-2.5">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-muted-foreground">Total provider spend</span>
<span data-testid="provider-spend-total" className="text-sm font-semibold tabular-nums">
${formatNumberWithCommas(total, 2)}
</span>
</div>
<div className="flex h-2 w-full overflow-hidden rounded-full bg-muted">
{visible.map((row, index) => (
<div
key={row.provider}
data-testid="provider-share-segment"
title={`${row.provider || "unknown"} · ${shareOf(row.spend).toFixed(1)}%`}
className="h-full first:rounded-l-full last:rounded-r-full"
style={{ width: `${shareOf(row.spend)}%`, backgroundColor: colorOf(index) }}
/>
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{visible.slice(0, 6).map((row, index) => (
<span key={row.provider} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="size-2 rounded-[2px]" style={{ backgroundColor: colorOf(index) }} />
<span className="text-foreground">{row.provider || "unknown"}</span>
<span className="tabular-nums">{shareOf(row.spend).toFixed(1)}%</span>
</span>
))}
</div>
</div>
)}
<DataTable
columns={columns}
data={sorted}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</div>
);
}
function ToggleField({
label,
checked,
onCheckedChange,
hint,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
hint?: string;
}) {
const id = useId();
return (
<div className="flex items-center gap-1.5">
<Switch id={id} size="sm" checked={checked} onCheckedChange={onCheckedChange} />
<label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</label>
{hint && (
<Tooltip>
<TooltipTrigger render={<Info className="size-3.5 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)}
</div>
);
}
const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChanging, providerSpend }) => {
const [includeZeroSpend, setIncludeZeroSpend] = useState(false);
const [includeUnknown, setIncludeUnknown] = useState(false);
@ -85,53 +175,28 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
});
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Spend by Provider</CardTitle>
<CardAction className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-foreground">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<label className="text-sm text-foreground">Show Unknown</label>
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={filteredProviderSpend}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
<DataTable
columns={columns}
data={filteredProviderSpend}
getRowId={(row) => row.provider}
noDataMessage="No provider usage data"
size="compact"
/>
</div>
)}
</CardContent>
</Card>
<Panel
icon={Server}
title="Spend by Provider"
className="h-full"
action={
<div className="flex flex-wrap items-center gap-3">
<ToggleField label="Show Zero Spend" checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
<ToggleField
label="Show Unknown"
checked={includeUnknown}
onCheckedChange={setIncludeUnknown}
hint="Requests that failed to route to a provider"
/>
</div>
}
>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<ProviderSpendBreakdown rows={filteredProviderSpend} />
)}
</Panel>
);
};

View file

@ -1,13 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import type { ColumnDef } from "@tanstack/react-table";
import { Download } from "lucide-react";
import { Download, Users } from "lucide-react";
import React, { useMemo } from "react";
import { teamSpendByUserCall } from "@/components/networking";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { Button } from "@/components/ui/button";
import { Card as ShadcnCard, CardContent } from "@/components/ui/card";
import { Panel } from "../overview/Primitives";
import {
buildTeamUserSpendCsv,
@ -27,37 +28,46 @@ 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>[] = [
{ header: "Team", accessorFn: teamLabel, id: "team", cell: ({ row }) => teamLabel(row.original) },
{ header: "User", accessorFn: userLabel, id: "user", cell: ({ row }) => userLabel(row.original) },
{ header: "Team", accessorFn: teamLabel, id: "team", meta: QUIET_HEADER, cell: ({ row }) => teamLabel(row.original) },
{
header: "User",
accessorFn: userLabel,
id: "user",
meta: QUIET_HEADER,
cell: ({ row }) => <span className="font-medium text-foreground">{userLabel(row.original)}</span>,
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
header: "Requests",
accessorKey: "api_requests",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.api_requests.toLocaleString(),
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success" },
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive" },
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "total_tokens",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
},
];
@ -73,36 +83,34 @@ const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, star
const rows = useMemo(() => sortBySpendDesc(data?.results ?? []), [data]);
return (
<ShadcnCard>
<CardContent className="flex flex-col space-y-4">
<div className="flex items-start justify-between">
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium text-foreground">Spend Per User Within Team</h3>
<p className="text-xs text-muted-foreground">
Attributed per request from spend logs, so it includes JWT/SSO traffic that does not use a virtual key
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={!data || rows.length === 0}
onClick={() => data && downloadCsv(buildTeamUserSpendCsv(data), teamUserSpendCsvFileName(data))}
>
<Download />
Download CSV
</Button>
</div>
<DataTable
columns={columns}
data={rows}
getRowId={teamUserSpendRowId}
isLoading={isLoading}
maxBodyHeight={320}
noDataMessage={teamIds.length === 0 ? "Select a team to see spend per user" : "No user spend in this range"}
size="compact"
/>
</CardContent>
</ShadcnCard>
<Panel
icon={Users}
title="Spend Per User Within Team"
action={
<Button
variant="outline"
size="xs"
disabled={!data || rows.length === 0}
onClick={() => data && downloadCsv(buildTeamUserSpendCsv(data), teamUserSpendCsvFileName(data))}
>
<Download />
Download CSV
</Button>
}
>
<p className="mb-3 text-xs text-muted-foreground">
Attributed per request from spend logs, so it includes JWT/SSO traffic that does not use a virtual key
</p>
<DataTable
columns={columns}
data={rows}
getRowId={teamUserSpendRowId}
isLoading={isLoading}
maxBodyHeight={320}
noDataMessage={teamIds.length === 0 ? "Select a team to see spend per user" : "No user spend in this range"}
size="compact"
/>
</Panel>
);
};

View file

@ -96,7 +96,7 @@ describe("TopModelView", () => {
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
});
it("renders one cyan bar per model with model names on the axis in chart view", async () => {
it("renders one brand-blue bar per model with model names on the axis in chart view", async () => {
const user = userEvent.setup();
const { container } = render(
<TopModelView
@ -126,7 +126,7 @@ describe("TopModelView", () => {
const bars = container.querySelectorAll("path.recharts-rectangle");
expect(bars).toHaveLength(2);
const fills = new Set(Array.from(bars).map((bar) => bar.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
expect(fills).toEqual(new Set(["#2b3fd6"]));
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0);
});

View file

@ -1,9 +1,11 @@
import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { useState } from "react";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { Segmented } from "../overview/Primitives";
import { providerForModel } from "../overview/modelProvider";
type TopModel = {
key: string;
@ -19,39 +21,68 @@ interface TopModelViewProps {
setTopModelsLimit: (limit: number) => void;
}
export const TOP_MODEL_LIMITS = [5, 10, 25, 50];
const TOP_MODEL_LIMITS = [5, 10, 25, 50];
type ViewMode = "chart" | "table";
const LIMIT_OPTIONS = TOP_MODEL_LIMITS.map((limit) => ({ value: String(limit), label: String(limit) }));
const VIEW_OPTIONS = [
{ value: "table", label: "Table View" },
{ 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 }) {
if (!name) return <>-</>;
const provider = providerForModel(name);
return (
<span className="flex min-w-0 items-center gap-2">
{provider && (
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border bg-background">
<ProviderLogo provider={provider} className="size-3.5 rounded-[3px]" />
</span>
)}
<span className="truncate font-medium text-foreground">{name}</span>
</span>
);
}
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table");
const [modelViewMode, setModelViewMode] = useState<ViewMode>("table");
const columns = [
{
header: "Model",
accessorKey: "key",
cell: (info: any) => info.getValue() || "-",
meta: QUIET_HEADER,
cell: (info: any) => <ModelName name={info.getValue()} />,
},
{
header: "Spend (USD)",
accessorKey: "spend",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: (info: any) => <span className="text-success">{info.getValue()?.toLocaleString() || 0}</span>,
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: (info: any) => <span className="text-destructive">{info.getValue()?.toLocaleString() || 0}</span>,
},
{
header: "Tokens",
accessorKey: "tokens",
meta: { numeric: true },
meta: { numeric: true, ...QUIET_HEADER },
cell: (info: any) => info.getValue()?.toLocaleString() || 0,
},
];
@ -59,36 +90,29 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
return (
<>
<div className="mb-4 flex justify-between items-center">
<Tabs value={String(topModelsLimit)} onValueChange={(value: string) => setTopModelsLimit(Number(value))}>
<TabsList aria-label="Number of models to show">
{TOP_MODEL_LIMITS.map((limit) => (
<TabsTrigger key={limit} value={String(limit)} className="flex-none px-3">
{limit}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Tabs value={modelViewMode} onValueChange={(value: string) => setModelViewMode(value as "chart" | "table")}>
<TabsList aria-label="Top model view mode">
<TabsTrigger value="table" className="flex-none px-3">
Table View
</TabsTrigger>
<TabsTrigger value="chart" className="flex-none px-3">
Chart View
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Segmented
label="Number of models to show"
value={String(topModelsLimit)}
options={LIMIT_OPTIONS}
onChange={(limit) => setTopModelsLimit(Number(limit))}
/>
<Segmented
label="Top model view mode"
value={modelViewMode}
options={VIEW_OPTIONS}
onChange={setModelViewMode}
/>
</div>
{modelViewMode === "chart" ? (
<div className="relative max-h-[600px] overflow-y-auto">
<BarChart
className="mt-4 cursor-pointer hover:opacity-90"
className="cursor-pointer hover:opacity-90"
style={{ height: Math.min(processedTopModels.length, topModelsLimit) * 52 }}
data={processedTopModels}
index="key"
categories={["spend"]}
colors={["cyan"]}
colors={[BAR_COLOR]}
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
layout="vertical"
yAxisWidth={200}

View file

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { MessageSquare, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Combobox,
@ -245,24 +246,17 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
style={{ width: 420 }}
>
{/* Header */}
<div className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-info" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" />
</svg>
<h3 className="text-base font-semibold text-foreground">Ask AI</h3>
</div>
<button
onClick={handleClose}
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-accent"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-5 pt-4 pb-3">
<div className="min-w-0">
<h3 className="flex items-center gap-2 text-sm leading-5 font-medium text-foreground">
<Sparkles aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Ask AI
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">Ask about your spend, models, keys, and trends</p>
</div>
<p className="text-xs text-muted-foreground">Ask about your spend, models, keys, and trends</p>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
<X />
</Button>
</div>
{/* Model selector */}
@ -293,18 +287,11 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
</div>
{/* Chat messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-muted">
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3 bg-muted/40">
{messages.length === 0 && !streamingContent && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<svg className="w-8 h-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<p className="text-sm font-medium">Ask a question about your usage</p>
<MessageSquare aria-hidden="true" className="mb-2 size-6" strokeWidth={1.5} />
<p className="text-sm font-medium text-foreground">Ask a question about your usage</p>
<p className="text-xs mt-1">e.g. &quot;Which model costs me the most?&quot;</p>
</div>
)}
@ -364,7 +351,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
</div>
{/* Input area */}
<div className="px-4 py-3 border-t border-border bg-card shrink-0">
<div className="px-5 py-3 border-t border-border bg-card shrink-0">
<div className="flex gap-2">
<Textarea
value={inputText}

View file

@ -12,8 +12,21 @@ import { processActivityData } from "@/components/activity_metrics";
import type { Organization } from "@/components/networking";
import type { ModelActivityData } from "@/components/UsagePage/types";
import * as networking from "@/components/networking";
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import { OTHER_COLOR } from "./overview/overviewData";
import UsagePage from "./UsagePageView";
// The Overview tab. The Key Activity tab stays mounted and carries its own spend-derived totals,
// so assertions about the overview's request tiles are scoped here rather than to the whole page.
const overview = () => within(screen.getByRole("tabpanel", { name: "Overview" }));
// The Total Requests stat cell: its label, the count, and the "N ok / N failed" line beneath it.
const totalRequestsCell = (): HTMLElement => {
const cell = overview().getByText("Total Requests").parentElement?.parentElement;
expect(cell).toBeTruthy();
return cell as HTMLElement;
};
const modelActivity = (label: string): ModelActivityData => ({
label,
total_requests: 1,
@ -470,17 +483,19 @@ describe("UsagePage", () => {
});
// Check that key metrics are displayed
const totalRequestElements = screen.getAllByText("Total Requests");
const totalRequestElements = overview().getAllByText("Total Requests");
expect(totalRequestElements.length).toBeGreaterThan(0);
const successfulRequestLabelElements = screen.getAllByText("Successful Requests");
expect(successfulRequestLabelElements.length).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.getAllByText("424,242").length).toBeGreaterThan(0);
expect(overview().getAllByText("424,242").length).toBeGreaterThan(0);
});
expect(screen.getAllByText("909").length).toBeGreaterThan(0);
expect(screen.getByText("425,151")).toBeInTheDocument();
expect(screen.queryByText("1,500")).not.toBeInTheDocument();
expect(screen.queryByText("1,450")).not.toBeInTheDocument();
expect(overview().getAllByText("909").length).toBeGreaterThan(0);
expect(overview().getByText("425,151")).toBeInTheDocument();
// Successful and failed counts now sit under Total Requests rather than in their own tiles.
expect(totalRequestsCell()).toHaveTextContent("425,151");
expect(totalRequestsCell()).toHaveTextContent("424,242 ok");
expect(totalRequestsCell()).toHaveTextContent("909 failed");
expect(overview().queryByText("1,500")).not.toBeInTheDocument();
expect(overview().queryByText("1,450")).not.toBeInTheDocument();
});
it("should stop showing the previous range's totals while a new range is in flight", async () => {
@ -498,9 +513,11 @@ describe("UsagePage", () => {
);
renderWithProviders(<UsagePage {...defaultProps} />);
// Total Tokens reads compact (75K); the exact count stays on hover.
await waitFor(() => {
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
expect(overview().getAllByText("75K").length).toBeGreaterThan(0);
});
expect(overview().getByTitle("75,000 tokens")).toHaveTextContent("75K");
await act(async () => {
fireEvent.click(screen.getByTestId("pick-a-different-range"));
@ -509,21 +526,22 @@ describe("UsagePage", () => {
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("75,000")).not.toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(await screen.findByText("425,151")).toBeInTheDocument();
expect(screen.getByText("Total Tokens").closest('[data-slot="card"]')).not.toHaveTextContent(/\d/);
expect(screen.queryByText("$0.0000")).not.toBeInTheDocument();
expect(overview().queryByText("75K")).not.toBeInTheDocument();
expect(overview().queryByText("$0.00")).not.toBeInTheDocument();
expect(overview().getByText("Total Tokens")).toBeInTheDocument();
expect(await overview().findByText("425,151")).toBeInTheDocument();
// The Total Tokens stat (label, value and hint) must hold no number while its range is in flight.
expect(overview().getByText("Total Tokens").parentElement).not.toHaveTextContent(/\d/);
expect(overview().queryByText("$0.0000")).not.toBeInTheDocument();
await act(async () => {
releaseSecondFetch();
});
await waitFor(() => {
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
expect(overview().getAllByText("75K").length).toBeGreaterThan(0);
});
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("$0.0838")).toBeInTheDocument();
expect(overview().getByText("Total Tokens")).toBeInTheDocument();
expect(overview().getByText("$0.0838")).toBeInTheDocument();
});
it("loads key pages separately from the aggregate and refreshes them when the range changes", async () => {
@ -555,9 +573,12 @@ describe("UsagePage", () => {
expect(mockGatewayDailyActivityCall).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getAllByText("1,450").length).toBeGreaterThan(0);
expect(overview().getAllByText("1,450").length).toBeGreaterThan(0);
});
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(overview().getByText("1,500")).toBeInTheDocument();
expect(totalRequestsCell()).toHaveTextContent("1,500");
expect(totalRequestsCell()).toHaveTextContent("1,450 ok");
expect(totalRequestsCell()).toHaveTextContent("50 failed");
expect(screen.queryByText("424,242")).not.toBeInTheDocument();
expect(screen.queryByText("909")).not.toBeInTheDocument();
expect(screen.queryByText("425,151")).not.toBeInTheDocument();
@ -573,7 +594,9 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockGatewayDailyActivityCall).not.toHaveBeenCalled();
expect(screen.getByText("1,500")).toBeInTheDocument();
await waitFor(() => {
expect(totalRequestsCell()).toHaveTextContent("1,500");
});
expect(screen.queryByText("424,242")).not.toBeInTheDocument();
expect(screen.queryByTestId("gateway-requests-by-endpoint")).not.toBeInTheDocument();
});
@ -588,19 +611,20 @@ describe("UsagePage", () => {
// Check for usage metrics cards
const totalRequestElements = screen.getAllByText("Total Requests");
expect(totalRequestElements.length).toBeGreaterThan(0);
const successfulRequestElements = screen.getAllByText("Successful Requests");
expect(successfulRequestElements.length).toBeGreaterThan(0);
const failedRequestElements = screen.getAllByText("Failed Requests");
expect(failedRequestElements.length).toBeGreaterThan(0);
// Successful and failed counts now sit under Total Requests rather than in their own tiles.
await waitFor(() => {
expect(totalRequestsCell()).toHaveTextContent(/\d+ ok/);
});
expect(totalRequestsCell()).toHaveTextContent(/\d+ failed/);
const totalTokensElements = screen.getAllByText("Total Tokens");
expect(totalTokensElements.length).toBeGreaterThan(0);
// Check for chart titles (these are in the Cost tab)
expect(screen.getByText("Daily Spend")).toBeInTheDocument();
// Check for chart titles (these are in the Overview tab)
expect(screen.getByText("Top models")).toBeInTheDocument();
expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument();
});
it("should render the daily spend and top models charts with cyan bars", async () => {
it("should render the top models chart stacked in the shared usage palette", async () => {
const { container } = renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
@ -616,14 +640,15 @@ describe("UsagePage", () => {
);
};
// One day stacked as the top model (gpt-4) over the "Other" remainder of that day's spend.
await waitFor(() => {
expect(spendBars()).toHaveLength(2);
});
const fills = new Set(spendBars().map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"]));
expect(fills).toEqual(new Set([STACKED_USAGE_PALETTE[0], OTHER_COLOR]));
expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0);
expect(screen.getAllByText("Jan 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
});
@ -635,7 +660,7 @@ describe("UsagePage", () => {
});
// Default view should show Global Usage (for admin)
expect(screen.getByText("Daily Spend")).toBeInTheDocument();
expect(screen.getByText("Top models")).toBeInTheDocument();
// Switch to Team Usage view
const usageSelect = screen.getByTestId("usage-view-select");
@ -888,13 +913,10 @@ describe("UsagePage", () => {
});
describe("admin user selector", () => {
// Anchored on the field's own label, so it does not depend on which library draws the control.
// Anchored on the header dropdown's own test id, so it does not depend on which library draws
// the control.
const userSelectCombobox = (): HTMLElement => {
let node: HTMLElement | null = screen.getByText("Filter by user");
while (node && !node.querySelector('[role="combobox"]')) {
node = node.parentElement;
}
const combobox = node?.querySelector('[role="combobox"]') ?? null;
const combobox = screen.getByTestId("user-dropdown").querySelector('[role="combobox"]');
expect(combobox).not.toBeNull();
return combobox as HTMLElement;
};
@ -1065,9 +1087,9 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// The admin case above proves this label is rendered when the selector exists, so its
// The admin case above proves this test id is rendered when the selector exists, so its
// absence here is a live assertion rather than a query that can never match.
expect(screen.queryByText("Filter by user")).not.toBeInTheDocument();
expect(screen.queryByTestId("user-dropdown")).not.toBeInTheDocument();
});
it("should always pass own userId for non-admin users", async () => {
@ -1184,8 +1206,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Default should be "groups" view showing "Top Public Model Names"
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
// Default should be the "groups" view, which feeds Model Activity from model_groups
expect(screen.getByText("activity-source:model_groups")).toBeInTheDocument();
expect(screen.getAllByText("Public Model Name").length).toBeGreaterThan(0);
expect(screen.getAllByText("Litellm Model Name").length).toBeGreaterThan(0);
});
@ -1203,9 +1225,9 @@ describe("UsagePage", () => {
fireEvent.click(litellmToggle);
});
// Title should change to "Top Litellm Models"
// Model Activity should switch to the litellm models breakdown
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
expect(screen.getByText("activity-source:models")).toBeInTheDocument();
});
});
@ -1223,7 +1245,7 @@ describe("UsagePage", () => {
});
await waitFor(() => {
expect(screen.getByText("Top Litellm Models")).toBeInTheDocument();
expect(screen.getByText("activity-source:models")).toBeInTheDocument();
});
// Switch back to groups
@ -1233,8 +1255,9 @@ describe("UsagePage", () => {
});
await waitFor(() => {
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
expect(screen.getByText("activity-source:model_groups")).toBeInTheDocument();
});
expect(screen.queryByText("activity-source:models")).not.toBeInTheDocument();
});
it("should feed the Model Activity tab from the model_groups breakdown by default", async () => {
@ -1370,7 +1393,7 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Cost")).toBeInTheDocument();
expect(screen.getByText("Overview")).toBeInTheDocument();
expect(screen.getByText("Model Activity")).toBeInTheDocument();
expect(screen.getByText("Key Activity")).toBeInTheDocument();
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();

View file

@ -6,18 +6,15 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { ChevronDown, ChevronRight, Download, Info, Search, Sparkles, X } from "lucide-react";
import { Download, Search, Sparkles, X } from "lucide-react";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BarChart } from "@/components/shared/charts";
import { StackedUsageChart } from "@/components/shared/charts";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
@ -25,7 +22,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { hasCapability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
@ -36,10 +32,8 @@ import { filterModelActivity } from "@/components/UsagePage/modelActivityFilter"
import { Team } from "@/components/key_team_helpers/key_list";
import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Tag } from "@/components/tag_management/types";
import UserAgentActivity from "@/components/user_agent_activity";
import ViewUserSpend from "@/components/view_user_spend";
import { useAggregatedDailyActivity } from "../hooks/useAggregatedDailyActivity";
import { ENTITY_API } from "./EntityUsage/entityFetchFns";
import {
@ -48,8 +42,6 @@ import {
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
import { MetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import {
fetchedRangeKey,
selectForRange,
@ -62,28 +54,27 @@ import {
import EndpointUsage from "./EndpointUsage/EndpointUsage";
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView";
import TopKeyView, { type TopKeyItem } from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import { getGlobalTopKeys } from "./EntityUsage/entityUsageAggregations";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
import { overviewTotals, rollUpBreakdown } from "./overview/overviewData";
import TopAgents from "./overview/TopAgents";
import type { AgentRow } from "./overview/agentCatalog";
import SpendByProvider from "./EntityUsage/SpendByProvider";
import { useTagSummary } from "@/app/(dashboard)/hooks/tags/useTagSummary";
import { Panel } from "./overview/Primitives";
import UsageOverview from "./overview/UsageOverview";
interface UsagePageProps {
teams: Team[];
organizations: Organization[];
}
const MetricValue = ({ pending, className, children }: { pending: boolean; className: string; children: ReactNode }) =>
pending ? <Skeleton className="h-8 w-24 mt-2" /> : <p className={className}>{children}</p>;
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(null);
// Separate loading states for better UX
const [isDateChanging, setIsDateChanging] = useState(false);
// Create initial dates outside of state to prevent recreation
const initialFromDate = useMemo(() => new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), []);
const initialToDate = useMemo(() => new Date(), []);
@ -124,8 +115,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [showTokenBreakdown, setShowTokenBreakdown] = useState(false);
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
useEffect(() => {
if (!isAdmin && userID) {
@ -199,6 +188,23 @@ 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.
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],
});
const tagDaily = useMemo(() => toDailyData(tagDailyRaw), [tagDailyRaw]);
const [agentActivityTags, setAgentActivityTags] = useState<readonly string[] | undefined>(undefined);
const openAgentActivity = useCallback((agent: AgentRow) => {
setAgentActivityTags(agent.tags);
setUsageView("user-agent-activity");
}, []);
// Gateway request counts (SGR). Admin-only: the source table is
// deployment-wide, so a non-admin must not see it.
@ -239,162 +245,19 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
[userSpendData],
);
useEffect(() => {
if (!loading) {
setIsDateChanging(false);
}
}, [loading]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
// Instant visual feedback
setIsDateChanging(true);
// Update date immediately for UI responsiveness
setDateValue(newValue);
}, []);
// Derived states from userSpendData
const totalSpend = userSpendData.metadata?.total_spend || 0;
// Calculate top models from the breakdown data
const topModels = useMemo(() => {
const modelSpend: { [key: string]: MetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => {
if (!modelSpend[model]) {
modelSpend[model] = {
metrics: {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
};
}
modelSpend[model].metrics.spend += metrics.metrics.spend;
modelSpend[model].metrics.prompt_tokens += metrics.metrics.prompt_tokens;
modelSpend[model].metrics.completion_tokens += metrics.metrics.completion_tokens;
modelSpend[model].metrics.total_tokens += metrics.metrics.total_tokens;
modelSpend[model].metrics.api_requests += metrics.metrics.api_requests;
modelSpend[model].metrics.successful_requests += metrics.metrics.successful_requests || 0;
modelSpend[model].metrics.failed_requests += metrics.metrics.failed_requests || 0;
modelSpend[model].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0;
modelSpend[model].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0;
});
});
return Object.entries(modelSpend)
.map(([model, metrics]) => ({
key: model,
spend: metrics.metrics.spend,
requests: metrics.metrics.api_requests,
successful_requests: metrics.metrics.successful_requests,
failed_requests: metrics.metrics.failed_requests,
tokens: metrics.metrics.total_tokens,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, topModelsLimit);
}, [userSpendData.results, topModelsLimit]);
const topModelGroups = useMemo(() => {
const modelGroupSpend: { [key: string]: MetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.model_groups || {}).forEach(([modelGroup, metrics]) => {
if (!modelGroupSpend[modelGroup]) {
modelGroupSpend[modelGroup] = {
metrics: {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
};
}
modelGroupSpend[modelGroup].metrics.spend += metrics.metrics.spend;
modelGroupSpend[modelGroup].metrics.prompt_tokens += metrics.metrics.prompt_tokens;
modelGroupSpend[modelGroup].metrics.completion_tokens += metrics.metrics.completion_tokens;
modelGroupSpend[modelGroup].metrics.total_tokens += metrics.metrics.total_tokens;
modelGroupSpend[modelGroup].metrics.api_requests += metrics.metrics.api_requests;
modelGroupSpend[modelGroup].metrics.successful_requests += metrics.metrics.successful_requests || 0;
modelGroupSpend[modelGroup].metrics.failed_requests += metrics.metrics.failed_requests || 0;
modelGroupSpend[modelGroup].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0;
modelGroupSpend[modelGroup].metrics.cache_creation_input_tokens +=
metrics.metrics.cache_creation_input_tokens || 0;
});
});
return Object.entries(modelGroupSpend)
.map(([modelGroup, metrics]) => ({
key: modelGroup,
spend: metrics.metrics.spend,
requests: metrics.metrics.api_requests,
successful_requests: metrics.metrics.successful_requests,
failed_requests: metrics.metrics.failed_requests,
tokens: metrics.metrics.total_tokens,
}))
.sort((a, b) => b.spend - a.spend)
.slice(0, topModelsLimit);
}, [userSpendData.results, topModelsLimit]);
// Calculate provider spend from the breakdown data
const providerSpend = useMemo(() => {
const providerSpendMap: { [key: string]: MetricWithMetadata } = {};
userSpendData.results.forEach((day) => {
Object.entries(day.breakdown.providers || {}).forEach(([provider, metrics]) => {
if (!providerSpendMap[provider]) {
providerSpendMap[provider] = {
metrics: {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
};
}
providerSpendMap[provider].metrics.spend += metrics.metrics.spend;
providerSpendMap[provider].metrics.prompt_tokens += metrics.metrics.prompt_tokens;
providerSpendMap[provider].metrics.completion_tokens += metrics.metrics.completion_tokens;
providerSpendMap[provider].metrics.total_tokens += metrics.metrics.total_tokens;
providerSpendMap[provider].metrics.api_requests += metrics.metrics.api_requests;
providerSpendMap[provider].metrics.successful_requests += metrics.metrics.successful_requests || 0;
providerSpendMap[provider].metrics.failed_requests += metrics.metrics.failed_requests || 0;
providerSpendMap[provider].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0;
providerSpendMap[provider].metrics.cache_creation_input_tokens +=
metrics.metrics.cache_creation_input_tokens || 0;
});
});
return Object.entries(providerSpendMap).map(([provider, metrics]) => ({
provider,
spend: metrics.metrics.spend,
requests: metrics.metrics.api_requests,
successful_requests: metrics.metrics.successful_requests,
failed_requests: metrics.metrics.failed_requests,
tokens: metrics.metrics.total_tokens,
}));
}, [userSpendData.results]);
const totals = useMemo(
() => overviewTotals(userSpendData.metadata, gatewayActivity),
[userSpendData.metadata, gatewayActivity],
);
const providerSpend = useMemo(
() => rollUpBreakdown(userSpendData.results, "providers").map(({ key, ...row }) => ({ provider: key, ...row })),
[userSpendData.results],
);
const { data: tagSummary, isLoading: tagSummaryLoading } = useTagSummary(startTime, endTime);
// Calculate top API keys from the breakdown data
const topKeys = useMemo<TopKeyItem[]>(
@ -451,42 +314,42 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
);
return (
<div style={{ width: "100%" }} className="p-8 relative">
{/* Global Date Picker and Tabs - Single Row */}
<div className="flex items-end justify-between gap-6 mb-6">
<div className="flex-1">
<div className="flex items-end justify-between gap-6 mb-4 w-full">
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
userRole={userRole}
canViewTagUsage={canViewTagUsage}
isOrgAdmin={isOrgAdmin}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{aggregatedFailed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
<div className="relative w-full px-4 pt-3 pb-8 sm:px-6">
<header className="mb-3 flex w-full flex-wrap items-center justify-between gap-3 border-b pb-3">
<UsageViewSelect
value={usageView}
onChange={(value) => setUsageView(value)}
userRole={userRole}
canViewTagUsage={canViewTagUsage}
isOrgAdmin={isOrgAdmin}
/>
<div className="flex flex-wrap items-center gap-2">
{isAdmin && usageView === "global" && (
<div className="w-64">
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
</div>
)}
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
</header>
{aggregatedFailed && (
<Alert variant="error" className="mb-3">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
<div>
<div>
{/* Your Usage / Global Usage Panel */}
{(usageView === "global" || usageView === "my-usage") && (
<>
{isAdmin && usageView === "global" && (
<div className="mb-4">
<p className="mb-2 text-sm text-foreground">Filter by user</p>
<UserDropdown value={selectedUserId} onChange={setSelectedUserId} />
</div>
)}
<Tabs defaultValue="cost">
<div className="flex justify-between items-center">
<TabsList className="mt-1">
<Tabs defaultValue="cost" className="gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<TabsList variant="line">
<TabsTrigger value="cost" className="flex-none px-3">
Cost
Overview
</TabsTrigger>
<TabsTrigger value="models" className="flex-none px-3">
Model Activity
@ -502,370 +365,69 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsAiChatOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setIsAiChatOpen(true)}>
<Sparkles />
Ask AI
</Button>
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setIsGlobalExportModalOpen(true)}>
<Download />
Export Data
</Button>
</div>
</div>
{/* Cost Panel */}
<TabsContent value="cost" keepMounted>
<div className="grid grid-cols-2 gap-2 w-full">
{/* Total Spend Card */}
<div className="col-span-2">
<div className="flex items-center gap-4 mt-2 mb-2">
<p className="text-lg text-muted-foreground">
Project Spend{" "}
{dateValue.from && dateValue.to && (
<>
{dateValue.from.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined,
})}
{" - "}
{dateValue.to.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</>
)}
</p>
</div>
{!loading && (
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
/>
)}
</div>
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Usage Metrics</h3>
<div className="grid grid-cols-5 gap-4 mt-4">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Total Requests</h3>
<MetricValue pending={requestCountsPending} className="text-2xl font-bold mt-2">
{(gatewayActivity
? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests
: userSpendData.metadata?.total_api_requests
)?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">Successful Requests</h3>
{gatewayActivity && (
<Tooltip>
<TooltipTrigger
render={<Info className="size-4 text-muted-foreground hover:text-foreground" />}
/>
<TooltipContent>
Counted by the gateway when it answers a request, independent of spend logging.
Deployment-wide, so it will not match the per-key or per-model breakdowns below.
</TooltipContent>
</Tooltip>
)}
</div>
{/*
TODO: drop the userSpendData fallback once every deployment
is writing LiteLLM_DailyGatewayRequests. It covers two cases
today: a non-admin (who may not read deployment-wide counts)
and an admin on a proxy whose table is still backfilling.
*/}
<MetricValue
pending={requestCountsPending}
className="text-2xl font-bold mt-2 text-success"
>
{(
gatewayActivity?.total_successful_requests ??
userSpendData.metadata?.total_successful_requests
)?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">Failed Requests</h3>
<Tooltip>
<TooltipTrigger
render={<Info className="size-4 text-muted-foreground hover:text-foreground" />}
/>
<TooltipContent>
{gatewayActivity
? "Counted by the gateway when it answers a request, independent of spend logging. Deployment-wide, so it will not match the per-key or per-model breakdowns below."
: "Includes requests that failed to route to a provider, tool usage failures, and other request errors where the provider cannot be determined."}
</TooltipContent>
</Tooltip>
</div>
{/* Same source as Successful Requests: the two must agree, or the
tile disagrees with the endpoint breakdown chart below it. */}
<MetricValue
pending={requestCountsPending}
className="text-2xl font-bold mt-2 text-destructive"
>
{(
gatewayActivity?.total_failed_requests ??
userSpendData.metadata?.total_failed_requests
)?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Average Cost per Request</h3>
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard
className="cursor-pointer hover:bg-accent transition-colors"
onClick={() => setShowTokenBreakdown(!showTokenBreakdown)}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">Total Tokens</h3>
{showTokenBreakdown ? (
<ChevronDown className="size-3 text-muted-foreground" />
) : (
<ChevronRight className="size-3 text-muted-foreground" />
)}
</div>
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
</div>
{showTokenBreakdown && (
<div className="grid grid-cols-4 gap-4 mt-4">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Input Tokens</h3>
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Output Tokens</h3>
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Read Tokens</h3>
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-success">
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Write Tokens</h3>
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-purple-600">
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
</MetricValue>
</CardContent>
</ShadcnCard>
</div>
)}
</CardContent>
</ShadcnCard>
</div>
{/* Daily Spend Chart */}
<div className="col-span-2">
<ShadcnCard>
<CardHeader>
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<BarChart
data={sortedDailyResults}
index="date"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
yAxisWidth={100}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-card p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-info">Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
<p className="text-muted-foreground">Requests: {data.metrics.api_requests}</p>
<p className="text-muted-foreground">
Successful: {data.metrics.successful_requests}
</p>
<p className="text-muted-foreground">Failed: {data.metrics.failed_requests}</p>
<p className="text-muted-foreground">Tokens: {data.metrics.total_tokens}</p>
</div>
);
}}
/>
)}
</CardContent>
</ShadcnCard>
</div>
{/* Gateway Requests by Endpoint (SGR) */}
{gatewayActivity && gatewayActivity.by_route.length > 0 && (
<div className="col-span-2">
<ShadcnCard data-testid="gateway-requests-by-endpoint">
<CardHeader>
<CardTitle className="text-base font-semibold">
Gateway Requests by Endpoint
<Tooltip>
<TooltipTrigger
render={
<Info className="ml-2 inline size-4 text-muted-foreground hover:text-foreground" />
}
/>
<TooltipContent>
Counted by the gateway middleware as each request is answered. Covers LLM, MCP and A2A
endpoints across the whole deployment.
</TooltipContent>
</Tooltip>
</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={gatewayRequestsByRoute}
index="route"
categories={["successful_requests", "failed_requests"]}
colors={["green", "red"]}
stack={true}
yAxisWidth={100}
valueFormatter={(value: number) => value.toLocaleString()}
/>
</CardContent>
</ShadcnCard>
</div>
)}
{/* Top API Keys */}
<div>
<ShadcnCard className="h-full">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys</h3>
<TopKeyView
topKeys={topKeys}
teams={null}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</CardContent>
</ShadcnCard>
</div>
{/* Top Models */}
<div>
<ShadcnCard className="h-full">
<CardContent>
<h3 className="text-lg font-medium text-foreground">
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
</h3>
<div className="flex justify-between items-center mb-4">
<Tabs
value={String(topModelsLimit)}
onValueChange={(value: string) => setTopModelsLimit(Number(value))}
>
<TabsList>
{TOP_MODEL_LIMITS.map((limit) => (
<TabsTrigger key={limit} value={String(limit)} className="flex-none px-3">
{limit}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="relative max-h-[600px] overflow-y-auto">
{(() => {
const modelData = modelViewType === "groups" ? topModelGroups : topModels;
return (
<BarChart
className="mt-4"
style={{ height: Math.min(modelData.length, topModelsLimit) * 52 }}
data={modelData}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-card p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-info">Spend: ${formatNumberWithCommas(data.spend, 2)}</p>
<p className="text-muted-foreground">
Total Requests: {data.requests.toLocaleString()}
</p>
<p className="text-success">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-destructive">
Failed: {data.failed_requests.toLocaleString()}
</p>
<p className="text-muted-foreground">
Tokens: {data.tokens.toLocaleString()}
</p>
</div>
);
}}
/>
);
})()}
</div>
)}
</CardContent>
</ShadcnCard>
</div>
{/* Spend by Provider */}
<div className="col-span-2">
<SpendByProvider
loading={loading}
isDateChanging={isDateChanging}
providerSpend={providerSpend}
<UsageOverview
results={sortedDailyResults}
totals={totals}
loading={loading}
requestCountsPending={requestCountsPending}
budget={currentUser?.max_budget ?? null}
topKeys={
<TopKeyView
topKeys={topKeys}
teams={null}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</div>
{/* Usage Metrics */}
</div>
}
gatewayByEndpoint={
gatewayActivity && gatewayActivity.by_route.length > 0 ? (
<Panel
testId="gateway-requests-by-endpoint"
title="Gateway Requests by Endpoint"
subtitle="Successful and failed requests per route"
bodyClassName="px-2 pt-4"
>
<StackedUsageChart
data={gatewayRequestsByRoute.map((row) => ({
route: row.route,
Successful: row.successful_requests,
Failed: row.failed_requests,
}))}
series={["Successful", "Failed"]}
colors={["#2b3fd6", "#ef4444"]}
xKey="route"
format={(value) => value.toLocaleString()}
className="h-64"
/>
</Panel>
) : null
}
topAgents={
canViewTagUsage ? (
<TopAgents
rows={tagSummary}
daily={tagDaily}
loading={tagSummaryLoading || tagDailyLoading}
totalTokens={totals.tokens}
onOpenAgent={isAdmin ? openAgentActivity : undefined}
/>
) : null
}
providerBreakdown={
<SpendByProvider loading={loading} isDateChanging={false} providerSpend={providerSpend} />
}
/>
</TabsContent>
{/* Activity Panel */}
@ -1043,7 +605,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
)}
{/* User Agent Activity Panel */}
{usageView === "user-agent-activity" && (
<UserAgentActivity accessToken={accessToken} userRole={userRole} dateValue={dateValue} />
<UserAgentActivity
// Remount when opened from a different agent so its tags become the starting filter.
key={agentActivityTags?.join("|") ?? "all"}
accessToken={accessToken}
userRole={userRole}
dateValue={dateValue}
initialTags={agentActivityTags}
/>
)}
</div>
</div>

View file

@ -24,9 +24,8 @@ describe("UsageViewSelect", () => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
expect(screen.getByText("Usage View")).toBeInTheDocument();
expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Usage" })).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Select the usage data you want to view" })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, "Your Usage")).toBe(true);

View file

@ -110,7 +110,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
userRole,
canViewTagUsage = false,
isOrgAdmin = false,
title = "Usage View",
title = "Usage",
description = "Select the usage data you want to view",
"data-id": dataId,
}) => {
@ -148,51 +148,43 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
const filteredOptions = getFilteredOptions();
const selectedOption = filteredOptions.find((option) => option.value === value);
return (
<div className="w-full" data-id={dataId}>
<div className="flex flex-wrap items-center justify-start gap-4">
<div className="flex items-stretch gap-2 min-w-0">
<div className="shrink-0 flex items-center">
<BarChart3 className="size-8" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-foreground mb-0.5 leading-tight">{title}</h3>
<p className="text-xs text-muted-foreground leading-tight">{description}</p>
</div>
</div>
<div className="shrink-0">
<Select
value={value}
onValueChange={(next: UsageOption | null) => {
if (next) onChange(next);
}}
>
<SelectTrigger className="w-54 sm:w-64 md:w-72">
<SelectValue>
{selectedOption && (
<span className="flex items-center gap-2">
{selectedOption.icon}
<span className="text-sm">{selectedOption.label}</span>
</span>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{filteredOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="flex items-center gap-2 py-1">
<span className="shrink-0 mt-0.5">{option.icon}</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-foreground">{option.label}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{option.description}</span>
</span>
{option.badgeText && <Badge>{option.badgeText}</Badge>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3" data-id={dataId}>
<h1 className="flex items-center gap-1.5 text-sm font-semibold text-foreground">
<BarChart3 aria-hidden="true" className="size-4" strokeWidth={2} />
{title}
</h1>
<Select
value={value}
onValueChange={(next: UsageOption | null) => {
if (next) onChange(next);
}}
>
<SelectTrigger aria-label={description} className="h-8 w-52">
<SelectValue>
{selectedOption && (
<span className="flex items-center gap-2">
{selectedOption.icon}
<span className="text-sm">{selectedOption.label}</span>
</span>
)}
</SelectValue>
</SelectTrigger>
{/* Sized to its content rather than the narrow trigger, so descriptions never clip. */}
<SelectContent align="start" className="w-auto min-w-72 p-1">
{filteredOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="rounded-md py-2 pr-8 pl-2">
<span className="flex items-start gap-2.5">
<span className="mt-0.5 shrink-0 text-muted-foreground">{option.icon}</span>
<span className="min-w-0">
<span className="block text-sm leading-5 text-foreground">{option.label}</span>
<span className="block text-xs leading-4 text-muted-foreground">{option.description}</span>
</span>
{option.badgeText && <Badge>{option.badgeText}</Badge>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View file

@ -0,0 +1,178 @@
"use client";
import React, { useMemo } from "react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import {
formatMetricValue,
leaderboard,
OTHER_COLOR,
rankValue,
seriesBy,
type BreakdownDimension,
type LeaderRow,
type Series,
type UsageMetric,
} from "./overviewData";
import { Segmented } from "./Primitives";
import { providerForModel } from "./modelProvider";
const METRIC_OPTIONS = [
{ value: "spend", label: "Spend" },
{ value: "tokens", label: "Tokens" },
{ value: "requests", label: "Requests" },
] as const satisfies readonly { value: UsageMetric; label: string }[];
const DIMENSION_OPTIONS = [
{ value: "model_groups", label: "Model" },
{ value: "models", label: "Deployment" },
{ value: "providers", label: "Provider" },
] as const satisfies readonly { value: BreakdownDimension; label: string }[];
export interface BreakdownState {
metric: UsageMetric;
dimension: BreakdownDimension;
}
export const useBreakdown = (results: readonly DailyData[], { metric, dimension }: BreakdownState, top = 6) => {
const series = useMemo(() => seriesBy(results, dimension, metric, top), [results, dimension, metric, top]);
const ranking = useMemo(() => leaderboard(results, dimension, metric), [results, dimension, metric]);
return { series, ranking };
};
export function BreakdownControls({
state,
onChange,
showDimension = true,
}: {
state: BreakdownState;
onChange: (next: BreakdownState) => void;
showDimension?: boolean;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<Segmented
label="Metric"
value={state.metric}
options={METRIC_OPTIONS}
onChange={(metric) => onChange({ ...state, metric })}
/>
{showDimension && (
<Segmented
label="Group by"
value={state.dimension}
options={DIMENSION_OPTIONS}
onChange={(dimension) => onChange({ ...state, dimension })}
/>
)}
</div>
);
}
/** Provider logo on a soft tile with a series-colored dot, so mixed logo styles still read as a set. */
function ModelMark({ model, color }: { model: string; color: string }) {
const provider = providerForModel(model);
return (
<span className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background">
{provider ? (
<ProviderLogo provider={provider} className="size-4 rounded-[3px]" />
) : (
<span className="text-[11px] font-semibold uppercase text-muted-foreground">{model.charAt(0)}</span>
)}
<span
aria-hidden="true"
className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-card"
style={{ backgroundColor: color }}
/>
</span>
);
}
const DELTA_FLOOR = 0.05;
function Delta({ value }: { value: number | null }) {
if (value === null || Math.abs(value) < DELTA_FLOOR) {
return <span className="text-xs tabular-nums text-muted-foreground">—</span>;
}
const Icon = value > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span className="inline-flex items-center justify-end gap-0.5 text-xs tabular-nums text-muted-foreground">
<Icon aria-hidden="true" className="size-3" />
{Math.abs(value).toFixed(1)}
</span>
);
}
/** Ranked share of the selected metric, with the same swatch as the chart segment. */
export function Leaderboard({
ranking,
series,
metric,
dimension,
limit = 10,
columns = 1,
dense = false,
}: {
ranking: readonly LeaderRow[];
series: Series;
metric: UsageMetric;
dimension: BreakdownDimension;
limit?: number;
columns?: 1 | 2;
dense?: boolean;
}) {
const colorOf = (key: string) => {
const index = series.keys.indexOf(key);
return index === -1 ? OTHER_COLOR : series.colors[index];
};
const value = (row: LeaderRow) => rankValue(row, metric);
const rows = ranking.slice(0, limit);
if (rows.length === 0) {
return <p className="py-8 text-center text-xs text-muted-foreground">No usage in this range</p>;
}
const half = columns === 2 ? Math.ceil(rows.length / 2) : rows.length;
const groups = columns === 2 ? [rows.slice(0, half), rows.slice(half)] : [rows];
return (
<div className={cn("grid gap-x-10", columns === 2 && "md:grid-cols-2")}>
{groups.map((group, groupIndex) => (
<ol key={groupIndex} className="divide-y divide-border/60">
{group.map((row, i) => (
<li
key={row.key}
className={cn(
"grid grid-cols-[1.25rem_minmax(0,1fr)_auto_3rem] items-center gap-3",
dense ? "py-1.5" : "py-2.5",
)}
>
<span className="text-xs tabular-nums text-muted-foreground">{groupIndex * half + i + 1}</span>
<span className="flex min-w-0 items-center gap-3">
{dimension === "providers" ? (
<ProviderLogo provider={row.key} className="size-4 shrink-0" />
) : (
<ModelMark model={row.key} color={colorOf(row.key)} />
)}
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{row.key}</span>
{providerForModel(row.key) && (
<span className="block truncate text-xs text-muted-foreground">by {providerForModel(row.key)}</span>
)}
</span>
</span>
<span className="text-right">
<span className="block text-sm tabular-nums text-foreground">{row.share.toFixed(1)}%</span>
{!dense && (
<span className="block text-xs tabular-nums text-muted-foreground">
{formatMetricValue(value(row), metric)}
</span>
)}
</span>
<Delta value={row.delta} />
</li>
))}
</ol>
))}
</div>
);
}

View file

@ -0,0 +1,175 @@
"use client";
import React, { type ReactNode } from "react";
import { Area, AreaChart } from "recharts";
import type { LucideIcon } from "lucide-react";
import { ChartContainer } from "@/components/ui/chart";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
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({
icon: Icon,
title,
subtitle,
action,
className,
bodyClassName,
children,
testId,
}: {
icon?: LucideIcon;
title: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
className?: string;
bodyClassName?: string;
children: ReactNode;
testId?: string;
}) {
return (
<section data-testid={testId} className={cn("flex min-w-0 flex-col rounded-xl border bg-card", className)}>
<header
className={cn(
"flex min-h-8 flex-wrap justify-between gap-x-3 gap-y-2 pt-4",
PANEL_INSET_X,
subtitle ? "items-start" : "items-center",
)}
>
<div className="min-w-0">
<h3 className="flex min-w-0 items-center gap-2 text-sm leading-5 font-medium text-foreground">
{Icon && <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />}
<span className="truncate">{title}</span>
</h3>
{subtitle && <p className="mt-0.5 truncate text-xs text-muted-foreground">{subtitle}</p>}
</div>
{action && <div className="flex flex-wrap items-center gap-2">{action}</div>}
</header>
<div className={cn("flex min-h-0 flex-1 flex-col pt-3 pb-4", PANEL_INSET_X, bodyClassName)}>{children}</div>
</section>
);
}
export function Sparkline({
data,
dataKey,
color,
className,
}: {
data: readonly SeriesDay[];
dataKey: string;
color: string;
className?: string;
}) {
const gradientId = React.useId().replace(/:/g, "");
if (data.length < 2) return <div className={cn("h-10 w-full", className)} />;
return (
// Decorative: no focus ring, hover cursor or tooltip, since the number above it is the readout.
<ChartContainer
config={EMPTY_CONFIG}
aria-hidden="true"
className={cn("pointer-events-none aspect-auto h-10 w-full", className)}
>
<AreaChart data={[...data]} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} accessibilityLayer={false}>
<defs>
<linearGradient id={`spark-${gradientId}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.18} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
dataKey={dataKey}
type="monotone"
stroke={color}
strokeWidth={1.5}
fill={`url(#spark-${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
);
}
export function ChartSkeleton({ className }: { className?: string }) {
return <Skeleton className={cn("h-48 w-full rounded-lg", className)} />;
}
export function Segmented<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: readonly { value: T; label: string }[];
onChange: (value: T) => void;
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex items-center rounded-lg bg-muted p-0.5">
{options.map((option) => (
<button
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",
)}
>
{option.label}
</button>
))}
</div>
);
}
export function Stat({
label,
value,
pending,
hint,
tone,
size = "md",
exact,
}: {
label: ReactNode;
value: ReactNode;
pending?: boolean;
hint?: ReactNode;
tone?: "success" | "destructive";
size?: "md" | "lg";
/** Full-precision value shown on hover when `value` is abbreviated (41.7M -> 41,719,305). */
exact?: string;
}) {
return (
<div className="min-w-0">
<div className="flex h-4 items-center gap-1.5 text-xs leading-4 text-muted-foreground">{label}</div>
{pending ? (
<Skeleton className={cn("mt-1", size === "lg" ? "h-9 w-32" : "h-7 w-20")} />
) : (
<div
title={exact}
className={cn(
"mt-1 truncate font-semibold tracking-tight tabular-nums",
size === "lg" ? "text-3xl leading-9" : "text-xl leading-7",
tone === "success" && "text-success",
tone === "destructive" && "text-destructive",
)}
>
{value}
</div>
)}
{hint && <div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">{hint}</div>}
</div>
);
}

View file

@ -0,0 +1,233 @@
"use client";
import React, { useMemo, useState } from "react";
import { ArrowRight, Bot } from "lucide-react";
import { StackedUsageChart } from "@/components/shared/charts";
import { Logo } from "@/components/molecules/logo/Logo";
import type { DailyData } from "@/components/UsagePage/types";
import { cn } from "@/lib/cva.config";
import {
bucketTotals,
formatCompact,
formatCompactUsd,
formatMetricValue,
seriesBy,
type UsageMetric,
} from "./overviewData";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
import { agentDailyData, topAgents, type AgentRow, type TagSummaryRow } from "./agentCatalog";
const ROWS = 10;
const CHART_SERIES = 6;
/** Below this share of tokens carrying a User-Agent, say so rather than imply the list is the whole picture. */
const COVERAGE_NOTE_BELOW = 0.5;
const METRICS = [
{ value: "tokens", label: "Tokens" },
{ value: "spend", label: "Spend" },
{ value: "requests", label: "Requests" },
] as const satisfies readonly { value: UsageMetric; label: string }[];
/** Brand-tinted monograms for runtimes that have no logo file, so a top spender never shows a blank tile. */
const MONOGRAMS: Readonly<Record<string, { text: string; className: string }>> = {
python: { text: "Py", className: "bg-[#3776ab] text-[#ffd43b]" },
node: { text: "JS", className: "bg-[#3c873a] text-white" },
curl: { text: "cu", className: "bg-[#073551] text-white" },
};
/** Claude Code's mark: a white spark on Claude terracotta. Drawn here since no Claude asset ships in the repo. */
function ClaudeSpark() {
const rays = Array.from({ length: 12 }, (_, i) => i * 30);
return (
<span className="flex size-full items-center justify-center bg-[#d97757]">
<svg viewBox="0 0 24 24" className="size-5" aria-hidden="true">
{rays.map((angle) => (
<rect
key={angle}
x="11.1"
y="3"
width="1.8"
height="9"
rx="0.9"
fill="white"
transform={`rotate(${angle} 12 12)`}
/>
))}
</svg>
</span>
);
}
function AgentMark({ agent, size = "md" }: { agent: AgentRow; size?: "sm" | "md" }) {
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 = (
<span
className={cn(
"flex size-full items-center justify-center text-xs font-semibold tracking-tight",
monogram.className,
)}
>
{monogram.text}
</span>
);
else mark = <span className="text-sm font-semibold uppercase text-muted-foreground">{agent.label.charAt(0)}</span>;
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>
);
}
function AgentItem({
agent,
rank,
share,
color,
onOpen,
}: {
agent: AgentRow;
rank: number;
share: number;
color: string | undefined;
onOpen?: (agent: AgentRow) => void;
}) {
return (
<li className="group grid grid-cols-[1.5rem_auto_minmax(0,1fr)_auto] items-center gap-3 py-2.5">
<span className="text-sm tabular-nums text-muted-foreground">{rank}.</span>
<span className="relative">
<AgentMark agent={agent} />
{color && (
<span
aria-hidden="true"
className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full ring-2 ring-card"
style={{ backgroundColor: color }}
/>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">{agent.label}</span>
{onOpen ? (
<button
type="button"
onClick={() => onOpen(agent)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
View activity
<ArrowRight aria-hidden="true" className="size-3 transition-transform group-hover:translate-x-0.5" />
</button>
) : (
<span className="block truncate text-xs text-muted-foreground">{agent.description}</span>
)}
</span>
<span className="text-right">
<span className="block text-sm tabular-nums text-foreground">{formatCompact(agent.tokens)} tokens</span>
<span className="block text-xs tabular-nums text-muted-foreground">
{formatCompactUsd(agent.spend)} · {share.toFixed(1)}%
</span>
</span>
</li>
);
}
/**
* 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,
* so agents are not buried under raw HTTP clients that can outspend them. Each row opens User Agent
* Activity filtered to that agent's tags.
*/
export default function TopAgents({
rows,
daily,
loading,
totalTokens,
onOpenAgent,
}: {
rows: readonly TagSummaryRow[] | undefined;
/** Tag daily activity; each day's tags are folded into agents for the chart. */
daily: readonly DailyData[];
loading: boolean;
/** All tokens in the range, so the panel can say how much traffic carried no User-Agent to attribute. */
totalTokens: number;
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 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);
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 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 = (
<>
<div className="px-2 pb-2">
<StackedUsageChart
data={series.data}
series={series.keys}
colors={series.colors}
xKey="label"
format={(value) => formatMetricValue(value, metric)}
totalFor={(label) => totalsByDay.get(label)}
className="h-64"
/>
</div>
<div className={cn("grid gap-x-10 border-t py-2 md:grid-cols-2", PANEL_INSET_X)}>
{[agents.slice(0, half), agents.slice(half)].map((column, columnIndex) => (
<ol key={columnIndex} className="divide-y divide-border/60">
{column.map((agent, index) => (
<AgentItem
key={agent.id}
agent={agent}
rank={columnIndex * half + index + 1}
share={totalShare > 0 ? (agent.tokens / totalShare) * 100 : 0}
color={colorOf(agent.label)}
onOpen={onOpenAgent}
/>
))}
</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}
</Panel>
);
}

View file

@ -0,0 +1,193 @@
"use client";
/**
* Usage overview: a stat strip, the Model Leaderboard's stacked chart and
* tooltip applied to the usage aggregate, then a ranked share list in
* matching colors.
*/
import React, { type ReactNode, useMemo, useState } from "react";
import type { DailyData } from "@/components/UsagePage/types";
import { KeyRound } from "lucide-react";
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
import {
bucketSeries,
bucketTotals,
dailyTotals,
formatCompact,
formatLatency,
formatMetricValue,
formatUsd,
type Granularity,
type OverviewTotals,
type UsageMetric,
} from "./overviewData";
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart";
import { cn } from "@/lib/cva.config";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Sparkline, Stat } from "./Primitives";
interface UsageOverviewProps {
results: readonly DailyData[];
totals: OverviewTotals;
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;
}
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
const BRAND = "#2b3fd6";
const GRANULARITY_OPTIONS = [
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
] as const satisfies readonly { value: Granularity; label: string }[];
const SCALE_OPTIONS = [
{ value: "linear", label: "Linear" },
{ value: "log", label: "Log" },
] as const satisfies readonly { value: StackedUsageScale; label: string }[];
export default function UsageOverview({
results,
totals,
loading,
requestCountsPending,
topKeys,
gatewayByEndpoint,
topAgents,
providerBreakdown,
budget,
}: UsageOverviewProps) {
const [state, setState] = useState<BreakdownState>({ metric: "spend", dimension: "model_groups" });
const [granularity, setGranularity] = useState<Granularity>("day");
const [scale, setScale] = useState<StackedUsageScale>("linear");
const { series: dailySeries, ranking } = useBreakdown(results, state, 8);
const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]);
const totalsByBucket = useMemo(() => bucketTotals(series), [series]);
const daily = useMemo(() => dailyTotals(results), [results]);
const format = (value: number) => formatMetricValue(value, state.metric);
return (
<div className="grid gap-3">
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
<Stat
label="Spend"
value={formatUsd(totals.spend)}
pending={loading}
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
/>
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
{!requestCountsPending && (
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
<span>
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
</span>
<span>
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
{totals.failed.toLocaleString()}
</span>{" "}
failed
</span>
</div>
)}
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
<Stat
label="Total Tokens"
value={loading ? null : formatCompact(totals.tokens)}
exact={`${totals.tokens.toLocaleString()} tokens`}
pending={loading}
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
/>
</StatCell>
<StatCell>
<div className="grid gap-3">
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
<Stat
label="Avg latency"
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
pending={loading}
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
/>
</div>
</StatCell>
</div>
<Panel
title="Top models"
subtitle={`${granularity === "day" ? "Daily" : "Weekly"} ${METRIC_NOUN[state.metric]}, top 8 stacked`}
action={
<>
<BreakdownControls state={state} onChange={setState} showDimension={false} />
<Segmented
label="Bucket size"
value={granularity}
options={GRANULARITY_OPTIONS}
onChange={setGranularity}
/>
<Segmented label="Scale" value={scale} options={SCALE_OPTIONS} onChange={setScale} />
</>
}
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" />
) : (
<StackedUsageChart
data={series.data}
series={series.keys}
colors={series.colors}
xKey="label"
scale={scale}
format={format}
totalFor={(label) => totalsByBucket.get(label)}
/>
)}
</div>
<div className={cn("border-t py-2", PANEL_INSET_X)}>
<Leaderboard
ranking={ranking}
series={dailySeries}
metric={state.metric}
dimension={state.dimension}
columns={2}
limit={10}
/>
</div>
</Panel>
{topAgents}
{providerBreakdown}
<Panel icon={KeyRound} title="Top Virtual Keys">
{topKeys}
</Panel>
{gatewayByEndpoint}
</div>
);
}
function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) {
return (
<div
className={cn(
"flex min-w-0 flex-col justify-between gap-3 border-b py-4 last:border-b-0 lg:border-b-0",
PANEL_INSET_X,
)}
>
<div className="min-w-0">{children}</div>
{trend}
</div>
);
}

View file

@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import { agentDailyData, isUserAgentTag, topAgents, userAgentProduct, type TagSummaryRow } from "./agentCatalog";
import type { DailyData } from "@/components/UsagePage/types";
const metric = (spend: number, tokens: number) => ({
metrics: {
spend,
total_tokens: tokens,
api_requests: 1,
prompt_tokens: 0,
completion_tokens: 0,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {},
api_key_breakdown: {},
});
const row = (
tag: string,
spend: number,
tokens: number,
{ requests = 1, users = 1 }: { requests?: number; users?: number } = {},
): TagSummaryRow => ({
tag,
total_spend: spend,
total_tokens: tokens,
total_requests: requests,
successful_requests: requests,
failed_requests: 0,
unique_users: users,
});
describe("userAgentProduct", () => {
it("drops the prefix, version and platform detail", () => {
expect(userAgentProduct("User-Agent: claude-cli/2.1.263 (external, cli)")).toBe("claude-cli");
expect(userAgentProduct("User-Agent: Codex Desktop/0.160.1 (Mac OS 26.6.0; arm64)")).toBe("Codex Desktop");
expect(userAgentProduct("User-Agent: python")).toBe("python");
});
});
describe("isUserAgentTag", () => {
it("only treats User-Agent tags as agents", () => {
expect(isUserAgentTag("User-Agent: codex-tui")).toBe(true);
expect(isUserAgentTag("Credential: gemini")).toBe(false);
expect(isUserAgentTag("claude-code")).toBe(false);
});
});
describe("topAgents", () => {
it("counts a product from its bare rollup row, not again from its versioned children", () => {
const agents = topAgents([
row("User-Agent: python", 100, 1000),
row("User-Agent: python/gevent-http-client-2.3.9", 100, 1000),
]);
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject({ id: "python", spend: 100, tokens: 1000 });
});
it("sums versioned rows when a product has no bare rollup", () => {
const agents = topAgents([
row("User-Agent: opencode/1.18.29 ai-sdk/provider-utils/4.0.23", 5, 50),
row("User-Agent: opencode/1.18.30 ai-sdk/provider-utils/4.0.23", 7, 70),
]);
expect(agents[0]).toMatchObject({ id: "opencode", label: "OpenCode", spend: 12, tokens: 120 });
});
it("folds every Codex surface into one Codex agent with the OpenAI mark", () => {
const agents = topAgents([
row("User-Agent: Codex Desktop", 20, 200),
row("User-Agent: codex-tui", 5, 50),
row("User-Agent: codex_exec/0.1", 1, 10),
row("User-Agent: codex_github_action/1.0", 2, 20),
]);
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject({ id: "codex", label: "Codex", kind: "agent", spend: 28, tokens: 280 });
expect(agents[0].logo).toBeTruthy();
});
it("maps claude-cli to Claude Code and ranks by tokens", () => {
const agents = topAgents([
row("User-Agent: python", 600, 300),
row("User-Agent: claude-cli", 370, 343),
row("User-Agent: Cursor/1.0", 1, 7),
]);
expect(agents.map((agent) => agent.label)).toEqual(["Claude Code", "Python", "Cursor"]);
expect(agents[0].kind).toBe("agent");
expect(agents[1].kind).toBe("sdk");
});
it("sums distinct products in a family, each read once from its own rollup (prod shape)", () => {
const agents = topAgents([
row("User-Agent: python", 61081, 30508),
row("User-Agent: python/gevent-http-client-2.3.9", 61081, 30508),
row("User-Agent: python-httpx", 8616, 1728),
row("User-Agent: python-httpx/0.28.1", 8616, 1728),
]);
expect(agents).toHaveLength(1);
expect(agents[0]).toMatchObject({ id: "python", spend: 61081 + 8616, tokens: 30508 + 1728 });
});
it("does not let a differently-cased product overwrite another's rollup (prod: python vs Python)", () => {
const [agent] = topAgents([
row("User-Agent: python", 61081, 30508),
row("User-Agent: python/gevent-http-client-2.3.9", 61081, 30508),
row("User-Agent: Python", 1, 1),
row("User-Agent: Python/3.13 aiohttp/3.14.3", 1, 1),
]);
expect(agent).toMatchObject({ id: "python", spend: 61082, tokens: 30509 });
});
it("recognises the OpenAI Agents SDK user agent", () => {
expect(topAgents([row("User-Agent: Agents/Python 0.4.2", 14, 10)])[0]).toMatchObject({ id: "openai-agents" });
});
it("keeps unrecognised clients under their own name with no logo", () => {
const [agent] = topAgents([row("User-Agent: SpeechCoach/2.0", 1, 5)]);
expect(agent).toMatchObject({ id: "ua:speechcoach", label: "SpeechCoach", logo: null });
});
it("ignores non user-agent tags and empty agents", () => {
expect(topAgents([row("Credential: gemini", 200, 0), row("User-Agent: ureq", 0, 0, { requests: 0 })])).toEqual([]);
});
it("does not sum unique users across a family", () => {
const [agent] = topAgents([
row("User-Agent: Codex Desktop", 1, 1, { users: 8 }),
row("User-Agent: codex-tui", 1, 1, { users: 5 }),
]);
expect(agent.users).toBe(8);
});
it("records every raw tag an agent was built from, for the drill-in filter", () => {
const [agent] = topAgents([
row("User-Agent: claude-cli", 10, 100),
row("User-Agent: claude-cli/2.1.263 (external, cli)", 10, 100),
]);
expect([...agent.tags].sort()).toEqual([
"User-Agent: claude-cli",
"User-Agent: claude-cli/2.1.263 (external, cli)",
]);
});
});
describe("agentDailyData", () => {
it("stacks one entry per agent per day, counting a rollup tag once and ignoring non user-agent tags", () => {
const day = {
date: "2026-10-01",
metrics: metric(0, 0).metrics,
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {
"User-Agent: claude-cli": metric(10, 100),
"User-Agent: claude-cli/2.1.263 (external, cli)": metric(10, 100),
"User-Agent: Codex Desktop/0.160.1 (Mac OS)": metric(4, 40),
"Credential: gemini": metric(99, 0),
},
},
} as unknown as DailyData;
const [result] = agentDailyData([day]);
// The day total is the agents' own sum, so untagged and non-agent traffic never shows up as "Other".
expect(result.metrics).toMatchObject({ spend: 14, total_tokens: 140 });
expect(Object.keys(result.breakdown.models).sort()).toEqual(["Claude Code", "Codex"]);
expect(result.breakdown.models["Claude Code"].metrics).toMatchObject({ spend: 10, total_tokens: 100 });
expect(result.breakdown.models["Codex"].metrics).toMatchObject({ spend: 4, total_tokens: 40 });
});
});

View file

@ -0,0 +1,325 @@
import anthropicLogo from "../../../../../../../public/assets/logos/anthropic.svg";
import cursorLogo from "../../../../../../../public/assets/logos/cursor.svg";
import copilotLogo from "../../../../../../../public/assets/logos/github_copilot.svg";
import githubLogo from "../../../../../../../public/assets/logos/github.svg";
import hermesLogo from "../../../../../../../public/assets/logos/hermes.png";
import langchainLogo from "../../../../../../../public/assets/logos/langchain.svg";
import litellmLogo from "../../../../../../../public/assets/logos/litellm_monogram.svg";
import openaiLogo from "../../../../../../../public/assets/logos/openai_small.svg";
import openaiAgentsLogo from "../../../../../../../public/assets/logos/openai-agents.svg";
import opencodeLogo from "../../../../../../../public/assets/moyai/logos/opencode.svg";
import pydanticAiLogo from "../../../../../../../public/assets/logos/pydantic-ai-color.svg";
import xaiLogo from "../../../../../../../public/assets/logos/xai.svg";
import type { components } from "@/lib/http/schema";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
/** One row of `GET /tag/summary`. */
export type TagSummaryRow = components["schemas"]["TagSummaryMetrics"];
export type AgentKind = "agent" | "sdk";
export interface AgentRow {
id: string;
label: string;
description: string;
kind: AgentKind;
logo: string | null;
spend: number;
tokens: number;
requests: number;
users: number;
/** The raw `User-Agent: ...` tags this agent was built from, used to filter User Agent Activity. */
tags: string[];
}
interface AgentFamily {
id: string;
label: string;
description: string;
kind: AgentKind;
logo: string | null;
/** Matched against the user-agent with its `User-Agent: ` prefix removed, case-insensitively. */
match: RegExp;
}
/**
* Most specific first. Coding agents beat the SDK they are built on, so `codex_sdk_ts` lands on
* Codex and only a bare `OpenAI/...` client falls through to the OpenAI SDK. Codex wears OpenAI's
* mark as in the Lens trace view; Claude Code gets its own spark tile rather than Anthropic's wordmark.
*/
const FAMILIES: readonly AgentFamily[] = [
{
id: "claude-code",
label: "Claude Code",
description: "Anthropic's coding agent",
kind: "agent",
// No Claude asset ships in the repo, so TopAgents draws the spark tile itself.
logo: null,
match: /^(claude-cli|claude-code|claude_code|claude-user)\b/i,
},
{
id: "codex",
label: "Codex",
description: "OpenAI's coding agent",
kind: "agent",
logo: openaiLogo.src,
match: /^(codex[ _-]|codex$)/i,
},
{
id: "opencode",
label: "OpenCode",
description: "Open-source terminal coding agent",
kind: "agent",
logo: opencodeLogo.src,
match: /^opencode\b/i,
},
{
id: "cursor",
label: "Cursor",
description: "AI code editor",
kind: "agent",
logo: cursorLogo.src,
match: /^cursor\b/i,
},
{
id: "copilot",
label: "GitHub Copilot",
description: "GitHub's coding assistant",
kind: "agent",
logo: copilotLogo.src,
match: /^(github-?copilot|copilot)\b/i,
},
{
id: "hermes",
label: "Hermes Agent",
description: "Open-source agent",
kind: "agent",
logo: hermesLogo.src,
match: /^hermes\b/i,
},
{ id: "grok", label: "Grok", description: "xAI's assistant", kind: "agent", logo: xaiLogo.src, match: /^grok\b/i },
{
id: "cline",
label: "Cline",
description: "Open-source coding agent",
kind: "agent",
logo: null,
match: /^cline\b/i,
},
{
id: "kilo-code",
label: "Kilo Code",
description: "Open-source coding agent",
kind: "agent",
logo: null,
match: /^kilo[- ]?code\b/i,
},
{
id: "aider",
label: "Aider",
description: "AI pair programming in the terminal",
kind: "agent",
logo: null,
match: /^aider\b/i,
},
{
id: "openai-agents",
label: "OpenAI Agents",
description: "OpenAI Agents SDK",
kind: "sdk",
logo: openaiAgentsLogo.src,
match: /^agents$/i,
},
{
id: "langchain",
label: "LangChain",
description: "Agent framework",
kind: "sdk",
logo: langchainLogo.src,
match: /^(langchain|langgraph|deepagents)/i,
},
{
id: "pydantic-ai",
label: "Pydantic AI",
description: "Agent framework",
kind: "sdk",
logo: pydanticAiLogo.src,
match: /^pydantic-ai\b/i,
},
{
id: "github-actions",
label: "GitHub Actions",
description: "CI workflows",
kind: "sdk",
logo: githubLogo.src,
match: /^github-actions\b/i,
},
{
id: "litellm",
label: "LiteLLM SDK",
description: "litellm Python SDK",
kind: "sdk",
logo: litellmLogo.src,
match: /^litellm\b/i,
},
{
id: "openai-sdk",
label: "OpenAI SDK",
description: "OpenAI client library",
kind: "sdk",
logo: openaiLogo.src,
match: /^(openai|asyncopenai)\b/i,
},
{
id: "anthropic-sdk",
label: "Anthropic SDK",
description: "Anthropic client library",
kind: "sdk",
logo: anthropicLogo.src,
match: /^anthropic\b/i,
},
{
id: "python",
label: "Python",
description: "Scripts and HTTP clients",
kind: "sdk",
logo: null,
match: /^(python|httpx|aiohttp|urllib|requests)/i,
},
{
id: "node",
label: "Node.js",
description: "Scripts and HTTP clients",
kind: "sdk",
logo: null,
match: /^(node|bun|deno|axios|undici)\b/i,
},
{ id: "curl", label: "curl", description: "Command line", kind: "sdk", logo: null, match: /^curl\b/i },
];
const USER_AGENT_PREFIX = /^user[- ]agent:\s*/i;
export const isUserAgentTag = (tag: string): boolean => USER_AGENT_PREFIX.test(tag.trim());
/** The user-agent product with its version and platform detail dropped: `claude-cli/2.1.263 (external)` -> `claude-cli`. */
export const userAgentProduct = (tag: string): string => {
const ua = tag.trim().replace(USER_AGENT_PREFIX, "");
return ua.split(/[/(;]/, 1)[0].trim();
};
const familyFor = (product: string): AgentFamily | null =>
FAMILIES.find((family) => family.match.test(product)) ?? null;
/**
* 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.
*/
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()]
.filter((agent) => agent.spend > 0 || agent.tokens > 0 || agent.requests > 0)
.sort((a, b) => b.tokens - a.tokens || b.spend - a.spend);
};
/**
* Rewrites one day's `breakdown.entities` (keyed by raw tag) into one entry per agent, keyed by the
* agent label, so the existing per-day series builders can stack agents the way they stack models.
* Uses the same bare-vs-versioned rule as `topAgents`, per day, so no day double-counts a product.
*/
const agentEntitiesForDay = (
entities: Readonly<Record<string, { metrics: SpendMetrics }>>,
): Record<string, { metrics: SpendMetrics }> => {
const rows: TagSummaryRow[] = Object.entries(entities).map(([tag, { metrics }]) => ({
tag,
total_spend: metrics.spend,
total_tokens: metrics.total_tokens,
total_requests: metrics.api_requests,
successful_requests: metrics.successful_requests,
failed_requests: metrics.failed_requests,
unique_users: 0,
}));
return Object.fromEntries(
topAgents(rows).map((agent) => [
agent.label,
{
metrics: {
spend: agent.spend,
total_tokens: agent.tokens,
api_requests: agent.requests,
prompt_tokens: 0,
completion_tokens: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
]),
);
};
/**
* Tag daily activity with each day's raw tags folded into agents, written into `breakdown.models`
* so `seriesBy(..., "models", ...)` stacks agents per day with no agent-specific chart code.
*/
export const agentDailyData = (days: readonly DailyData[]): DailyData[] =>
days.map((day) => {
const models = agentEntitiesForDay(day.breakdown.entities ?? {}) as DailyData["breakdown"]["models"];
const agentTotal = (key: "spend" | "total_tokens" | "api_requests") =>
Object.values(models).reduce((sum, entry) => sum + entry.metrics[key], 0);
// The day total becomes the agents' own sum, so the chart's "Other" means agents past the top N,
// not the untagged and non-agent traffic in the tag table's day total.
return {
...day,
metrics: {
...day.metrics,
spend: agentTotal("spend"),
total_tokens: agentTotal("total_tokens"),
api_requests: agentTotal("api_requests"),
},
breakdown: { ...day.breakdown, models },
};
});

View file

@ -0,0 +1,32 @@
/**
* Best-effort provider for a model name, used only to pick a logo. The usage
* aggregate does not carry a provider per model, so this reads the
* `provider/model` prefix when there is one and otherwise matches well-known
* model families. Unknown names return null and render a neutral monogram.
*/
const FAMILY_PROVIDERS: readonly (readonly [RegExp, string])[] = [
[/claude|anthropic/, "anthropic"],
[/^(gpt|o\d|chatgpt|text-embedding|dall-e|whisper|tts-|davinci|babbage|omni-moderation)/, "openai"],
[/gemini|gemma|palm|bison|imagen|veo/, "gemini"],
[/llama|meta-/, "meta_llama"],
[/mistral|mixtral|codestral|ministral|pixtral|magistral/, "mistral"],
[/command|cohere|embed-(english|multilingual)/, "cohere"],
[/deepseek/, "deepseek"],
[/grok/, "xai"],
[/qwen|qwq/, "dashscope"],
[/kimi|moonshot/, "moonshot"],
[/sonar|pplx/, "perplexity"],
[/minimax|abab/, "minimax"],
[/glm|chatglm/, "zai"],
[/titan|nova-|amazon\./, "bedrock"],
[/voyage/, "voyage"],
[/jina/, "jina_ai"],
];
export const providerForModel = (model: string): string | null => {
const name = model.trim().toLowerCase();
const slash = name.indexOf("/");
if (slash > 0) return name.slice(0, slash);
return FAMILY_PROVIDERS.find(([pattern]) => pattern.test(name))?.[1] ?? null;
};

View file

@ -0,0 +1,243 @@
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { stackedUsageColor } from "@/components/shared/charts";
import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityMetadata } from "@/components/UsagePage/dailyActivityApi";
import type { GatewayActivity } from "../gatewayActivity";
export type BreakdownDimension = "models" | "model_groups" | "providers";
/** Extends Record so it satisfies the chart component's row constraint. */
interface RankedRow extends Record<string, unknown> {
key: string;
spend: number;
requests: number;
successful_requests: number;
failed_requests: number;
tokens: number;
}
/** 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) ?? {
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);
}
}
return [...totals.values()].sort((a, b) => b.spend - a.spend);
};
export const OTHER_COLOR = "#94a3b8";
const OTHER_SERIES = "Other";
export interface SeriesDay extends Record<string, unknown> {
date: string;
label: string;
}
export interface Series {
data: SeriesDay[];
keys: string[];
colors: string[];
}
const formatDayLabel = (date: string): string => {
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) return date;
return parsed.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
const sortByDate = (results: readonly DailyData[]) =>
[...results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
export type UsageMetric = "spend" | "tokens" | "requests";
const metricOf = (metrics: SpendMetrics, metric: UsageMetric): number => {
if (metric === "spend") return metrics.spend;
if (metric === "tokens") return metrics.total_tokens;
return metrics.api_requests;
};
export const rankValue = (row: RankedRow, metric: UsageMetric): number => {
if (metric === "spend") return row.spend;
if (metric === "tokens") return row.tokens;
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.
*/
export const seriesBy = (
results: readonly DailyData[],
dimension: BreakdownDimension,
metric: UsageMetric,
top = 6,
): Series => {
const leaders = rollUpBreakdown(results, dimension)
.filter((row) => rankValue(row, metric) > 0)
.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;
}
// 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 keys = hasOther || leaders.length === 0 ? [...leaders, OTHER_SERIES] : leaders;
return { data, keys, colors: keys.map((key, i) => seriesColor(key, i)) };
};
export type Granularity = "day" | "week";
const DAY_MS = 86_400_000;
/** Sums a daily series into 7-day buckets anchored on the first day, keeping one point per bucket. */
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()] };
};
/** 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)]));
export interface LeaderRow extends RankedRow {
share: number;
/** Share in the later half of the range minus share in the earlier half, in points. */
delta: number | null;
}
/** Share of `metric` per key, plus how that share moved between the two halves of the range. */
export const leaderboard = (
results: readonly DailyData[],
dimension: BreakdownDimension,
metric: UsageMetric,
): LeaderRow[] => {
const days = sortByDate(results);
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 comparable = half > 0 && earlier.total > 0 && later.total > 0;
return rows
.filter((row) => rankValue(row, metric) > 0)
.map((row) => ({
...row,
share: grand > 0 ? (rankValue(row, metric) / grand) * 100 : 0,
delta: comparable ? later.of(row.key) - earlier.of(row.key) : null,
}));
};
export const formatMetricValue = (value: number, metric: UsageMetric): string =>
metric === "spend" ? formatCompactUsd(value) : formatCompact(value);
/** One point per day with the headline totals, for sparklines. */
export const dailyTotals = (results: readonly DailyData[]): SeriesDay[] =>
sortByDate(results).map((day) => ({
date: day.date,
label: formatDayLabel(day.date),
spend: day.metrics.spend,
requests: day.metrics.api_requests,
tokens: day.metrics.total_tokens,
}));
export const formatUsd = (value: number, digits = 2): string =>
`$${value.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
export const formatCompactUsd = (value: number): string => {
if (value === 0) return "$0";
if (Math.abs(value) < 100) return `$${value.toFixed(2)}`;
if (Math.abs(value) < 1000) return `$${value.toFixed(0)}`;
return `$${Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value)}`;
};
export const formatCompact = (value: number): string =>
Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
export const formatLatency = (ms: number): string => (ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`);
export interface OverviewTotals {
spend: number;
requests: number;
successful: number;
failed: number;
tokens: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
avgCostPerRequest: number;
avgLatencyMs: number | null;
successRate: number | null;
}
/**
* Request counts prefer the gateway's own tallies, falling back to the spend
* aggregate, the same precedence the request tiles have always used.
*/
export const overviewTotals = (raw: DailyActivityMetadata, gateway: GatewayActivity | null): OverviewTotals => {
// The schema marks every total optional; filling the defaults once keeps the arithmetic below plain.
const metadata = { ...EMPTY_DAILY_ACTIVITY_METADATA, ...raw };
const successful = gateway?.total_successful_requests ?? metadata.total_successful_requests;
const failed = gateway?.total_failed_requests ?? metadata.total_failed_requests;
const timed = metadata.total_timed_requests;
return {
spend: metadata.total_spend,
requests: gateway ? successful + failed : metadata.total_api_requests,
successful,
failed,
tokens: metadata.total_tokens,
inputTokens: metadata.total_prompt_tokens,
outputTokens: metadata.total_completion_tokens,
cacheReadTokens: metadata.total_cache_read_input_tokens,
cacheWriteTokens: metadata.total_cache_creation_input_tokens,
avgCostPerRequest: metadata.total_spend / (metadata.total_api_requests || 1),
avgLatencyMs: timed > 0 ? metadata.total_response_time_ms / timed : null,
successRate: successful + failed > 0 ? (successful / (successful + failed)) * 100 : null,
};
};

View file

@ -106,13 +106,13 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
return (
<>
<div className="mb-4">
<div className="mb-3">
{/**
* Use CSS grid with items-end so all cells (filter, button)
* align to the same baseline regardless of label heights. This removes
* vertical drift when the right column has a label above the input.
*/}
<div className={`grid ${hasFilters ? "grid-cols-[1fr_auto]" : "grid-cols-[auto]"} items-end gap-4`}>
<div className={`grid ${hasFilters ? "grid-cols-[1fr_auto]" : "grid-cols-[auto]"} items-end gap-3`}>
{hasFilters && (
<div>
{filterLabel && <label className="text-sm font-medium text-foreground block mb-2">{filterLabel}</label>}
@ -121,7 +121,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
)}
<div className="justify-self-end">
<Button onClick={() => setIsExportModalOpen(true)}>
<Button variant="outline" size="sm" onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>

View file

@ -66,17 +66,17 @@ describe("TopKeyView", () => {
it("should render", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Table View" })).toBeInTheDocument();
});
it("should display table view button", () => {
it("should display table view toggle", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Table View" })).toBeInTheDocument();
});
it("should display chart view button", () => {
it("should display chart view toggle", () => {
render(<TopKeyView {...baseProps} />);
expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Chart View" })).toBeInTheDocument();
});
it("should display base table column headers", () => {
@ -179,13 +179,13 @@ describe("TopKeyView", () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
const chartViewButton = screen.getByRole("radio", { name: "Chart View" });
await user.click(chartViewButton);
expect(chartViewButton).toHaveClass("bg-info/15");
expect(chartViewButton).toBeChecked();
});
it("renders cyan bars with truncated aliases in chart view and opens the key info modal on bar click", async () => {
it("renders brand-blue bars with truncated aliases in chart view and opens the key info modal on bar click", async () => {
const mockKeyInfo = { key: "info" };
const mockTransformedData = { transformed: "data" } as unknown as KeyResponse;
mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo);
@ -206,11 +206,11 @@ describe("TopKeyView", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Chart View" }));
await user.click(screen.getByRole("radio", { name: "Chart View" }));
const bars = chartBars(container);
expect(bars).toHaveLength(1);
expect(bars[0]).toHaveAttribute("fill", "var(--color-cyan-500, #06b6d4)");
expect(bars[0]).toHaveAttribute("fill", "#2b3fd6");
expect(screen.getAllByText("A Very Lon...").length).toBeGreaterThan(0);
fireEvent.click(bars[0]);
@ -229,13 +229,13 @@ describe("TopKeyView", () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
const tableViewButton = screen.getByRole("button", { name: "Table View" });
const chartViewButton = screen.getByRole("radio", { name: "Chart View" });
const tableViewButton = screen.getByRole("radio", { name: "Table View" });
await user.click(chartViewButton);
await user.click(tableViewButton);
expect(tableViewButton).toHaveClass("bg-info/15");
expect(tableViewButton).toBeChecked();
});
it("should call setTopKeysLimit when limit is changed via the segmented control", async () => {
@ -512,7 +512,7 @@ describe("TopKeyView", () => {
expect(screen.queryByRole("button", { name: "session-key" })).not.toBeInTheDocument();
await user.click(screen.getByText("session-key"));
await user.click(screen.getByRole("button", { name: "Chart View" }));
await user.click(screen.getByRole("radio", { name: "Chart View" }));
const bars = chartBars(container);
expect(bars).toHaveLength(2);
bars.forEach((bar) => fireEvent.click(bar));

View file

@ -3,8 +3,7 @@ import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Segmented } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { SimpleTooltip } from "@/components/ui/tooltip";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../../../../utils/dataUtils";
@ -14,6 +13,18 @@ import KeyInfoView from "../../../templates/key_info_view";
import { TagUsage } from "../../types";
const TOP_KEYS_LIMITS = [5, 10, 25, 50] as const;
const LIMIT_OPTIONS = TOP_KEYS_LIMITS.map((limit) => ({ value: String(limit), label: String(limit) }));
type ViewMode = "table" | "chart";
const VIEW_OPTIONS = [
{ value: "table", label: "Table View" },
{ 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 {
api_key: string;
@ -42,7 +53,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [keyData, setKeyData] = useState<any | undefined>(undefined);
const [viewMode, setViewMode] = useState<"chart" | "table">("table");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
const toggleTagsExpansion = (apiKey: string) => {
@ -102,6 +113,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
{
header: "Key ID",
accessorKey: "api_key",
meta: QUIET_HEADER,
cell: (info: any) =>
canOpenKeyInfo(info.row.original) ? (
<IdCell value={info.getValue()} onClick={() => handleKeyClick(info.row.original)} />
@ -112,6 +124,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
{
header: "Key Alias",
accessorKey: "key_alias",
meta: QUIET_HEADER,
cell: (info: any) => info.getValue() || "-",
},
...(topKeys.some((k) => k.user)
@ -119,6 +132,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
{
header: "User",
accessorKey: "user",
meta: QUIET_HEADER,
cell: (info: any) => info.getValue() || "-",
},
]
@ -128,6 +142,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
const tagsColumn = {
header: "Tags",
accessorKey: "tags",
meta: QUIET_HEADER,
cell: (info: any) => {
const tags = info.getValue() as TagUsage[] | undefined;
const apiKey = info.row.original.api_key;
@ -159,13 +174,15 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
</div>
}
>
<span className="px-2 py-1 bg-muted rounded-full text-xs">{tag.tag.slice(0, 7)}...</span>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{tag.tag.slice(0, 7)}...
</span>
</SimpleTooltip>
))}
{hasMoreTags && (
<button
onClick={() => toggleTagsExpansion(apiKey)}
className="ml-1 p-1 hover:bg-accent rounded-full transition-colors"
className="ml-0.5 rounded-md p-1 transition-colors hover:bg-accent"
title={isExpanded ? "Show fewer tags" : "Show all tags"}
>
{isExpanded ? (
@ -184,7 +201,7 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
const spendColumn = {
header: "Spend (USD)",
accessorKey: "spend",
meta: { numeric: true },
meta: { ...QUIET_HEADER, numeric: true },
cell: (info: any) => <MoneyCell value={info.getValue()} decimals={2} />,
};
@ -197,48 +214,25 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
return (
<>
<div className="mb-4 flex justify-between items-center">
<RadioGroup
aria-label="Number of top keys to show"
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<Segmented
label="Number of top keys to show"
value={String(topKeysLimit)}
onValueChange={(limit: unknown) => setTopKeysLimit(Number(limit))}
className="inline-flex w-fit items-center gap-1 rounded-lg bg-muted p-[3px]"
>
{TOP_KEYS_LIMITS.map((limit) => (
<Label
key={limit}
className="cursor-pointer rounded-md px-3 py-1 font-medium text-foreground/60 transition-colors has-data-checked:bg-background has-data-checked:text-foreground has-data-checked:shadow-sm"
>
<RadioGroupItem value={String(limit)} className="sr-only" />
{limit}
</Label>
))}
</RadioGroup>
<div className="flex space-x-2">
<button
onClick={() => setViewMode("table")}
className={`px-3 py-1 text-sm rounded-md ${viewMode === "table" ? "bg-info/15 text-info" : "bg-muted text-foreground"}`}
>
Table View
</button>
<button
onClick={() => setViewMode("chart")}
className={`px-3 py-1 text-sm rounded-md ${viewMode === "chart" ? "bg-info/15 text-info" : "bg-muted text-foreground"}`}
>
Chart View
</button>
</div>
options={LIMIT_OPTIONS}
onChange={(limit) => setTopKeysLimit(Number(limit))}
/>
<Segmented label="View" value={viewMode} options={VIEW_OPTIONS} onChange={setViewMode} />
</div>
{viewMode === "chart" ? (
<div className="relative max-h-[600px] overflow-y-auto">
<BarChart
className="mt-4 cursor-pointer hover:opacity-90"
className="cursor-pointer hover:opacity-90"
style={{ height: Math.min(processedTopKeys.length, topKeysLimit) * 52 }}
data={processedTopKeys}
index="display_key_alias"
categories={["spend"]}
colors={["cyan"]}
colors={[BAR_COLOR]}
yAxisWidth={120}
tickGap={5}
layout="vertical"
@ -249,19 +243,21 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
customTooltip={(props) => {
const item = props.payload?.[0]?.payload;
return (
<div className="relative z-floating p-3 bg-black/90 shadow-lg rounded-lg text-white max-w-xs">
<div className="space-y-1.5">
<div className="text-sm">
<div className="relative z-floating max-w-xs rounded-lg border bg-popover px-3 py-2 text-xs shadow-lg">
<div className="grid gap-1">
<div>
<span className="text-muted-foreground">Key Alias: </span>
<span className="font-mono text-gray-100 break-all">{item?.key_alias}</span>
<span className="break-all font-mono text-foreground">{item?.key_alias}</span>
</div>
<div className="text-sm">
<div>
<span className="text-muted-foreground">Key ID: </span>
<span className="font-mono text-gray-100 break-all">{item?.api_key}</span>
<span className="break-all font-mono text-foreground">{item?.api_key}</span>
</div>
<div className="text-sm">
<div>
<span className="text-muted-foreground">Spend: </span>
<span className="text-white font-medium">${formatNumberWithCommas(item?.spend, 2)}</span>
<span className="font-medium tabular-nums text-foreground">
${formatNumberWithCommas(item?.spend, 2)}
</span>
</div>
</div>
</div>

View file

@ -1,9 +1,14 @@
import { Search, X } from "lucide-react";
import { KeyRound, Search, X } from "lucide-react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ActivityMetrics, ModelCollapsible, ModelSection } from "@/components/activity_metrics";
import {
formatCompact,
formatUsd,
type SeriesDay,
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import { Panel, Sparkline, Stat } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { ModelCollapsible, ModelSection } from "@/components/activity_metrics";
import type { Team } from "@/components/key_team_helpers/key_list";
import { ChartLoader } from "@/components/shared/chart_loader";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import type {
@ -20,6 +25,8 @@ import type { ModelActivityData } from "../types";
const PAGE_SIZE = 50;
const SEARCH_DEBOUNCE_MS = 300;
const MIN_SEARCH_LENGTH = 2;
const BRAND = "#2b3fd6";
const KEY_ROW_GRID = "grid w-full grid-cols-[minmax(0,1fr)_5.5rem_5.5rem] items-center gap-4";
type FetchKeyPage = (offset: number, limit: number) => Promise<DailyActivityKeyPageResponse>;
type FetchKeyDetail = (apiKey: string) => Promise<ModelActivityData | undefined>;
@ -86,6 +93,96 @@ const KeyDetailContent: React.FC<KeyDetailContentProps> = ({ apiKey, detail, hid
);
};
const summarySeries = (summary: ModelActivityData): SeriesDay[] =>
[...summary.daily_data]
.sort((a, b) => a.date.localeCompare(b.date))
.map((day) => ({
date: day.date,
label: day.date,
spend: day.metrics.spend,
requests: day.metrics.api_requests,
tokens: day.metrics.total_tokens,
}));
const StatCell: React.FC<{ children: React.ReactNode; trend?: React.ReactNode }> = ({ children, trend }) => (
<div className="flex min-w-0 flex-col justify-between gap-3 border-b px-4 pt-3.5 pb-3 last:border-b-0 lg:border-b-0">
<div className="min-w-0">{children}</div>
{trend}
</div>
);
interface KeySummaryStripProps {
summary: ModelActivityData;
loading: boolean;
hidePromptCachingMetrics: boolean;
}
const KeySummaryStrip: React.FC<KeySummaryStripProps> = ({ summary, loading, hidePromptCachingMetrics }) => {
const daily = useMemo(() => summarySeries(summary), [summary]);
const trend = (dataKey: string) =>
loading || daily.length < 2 ? undefined : (
<Sparkline data={daily} dataKey={dataKey} color={BRAND} className="h-12" />
);
const avgCost = summary.total_requests > 0 ? summary.total_spend / summary.total_requests : 0;
return (
<section
aria-busy={loading}
className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x"
>
{loading ? (
<span role="status" className="sr-only">
Loading chart data...
</span>
) : (
<h3 className="sr-only">Overall Usage</h3>
)}
<StatCell trend={trend("spend")}>
<Stat label="Spend" value={formatUsd(summary.total_spend)} pending={loading} />
</StatCell>
<StatCell trend={trend("requests")}>
<Stat label="Requests" value={summary.total_requests.toLocaleString()} pending={loading} />
{!loading && (
<div className="mt-0.5 flex gap-3 text-xs text-muted-foreground">
<span>
<span className="text-success tabular-nums">{summary.total_successful_requests.toLocaleString()}</span> ok
</span>
<span>
<span className={summary.total_failed_requests > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
{summary.total_failed_requests.toLocaleString()}
</span>{" "}
failed
</span>
</div>
)}
</StatCell>
<StatCell trend={trend("tokens")}>
<Stat
label="Tokens"
value={summary.total_tokens.toLocaleString()}
pending={loading}
hint={
loading
? undefined
: `${formatCompact(summary.prompt_tokens)} in · ${formatCompact(summary.completion_tokens)} out`
}
/>
</StatCell>
<StatCell>
<Stat
label="Avg cost / request"
value={formatUsd(avgCost, 4)}
pending={loading}
hint={
loading || hidePromptCachingMetrics
? undefined
: `${formatCompact(summary.total_cache_read_input_tokens)} cache read tokens`
}
/>
</StatCell>
</section>
);
};
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
summary,
summaryLoading = false,
@ -285,7 +382,7 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
loadMore();
};
const firstPageError = (
<p className="p-4 text-sm text-muted-foreground">
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Could not load keys for this range.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={retryFirstPage}>
Retry
@ -295,27 +392,31 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
const emptyListBody = (() => {
if (failed) return firstPageError;
if (!loading) return null;
return <p className="p-4 text-sm text-muted-foreground">Loading keys...</p>;
return <p className="px-4 py-8 text-center text-sm text-muted-foreground">Loading keys...</p>;
})();
const maxSpend = Math.max(0, ...Object.values(filteredMetrics).map((metrics) => metrics.total_spend));
const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => {
const detail = visibleDetails[apiKey];
const share = maxSpend > 0 ? (metrics.total_spend / maxSpend) * 100 : 0;
return (
<ModelCollapsible
key={apiKey}
defaultOpen={false}
onFirstOpen={() => loadKeyDetail(apiKey)}
header={
<div className="flex w-full items-center justify-between gap-4">
<span className="truncate font-medium text-foreground">{metrics.label}</span>
<span className="shrink-0 space-x-4 text-sm text-muted-foreground">
<span>
$
{metrics.total_spend.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<span>{metrics.total_requests.toLocaleString()} requests</span>
<div className={KEY_ROW_GRID}>
<div className="min-w-0">
<div className="truncate text-sm text-foreground">{metrics.label}</div>
<div aria-hidden="true" className="mt-1 h-1 w-full max-w-xs overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full opacity-80"
style={{ width: `${share}%`, backgroundColor: BRAND }}
/>
</div>
</div>
<span className="text-right text-sm tabular-nums text-foreground">{formatUsd(metrics.total_spend)}</span>
<span className="text-right text-sm tabular-nums text-muted-foreground">
{metrics.total_requests.toLocaleString()} <span className="sr-only">requests</span>
</span>
</div>
}
@ -343,11 +444,9 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
</button>
);
const emptyFilterBody = searchFailed ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
Could not search keys. {searchRetryButton}
</p>
<p className="px-4 py-8 text-center text-sm text-muted-foreground">Could not search keys. {searchRetryButton}</p>
) : (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No keys match &quot;{trimmedQuery}&quot; in this date range
</p>
);
@ -382,55 +481,67 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
[detailState, fetchKeyDetail, fetchKeyPage],
);
const showColumnHeaders = !noMatches && listBody === keyList && keyList.length > 0;
const showFooter = showSearchErrorNote || loadingMore || showNextPageRetry;
const searchInput = (
<InputGroup className="h-8 w-full sm:w-72">
<InputGroupAddon>
<Search className="size-3.5 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search keys"
placeholder="Search alias, hash, user ID, or email"
value={query}
onChange={(event) => updateQuery(event.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => updateQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
);
return (
<div className="space-y-4">
<div className="mt-2 flex flex-wrap items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search keys"
placeholder="Search by key alias, key hash, user ID, or email"
value={query}
onChange={(event) => updateQuery(event.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => updateQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
{searchInput}
{!pageUnavailable && (
<span className="text-sm text-muted-foreground" aria-live="polite">
<span className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
{keyCountLabel}
</span>
)}
</div>
{summaryLoading ? (
<ChartLoader />
) : (
<ActivityMetrics
modelMetrics={{}}
summaryMetrics={summary}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
)}
{noMatches ? emptyFilterBody : <div className="rounded-lg border">{listBody}</div>}
{showSearchErrorNote && (
<p className="text-sm text-muted-foreground">Could not search all keys. {searchRetryButton}</p>
)}
{loadingMore && <p className="text-sm text-muted-foreground">Loading more keys...</p>}
{showNextPageRetry && (
<p className="text-sm text-muted-foreground">
Could not load more keys.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={retryNextPage}>
Retry
</button>
</p>
)}
<KeySummaryStrip summary={summary} loading={summaryLoading} hidePromptCachingMetrics={hidePromptCachingMetrics} />
<Panel icon={KeyRound} title="Virtual keys" className="overflow-hidden" bodyClassName="px-0 pt-3 pb-0">
{showColumnHeaders && (
<div
aria-hidden="true"
className={`${KEY_ROW_GRID} border-y bg-muted/40 py-2 pr-4 pl-10 text-xs font-medium text-muted-foreground`}
>
<span>Key</span>
<span className="text-right">Spend</span>
<span className="text-right">Requests</span>
</div>
)}
<div className={showColumnHeaders ? undefined : "border-t"}>{noMatches ? emptyFilterBody : listBody}</div>
{showFooter && (
<div className="border-t px-4 py-2.5 text-xs text-muted-foreground">
{showSearchErrorNote && <p>Could not search all keys. {searchRetryButton}</p>}
{loadingMore && <p>Loading more keys...</p>}
{showNextPageRetry && (
<p>
Could not load more keys.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={retryNextPage}>
Retry
</button>
</p>
)}
</div>
)}
</Panel>
{!isFiltering && <div ref={sentinelRef} aria-hidden="true" className="h-1" />}
</div>
);

View file

@ -603,13 +603,13 @@ describe("ActivityMetrics charts", () => {
"var(--color-emerald-500, #10b981)",
"var(--color-red-500, #ef4444)",
]);
expect(barFills(chartTitled("Spend per day"))).toEqual(["var(--color-green-500, #22c55e)"]);
expect(barFills(chartTitled("Spend per day"))).toEqual(["#2b3fd6"]);
expect(areaStrokes(chartTitled("Total Tokens"))).toEqual([
"var(--color-blue-500, #3b82f6)",
"var(--color-cyan-500, #06b6d4)",
"var(--color-indigo-500, #6366f1)",
]);
expect(barFills(chartTitled("Requests per day"))).toEqual(["var(--color-blue-500, #3b82f6)"]);
expect(barFills(chartTitled("Requests per day"))).toEqual(["#2b3fd6"]);
expect(areaStrokes(chartTitled("Success vs Failed Requests"))).toEqual([
"var(--color-green-500, #22c55e)",
"var(--color-red-500, #ef4444)",
@ -620,11 +620,12 @@ describe("ActivityMetrics charts", () => {
]);
});
it("shows the built-in chart legend only on the spend per day chart", () => {
it("keys every chart from its panel header instead of a built-in chart legend", () => {
const { container } = render(<ActivityMetrics modelMetrics={twoDayModelMetrics} />);
expect(container.querySelectorAll(".recharts-legend-wrapper")).toHaveLength(1);
expect(screen.getByText("metrics.spend")).toBeInTheDocument();
expect(container.querySelectorAll(".recharts-legend-wrapper")).toHaveLength(0);
expect(screen.queryByText("metrics.spend")).not.toBeInTheDocument();
expect(screen.getByText("Spend")).toBeInTheDocument();
});
it("renders formatted header legends for each chart card", () => {

View file

@ -1,19 +1,24 @@
import {
AreaChart,
BarChart,
type ChartColor,
type ChartTooltipProps,
CustomLegend,
chartColorValue,
CustomTooltip,
formatCategoryName,
LineChart,
stackedUsageColor,
ValueTooltip,
} from "@/components/shared/charts";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import React, { useRef, useState } from "react";
import { Panel, Stat } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { providerForModel } from "@/app/(dashboard)/usage/_components/components/overview/modelProvider";
import { cn } from "@/lib/cva.config";
import { Activity, ChevronDown, Coins, Database, KeyRound, Timer } from "lucide-react";
import React, { type ReactNode, useRef, useState } from "react";
import { Team } from "./key_team_helpers/key_list";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
@ -29,9 +34,18 @@ interface ActivityMetricsProps {
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
}
const BRAND: ChartColor = "#2b3fd6";
const TOKEN_CATEGORIES = ["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"] as const;
const TOKEN_COLORS: readonly ChartColor[] = ["blue", "cyan", "indigo"];
const REQUEST_CATEGORIES = ["metrics.successful_requests", "metrics.failed_requests"] as const;
const CACHE_CATEGORIES = ["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"] as const;
const CHART_CLASS = "mt-1 h-56";
const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null =>
averageResponseTimeMs(metrics.total_response_time_ms ?? 0, metrics.total_timed_requests ?? 0);
const formatSpend = (value: number) => `$${formatNumberWithCommas(value, 2, true)}`;
export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProps) => (
<ValueTooltip
active={active}
@ -41,6 +55,72 @@ 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) => (
<span key={category} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-[2px]"
style={{ backgroundColor: chartColorValue(colors[idx % colors.length]) }}
/>
{formatCategoryName(category)}
</span>
))}
</div>
);
/** A stat value kept as a heading so it stays addressable by its accessible name. */
const StatValue = ({ children }: { children: ReactNode }) => <h4 className="truncate">{children}</h4>;
const STRIP_COLUMNS = { 4: "lg:grid-cols-4", 5: "lg:grid-cols-5" } as const;
const StatStrip = ({ columns, children }: { columns: keyof typeof STRIP_COLUMNS; children: ReactNode }) => (
<div
className={cn(
"grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:divide-x",
STRIP_COLUMNS[columns],
)}
>
{children}
</div>
);
const StatCell = ({ children }: { children: ReactNode }) => (
<div className="min-w-0 border-b px-4 pt-3.5 pb-3 last:border-b-0 lg:border-b-0">{children}</div>
);
/**
* Same tile as the Overview leaderboard's model mark: provider logo for model names, a neutral
* monogram for anything else (MCP servers, agents), and a series-colored dot so rows read as a set.
*/
const ItemMark = ({ label, color }: { label: string; color: string }) => {
const provider = providerForModel(label);
return (
<span className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-md border bg-background">
{provider ? (
<ProviderLogo provider={provider} className="size-4 rounded-[3px]" />
) : (
<span aria-hidden="true" className="text-[11px] font-semibold uppercase text-muted-foreground">
{label.charAt(0) || "?"}
</span>
)}
<span
aria-hidden="true"
className="absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-card"
style={{ backgroundColor: color }}
/>
</span>
);
};
const TopKeysPanel = ({ children }: { children: ReactNode }) => (
<Panel icon={KeyRound} title="Top Virtual Keys by Spend">
{children}
</Panel>
);
const ModelTopKeys = ({
modelName,
fetchTopApiKeys,
@ -99,35 +179,29 @@ const ModelTopKeys = ({
if (current === null) {
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<p className="mt-3 text-sm text-muted-foreground">Loading top keys...</p>
</CardContent>
</Card>
<TopKeysPanel>
<p className="py-2 text-xs text-muted-foreground">Loading top keys...</p>
</TopKeysPanel>
);
}
if (current.failed) {
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<p className="mt-3 text-sm text-muted-foreground">
Could not load top keys.{" "}
<button
type="button"
className="font-medium text-foreground underline"
onClick={() => {
setSettled(null);
setRetryToken((token) => token + 1);
}}
>
Retry
</button>
</p>
</CardContent>
</Card>
<TopKeysPanel>
<p className="py-2 text-xs text-muted-foreground">
Could not load top keys.{" "}
<button
type="button"
className="font-medium text-foreground underline"
onClick={() => {
setSettled(null);
setRetryToken((token) => token + 1);
}}
>
Retry
</button>
</p>
</TopKeysPanel>
);
}
@ -135,35 +209,30 @@ const ModelTopKeys = ({
if (rows.length === 0) return null;
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<div className="mt-3">
<div className="grid grid-cols-1 gap-2">
{rows.map((keyData) => {
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
return (
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-muted rounded-lg">
<div>
<p className="font-medium">{keyLabel}</p>
{keyData.team_id && <p className="text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
{keyData.user && keyData.user !== keyLabel && (
<p className="text-xs text-muted-foreground">User: {keyData.user}</p>
)}
</div>
<div className="text-right">
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
<p className="text-xs text-muted-foreground">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</p>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
<TopKeysPanel>
<ol className="divide-y divide-border/60">
{rows.map((keyData) => {
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
return (
<li key={keyData.api_key} className="flex items-center justify-between gap-4 py-2.5">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{keyLabel}</p>
{keyData.team_id && <p className="truncate text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
{keyData.user && keyData.user !== keyLabel && (
<p className="truncate text-xs text-muted-foreground">User: {keyData.user}</p>
)}
</div>
<div className="shrink-0 text-right">
<p className="text-sm tabular-nums text-foreground">${formatNumberWithCommas(keyData.spend, 2)}</p>
<p className="text-xs tabular-nums text-muted-foreground">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</p>
</div>
</li>
);
})}
</ol>
</TopKeysPanel>
);
};
@ -179,195 +248,143 @@ export const ModelSection = ({
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
}) => {
return (
<div className="space-y-2">
{/* Summary Cards */}
<div className="grid grid-cols-5 gap-4">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
<h3 className="text-lg font-medium text-foreground">{metrics.total_requests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
<h3 className="text-lg font-medium text-foreground">
{metrics.total_successful_requests.toLocaleString()}
</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<h3 className="text-lg font-medium text-foreground">{metrics.total_tokens.toLocaleString()}</h3>
<p className="text-sm text-muted-foreground">
{Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Spend</p>
<h3 className="text-lg font-medium text-foreground">${formatNumberWithCommas(metrics.total_spend, 2)}</h3>
<p className="text-sm text-muted-foreground">
${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful
request
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Avg Response Time</p>
<h3 className="text-lg font-medium text-foreground">
{formatResponseTime(modelAverageResponseTimeMs(metrics))}
</h3>
<p className="text-sm text-muted-foreground">
over {(metrics.total_timed_requests ?? 0).toLocaleString()} timed successful requests
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-3">
<StatStrip columns={5}>
<StatCell>
<Stat label="Total Requests" value={<StatValue>{metrics.total_requests.toLocaleString()}</StatValue>} />
</StatCell>
<StatCell>
<Stat
label="Total Successful Requests"
value={<StatValue>{metrics.total_successful_requests.toLocaleString()}</StatValue>}
/>
</StatCell>
<StatCell>
<Stat
label="Total Tokens"
value={<StatValue>{metrics.total_tokens.toLocaleString()}</StatValue>}
hint={`${Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request`}
/>
</StatCell>
<StatCell>
<Stat
label="Total Spend"
value={<StatValue>${formatNumberWithCommas(metrics.total_spend, 2)}</StatValue>}
hint={`$${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful request`}
/>
</StatCell>
<StatCell>
<Stat
label="Avg Response Time"
value={<StatValue>{formatResponseTime(modelAverageResponseTimeMs(metrics))}</StatValue>}
hint={`over ${(metrics.total_timed_requests ?? 0).toLocaleString()} timed successful requests`}
/>
</StatCell>
</StatStrip>
{fetchTopApiKeys && <ModelTopKeys modelName={modelName} fetchTopApiKeys={fetchTopApiKeys} />}
{metrics.top_models && metrics.top_models.length > 0 && <KeyModelUsageView topModels={metrics.top_models} />}
{/* Spend per day - Full width card */}
<Card className="mt-4">
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Spend per day</h3>
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
</div>
<BarChart
className="mt-4"
<Panel icon={Coins} title="Spend per day" action={<SeriesKey categories={["metrics.spend"]} colors={[BRAND]} />}>
<BarChart
className={CHART_CLASS}
data={metrics.daily_data}
index="date"
categories={["metrics.spend"]}
colors={[BRAND]}
maxBarSize={28}
valueFormatter={formatSpend}
yAxisWidth={72}
showLegend={false}
/>
</Panel>
<div className="grid gap-3 lg:grid-cols-2">
<Panel title="Total Tokens" action={<SeriesKey categories={TOKEN_CATEGORIES} colors={TOKEN_COLORS} />}>
<AreaChart
className={CHART_CLASS}
data={metrics.daily_data}
index="date"
categories={["metrics.spend"]}
colors={["green"]}
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
yAxisWidth={72}
categories={TOKEN_CATEGORIES}
colors={TOKEN_COLORS}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</CardContent>
</Card>
</Panel>
{/* Charts */}
<div className="grid grid-cols-2 gap-4 mt-4">
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Total Tokens</h3>
<CustomLegend
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
colors={["blue", "cyan", "indigo"]}
/>
</div>
<AreaChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
colors={["blue", "cyan", "indigo"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Requests per day</h3>
<CustomLegend categories={["metrics.api_requests"]} colors={["blue"]} />
</div>
<BarChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.api_requests"]}
colors={["blue"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</CardContent>
</Card>
<Panel title="Requests per day" action={<SeriesKey categories={["metrics.api_requests"]} colors={[BRAND]} />}>
<BarChart
className={CHART_CLASS}
data={metrics.daily_data}
index="date"
categories={["metrics.api_requests"]}
colors={[BRAND]}
maxBarSize={28}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</Panel>
{(metrics.total_timed_requests ?? 0) > 0 && (
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Avg Response Time per day</h3>
<CustomLegend categories={["metrics.avg_response_time_ms"]} colors={["amber"]} />
</div>
<LineChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.avg_response_time_ms"]}
colors={["amber"]}
valueFormatter={formatResponseTime}
customTooltip={ResponseTimeTooltip}
connectNulls={true}
showLegend={false}
/>
</CardContent>
</Card>
)}
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Success vs Failed Requests</h3>
<CustomLegend
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
/>
</div>
<AreaChart
className="mt-4"
<Panel
icon={Timer}
title="Avg Response Time per day"
action={<SeriesKey categories={["metrics.avg_response_time_ms"]} colors={[BRAND]} />}
>
<LineChart
className={CHART_CLASS}
data={metrics.daily_data}
index="date"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
categories={["metrics.avg_response_time_ms"]}
colors={[BRAND]}
valueFormatter={formatResponseTime}
customTooltip={ResponseTimeTooltip}
connectNulls={true}
showLegend={false}
/>
</Panel>
)}
<Panel
title="Success vs Failed Requests"
action={<SeriesKey categories={REQUEST_CATEGORIES} colors={["green", "red"]} />}
>
<AreaChart
className={CHART_CLASS}
data={metrics.daily_data}
index="date"
categories={REQUEST_CATEGORIES}
colors={["green", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</Panel>
{!hidePromptCachingMetrics && (
<Panel
icon={Database}
title="Prompt Caching Metrics"
action={<SeriesKey categories={CACHE_CATEGORIES} colors={["cyan", "purple"]} />}
>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs tabular-nums text-muted-foreground">
<p>Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens</p>
<p>Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens</p>
</div>
<AreaChart
className={cn(CHART_CLASS, "mt-3")}
data={metrics.daily_data}
index="date"
categories={CACHE_CATEGORIES}
colors={["cyan", "purple"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</CardContent>
</Card>
{!hidePromptCachingMetrics && (
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Prompt Caching Metrics</h3>
<CustomLegend
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
colors={["cyan", "purple"]}
/>
</div>
<div className="mb-2">
<p className="text-sm">
Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens
</p>
<p className="text-sm">
Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens
</p>
</div>
<AreaChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
colors={["cyan", "purple"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</CardContent>
</Card>
</Panel>
)}
</div>
</div>
@ -404,13 +421,13 @@ export const ModelCollapsible = ({
}}
className="border-b last:border-b-0"
>
<CollapsibleTrigger className="flex w-full items-center gap-2 px-4 py-3 text-left">
<CollapsibleTrigger className="flex w-full items-center gap-2 px-4 py-3 text-left outline-none transition-colors hover:bg-muted/40 focus-visible:bg-muted/40">
<ChevronDown
className={`size-4 shrink-0 text-muted-foreground transition-transform ${open ? "" : "-rotate-90"}`}
/>
{header}
</CollapsibleTrigger>
<CollapsibleContent keepMounted={everOpened} className="px-4 pb-4">
<CollapsibleContent keepMounted={everOpened} className="border-t bg-muted/30 p-3">
{children}
</CollapsibleContent>
</Collapsible>
@ -501,118 +518,120 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
const totalTokens = summaryMetrics?.total_tokens ?? totalMetrics.total_tokens;
const totalSpend = summaryMetrics?.total_spend ?? totalMetrics.total_spend;
const maxSpend = Math.max(0, ...modelNames.map((name) => modelMetrics[name].total_spend));
return (
<div className="space-y-8">
{/* Global Summary */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-foreground">{summaryTitle}</h3>
<div className="grid grid-cols-4 gap-4 mb-4">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
<h3 className="text-lg font-medium text-foreground">{totalRequests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
<h3 className="text-lg font-medium text-foreground">{totalSuccessfulRequests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<h3 className="text-lg font-medium text-foreground">{totalTokens.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Spend</p>
<h3 className="text-lg font-medium text-foreground">${formatNumberWithCommas(totalSpend, 2)}</h3>
</CardContent>
</Card>
</div>
<div className="grid gap-3">
<section className="grid gap-3">
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground">
<Activity aria-hidden="true" className="size-4 text-muted-foreground" strokeWidth={1.75} />
{summaryTitle}
</h3>
<StatStrip columns={4}>
<StatCell>
<Stat label="Total Requests" value={<StatValue>{totalRequests.toLocaleString()}</StatValue>} />
</StatCell>
<StatCell>
<Stat
label="Total Successful Requests"
value={<StatValue>{totalSuccessfulRequests.toLocaleString()}</StatValue>}
/>
</StatCell>
<StatCell>
<Stat label="Total Tokens" value={<StatValue>{totalTokens.toLocaleString()}</StatValue>} />
</StatCell>
<StatCell>
<Stat label="Total Spend" value={<StatValue>${formatNumberWithCommas(totalSpend, 2)}</StatValue>} />
</StatCell>
</StatStrip>
<div className="grid grid-cols-2 gap-4">
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Total Tokens Over Time</h3>
<CustomLegend
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
colors={["blue", "cyan", "indigo"]}
/>
</div>
<AreaChart
className="mt-4"
data={sortedDailyData}
index="date"
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
colors={["blue", "cyan", "indigo"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
yAxisWidth={80}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Total Requests Over Time</h3>
<CustomLegend
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["emerald", "red"]}
/>
</div>
<AreaChart
className="mt-4"
data={sortedDailyData}
index="date"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["emerald", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
yAxisWidth={80}
/>
</CardContent>
</Card>
<div className="grid gap-3 lg:grid-cols-2">
<Panel
title="Total Tokens Over Time"
action={<SeriesKey categories={TOKEN_CATEGORIES} colors={TOKEN_COLORS} />}
>
<AreaChart
className={CHART_CLASS}
data={sortedDailyData}
index="date"
categories={TOKEN_CATEGORIES}
colors={TOKEN_COLORS}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
yAxisWidth={80}
/>
</Panel>
<Panel
title="Total Requests Over Time"
action={<SeriesKey categories={REQUEST_CATEGORIES} colors={["emerald", "red"]} />}
>
<AreaChart
className={CHART_CLASS}
data={sortedDailyData}
index="date"
categories={REQUEST_CATEGORIES}
colors={["emerald", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
yAxisWidth={80}
/>
</Panel>
</div>
</div>
</section>
{/* Individual Model Sections */}
{modelNames.length > 0 && (
<div className="rounded-lg border">
{modelNames.map((modelName) => (
<ModelCollapsible
key={modelName}
defaultOpen={modelName === modelNames[0]}
header={
<div className="flex justify-between items-center w-full">
<h3 className="text-lg font-medium text-foreground">
{modelMetrics[modelName].label || "Unknown Item"}
</h3>
<div className="flex space-x-4 text-sm text-muted-foreground">
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
<span>
{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response
</span>
)}
<div className="overflow-hidden rounded-xl border bg-card">
{modelNames.map((modelName, index) => {
const metrics = modelMetrics[modelName];
const label = metrics.label || "Unknown Item";
const provider = providerForModel(metrics.label || "");
const avgResponse = modelAverageResponseTimeMs(metrics);
const share = maxSpend > 0 ? (metrics.total_spend / maxSpend) * 100 : 0;
return (
<ModelCollapsible
key={modelName}
defaultOpen={modelName === modelNames[0]}
header={
<div className="grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div className="flex min-w-0 items-center gap-3">
<ItemMark label={metrics.label || ""} color={stackedUsageColor(index)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate text-sm font-medium text-foreground">{label}</span>
{provider && <span className="shrink-0 text-xs text-muted-foreground">{provider}</span>}
</div>
<div
aria-hidden="true"
className="mt-1 h-1 w-full max-w-xs overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full opacity-80"
style={{ width: `${share}%`, backgroundColor: BRAND }}
/>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-5 text-sm tabular-nums text-muted-foreground">
<span className="text-foreground">${formatNumberWithCommas(metrics.total_spend, 2)}</span>
<span>{metrics.total_requests.toLocaleString()} requests</span>
{avgResponse != null && (
<span className="hidden md:inline">{formatResponseTime(avgResponse)} avg response</span>
)}
</div>
</div>
</div>
}
>
<ModelSection
modelName={modelName || "Unknown Model"}
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
fetchTopApiKeys={fetchTopApiKeys}
/>
</ModelCollapsible>
))}
}
>
<ModelSection
modelName={modelName || "Unknown Model"}
metrics={metrics}
hidePromptCachingMetrics={hidePromptCachingMetrics}
fetchTopApiKeys={fetchTopApiKeys}
/>
</ModelCollapsible>
);
})}
</div>
)}
</div>

View file

@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { describe, expect, it } from "vitest";
import { STACKED_USAGE_PALETTE, StackedUsageChart, StackedUsageTooltipRow } from "./StackedUsageChart";
const usd = (value: number) => `$${value.toFixed(2)}`;
describe("StackedUsageChart", () => {
it("stacks one bar per series per bucket in the shared palette, in series order", () => {
const { container } = render(
<StackedUsageChart
data={[
{ day: "Oct 1", a: 1, b: 2 },
{ day: "Oct 2", a: 3, b: 4 },
]}
series={["a", "b"]}
xKey="day"
format={usd}
/>,
);
const fills = Array.from(container.querySelectorAll("path.recharts-rectangle")).map((bar) =>
bar.getAttribute("fill"),
);
expect(fills).toHaveLength(4);
expect(new Set(fills)).toEqual(new Set([STACKED_USAGE_PALETTE[0], STACKED_USAGE_PALETTE[1]]));
});
it("lets a caller override a series color, e.g. a slate Other bucket", () => {
const { container } = render(
<StackedUsageChart
data={[{ day: "Oct 1", a: 1, Other: 2 }]}
series={["a", "Other"]}
colors={[STACKED_USAGE_PALETTE[0], "#94a3b8"]}
xKey="day"
format={usd}
/>,
);
const fills = Array.from(container.querySelectorAll("path.recharts-rectangle")).map((bar) =>
bar.getAttribute("fill"),
);
expect(fills).toContain("#94a3b8");
});
});
describe("StackedUsageTooltipRow", () => {
it("formats the value and keeps it apart from a long series name", () => {
render(<StackedUsageTooltipRow value={0.091} name="cerebras/gpt-oss-120b" color="#a855f7" format={usd} />);
expect(screen.getByText("cerebras/gpt-oss-120b")).toBeInTheDocument();
expect(screen.getByText("$0.09")).toBeInTheDocument();
expect(screen.queryByText("0.091")).not.toBeInTheDocument();
});
it("renders nothing for a zero value, so a day lists only what it used", () => {
const { container } = render(<StackedUsageTooltipRow value={0} name="gpt-4.1-mini" color="#3b82f6" format={usd} />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { cn } from "@/lib/cva.config";
/** The Model Leaderboard palette; shared so the usage page stacks in the same colors. */
export const STACKED_USAGE_PALETTE = [
"#ec4899",
"#a855f7",
"#f59e0b",
"#3b82f6",
"#10b981",
"#ef4444",
"#14b8a6",
"#84cc16",
"#6366f1",
"#f97316",
] as const;
export const stackedUsageColor = (index: number) => STACKED_USAGE_PALETTE[index % STACKED_USAGE_PALETTE.length];
export type StackedUsageScale = "linear" | "log";
export interface StackedUsageChartProps {
data: readonly Record<string, unknown>[];
/** Series keys, bottom of the stack first. */
series: readonly string[];
/** Overrides the palette per series, e.g. to grey out an "Other" bucket. */
colors?: readonly string[];
xKey: string;
format: (value: number) => string;
/** Appended to the tooltip header as "· Total …" for the hovered bucket. */
totalFor?: (label: string) => number | undefined;
totalLabel?: string;
scale?: StackedUsageScale;
className?: string;
}
/**
* One tooltip row: the default swatch layout, but the value goes through `format` and is held apart
* from long series names. Zero rows render nothing, so a day lists only what it actually used.
*/
export function StackedUsageTooltipRow({
value,
name,
color,
format,
}: {
value: number;
name: string;
color: string | undefined;
format: (value: number) => string;
}) {
if (value === 0) return null;
return (
<div className="flex w-full items-center gap-2">
<span className="size-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: color }} />
<span className="min-w-0 flex-1 truncate text-muted-foreground">{name}</span>
<span className="ml-3 font-medium tabular-nums text-foreground">{format(value)}</span>
</div>
);
}
/** Daily usage stacked by series, with a tooltip listing each series and the bucket total. */
export function StackedUsageChart({
data,
series,
colors,
xKey,
format,
totalFor,
totalLabel = "Total",
scale = "linear",
className,
}: StackedUsageChartProps) {
const fill = (index: number) => colors?.[index] ?? stackedUsageColor(index);
const config = Object.fromEntries(
series.map((key, index) => [key, { label: key, color: fill(index) }]),
) satisfies ChartConfig;
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} />
<YAxis
scale={scale}
domain={scale === "log" ? [1, "auto"] : [0, "auto"]}
allowDataOverflow
tickLine={false}
axisLine={false}
tickFormatter={(value) => format(Number(value))}
/>
<ChartTooltip
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} />
)}
/>
}
/>
{series.map((key, index) => (
<Bar key={key} dataKey={key} stackId="usage" fill={fill(index)} isAnimationActive={false} />
))}
</BarChart>
</ChartContainer>
);
}

View file

@ -18,3 +18,10 @@ export {
} from "./colors";
export { DonutChart, type DonutChartProps } from "./donut_chart";
export { LineChart, type LineChartCurveType, type LineChartProps } from "./line_chart";
export {
STACKED_USAGE_PALETTE,
StackedUsageChart,
stackedUsageColor,
type StackedUsageChartProps,
type StackedUsageScale,
} from "./StackedUsageChart";

View file

@ -1,5 +1,6 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import UserAgentActivity from "./user_agent_activity";
import * as networking from "./networking";
@ -201,7 +202,8 @@ describe("UserAgentActivity", () => {
const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle"));
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));
// Stacked in the shared usage palette, so these charts match the Usage overview.
expect(fills).toEqual(new Set([STACKED_USAGE_PALETTE[0], STACKED_USAGE_PALETTE[1]]));
const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
expect(xPositions.size).toBe(1);
@ -232,7 +234,7 @@ describe("UserAgentActivity", () => {
expect(screen.getByText("Per User Usage")).toBeInTheDocument();
});
it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => {
it("renders the DAU chart stacked in the shared usage palette with abbreviated axis ticks", async () => {
const firstBucketDate = new Date();
firstBucketDate.setDate(firstBucketDate.getDate() - 6);
const todayStr = new Date().toISOString().split("T")[0];

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Bot, Users } from "lucide-react";
import {
Combobox,
ComboboxChip,
@ -13,13 +13,28 @@ import {
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { BarChart } from "@/components/shared/charts";
import { BarChart, STACKED_USAGE_PALETTE } from "@/components/shared/charts";
import { ChartSkeleton, Panel, Segmented } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking";
import PerUserUsage from "./per_user_usage";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { ChartLoader } from "./shared/chart_loader";
type ActivityView = "active-users" | "per-user";
type ActivePeriod = "dau" | "wau" | "mau";
const VIEW_OPTIONS = [
{ value: "active-users", label: "Active users" },
{ value: "per-user", label: "Per user" },
] as const satisfies readonly { value: ActivityView; label: string }[];
const PERIOD_OPTIONS = [
{ value: "dau", label: "DAU" },
{ value: "wau", label: "WAU" },
{ value: "mau", label: "MAU" },
] as const satisfies readonly { value: ActivePeriod; label: string }[];
const SUMMARY_SLOTS = 4;
// New interfaces for the updated API response
interface TagActiveUsersResponse {
@ -52,14 +67,71 @@ interface DistinctTagResponse {
tag: string;
}
// Helper function to extract user agent from tag
const extractUserAgent = (tag: string): string => {
if (tag.startsWith("User-Agent: ")) {
return tag.replace("User-Agent: ", "");
}
return tag;
};
// Format numbers with K, M abbreviations
const formatAbbreviatedNumber = (value: number, decimalPlaces: number = 0): string => {
if (value >= 1000000) return (value / 1000000).toFixed(decimalPlaces) + "M";
if (value >= 1000) return (value / 1000).toFixed(decimalPlaces) + "K";
return value.toFixed(decimalPlaces);
};
interface ActiveUserChart {
value: ActivePeriod;
title: string;
loading: boolean;
data: Record<string, unknown>[];
index: string;
tags: string[];
}
function ActiveUsersChart({ chart }: { chart: ActiveUserChart }) {
if (chart.loading) return <ChartSkeleton className="h-72" />;
if (chart.tags.length === 0) {
return (
<div className="flex h-72 items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
No active users in this period
</div>
);
}
return (
<BarChart
data={chart.data}
index={chart.index}
categories={chart.tags.map(extractUserAgent)}
colors={STACKED_USAGE_PALETTE}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={48}
maxBarSize={48}
showLegend={true}
stack={true}
className="h-72"
/>
);
}
interface UserAgentActivityProps {
accessToken: string | null;
userRole: string | null;
dateValue: DateRangePickerValue;
onDateChange?: (value: DateRangePickerValue) => void; // Optional - not used anymore
/** Tags to pre-select, e.g. an agent's user-agent tags when opened from the Overview's Top agents. */
initialTags?: readonly string[];
}
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, userRole, dateValue, onDateChange }) => {
const UserAgentActivity: React.FC<UserAgentActivityProps> = ({
accessToken,
userRole,
dateValue,
onDateChange,
initialTags,
}) => {
const anchor = useComboboxAnchor();
// Maximum number of categories to show in charts to prevent color palette overflow
const MAX_CATEGORIES = 10;
@ -74,7 +146,7 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
// Tag filtering state
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>(() => [...(initialTags ?? [])]);
const [tagsLoading, setTagsLoading] = useState(false);
// Separate loading states for each endpoint
@ -83,6 +155,9 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
const [mauLoading, setMauLoading] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(false);
const [activityView, setActivityView] = useState<ActivityView>("active-users");
const [period, setPeriod] = useState<ActivePeriod>("dau");
// Use today's date as the end date for all API calls
const today = new Date();
@ -205,14 +280,6 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
return () => clearTimeout(timeoutId);
}, [accessToken, dateValue, selectedTags]);
// Helper function to extract user agent from tag
const extractUserAgent = (tag: string): string => {
if (tag.startsWith("User-Agent: ")) {
return tag.replace("User-Agent: ", "");
}
return tag;
};
// Helper function to truncate user agent name (used with Ant Design Tooltip)
const truncateUserAgent = (userAgent: string): string => {
if (userAgent.length > 15) {
@ -350,242 +417,185 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
const monthlyChartData = generateMonthlyChartData();
// Format numbers with K, M abbreviations
const formatAbbreviatedNumber = (value: number, decimalPlaces: number = 0): string => {
if (value >= 100000000) {
return (value / 1000000).toFixed(decimalPlaces) + "M";
} else if (value >= 10000000) {
return (value / 1000000).toFixed(decimalPlaces) + "M";
} else if (value >= 1000000) {
return (value / 1000000).toFixed(decimalPlaces) + "M";
} else if (value >= 10000) {
return (value / 1000).toFixed(decimalPlaces) + "K";
} else if (value >= 1000) {
return (value / 1000).toFixed(decimalPlaces) + "K";
} else {
return value.toFixed(decimalPlaces);
}
};
const activeUserCharts: readonly ActiveUserChart[] = [
{
value: "dau",
title: "Daily Active Users - Last 7 Days",
loading: dauLoading,
data: dailyChartData,
index: "date",
tags: allDauTags,
},
{
value: "wau",
title: "Weekly Active Users - Last 7 Weeks",
loading: wauLoading,
data: weeklyChartData,
index: "week",
tags: allWauTags,
},
{
value: "mau",
title: "Monthly Active Users - Last 7 Months",
loading: mauLoading,
data: monthlyChartData,
index: "month",
tags: allMauTags,
},
];
const summaryRows = (summaryData.results || []).slice(0, SUMMARY_SLOTS);
return (
<div className="space-y-6 mt-6">
{/* Summary Section Card */}
<Card>
<CardContent className="space-y-6">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-medium text-foreground">Summary by User Agent</h3>
<p className="text-sm text-muted-foreground">Performance metrics for different user agents</p>
</div>
{/* User Agent Filter */}
<div className="w-96">
<label className="text-sm font-medium block mb-2">Filter by User Agents</label>
<Combobox
multiple
items={availableTags}
value={selectedTags}
onValueChange={(next: string[]) => setSelectedTags(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="w-full" aria-busy={tagsLoading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((tag) => (
<ComboboxChip key={tag} aria-label={extractUserAgent(tag)}>
{truncateUserAgent(extractUserAgent(tag))}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder="All User Agents" aria-label="All User Agents" />
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No user agents found</ComboboxEmpty>
<ComboboxList>
{(tag: string) => {
const userAgent = extractUserAgent(tag);
return (
<ComboboxItem key={tag} value={tag} title={userAgent}>
{userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="grid gap-3">
<Panel
icon={Bot}
title="Summary by User Agent"
subtitle="Performance metrics for different user agents"
action={
<div className="flex w-full items-center gap-2 sm:w-96">
<label className="shrink-0 text-xs text-muted-foreground">Filter by User Agents</label>
<Combobox
multiple
items={availableTags}
value={selectedTags}
onValueChange={(next: string[]) => setSelectedTags(next)}
>
<ComboboxChips render={<div ref={anchor} />} className="min-w-0 flex-1" aria-busy={tagsLoading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((tag) => (
<ComboboxChip key={tag} aria-label={extractUserAgent(tag)}>
{truncateUserAgent(extractUserAgent(tag))}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput placeholder="All User Agents" aria-label="All User Agents" />
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No user agents found</ComboboxEmpty>
<ComboboxList>
{(tag: string) => {
const userAgent = extractUserAgent(tag);
return (
<ComboboxItem key={tag} value={tag} title={userAgent}>
{userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{/* Date Range Picker is controlled by parent component */}
{/* Top 4 User Agents Cards */}
{summaryLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<div className="grid grid-cols-4 gap-4">
{(summaryData.results || []).slice(0, 4).map((tag, index) => {
const userAgent = extractUserAgent(tag.tag);
const displayName = truncateUserAgent(userAgent);
return (
<Card key={index}>
<CardContent>
<Tooltip>
<TooltipTrigger
render={<h4 className="truncate text-lg font-medium text-foreground">{displayName}</h4>}
/>
<TooltipContent side="top">{userAgent}</TooltipContent>
</Tooltip>
<div className="mt-4 space-y-3">
<div>
<p className="text-sm text-muted-foreground">Success Requests</p>
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.successful_requests)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.total_tokens)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Cost</p>
<p className="text-lg font-semibold">${formatAbbreviatedNumber(tag.total_spend, 4)}</p>
</div>
</div>
</CardContent>
</Card>
);
})}
{/* Fill remaining slots if less than 4 agents */}
{Array.from({ length: Math.max(0, 4 - (summaryData.results || []).length) }).map((_, index) => (
<Card key={`empty-${index}`}>
<CardContent>
<h4 className="text-lg font-medium text-foreground">No Data</h4>
<div className="mt-4 space-y-3">
<div>
<p className="text-sm text-muted-foreground">Success Requests</p>
<p className="text-lg font-semibold">-</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<p className="text-lg font-semibold">-</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Cost</p>
<p className="text-lg font-semibold">-</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
{/* Main tabs for DAU/WAU/MAU vs Per User Usage */}
<Card>
<CardContent>
<Tabs defaultValue="active-users">
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="active-users" className="flex-none rounded-none px-4 py-2">
DAU/WAU/MAU
</TabsTrigger>
<TabsTrigger value="per-user" className="flex-none rounded-none px-4 py-2">
Per User Usage (Last 30 Days)
</TabsTrigger>
</TabsList>
{/* DAU/WAU/MAU Tab Panel */}
<TabsContent value="active-users" keepMounted>
<div className="mb-6">
<h3 className="text-lg font-medium text-foreground">DAU, WAU &amp; MAU per Agent</h3>
<p className="text-sm text-muted-foreground">Active users across different time periods</p>
</div>
<Tabs defaultValue="dau">
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="dau" className="flex-none rounded-none px-4 py-2">
DAU
</TabsTrigger>
<TabsTrigger value="wau" className="flex-none rounded-none px-4 py-2">
WAU
</TabsTrigger>
<TabsTrigger value="mau" className="flex-none rounded-none px-4 py-2">
MAU
</TabsTrigger>
</TabsList>
<TabsContent value="dau" keepMounted>
<div className="mb-4">
<h4 className="text-lg font-medium text-foreground">Daily Active Users - Last 7 Days</h4>
</div>
{dauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={dailyChartData}
index="date"
categories={allDauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
<TabsContent value="wau" keepMounted>
<div className="mb-4">
<h4 className="text-lg font-medium text-foreground">Weekly Active Users - Last 7 Weeks</h4>
</div>
{wauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={weeklyChartData}
index="week"
categories={allWauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
<TabsContent value="mau" keepMounted>
<div className="mb-4">
<h4 className="text-lg font-medium text-foreground">Monthly Active Users - Last 7 Months</h4>
</div>
{mauLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<BarChart
data={monthlyChartData}
index="month"
categories={allMauTags.map(extractUserAgent)}
valueFormatter={(value: number) => formatAbbreviatedNumber(value)}
yAxisWidth={60}
showLegend={true}
stack={true}
/>
)}
</TabsContent>
</Tabs>
</TabsContent>
{/* Per User Usage Tab Panel */}
<TabsContent value="per-user" keepMounted>
<PerUserUsage
accessToken={accessToken}
selectedTags={selectedTags}
formatAbbreviatedNumber={formatAbbreviatedNumber}
}
>
{/* Date range is controlled by the parent page header */}
{summaryLoading ? (
<ChartSkeleton className="h-32" />
) : (
<div className="grid grid-cols-1 overflow-hidden rounded-lg border sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
{summaryRows.map((tag, index) => {
const userAgent = extractUserAgent(tag.tag);
return (
<SummaryCell
key={index}
name={
<Tooltip>
<TooltipTrigger
render={
<h4 className="truncate text-sm font-medium text-foreground">
{truncateUserAgent(userAgent)}
</h4>
}
/>
<TooltipContent side="top">{userAgent}</TooltipContent>
</Tooltip>
}
successRequests={formatAbbreviatedNumber(tag.successful_requests)}
totalTokens={formatAbbreviatedNumber(tag.total_tokens)}
totalCost={`$${formatAbbreviatedNumber(tag.total_spend, 4)}`}
/>
);
})}
{/* Fill remaining slots if less than 4 agents */}
{Array.from({ length: Math.max(0, SUMMARY_SLOTS - summaryRows.length) }).map((_, index) => (
<SummaryCell
key={`empty-${index}`}
name={<h4 className="truncate text-sm font-medium text-muted-foreground">No Data</h4>}
successRequests="-"
totalTokens="-"
totalCost="-"
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
))}
</div>
)}
</Panel>
{/* DAU/WAU/MAU vs Per User Usage. Every view stays mounted so switching does not reset its state. */}
<Panel
icon={Users}
title={activityView === "active-users" ? "DAU, WAU & MAU per Agent" : "Per User Usage (Last 30 Days)"}
subtitle={activityView === "active-users" ? "Active users across different time periods" : "Usage per end user"}
action={
<>
{activityView === "active-users" && (
<Segmented label="Active users period" value={period} options={PERIOD_OPTIONS} onChange={setPeriod} />
)}
<Segmented label="Activity view" value={activityView} options={VIEW_OPTIONS} onChange={setActivityView} />
</>
}
>
<div hidden={activityView !== "active-users"}>
{activeUserCharts.map((chart) => (
<div key={chart.value} hidden={period !== chart.value}>
<h4 className="mb-2 text-xs text-muted-foreground">{chart.title}</h4>
<ActiveUsersChart chart={chart} />
</div>
))}
</div>
<div hidden={activityView !== "per-user"}>
<PerUserUsage
accessToken={accessToken}
selectedTags={selectedTags}
formatAbbreviatedNumber={formatAbbreviatedNumber}
/>
</div>
</Panel>
</div>
);
};
function SummaryCell({
name,
successRequests,
totalTokens,
totalCost,
}: {
name: React.ReactNode;
successRequests: string;
totalTokens: string;
totalCost: string;
}) {
const rows = [
{ label: "Success Requests", value: successRequests },
{ label: "Total Tokens", value: totalTokens },
{ label: "Total Cost", value: totalCost },
];
return (
<div className="min-w-0 border-b px-4 py-3.5 last:border-b-0 lg:border-b-0">
{name}
<dl className="mt-3 grid gap-1.5">
{rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-3">
<dt className="text-xs text-muted-foreground">{row.label}</dt>
<dd className="text-sm font-semibold tracking-tight tabular-nums text-foreground">{row.value}</dd>
</div>
))}
</dl>
</div>
);
}
export default UserAgentActivity;