mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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.
This commit is contained in:
parent
e88364d888
commit
e395744038
6 changed files with 243 additions and 105 deletions
|
|
@ -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(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
|
||||
);
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ProjectUsageProps> = ({ 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<ProjectUsageProps> = ({ 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 (
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select at least one project above to view its usage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load project usage</AlertTitle>
|
||||
<AlertDescription>{humanizeBackendListMessage(extractProxyErrorMessage(error))}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Project Spend Overview</h3>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
{buildSummaryTiles(summary, false).map(renderSummaryTile)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailySpend}
|
||||
index="date"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ProjectSpendBreakdown loading={isLoadingRows} isDateChanging={false} projectSpend={projectBreakdown} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
|
|
@ -101,56 +179,7 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
|
|||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{!hasSelection ? (
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select at least one project above to view its usage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-medium text-foreground">Project Spend Overview</h3>
|
||||
<div className="grid grid-cols-5 gap-4 mt-4">
|
||||
{buildSummaryTiles(summary, false).map(renderSummaryTile)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ShadcnCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Daily Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingRows ? (
|
||||
<ChartLoader isDateChanging={false} />
|
||||
) : (
|
||||
<BarChart
|
||||
data={dailySpend}
|
||||
index="date"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatterSpend}
|
||||
yAxisWidth={100}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<ProjectSpendBreakdown loading={isLoadingRows} isDateChanging={false} projectSpend={projectBreakdown} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{renderResultsPanel()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
project_id: string;
|
||||
project_alias: string;
|
||||
|
|
@ -11,7 +10,6 @@ export interface ProjectSpendRow extends Record<string, unknown> {
|
|||
tokens: number;
|
||||
}
|
||||
|
||||
/** One point per day, spend summed across every selected project: the shape the daily spend chart renders. */
|
||||
export interface DailyProjectSpendPoint extends Record<string, unknown> {
|
||||
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<string, number>();
|
||||
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<Record<string, number>>(
|
||||
(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<string, ProjectSpendRow>();
|
||||
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<string, ProjectDailySpendRow[]> =>
|
||||
rows.reduce<Record<string, ProjectDailySpendRow[]>>(
|
||||
(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<Record<string, number>>(
|
||||
(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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,12 +121,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
|
||||
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
|
||||
const [selectedUsageView, setUsageView] = useState<UsageOption>("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<number>(5);
|
||||
|
|
|
|||
|
|
@ -1632,14 +1632,6 @@ export const projectDailyActivityCall = async (
|
|||
endTime: Date,
|
||||
projectIds: string[],
|
||||
): Promise<ProjectDailySpendResponse> => {
|
||||
/**
|
||||
* 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<ProjectDailySpendResponse>(`/project/daily/activity`, {
|
||||
accessToken,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue