mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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.
This commit is contained in:
parent
e395744038
commit
05ef5627ae
4 changed files with 224 additions and 91 deletions
|
|
@ -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<SpendByProviderProps> = ({ 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<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
return provider.spend > 0;
|
||||
});
|
||||
|
||||
const headerAction = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-foreground">Show Zero Spend</label>
|
||||
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm text-foreground">Show Unknown</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Provider</CardTitle>
|
||||
<CardAction className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-foreground">Show Zero Spend</label>
|
||||
<Switch checked={includeZeroSpend} onCheckedChange={setIncludeZeroSpend} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-sm text-foreground">Show Unknown</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
|
||||
<TooltipContent>Requests that failed to route to a provider</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={includeUnknown} onCheckedChange={setIncludeUnknown} />
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={filteredProviderSpend}
|
||||
index="provider"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredProviderSpend}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Provider"
|
||||
headerAction={headerAction}
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
data={filteredProviderSpend}
|
||||
indexKey="provider"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.provider}
|
||||
noDataMessage="No provider usage data"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ProjectSpendRow>[] = [
|
|||
];
|
||||
|
||||
const ProjectSpendBreakdown: React.FC<ProjectSpendBreakdownProps> = ({ loading, isDateChanging, projectSpend }) => (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Project</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={projectSpend}
|
||||
index="project_alias"
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={projectSpend}
|
||||
getRowId={(row) => row.project_id}
|
||||
noDataMessage="No project usage data"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Project"
|
||||
loading={loading}
|
||||
isDateChanging={isDateChanging}
|
||||
data={projectSpend}
|
||||
indexKey="project_alias"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.project_id}
|
||||
noDataMessage="No project usage data"
|
||||
/>
|
||||
);
|
||||
|
||||
export default ProjectSpendBreakdown;
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div data-testid="chart-loader">{isDateChanging ? "Processing date selection..." : "Loading chart data..."}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
interface TestRow extends Record<string, unknown> {
|
||||
key: string;
|
||||
spend: number;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TestRow>[] = [{ header: "Key", accessorKey: "key" }];
|
||||
|
||||
const rows: TestRow[] = [{ key: "row-1", spend: 5 }];
|
||||
|
||||
describe("SpendByCategoryPanel", () => {
|
||||
it("displays the given title", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Spend by Widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loader instead of chart content while loading", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading
|
||||
isDateChanging={false}
|
||||
data={rows}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => 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(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={rows}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("row-1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows the empty message when there is no data", () => {
|
||||
render(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => 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(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Toggle")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SpendByCategoryPanel
|
||||
title="Spend by Widget"
|
||||
headerAction={<button type="button">Toggle</button>}
|
||||
loading={false}
|
||||
isDateChanging={false}
|
||||
data={[]}
|
||||
indexKey="key"
|
||||
columns={columns}
|
||||
getRowId={(row) => row.key}
|
||||
noDataMessage="No data"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Toggle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<TRow extends Record<string, unknown>> {
|
||||
title: string;
|
||||
headerAction?: React.ReactNode;
|
||||
loading: boolean;
|
||||
isDateChanging: boolean;
|
||||
data: TRow[];
|
||||
indexKey: keyof TRow & string;
|
||||
columns: ColumnDef<TRow>[];
|
||||
getRowId: (row: TRow) => string;
|
||||
noDataMessage: string;
|
||||
}
|
||||
|
||||
export function SpendByCategoryPanel<TRow extends Record<string, unknown>>({
|
||||
title,
|
||||
headerAction,
|
||||
loading,
|
||||
isDateChanging,
|
||||
data,
|
||||
indexKey,
|
||||
columns,
|
||||
getRowId,
|
||||
noDataMessage,
|
||||
}: SpendByCategoryPanelProps<TRow>) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{headerAction && <CardAction className="flex items-center gap-4">{headerAction}</CardAction>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
data={data}
|
||||
index={indexKey}
|
||||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
<DataTable columns={columns} data={data} getRowId={getRowId} noDataMessage={noDataMessage} size="compact" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpendByCategoryPanel;
|
||||
Loading…
Add table
Reference in a new issue