feat(ui): add daily model usage to customer pages (#51419)

* feat(ui): show daily model usage on customer pages

* test(ui): assert the customer budget hint is hidden
This commit is contained in:
ishaan-j-berri 2026-11-13 01:45:40 +08:00 • committed by GitHub
parent be623b5bde
commit f10b42b1ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 253 additions and 5 deletions

View file

@ -14,6 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { customerStatus, useCustomerUsage } from "./customerPortalData";
import EditCustomerDialog from "./EditCustomerDialog";
import StatusBadge from "./StatusBadge";
import CustomerUsageOverview from "./CustomerUsageOverview";
function UsageSummary({ spend, budget }: { spend: number; budget: EndUser["litellm_budget_table"] | undefined }) {
const { t } = useTranslation();
@ -112,6 +113,8 @@ export default function CustomerDetail({ customerId, onBack }: { customerId: str
</div>
<p className="text-xs text-muted-foreground">{t("customerPortal.common.blockHint")}</p>
<CustomerUsageOverview customerId={customerId} />
<div className="rounded-lg border bg-card">
<div className="flex items-baseline gap-3 border-b px-4 py-3">
<h2 className="text-sm font-semibold">{t("customerPortal.detail.keysTitle")}</h2>

View file

@ -0,0 +1,155 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBaseUrlGetter } from "@/lib/http/runtime";
import { formatDate } from "@/components/networking";
import { relativeTimeOptions } from "@/components/shared/date_range_presets";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
EMPTY_DAILY_ACTIVITY_RESPONSE,
type DailyActivityAggregatedResponse,
} from "@/components/UsagePage/dailyActivityApi";
import type { SpendMetrics } from "@/components/UsagePage/types";
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../../../tests/test-utils";
import CustomerUsageOverview from "./CustomerUsageOverview";
const metrics = (spend: number, requests: number, tokens: number): SpendMetrics => ({
spend,
api_requests: requests,
successful_requests: requests,
failed_requests: 0,
total_tokens: tokens,
prompt_tokens: tokens * 0.8,
completion_tokens: tokens * 0.2,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const daily = (date: string, model: string, values: SpendMetrics) => ({
date,
metrics: values,
breakdown: { model_groups: { [model]: { metrics: values, metadata: {} } } },
});
const activity: DailyActivityAggregatedResponse = {
results: [
daily("2026-11-01", "customer-primary", metrics(12, 5, 500)),
daily("2026-11-02", "customer-reserve", metrics(3, 2, 200)),
],
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_spend: 15,
total_api_requests: 7,
total_successful_requests: 7,
total_tokens: 700,
total_prompt_tokens: 560,
total_completion_tokens: 140,
},
};
const fetchMock = vi.fn<typeof fetch>();
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const requestUrl = (input: Parameters<typeof fetch>[0]) =>
new URL(input instanceof Request ? input.url : String(input));
const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input));
describe("CustomerUsageOverview", () => {
beforeEach(() => {
testQueryClient.clear();
registerBaseUrlGetter(() => "http://localhost");
fetchMock.mockReset();
fetchMock.mockResolvedValue(json(activity));
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 50 });
return 1;
});
});
afterEach(() => {
testQueryClient.clear();
vi.unstubAllGlobals();
});
it("should render customer-scoped daily usage with the Usage page's totals and model breakdown", async () => {
const user = userEvent.setup();
renderWithProviders(<CustomerUsageOverview customerId="customer-1" />);
expect(await screen.findByText("$15.00")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Daily usage" })).toBeInTheDocument();
expect(screen.getByTitle("700 tokens")).toBeInTheDocument();
expect(screen.getByText("customer-primary")).toBeInTheDocument();
expect(screen.getByText("customer-reserve")).toBeInTheDocument();
expect(screen.queryByText("No budget set")).not.toBeInTheDocument();
const url = requestedUrls()[0];
expect(url.pathname).toBe("/customer/daily/activity/aggregated");
expect(url.searchParams.get("end_user_ids")).toBe("customer-1");
expect(url.searchParams.get("include_entity_breakdown")).toBe("false");
const month = relativeTimeOptions.find((option) => option.shortLabel === "MTD")!.getValue();
expect(url.searchParams.get("start_date")).toBe(formatDate(month.from));
expect(url.searchParams.get("end_date")).toBe(formatDate(month.to));
await user.click(screen.getByRole("radio", { name: "Tokens" }));
expect(screen.getByText("500")).toBeInTheDocument();
expect(screen.getByText("200")).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Requests" }));
expect(screen.getByText("5")).toBeInTheDocument();
});
it("should change the customer date range without losing the selected customer or unrelated URL state", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<CustomerUsageOverview customerId="customer-1" />, {
searchParams: "?id=customer-1&source=overview",
onUrlUpdate,
});
expect(await screen.findByText("customer-primary")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: / - / }));
await user.click(screen.getByRole("button", { name: /Last 7 days/ }));
await user.click(screen.getByRole("button", { name: "Apply" }));
const week = relativeTimeOptions.find((option) => option.shortLabel === "7d")!.getValue();
await waitFor(() => expect(requestedUrls().at(-1)?.searchParams.get("start_date")).toBe(formatDate(week.from)));
expect(requestedUrls().at(-1)?.searchParams.get("end_user_ids")).toBe("customer-1");
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ queryString: "?id=customer-1&source=overview&range=7d" }),
);
});
it("should remove the previous customer's values while the next customer's usage loads", async () => {
const { rerender } = renderWithProviders(<CustomerUsageOverview customerId="customer-1" />);
expect(await screen.findByText("customer-primary")).toBeInTheDocument();
fetchMock.mockImplementation(() => new Promise<Response>(() => {}));
rerender(<CustomerUsageOverview customerId="customer-2" />);
expect(screen.queryByText("customer-primary")).not.toBeInTheDocument();
expect(screen.queryByText("$15.00")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "Loading top models" })).toBeInTheDocument();
await waitFor(() => expect(requestedUrls().at(-1)?.searchParams.get("end_user_ids")).toBe("customer-2"));
});
it("should show a failed usage request as an error and let the customer retry", async () => {
const user = userEvent.setup();
fetchMock.mockResolvedValueOnce(json({ error: "Unavailable" }, 500));
renderWithProviders(<CustomerUsageOverview customerId="customer-1" />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load customer usage");
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
expect(screen.queryByText("No usage in this range")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("$15.00")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("should show an empty range only after the customer query returns no activity", async () => {
fetchMock.mockResolvedValue(json(EMPTY_DAILY_ACTIVITY_RESPONSE));
renderWithProviders(<CustomerUsageOverview customerId="customer-1" />);
expect(await screen.findByText("No usage in this range")).toBeInTheDocument();
expect(screen.getByText("$0.00")).toBeInTheDocument();
expect(screen.queryByRole("status", { name: "Loading top models" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,84 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useQueryStates } from "nuqs";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import {
dateRangeFromParams,
dateRangePatch,
defaultUsageDateRange,
usageUrlParsers,
} from "@/app/(dashboard)/usage/_components/usageUrlState";
import { overviewTotals } from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
import UsageBreakdownChart from "@/app/(dashboard)/usage/_components/components/overview/UsageBreakdownChart";
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
import { dailyActivityAggregatedCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { ErrorState } from "@/components/shared/ErrorState";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
EMPTY_DAILY_ACTIVITY_RESPONSE,
toDailyData,
} from "@/components/UsagePage/dailyActivityApi";
const dateParsers = {
range: usageUrlParsers.range,
from: usageUrlParsers.from,
to: usageUrlParsers.to,
};
export default function CustomerUsageOverview({ customerId }: { customerId: string }) {
const { t } = useTranslation();
const { accessToken } = useAuthorized();
const [params, setParams] = useQueryStates(dateParsers);
const defaultRange = useMemo(() => defaultUsageDateRange("month_to_date"), []);
const dateValue = useMemo(() => dateRangeFromParams(params) ?? defaultRange.value, [params, defaultRange]);
const preset = params.range ?? (params.from === null && params.to === null ? defaultRange.preset : null);
const startTime = dateValue.from;
const endTime = dateValue.to;
const request =
accessToken && startTime && endTime
? { accessToken, startTime, endTime, entityIds: [customerId], includeEntityBreakdown: false }
: null;
const usage = useQuery({
queryKey: ["customer-portal", "daily-usage", customerId, startTime?.getTime(), endTime?.getTime()],
queryFn: request ? () => dailyActivityAggregatedCall("customer", request) : skipToken,
});
const data = usage.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE;
const results = useMemo(() => toDailyData(data), [data]);
const totals = useMemo(() => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, null), [data]);
return (
<section className="space-y-3" aria-label={t("customerPortal.usage.title")}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold">{t("customerPortal.usage.title")}</h2>
<p className="text-xs text-muted-foreground">{t("customerPortal.usage.description")}</p>
</div>
<AdvancedDatePicker
value={dateValue}
presetShortLabel={preset}
showTimeRange={false}
onValueChange={(value, selectedPreset) => void setParams(dateRangePatch(value, selectedPreset))}
/>
</div>
{usage.isError ? (
<ErrorState title={t("customerPortal.usage.error")} onRetry={() => void usage.refetch()} />
) : (
<>
<UsageStatStrip
results={results}
totals={totals}
loading={usage.isPending}
requestCountsPending={usage.isPending}
budget={null}
showBudgetHint={false}
/>
<UsageBreakdownChart results={results} loading={usage.isPending} />
</>
)}
</section>
);
}

View file

@ -21,6 +21,7 @@ interface UsageStatStripProps {
loading: boolean;
requestCountsPending: boolean;
budget: number | null;
showBudgetHint?: boolean;
}
/** Spend, requests, tokens and averages for a range, each with a daily sparkline. */
@ -30,9 +31,13 @@ export default function UsageStatStrip({
loading,
requestCountsPending,
budget,
showBudgetHint = true,
}: UsageStatStripProps) {
const { t } = useTranslation();
const daily = useMemo(() => dailyTotals(results), [results]);
const budgetHint = budget
? t("usage.statStrip.budgetHint", { budget: t("format.usd", { val: budget }) })
: t("usage.statStrip.noBudget");
return (
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
@ -40,11 +45,7 @@ export default function UsageStatStrip({
label={t("usage.statStrip.spend")}
value={t("format.usd", { val: totals.spend })}
pending={loading}
hint={
budget
? t("usage.statStrip.budgetHint", { budget: t("format.usd", { val: budget }) })
: t("usage.statStrip.noBudget")
}
hint={showBudgetHint ? budgetHint : undefined}
/>
</StatCell>
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>

View file

@ -20656,6 +20656,11 @@
"unnamedKey": "Unnamed key",
"back": "Back to customers"
},
"usage": {
"title": "Usage",
"description": "Daily activity by model across this customer's keys.",
"error": "Could not load customer usage"
},
"createDialog": {
"title": "Create customer",
"description": "A customer is an end user of your product. Pass its id as `user` on every request to track spend and apply its limits.",