Merge pull request #36834 from BerriAI/litellm_/compassionate-jones-9c0d5c

refactor(ui): migrate usage to shadcn
This commit is contained in:
yuneng-jiang 2026-08-13 18:30:30 -07:00 • committed by GitHub
commit ce3a56dce6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1848 additions and 1775 deletions

View file

@ -1603,25 +1603,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
}
@ -1630,9 +1617,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
@ -1648,7 +1632,7 @@
"count": 1
},
"no-restricted-imports": {
"count": 2
"count": 1
},
"react-hooks/purity": {
"count": 1
@ -1657,14 +1641,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
@ -1927,29 +1903,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": {
@ -2234,9 +2195,6 @@
},
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/add_model/AdaptiveRoutingConfig.tsx": {
@ -2721,9 +2679,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": {
@ -3090,9 +3045,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -3625,7 +3577,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,14 @@ 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} />
<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

@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "@/components/networking";
import EntityUsage from "./EntityUsage";
@ -500,18 +500,33 @@ describe("EntityUsage", () => {
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
});
const selectedPanels = (container: HTMLElement) =>
Array.from(container.querySelectorAll("div.tremor-TabPanel-root")).filter(
(panel) => panel.getAttribute("aria-selected") === "true",
);
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
const isShowing = (element: HTMLElement): boolean => {
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
if (node.hasAttribute("hidden")) return false;
if (node.getAttribute("aria-selected") === "false") return false;
}
return true;
};
it.each([
const showingCount = (marker: string): number => screen.queryAllByText(marker).filter(isShowing).length;
const showingText = (text: string): HTMLElement => {
const [element] = screen.getAllByText(text).filter(isShowing);
expect(element).toBeDefined();
return element;
};
const NON_TEAM_PANELS: [string, string][] = [
["Cost", "Tag Spend Overview"],
["Model Activity", "metrics-source:model_groups"],
["Key Activity", "metrics-source:api_keys"],
["Endpoint Activity", "Endpoint Usage Panel"],
])("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
const { container } = render(<EntityUsage {...defaultProps} />);
];
it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
@ -521,19 +536,23 @@ describe("EntityUsage", () => {
fireEvent.click(screen.getByText(tabLabel));
});
const selected = selectedPanels(container);
expect(selected).toHaveLength(1);
expect(selected[0].textContent).toContain(marker);
expect(showingCount(marker)).toBeGreaterThan(0);
for (const [otherLabel, otherMarker] of NON_TEAM_PANELS) {
if (otherLabel === tabLabel) continue;
expect(showingCount(otherMarker)).toBe(0);
}
});
it.each([
const TEAM_PANELS: [string, string][] = [
["Cost", "Team Spend Overview"],
["Model Activity", "metrics-source:model_groups"],
["Agent Activity", "metrics-source:entities"],
["Key Activity", "metrics-source:api_keys"],
["Endpoint Activity", "Endpoint Usage Panel"],
])("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
const { container } = render(<EntityUsage {...defaultProps} entityType="team" />);
];
it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
@ -543,9 +562,11 @@ describe("EntityUsage", () => {
fireEvent.click(screen.getByText(tabLabel));
});
const selected = selectedPanels(container);
expect(selected).toHaveLength(1);
expect(selected[0].textContent).toContain(marker);
expect(showingCount(marker)).toBeGreaterThan(0);
for (const [otherLabel, otherMarker] of TEAM_PANELS) {
if (otherLabel === tabLabel) continue;
expect(showingCount(otherMarker)).toBe(0);
}
});
it("should handle empty data gracefully", async () => {
@ -615,20 +636,19 @@ describe("EntityUsage", () => {
fireEvent.click(screen.getByText("Model Activity"));
});
const modelActivityPanel = () => selectedPanels(container)[0] as HTMLElement;
expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups");
expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0);
act(() => {
fireEvent.click(within(modelActivityPanel()).getByText("Litellm Model Name"));
fireEvent.click(showingText("Litellm Model Name"));
});
expect(modelActivityPanel().textContent).toContain("metrics-source:models");
expect(showingCount("metrics-source:models")).toBeGreaterThan(0);
act(() => {
fireEvent.click(within(modelActivityPanel()).getByText("Public Model Name"));
fireEvent.click(showingText("Public Model Name"));
});
expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups");
expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0);
});
it("should display Top Agents title for agent entity type", async () => {

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

@ -10,6 +10,14 @@ describe("TopModelView", () => {
mockSetTopModelsLimit.mockClear();
});
// Which element a control library gives its label to is its own business, so drive the
// control by its visible text and judge the result by what the panel renders.
const clickControl = async (user: ReturnType<typeof userEvent.setup>, label: string) => {
await user.click(screen.getByText(label));
};
const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null;
it("should render", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("Table View")).toBeInTheDocument();
@ -17,12 +25,12 @@ describe("TopModelView", () => {
it("should display table view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
expect(screen.getByText("Table View")).toBeInTheDocument();
});
it("should display chart view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument();
expect(screen.getByText("Chart View")).toBeInTheDocument();
});
it("should display all table column headers", () => {
@ -60,27 +68,32 @@ describe("TopModelView", () => {
expect(screen.getByText("50,000")).toBeInTheDocument();
});
const oneModel = [{ key: "gpt-4", spend: 150.5, successful_requests: 100, failed_requests: 5, tokens: 50000 }];
it("should switch to chart view when chart view button is clicked", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const { container } = render(
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
await user.click(chartViewButton);
expect(showsChart(container)).toBe(false);
await clickControl(user, "Chart View");
expect(chartViewButton).toHaveClass("bg-blue-100");
expect(showsChart(container)).toBe(true);
expect(screen.queryByText("Spend (USD)")).not.toBeInTheDocument();
});
it("should switch to table view when table view button is clicked", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const { container } = render(
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
);
const chartViewButton = screen.getByRole("button", { name: "Chart View" });
const tableViewButton = screen.getByRole("button", { name: "Table View" });
await clickControl(user, "Chart View");
await clickControl(user, "Table View");
await user.click(chartViewButton);
await user.click(tableViewButton);
expect(tableViewButton).toHaveClass("bg-blue-100");
expect(showsChart(container)).toBe(false);
expect(screen.getByText("Spend (USD)")).toBeInTheDocument();
});
it("renders one cyan bar per model with model names on the axis in chart view", async () => {
@ -108,7 +121,7 @@ describe("TopModelView", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Chart View" }));
await clickControl(user, "Chart View");
const bars = container.querySelectorAll("path.recharts-rectangle");
expect(bars).toHaveLength(2);
@ -118,19 +131,11 @@ describe("TopModelView", () => {
expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0);
});
it("should call setTopModelsLimit when limit is changed via Segmented control", async () => {
it("should call setTopModelsLimit when the limit control is changed", async () => {
const user = userEvent.setup();
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const limit10Radio = screen.getByRole("radio", { name: "10" });
const limit10Label = limit10Radio.closest("label");
if (limit10Label) {
await user.click(limit10Label);
} else {
// Fallback: click the div with title="10"
const limit10Div = screen.getByTitle("10");
await user.click(limit10Div);
}
await clickControl(user, "10");
expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10);
});

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

@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => {
it("should render model selector", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument();
// One library paints the prompt as its own text node and the other leaves it on the input's
// placeholder attribute, so either one means the user is being told what to pick.
const prompt = "Select a model (optional, defaults to gpt-4o-mini)";
expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0);
});
it("should render empty state message when no conversation", () => {

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

@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import type { Organization } from "@/components/networking";
@ -143,207 +144,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
useInfiniteUsers: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const React = await import("react");
const actual = await importOriginal<typeof import("antd")>();
function Select(props: any) {
const { value, onChange, options, ...rest } = props;
return React.createElement(
"select",
{
...rest,
value,
onChange: (e: any) => onChange?.(e.target.value),
role: "combobox",
},
options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)),
);
}
(Select as any).displayName = "AntdSelect";
function Alert(props: any) {
const { message, description, type, closable, onClose, ...rest } = props;
return React.createElement(
"div",
{ ...rest, "data-testid": "antd-alert", "data-type": type },
message && React.createElement("div", null, message),
description && React.createElement("div", null, description),
closable && React.createElement("button", { onClick: onClose, "aria-label": "Close" }, "×"),
);
}
(Alert as any).displayName = "AntdAlert";
function Badge(props: any) {
const { count, color, children, ...rest } = props;
return React.createElement(
"div",
{ ...rest, "data-testid": "antd-badge", "data-color": color },
count && React.createElement("span", { "data-testid": "antd-badge-count" }, count),
children,
);
}
(Badge as any).displayName = "AntdBadge";
function Table({ columns, dataSource, ...rest }: any) {
return React.createElement(
"div",
{ ...rest, "data-testid": "antd-table" },
columns?.map((col: any) =>
React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title),
),
dataSource?.map((row: any) =>
React.createElement(
"div",
{ key: row.key, "data-testid": `row-${row.key}` },
columns?.map((col: any) => {
const value = col.render ? col.render(row[col.dataIndex], row) : row[col.dataIndex];
return React.createElement("div", { key: col.key }, value);
}),
),
),
);
}
(Table as any).displayName = "Table";
function Segmented(props: any) {
const { value, onChange, options, ...rest } = props;
return React.createElement(
"div",
{ ...rest, "data-testid": "antd-segmented" },
options?.map((opt: any) =>
React.createElement(
"button",
{
key: opt.value,
onClick: () => onChange?.(opt.value),
"data-selected": value === opt.value,
},
opt.label,
),
),
);
}
(Segmented as any).displayName = "AntdSegmented";
function Tooltip(props: any) {
const { title, children, ...rest } = props;
return React.createElement("div", { ...rest, "data-testid": "antd-tooltip", title }, children);
}
(Tooltip as any).displayName = "AntdTooltip";
return {
...actual,
Select,
Alert,
Badge,
Table,
Segmented,
Tooltip,
};
});
vi.mock("@ant-design/icons", async () => {
const React = await import("react");
function Icon() {
return React.createElement("span");
}
function LoadingOutlined(props: any) {
return React.createElement("span", { "data-testid": "loading-icon", ...props });
}
return {
GlobalOutlined: Icon,
BankOutlined: Icon,
TeamOutlined: Icon,
ShoppingCartOutlined: Icon,
TagsOutlined: Icon,
RobotOutlined: Icon,
LineChartOutlined: Icon,
BarChartOutlined: Icon,
ClockCircleOutlined: Icon,
CalendarOutlined: Icon,
InfoCircleOutlined: Icon,
UserOutlined: Icon,
DownOutlined: Icon,
RightOutlined: Icon,
ExportOutlined: Icon,
LoadingOutlined,
};
});
// Mock Tremor components
vi.mock("@tremor/react", async () => {
const React = await import("react");
const actual = await import("@tremor/react");
function TabGroup({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-tab-group" }, children);
}
function TabList({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-tab-list" }, children);
}
function Tab({ children, ...props }: any) {
return React.createElement("button", { ...props, "data-testid": "tremor-tab" }, children);
}
function TabPanels({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-tab-panels" }, children);
}
function TabPanel({ children }: any) {
return React.createElement("div", { "data-testid": "tremor-tab-panel" }, children);
}
function Card({ children, ...props }: any) {
return React.createElement("div", { ...props, "data-testid": "tremor-card" }, children);
}
function Grid({ children, numItems, ...props }: any) {
return React.createElement("div", { ...props, "data-testid": "tremor-grid" }, children);
}
function Col({ children, numColSpan, ...props }: any) {
return React.createElement("div", { ...props, "data-testid": "tremor-col" }, children);
}
function Title({ children, ...props }: any) {
return React.createElement("h2", { ...props, "data-testid": "tremor-title" }, children);
}
function Text({ children, ...props }: any) {
return React.createElement("p", { ...props, "data-testid": "tremor-text" }, children);
}
function Button({ children, icon, onClick, ...props }: any) {
return React.createElement(
"button",
{ ...props, onClick, "data-testid": "tremor-button" },
icon && React.createElement("span", { "data-testid": "tremor-button-icon" }),
children,
);
}
return {
...actual,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
Card,
Grid,
Col,
Title,
Text,
Button,
};
});
describe("UsagePage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
@ -885,6 +685,26 @@ describe("UsagePage", () => {
});
describe("admin user selector", () => {
// Anchored on the field's own label, so it does not depend on which library draws the control.
const userSelectCombobox = (): HTMLElement => {
let node: HTMLElement | null = screen.getByText("Filter by user");
while (node && !node.querySelector('[role="combobox"]')) {
node = node.parentElement;
}
const combobox = node?.querySelector('[role="combobox"]') ?? null;
expect(combobox).not.toBeNull();
return combobox as HTMLElement;
};
const openUserSelect = async () => {
await userEvent.setup().click(userSelectCombobox());
};
// One library paints the prompt as its own text node and the other leaves it on the input's
// placeholder attribute, so either one means the user is being told what to type.
const promptsWith = (text: string) =>
screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0;
it("should render user selector for admin users in global view", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
@ -892,10 +712,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Admin should see the user selector select element with the placeholder attribute
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter...");
expect(userSelect).toBeDefined();
expect(userSelectCombobox()).toBeInTheDocument();
expect(promptsWith("Select user to filter...")).toBe(true);
});
it("should format user options with alias when available", async () => {
@ -905,6 +723,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
await openUserSelect();
// User with alias should show "alias (id)"
expect(screen.getByText("Alice (user-001)")).toBeInTheDocument();
// User without alias but with email should show "email (id)"
@ -958,6 +778,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
await openUserSelect();
// Duplicate user should appear only once
const dupElements = screen.getAllByText("DupUser (user-dup)");
expect(dupElements).toHaveLength(1);
@ -1003,10 +825,9 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Non-admin should not see the user selector
const userSelects = screen.getAllByRole("combobox");
const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter...");
expect(userSelect).toBeUndefined();
// The admin case above proves this label is rendered when the selector exists, so its
// absence here is a live assertion rather than a query that can never match.
expect(screen.queryByText("Filter by user")).not.toBeInTheDocument();
});
it("should always pass own userId for non-admin users", async () => {

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,7 @@ 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,
});
const [settledUserSearch, setSettledUserSearch] = useState("");
const {
data: usersInfiniteData,
@ -125,7 +112,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
hasNextPage: hasNextUsersPage,
isFetchingNextPage: isFetchingNextUsersPage,
isLoading: isLoadingUsers,
} = useInfiniteUsers(50, debouncedUserSearch || undefined);
} = useInfiniteUsers(50, settledUserSearch || undefined);
const userOptions = useMemo(() => {
if (!usersInfiniteData?.pages) return [];
@ -148,19 +135,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 +496,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={setSettledUserSearch}
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 +898,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 +992,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,86 +1,16 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UsageViewSelect } from "./UsageViewSelect";
vi.mock("antd", async () => {
const React = await import("react");
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("combobox"));
};
function Select(props: any) {
const { value, onChange, options, optionRender, labelRender, ...rest } = props;
const optionElements = options?.map((opt: any) =>
React.createElement("option", { key: opt.value, value: opt.value }, opt.label),
);
const optionRenderOutputs = options
?.map((opt: any) => {
if (optionRender) {
const rendered = optionRender({ value: opt.value, label: opt.label });
return React.createElement(
"div",
{
key: `option-render-${opt.value}`,
"data-testid": `option-render-${opt.value}`,
style: { display: "none" },
},
rendered,
);
}
return null;
})
.filter(Boolean);
return React.createElement(
React.Fragment,
null,
React.createElement(
"select",
{
...rest,
value,
onChange: (e: any) => onChange?.(e.target.value),
role: "combobox",
},
optionElements,
),
...(optionRenderOutputs || []),
);
}
(Select as any).displayName = "AntdSelect";
function Badge(props: any) {
const { count, color, children, ...rest } = props;
return React.createElement(
"span",
{ ...rest, "data-testid": "antd-badge", "data-color": color },
count && React.createElement("span", { "data-testid": "antd-badge-count" }, count),
children,
);
}
(Badge as any).displayName = "AntdBadge";
return { Select, Badge };
});
vi.mock("@ant-design/icons", async () => {
const React = await import("react");
function Icon(props: any) {
return React.createElement("span", { "data-testid": "antd-icon" });
}
return {
GlobalOutlined: Icon,
BankOutlined: Icon,
TeamOutlined: Icon,
ShoppingCartOutlined: Icon,
TagsOutlined: Icon,
RobotOutlined: Icon,
UserOutlined: Icon,
LineChartOutlined: Icon,
BarChartOutlined: Icon,
};
});
// The listbox is portalled outside the render container in both antd and Base UI, so an
// option is "offered" when the label appears more times on the page than inside the trigger.
const offers = (container: HTMLElement, label: string) =>
screen.queryAllByText(label).length > within(container).queryAllByText(label).length;
describe("UsageViewSelect", () => {
const mockOnChange = vi.fn();
@ -89,53 +19,73 @@ describe("UsageViewSelect", () => {
mockOnChange.mockClear();
});
it("should render", () => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
it("should render", async () => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
expect(screen.getByText("Usage View")).toBeInTheDocument();
expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument();
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, "Your Usage")).toBe(true);
});
it("should call onChange when value changes", () => {
it("should call onChange when value changes", async () => {
const user = userEvent.setup();
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
const select = screen.getByRole("combobox");
act(() => {
fireEvent.change(select, { target: { value: "team" } });
});
await openMenu(user);
const matches = screen.getAllByText("Team Usage");
await user.click(matches[matches.length - 1]);
expect(mockOnChange).toHaveBeenCalledWith("team");
expect(mockOnChange).toHaveBeenCalled();
expect(mockOnChange.mock.calls[0][0]).toBe("team");
});
it("should show Tag Usage for non-admin users with tag usage permission", () => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it("should show Tag Usage for non-admin users with tag usage permission", async () => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, "Tag Usage")).toBe(true);
});
it("should hide Tag Usage for non-admin users without tag usage permission", () => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
it("should hide Tag Usage for non-admin users without tag usage permission", async () => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument();
await openMenu(user);
expect(offers(container, "Tag Usage")).toBe(false);
});
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", (optionName) => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, optionName)).toBe(true);
});
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", (optionName) => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
expect(screen.queryByRole("option", { name: optionName })).not.toBeInTheDocument();
await openMenu(user);
expect(offers(container, optionName)).toBe(false);
});
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", (optionName) => {
render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, optionName)).toBe(true);
});
});

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,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { ActivityMetrics, formatKeyLabel, processActivityData } from "./activity_metrics";
@ -15,38 +15,12 @@ beforeAll(() => {
}
});
vi.mock("@tremor/react", () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Grid: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Text: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Title: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
AreaChart: () => <div>AreaChart</div>,
BarChart: () => <div>BarChart</div>,
}));
vi.mock("antd", () => {
const CollapseComponent = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const PanelComponent = ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => (
<div>
<div>{header}</div>
<div>{children}</div>
</div>
);
PanelComponent.displayName = "Collapse.Panel";
CollapseComponent.Panel = PanelComponent;
const TableComponent = ({ dataSource, columns }: { dataSource?: unknown[]; columns?: { title: string }[] }) => (
<table>
<thead>
<tr>{columns?.map((col, i) => <th key={i}>{col.title}</th>)}</tr>
</thead>
<tbody>{dataSource?.map((_, i) => <tr key={i} />)}</tbody>
</table>
);
return {
Collapse: CollapseComponent,
Table: TableComponent,
};
});
// Panel order is a contract; which element the label lands in is not, so compare document order.
const precedes = (firstLabel: string, secondLabel: string): boolean => {
const first = screen.getAllByText(firstLabel)[0];
const second = screen.getAllByText(secondLabel)[0];
return Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
};
vi.mock("@/utils/dataUtils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/dataUtils")>();
@ -256,10 +230,7 @@ describe("ActivityMetrics", () => {
};
render(<ActivityMetrics modelMetrics={multipleModels} />);
const headers = screen.getAllByRole("heading", { level: 2 });
const gpt4Index = headers.findIndex((h) => h.textContent?.includes("GPT-4"));
const gpt35Index = headers.findIndex((h) => h.textContent?.includes("GPT-3.5"));
expect(gpt4Index).toBeLessThan(gpt35Index);
expect(precedes("GPT-4", "GPT-3.5")).toBe(true);
});
it("should display model summary cards with correct values", () => {
@ -387,10 +358,27 @@ describe("ActivityMetrics", () => {
};
render(<ActivityMetrics modelMetrics={modelsWithEmptyKey} />);
const headings = screen.getAllByRole("heading", { level: 2 });
const gpt4Index = headings.findIndex((h) => h.textContent?.includes("GPT-4"));
const unknownIndex = headings.findIndex((h) => h.textContent?.includes("Unknown"));
expect(gpt4Index).toBeLessThan(unknownIndex);
expect(precedes("GPT-4", "Unknown")).toBe(true);
});
// A model section owns view-mode state, so collapsing one must not throw its subtree away.
it("keeps a model section mounted once it has been expanded", () => {
const multipleModels: Record<string, ModelActivityData> = {
"gpt-3.5": GPT_35_MODEL_DATA,
"gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100.5 },
};
render(<ActivityMetrics modelMetrics={multipleModels} />);
// Only the highest-spend section is expanded initially, so only its body is mounted.
const sectionsMounted = () => screen.getAllByText("Spend per day").length;
expect(sectionsMounted()).toBe(1);
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
expect(sectionsMounted()).toBe(2);
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
expect(sectionsMounted()).toBe(2);
});
it("should display average tokens per successful request", () => {

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,156 @@ 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>
);
};
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 +319,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 +422,9 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
</Collapse.Panel>
</ModelCollapsible>
))}
</Collapse>
</div>
</div>
);
};

View file

@ -0,0 +1,124 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import TeamMultiSelect from "./team_multi_select";
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useInfiniteTeams: vi.fn(),
}));
const team = (id: string, alias: string) => ({ team_id: id, team_alias: alias });
const mockTeamsResult = (
overrides: Partial<{
pages: { teams: ReturnType<typeof team>[] }[];
isLoading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
}> = {},
) => {
const { pages = [{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }], ...rest } = overrides;
return {
data: { pages },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
...rest,
};
};
describe("TeamMultiSelect", () => {
const mockUseInfiniteTeams = vi.mocked(useInfiniteTeams);
beforeEach(() => {
vi.clearAllMocks();
mockUseInfiniteTeams.mockReturnValue(mockTeamsResult() as never);
});
const combobox = () => screen.getByRole("combobox");
// One library paints the prompt as its own text node and the other leaves it on the input's
// placeholder attribute, so either one means the user is being told what to type.
const promptsWith = (text: string) =>
screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0;
it("renders a search control with the given placeholder", () => {
render(<TeamMultiSelect placeholder="Search teams by alias..." />);
expect(combobox()).toBeInTheDocument();
expect(promptsWith("Search teams by alias...")).toBe(true);
});
it("offers every loaded team by alias and id", async () => {
const user = userEvent.setup();
render(<TeamMultiSelect />);
await user.click(combobox());
expect(screen.getByText("Alpha Team")).toBeInTheDocument();
expect(screen.getByText("(team-1)")).toBeInTheDocument();
expect(screen.getByText("Beta Team")).toBeInTheDocument();
expect(screen.getByText("(team-2)")).toBeInTheDocument();
});
it("deduplicates a team that appears on more than one page", async () => {
mockUseInfiniteTeams.mockReturnValue(
mockTeamsResult({
pages: [
{ teams: [team("team-1", "Alpha Team")] },
{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] },
],
}) as never,
);
const user = userEvent.setup();
render(<TeamMultiSelect />);
await user.click(combobox());
expect(screen.getAllByText("Alpha Team")).toHaveLength(1);
expect(screen.getByText("Beta Team")).toBeInTheDocument();
});
it("reports the picked team id to onChange", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<TeamMultiSelect onChange={onChange} />);
await user.click(combobox());
const matches = screen.getAllByText("Beta Team");
await user.click(matches[matches.length - 1]);
expect(onChange).toHaveBeenCalled();
expect(onChange.mock.calls[0][0]).toEqual(["team-2"]);
});
it("does not report a selection while disabled", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<TeamMultiSelect onChange={onChange} disabled />);
await user.click(combobox());
expect(screen.queryByText("Alpha Team")).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it("tells the user when there are no teams to pick", async () => {
mockUseInfiniteTeams.mockReturnValue(mockTeamsResult({ pages: [{ teams: [] }] }) as never);
const user = userEvent.setup();
render(<TeamMultiSelect />);
await user.click(combobox());
// Substring match because one library appends an invisible word joiner for its live region.
expect(screen.getByText(/No teams found/)).toBeInTheDocument();
});
it("passes the page size and organization filter through to the teams query", () => {
render(<TeamMultiSelect pageSize={25} organizationId="org-7" />);
expect(mockUseInfiniteTeams).toHaveBeenCalledWith(25, undefined, "org-7");
});
});

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

@ -29,6 +29,14 @@ const userRow = (userId: string, userAgent: string | null, successfulRequests: n
spend: 1,
});
// The distribution panel owns the only chart in this component, so resolving it by slot keeps
// the assertions independent of how many wrappers the tab library puts around a panel.
const distributionChart = (): HTMLElement => {
const chart = document.querySelector('[data-slot="chart"]');
expect(chart).not.toBeNull();
return chart as HTMLElement;
};
describe("PerUserUsage", () => {
const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall);
@ -70,6 +78,22 @@ describe("PerUserUsage", () => {
});
});
it("keeps both tab panels mounted so switching tabs does not reset their state", async () => {
render(<PerUserUsage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("u1")).toBeInTheDocument();
});
// Still on the User Details tab: the distribution panel is mounted alongside it.
expect(screen.getByText("User Usage Distribution")).toBeInTheDocument();
fireEvent.click(screen.getByText("Usage Distribution"));
// And the details panel survives the switch rather than unmounting.
expect(screen.getByText("u1")).toBeInTheDocument();
});
it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => {
render(<PerUserUsage {...defaultProps} />);
@ -79,26 +103,22 @@ describe("PerUserUsage", () => {
fireEvent.click(screen.getByText("Usage Distribution"));
const panel = screen.getByText("User Usage Distribution").closest("div")?.parentElement;
expect(panel).not.toBeNull();
await waitFor(() => {
expect(panel!.querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
expect(distributionChart().querySelectorAll("path.recharts-rectangle")).toHaveLength(4);
});
const chart = panel!.querySelector('[data-slot="chart"]');
expect(chart).not.toBeNull();
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);
const chart = distributionChart();
expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2);
const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle"));
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-green-500, #22c55e)"]));
const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
expect(xPositions.size).toBe(3);
expect(chart!.textContent).toContain("curl/8.0");
expect(chart!.textContent).toContain("Unknown");
expect(chart.textContent).toContain("curl/8.0");
expect(chart.textContent).toContain("Unknown");
for (const bucket of [
"1-9 requests",
"10-99 requests",
@ -107,10 +127,10 @@ describe("PerUserUsage", () => {
"10K-99.9K requests",
"100K+ requests",
]) {
expect(chart!.textContent).toContain(bucket);
expect(chart.textContent).toContain(bucket);
}
const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent ?? "",
);
expect(tickTexts.some((tick) => / users$/.test(tick))).toBe(true);

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

@ -177,40 +177,61 @@ describe("UserAgentActivity", () => {
// Check that filter label is present
expect(screen.getByText("Filter by User Agents")).toBeInTheDocument();
// The Ant Design Select component should be in the document with placeholder
const selectElement = screen.getByText("All User Agents");
expect(selectElement).toBeInTheDocument();
// One library paints the prompt as its own text node and the other leaves it on the input's
// placeholder attribute, so either one means the user is being told what the filter does.
const prompts =
screen.queryAllByText("All User Agents").length + screen.queryAllByPlaceholderText("All User Agents").length;
expect(prompts).toBeGreaterThan(0);
});
const getPanelForTitle = (title: string): HTMLElement => {
// Assumes two wrapper divs between the Tremor <Title> and the panel root; update if Tremor's TabPanel depth changes.
const panel = screen.getByText(title).closest("div")?.parentElement;
expect(panel).not.toBeNull();
return panel!;
// Walks up from the panel's heading to the nearest ancestor that owns a chart, so the
// assertions do not depend on how many wrappers the tab library puts around a panel.
const chartForTitle = (title: string): HTMLElement => {
let node: HTMLElement | null = screen.getByText(title);
while (node && !node.querySelector('[data-slot="chart"]')) {
node = node.parentElement;
}
const chart = node?.querySelector('[data-slot="chart"]') ?? null;
expect(chart).not.toBeNull();
return chart as HTMLElement;
};
const expectStackedTwoCategoryChart = (panel: HTMLElement, firstBucketLabel: string) => {
const chart = panel.querySelector('[data-slot="chart"]');
expect(chart).not.toBeNull();
expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2);
const expectStackedTwoCategoryChart = (chart: HTMLElement, firstBucketLabel: string) => {
expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2);
const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle"));
const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle"));
const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill")));
expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"]));
const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1]));
expect(xPositions.size).toBe(1);
expect(chart!.textContent).toContain("Chrome/1.0");
expect(chart!.textContent).toContain("Firefox/2.0");
expect(chart!.textContent).toContain(firstBucketLabel);
expect(chart.textContent).toContain("Chrome/1.0");
expect(chart.textContent).toContain("Firefox/2.0");
expect(chart.textContent).toContain(firstBucketLabel);
const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map(
(tick) => tick.textContent ?? "",
);
expect(tickTexts.some((tick) => /^\d+K$/.test(tick))).toBe(true);
};
it("keeps every tab panel mounted so switching tabs does not reset their state", async () => {
render(<UserAgentActivity {...defaultProps} />);
await waitFor(() => {
expect(mockTagDauCall).toHaveBeenCalled();
});
// No tab has been clicked: the inactive DAU/WAU/MAU panels are mounted alongside the active one.
expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument();
expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument();
expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument();
// And so is the second panel of the outer tab group.
expect(screen.getByText("Per User Usage")).toBeInTheDocument();
});
it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => {
const firstBucketDate = new Date();
firstBucketDate.setDate(firstBucketDate.getDate() - 6);
@ -224,12 +245,16 @@ describe("UserAgentActivity", () => {
render(<UserAgentActivity {...defaultProps} />);
const panel = getPanelForTitle("Daily Active Users - Last 7 Days");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
expect(
chartForTitle("Daily Active Users - Last 7 Days").querySelectorAll("path.recharts-rectangle"),
).toHaveLength(2);
});
expectStackedTwoCategoryChart(panel, firstBucketDate.toISOString().split("T")[0]);
expectStackedTwoCategoryChart(
chartForTitle("Daily Active Users - Last 7 Days"),
firstBucketDate.toISOString().split("T")[0],
);
});
it("renders the WAU chart stacked with week buckets and abbreviated axis ticks", async () => {
@ -242,12 +267,13 @@ describe("UserAgentActivity", () => {
render(<UserAgentActivity {...defaultProps} />);
const panel = getPanelForTitle("Weekly Active Users - Last 7 Weeks");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
expect(
chartForTitle("Weekly Active Users - Last 7 Weeks").querySelectorAll("path.recharts-rectangle"),
).toHaveLength(2);
});
expectStackedTwoCategoryChart(panel, "Week 1");
expectStackedTwoCategoryChart(chartForTitle("Weekly Active Users - Last 7 Weeks"), "Week 1");
});
it("renders the MAU chart stacked with month buckets and abbreviated axis ticks", async () => {
@ -260,11 +286,12 @@ describe("UserAgentActivity", () => {
render(<UserAgentActivity {...defaultProps} />);
const panel = getPanelForTitle("Monthly Active Users - Last 7 Months");
await waitFor(() => {
expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2);
expect(
chartForTitle("Monthly Active Users - Last 7 Months").querySelectorAll("path.recharts-rectangle"),
).toHaveLength(2);
});
expectStackedTwoCategoryChart(panel, "Month 1");
expectStackedTwoCategoryChart(chartForTitle("Monthly Active Users - Last 7 Months"), "Month 1");
});
});

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>
);