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;