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 ? ( + + ) : null} +
+ ); +}