mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
889d12f9b9
commit
6badcee6c2
8 changed files with 962 additions and 845 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue