From e3957440381733f5f03bac643e27b07cb126f802 Mon Sep 17 00:00:00 2001 From: Aryan Gupta Date: Thu, 10 Sep 2026 17:51:54 +0530 Subject: [PATCH] fix: surface load errors and fix stale loading state in Project Usage Surfaces API errors instead of rendering silent zeros, fixes the loading indicator not appearing while switching between selected projects, gates the summary tiles behind the same loading check as the chart, disambiguates donut chart entries for projects sharing an alias, and falls back to Global usage when project view access is lost mid-session. --- .../ProjectUsage/ProjectUsage.test.tsx | 67 +++++++++ .../components/ProjectUsage/ProjectUsage.tsx | 135 +++++++++++------- .../projectUsageAggregations.test.ts | 44 +++++- .../ProjectUsage/projectUsageAggregations.ts | 84 ++++++----- .../_components/components/UsagePageView.tsx | 10 +- .../src/components/networking.tsx | 8 -- 6 files changed, 243 insertions(+), 105 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx index 1e9e0dffefb..3d5a17fe411 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -84,4 +84,71 @@ describe("ProjectUsage", () => { expect(screen.getAllByText("4").length).toBeGreaterThan(0); expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); }); + + it("shows an error instead of silently rendering zeros when the request fails", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Could not load project usage")).toBeInTheDocument(); + expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("shows a loading indicator while fetching data for a newly-added project", async () => { + let resolveSecondCall: (value: unknown) => void = () => {}; + mockProjectDailyActivityCall + .mockResolvedValueOnce({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondCall = resolve; + }), + ); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Beta" })); + + await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0)); + + resolveSecondCall({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [], + }); + + await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0)); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx index b4f06af67c8..62e3d675318 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -10,12 +10,18 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { projectDailyActivityCall } from "@/components/networking"; +import { extractProxyErrorMessage } from "@/lib/http/client"; import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary"; import type { EntityList } from "../EntityUsage/EntityUsage"; import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; -import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; +import { + buildDailySpendSeries, + buildProjectSpendBreakdown, + humanizeBackendListMessage, + summarizeProjectUsage, +} from "./projectUsageAggregations"; interface ProjectUsageProps { accessToken: string | null; @@ -55,7 +61,7 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d enabled, placeholderData: keepPreviousData, }; - const { data, isFetching, isPlaceholderData } = useQuery(queryOptions); + const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions); if (!premiumUser) { return ( @@ -76,7 +82,79 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d const summary = summarizeProjectUsage(rows); const dailySpend = buildDailySpendSeries(rows); const projectBreakdown = buildProjectSpendBreakdown(rows); - const isLoadingRows = isFetching && !isPlaceholderData; + const isLoadingRows = isPending || (isFetching && isPlaceholderData); + + const renderResultsPanel = () => { + if (!hasSelection) { + return ( +
+ + +

+ Select at least one project above to view its usage. +

+
+
+
+ ); + } + + if (isError) { + return ( +
+ + Could not load project usage + {humanizeBackendListMessage(extractProxyErrorMessage(error))} + +
+ ); + } + + return ( + <> +
+ + +

Project Spend Overview

+ {isLoadingRows ? ( + + ) : ( +
+ {buildSummaryTiles(summary, false).map(renderSummaryTile)} +
+ )} +
+
+
+ +
+ + + Daily Spend + + + {isLoadingRows ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + ); + }; return (
@@ -101,56 +179,7 @@ const ProjectUsage: React.FC = ({ accessToken, projectList, d
- {!hasSelection ? ( -
- - -

- Select at least one project above to view its usage. -

-
-
-
- ) : ( - <> -
- - -

Project Spend Overview

-
- {buildSummaryTiles(summary, false).map(renderSummaryTile)} -
-
-
-
- -
- - - Daily Spend - - - {isLoadingRows ? ( - - ) : ( - - )} - - -
- -
- -
- - )} + {renderResultsPanel()} ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts index 090ee973dd4..c95747f7a7f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import type { ProjectDailySpendRow } from "@/components/networking"; -import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; +import { + buildDailySpendSeries, + buildProjectSpendBreakdown, + humanizeBackendListMessage, + summarizeProjectUsage, +} from "./projectUsageAggregations"; const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ date: "2026-09-01", @@ -103,4 +108,41 @@ describe("buildProjectSpendBreakdown", () => { expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled"); }); + + it("disambiguates two projects that share the same human-set alias", () => { + const rows = [ + row({ project_id: "project-one", project_alias: "Production" }), + row({ project_id: "project-two", project_alias: "Production" }), + ]; + + const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias); + expect(new Set(aliases).size).toBe(2); + expect(aliases.every((alias) => alias.includes("Production"))).toBe(true); + }); + + it("leaves a unique alias untouched", () => { + const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha"); + }); +}); + +describe("humanizeBackendListMessage", () => { + it("strips a single-quoted Python list down to plain text", () => { + expect(humanizeBackendListMessage("Project(s) not found: ['proj-123']")).toBe( + "Project(s) not found: proj-123", + ); + }); + + it("comma-joins a multi-item Python list", () => { + expect(humanizeBackendListMessage("Project(s) not found: ['proj-1', 'proj-2']")).toBe( + "Project(s) not found: proj-1, proj-2", + ); + }); + + it("leaves a message with no trailing list untouched", () => { + expect(humanizeBackendListMessage("Not authorized to view this project")).toBe( + "Not authorized to view this project", + ); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts index 8a0422fbadf..a94b3aa67e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -1,6 +1,5 @@ import type { ProjectDailySpendRow } from "@/components/networking"; -/** One row per project, summed across the whole range: the shape the breakdown table and donut chart render. */ export interface ProjectSpendRow extends Record { project_id: string; project_alias: string; @@ -11,7 +10,6 @@ export interface ProjectSpendRow extends Record { tokens: number; } -/** One point per day, spend summed across every selected project: the shape the daily spend chart renders. */ export interface DailyProjectSpendPoint extends Record { date: string; spend: number; @@ -33,7 +31,6 @@ const EMPTY_SUMMARY: ProjectUsageSummary = { total_tokens: 0, }; -/** Aggregate totals across every row, for the summary tiles. */ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary => rows.reduce( (totals, row) => ({ @@ -46,41 +43,54 @@ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsag EMPTY_SUMMARY, ); -/** One point per day, spend summed across every selected project, sorted oldest first. */ export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { - const spendByDate = new Map(); - rows.forEach((row) => { - spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); - }); - return Array.from(spendByDate, ([date, spend]) => ({ date, spend })).sort((a, b) => a.date.localeCompare(b.date)); + const spendByDate = rows.reduce>( + (totals, row) => ({ ...totals, [row.date]: (totals[row.date] ?? 0) + row.spend }), + {}, + ); + return Object.entries(spendByDate) + .map(([date, spend]) => ({ date, spend })) + .sort((a, b) => a.date.localeCompare(b.date)); }; -/** - * One row per project, spend/tokens/requests summed across the whole range, sorted by - * spend descending like every other "top X" breakdown on the usage page. - */ -export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { - const byProject = new Map(); - rows.forEach((row) => { - const existing = byProject.get(row.project_id); - if (existing) { - existing.spend += row.spend; - existing.requests += row.api_requests; - existing.successful_requests += row.successful_requests; - existing.failed_requests += row.failed_requests; - existing.tokens += row.total_tokens; - return; - } - const newRow: ProjectSpendRow = { - project_id: row.project_id, - project_alias: row.project_alias || row.project_id, - spend: row.spend, - requests: row.api_requests, - successful_requests: row.successful_requests, - failed_requests: row.failed_requests, - tokens: row.total_tokens, - }; - byProject.set(row.project_id, newRow); - }); - return Array.from(byProject.values()).sort((a, b) => b.spend - a.spend); +const groupByProjectId = (rows: ProjectDailySpendRow[]): Record => + rows.reduce>( + (groups, row) => ({ ...groups, [row.project_id]: [...(groups[row.project_id] ?? []), row] }), + {}, + ); + +const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { + const [{ project_id, project_alias }] = rows; + const totals = rows.reduce( + (acc, row) => ({ + spend: acc.spend + row.spend, + requests: acc.requests + row.api_requests, + successful_requests: acc.successful_requests + row.successful_requests, + failed_requests: acc.failed_requests + row.failed_requests, + tokens: acc.tokens + row.total_tokens, + }), + { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 }, + ); + return { project_id, project_alias: project_alias || project_id, ...totals }; }; + +const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => { + const aliasCounts = rows.reduce>( + (counts, row) => ({ ...counts, [row.project_alias]: (counts[row.project_alias] ?? 0) + 1 }), + {}, + ); + return rows.map((row) => + aliasCounts[row.project_alias] > 1 ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } : row, + ); +}; + +export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { + const summarized = Object.values(groupByProjectId(rows)).map(summarizeProjectGroup); + return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend); +}; + +export const humanizeBackendListMessage = (message: string): string => + message.replace(/\[([^\]]*)]\s*$/, (_match, listContents: string) => { + const items = [...listContents.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]); + return items.length > 0 ? items.join(", ") : listContents; + }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 55aae0940a0..b6ca7397677 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -121,12 +121,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); const [selectedUsageView, setUsageView] = useState("global"); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const stillHasAccessToSelectedView = + (selectedUsageView !== "organization" || canViewOrganizationUsage) && + (selectedUsageView !== "project" || canViewProjectUsage); + const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global"; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 21c71f9a774..67ddb5d2dba 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1632,14 +1632,6 @@ export const projectDailyActivityCall = async ( endTime: Date, projectIds: string[], ): Promise => { - /** - * Get daily spend per project from /project/daily/activity. - * - * Unlike team/user/tag/agent, there is no daily-aggregated project spend - * table, so this scans spend logs directly and returns the whole range in - * one response instead of paginating. project_ids is comma-joined because - * the endpoint takes a single string, not repeated query params. - */ try { return await apiClient.get(`/project/daily/activity`, { accessToken,