fix(ui): show a failed state with Retry on the Logs table, Usage totals and Caching chart when their fetch errors (#47671)

* fix(ui): show a failed state with Retry in DataTable when the fetch errors instead of an empty table

* fix(ui): show the shared failed state with Retry on the Usage spend totals and the Caching activity chart when their fetch errors

* refactor(ui): name the settled-fetch objects so the inline object arg lint budget holds

* test(ui): find the caching chart card through the existing cardTitled helper

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-10-20 17:36:08 -07:00 • committed by GitHub
parent 17db64ef78
commit adf3373381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 200 additions and 42 deletions

View file

@ -277,4 +277,25 @@ describe("CacheDashboard cache analytics charts", () => {
expect(compactTicks(requestsCard).length).toBeGreaterThan(0);
expect(compactTicks(tokensCard)).toContain("60K");
});
it("shows the failed state with the server message and Retry when the activity query errors", async () => {
const refetch = vi.fn();
const failedActivity = {
data: undefined,
isError: true,
error: { error: { message: "Request timed out after 30s" } },
refetch,
};
useCacheActivity.mockReturnValue(failedActivity);
renderDashboard();
await screen.findByText(REQUESTS_CHART_TITLE);
const card = cardTitled(REQUESTS_CHART_TITLE);
const alert = within(card).getByRole("alert");
expect(alert).toHaveTextContent("Failed to load cache activity");
expect(alert).toHaveTextContent("Request timed out after 30s");
expect(within(card).getByText(/Click a red failed-requests segment/)).toBeInTheDocument();
fireEvent.click(within(alert).getByRole("button", { name: "Retry" }));
expect(refetch).toHaveBeenCalledTimes(1);
});
});

View file

@ -21,6 +21,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { RefreshCw } from "lucide-react";
import { cachingHealthCheckCall } from "@/components/networking";
import { deriveErrorMessage } from "@/lib/http/client";
import { ErrorState } from "@/components/shared/ErrorState";
import { useCacheActivity, type CacheActivityGroup } from "@/app/(dashboard)/hooks/caching/useCacheActivity";
// Import the new component
@ -94,7 +96,12 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
const [lastRefreshed, setLastRefreshed] = useState("");
const [healthCheckResponse, setHealthCheckResponse] = useState<any>("");
const { data: activity, refetch } = useCacheActivity({
const {
data: activity,
isError: activityFailed,
error: activityError,
refetch,
} = useCacheActivity({
startDate: formatDateWithoutTZ(dateValue.from),
endDate: formatDateWithoutTZ(dateValue.to),
keyAliases: selectedApiKeys,
@ -295,19 +302,27 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
Click a red failed-requests segment to see which error codes caused those failures.
</p>
{hasUnknownGroup && <p className="mt-1 text-sm text-muted-foreground">{UNKNOWN_CALL_TYPE_NOTE}</p>}
<BarChart
data={chartData}
stack={true}
index="name"
valueFormatter={valueFormatterNumbers}
categories={[REQUEST_SERIES.apiRequests, REQUEST_SERIES.cacheHits, REQUEST_SERIES.failed]}
colors={["sky", "teal", "red"]}
yAxisWidth={48}
className="mt-2"
onValueChange={(item) => {
if (item.categoryClicked === REQUEST_SERIES.failed) setErrorDrilldownCallType(item.name);
}}
/>
{activityFailed ? (
<ErrorState
title="Failed to load cache activity"
message={deriveErrorMessage(activityError)}
onRetry={() => void refetch()}
/>
) : (
<BarChart
data={chartData}
stack={true}
index="name"
valueFormatter={valueFormatterNumbers}
categories={[REQUEST_SERIES.apiRequests, REQUEST_SERIES.cacheHits, REQUEST_SERIES.failed]}
colors={["sky", "teal", "red"]}
yAxisWidth={48}
className="mt-2"
onValueChange={(item) => {
if (item.categoryClicked === REQUEST_SERIES.failed) setErrorDrilldownCallType(item.name);
}}
/>
)}
</CardContent>
</Card>

View file

@ -1356,9 +1356,10 @@ describe("EntityUsage", () => {
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0);
});
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load spend data");
expect(alert).toHaveTextContent("aggregated unavailable");
expect(within(alert).getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1);
});

View file

@ -14,7 +14,7 @@ import { hasCapability, type Capability } from "@/utils/capabilities";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { Bot, Boxes, ChevronDown, ChevronRight, ExternalLink, Info, KeyRound, Layers, Server } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { ErrorState } from "@/components/shared/ErrorState";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -189,6 +189,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
data: spendDataRaw,
loading,
failed,
errorMessage,
reload,
} = useAggregatedDailyActivity({
fetch: () => api.aggregated(request as DailyActivityRequest),
enabled: enabled && request !== null,
@ -208,6 +210,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
data: agentSpendDataRaw,
loading: agentLoading,
failed: agentFailed,
errorMessage: agentErrorMessage,
reload: reloadAgent,
} = useAggregatedDailyActivity({
fetch: () => ENTITY_API.agent.aggregated(agentRequest as DailyActivityRequest),
enabled: enabled && showAgentBreakdown && agentRequest !== null,
@ -663,21 +667,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
return (
<div className="relative grid w-full gap-3">
{failed && (
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
{failed && <ErrorState title="Failed to load spend data" message={errorMessage} onRetry={reload} />}
{showAgentBreakdown && agentFailed && (
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
<ErrorState title="Failed to load agent data" message={agentErrorMessage} onRetry={reloadAgent} />
)}
<div className="max-w-full [&>div:first-child]:mb-0 [&>div:first-child_label]:sr-only">
<UsageExportHeader

View file

@ -1301,7 +1301,22 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "user")).toHaveLength(1);
expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument();
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Failed to load spend data");
expect(alert).toHaveTextContent("Aggregated endpoint not available");
expect(within(alert).getByRole("button", { name: "Retry" })).toBeInTheDocument();
});
it("re-issues the aggregated call when Retry is clicked", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available"));
renderWithProviders(<UsagePage {...defaultProps} />);
const alert = await screen.findByRole("alert");
const callsBefore = mockUserDailyActivityAggregatedCall.mock.calls.length;
fireEvent.click(within(alert).getByRole("button", { name: "Retry" }));
await waitFor(() => expect(mockUserDailyActivityAggregatedCall.mock.calls.length).toBe(callsBefore + 1));
});
});

View file

@ -13,6 +13,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StackedUsageChart } from "@/components/shared/charts";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { ErrorState } from "@/components/shared/ErrorState";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -218,6 +219,8 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
data: aggregatedRaw,
loading: aggregatedLoading,
failed: aggregatedFailed,
errorMessage: aggregatedError,
reload: reloadAggregated,
} = useAggregatedDailyActivity({
fetch: () => ENTITY_API.user.aggregated(dailyActivityRequest as DailyActivityRequest),
enabled: dailyActivityRequest !== null,
@ -415,12 +418,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</div>
</header>
{aggregatedFailed && (
<Alert variant="error" className="mb-3">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
<ErrorState title="Failed to load spend data" message={aggregatedError} onRetry={reloadAggregated} />
)}
<div>
<div>

View file

@ -44,6 +44,19 @@ describe("useAggregatedDailyActivity", () => {
await waitFor(() => expect(result.current.failed).toBe(true));
expect(result.current.loading).toBe(false);
expect(result.current.errorMessage).toBe("boom");
});
it("reload fetches again after a failure and clears failed on success", async () => {
const fetch = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce({ results: [], metadata: {} });
const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: true, deps: ["a"] }));
await waitFor(() => expect(result.current.failed).toBe(true));
act(() => result.current.reload());
await waitFor(() => expect(result.current.failed).toBe(false));
expect(fetch).toHaveBeenCalledTimes(2);
expect(result.current.errorMessage).toBeNull();
});
it("ignores an out-of-order resolution from the previous deps", async () => {

View file

@ -14,16 +14,31 @@ interface Result {
data: DailyActivityAggregatedResponse;
loading: boolean;
failed: boolean;
errorMessage: string | null;
reload: () => void;
}
interface SettledFetch {
key: string;
data: DailyActivityAggregatedResponse;
failed: boolean;
errorMessage: string | null;
}
const settledFetch = (
key: string,
data: DailyActivityAggregatedResponse,
errorMessage: string | null,
): SettledFetch => ({
key,
data,
failed: errorMessage !== null,
errorMessage,
});
export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): Result {
const [settled, setSettled] = useState<SettledFetch | null>(null);
const [attempt, setAttempt] = useState(0);
const requestIdRef = useRef(0);
const fetchRef = useRef(fetch);
@ -31,7 +46,7 @@ export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): R
fetchRef.current = fetch;
});
const depsKey = JSON.stringify(deps);
const depsKey = JSON.stringify([attempt, ...deps]);
useEffect(() => {
if (!enabled) return;
@ -43,12 +58,13 @@ export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): R
.current()
.then((response) => {
if (isStale()) return;
setSettled({ key: depsKey, data: response, failed: false });
setSettled(settledFetch(depsKey, response, null));
})
.catch((error) => {
if (isStale()) return;
console.error("Error fetching daily activity:", error);
setSettled({ key: depsKey, data: EMPTY_DAILY_ACTIVITY_RESPONSE, failed: true });
const message = error instanceof Error ? error.message : String(error);
setSettled(settledFetch(depsKey, EMPTY_DAILY_ACTIVITY_RESPONSE, message));
});
return () => {
@ -61,5 +77,7 @@ export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): R
data: current?.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE,
loading: enabled && current === null,
failed: current?.failed ?? false,
errorMessage: current?.errorMessage ?? null,
reload: () => setAttempt((n) => n + 1),
};
}

View file

@ -803,4 +803,13 @@ describe("RequestLogsPanel", () => {
expect(screen.queryByText("Refreshing every 15s")).not.toBeInTheDocument();
});
});
it("a failed logs request says so instead of showing an empty table", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Request timed out after 30s"));
renderPanel();
const alert = await screen.findByRole("alert", {}, { timeout: 3000 });
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Request timed out after 30s");
expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
});
});

View file

@ -301,6 +301,8 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
data={rows}
rowCount={rowCount}
isLoading={logsQuery.isLoading}
isError={logsQuery.isError}
errorMessage={logsQuery.error instanceof Error ? logsQuery.error.message : undefined}
isRefreshing={logsQuery.isFetching}
pagination={pagination}
onPaginationChange={handlePaginationChange}

View file

@ -18,6 +18,8 @@ interface RequestLogsTableProps {
data: LogEntry[];
rowCount: number;
isLoading: boolean;
isError?: boolean;
errorMessage?: ReactNode;
isRefreshing: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
@ -66,6 +68,8 @@ export function RequestLogsTable({
data,
rowCount,
isLoading,
isError = false,
errorMessage,
isRefreshing,
pagination,
onPaginationChange,
@ -112,6 +116,9 @@ export function RequestLogsTable({
columnFilters={columnFilters}
onColumnFiltersChange={onColumnFiltersChange}
isLoading={isLoading}
isError={isError}
errorMessage={errorMessage}
onRetry={onRefresh}
loadingMessage="Loading request logs…"
noDataMessage={<RequestLogsEmptyState filtered={isFiltered} />}
size="compact"

View file

@ -254,6 +254,34 @@ describe("DataTable sorting", () => {
});
});
describe("DataTable error state", () => {
it("shows the failure and a Retry button instead of the empty state when the fetch failed", async () => {
const onRetry = vi.fn();
render(
<DataTable
data={[]}
columns={numericColumns}
isError
errorMessage="Request timed out after 30s"
onRetry={onRetry}
noDataMessage="No people"
/>,
);
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("Failed to load");
expect(alert).toHaveTextContent("Request timed out after 30s");
expect(screen.queryByText("No people")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("keeps showing rows it already has when a refetch fails", () => {
render(<DataTable data={[person("a", "Alice")]} columns={numericColumns} isError />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
describe("DataTable layout", () => {
it("stretches the table to fill the container when resizing is on, so hidden columns leave no right-side gap", () => {
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameCellColumns} enableColumnResizing />);

View file

@ -26,6 +26,7 @@ import {
type VisibilityState,
} from "@tanstack/react-table";
import { SearchX } from "lucide-react";
import { ErrorState } from "@/components/shared/ErrorState";
import * as React from "react";
import { Fragment, useEffect, useState } from "react";
@ -599,6 +600,9 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const {
isLoading = false,
isError = false,
errorMessage,
onRetry,
loadingMessage = "Loading…",
skeletonRowCount = 8,
noDataMessage,
@ -658,6 +662,13 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
/>
);
}
if (isError && rows.length === 0) {
return (
<MessageRow colSpan={visibleColumnCount} stretch={stretchEmpty}>
<ErrorState message={errorMessage} onRetry={onRetry} />
</MessageRow>
);
}
if (rows.length === 0) {
return (
<MessageRow colSpan={visibleColumnCount} stretch={stretchEmpty}>

View file

@ -28,6 +28,8 @@ export interface DataTableResolvedProps<TData extends RowData, TValue> {
isLoading?: boolean;
isError?: boolean;
errorMessage?: React.ReactNode;
onRetry?: () => void;
loadingMessage?: string;
skeletonRowCount?: number;
noDataMessage?: React.ReactNode;

View file

@ -0,0 +1,26 @@
import { Button } from "@/components/ui/button";
import { TriangleAlert } from "lucide-react";
import * as React from "react";
interface ErrorStateProps {
title?: string;
message?: React.ReactNode;
onRetry?: () => void;
}
export function ErrorState({ title = "Failed to load", message, onRetry }: ErrorStateProps) {
return (
<div role="alert" className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-destructive/10">
<TriangleAlert className="size-5 text-destructive" />
</div>
<div className="text-sm font-medium text-foreground">{title}</div>
{message ? <div className="max-w-prose text-center text-sm text-muted-foreground">{message}</div> : null}
{onRetry ? (
<Button variant="outline" size="sm" className="mt-2" onClick={onRetry}>
Retry
</Button>
) : null}
</div>
);
}