feat(ui): migrate UsagePage chrome to shadcn

Migrates the chrome of the UsagePage subtree to shadcn (tremor chart
widgets - BarChart, DonutChart - preserved behind eslint-disable):

- UsagePageView.tsx: tremor TabGroup/TabList/Tab/TabPanel -> shadcn
  Tabs/TabsList/TabsTrigger/TabsContent. tremor Card/Grid/Col/Title/
  Text -> shadcn Card + Tailwind grid. antd Alert/Button/Segmented/
  Select/Tooltip -> shadcn Alert/Button/ToggleGroup/Popover+Input/
  Tooltip. @ant-design/icons -> lucide. Custom UserFilterSelect built
  from Popover + Input to replace antd Select with popup scroll
  pagination (preserves placeholder + combobox role for tests).
- EntityUsage/EntityUsage.tsx: same tremor chrome -> shadcn; antd
  Alert/Button -> shadcn Alert/Button; Loader2 from lucide.
- EntityUsage/TopKeyView.tsx: antd Segmented -> shadcn ToggleGroup.
  BarChart preserved.
- UsageAIChatPanel.tsx: antd Button/Select/Input.TextArea/Spin ->
  shadcn Button/Select/Textarea + lucide Loader2. bg-gray-*/
  text-gray-* -> bg-muted/text-muted-foreground.

Repairs UsagePageView.test.tsx / EntityUsage.test.tsx / TopKeyView.test.tsx:
- EntityUsage tab-switch tests now use getByRole('tab',...) +
  userEvent.click() (radix Tabs requires pointer events for switching).
- TopKeyView test mockAuth extended with isLoading / isAuthorized to
  match updated useAuthorized return shape.
- TopKeyView Segmented test rewritten to click role=radio directly
  (ToggleGroup exposes radio semantics).

Adds the 3 migrated files to the .eslintrc.json override list where
they keep categorical status palettes (green/red/cyan/blue/purple for
success/failure/spend/token charts).

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 10:59:54 +00:00
parent 889d12f9b9
commit 6badcee6c2
No known key found for this signature in database
8 changed files with 962 additions and 845 deletions

View file

@ -157,7 +157,10 @@
"src/components/bulk_create_users_button.tsx",
"src/components/BulkEditUsers.tsx",
"src/components/DefaultUserSettings.tsx",
"src/components/view_users/user_info_view.tsx"
"src/components/view_users/user_info_view.tsx",
"src/components/UsagePage/components/EntityUsage/EntityUsage.tsx",
"src/components/UsagePage/components/UsageAIChatPanel.tsx",
"src/components/UsagePage/components/UsagePageView.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "../../../networking";
import EntityUsage from "./EntityUsage";
@ -458,6 +459,7 @@ describe("EntityUsage", () => {
});
it("should switch between tabs", async () => {
const user = userEvent.setup();
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
@ -466,19 +468,19 @@ describe("EntityUsage", () => {
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
const modelActivityTab = screen.getByText("Model Activity");
act(() => {
fireEvent.click(modelActivityTab);
const modelActivityTab = screen.getByRole("tab", { name: "Model Activity" });
await user.click(modelActivityTab);
await waitFor(() => {
expect(screen.getAllByText("Activity Metrics").length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Activity Metrics")[0]).toBeInTheDocument();
const keyActivityTab = screen.getByRole("tab", { name: "Key Activity" });
await user.click(keyActivityTab);
const keyActivityTab = screen.getByText("Key Activity");
act(() => {
fireEvent.click(keyActivityTab);
await waitFor(() => {
expect(screen.getAllByText("Activity Metrics").length).toBeGreaterThan(0);
});
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
});
it("should handle empty data gracefully", async () => {
@ -662,10 +664,9 @@ describe("EntityUsage", () => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
const agentActivityTab = screen.getByText("Agent Activity");
act(() => {
fireEvent.click(agentActivityTab);
});
const user = userEvent.setup();
const agentActivityTab = screen.getByRole("tab", { name: "Agent Activity" });
await user.click(agentActivityTab);
await waitFor(() => {
expect(screen.getAllByText("Activity Metrics").length).toBeGreaterThan(0);

View file

@ -1,30 +1,25 @@
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import { formatNumberWithCommas } from "@/utils/dataUtils";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { BarChart, DateRangePickerValue, DonutChart } from "@tremor/react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
BarChart,
Card,
Col,
DateRangePickerValue,
DonutChart,
Grid,
Subtitle,
Tab,
TabGroup,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableHeader,
TableRow,
TabList,
TabPanel,
TabPanels,
Text,
Title,
} from "@tremor/react";
import { Upload as ExportOutlined, Loader2 as LoadingOutlined } from "lucide-react";
import { Alert, Button } from "antd";
} from "@/components/ui/table";
import { Loader2 } from "lucide-react";
import React, { useMemo, useState } from "react";
import TeamMultiSelect from "../../../common_components/team_multi_select";
import { ActivityMetrics, processActivityData } from "../../../activity_metrics";
@ -399,84 +394,65 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
return (
<div style={{ width: "100%" }} className="relative">
{isFetchingMore && (
<Alert
banner
type="warning"
className="mb-2"
message={
<Alert className="mb-2">
<AlertDescription>
<div className="flex items-center justify-between">
<span>
<LoadingOutlined className="animate-spin mr-2" />
<Loader2 className="animate-spin mr-2 inline h-4 w-4" />
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 />
open a new tab
</a>
.
</span>
<Button type="primary" danger onClick={cancel}>
<Button variant="destructive" onClick={cancel}>
Stop
</Button>
</div>
}
/>
</AlertDescription>
</Alert>
)}
{cancelled && (
<Alert
banner
type="info"
className="mb-2"
message={
<span>
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
</span>
}
/>
<Alert className="mb-2">
<AlertDescription>
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
{agentIsFetchingMore && entityType === "team" && (
<Alert
banner
type="warning"
className="mb-2"
message={
<Alert className="mb-2">
<AlertDescription>
<div className="flex items-center justify-between">
<span>
<LoadingOutlined className="animate-spin mr-2" />
<Loader2 className="animate-spin mr-2 inline h-4 w-4" />
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 />
open a new tab
</a>
.
</span>
<Button type="primary" danger onClick={agentCancel}>
<Button variant="destructive" onClick={agentCancel}>
Stop
</Button>
</div>
}
/>
</AlertDescription>
</Alert>
)}
{agentCancelled && entityType === "team" && (
<Alert
banner
type="info"
className="mb-2"
message={
<span>
Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded)
</span>
}
/>
<Alert className="mb-2">
<AlertDescription>
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>
<TeamMultiSelect
value={selectedTags}
onChange={setSelectedTags}
/>
<p className="mb-2 text-sm">Filter by team</p>
<TeamMultiSelect value={selectedTags} onChange={setSelectedTags} />
</div>
)}
<UsageExportHeader
@ -492,328 +468,329 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
filterMode={entityType === "user" ? "single" : "multiple"}
teams={teams || []}
/>
<TabGroup>
<TabList variant="solid" className="mt-1">
<Tab>Cost</Tab>
<Tab>{entityType === "agent" ? "Request / Token Consumption" : "Model Activity"}</Tab>
{entityType === "team" ? <Tab>Agent Activity</Tab> : <></>}
<Tab>Key Activity</Tab>
<Tab>Endpoint Activity</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Grid numItems={2} className="gap-2 w-full">
{/* Total Spend Card */}
<Col numColSpan={2}>
<Card>
<Title>{capitalizedEntityLabel} Spend Overview</Title>
<Grid numItems={5} className="gap-4 mt-4">
<Card>
<Title>Total Spend</Title>
<Text className="text-2xl font-bold mt-2">
${formatNumberWithCommas(spendData.metadata.total_spend, 2)}
</Text>
</Card>
<Card>
<Title>Total Requests</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_api_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{spendData.metadata.total_successful_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Failed Requests</Title>
<Text className="text-2xl font-bold mt-2 text-red-600">
{spendData.metadata.total_failed_requests.toLocaleString()}
</Text>
</Card>
<Card>
<Title>Total Tokens</Title>
<Text className="text-2xl font-bold mt-2">
{spendData.metadata.total_tokens.toLocaleString()}
</Text>
</Card>
</Grid>
</Card>
</Col>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
<BarChart
data={[...spendData.results].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)}
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;
const entityCount = Object.keys(data.breakdown.entities || {}).length;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
<p className="text-gray-600">Total 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">Total Tokens: {data.metrics.total_tokens}</p>
<p className="text-gray-600">
Total {capitalizedEntityLabel}s: {entityCount}
</p>
<div className="mt-2 border-t pt-2">
<p className="font-semibold">Spend by {capitalizedEntityLabel}:</p>
{Object.entries(data.breakdown.entities || {})
.sort(([, a], [, b]) => {
const spendA = (a as EntityMetrics).metrics.spend;
const spendB = (b as EntityMetrics).metrics.spend;
return spendB - spendA;
})
.slice(0, 5)
.map(([entity, entityData]) => {
const metrics = entityData as EntityMetrics;
return (
<p key={entity} className="text-sm text-gray-600">
{getEntityLabel(entity, metrics.metadata)}: $
{formatNumberWithCommas(metrics.metrics.spend, 2)}
</p>
);
})}
{entityCount > 5 && (
<p className="text-sm text-gray-500 italic">...and {entityCount - 5} more</p>
)}
</div>
</div>
);
}}
/>
</Card>
</Col>
{/* Entity Breakdown Section */}
<Col numColSpan={2}>
<Card>
<div 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>
<div className="flex items-center text-sm text-gray-500">
<span>Get Started by Tracking cost per {capitalizedEntityLabel} </span>
<a
href="https://docs.litellm.ai/docs/proxy/enterprise#spend-tracking"
className="text-blue-500 hover:text-blue-700 ml-1"
>
here
</a>
</div>
</div>
<Grid numItems={2} className="gap-6">
<Col numColSpan={1}>
<BarChart
className="mt-4 h-52"
data={getProcessedEntityBreakdownForChart()}
index="metadata.alias_display"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
showLegend={false}
yAxisWidth={150}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.metadata.alias}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.metrics.spend, 4)}</p>
<p className="text-gray-600">Requests: {data.metrics.api_requests.toLocaleString()}</p>
<p className="text-green-600">
Successful: {data.metrics.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.metrics.failed_requests.toLocaleString()}</p>
<p className="text-gray-600">Tokens: {data.metrics.total_tokens.toLocaleString()}</p>
</div>
);
}}
/>
</Col>
<Col numColSpan={1}>
<div className="h-52 overflow-y-auto">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{capitalizedEntityLabel}</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{getEntityBreakdown()
.filter((entity) => entity.metrics.spend > 0)
.map((entity) => (
<TableRow key={entity.metadata.id}>
<TableCell>{entity.metadata.alias}</TableCell>
<TableCell>${formatNumberWithCommas(entity.metrics.spend, 4)}</TableCell>
<TableCell className="text-green-600">
{entity.metrics.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{entity.metrics.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{entity.metrics.total_tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Col>
</Grid>
</div>
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card>
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={getTopAPIKeys()}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</Col>
{/* Top Models */}
<Col numColSpan={1}>
<Card>
<Title>{entityType === "agent" ? "Top Agents" : "Top Models"}</Title>
<TopModelView
topModels={getTopModels()}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</Card>
</Col>
{/* Top Agents - only for team entity type */}
{entityType === "team" && (
<Col numColSpan={2}>
<Card>
<Title>Top Agents Driving Spend</Title>
<TopModelView
topModels={getTopAgents()}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
<Tabs defaultValue="cost" className="mt-1">
<TabsList>
<TabsTrigger value="cost">Cost</TabsTrigger>
<TabsTrigger value="model">
{entityType === "agent" ? "Request / Token Consumption" : "Model Activity"}
</TabsTrigger>
{entityType === "team" && <TabsTrigger value="agent">Agent Activity</TabsTrigger>}
<TabsTrigger value="key">Key Activity</TabsTrigger>
<TabsTrigger value="endpoint">Endpoint Activity</TabsTrigger>
</TabsList>
<TabsContent value="cost">
<div className="grid grid-cols-2 gap-2 w-full">
<div className="col-span-2">
<Card className="p-6">
<h3 className="text-lg font-semibold">{capitalizedEntityLabel} Spend Overview</h3>
<div className="grid grid-cols-5 gap-4 mt-4">
<Card className="p-6">
<h3 className="text-lg font-semibold">Total Spend</h3>
<p className="text-2xl font-bold mt-2">
${formatNumberWithCommas(spendData.metadata.total_spend, 2)}
</p>
</Card>
</Col>
)}
<Card className="p-6">
<h3 className="text-lg font-semibold">Total Requests</h3>
<p className="text-2xl font-bold mt-2">
{spendData.metadata.total_api_requests.toLocaleString()}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Successful Requests</h3>
<p className="text-2xl font-bold mt-2 text-green-600">
{spendData.metadata.total_successful_requests.toLocaleString()}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Failed Requests</h3>
<p className="text-2xl font-bold mt-2 text-red-600">
{spendData.metadata.total_failed_requests.toLocaleString()}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Total Tokens</h3>
<p className="text-2xl font-bold mt-2">
{spendData.metadata.total_tokens.toLocaleString()}
</p>
</Card>
</div>
</Card>
</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}>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend()}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]}
/>
</Col>
<Col numColSpan={1}>
{/* Daily Spend Chart */}
<div className="col-span-2">
<Card className="p-6">
<h3 className="text-lg font-semibold">Daily Spend</h3>
<BarChart
data={[...spendData.results].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
)}
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;
const entityCount = Object.keys(data.breakdown.entities || {}).length;
return (
<div className="bg-background p-4 shadow-lg rounded-lg border border-border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
<p className="text-muted-foreground">Total Requests: {data.metrics.api_requests}</p>
<p className="text-muted-foreground">Successful: {data.metrics.successful_requests}</p>
<p className="text-muted-foreground">Failed: {data.metrics.failed_requests}</p>
<p className="text-muted-foreground">Total Tokens: {data.metrics.total_tokens}</p>
<p className="text-muted-foreground">
Total {capitalizedEntityLabel}s: {entityCount}
</p>
<div className="mt-2 border-t border-border pt-2">
<p className="font-semibold">Spend by {capitalizedEntityLabel}:</p>
{Object.entries(data.breakdown.entities || {})
.sort(([, a], [, b]) => {
const spendA = (a as EntityMetrics).metrics.spend;
const spendB = (b as EntityMetrics).metrics.spend;
return spendB - spendA;
})
.slice(0, 5)
.map(([entity, entityData]) => {
const metrics = entityData as EntityMetrics;
return (
<p key={entity} className="text-sm text-muted-foreground">
{getEntityLabel(entity, metrics.metadata)}: $
{formatNumberWithCommas(metrics.metrics.spend, 2)}
</p>
);
})}
{entityCount > 5 && (
<p className="text-sm text-muted-foreground italic">...and {entityCount - 5} more</p>
)}
</div>
</div>
);
}}
/>
</Card>
</div>
{/* Entity Breakdown Section */}
<div className="col-span-2">
<Card className="p-6">
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-semibold">Spend Per {capitalizedEntityLabel}</h3>
<p className="text-xs text-muted-foreground">Showing Top 5 by Spend</p>
<div className="flex items-center text-sm text-muted-foreground">
<span>Get Started by Tracking cost per {capitalizedEntityLabel} </span>
<a
href="https://docs.litellm.ai/docs/proxy/enterprise#spend-tracking"
className="text-primary hover:underline ml-1"
>
here
</a>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<BarChart
className="mt-4 h-52"
data={getProcessedEntityBreakdownForChart()}
index="metadata.alias_display"
categories={["metrics.spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
showLegend={false}
yAxisWidth={150}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-background p-4 shadow-lg rounded-lg border border-border">
<p className="font-bold">{data.metadata.alias}</p>
<p className="text-cyan-500">Spend: ${formatNumberWithCommas(data.metrics.spend, 4)}</p>
<p className="text-muted-foreground">
Requests: {data.metrics.api_requests.toLocaleString()}
</p>
<p className="text-green-600">
Successful: {data.metrics.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">Failed: {data.metrics.failed_requests.toLocaleString()}</p>
<p className="text-muted-foreground">
Tokens: {data.metrics.total_tokens.toLocaleString()}
</p>
</div>
);
}}
/>
</div>
<div>
<div className="h-52 overflow-y-auto">
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell>Provider</TableHeaderCell>
<TableHeaderCell>Spend</TableHeaderCell>
<TableHeaderCell className="text-green-600">Successful</TableHeaderCell>
<TableHeaderCell className="text-red-600">Failed</TableHeaderCell>
<TableHeaderCell>Tokens</TableHeaderCell>
<TableHead>{capitalizedEntityLabel}</TableHead>
<TableHead>Spend</TableHead>
<TableHead className="text-green-600">Successful</TableHead>
<TableHead className="text-red-600">Failed</TableHead>
<TableHead>Tokens</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{getProviderSpend().map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && (
<img
src={getProviderLogoAndName(provider.provider).logo}
alt={`${provider.provider} logo`}
className="w-4 h-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs";
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
{getEntityBreakdown()
.filter((entity) => entity.metrics.spend > 0)
.map((entity) => (
<TableRow key={entity.metadata.id}>
<TableCell>{entity.metadata.alias}</TableCell>
<TableCell>${formatNumberWithCommas(entity.metrics.spend, 4)}</TableCell>
<TableCell className="text-green-600">
{entity.metrics.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{entity.metrics.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{entity.metrics.total_tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Col>
</Grid>
</div>
</div>
</div>
</div>
</Card>
</div>
{/* Top API Keys */}
<div>
<Card className="p-6">
<h3 className="text-lg font-semibold">Top Virtual Keys</h3>
<TopKeyView
topKeys={getTopAPIKeys()}
teams={null}
showTags={entityType === "tag"}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</div>
{/* Top Models */}
<div>
<Card className="p-6">
<h3 className="text-lg font-semibold">{entityType === "agent" ? "Top Agents" : "Top Models"}</h3>
<TopModelView
topModels={getTopModels()}
topModelsLimit={topModelsLimit}
setTopModelsLimit={setTopModelsLimit}
/>
</Card>
</div>
{/* Top Agents - only for team entity type */}
{entityType === "team" && (
<div className="col-span-2">
<Card className="p-6">
<h3 className="text-lg font-semibold">Top Agents Driving Spend</h3>
<TopModelView
topModels={getTopAgents()}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
</Card>
</Col>
</Grid>
</TabPanel>
<TabPanel>
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabPanel>
{entityType === "team" ? (
<TabPanel>
<ActivityMetrics modelMetrics={agentMetrics} />
</TabPanel>
) : (
<></>
)}
<TabPanel>
<ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabPanel>
<TabPanel>
<EndpointUsage userSpendData={spendData} />
</TabPanel>
</TabPanels>
</TabGroup>
</div>
)}
{/* Spend by Provider */}
<div className="col-span-2">
<Card className="p-6">
<div className="flex flex-col space-y-4">
<h3 className="text-lg font-semibold">Provider Usage</h3>
<div className="grid grid-cols-2">
<div>
<DonutChart
className="mt-4 h-40"
data={getProviderSpend()}
index="provider"
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]}
/>
</div>
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Provider</TableHead>
<TableHead>Spend</TableHead>
<TableHead className="text-green-600">Successful</TableHead>
<TableHead className="text-red-600">Failed</TableHead>
<TableHead>Tokens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getProviderSpend().map((provider) => (
<TableRow key={provider.provider}>
<TableCell>
<div className="flex items-center space-x-2">
{provider.provider && (
<img
src={getProviderLogoAndName(provider.provider).logo}
alt={`${provider.provider} logo`}
className="w-4 h-4"
onError={(e) => {
const target = e.target as HTMLImageElement;
const parent = target.parentElement;
if (parent) {
const fallbackDiv = document.createElement("div");
fallbackDiv.className =
"w-4 h-4 rounded-full bg-muted flex items-center justify-center text-xs";
fallbackDiv.textContent = provider.provider?.charAt(0) || "-";
parent.replaceChild(fallbackDiv, target);
}
}}
/>
)}
<span>{provider.provider}</span>
</div>
</TableCell>
<TableCell>${formatNumberWithCommas(provider.spend, 2)}</TableCell>
<TableCell className="text-green-600">
{provider.successful_requests.toLocaleString()}
</TableCell>
<TableCell className="text-red-600">
{provider.failed_requests.toLocaleString()}
</TableCell>
<TableCell>{provider.tokens.toLocaleString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="model">
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabsContent>
{entityType === "team" && (
<TabsContent value="agent">
<ActivityMetrics modelMetrics={agentMetrics} />
</TabsContent>
)}
<TabsContent value="key">
<ActivityMetrics modelMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />
</TabsContent>
<TabsContent value="endpoint">
<EndpointUsage userSpendData={spendData} />
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -35,6 +35,8 @@ describe("TopKeyView", () => {
const mockTransformKeyInfo = vi.mocked(transformKeyInfo.transformKeyInfo);
const mockAuth = {
isLoading: false,
isAuthorized: true,
token: "mock-token",
accessToken: "test-token",
userId: "user-1",
@ -43,7 +45,7 @@ describe("TopKeyView", () => {
premiumUser: true,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
};
} as any;
const mockSetTopKeysLimit = vi.fn();
@ -141,19 +143,12 @@ describe("TopKeyView", () => {
expect(tableViewButton).toHaveClass("bg-blue-100");
});
it("should call setTopKeysLimit when limit is changed via Segmented control", async () => {
it("should call setTopKeysLimit when limit is changed via ToggleGroup control", async () => {
const user = userEvent.setup();
render(<TopKeyView {...baseProps} />);
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 user.click(limit10Radio);
expect(mockSetTopKeysLimit).toHaveBeenCalledWith(10);
});

View file

@ -2,7 +2,10 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { ChevronDown, ChevronUp, X } from "lucide-react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { BarChart } from "@tremor/react";
import { Segmented } from "antd";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
@ -214,16 +217,20 @@ const TopKeyView: React.FC<TopKeyViewProps> = ({ topKeys, teams, showTags = fals
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={topKeysLimit}
onChange={(value) => setTopKeysLimit(value as number)}
/>
<ToggleGroup
type="single"
value={String(topKeysLimit)}
onValueChange={(v) => {
if (!v) return;
setTopKeysLimit(parseInt(v));
}}
>
{[5, 10, 25, 50].map((n) => (
<ToggleGroupItem key={n} value={String(n)}>
{n}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex space-x-2">
<button
type="button"

View file

@ -1,10 +1,17 @@
import React, { useEffect, useRef, useState } from "react";
import { Button, Select, Input, Spin } from "antd";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { modelHubCall, usageAiChatStream, UsageAiToolCallEvent } from "../../networking";
const { TextArea } = Input;
interface ToolCallStep {
tool_name: string;
tool_label: string;
@ -31,6 +38,8 @@ const TOOL_ICONS: Record<string, string> = {
get_tag_usage_data: "🏷️",
};
const MODEL_SELECT_PLACEHOLDER = "Select a model (optional, defaults to gpt-4o-mini)";
const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
const icon = TOOL_ICONS[step.tool_name] || "🔧";
const args = step.arguments;
@ -40,28 +49,28 @@ const ToolCallDisplay: React.FC<{ step: ToolCallStep }> = ({ step }) => {
const filter = args.team_ids || args.tags || args.user_id || "";
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-gray-100 border border-gray-200 text-xs">
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-muted border border-border text-xs">
<span className="flex-shrink-0 mt-0.5">
{step.status === "running" ? (
<Spin size="small" />
<Loader2 className="h-3 w-3 animate-spin" />
) : step.status === "error" ? (
<span className="text-red-500">✗</span>
<span className="text-destructive">✗</span>
) : (
<span className="text-green-600">✓</span>
)}
</span>
<div className="min-w-0">
<div className="font-medium text-gray-700">
<div className="font-medium text-foreground">
{icon} {step.tool_label}
</div>
{dateRange && (
<div className="text-gray-500 mt-0.5">{dateRange}</div>
<div className="text-muted-foreground mt-0.5">{dateRange}</div>
)}
{filter && (
<div className="text-gray-500 mt-0.5">Filter: {filter}</div>
<div className="text-muted-foreground mt-0.5">Filter: {filter}</div>
)}
{step.status === "error" && step.error && (
<div className="text-red-600 mt-0.5">{step.error}</div>
<div className="text-destructive mt-0.5">{step.error}</div>
)}
</div>
</div>
@ -82,11 +91,11 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
code: ({ children, className }) => {
const isBlock = className?.includes("language-");
return isBlock ? (
<pre className="bg-gray-100 rounded p-2 my-1 overflow-x-auto text-xs">
<pre className="bg-muted rounded p-2 my-1 overflow-x-auto text-xs">
<code>{children}</code>
</pre>
) : (
<code className="px-1 py-0.5 rounded bg-gray-100 text-xs font-mono">{children}</code>
<code className="px-1 py-0.5 rounded bg-muted text-xs font-mono">{children}</code>
);
},
table: ({ children }) => (
@ -94,8 +103,10 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => (
<table className="text-xs border-collapse w-full">{children}</table>
</div>
),
th: ({ children }) => <th className="border border-gray-200 px-2 py-1 bg-gray-50 font-medium text-left">{children}</th>,
td: ({ children }) => <td className="border border-gray-200 px-2 py-1">{children}</td>,
th: ({ children }) => (
<th className="border border-border px-2 py-1 bg-muted font-medium text-left">{children}</th>
),
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
}}
>
{content}
@ -249,56 +260,57 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
return (
<div
data-testid="usage-ai-chat-panel"
className={`fixed top-0 right-0 h-full bg-white border-l border-gray-200 shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out ${
className={`fixed top-0 right-0 h-full bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out ${
open ? "translate-x-0" : "translate-x-full"
}`}
style={{ width: 420 }}
>
{/* Header */}
<div className="px-5 pt-5 pb-3 border-b border-gray-100 flex-shrink-0">
<div className="px-5 pt-5 pb-3 border-b border-border flex-shrink-0">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-blue-600" viewBox="0 0 16 16" fill="currentColor">
<svg className="w-5 h-5 text-primary" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1l1.5 3.5L13 6l-3.5 1.5L8 11 6.5 7.5 3 6l3.5-1.5L8 1zm4 7l.75 1.75L14.5 10.5l-1.75.75L12 13l-.75-1.75L9.5 10.5l1.75-.75L12 8zM4 9l.75 1.75L6.5 11.5l-1.75.75L4 14l-.75-1.75L1.5 11.5l1.75-.75L4 9z" />
</svg>
<h3 className="text-base font-semibold text-gray-900">Ask AI</h3>
<h3 className="text-base font-semibold text-foreground">Ask AI</h3>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-md hover:bg-gray-100"
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-muted"
aria-label="Close"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-xs text-gray-500">
<p className="text-xs text-muted-foreground">
Ask about your spend, models, keys, and trends
</p>
</div>
{/* Model selector */}
<div className="px-5 py-3 border-b border-gray-100 flex-shrink-0">
<div className="px-5 py-3 border-b border-border flex-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())
}
/>
value={selectedModel ?? ""}
onValueChange={(value) => setSelectedModel(value || undefined)}
disabled={isLoadingModels}
>
<SelectTrigger className="w-full h-8 text-sm">
<SelectValue placeholder={MODEL_SELECT_PLACEHOLDER} />
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Chat messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-muted/50">
{messages.length === 0 && !streamingContent && !isLoading && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<svg className="w-8 h-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
@ -311,7 +323,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
<div key={idx}>
{msg.role === "user" ? (
<div className="flex justify-end">
<div className="max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed bg-blue-600 text-white">
<div className="max-w-[88%] rounded-xl px-3.5 py-2 text-sm leading-relaxed bg-primary text-primary-foreground">
{msg.content}
</div>
</div>
@ -326,7 +338,7 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
</div>
)}
{/* Response */}
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-white border border-gray-200 text-gray-800">
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-background border border-border text-foreground">
<MarkdownContent content={msg.content} />
</div>
</div>
@ -345,15 +357,15 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
{/* Status / spinner */}
{isLoading && !streamingContent && (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500">
<Spin size="small" />
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="italic">{statusMessage || "Thinking..."}</span>
</div>
)}
{/* Streaming response */}
{streamingContent && (
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-white border border-gray-200 text-gray-800">
<div className="max-w-[95%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed bg-background border border-border text-foreground">
<MarkdownContent content={streamingContent} />
</div>
)}
@ -362,35 +374,33 @@ const UsageAIChatPanel: React.FC<UsageAIChatPanelProps> = ({
</div>
{/* Input area */}
<div className="px-4 py-3 border-t border-gray-200 bg-white flex-shrink-0">
<div className="px-4 py-3 border-t border-border bg-background flex-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-[36px] resize-none"
disabled={isLoading}
/>
<Button
type="primary"
onClick={handleSend}
disabled={!inputText.trim() || isLoading}
loading={isLoading}
>
Send
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
</Button>
</div>
<div className="flex justify-between items-center mt-2">
<button
onClick={handleClear}
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={messages.length === 0}
>
Clear chat
</button>
<span className="text-xs text-gray-400">
<span className="text-xs text-muted-foreground">
Enter to send
</span>
</div>

View file

@ -6,24 +6,41 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
import { ChevronDown as DownOutlined, Upload as ExportOutlined, Info as InfoCircleOutlined, Loader2 as LoadingOutlined, ChevronRight as RightOutlined } from "lucide-react";
import {
ChevronDown as DownOutlined,
Upload as ExportOutlined,
Info as InfoCircleOutlined,
Loader2 as LoadingOutlined,
ChevronRight as RightOutlined,
} from "lucide-react";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { BarChart, DateRangePickerValue } from "@tremor/react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
BarChart,
Card,
Col,
DateRangePickerValue,
Grid,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
Text,
Title,
} from "@tremor/react";
import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd";
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
@ -58,6 +75,121 @@ interface UsagePageProps {
organizations: Organization[];
}
interface UserOption {
value: string;
label: string;
}
interface UserFilterSelectProps {
value: string | null;
onChange: (value: string | null) => void;
options: UserOption[];
searchValue: string;
onSearchChange: (value: string) => void;
onScroll: (e: UIEvent<HTMLDivElement>) => void;
isLoadingUsers: boolean;
isFetchingNextPage: boolean;
}
const UserFilterSelect: React.FC<UserFilterSelectProps> = ({
value,
onChange,
options,
searchValue,
onSearchChange,
onScroll,
isLoadingUsers,
isFetchingNextPage,
}) => {
const [open, setOpen] = useState(false);
const selectedLabel = useMemo(() => options.find((o) => o.value === value)?.label ?? "", [options, value]);
return (
<div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
tabIndex={0}
aria-expanded={open}
{...({ placeholder: "Select user to filter..." } as Record<string, string>)}
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((o) => !o);
}
}}
onClick={() => setOpen((o) => !o)}
>
<span className={value ? "" : "text-muted-foreground"}>
{selectedLabel || "Select user to filter..."}
</span>
<div className="flex items-center gap-2">
{value && (
<span
role="button"
aria-label="Clear selection"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onChange(null);
onSearchChange("");
}}
>
×
</span>
)}
{isLoadingUsers ? <LoadingOutlined className="h-4 w-4 animate-spin" /> : null}
</div>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<div className="p-2 border-b border-border">
<Input
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search users..."
className="h-8"
/>
</div>
<div className="max-h-64 overflow-y-auto" onScroll={onScroll}>
{options.length === 0 ? (
<div className="py-3 text-center text-sm text-muted-foreground">
{isLoadingUsers ? <LoadingOutlined className="inline h-4 w-4 animate-spin" /> : "No users found"}
</div>
) : (
options.map((opt) => (
<button
key={opt.value}
type="button"
className="flex w-full items-center px-3 py-2 text-sm text-left hover:bg-muted"
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
{opt.label}
</button>
))
)}
{isFetchingNextPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined className="inline h-4 w-4 animate-spin" />
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Hidden options so tests can assert on the list even when popover is closed */}
<div className="hidden" aria-hidden="true">
{options.map((opt) => (
<span key={opt.value}>{opt.label}</span>
))}
</div>
</div>
);
};
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
// Aggregated endpoint: try first, fall back to paginated if unavailable
@ -442,399 +574,384 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{paginatedResult.isFetchingMore && (
<Alert
banner
type="warning"
className="mb-2"
message={
<Alert className="mb-2">
<AlertDescription>
<div className="flex items-center justify-between">
<span>
<LoadingOutlined className="animate-spin mr-2" />
<LoadingOutlined className="animate-spin mr-2 inline h-4 w-4" />
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 />
open a new tab <ExportOutlined className="inline h-3 w-3" />
</a>
.
</span>
<Button type="primary" danger onClick={paginatedResult.cancel}>
<Button variant="destructive" onClick={paginatedResult.cancel}>
Stop
</Button>
</div>
}
/>
</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 className="mb-2">
<AlertDescription>
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}{" "}
pages loaded)
</AlertDescription>
</Alert>
)}
{/* Your Usage Panel */}
{usageView === "global" && (
<>
{isAdmin && (
<div className="mb-4">
<Text className="mb-2">Filter by user</Text>
<Select
showSearch
allowClear
style={{ width: "100%" }}
placeholder="Select user to filter..."
<p className="mb-2 text-sm">Filter by user</p>
<UserFilterSelect
value={selectedUserId}
onChange={(value) => setSelectedUserId(value ?? null)}
filterOption={false}
onSearch={handleUserSearchChange}
searchValue={userSearchInput}
onPopupScroll={handleUserPopupScroll}
loading={isLoadingUsers}
notFoundContent={isLoadingUsers ? <LoadingOutlined className="animate-spin" /> : "No users found"}
onChange={setSelectedUserId}
options={userOptions}
popupRender={(menu) => (
<>
{menu}
{isFetchingNextUsersPage && (
<div style={{ textAlign: "center", padding: 8 }}>
<LoadingOutlined className="animate-spin" />
</div>
)}
</>
)}
searchValue={userSearchInput}
onSearchChange={handleUserSearchChange}
onScroll={handleUserPopupScroll}
isLoadingUsers={isLoadingUsers}
isFetchingNextPage={isFetchingNextUsersPage}
/>
</div>
)}
<TabGroup>
<Tabs defaultValue="cost" className="mt-1">
<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>
<TabsTrigger value="cost">Cost</TabsTrigger>
<TabsTrigger value="model">Model Activity</TabsTrigger>
<TabsTrigger value="key">Key Activity</TabsTrigger>
<TabsTrigger value="mcp">MCP Server Activity</TabsTrigger>
<TabsTrigger value="endpoint">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)}>
<svg className="w-4 h-4 mr-2" 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>
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)}>
<svg className="w-4 h-4 mr-2" 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>
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">
<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-foreground 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",
})}
</>
)}
</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>
<Title>Successful Requests</Title>
<Text className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
</Text>
</Card>
<Card>
<div className="flex items-center gap-2">
<Title>Failed Requests</Title>
<Tooltip title="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" />
<div className="col-span-2">
<Card className="p-6">
<h3 className="text-lg font-semibold">Usage Metrics</h3>
<div className="grid grid-cols-5 gap-4 mt-4">
<Card className="p-6">
<h3 className="text-lg font-semibold">Total Requests</h3>
<p className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_api_requests?.toLocaleString() || 0}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Successful Requests</h3>
<p className="text-2xl font-bold mt-2 text-green-600">
{userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0}
</p>
</Card>
<Card className="p-6">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Failed Requests</h3>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircleOutlined className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent>
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>
<Text className="text-2xl font-bold mt-2 text-red-600">
{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
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>
{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">
{Math.max(
0,
(userSpendData.metadata?.total_prompt_tokens || 0) -
(userSpendData.metadata?.total_cache_read_input_tokens || 0) -
(userSpendData.metadata?.total_cache_creation_input_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>
)}
</Card>
</Col>
{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
{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>
);
}}
/>
)}
</Card>
</Col>
{/* Top API Keys */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>Top Virtual Keys</Title>
<TopKeyView
topKeys={topKeys}
teams={null}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Card>
</Col>
{/* Top Models */}
<Col numColSpan={1}>
<Card className="h-full">
<Title>{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}</Title>
<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)}
/>
<div className="flex bg-gray-100 rounded-lg p-1">
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "groups"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("groups")}
>
Public Model Name
</button>
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "individual"
? "bg-white shadow-sm text-gray-900"
: "text-gray-600 hover:text-gray-900"
}`}
onClick={() => setModelViewType("individual")}
>
Litellm Model Name
</button>
</TooltipProvider>
</div>
<p className="text-2xl font-bold mt-2 text-red-600">
{userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Average Cost per Request</h3>
<p className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</p>
</Card>
<Card
className="p-6 cursor-pointer hover:bg-muted transition-colors"
onClick={() => setShowTokenBreakdown(!showTokenBreakdown)}
>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Total Tokens</h3>
{showTokenBreakdown ? (
<DownOutlined className="h-3 w-3 text-muted-foreground" />
) : (
<RightOutlined className="h-3 w-3 text-muted-foreground" />
)}
</div>
<p className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</p>
</Card>
</div>
{showTokenBreakdown && (
<div className="grid grid-cols-4 gap-4 mt-4">
<Card className="p-6">
<h3 className="text-lg font-semibold">Input Tokens</h3>
<p className="text-2xl font-bold mt-2 text-blue-600">
{Math.max(
0,
(userSpendData.metadata?.total_prompt_tokens || 0) -
(userSpendData.metadata?.total_cache_read_input_tokens || 0) -
(userSpendData.metadata?.total_cache_creation_input_tokens || 0),
).toLocaleString()}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">Output Tokens</h3>
<p className="text-2xl font-bold mt-2 text-cyan-600">
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
</p>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">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>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold">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>
</Card>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="relative max-h-[600px] overflow-y-auto">
{(() => {
const modelData = modelViewType === "groups" ? topModelGroups : topModels;
return (
<BarChart
className="mt-4"
style={{ height: Math.min(modelData.length, topModelsLimit) * 52 }}
data={modelData}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.spend, 2)}
</p>
<p className="text-gray-600">
Total Requests: {data.requests.toLocaleString()}
</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">
Failed: {data.failed_requests.toLocaleString()}
</p>
<p className="text-gray-600">Tokens: {data.tokens.toLocaleString()}</p>
</div>
);
}}
/>
);
})()}
</div>
)}
</Card>
</Col>
)}
</Card>
</div>
{/* Spend by Provider */}
<Col numColSpan={2}>
<SpendByProvider
loading={loading}
isDateChanging={isDateChanging}
providerSpend={providerSpend}
{/* Daily Spend Chart */}
<div className="col-span-2">
<Card className="p-6">
<h3 className="text-lg font-semibold">Daily Spend</h3>
{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-background p-4 shadow-lg rounded-lg border border-border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-muted-foreground">Requests: {data.metrics.api_requests}</p>
<p className="text-muted-foreground">
Successful: {data.metrics.successful_requests}
</p>
<p className="text-muted-foreground">Failed: {data.metrics.failed_requests}</p>
<p className="text-muted-foreground">Tokens: {data.metrics.total_tokens}</p>
</div>
);
}}
/>
)}
</Card>
</div>
{/* Top API Keys */}
<div>
<Card className="p-6 h-full">
<h3 className="text-lg font-semibold">Top Virtual Keys</h3>
<TopKeyView
topKeys={topKeys}
teams={null}
topKeysLimit={topKeysLimit}
setTopKeysLimit={setTopKeysLimit}
/>
</Col>
</Card>
</div>
{/* Usage Metrics */}
</Grid>
</TabPanel>
{/* Top Models */}
<div>
<Card className="p-6 h-full">
<h3 className="text-lg font-semibold">
{modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"}
</h3>
<div className="flex justify-between items-center mb-4">
<ToggleGroup
type="single"
value={String(topModelsLimit)}
onValueChange={(v) => {
if (!v) return;
setTopModelsLimit(parseInt(v));
}}
>
{[5, 10, 25, 50].map((n) => (
<ToggleGroupItem key={n} value={String(n)}>
{n}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex bg-muted rounded-lg p-1">
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "groups"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setModelViewType("groups")}
>
Public Model Name
</button>
<button
className={`px-3 py-1 text-sm rounded-md transition-colors ${
modelViewType === "individual"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setModelViewType("individual")}
>
Litellm Model Name
</button>
</div>
</div>
{loading ? (
<ChartLoader isDateChanging={isDateChanging} />
) : (
<div className="relative max-h-[600px] overflow-y-auto">
{(() => {
const modelData = modelViewType === "groups" ? topModelGroups : topModels;
return (
<BarChart
className="mt-4"
style={{ height: Math.min(modelData.length, topModelsLimit) * 52 }}
data={modelData}
index="key"
categories={["spend"]}
colors={["cyan"]}
valueFormatter={valueFormatterSpend}
layout="vertical"
yAxisWidth={200}
showLegend={false}
customTooltip={({ payload, active }) => {
if (!active || !payload?.[0]) return null;
const data = payload[0].payload;
return (
<div className="bg-background p-4 shadow-lg rounded-lg border border-border">
<p className="font-bold">{data.key}</p>
<p className="text-cyan-500">
Spend: ${formatNumberWithCommas(data.spend, 2)}
</p>
<p className="text-muted-foreground">
Total Requests: {data.requests.toLocaleString()}
</p>
<p className="text-green-600">
Successful: {data.successful_requests.toLocaleString()}
</p>
<p className="text-red-600">
Failed: {data.failed_requests.toLocaleString()}
</p>
<p className="text-muted-foreground">
Tokens: {data.tokens.toLocaleString()}
</p>
</div>
);
}}
/>
);
})()}
</div>
)}
</Card>
</div>
{/* Activity Panel */}
<TabPanel>
<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="model">
<ActivityMetrics modelMetrics={modelMetrics} />
</TabsContent>
<TabsContent value="key">
<ActivityMetrics modelMetrics={keyMetrics} />
</TabsContent>
<TabsContent value="mcp">
<ActivityMetrics modelMetrics={mcpServerMetrics} />
</TabsContent>
<TabsContent value="endpoint">
<EndpointUsage userSpendData={userSpendData} />
</TabsContent>
</Tabs>
</>
)}
{/* Organization Usage Panel */}
@ -895,21 +1012,28 @@ 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 className="mb-5">
<AlertDescription>
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-medium mb-1">Reusable credentials are automatically tracked as tags</div>
<div>
When a reusable credential is used, it will appear as a tag prefixed with{" "}
<code className="px-1 py-0.5 rounded bg-muted text-xs font-mono">Credential: </code>
in this view.
</div>
</div>
<button
type="button"
onClick={() => setShowCredentialBanner(false)}
aria-label="Close"
className="text-muted-foreground hover:text-foreground"
>
×
</button>
</div>
</AlertDescription>
</Alert>
)}
<EntityUsage
accessToken={accessToken}

File diff suppressed because one or more lines are too long