diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f328ecbdcfa..32cd1cd6eff 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1527,21 +1527,6 @@ "count": 1 } }, - "src/components/UsagePage/components/EntityUsage/TopKeyView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/UsagePage/components/EntityUsage/TopModelView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/components/UsagePage/components/KeyModelUsageView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/UsagePage/components/UsageAIChatPanel.tsx": { "no-nested-ternary": { "count": 1 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 15f572717e7..c95f81e3086 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 @@ -575,7 +575,7 @@ describe("EntityUsage", () => { }); await waitFor(() => { - expect(screen.getByText("Tag 1")).toBeInTheDocument(); + expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0); }); }); @@ -587,7 +587,7 @@ describe("EntityUsage", () => { }); await waitFor(() => { - expect(screen.getByText("Tag 1")).toBeInTheDocument(); + expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0); }); }); @@ -700,10 +700,37 @@ describe("EntityUsage", () => { }); await waitFor(() => { - expect(screen.getByText("tag-1")).toBeInTheDocument(); + expect(screen.getAllByText("tag-1").length).toBeGreaterThan(0); }); }); + it("renders daily spend bars, per-entity bars, and the provider donut with cyan fills and a $ center total", async () => { + const { container } = render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + }); + + const barFills = new Set( + Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")), + ); + expect(barFills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); + + expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0); + expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(1); + + const sectors = container.querySelectorAll(".recharts-pie-sector path"); + expect(sectors).toHaveLength(1); + expect(sectors[0].getAttribute("fill")).toBe("var(--color-cyan-500, #06b6d4)"); + + const centerLabels = Array.from(container.querySelectorAll("text.fill-foreground")).map((text) => text.textContent); + expect(centerLabels).toContain("$100.50"); + }); + it("should label the chart with user_email metadata instead of the raw UUID (LIT-3889)", async () => { const userUuid = "c0e68be8-057e-4e2f-9d3a-000000000000"; const spendDataForUser = { 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 9dd72f44f67..8e48b1e9139 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -1,12 +1,12 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { BarChart, DonutChart } from "@/components/shared/charts"; import { MoneyCell } from "@/components/shared/table_cells"; +import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { - BarChart, Card, Col, DateRangePickerValue, - DonutChart, Grid, Subtitle, Tab, @@ -61,9 +61,9 @@ interface EntityMetrics { metadata: Record; } -interface ExtendedDailyData extends DailyData { +type ExtendedDailyData = DailyData & { breakdown: BreakdownMetrics; -} +}; interface EntitySpendData { results: ExtendedDailyData[]; @@ -545,60 +545,66 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti {/* 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

- )} + + + 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 */} @@ -741,6 +747,9 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti category="spend" valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`} colors={["cyan", "blue", "indigo", "violet", "purple"]} + showLabel + startAngle={90} + endAngle={-270} /> diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx index 7eea7653ef1..983da5a4abb 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx @@ -167,7 +167,7 @@ describe("SpendByProvider", () => { }, ]; render(); - expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getAllByText("$100.00").length).toBeGreaterThan(0); }); it("should handle provider with empty string provider name", () => { @@ -182,7 +182,7 @@ describe("SpendByProvider", () => { }, ]; render(); - expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getAllByText("$100.00").length).toBeGreaterThan(0); }); it("should display large token numbers with comma formatting", () => { @@ -216,6 +216,52 @@ describe("SpendByProvider", () => { expect(screen.queryByText("unknown")).not.toBeInTheDocument(); }); + it("renders one cyan donut sector per visible provider with the $ total as center label", () => { + const { container } = render( + , + ); + + const sectors = container.querySelectorAll(".recharts-pie-sector path"); + expect(sectors).toHaveLength(2); + const fills = new Set(Array.from(sectors).map((sector) => sector.getAttribute("fill"))); + expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); + + const centerLabels = Array.from(container.querySelectorAll("text.fill-foreground")).map((text) => text.textContent); + expect(centerLabels).toContain("$351.25"); + }); + + it("adds the unknown provider slice and updates the center total when Show Unknown is on", () => { + const providerSpendWithUnknown = [ + { + provider: "openai", + spend: 150.5, + requests: 100, + successful_requests: 95, + failed_requests: 5, + tokens: 50000, + }, + { + provider: "unknown", + spend: 50, + requests: 10, + successful_requests: 5, + failed_requests: 5, + tokens: 1000, + }, + ]; + const { container } = render( + , + ); + + expect(container.querySelectorAll(".recharts-pie-sector path")).toHaveLength(1); + expect(container.querySelector("text.fill-foreground")?.textContent).toBe("$150.50"); + + fireEvent.click(screen.getAllByRole("switch")[1]); + + expect(container.querySelectorAll(".recharts-pie-sector path")).toHaveLength(2); + expect(container.querySelector("text.fill-foreground")?.textContent).toBe("$200.50"); + }); + it("should include all providers with spend greater than zero by default", () => { const providerSpendWithMixed = [ { diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx index 5cea84affb9..89b574d2a33 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx @@ -1,10 +1,10 @@ +import { DonutChart } from "@/components/shared/charts"; import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Card, Col, - DonutChart, Grid, Switch, Table, @@ -20,14 +20,14 @@ import React, { useState } from "react"; import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import { ChartLoader } from "../../../shared/chart_loader"; -interface ProviderSpendData { +type ProviderSpendData = { provider: string; spend: number; requests: number; successful_requests: number; failed_requests: number; tokens: number; -} +}; interface SpendByProviderProps { loading: boolean; @@ -88,6 +88,9 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi category="spend" valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`} colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} /> 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 766f027434f..be13ea1a7d3 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 @@ -1,5 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { KeyResponse } from "../../../key_team_helpers/key_list"; @@ -128,6 +128,45 @@ describe("TopKeyView", () => { expect(chartViewButton).toHaveClass("bg-blue-100"); }); + it("renders cyan bars with truncated aliases in chart view and opens the key info modal on bar click", async () => { + const mockKeyInfo = { key: "info" }; + const mockTransformedData = { transformed: "data" } as unknown as KeyResponse; + mockKeyInfoV1Call.mockResolvedValue(mockKeyInfo); + mockTransformKeyInfo.mockReturnValue(mockTransformedData); + + const user = userEvent.setup(); + const { container } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Chart View" })); + + const bars = container.querySelectorAll("path.recharts-rectangle"); + expect(bars).toHaveLength(1); + expect(bars[0].getAttribute("fill")).toBe("var(--color-cyan-500, #06b6d4)"); + expect(screen.getAllByText("A Very Lon...").length).toBeGreaterThan(0); + + fireEvent.click(bars[0]); + + await waitFor(() => { + expect(mockKeyInfoV1Call).toHaveBeenCalledWith("test-token", "key-123"); + }); + + await waitFor(() => { + expect(screen.getByTestId("key-info-view")).toBeInTheDocument(); + }); + expect(screen.getByText("Key Info View for key-123")).toBeInTheDocument(); + }); + it("should switch to table view when table view button is clicked", async () => { const user = userEvent.setup(); render(); 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 2dcf98a1e5c..60f25837589 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -1,7 +1,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { BarChart } from "@/components/shared/charts"; import { IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; -import { BarChart } from "@tremor/react"; import { Segmented, Tooltip } from "antd"; import React, { useState } from "react"; import { formatNumberWithCommas } from "../../../../utils/dataUtils"; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx index bbf9379f5e1..2770bac47d9 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx @@ -83,6 +83,41 @@ describe("TopModelView", () => { expect(tableViewButton).toHaveClass("bg-blue-100"); }); + it("renders one cyan bar per model with model names on the axis in chart view", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await user.click(screen.getByRole("button", { name: "Chart View" })); + + const bars = container.querySelectorAll("path.recharts-rectangle"); + expect(bars).toHaveLength(2); + const fills = new Set(Array.from(bars).map((bar) => bar.getAttribute("fill"))); + expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0); + }); + it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index 8938767c02c..148a290bcb7 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -1,17 +1,17 @@ -import { BarChart } from "@tremor/react"; +import { BarChart } from "@/components/shared/charts"; +import { MoneyCell } from "@/components/shared/table_cells"; import { Segmented } from "antd"; import { useState } from "react"; -import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "../../../../utils/dataUtils"; import { DataTable } from "../../../view_logs/table"; -interface TopModel { +type TopModel = { key: string; spend: number; successful_requests: number; failed_requests: number; tokens: number; -} +}; interface TopModelViewProps { topModels: TopModel[]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx index 322f00a501a..024226c3c03 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx @@ -198,6 +198,20 @@ describe("KeyModelUsageView", () => { expect(screen.queryByText("Model")).not.toBeInTheDocument(); }); + it("renders one cyan bar per model with model names on the axis in chart view", async () => { + const user = userEvent.setup(); + const { container } = render(); + + await user.click(screen.getByRole("button", { name: "Chart" })); + + const bars = container.querySelectorAll("path.recharts-rectangle"); + expect(bars).toHaveLength(2); + const fills = new Set(Array.from(bars).map((bar) => bar.getAttribute("fill"))); + expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + expect(screen.getAllByText("gpt-3.5-turbo").length).toBeGreaterThan(0); + }); + it("should display table when table view is selected", () => { render(); expect(screen.getByText("Model")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx index ceb00e8a19f..ed78b7e3dba 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx @@ -1,6 +1,7 @@ +import { BarChart } from "@/components/shared/charts"; import { MoneyCell } from "@/components/shared/table_cells"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { BarChart, Card, Title } from "@tremor/react"; import { Table } from "antd"; import type { ColumnsType } from "antd/es/table"; import React, { useState } from "react"; @@ -56,48 +57,52 @@ const KeyModelUsageView: React.FC = ({ topModels }) => { return ( -
- Model Usage -
- - -
-
- {viewMode === "chart" ? ( -
- ({ key: m.model, spend: m.spend }))} - index="key" - categories={["spend"]} - colors={["cyan"]} - valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`} - layout="vertical" - yAxisWidth={180} - tickGap={5} - showLegend={false} + + Model Usage + +
+ + +
+
+
+ + {viewMode === "chart" ? ( +
+ ({ key: m.model, spend: m.spend }))} + index="key" + categories={["spend"]} + colors={["cyan"]} + valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`} + layout="vertical" + yAxisWidth={180} + tickGap={5} + showLegend={false} + /> +
+ ) : ( + VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined} /> - - ) : ( -
VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined} - /> - )} + )} + ); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index 7d5aec5f0b8..b412d1ee365 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -302,14 +302,6 @@ vi.mock("@tremor/react", async () => { return React.createElement("p", { ...props, "data-testid": "tremor-text" }, children); } - function BarChart({ data, valueFormatter, yAxisWidth, showLegend, customTooltip, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-bar-chart" }, "Bar Chart"); - } - - function DonutChart({ data, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-donut-chart" }, "Donut Chart"); - } - function Button({ children, icon, onClick, ...props }: any) { return React.createElement( "button", @@ -331,8 +323,6 @@ vi.mock("@tremor/react", async () => { Col, Title, Text, - BarChart, - DonutChart, Button, }; }); @@ -606,6 +596,26 @@ describe("UsagePage", () => { expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument(); }); + it("should render the daily spend and top models charts with cyan bars", async () => { + const { container } = renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(container.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + }); + + const fills = new Set( + Array.from(container.querySelectorAll("path.recharts-rectangle")).map((rect) => rect.getAttribute("fill")), + ); + expect(fills).toEqual(new Set(["var(--color-cyan-500, #06b6d4)"])); + + expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0); + expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); + }); + it("should switch between usage views correctly", async () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 37c2a25c6a6..4998b197c01 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -9,7 +9,6 @@ import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { - BarChart, Card, Col, DateRangePickerValue, @@ -25,6 +24,9 @@ import { import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd"; import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; +import { BarChart } from "@/components/shared/charts"; +import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -680,38 +682,42 @@ const UsagePage: React.FC = ({ teams, organizations }) => { {/* Daily Spend Chart */} - - Daily Spend - {loading ? ( - - ) : ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.date}

-

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

-

Requests: {data.metrics.api_requests}

-

Successful: {data.metrics.successful_requests}

-

Failed: {data.metrics.failed_requests}

-

Tokens: {data.metrics.total_tokens}

-
- ); - }} - /> - )} -
+ + + Daily Spend + + + {loading ? ( + + ) : ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.date}

+

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

+

Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Tokens: {data.metrics.total_tokens}

+
+ ); + }} + /> + )} +
+
{/* Top API Keys */} diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index c70a6a0ed92..6c1297613fa 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -10,11 +10,11 @@ export interface SpendMetrics { cache_creation_input_tokens: number; } -export interface DailyData { +export type DailyData = { date: string; metrics: SpendMetrics; breakdown: BreakdownMetrics; -} +}; export interface BreakdownMetrics { models: { [key: string]: MetricWithMetadata }; diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index e63469207ac..f37643f2366 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -356,7 +356,7 @@ describe("ActivityMetrics", () => { }; render(); - expect(screen.getByRole("heading", { name: "Model Usage" })).toBeInTheDocument(); + expect(screen.getByText("Model Usage").closest('[data-slot="card-title"]')).toBeInTheDocument(); }); it("should display Spend per day in model section", () => { diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx index 123c6cad0ec..e1fbbe1183b 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.test.tsx @@ -35,4 +35,84 @@ describe("DonutChart", () => { expect(donutPath).not.toEqual(piePath); expect((donutPath.match(/A/g) ?? []).length).toBeGreaterThan((piePath.match(/A/g) ?? []).length); }); + + it("hides the center label by default and shows the formatted total when showLabel is set", () => { + const { container, rerender } = render( + `$${value.toFixed(2)}`} + />, + ); + expect(container.querySelector("text.fill-foreground")).toBeNull(); + + rerender( + `$${value.toFixed(2)}`} + showLabel + />, + ); + expect(container.querySelector("text.fill-foreground")?.textContent).toBe("$90.00"); + }); + + it("never invokes the valueFormatter for the center label unless it is shown", () => { + const formatterCalls: number[] = []; + render( + { + formatterCalls.push(value); + return `$${value.toFixed(2)}`; + }} + />, + ); + expect(formatterCalls).toEqual([]); + }); + + it("prefers an explicit label over the computed total", () => { + const { container } = render( + , + ); + expect(container.querySelector("text.fill-foreground")?.textContent).toBe("All providers"); + }); + + it("renders no center label when data is empty", () => { + const { container } = render( + , + ); + expect(container.querySelector("text.fill-foreground")).toBeNull(); + }); + + const firstPathPoint = (container: HTMLElement) => { + const d = container.querySelector(".recharts-pie-sector path")?.getAttribute("d") ?? ""; + const match = d.match(/M\s*([\d.-]+)\s*,\s*([\d.-]+)/); + expect(match).not.toBeNull(); + return { x: Number(match![1]), y: Number(match![2]) }; + }; + + it("starts the first sector at 3 o'clock by default and at 12 o'clock with tremor's 90/-270 angles", () => { + const { container: byDefault } = render( + , + ); + const { container: clockwiseFromTop } = render( + , + ); + + const defaultStart = firstPathPoint(byDefault); + const angledStart = firstPathPoint(clockwiseFromTop); + expect(defaultStart.x).toBeGreaterThan(400); + expect(Math.abs(defaultStart.y - 200)).toBeLessThan(1); + expect(Math.abs(angledStart.x - 400)).toBeLessThan(1); + expect(angledStart.y).toBeLessThan(200); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx index c2ce8c02e35..35d76d11a6f 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/donut_chart.tsx @@ -15,10 +15,26 @@ export type DonutChartProps> = { variant?: "donut" | "pie"; valueFormatter?: (value: number) => string; showTooltip?: boolean; + showLabel?: boolean; + label?: string; + startAngle?: number; + endAngle?: number; className?: string; style?: React.CSSProperties; }; +function formattedCategoryTotal>( + data: readonly TDatum[], + category: string, + valueFormatter?: (value: number) => string, +): string { + const total = data.reduce((sum, datum) => { + const value = datum[category]; + return sum + (typeof value === "number" ? value : 0); + }, 0); + return valueFormatter ? valueFormatter(total) : String(total); +} + export function DonutChart>({ data, index, @@ -27,6 +43,10 @@ export function DonutChart>({ variant = "donut", valueFormatter, showTooltip = true, + showLabel = false, + label, + startAngle = 0, + endAngle = 360, className, style, }: DonutChartProps) { @@ -37,23 +57,31 @@ export function DonutChart>({ return [name, { label: name }]; }), ); + const showCenterLabel = showLabel && variant === "donut" && data.length > 0; return ( {showTooltip && ( ( - + content={({ active, payload, label: tooltipLabel }) => ( + )} /> )} + {showCenterLabel && ( + + {label ?? formattedCategoryTotal(data, category, valueFormatter)} + + )}