-
{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)}
+
+ )}