Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
observe() {}
unobserve() {}
disconnect() {}
} as any;

Check warning on line 12 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}
});

Expand Down Expand Up @@ -575,7 +575,7 @@
});

await waitFor(() => {
expect(screen.getByText("Tag 1")).toBeInTheDocument();
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0);
});
});

Expand All @@ -587,7 +587,7 @@
});

await waitFor(() => {
expect(screen.getByText("Tag 1")).toBeInTheDocument();
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0);
});
});

Expand Down Expand Up @@ -700,10 +700,37 @@
});

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(<EntityUsage {...defaultProps} />);

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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -58,12 +58,12 @@
failed_requests: number;
api_requests: number;
};
metadata: Record<string, any>;

Check warning on line 61 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}

interface ExtendedDailyData extends DailyData {
type ExtendedDailyData = DailyData & {
breakdown: BreakdownMetrics;
}
};

interface EntitySpendData {
results: ExtendedDailyData[];
Expand Down Expand Up @@ -92,7 +92,7 @@
dateValue: DateRangePickerValue;
}

const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {

Check warning on line 95 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

Check warning on line 95 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
tag: tagDailyActivityCall,
team: teamDailyActivityCall,
organization: organizationDailyActivityCall,
Expand All @@ -101,7 +101,7 @@
user: userDailyActivityCall,
};

const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, entityId, entityList, dateValue }) => {

Check warning on line 104 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Arrow function has a complexity of 25. Maximum allowed is 20
const { teams } = useTeams();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
Expand Down Expand Up @@ -152,7 +152,7 @@
const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {};

const getTopModels = () => {
const modelSpend: { [key: string]: any } = {};

Check warning on line 155 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
spendData.results.forEach((day) => {
Object.entries(day.breakdown.models || {}).forEach(([model, metrics]) => {
if (!modelSpend[model]) {
Expand Down Expand Up @@ -186,7 +186,7 @@
};

const getTopAgents = () => {
const agentSpend: { [key: string]: any } = {};

Check warning on line 189 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
agentSpendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([agentId, data]) => {
if (!agentSpend[agentId]) {
Expand All @@ -196,7 +196,7 @@
successful_requests: 0,
failed_requests: 0,
tokens: 0,
agent_name: (data.metadata as any)?.agent_name || agentId,

Check warning on line 199 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
};
}
agentSpend[agentId].spend += data.metrics.spend;
Expand Down Expand Up @@ -278,7 +278,7 @@
};

const getProviderSpend = () => {
const providerSpend: { [key: string]: any } = {};

Check warning on line 281 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
spendData.results.forEach((day) => {
Object.entries(day.breakdown.providers || {}).forEach(([provider, metrics]) => {
if (!providerSpend[provider]) {
Expand Down Expand Up @@ -314,7 +314,7 @@
}
};

const getEntityLabel = (entity: string, metadata?: Record<string, any>): string => {

Check warning on line 317 in ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (entityList) {
const entityItem = entityList.find((item) => item.value === entity);
if (entityItem) {
Expand Down Expand Up @@ -545,60 +545,66 @@

{/* Daily Spend Chart */}
<Col numColSpan={2}>
<Card>
<Title>Daily Spend</Title>
<BarChart
data={[...spendData.results].sort(
(a, b) => 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 (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}</p>
<p className="text-gray-600">Total Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">Total Tokens: {data.metrics.total_tokens}</p>
<p className="text-gray-600">
Total {capitalizedEntityLabel}s: {entityCount}
</p>
<div className="mt-2 border-t pt-2">
<p className="font-semibold">Spend by {capitalizedEntityLabel}:</p>
{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 (
<p key={entity} className="text-sm text-gray-600">
{getEntityLabel(entity, metrics.metadata)}: $
{formatNumberWithCommas(metrics.metrics.spend, 2)}
</p>
);
})}
{entityCount > 5 && (
<p className="text-sm text-gray-500 italic">...and {entityCount - 5} more</p>
)}
<ShadcnCard>
<CardHeader>
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
</CardHeader>
<CardContent>
<BarChart
data={[...spendData.results].sort(
(a, b) => 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 (
<div className="bg-white p-4 shadow-lg rounded-lg border">
<p className="font-bold">{data.date}</p>
<p className="text-cyan-500">
Total Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
</p>
<p className="text-gray-600">Total Requests: {data.metrics.api_requests}</p>
<p className="text-gray-600">Successful: {data.metrics.successful_requests}</p>
<p className="text-gray-600">Failed: {data.metrics.failed_requests}</p>
<p className="text-gray-600">Total Tokens: {data.metrics.total_tokens}</p>
<p className="text-gray-600">
Total {capitalizedEntityLabel}s: {entityCount}
</p>
<div className="mt-2 border-t pt-2">
<p className="font-semibold">Spend by {capitalizedEntityLabel}:</p>
{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 (
<p key={entity} className="text-sm text-gray-600">
{getEntityLabel(entity, metrics.metadata)}: $
{formatNumberWithCommas(metrics.metrics.spend, 2)}
</p>
);
})}
{entityCount > 5 && (
<p className="text-sm text-gray-500 italic">...and {entityCount - 5} more</p>
)}
</div>
</div>
</div>
);
}}
/>
</Card>
);
}}
/>
</CardContent>
</ShadcnCard>
</Col>

{/* Entity Breakdown Section */}
Expand Down Expand Up @@ -741,6 +747,9 @@
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan", "blue", "indigo", "violet", "purple"]}
showLabel
startAngle={90}
endAngle={-270}
/>
</Col>
<Col numColSpan={1}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe("SpendByProvider", () => {
},
];
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={providerSpendWithNull} />);
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getAllByText("$100.00").length).toBeGreaterThan(0);
});

it("should handle provider with empty string provider name", () => {
Expand All @@ -182,7 +182,7 @@ describe("SpendByProvider", () => {
},
];
render(<SpendByProvider loading={false} isDateChanging={false} providerSpend={providerSpendWithEmpty} />);
expect(screen.getByText("$100.00")).toBeInTheDocument();
expect(screen.getAllByText("$100.00").length).toBeGreaterThan(0);
});

it("should display large token numbers with comma formatting", () => {
Expand Down Expand Up @@ -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(
<SpendByProvider loading={false} isDateChanging={false} providerSpend={mockProviderSpend} />,
);

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(
<SpendByProvider loading={false} isDateChanging={false} providerSpend={providerSpendWithUnknown} />,
);

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 = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -88,6 +88,9 @@ const SpendByProvider: React.FC<SpendByProviderProps> = ({ loading, isDateChangi
category="spend"
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
colors={["cyan"]}
showLabel
startAngle={90}
endAngle={-270}
/>
</Col>
<Col numColSpan={1}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<TopKeyView
{...baseProps}
topKeys={[
{
api_key: "key-123",
key_alias: "A Very Long Key Alias",
spend: 100,
},
]}
/>,
);

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(<TopKeyView {...baseProps} />);
Expand Down
Loading
Loading