mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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:
parent
0e076008e1
commit
5410752f43
39 changed files with 3549 additions and 2077 deletions
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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") ?? "";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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. "Which model costs me the most?"</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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
};
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "{trimmedQuery}" 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 & 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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue