mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
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:
parent
17db64ef78
commit
adf3373381
15 changed files with 200 additions and 42 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />);
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
26
ui/litellm-dashboard/src/components/shared/ErrorState.tsx
Normal file
26
ui/litellm-dashboard/src/components/shared/ErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue