From adf337338172ce7ea6edbd80851f68b6621af2f5 Mon Sep 17 00:00:00 2001
From: Kerry Lu
Date: Tue, 20 Oct 2026 17:36:08 -0700
Subject: [PATCH] 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>
---
.../_components/cache_dashboard.test.tsx | 21 +++++++++
.../caching/_components/cache_dashboard.tsx | 43 +++++++++++++------
.../EntityUsage/EntityUsage.test.tsx | 7 +--
.../components/EntityUsage/EntityUsage.tsx | 22 +++-------
.../components/UsagePageView.test.tsx | 17 +++++++-
.../_components/components/UsagePageView.tsx | 10 ++---
.../hooks/useAggregatedDailyActivity.test.ts | 13 ++++++
.../hooks/useAggregatedDailyActivity.ts | 24 +++++++++--
.../logs/request/RequestLogsPanel.test.tsx | 9 ++++
.../logs/request/RequestLogsPanel.tsx | 2 +
.../logs/request/RequestLogsTable.tsx | 7 +++
.../shared/DataTable/DataTable.test.tsx | 28 ++++++++++++
.../components/shared/DataTable/DataTable.tsx | 11 +++++
.../src/components/shared/DataTable/types.ts | 2 +
.../src/components/shared/ErrorState.tsx | 26 +++++++++++
15 files changed, 200 insertions(+), 42 deletions(-)
create mode 100644 ui/litellm-dashboard/src/components/shared/ErrorState.tsx
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx
index 3447602dd79..ece32449041 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.test.tsx
@@ -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);
+ });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx
index 29819adf524..9e730be4df3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/caching/_components/cache_dashboard.tsx
@@ -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 = ({ accessToken, token, userRole
const [lastRefreshed, setLastRefreshed] = useState("");
const [healthCheckResponse, setHealthCheckResponse] = useState("");
- 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 = ({ accessToken, token, userRole
Click a red failed-requests segment to see which error codes caused those failures.
{hasUnknownGroup && {UNKNOWN_CALL_TYPE_NOTE}
}
- {
- if (item.categoryClicked === REQUEST_SERIES.failed) setErrorDrilldownCallType(item.name);
- }}
- />
+ {activityFailed ? (
+ void refetch()}
+ />
+ ) : (
+ {
+ if (item.categoryClicked === REQUEST_SERIES.failed) setErrorDrilldownCallType(item.name);
+ }}
+ />
+ )}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx
index cdee86ed559..04b4dcf9be1 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx
@@ -1356,9 +1356,10 @@ describe("EntityUsage", () => {
render( );
- 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);
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx
index 8518f52e165..a75afd63633 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx
@@ -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 = ({
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 = ({
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 = ({
return (
- {failed && (
-
-
- Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
- again.
-
-
- )}
+ {failed &&
}
{showAgentBreakdown && agentFailed && (
-
-
- Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try
- again.
-
-
+
)}
{
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( );
+
+ 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));
});
});
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 84cc9359ded..feb1adb0f1d 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
@@ -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 = ({ 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 = ({ teams, organizations }) => {
{aggregatedFailed && (
-
-
- Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
- again.
-
-
+
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts
index 16a45d03db9..7e667230cb0 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts
@@ -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 () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts
index 08aec2e77f2..482041c900d 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts
@@ -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
(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),
};
}
diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx
index d950859f22e..ce136c1cf77 100644
--- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx
+++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx
@@ -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();
+ });
});
diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx
index 605ff373c7c..e29c48b94c0 100644
--- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx
+++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx
@@ -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}
diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx
index 822bf73302b..d3d6b43e2e3 100644
--- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx
+++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsTable.tsx
@@ -18,6 +18,8 @@ interface RequestLogsTableProps {
data: LogEntry[];
rowCount: number;
isLoading: boolean;
+ isError?: boolean;
+ errorMessage?: ReactNode;
isRefreshing: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn;
@@ -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={ }
size="compact"
diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx
index 05ba7e04c39..959b5ea2375 100644
--- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx
+++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx
@@ -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(
+ ,
+ );
+ 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( );
+ 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( );
diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx
index a94c4e34611..0c484de8e73 100644
--- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx
+++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx
@@ -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(props: DataTableProps(props: DataTableProps
);
}
+ if (isError && rows.length === 0) {
+ return (
+
+
+
+ );
+ }
if (rows.length === 0) {
return (
diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts
index 4529e3df164..787ea633492 100644
--- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts
+++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts
@@ -28,6 +28,8 @@ export interface DataTableResolvedProps {
isLoading?: boolean;
isError?: boolean;
+ errorMessage?: React.ReactNode;
+ onRetry?: () => void;
loadingMessage?: string;
skeletonRowCount?: number;
noDataMessage?: React.ReactNode;
diff --git a/ui/litellm-dashboard/src/components/shared/ErrorState.tsx b/ui/litellm-dashboard/src/components/shared/ErrorState.tsx
new file mode 100644
index 00000000000..140aaf211c9
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/shared/ErrorState.tsx
@@ -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 (
+
+
+
+
+
{title}
+ {message ?
{message}
: null}
+ {onRetry ? (
+
+ Retry
+
+ ) : null}
+
+ );
+}