diff --git a/ui/litellm-dashboard/.eslintrc.json b/ui/litellm-dashboard/.eslintrc.json index 15d2b45de20..1c2f96fefd7 100644 --- a/ui/litellm-dashboard/.eslintrc.json +++ b/ui/litellm-dashboard/.eslintrc.json @@ -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", diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index dc201cccfea..26bbd956d86 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -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(); 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); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 232b32ebe7e..6b593472929 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -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 = ({ accessToken, entityType, enti return (
{isFetchingMore && ( - +
- + 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,{" "} - open a new tab + open a new tab . -
- } - /> +
+
)} {cancelled && ( - - Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) - - } - /> + + + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + + )} {agentIsFetchingMore && entityType === "team" && ( - +
- + 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,{" "} - open a new tab + open a new tab . -
- } - /> +
+
)} {agentCancelled && entityType === "team" && ( - - Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) - - } - /> + + + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + + )} {entityType === "team" && (
- Filter by team - +

Filter by team

+
)} = ({ accessToken, entityType, enti filterMode={entityType === "user" ? "single" : "multiple"} teams={teams || []} /> - - - Cost - {entityType === "agent" ? "Request / Token Consumption" : "Model Activity"} - {entityType === "team" ? Agent Activity : <>} - Key Activity - Endpoint Activity - - - - - {/* Total Spend Card */} - - - {capitalizedEntityLabel} Spend Overview - - - Total Spend - - ${formatNumberWithCommas(spendData.metadata.total_spend, 2)} - - - - Total Requests - - {spendData.metadata.total_api_requests.toLocaleString()} - - - - Successful Requests - - {spendData.metadata.total_successful_requests.toLocaleString()} - - - - Failed Requests - - {spendData.metadata.total_failed_requests.toLocaleString()} - - - - Total Tokens - - {spendData.metadata.total_tokens.toLocaleString()} - - - - - - - {/* Daily Spend Chart */} - - - Daily Spend - 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 ( -
-

{data.date}

-

Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}

-

Total Requests: {data.metrics.api_requests}

-

Successful: {data.metrics.successful_requests}

-

Failed: {data.metrics.failed_requests}

-

Total Tokens: {data.metrics.total_tokens}

-

- Total {capitalizedEntityLabel}s: {entityCount} -

-
-

Spend by {capitalizedEntityLabel}:

- {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 ( -

- {getEntityLabel(entity, metrics.metadata)}: $ - {formatNumberWithCommas(metrics.metrics.spend, 2)} -

- ); - })} - {entityCount > 5 && ( -

...and {entityCount - 5} more

- )} -
-
- ); - }} - /> -
- - - {/* Entity Breakdown Section */} - - -
-
- Spend Per {capitalizedEntityLabel} - Showing Top 5 by Spend -
- Get Started by Tracking cost per {capitalizedEntityLabel} - - here - -
-
- - - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.metadata.alias}

-

Spend: ${formatNumberWithCommas(data.metrics.spend, 4)}

-

Requests: {data.metrics.api_requests.toLocaleString()}

-

- Successful: {data.metrics.successful_requests.toLocaleString()} -

-

Failed: {data.metrics.failed_requests.toLocaleString()}

-

Tokens: {data.metrics.total_tokens.toLocaleString()}

-
- ); - }} - /> - - -
- - - - {capitalizedEntityLabel} - Spend - Successful - Failed - Tokens - - - - {getEntityBreakdown() - .filter((entity) => entity.metrics.spend > 0) - .map((entity) => ( - - {entity.metadata.alias} - ${formatNumberWithCommas(entity.metrics.spend, 4)} - - {entity.metrics.successful_requests.toLocaleString()} - - - {entity.metrics.failed_requests.toLocaleString()} - - {entity.metrics.total_tokens.toLocaleString()} - - ))} - -
-
- -
-
-
- - - {/* Top API Keys */} - - - Top Virtual Keys - - - - - {/* Top Models */} - - - {entityType === "agent" ? "Top Agents" : "Top Models"} - - - - - {/* Top Agents - only for team entity type */} - {entityType === "team" && ( - - - Top Agents Driving Spend - + + + Cost + + {entityType === "agent" ? "Request / Token Consumption" : "Model Activity"} + + {entityType === "team" && Agent Activity} + Key Activity + Endpoint Activity + + +
+
+ +

{capitalizedEntityLabel} Spend Overview

+
+ +

Total Spend

+

+ ${formatNumberWithCommas(spendData.metadata.total_spend, 2)} +

- - )} + +

Total Requests

+

+ {spendData.metadata.total_api_requests.toLocaleString()} +

+
+ +

Successful Requests

+

+ {spendData.metadata.total_successful_requests.toLocaleString()} +

+
+ +

Failed Requests

+

+ {spendData.metadata.total_failed_requests.toLocaleString()} +

+
+ +

Total Tokens

+

+ {spendData.metadata.total_tokens.toLocaleString()} +

+
+
+
+
- {/* Spend by Provider */} - - -
- Provider Usage - - - `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan", "blue", "indigo", "violet", "purple"]} - /> - - + {/* Daily Spend Chart */} +
+ +

Daily Spend

+ 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 ( +
+

{data.date}

+

Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}

+

Total Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Total Tokens: {data.metrics.total_tokens}

+

+ Total {capitalizedEntityLabel}s: {entityCount} +

+
+

Spend by {capitalizedEntityLabel}:

+ {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 ( +

+ {getEntityLabel(entity, metrics.metadata)}: $ + {formatNumberWithCommas(metrics.metrics.spend, 2)} +

+ ); + })} + {entityCount > 5 && ( +

...and {entityCount - 5} more

+ )} +
+
+ ); + }} + /> +
+
+ + {/* Entity Breakdown Section */} +
+ +
+
+

Spend Per {capitalizedEntityLabel}

+

Showing Top 5 by Spend

+
+ Get Started by Tracking cost per {capitalizedEntityLabel} + + here + +
+
+
+
+ { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.metadata.alias}

+

Spend: ${formatNumberWithCommas(data.metrics.spend, 4)}

+

+ Requests: {data.metrics.api_requests.toLocaleString()} +

+

+ Successful: {data.metrics.successful_requests.toLocaleString()} +

+

Failed: {data.metrics.failed_requests.toLocaleString()}

+

+ Tokens: {data.metrics.total_tokens.toLocaleString()} +

+
+ ); + }} + /> +
+
+
- + - Provider - Spend - Successful - Failed - Tokens + {capitalizedEntityLabel} + Spend + Successful + Failed + Tokens - + - {getProviderSpend().map((provider) => ( - - -
- {provider.provider && ( - {`${provider.provider} { - 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); - } - }} - /> - )} - {provider.provider} -
-
- ${formatNumberWithCommas(provider.spend, 2)} - - {provider.successful_requests.toLocaleString()} - - - {provider.failed_requests.toLocaleString()} - - {provider.tokens.toLocaleString()} -
- ))} + {getEntityBreakdown() + .filter((entity) => entity.metrics.spend > 0) + .map((entity) => ( + + {entity.metadata.alias} + ${formatNumberWithCommas(entity.metrics.spend, 4)} + + {entity.metrics.successful_requests.toLocaleString()} + + + {entity.metrics.failed_requests.toLocaleString()} + + {entity.metrics.total_tokens.toLocaleString()} + + ))}
- - +
+
+
+
+
+ + {/* Top API Keys */} +
+ +

Top Virtual Keys

+ +
+
+ + {/* Top Models */} +
+ +

{entityType === "agent" ? "Top Agents" : "Top Models"}

+ +
+
+ + {/* Top Agents - only for team entity type */} + {entityType === "team" && ( +
+ +

Top Agents Driving Spend

+
- - - - - - - {entityType === "team" ? ( - - - - ) : ( - <> - )} - - - - - - - - +
+ )} + + {/* Spend by Provider */} +
+ +
+

Provider Usage

+
+
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan", "blue", "indigo", "violet", "purple"]} + /> +
+
+ + + + Provider + Spend + Successful + Failed + Tokens + + + + {getProviderSpend().map((provider) => ( + + +
+ {provider.provider && ( + {`${provider.provider} { + 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); + } + }} + /> + )} + {provider.provider} +
+
+ ${formatNumberWithCommas(provider.spend, 2)} + + {provider.successful_requests.toLocaleString()} + + + {provider.failed_requests.toLocaleString()} + + {provider.tokens.toLocaleString()} +
+ ))} +
+
+
+
+
+
+
+
+ + + + + {entityType === "team" && ( + + + + )} + + + + + + +
); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index 126bf51bd36..b078ca94b58 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -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(); 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); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 484aa0fcb18..52a6ff7c3c0 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -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 = ({ topKeys, teams, showTags = fals return ( <>
- setTopKeysLimit(value as number)} - /> + { + if (!v) return; + setTopKeysLimit(parseInt(v)); + }} + > + {[5, 10, 25, 50].map((n) => ( + + {n} + + ))} +
-

+

Ask about your spend, models, keys, and trends

{/* Model selector */} -
+
{/* Chat messages */} -
+
{messages.length === 0 && !streamingContent && !isLoading && ( -
+
@@ -311,7 +323,7 @@ const UsageAIChatPanel: React.FC = ({
{msg.role === "user" ? (
-
+
{msg.content}
@@ -326,7 +338,7 @@ const UsageAIChatPanel: React.FC = ({
)} {/* Response */} -
+
@@ -345,15 +357,15 @@ const UsageAIChatPanel: React.FC = ({ {/* Status / spinner */} {isLoading && !streamingContent && ( -
- +
+ {statusMessage || "Thinking..."}
)} {/* Streaming response */} {streamingContent && ( -
+
)} @@ -362,35 +374,33 @@ const UsageAIChatPanel: React.FC = ({
{/* Input area */} -
+
-