- {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}
+
+ );
+}