mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
refactor(ui): convert entity usage and usage page charts to shadcn/recharts (#32729)
* refactor(ui): convert entity usage and usage page charts to shadcn/recharts Swap the tremor BarChart/DonutChart render sites in EntityUsage, SpendByProvider, TopKeyView, TopModelView, KeyModelUsageView and UsagePageView to the shared shadcn/recharts wrappers. Convert the two sole-chart Daily Spend cards and the KeyModelUsageView card to the shadcn Card primitives. Close the donut parity gap with strictly additive optional DonutChart props: showLabel/label render a center total (tremor showed valueFormatter(sum) by default) and startAngle/endAngle forward to the Pie so both provider donuts keep tremor's clockwise-from-12 layout. Defaults preserve the previous wrapper behavior. DailyData and two site-local row types move from interface to type alias so they satisfy the wrappers' Record<string, unknown> constraint; interfaces lack implicit index signatures. Tests now assert on real recharts output: bar/sector counts, cyan fills, axis labels, donut center totals, and the TopKeyView bar-click drill-down into the key info modal. The dead tremor chart mocks in UsagePageView.test.tsx are removed and lint metrics/suppressions are regenerated for the dropped tremor imports. * fix(ui): compute donut center label only when shown and assert Model Usage renders as a card title
This commit is contained in:
parent
f2fb6b8e73
commit
0710cf2990
17 changed files with 463 additions and 176 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -575,7 +575,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tag 1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -587,7 +587,7 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tag 1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Tag 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -700,10 +700,37 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -61,9 +61,9 @@ interface EntityMetrics {
|
|||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ExtendedDailyData extends DailyData {
|
||||
type ExtendedDailyData = DailyData & {
|
||||
breakdown: BreakdownMetrics;
|
||||
}
|
||||
};
|
||||
|
||||
interface EntitySpendData {
|
||||
results: ExtendedDailyData[];
|
||||
|
|
@ -545,60 +545,66 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
|
||||
{/* 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 */}
|
||||
|
|
@ -741,6 +747,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
|
|||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan", "blue", "indigo", "violet", "purple"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -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", () => {
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<SpendByProviderProps> = ({ loading, isDateChangi
|
|||
category="spend"
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
colors={["cyan"]}
|
||||
showLabel
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
/>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<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} />);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<TopModelView
|
||||
topModels={[
|
||||
{
|
||||
key: "gpt-4",
|
||||
spend: 150.5,
|
||||
successful_requests: 100,
|
||||
failed_requests: 5,
|
||||
tokens: 50000,
|
||||
},
|
||||
{
|
||||
key: "claude-3",
|
||||
spend: 75.25,
|
||||
successful_requests: 50,
|
||||
failed_requests: 2,
|
||||
tokens: 25000,
|
||||
},
|
||||
]}
|
||||
topModelsLimit={5}
|
||||
setTopModelsLimit={mockSetTopModelsLimit}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
|
||||
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(<KeyModelUsageView topModels={mockTopModels} />);
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -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<KeyModelUsageViewProps> = ({ topModels }) => {
|
|||
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Title>Model Usage</Title>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "table" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("chart")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "chart" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Chart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "chart" ? (
|
||||
<div className="max-h-[234px] overflow-y-auto">
|
||||
<BarChart
|
||||
style={{ height: topModels.length * 40 }}
|
||||
data={topModels.map((m) => ({ 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}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Model Usage</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "table" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("chart")}
|
||||
className={`px-3 py-1 text-sm rounded-md ${viewMode === "chart" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"}`}
|
||||
>
|
||||
Chart
|
||||
</button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{viewMode === "chart" ? (
|
||||
<div className="max-h-[234px] overflow-y-auto">
|
||||
<BarChart
|
||||
style={{ height: topModels.length * 40 }}
|
||||
data={topModels.map((m) => ({ 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}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={topModels}
|
||||
rowKey="model"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={topModels.length > VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={topModels}
|
||||
rowKey="model"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={topModels.length > VISIBLE_ROWS ? { y: VISIBLE_ROWS * ANTD_SMALL_TABLE_ROW_HEIGHT } : undefined}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(<UsagePage {...defaultProps} />);
|
||||
|
||||
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(<UsagePage {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<UsagePageProps> = ({ teams, organizations }) => {
|
|||
|
||||
{/* Daily Spend Chart */}
|
||||
<Col numColSpan={2}>
|
||||
<Card>
|
||||
<Title>Daily Spend</Title>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={sortedDailyResults}
|
||||
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;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.date}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">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">Tokens: {data.metrics.total_tokens}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<ChartLoader isDateChanging={isDateChanging} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={sortedDailyResults}
|
||||
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;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded-lg border">
|
||||
<p className="font-bold">{data.date}</p>
|
||||
<p className="text-cyan-500">
|
||||
Spend: ${formatNumberWithCommas(data.metrics.spend, 2)}
|
||||
</p>
|
||||
<p className="text-gray-600">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">Tokens: {data.metrics.total_tokens}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</Col>
|
||||
{/* Top API Keys */}
|
||||
<Col numColSpan={1}>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -356,7 +356,7 @@ describe("ActivityMetrics", () => {
|
|||
};
|
||||
|
||||
render(<ActivityMetrics modelMetrics={modelWithTopModels} />);
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<DonutChart
|
||||
data={data}
|
||||
index="provider"
|
||||
category="spend"
|
||||
colors={["cyan"]}
|
||||
valueFormatter={(value) => `$${value.toFixed(2)}`}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("text.fill-foreground")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<DonutChart
|
||||
data={data}
|
||||
index="provider"
|
||||
category="spend"
|
||||
colors={["cyan"]}
|
||||
valueFormatter={(value) => `$${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(
|
||||
<DonutChart
|
||||
data={data}
|
||||
index="provider"
|
||||
category="spend"
|
||||
colors={["cyan"]}
|
||||
showTooltip={false}
|
||||
valueFormatter={(value) => {
|
||||
formatterCalls.push(value);
|
||||
return `$${value.toFixed(2)}`;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(formatterCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers an explicit label over the computed total", () => {
|
||||
const { container } = render(
|
||||
<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} showLabel label="All providers" />,
|
||||
);
|
||||
expect(container.querySelector("text.fill-foreground")?.textContent).toBe("All providers");
|
||||
});
|
||||
|
||||
it("renders no center label when data is empty", () => {
|
||||
const { container } = render(
|
||||
<DonutChart data={[]} index="provider" category="spend" colors={["cyan"]} showLabel />,
|
||||
);
|
||||
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(
|
||||
<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} />,
|
||||
);
|
||||
const { container: clockwiseFromTop } = render(
|
||||
<DonutChart data={data} index="provider" category="spend" colors={["cyan"]} startAngle={90} endAngle={-270} />,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,10 +15,26 @@ export type DonutChartProps<TDatum extends Record<string, unknown>> = {
|
|||
variant?: "donut" | "pie";
|
||||
valueFormatter?: (value: number) => string;
|
||||
showTooltip?: boolean;
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function formattedCategoryTotal<TDatum extends Record<string, unknown>>(
|
||||
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<TDatum extends Record<string, unknown>>({
|
||||
data,
|
||||
index,
|
||||
|
|
@ -27,6 +43,10 @@ export function DonutChart<TDatum extends Record<string, unknown>>({
|
|||
variant = "donut",
|
||||
valueFormatter,
|
||||
showTooltip = true,
|
||||
showLabel = false,
|
||||
label,
|
||||
startAngle = 0,
|
||||
endAngle = 360,
|
||||
className,
|
||||
style,
|
||||
}: DonutChartProps<TDatum>) {
|
||||
|
|
@ -37,23 +57,31 @@ export function DonutChart<TDatum extends Record<string, unknown>>({
|
|||
return [name, { label: name }];
|
||||
}),
|
||||
);
|
||||
const showCenterLabel = showLabel && variant === "donut" && data.length > 0;
|
||||
|
||||
return (
|
||||
<ChartContainer config={config} className={cn("aspect-auto h-40 w-full", className)} style={style}>
|
||||
<PieChart>
|
||||
{showTooltip && (
|
||||
<ChartTooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<ValueTooltip active={active} payload={payload} label={label} valueFormatter={valueFormatter} />
|
||||
content={({ active, payload, label: tooltipLabel }) => (
|
||||
<ValueTooltip active={active} payload={payload} label={tooltipLabel} valueFormatter={valueFormatter} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showCenterLabel && (
|
||||
<text className="fill-foreground text-base" x="50%" y="50%" textAnchor="middle" dominantBaseline="middle">
|
||||
{label ?? formattedCategoryTotal(data, category, valueFormatter)}
|
||||
</text>
|
||||
)}
|
||||
<Pie
|
||||
data={[...data]}
|
||||
dataKey={category}
|
||||
nameKey={index}
|
||||
innerRadius={variant === "pie" ? "0%" : "75%"}
|
||||
outerRadius="100%"
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue