From 05ef5627ae0cf7af98046bb06477ee5f2fcdd772 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 17:52:07 +0530 Subject: [PATCH] refactor: extract a shared spend-by-category panel from Project and Provider usage SpendByProvider and ProjectSpendBreakdown rendered the same donut chart, table, and loading state with only the columns and title differing. Pulls that shell into SpendByCategoryPanel and has both consumers pass in their own columns and data, keeping provider-specific filtering where it belongs. --- .../EntityUsage/SpendByProvider.tsx | 87 +++++-------- .../ProjectUsage/ProjectSpendBreakdown.tsx | 47 ++----- .../components/SpendByCategoryPanel.test.tsx | 118 ++++++++++++++++++ .../components/SpendByCategoryPanel.tsx | 63 ++++++++++ 4 files changed, 224 insertions(+), 91 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index f5d6db67478..5ace97421e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -1,15 +1,12 @@ -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { ChartLoader } from "@/components/shared/chart_loader"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; type ProviderSpendData = { provider: string; @@ -70,13 +67,10 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi const filteredProviderSpend = providerSpend.filter((provider) => { const isUnknown = provider.provider?.toLowerCase() === "unknown"; - // If includeUnknown is true, always include unknown provider if (isUnknown) { return includeUnknown; } - // If includeZeroSpend is true, include all providers (including those with 0 spend) - // Otherwise, only include providers with spend > 0 if (includeZeroSpend) { return true; } @@ -84,54 +78,37 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return provider.spend > 0; }); + const headerAction = ( + <> +
+ + +
+
+
+ + + } /> + Requests that failed to route to a provider + +
+ +
+ + ); + return ( - - - Spend by Provider - -
- - -
-
-
- - - } /> - Requests that failed to route to a provider - -
- -
-
-
- - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.provider} - noDataMessage="No provider usage data" - size="compact" - /> -
- )} -
-
+ row.provider} + noDataMessage="No provider usage data" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx index e7e41126861..d2459e65665 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx @@ -1,13 +1,9 @@ import React from "react"; import type { ColumnDef } from "@tanstack/react-table"; -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { ChartLoader } from "@/components/shared/chart_loader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; import type { ProjectSpendRow } from "./projectUsageAggregations"; interface ProjectSpendBreakdownProps { @@ -48,37 +44,16 @@ const columns: ColumnDef[] = [ ]; const ProjectSpendBreakdown: React.FC = ({ loading, isDateChanging, projectSpend }) => ( - - - Spend by Project - - - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.project_id} - noDataMessage="No project usage data" - size="compact" - /> -
- )} -
-
+ row.project_id} + noDataMessage="No project usage data" + /> ); export default ProjectSpendBreakdown; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx new file mode 100644 index 00000000000..33dd4d41095 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { SpendByCategoryPanel } from "./SpendByCategoryPanel"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +interface TestRow extends Record { + key: string; + spend: number; +} + +const columns: ColumnDef[] = [{ header: "Key", accessorKey: "key" }]; + +const rows: TestRow[] = [{ key: "row-1", spend: 5 }]; + +describe("SpendByCategoryPanel", () => { + it("displays the given title", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Spend by Widget")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("row-1")).not.toBeInTheDocument(); + }); + + it("renders the table rows once loaded", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getAllByText("row-1").length).toBeGreaterThan(0); + }); + + it("shows the empty message when there is no data", () => { + render( + row.key} + noDataMessage="No data for this range" + />, + ); + expect(screen.getByText("No data for this range")).toBeInTheDocument(); + }); + + it("renders a header action only when one is given", () => { + const { rerender } = render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.queryByText("Toggle")).not.toBeInTheDocument(); + + rerender( + Toggle} + loading={false} + isDateChanging={false} + data={[]} + indexKey="key" + columns={columns} + getRowId={(row) => row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Toggle")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx new file mode 100644 index 00000000000..9dbf1550e88 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +interface SpendByCategoryPanelProps> { + title: string; + headerAction?: React.ReactNode; + loading: boolean; + isDateChanging: boolean; + data: TRow[]; + indexKey: keyof TRow & string; + columns: ColumnDef[]; + getRowId: (row: TRow) => string; + noDataMessage: string; +} + +export function SpendByCategoryPanel>({ + title, + headerAction, + loading, + isDateChanging, + data, + indexKey, + columns, + getRowId, + noDataMessage, +}: SpendByCategoryPanelProps) { + return ( + + + {title} + {headerAction && {headerAction}} + + + {loading ? ( + + ) : ( +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} + /> + +
+ )} +
+
+ ); +} + +export default SpendByCategoryPanel;