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:
ryan-crabbe-berri 2026-07-11 16:11:05 -07:00 committed by GitHub
parent f2fb6b8e73
commit 0710cf2990
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 463 additions and 176 deletions

View file

@ -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

View file

@ -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 = {

View file

@ -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}>

View file

@ -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 = [
{

View file

@ -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}>

View file

@ -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} />);

View file

@ -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";

View file

@ -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} />);

View file

@ -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[];

View file

@ -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();

View file

@ -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>
);
};

View file

@ -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} />);

View file

@ -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}>

View file

@ -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 };

View file

@ -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", () => {

View file

@ -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);
});
});

View file

@ -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}
>