refactor(ui): migrate usage to shadcn

Replaces antd and Tremor markup on the usage route with the installed shadcn
base-vega primitives. Behaviour is unchanged: the route's characterisation
tests were rewritten to role and text queries in the previous commit, proven
green against the antd components, and pass through this commit unedited.

Tremor tab panels stayed mounted once rendered, so every migrated TabsContent
and the collapsible model sections in activity_metrics carry keepMounted to
keep view-mode and expansion state alive across tab switches.
This commit is contained in:
Yuneng Jiang 2026-08-13 15:02:40 -07:00
parent 28032eb4c9
commit b69177712e
No known key found for this signature in database
16 changed files with 1454 additions and 1335 deletions

View file

@ -1658,25 +1658,12 @@
"src/app/(dashboard)/usage/_components/components/EndpointUsage/components/EndpointUsageTable.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.tsx": {
"no-restricted-imports": {
"count": 1
}
@ -1685,9 +1672,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
@ -1703,7 +1687,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/purity": {
"count": 1
@ -1712,14 +1696,6 @@
"count": 3
}
},
"src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
"react-hooks/refs": {
"count": 1
@ -1982,29 +1958,14 @@
"count": 1
}
},
"src/components/EntityUsageExport/EntityUsageExportModal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/ExportFormatSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/ExportSummary.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/ExportTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/UsageExportHeader.tsx": {
"no-restricted-imports": {
"count": 3
"count": 1
}
},
"src/components/EntityUsageExport/types.ts": {
@ -2289,9 +2250,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/add_model/AdaptiveRoutingConfig.tsx": {
@ -2794,9 +2752,6 @@
"src/components/common_components/team_multi_select.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/common_components/user_search_modal.tsx": {
@ -3171,9 +3126,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -3714,7 +3666,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 3
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1

View file

@ -1,6 +1,6 @@
import React from "react";
import { Progress } from "antd";
import type { ColumnDef } from "@tanstack/react-table";
import { Meter, MeterIndicator, MeterTrack } from "@/components/ui/meter";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { MetricWithMetadata } from "@/components/UsagePage/types";
@ -53,19 +53,15 @@ const EndpointUsageTable: React.FC<EndpointUsageTableProps> = ({ endpointData })
const failurePercentage = record.api_requests > 0 ? (record.failed_requests / record.api_requests) * 100 : 0;
const totalPercentage = successPercentage + failurePercentage;
const strokeColorConfig: Record<string, string> = {
"0%": "#22c55e",
};
if (successPercentage > 0 && successPercentage < 100) {
strokeColorConfig[`${successPercentage}%`] = "#22c55e";
strokeColorConfig[`${successPercentage + 0.01}%`] = "#ef4444";
}
strokeColorConfig["100%"] = failurePercentage > 0 ? "#ef4444" : "#22c55e";
return (
<div className="flex items-center space-x-3">
<div className="flex-1 relative">
<Progress percent={totalPercentage} size="small" strokeColor={strokeColorConfig} showInfo={false} />
{/* The failed share is the track showing through behind the successful share. */}
<Meter value={successPercentage} max={totalPercentage || 100} aria-label="Successful requests">
<MeterTrack className={failurePercentage > 0 ? "bg-red-500" : undefined}>
<MeterIndicator className="bg-green-500" />
</MeterTrack>
</Meter>
</div>
<div className="flex items-center space-x-2 text-sm min-w-[100px]">
<span className="text-green-600 font-medium">{record.successful_requests.toLocaleString()}</span>

View file

@ -14,23 +14,13 @@ 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 {
Card,
Col,
DateRangePickerValue,
Grid,
Subtitle,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Text,
Title,
} from "@tremor/react";
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
import type { DateRangePickerValue } from "@tremor/react";
import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Alert, Button, Tooltip } from "antd";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { type ReactNode, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
@ -341,23 +331,29 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
[],
);
const chev = "text-gray-400 text-xs";
const expandIcon = showCostBreakdown ? <DownOutlined className={chev} /> : <RightOutlined className={chev} />;
const infoIcon = <InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />;
const chev = "size-3 text-gray-400";
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
<Card
<ShadcnCard
key={title}
className={expandable ? "cursor-pointer hover:bg-gray-50 transition-colors" : undefined}
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
>
<div className="flex items-center gap-2">
<Title>{title}</Title>
{tooltip ? <Tooltip title={tooltip}>{infoIcon}</Tooltip> : null}
{expandable ? expandIcon : null}
</div>
<Text className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</Text>
</Card>
<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-gray-400 hover:text-gray-600" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
);
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];
@ -366,18 +362,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
const costPanel = (
<Grid numItems={2} className="gap-2 w-full">
<Col numColSpan={2}>
<Card>
<Title>{capitalizedEntityLabel} Spend Overview</Title>
<Grid numItems={5} className="gap-4 mt-4">
{summaryTiles.map(renderSummaryTile)}
</Grid>
</Card>
</Col>
<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>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<div className="col-span-2">
<ShadcnCard>
<CardHeader>
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
@ -451,15 +447,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
/>
</CardContent>
</ShadcnCard>
</Col>
</div>
{/* Entity Breakdown Section */}
<Col numColSpan={2}>
<Card>
<div className="flex flex-col space-y-4">
<div className="col-span-2">
<ShadcnCard>
<CardContent className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<Title>Spend Per {capitalizedEntityLabel}</Title>
<Subtitle className="text-xs">Showing Top 5 by Spend</Subtitle>
<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-gray-500">
<span>Get Started by Tracking cost per {capitalizedEntityLabel} </span>
<a
@ -470,8 +466,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</a>
</div>
</div>
<Grid numItems={2} className="gap-6">
<Col numColSpan={1}>
<div className="grid grid-cols-2 gap-6">
<div>
<BarChart
className="mt-4 h-52"
data={getProcessedEntityBreakdownForChart()}
@ -499,8 +495,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
);
}}
/>
</Col>
<Col numColSpan={1}>
</div>
<div>
<DataTable
columns={entityBreakdownColumns}
data={getEntityBreakdown().filter((entity) => entity.metrics.spend > 0)}
@ -509,61 +505,69 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
noDataMessage={`No ${entityType} spend data`}
size="compact"
/>
</Col>
</Grid>
</div>
</Card>
</Col>
</div>
</div>
</CardContent>
</ShadcnCard>
</div>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card>
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={getTopAPIKeys(spendData.results, topKeysLimit)}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</Col>
<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 */}
<Col numColSpan={1}>
<Card>
<div className="flex justify-between items-center">
<Title>{entityType === "agent" ? "Top Agents" : modelViewTitle}</Title>
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<TopModelView
topModels={getTopModels(spendData.results, modelBreakdownKey, topModelsLimit)}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</Card>
</Col>
<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>
{showAgentBreakdown && (
<Col numColSpan={2}>
<Card>
<Title>Top Agents Driving Spend</Title>
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
</Card>
</Col>
<div className="col-span-2">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Agents Driving Spend</h3>
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
</CardContent>
</ShadcnCard>
</div>
)}
{/* Spend by Provider */}
<Col numColSpan={2}>
<Card>
<div className="flex flex-col space-y-4">
<Title>Provider Usage</Title>
<Grid numItems={2}>
<Col numColSpan={1}>
<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}
@ -575,8 +579,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
startAngle={90}
endAngle={-270}
/>
</Col>
<Col numColSpan={1}>
</div>
<div>
<DataTable
columns={providerSpendColumns}
data={providerSpend}
@ -584,12 +588,12 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
noDataMessage="No provider usage data"
size="compact"
/>
</Col>
</Grid>
</div>
</Card>
</Col>
</Grid>
</div>
</div>
</CardContent>
</ShadcnCard>
</div>
</div>
);
const tabs: readonly { key: string; label: string; content: ReactNode }[] = [
@ -620,80 +624,60 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
return (
<div style={{ width: "100%" }} className="relative">
{isFetchingMore && (
<Alert
banner
type="warning"
className="mb-2"
message={
<div className="flex items-center justify-between">
<span>
<LoadingOutlined spin className="mr-2" />
Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
update periodically as data loads. Moving off of this page will stop and reset this. To continue using
the UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExportOutlined />
</a>
.
</span>
<Button type="primary" danger onClick={cancel}>
Stop
</Button>
</div>
}
/>
<Alert variant="warning" className="mb-2">
<AlertDescription className="flex items-center justify-between text-inherit">
<span>
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
update periodically as data loads. Moving off of this page will stop and reset this. To continue using the
UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
</a>
.
</span>
<Button variant="destructive" onClick={cancel}>
Stop
</Button>
</AlertDescription>
</Alert>
)}
{cancelled && (
<Alert
banner
type="info"
className="mb-2"
message={
<span>
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
</span>
}
/>
<Alert variant="info" className="mb-2">
<AlertDescription className="text-inherit">
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
{agentIsFetchingMore && showAgentBreakdown && (
<Alert
banner
type="warning"
className="mb-2"
message={
<div className="flex items-center justify-between">
<span>
<LoadingOutlined spin className="mr-2" />
Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages.
Charts will update periodically as data loads. Moving off of this page will stop and reset this. To
continue using the UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExportOutlined />
</a>
.
</span>
<Button type="primary" danger onClick={agentCancel}>
Stop
</Button>
</div>
}
/>
<Alert variant="warning" className="mb-2">
<AlertDescription className="flex items-center justify-between text-inherit">
<span>
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages.
Charts will update periodically as data loads. Moving off of this page will stop and reset this. To
continue using the UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
</a>
.
</span>
<Button variant="destructive" onClick={agentCancel}>
Stop
</Button>
</AlertDescription>
</Alert>
)}
{agentCancelled && showAgentBreakdown && (
<Alert
banner
type="info"
className="mb-2"
message={
<span>
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
</span>
}
/>
<Alert variant="info" className="mb-2">
<AlertDescription className="text-inherit">
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
{entityType === "team" && (
<div className="mb-4">
<Text className="mb-2">Filter by team</Text>
<p className="mb-2 text-sm text-foreground">Filter by team</p>
<TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />
</div>
)}
@ -710,18 +694,20 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
filterMode={entityType === "user" ? "single" : "multiple"}
teams={teams || []}
/>
<TabGroup>
<TabList variant="solid" className="mt-1">
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">
{tabs.map(({ key, label }) => (
<Tab key={key}>{label}</Tab>
<TabsTrigger key={key} value={key} className="flex-none px-3">
{label}
</TabsTrigger>
))}
</TabList>
<TabPanels>
{tabs.map(({ key, content }) => (
<TabPanel key={key}>{content}</TabPanel>
))}
</TabPanels>
</TabGroup>
</TabsList>
{tabs.map(({ key, content }) => (
<TabsContent key={key} value={key} keepMounted>
{content}
</TabsContent>
))}
</Tabs>
</div>
);
};

View file

@ -2,10 +2,11 @@ import { DonutChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Info } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Card, Col, Grid, Switch, Title } from "@tremor/react";
import { Tooltip } from "antd";
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 { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
import { ChartLoader } from "@/components/shared/chart_loader";
@ -85,29 +86,30 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
return (
<Card className="h-full">
<div className="flex justify-between items-center mb-4">
<Title>Spend by Provider</Title>
<div className="flex items-center gap-4">
<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-gray-700">Show Zero Spend</label>
<Switch checked={includeZeroSpend} onChange={setIncludeZeroSpend} />
<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-gray-700">Show Unknown</label>
<Tooltip title="Requests that failed to route to a provider">
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-gray-400 hover:text-gray-600" />} />
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
</Tooltip>
</div>
<Switch checked={includeUnknown} onChange={setIncludeUnknown} />
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
</div>
</div>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<Grid numItems={2}>
<Col numColSpan={1}>
</CardAction>
</CardHeader>
<CardContent>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="grid grid-cols-2">
<DonutChart
className="mt-4 h-40"
data={filteredProviderSpend}
@ -119,8 +121,6 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
startAngle={90}
endAngle={-270}
/>
</Col>
<Col numColSpan={1}>
<DataTable
columns={columns}
data={filteredProviderSpend}
@ -128,9 +128,9 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
noDataMessage="No provider usage data"
size="compact"
/>
</Col>
</Grid>
)}
</div>
)}
</CardContent>
</Card>
);
};

View file

@ -1,7 +1,7 @@
import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import { Segmented } from "antd";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useState } from "react";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -19,6 +19,8 @@ interface TopModelViewProps {
setTopModelsLimit: (limit: number) => void;
}
export const TOP_MODEL_LIMITS = [5, 10, 25, 50];
export default function TopModelView({ topModels, topModelsLimit, setTopModelsLimit }: TopModelViewProps) {
const [modelViewMode, setModelViewMode] = useState<"chart" | "table">("table");
@ -58,30 +60,25 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi
return (
<>
<div className="mb-4 flex justify-between items-center">
<Segmented
options={[
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
]}
value={topModelsLimit}
onChange={(value) => setTopModelsLimit(value as number)}
/>
<div className="flex space-x-2">
<button
onClick={() => setModelViewMode("table")}
className={`px-3 py-1 text-sm rounded-md ${modelViewMode === "table" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
>
Table View
</button>
<button
onClick={() => setModelViewMode("chart")}
className={`px-3 py-1 text-sm rounded-md ${modelViewMode === "chart" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
>
Chart View
</button>
</div>
<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>
{modelViewMode === "chart" ? (
<div className="relative max-h-[600px] overflow-y-auto">

View file

@ -1,10 +1,18 @@
import React, { useEffect, useRef, useState } from "react";
import { Button, Select, Input, Spin } from "antd";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "@/components/networking";
const { TextArea } = Input;
interface ToolCallStep {
tool_name: string;
tool_label: string;
@ -41,7 +49,7 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-gray-100 border border-gray-200 text-xs">
<span className="shrink-0 mt-0.5">
{step.status === "running" ? (
<Spin size="small" />
<UiLoadingSpinner className="size-3.5" />
) : step.status === "error" ? (
<span className="text-red-500">✗</span>
) : (
@ -259,18 +267,29 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
{/* Model selector */}
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
<Select
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
value={selectedModel}
onChange={(value) => setSelectedModel(value)}
loading={isLoadingModels}
showSearch
allowClear
size="small"
className="w-full"
options={availableModels.map((m) => ({ label: m, value: m }))}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
/>
<Combobox
items={availableModels}
value={selectedModel ?? null}
onValueChange={(value: string | null) => setSelectedModel(value ?? undefined)}
>
<ComboboxInput
className="w-full"
placeholder="Select a model (optional, defaults to gpt-4o-mini)"
aria-label="Select a model (optional, defaults to gpt-4o-mini)"
aria-busy={isLoadingModels}
showClear={selectedModel !== undefined}
/>
<ComboboxContent>
<ComboboxEmpty>{isLoadingModels ? "Loading models…" : "No models found"}</ComboboxEmpty>
<ComboboxList>
{(model: string) => (
<ComboboxItem key={model} value={model}>
{model}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{/* Chat messages */}
@ -329,7 +348,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
{/* Status / spinner */}
{isLoading && !streamingContent && (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
<Spin size="small" />
<UiLoadingSpinner className="size-3.5" />
<span className="italic">{statusMessage || "Thinking..."}</span>
</div>
)}
@ -347,16 +366,17 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({ open, onClose, acce
{/* Input area */}
<div className="px-4 py-3 border-t border-gray-200 bg-white shrink-0">
<div className="flex gap-2">
<TextArea
<Textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your usage..."
autoSize={{ minRows: 1, maxRows: 3 }}
className="flex-1"
rows={1}
className="flex-1 min-h-9 max-h-24"
disabled={isLoading}
/>
<Button type="primary" onClick={handleSend} disabled={!inputText.trim() || isLoading} loading={isLoading}>
<Button onClick={handleSend} disabled={!inputText.trim() || isLoading}>
{isLoading && <UiLoadingSpinner className="size-4" />}
Send
</Button>
</div>

View file

@ -6,27 +6,17 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import {
Card,
Col,
DateRangePickerValue,
Grid,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Text,
Title,
} from "@tremor/react";
import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd";
import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
import { ChevronDown, ChevronRight, Download, ExternalLink, Info, Loader2, Sparkles, X } from "lucide-react";
import type { DateRangePickerValue } from "@tremor/react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BarChart } from "@/components/shared/charts";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect";
import { Button } from "@/components/ui/button";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
@ -68,6 +58,7 @@ 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 from "@/components/UsagePage/components/EntityUsage/TopKeyView";
import UsageAIChatPanel from "./UsageAIChatPanel";
import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect";
@ -113,11 +104,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage");
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
// Debounced search for user selector
const [userSearchInput, setUserSearchInput] = useState("");
const [debouncedUserSearch, setDebouncedUserSearch] = useDebouncedState("", {
wait: DEBOUNCE_WAIT_MS,
});
// The selector debounces its own input, so this holds the already-settled query.
const [debouncedUserSearch, setDebouncedUserSearch] = useState("");
const {
data: usersInfiniteData,
@ -148,19 +136,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
return result;
}, [usersInfiniteData]);
const handleUserSearchChange = (value: string) => {
setUserSearchInput(value);
setDebouncedUserSearch(value);
};
const handleUserPopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) {
fetchNextUsersPage();
}
};
// For admins: null means global view (all users), a string means filter by that user
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState<string | null>(isAdmin ? null : userID || null);
@ -522,352 +497,361 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{paginatedResult.isFetchingMore && (
<Alert
banner
type="warning"
className="mb-2"
message={
<div className="flex items-center justify-between">
<span>
<LoadingOutlined spin className="mr-2" />
Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "}
{paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving
off of this page will stop and reset this. To continue using the UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExportOutlined />
</a>
.
</span>
<Button type="primary" danger onClick={paginatedResult.cancel}>
Stop
</Button>
</div>
}
/>
<Alert variant="warning" className="mb-2">
<AlertDescription className="flex items-center justify-between text-inherit">
<span>
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "}
{paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving off
of this page will stop and reset this. To continue using the UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
</a>
.
</span>
<Button variant="destructive" onClick={paginatedResult.cancel}>
Stop
</Button>
</AlertDescription>
</Alert>
)}
{paginatedResult.cancelled && (
<Alert
banner
type="info"
className="mb-2"
message={
<span>
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}{" "}
pages loaded)
</span>
}
/>
<Alert variant="info" className="mb-2">
<AlertDescription className="text-inherit">
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages
loaded)
</AlertDescription>
</Alert>
)}
{/* Your Usage / Global Usage Panel */}
{(usageView === "global" || usageView === "my-usage") && (
<>
{isAdmin && usageView === "global" && (
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<Select
showSearch
allowClear
style={{ width: "100%" }}
placeholder="Select user to filter..."
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined spin /> : "No users found"}
<p className="mb-2 text-sm text-foreground">Filter by user</p>
<PaginatedSearchSelect
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
value={selectedUserId ?? undefined}
onValueChange={(value) => setSelectedUserId(value === "" ? null : value)}
onSearchChange={setDebouncedUserSearch}
onLoadMore={fetchNextUsersPage}
hasNextPage={hasNextUsersPage}
isLoading={isLoadingUsers}
isFetchingNextPage={isFetchingNextUsersPage}
placeholder="Select user to filter..."
emptyText="No users found"
/>
</div>
)}
<TabGroup>
<Tabs defaultValue="cost">
<div className="flex justify-between items-center">
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>Model Activity</Tab>
<Tab>Key Activity</Tab>
<Tab>MCP Server Activity</Tab>
<Tab>Endpoint Activity</Tab>
</TabList>
<TabsList className="mt-1">
<TabsTrigger value="cost" className="flex-none px-3">
Cost
</TabsTrigger>
<TabsTrigger value="models" className="flex-none px-3">
Model Activity
</TabsTrigger>
<TabsTrigger value="keys" className="flex-none px-3">
Key Activity
</TabsTrigger>
<TabsTrigger value="mcp" className="flex-none px-3">
MCP Server Activity
</TabsTrigger>
<TabsTrigger value="endpoints" className="flex-none px-3">
Endpoint Activity
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<Button
onClick={() => setIsAiChatOpen(true)}
icon={
<svg className="w-4 h-4" 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>
}
>
<Button variant="outline" onClick={() => setIsAiChatOpen(true)}>
<Sparkles />
Ask AI
</Button>
<Button
onClick={() => setIsGlobalExportModalOpen(true)}
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
}
>
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
<Download />
Export Data
</Button>
</div>
</div>
<TabPanels>
{/* Cost Panel */}
<TabPanel>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<div className="flex items-center gap-4 mt-2 mb-2">
<Text className="text-tremor-default text-tremor-content dark:text-dark-tremor-content text-lg">
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",
})}
</>
)}
</Text>
</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>
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
/>
</Col>
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
/>
</div>
<Col numColSpan={2}>
<Card>
<Title>Usage Metrics</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<div className="flex items-center gap-2">
<Title>Successful Requests</Title>
{gatewayActivity && (
<Tooltip title="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.">
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
<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>
<p className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
</p>
</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-gray-400 hover:text-gray-600" />}
/>
<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.
*/}
<p className="text-2xl font-bold mt-2 text-green-600">
{(
gatewayActivity?.total_successful_requests ??
userSpendData.metadata?.total_successful_requests
)?.toLocaleString() || 0}
</p>
</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-gray-400 hover:text-gray-600" />}
/>
<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>
{/*
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.
*/}
<Text className="text-2xl font-bold mt-2 text-green-600">
{(
gatewayActivity?.total_successful_requests ??
userSpendData.metadata?.total_successful_requests
)?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<div className="flex items-center gap-2">
<Title>Failed Requests</Title>
<Tooltip
title={
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."
}
>
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
</Tooltip>
</div>
{/* Same source as Successful Requests: the two must agree, or the
tile disagrees with the endpoint breakdown chart below it. */}
<Text className="text-2xl font-bold mt-2 text-red-600">
{(
gatewayActivity?.total_failed_requests ??
userSpendData.metadata?.total_failed_requests
)?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Average Cost per Request</Title>
<Text className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</Text>
</Card>
<Card
</div>
{/* Same source as Successful Requests: the two must agree, or the
tile disagrees with the endpoint breakdown chart below it. */}
<p className="text-2xl font-bold mt-2 text-red-600">
{(
gatewayActivity?.total_failed_requests ??
userSpendData.metadata?.total_failed_requests
)?.toLocaleString() || 0}
</p>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Average Cost per Request</h3>
<p className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</p>
</CardContent>
</ShadcnCard>
<ShadcnCard
className="cursor-pointer hover:bg-gray-50 transition-colors"
onClick={() => setShowTokenBreakdown(!showTokenBreakdown)}
>
<div className="flex items-center gap-2">
<Title>Total Tokens</Title>
{showTokenBreakdown ? (
<DownOutlined className="text-gray-400 text-xs" />
) : (
<RightOutlined className="text-gray-400 text-xs" />
)}
</div>
<Text className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</Text>
</Card>
</Grid>
<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-gray-400" />
) : (
<ChevronRight className="size-3 text-gray-400" />
)}
</div>
<p className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</p>
</CardContent>
</ShadcnCard>
</div>
{showTokenBreakdown && (
<Grid numItems={4} className="gap-4 mt-4">
<Card>
<Title>Input Tokens</Title>
<Text className="text-2xl font-bold mt-2 text-blue-600">
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
</Text>
</Card>
<Card>
<Title>Output Tokens</Title>
<Text className="text-2xl font-bold mt-2 text-cyan-600">
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Cache Read Tokens</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<Title>Cache Write Tokens</Title>
<Text className="text-2xl font-bold mt-2 text-purple-600">
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
</Text>
</Card>
</Grid>
<div className="grid grid-cols-4 gap-4 mt-4">
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Input Tokens</h3>
<p className="text-2xl font-bold mt-2 text-blue-600">
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
</p>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Output Tokens</h3>
<p className="text-2xl font-bold mt-2 text-cyan-600">
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
</p>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Read Tokens</h3>
<p className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
</p>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Write Tokens</h3>
<p className="text-2xl font-bold mt-2 text-purple-600">
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
</p>
</CardContent>
</ShadcnCard>
</div>
)}
</Card>
</Col>
</CardContent>
</ShadcnCard>
</div>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<ShadcnCard>
{/* 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-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">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">Daily Spend</CardTitle>
<CardTitle className="text-base font-semibold">
Gateway Requests by Endpoint
<Tooltip>
<TooltipTrigger
render={<Info className="ml-2 inline size-4 text-gray-400 hover:text-gray-600" />}
/>
<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>
{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-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-gray-600">Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">Tokens: {data.metrics.total_tokens}</p>
</div>
);
}}
/>
)}
<BarChart
data={gatewayRequestsByRoute}
index="route"
categories={["successful_requests", "failed_requests"]}
colors={["green", "red"]}
stack={true}
yAxisWidth={100}
valueFormatter={(value: number) => value.toLocaleString()}
/>
</CardContent>
</ShadcnCard>
</Col>
{/* Gateway Requests by Endpoint (SGR) */}
{gatewayActivity && gatewayActivity.by_route.length > 0 && (
<Col numColSpan={2}>
<ShadcnCard data-testid="gateway-requests-by-endpoint">
<CardHeader>
<CardTitle className="text-base font-semibold">
Gateway Requests by Endpoint
<Tooltip title="Counted by the gateway middleware as each request is answered. Covers LLM, MCP and A2A endpoints across the whole deployment.">
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
</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>
</Col>
)}
{/* Top API Keys */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top Virtual Keys</Title>
</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}
/>
</Card>
</Col>
</CardContent>
</ShadcnCard>
</div>
{/* Top Models */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
{/* 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">
<Segmented
options={[
{ label: "5", value: 5 },
{ label: "10", value: 10 },
{ label: "25", value: 25 },
{ label: "50", value: 50 },
]}
value={topModelsLimit}
onChange={(value) => setTopModelsLimit(value as number)}
/>
<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 ? (
@ -915,40 +899,40 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
})()}
</div>
)}
</Card>
</Col>
{/* Spend by Provider */}
<Col numColSpan={2}>
<SpendByProvider
loading={loading}
isDateChanging={isDateChanging}
providerSpend={providerSpend}
/>
</Col>
{/* Usage Metrics */}
</Grid>
</TabPanel>
{/* Activity Panel */}
<TabPanel>
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</CardContent>
</ShadcnCard>
</div>
<ActivityMetrics modelMetrics={modelMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={keyMetrics} />
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={mcpServerMetrics} />
</TabPanel>
<TabPanel>
<EndpointUsage userSpendData={userSpendData} />
</TabPanel>
</TabPanels>
</TabGroup>
{/* Spend by Provider */}
<div className="col-span-2">
<SpendByProvider
loading={loading}
isDateChanging={isDateChanging}
providerSpend={providerSpend}
/>
</div>
{/* Usage Metrics */}
</div>
</TabsContent>
{/* Activity Panel */}
<TabsContent value="models" keepMounted>
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} />
</TabsContent>
<TabsContent value="keys" keepMounted>
<ActivityMetrics modelMetrics={keyMetrics} />
</TabsContent>
<TabsContent value="mcp" keepMounted>
<ActivityMetrics modelMetrics={mcpServerMetrics} />
</TabsContent>
<TabsContent value="endpoints" keepMounted>
<EndpointUsage userSpendData={userSpendData} />
</TabsContent>
</Tabs>
</>
)}
{/* Organization Usage Panel */}
@ -1009,21 +993,24 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
{usageView === "tag" && (
<>
{showCredentialBanner && (
<Alert
banner
type="info"
message="Reusable credentials are automatically tracked as tags"
description={
<Typography.Text>
When a reusable credential is used, it will appear as a tag prefixed with{" "}
<Typography.Text code>Credential: </Typography.Text>
in this view.
</Typography.Text>
}
closable
onClose={() => setShowCredentialBanner(false)}
className="mb-5"
/>
<Alert variant="info" className="mb-5">
<AlertTitle>Reusable credentials are automatically tracked as tags</AlertTitle>
<AlertDescription className="text-inherit">
When a reusable credential is used, it will appear as a tag prefixed with{" "}
<code className="rounded bg-black/5 px-1 py-0.5 font-mono text-xs">Credential: </code>
in this view.
</AlertDescription>
<AlertAction>
<Button
variant="ghost"
size="icon-xs"
aria-label="Close"
onClick={() => setShowCredentialBanner(false)}
>
<X />
</Button>
</AlertAction>
</Alert>
)}
<EntityUsage
accessToken={accessToken}

View file

@ -1,16 +1,7 @@
import {
BankOutlined,
BarChartOutlined,
GlobalOutlined,
LineChartOutlined,
RobotOutlined,
ShoppingCartOutlined,
TagsOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Badge, Select } from "antd";
import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react";
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { hasCapability, type Capability } from "@/utils/capabilities";
import { all_admin_roles } from "@/utils/roles";
export type UsageOption =
@ -54,61 +45,61 @@ const OPTIONS: OptionConfig[] = [
description: "View usage across all resources",
descriptionForAdmin: "View usage across all resources",
descriptionForNonAdmin: "View your usage",
icon: <GlobalOutlined style={{ fontSize: "16px" }} />,
icon: <Globe className="size-4" />,
},
{
value: "my-usage",
label: "Your Usage",
description: "View your own usage",
icon: <UserOutlined style={{ fontSize: "16px" }} />,
icon: <User className="size-4" />,
adminOnly: true,
},
{
value: "organization",
label: "Organization Usage",
description: "View usage across all organizations",
icon: <BankOutlined style={{ fontSize: "16px" }} />,
icon: <Building2 className="size-4" />,
capability: "viewOrganizationUsage",
},
{
value: "team",
label: "Team Usage",
description: "View usage by team",
icon: <TeamOutlined style={{ fontSize: "16px" }} />,
icon: <Users className="size-4" />,
},
{
value: "customer",
label: "Customer Usage",
description: "View usage by customer accounts",
icon: <ShoppingCartOutlined style={{ fontSize: "16px" }} />,
icon: <ShoppingCart className="size-4" />,
adminOnly: true,
},
{
value: "tag",
label: "Tag Usage",
description: "View usage grouped by tags",
icon: <TagsOutlined style={{ fontSize: "16px" }} />,
icon: <Tags className="size-4" />,
adminOnly: true,
},
{
value: "agent",
label: "Agent Usage (A2A)",
description: "View usage by AI agents",
icon: <RobotOutlined style={{ fontSize: "16px" }} />,
icon: <Bot className="size-4" />,
capability: "viewAgentUsage",
},
{
value: "user",
label: "User Usage",
description: "View usage by individual users",
icon: <UserOutlined style={{ fontSize: "16px" }} />,
icon: <User className="size-4" />,
adminOnly: true,
},
{
value: "user-agent-activity",
label: "User Agent Activity",
description: "View detailed user agent activity logs",
icon: <LineChartOutlined style={{ fontSize: "16px" }} />,
icon: <LineChart className="size-4" />,
adminOnly: true,
},
];
@ -153,12 +144,13 @@ 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">
<BarChartOutlined style={{ fontSize: "32px" }} />
<BarChart3 className="size-8" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 mb-0.5 leading-tight">{title}</h3>
@ -168,42 +160,35 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
<div className="shrink-0">
<Select
value={value}
onChange={onChange}
className="w-54 sm:w-64 md:w-72"
size="large"
options={filteredOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
optionRender={(option) => {
const opt = filteredOptions.find((o) => o.value === option.value);
if (!opt) return option.label;
return (
<div className="flex items-center gap-2 py-1">
<div className="shrink-0 mt-0.5">{opt.icon}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{opt.label}</div>
<div className="text-xs text-gray-600 mt-0.5">{opt.description}</div>
</div>
{opt.badgeText && (
<div className="items-center">
<Badge color="blue" count={opt.badgeText} />
</div>
)}
</div>
);
onValueChange={(next: UsageOption | null) => {
if (next) onChange(next);
}}
labelRender={(props) => {
const opt = filteredOptions.find((o) => o.value === props.value);
if (!opt) return props.label;
return (
<div className="flex items-center gap-2">
<div>{opt.icon}</div>
<span className="text-sm">{opt.label}</span>
</div>
);
}}
/>
>
<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-gray-900">{option.label}</span>
<span className="block text-xs text-gray-600 mt-0.5">{option.description}</span>
</span>
{option.badgeText && <Badge>{option.badgeText}</Badge>}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>

View file

@ -1,7 +1,10 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { createTeamAliasMap } from "@/utils/teamUtils";
import { Button, Modal, Skeleton } from "antd";
import { Loader2 } from "lucide-react";
import React, { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import NotificationsManager from "../molecules/notifications_manager";
import ExportFormatSelector from "./ExportFormatSelector";
import ExportSummary from "./ExportSummary";
@ -49,45 +52,51 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
};
return (
<Modal
title={<span className="text-base font-semibold">{modalTitle}</span>}
<Dialog
open={isOpen}
onCancel={onClose}
footer={null}
width={480}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<div className="space-y-5 py-2">
{isLoadingTeams ? (
<Skeleton active />
) : (
<>
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
</>
)}
{isLoadingTeams ? (
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="text-base font-semibold">{modalTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{isLoadingTeams ? (
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<>
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
</>
)}
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Skeleton.Button active />
<Skeleton.Button active />
{isLoadingTeams ? (
<>
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-28" />
</>
) : (
<>
<Button variant="outline" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button onClick={() => handleExport()} disabled={isExporting}>
{isExporting && <Loader2 className="animate-spin" />}
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</>
)}
</div>
) : (
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button variant="outlined" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button
onClick={() => handleExport()}
loading={isExporting || isLoadingTeams}
disabled={isExporting || isLoadingTeams}
type="primary"
>
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
)}
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -1,5 +1,5 @@
import React from "react";
import { Select } from "antd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { ExportFormat } from "./types";
interface ExportFormatSelectorProps {
@ -7,25 +7,27 @@ interface ExportFormatSelectorProps {
onChange: (value: ExportFormat) => void;
}
const FORMAT_LABELS: Record<ExportFormat, string> = {
csv: "CSV (Excel, Google Sheets)",
json: "JSON (includes metadata)",
};
const ExportFormatSelector: React.FC<ExportFormatSelectorProps> = ({ value, onChange }) => {
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Format</label>
<Select
value={value}
onChange={onChange}
className="w-full"
options={[
{
value: "csv",
label: "CSV (Excel, Google Sheets)",
},
{
value: "json",
label: "JSON (includes metadata)",
},
]}
/>
<Select value={value} onValueChange={(next: ExportFormat | null) => next && onChange(next)}>
<SelectTrigger className="w-full">
<SelectValue>{FORMAT_LABELS[value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(FORMAT_LABELS) as ExportFormat[]).map((format) => (
<SelectItem key={format} value={format}>
{FORMAT_LABELS[format]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View file

@ -1,5 +1,5 @@
import React from "react";
import { Radio } from "antd";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ExportScope, EntityType } from "./types";
interface ExportTypeSelectorProps {
@ -9,36 +9,41 @@ interface ExportTypeSelectorProps {
}
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
const scopes: { value: ExportScope; title: string; description: string }[] = [
{
value: "daily",
title: `Day-by-day breakdown by ${entityType}`,
description: `Daily metrics for each ${entityType}`,
},
{
value: "daily_with_keys",
title: `Day-by-day breakdown by ${entityType} and key`,
description: `Daily metrics for each ${entityType}, split by API key`,
},
{
value: "daily_with_models",
title: `Day-by-day by ${entityType} and model`,
description: "Daily metrics split by model",
},
];
return (
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Export type</label>
<Radio.Group value={value} onChange={(e) => onChange(e.target.value)} className="w-full">
<div className="space-y-2">
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily" className="mt-0.5" />
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportScope)} className="gap-2">
{scopes.map((scope) => (
<label
key={scope.value}
className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
>
<RadioGroupItem value={scope.value} className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown by {entityType}</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}</div>
<div className="font-medium text-sm">{scope.title}</div>
<div className="text-xs text-gray-500 mt-0.5">{scope.description}</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_keys" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day breakdown by {entityType} and key</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics for each {entityType}, split by API key</div>
</div>
</label>
<label className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
<Radio value="daily_with_models" className="mt-0.5" />
<div className="ml-3 flex-1">
<div className="font-medium text-sm">Day-by-day by {entityType} and model</div>
<div className="text-xs text-gray-500 mt-0.5">Daily metrics split by model</div>
</div>
</label>
</div>
</Radio.Group>
))}
</RadioGroup>
</div>
);
};

View file

@ -1,7 +1,20 @@
import type { DateRangePickerValue } from "@tremor/react";
import { Button, Text } from "@tremor/react";
import { Select } from "antd";
import { Download } from "lucide-react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
@ -40,13 +53,22 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
}) => {
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// Determine grid layout based on what's visible
const getGridCols = () => {
const hasFilters = showFilters && filterOptions.length > 0;
const hasFilters = showFilters && filterOptions.length > 0;
const optionValues = filterOptions.map((option) => option.value);
const labelOf = (value: string) => filterOptions.find((option) => option.value === value)?.label ?? value;
if (hasFilters) return "grid-cols-[1fr_auto]";
return "grid-cols-[auto]";
};
const filterList = (
<ComboboxContent>
<ComboboxEmpty>No options found</ComboboxEmpty>
<ComboboxList>
{(value: string) => (
<ComboboxItem key={value} value={value}>
{labelOf(value)}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
);
return (
<>
@ -56,42 +78,58 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
* 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 ${getGridCols()} items-end gap-4`}>
{showFilters && filterOptions.length > 0 && (
<div className={`grid ${hasFilters ? "grid-cols-[1fr_auto]" : "grid-cols-[auto]"} items-end gap-4`}>
{hasFilters && (
<div>
{filterLabel && <Text className="mb-2">{filterLabel}</Text>}
<Select
mode={filterMode === "single" ? undefined : "multiple"}
style={{ width: "100%" }}
placeholder={filterPlaceholder}
value={filterMode === "single" ? selectedFilters[0] ?? undefined : selectedFilters}
onChange={(value: any) => {
if (filterMode === "single") {
onFiltersChange?.(value ? [value] : []);
} else {
onFiltersChange?.(value);
}
}}
options={filterOptions}
allowClear
/>
{filterLabel && <label className="text-sm font-medium text-gray-700 block mb-2">{filterLabel}</label>}
{filterMode === "single" ? (
<Combobox
items={optionValues}
value={selectedFilters[0] ?? null}
onValueChange={(next: string | null) => onFiltersChange?.(next ? [next] : [])}
itemToStringLabel={labelOf}
>
<ComboboxInput
className="w-full"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
showClear={selectedFilters.length > 0}
/>
{filterList}
</Combobox>
) : (
<Combobox
multiple
items={optionValues}
value={selectedFilters}
onValueChange={(next: string[]) => onFiltersChange?.(next)}
>
<ComboboxChips className="w-full">
<ComboboxValue>
{(selected: string[]) =>
selected.map((value) => (
<ComboboxChip key={value} aria-label={labelOf(value)}>
{labelOf(value)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
className="border-0 bg-transparent"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder}
/>
{selectedFilters.length > 0 && <ComboboxClear aria-label={`Clear ${filterLabel ?? "filters"}`} />}
</ComboboxChips>
{filterList}
</Combobox>
)}
</div>
)}
<div className="justify-self-end">
<Button
onClick={() => setIsExportModalOpen(true)}
icon={() => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
>
<Button onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>
</div>

View file

@ -1,9 +1,10 @@
import { AreaChart, BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
import { Card, Grid, Text, Title } from "@tremor/react";
import { Collapse } from "antd";
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import React, { useState } from "react";
import { Team } from "./key_team_helpers/key_list";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
@ -26,50 +27,65 @@ const ModelSection = ({
return (
<div className="space-y-2">
{/* Summary Cards */}
<Grid numItems={4} className="gap-4">
<div className="grid grid-cols-4 gap-4">
<Card>
<Text>Total Requests</Text>
<Title>{metrics.total_requests.toLocaleString()}</Title>
<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>
<Text>Total Successful Requests</Text>
<Title>{metrics.total_successful_requests.toLocaleString()}</Title>
<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>
<Text>Total Tokens</Text>
<Title>{metrics.total_tokens.toLocaleString()}</Title>
<Text>{Math.round(metrics.total_tokens / metrics.total_successful_requests)} avg per successful request</Text>
<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>
<Text>Total Spend</Text>
<Title>${formatNumberWithCommas(metrics.total_spend, 2)}</Title>
<Text>
${formatNumberWithCommas(metrics.total_spend / metrics.total_successful_requests, 3)} per successful request
</Text>
<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>
</Grid>
</div>
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
<Card className="mt-4">
<Title>Top Virtual Keys by Spend</Title>
<div className="mt-3">
<div className="grid grid-cols-1 gap-2">
{metrics.top_api_keys.map((keyData, index) => (
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<div>
<Text className="font-medium">{keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`}</Text>
{keyData.team_id && <Text className="text-xs text-gray-500">Team: {keyData.team_id}</Text>}
<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">
{metrics.top_api_keys.map((keyData) => (
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium">{keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`}</p>
{keyData.team_id && <p className="text-xs text-gray-500">Team: {keyData.team_id}</p>}
</div>
<div className="text-right">
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
<p className="text-xs text-gray-500">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</p>
</div>
</div>
<div className="text-right">
<Text className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</Text>
<Text className="text-xs text-gray-500">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</Text>
</div>
</div>
))}
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
@ -77,110 +93,158 @@ const ModelSection = ({
{/* Spend per day - Full width card */}
<Card className="mt-4">
<div className="flex justify-between items-center">
<Title>Spend per day</Title>
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
</div>
<BarChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.spend"]}
colors={["green"]}
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
yAxisWidth={72}
/>
</Card>
{/* Charts */}
<Grid numItems={2} className="gap-4 mt-4">
<Card>
<CardContent>
<div className="flex justify-between items-center">
<Title>Total Tokens</Title>
<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}
/>
</Card>
<Card>
<div className="flex justify-between items-center">
<Title>Requests per day</Title>
<CustomLegend categories={["metrics.api_requests"]} colors={["blue"]} />
<h3 className="text-lg font-medium text-foreground">Spend per day</h3>
<CustomLegend categories={["metrics.spend"]} colors={["green"]} />
</div>
<BarChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.api_requests"]}
colors={["blue"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
categories={["metrics.spend"]}
colors={["green"]}
valueFormatter={(value: number) => `$${formatNumberWithCommas(value, 2, true)}`}
yAxisWidth={72}
/>
</Card>
</CardContent>
</Card>
{/* Charts */}
<div className="grid grid-cols-2 gap-4 mt-4">
<Card>
<div className="flex justify-between items-center">
<Title>Success vs Failed Requests</Title>
<CustomLegend
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
/>
</div>
<AreaChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
valueFormatter={valueFormatter}
customTooltip={CustomTooltip}
showLegend={false}
/>
</Card>
{!hidePromptCachingMetrics && (
<Card>
<CardContent>
<div className="flex justify-between items-center">
<Title>Prompt Caching Metrics</Title>
<h3 className="text-lg font-medium text-foreground">Total Tokens</h3>
<CustomLegend
categories={["metrics.cache_read_input_tokens", "metrics.cache_creation_input_tokens"]}
colors={["cyan", "purple"]}
categories={["metrics.prompt_tokens", "metrics.completion_tokens", "metrics.total_tokens"]}
colors={["blue", "cyan", "indigo"]}
/>
</div>
<div className="mb-2">
<Text>Cache Read: {metrics.total_cache_read_input_tokens?.toLocaleString() || 0} tokens</Text>
<Text>Cache Creation: {metrics.total_cache_creation_input_tokens?.toLocaleString() || 0} tokens</Text>
</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"]}
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>
<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"
data={metrics.daily_data}
index="date"
categories={["metrics.successful_requests", "metrics.failed_requests"]}
colors={["green", "red"]}
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>
)}
</Grid>
</div>
</div>
);
};
// Mirrors the antd Collapse this replaced: a panel's subtree is not built until it is first
// opened, and it stays mounted afterwards so its view-mode state survives being collapsed.
const ModelCollapsible = ({
defaultOpen,
header,
children,
}: {
defaultOpen: boolean;
header: React.ReactNode;
children: React.ReactNode;
}) => {
const [open, setOpen] = useState(defaultOpen);
const [everOpened, setEverOpened] = useState(defaultOpen);
return (
<Collapsible
open={open}
onOpenChange={(next: boolean) => {
setOpen(next);
if (next) setEverOpened(true);
}}
className="border-b last:border-b-0"
>
<CollapsibleTrigger className="flex w-full items-center gap-2 px-4 py-3 text-left">
<ChevronDown className={`size-4 shrink-0 text-gray-400 transition-transform ${open ? "" : "-rotate-90"}`} />
{header}
</CollapsibleTrigger>
<CollapsibleContent keepMounted={everOpened} className="px-4 pb-4">
{children}
</CollapsibleContent>
</Collapsible>
);
};
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
if (a === "") return 1;
@ -257,78 +321,97 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
<div className="space-y-8">
{/* Global Summary */}
<div className="border rounded-lg p-4">
<Title>Overall Usage</Title>
<Grid numItems={4} className="gap-4 mb-4">
<h3 className="text-lg font-medium text-foreground">Overall Usage</h3>
<div className="grid grid-cols-4 gap-4 mb-4">
<Card>
<Text>Total Requests</Text>
<Title>{totalMetrics.total_requests.toLocaleString()}</Title>
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_requests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<Text>Total Successful Requests</Text>
<Title>{totalMetrics.total_successful_requests.toLocaleString()}</Title>
<CardContent>
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
<h3 className="text-lg font-medium text-foreground">
{totalMetrics.total_successful_requests.toLocaleString()}
</h3>
</CardContent>
</Card>
<Card>
<Text>Total Tokens</Text>
<Title>{totalMetrics.total_tokens.toLocaleString()}</Title>
<CardContent>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_tokens.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<Text>Total Spend</Text>
<Title>${formatNumberWithCommas(totalMetrics.total_spend, 2)}</Title>
<CardContent>
<p className="text-sm text-muted-foreground">Total Spend</p>
<h3 className="text-lg font-medium text-foreground">
${formatNumberWithCommas(totalMetrics.total_spend, 2)}
</h3>
</CardContent>
</Card>
</Grid>
</div>
<Grid numItems={2} className="gap-4">
<div className="grid grid-cols-2 gap-4">
<Card>
<div className="flex justify-between items-center">
<Title>Total Tokens Over Time</Title>
<CustomLegend
<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}
/>
</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>
<div className="flex justify-between items-center">
<Title>Total Requests Over Time</Title>
<CustomLegend
<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}
/>
</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>
</Grid>
</div>
</div>
{/* Individual Model Sections */}
<Collapse defaultActiveKey={modelNames[0]}>
<div className="rounded-lg border">
{modelNames.map((modelName) => (
<Collapse.Panel
<ModelCollapsible
key={modelName}
defaultOpen={modelName === modelNames[0]}
header={
<div className="flex justify-between items-center w-full">
<Title>{modelMetrics[modelName].label || "Unknown Item"}</Title>
<h3 className="text-lg font-medium text-foreground">
{modelMetrics[modelName].label || "Unknown Item"}
</h3>
<div className="flex space-x-4 text-sm text-gray-500">
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
@ -341,9 +424,9 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
</Collapse.Panel>
</ModelCollapsible>
))}
</Collapse>
</div>
</div>
);
};

View file

@ -1,13 +1,22 @@
import React, { useMemo, useState, type UIEvent } from "react";
import { Select, Typography } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import { Loader2 } from "lucide-react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { Team } from "../key_team_helpers/key_list";
const { Text } = Typography;
interface TeamMultiSelectProps {
value?: string[];
onChange?: (value: string[]) => void;
@ -27,77 +36,82 @@ const TeamMultiSelect: React.FC<TeamMultiSelectProps> = ({
pageSize = 20,
placeholder = "Search teams by alias...",
}) => {
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
wait: DEBOUNCE_WAIT_MS,
});
const [search, setSearch] = useState("");
const debouncedSetSearch = useDebouncedCallback(setSearch, { wait: DEBOUNCE_WAIT_MS });
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteTeams(
pageSize,
debouncedSearch || undefined,
search || undefined,
organizationId,
);
const teams = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
const result: Team[] = [];
for (const page of data.pages) {
for (const team of page.teams) {
if (seen.has(team.team_id)) continue;
seen.add(team.team_id);
result.push(team);
}
}
return result;
}, [data]);
const teamById = useMemo(
() =>
new Map<string, Team>(
(data?.pages ?? []).flatMap((page) => page.teams).map((team) => [team.team_id, team] as const),
),
[data],
);
const teamIds = useMemo(() => Array.from(teamById.keys()), [teamById]);
const handlePopupScroll = (e: UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const aliasOf = (teamId: string) => teamById.get(teamId)?.team_alias ?? teamId;
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (target.scrollHeight === 0) return;
const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight;
if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const handleSearch = (val: string) => {
setSearchInput(val);
setDebouncedSearch(val);
};
return (
<Select
mode="multiple"
showSearch
placeholder={placeholder}
<Combobox
multiple
items={teamIds}
value={value}
onChange={(val: string[]) => onChange?.(val)}
onValueChange={(next: string[]) => onChange?.(next)}
filter={null}
onInputValueChange={debouncedSetSearch}
disabled={disabled}
allowClear
filterOption={false}
onSearch={handleSearch}
searchValue={searchInput}
onPopupScroll={handlePopupScroll}
loading={isLoading}
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
style={{ width: "100%" }}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined spin />
</div>
)}
</>
)}
>
{teams.map((team) => (
<Select.Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias}</span> <Text type="secondary">({team.team_id})</Text>
</Select.Option>
))}
</Select>
<ComboboxChips className="w-full" aria-busy={isLoading}>
<ComboboxValue>
{(selected: string[]) =>
selected.map((teamId) => (
<ComboboxChip key={teamId} aria-label={aliasOf(teamId)}>
{aliasOf(teamId)}
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxChipsInput
className="border-0 bg-transparent"
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
/>
{value.length > 0 && <ComboboxClear aria-label="Clear all teams" disabled={disabled} />}
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>
{isLoading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : "No teams found"}
</ComboboxEmpty>
<ComboboxList onScroll={handleScroll}>
{(teamId: string) => (
<ComboboxItem key={teamId} value={teamId}>
<span className="font-medium">{aliasOf(teamId)}</span>{" "}
<span className="text-muted-foreground">({teamId})</span>
</ComboboxItem>
)}
</ComboboxList>
{isFetchingNextPage && (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</ComboboxContent>
</Combobox>
);
};

View file

@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react";
import { Title, Subtitle, Text, Button, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import type { ColumnDef } from "@tanstack/react-table";
import { BarChart } from "@/components/shared/charts";
import { DataTable } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { perUserAnalyticsCall } from "./networking";
interface PerUserMetrics {
@ -119,131 +120,133 @@ const PerUserUsage: React.FC<PerUserUsageProps> = ({ accessToken, selectedTags,
return (
<div className="mb-6">
<Title>Per User Usage</Title>
<Subtitle>Individual developer usage metrics</Subtitle>
<h3 className="text-lg font-medium text-foreground">Per User Usage</h3>
<p className="text-sm text-muted-foreground">Individual developer usage metrics</p>
<TabGroup>
<TabList className="mb-6">
<Tab>User Details</Tab>
<Tab>Usage Distribution</Tab>
</TabList>
<Tabs defaultValue="details">
<TabsList className="mb-6">
<TabsTrigger value="details" className="flex-none px-3">
User Details
</TabsTrigger>
<TabsTrigger value="distribution" className="flex-none px-3">
Usage Distribution
</TabsTrigger>
</TabsList>
<TabPanels>
{/* Tab 1: Existing User Details Table */}
<TabPanel>
<DataTable
columns={columns}
data={perUserData.results.slice(0, 10)}
getRowId={(row) => row.user_id}
noDataMessage="No per-user usage data"
size="compact"
/>
{/* Tab 1: Existing User Details Table */}
<TabsContent value="details" keepMounted>
<DataTable
columns={columns}
data={perUserData.results.slice(0, 10)}
getRowId={(row) => row.user_id}
noDataMessage="No per-user usage data"
size="compact"
/>
{perUserData.results.length > 10 && (
<div className="mt-4 flex justify-between items-center">
<Text className="text-sm text-gray-500">Showing 10 of {perUserData.total_count} results</Text>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handlePrevPage} disabled={currentPage === 1}>
Previous
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleNextPage}
disabled={currentPage >= perUserData.total_pages}
>
Next
</Button>
</div>
{perUserData.results.length > 10 && (
<div className="mt-4 flex justify-between items-center">
<p className="text-sm text-gray-500">Showing 10 of {perUserData.total_count} results</p>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={handlePrevPage} disabled={currentPage === 1}>
Previous
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleNextPage}
disabled={currentPage >= perUserData.total_pages}
>
Next
</Button>
</div>
)}
</TabPanel>
{/* Tab 2: Usage Distribution Histogram */}
<TabPanel>
<div className="mb-4">
<Title className="text-lg">User Usage Distribution</Title>
<Subtitle>Number of users by successful request frequency</Subtitle>
</div>
)}
</TabsContent>
<BarChart
data={(() => {
// Get top user agents by frequency first
const userAgentCounts = new Map<string, number>();
perUserData.results.forEach((item: PerUserMetrics) => {
const agent = item.user_agent || "Unknown";
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
});
{/* Tab 2: Usage Distribution Histogram */}
<TabsContent value="distribution" keepMounted>
<div className="mb-4">
<h4 className="text-lg font-medium text-foreground">User Usage Distribution</h4>
<p className="text-sm text-muted-foreground">Number of users by successful request frequency</p>
</div>
const topUserAgents = Array.from(userAgentCounts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_USER_AGENTS)
.map(([agent]) => agent);
<BarChart
data={(() => {
// Get top user agents by frequency first
const userAgentCounts = new Map<string, number>();
perUserData.results.forEach((item: PerUserMetrics) => {
const agent = item.user_agent || "Unknown";
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
});
// Categorize users by successful request count and user agent
const categories = {
"1-9 requests": { range: [1, 9], agents: {} as Record<string, number> },
"10-99 requests": { range: [10, 99], agents: {} as Record<string, number> },
"100-999 requests": { range: [100, 999], agents: {} as Record<string, number> },
"1K-9.9K requests": { range: [1000, 9999], agents: {} as Record<string, number> },
"10K-99.9K requests": { range: [10000, 99999], agents: {} as Record<string, number> },
"100K+ requests": { range: [100000, Infinity], agents: {} as Record<string, number> },
};
const topUserAgents = Array.from(userAgentCounts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_USER_AGENTS)
.map(([agent]) => agent);
// Count users in each category by user agent (only for top user agents)
perUserData.results.forEach((item: PerUserMetrics) => {
const successCount = item.successful_requests;
const userAgent = item.user_agent || "Unknown";
// Categorize users by successful request count and user agent
const categories = {
"1-9 requests": { range: [1, 9], agents: {} as Record<string, number> },
"10-99 requests": { range: [10, 99], agents: {} as Record<string, number> },
"100-999 requests": { range: [100, 999], agents: {} as Record<string, number> },
"1K-9.9K requests": { range: [1000, 9999], agents: {} as Record<string, number> },
"10K-99.9K requests": { range: [10000, 99999], agents: {} as Record<string, number> },
"100K+ requests": { range: [100000, Infinity], agents: {} as Record<string, number> },
};
// Only process if this is one of the top user agents
if (topUserAgents.includes(userAgent)) {
Object.entries(categories).forEach(([categoryName, category]) => {
if (successCount >= category.range[0] && successCount <= category.range[1]) {
if (!category.agents[userAgent]) {
category.agents[userAgent] = 0;
}
category.agents[userAgent]++;
// Count users in each category by user agent (only for top user agents)
perUserData.results.forEach((item: PerUserMetrics) => {
const successCount = item.successful_requests;
const userAgent = item.user_agent || "Unknown";
// Only process if this is one of the top user agents
if (topUserAgents.includes(userAgent)) {
Object.entries(categories).forEach(([categoryName, category]) => {
if (successCount >= category.range[0] && successCount <= category.range[1]) {
if (!category.agents[userAgent]) {
category.agents[userAgent] = 0;
}
});
}
});
// Convert to chart data format for stacked bar chart
return Object.entries(categories).map(([categoryName, category]) => {
const dataPoint: Record<string, any> = { category: categoryName };
// Add count for each top user agent
topUserAgents.forEach((agent) => {
dataPoint[agent] = category.agents[agent] || 0;
category.agents[userAgent]++;
}
});
}
});
return dataPoint;
});
})()}
index="category"
categories={(() => {
// Count user agents by frequency and get top ones
const userAgentCounts = new Map<string, number>();
perUserData.results.forEach((item: PerUserMetrics) => {
const agent = item.user_agent || "Unknown";
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
// Convert to chart data format for stacked bar chart
return Object.entries(categories).map(([categoryName, category]) => {
const dataPoint: Record<string, any> = { category: categoryName };
// Add count for each top user agent
topUserAgents.forEach((agent) => {
dataPoint[agent] = category.agents[agent] || 0;
});
// Sort by frequency (most common first) and limit to top MAX_USER_AGENTS
return Array.from(userAgentCounts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_USER_AGENTS)
.map(([agent]) => agent);
})()}
colors={["blue", "green", "orange", "red", "purple", "yellow", "pink", "indigo"]}
valueFormatter={(value: number) => `${value} users`}
yAxisWidth={80}
showLegend={true}
stack={true}
/>
</TabPanel>
</TabPanels>
</TabGroup>
return dataPoint;
});
})()}
index="category"
categories={(() => {
// Count user agents by frequency and get top ones
const userAgentCounts = new Map<string, number>();
perUserData.results.forEach((item: PerUserMetrics) => {
const agent = item.user_agent || "Unknown";
userAgentCounts.set(agent, (userAgentCounts.get(agent) || 0) + 1);
});
// Sort by frequency (most common first) and limit to top MAX_USER_AGENTS
return Array.from(userAgentCounts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_USER_AGENTS)
.map(([agent]) => agent);
})()}
colors={["blue", "green", "orange", "red", "purple", "yellow", "pink", "indigo"]}
valueFormatter={(value: number) => `${value} users`}
yAxisWidth={80}
showLegend={true}
stack={true}
/>
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -1,10 +1,23 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Grid, Metric, Subtitle, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react";
import { Select, Tooltip } from "antd";
import { Card, CardContent } from "@/components/ui/card";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxClear,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} 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 { userAgentSummaryCall, tagDauCall, tagWauCall, tagMauCall, tagDistinctCall } from "./networking";
import PerUserUsage from "./per_user_usage";
import { DateRangePickerValue } from "@tremor/react";
import type { DateRangePickerValue } from "@tremor/react";
import { ChartLoader } from "./shared/chart_loader";
// New interfaces for the updated API response
@ -356,39 +369,53 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
<div className="space-y-6 mt-6">
{/* Summary Section Card */}
<Card>
<div className="space-y-6">
<CardContent className="space-y-6">
<div className="flex justify-between items-start">
<div>
<Title>Summary by User Agent</Title>
<Subtitle>Performance metrics for different user agents</Subtitle>
<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">
<Text className="text-sm font-medium block mb-2">Filter by User Agents</Text>
<Select
mode="multiple"
placeholder="All User Agents"
<label className="text-sm font-medium block mb-2">Filter by User Agents</label>
<Combobox
multiple
items={availableTags}
value={selectedTags}
onChange={setSelectedTags}
style={{ width: "100%" }}
showSearch={true}
allowClear={true}
loading={tagsLoading}
optionFilterProp="label"
className="rounded-md"
maxTagCount="responsive"
onValueChange={(next: string[]) => setSelectedTags(next)}
>
{availableTags.map((tag) => {
const userAgent = extractUserAgent(tag);
const displayName = userAgent.length > 50 ? `${userAgent.substring(0, 50)}...` : userAgent;
return (
<Select.Option key={tag} value={tag} label={displayName} title={userAgent}>
{displayName}
</Select.Option>
);
})}
</Select>
<ComboboxChips 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
className="border-0 bg-transparent"
placeholder="All User Agents"
aria-label="All User Agents"
/>
{selectedTags.length > 0 && <ComboboxClear aria-label="Clear user agent filter" />}
</ComboboxChips>
<ComboboxContent>
<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>
@ -398,151 +425,166 @@ const UserAgentActivity: React.FC<UserAgentActivityProps> = ({ accessToken, user
{summaryLoading ? (
<ChartLoader isDateChanging={false} />
) : (
<Grid numItems={4} className="gap-4">
<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}>
<Tooltip title={userAgent} placement="top">
<Title className="truncate">{displayName}</Title>
</Tooltip>
<div className="mt-4 space-y-3">
<div>
<Text className="text-sm text-gray-600">Success Requests</Text>
<Metric className="text-lg">{formatAbbreviatedNumber(tag.successful_requests)}</Metric>
<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-gray-600">Success Requests</p>
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.successful_requests)}</p>
</div>
<div>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-lg font-semibold">{formatAbbreviatedNumber(tag.total_tokens)}</p>
</div>
<div>
<p className="text-sm text-gray-600">Total Cost</p>
<p className="text-lg font-semibold">${formatAbbreviatedNumber(tag.total_spend, 4)}</p>
</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Tokens</Text>
<Metric className="text-lg">{formatAbbreviatedNumber(tag.total_tokens)}</Metric>
</div>
<div>
<Text className="text-sm text-gray-600">Total Cost</Text>
<Metric className="text-lg">${formatAbbreviatedNumber(tag.total_spend, 4)}</Metric>
</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}`}>
<Title>No Data</Title>
<div className="mt-4 space-y-3">
<div>
<Text className="text-sm text-gray-600">Success Requests</Text>
<Metric className="text-lg">-</Metric>
<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-gray-600">Success Requests</p>
<p className="text-lg font-semibold">-</p>
</div>
<div>
<p className="text-sm text-gray-600">Total Tokens</p>
<p className="text-lg font-semibold">-</p>
</div>
<div>
<p className="text-sm text-gray-600">Total Cost</p>
<p className="text-lg font-semibold">-</p>
</div>
</div>
<div>
<Text className="text-sm text-gray-600">Total Tokens</Text>
<Metric className="text-lg">-</Metric>
</div>
<div>
<Text className="text-sm text-gray-600">Total Cost</Text>
<Metric className="text-lg">-</Metric>
</div>
</div>
</CardContent>
</Card>
))}
</Grid>
</div>
)}
</div>
</CardContent>
</Card>
{/* Main TabGroup for DAU/WAU/MAU vs Per User Usage */}
{/* Main tabs for DAU/WAU/MAU vs Per User Usage */}
<Card>
<TabGroup>
<TabList className="mb-6">
<Tab>DAU/WAU/MAU</Tab>
<Tab>Per User Usage (Last 30 Days)</Tab>
</TabList>
<CardContent>
<Tabs defaultValue="active-users">
<TabsList className="mb-6">
<TabsTrigger value="active-users" className="flex-none px-3">
DAU/WAU/MAU
</TabsTrigger>
<TabsTrigger value="per-user" className="flex-none px-3">
Per User Usage (Last 30 Days)
</TabsTrigger>
</TabsList>
<TabPanels>
{/* DAU/WAU/MAU Tab Panel */}
<TabPanel>
<TabsContent value="active-users" keepMounted>
<div className="mb-6">
<Title>DAU, WAU & MAU per Agent</Title>
<Subtitle>Active users across different time periods</Subtitle>
<h3 className="text-lg font-medium text-foreground">DAU, WAU &amp; MAU per Agent</h3>
<p className="text-sm text-muted-foreground">Active users across different time periods</p>
</div>
<TabGroup>
<TabList className="mb-6">
<Tab>DAU</Tab>
<Tab>WAU</Tab>
<Tab>MAU</Tab>
</TabList>
<Tabs defaultValue="dau">
<TabsList className="mb-6">
<TabsTrigger value="dau" className="flex-none px-3">
DAU
</TabsTrigger>
<TabsTrigger value="wau" className="flex-none px-3">
WAU
</TabsTrigger>
<TabsTrigger value="mau" className="flex-none px-3">
MAU
</TabsTrigger>
</TabsList>
<TabPanels>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Daily Active Users - Last 7 Days</Title>
</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}
/>
)}
</TabPanel>
<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>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Weekly Active Users - Last 7 Weeks</Title>
</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}
/>
)}
</TabPanel>
<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>
<TabPanel>
<div className="mb-4">
<Title className="text-lg">Monthly Active Users - Last 7 Months</Title>
</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}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
</TabPanel>
<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 */}
<TabPanel>
<TabsContent value="per-user" keepMounted>
<PerUserUsage
accessToken={accessToken}
selectedTags={selectedTags}
formatAbbreviatedNumber={formatAbbreviatedNumber}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);