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:
Aryan Gupta 2026-09-10 17:51:54 +05:30
parent e88364d888
commit e395744038
6 changed files with 243 additions and 105 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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