From 07eb0dd10fa8230a4826117ed3ada2d954102dd6 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sat, 3 Oct 2026 18:21:38 -0700 Subject: [PATCH] fix(ui): centered Lens loading/error states and proxy JSON calls in dev The dev server answered GET /lens with the Lens page HTML because the UI route shadowed the proxy fallback rewrite. JSON API requests now go to the proxy before page routes, and a non-JSON success body raises a readable ApiError. Co-Authored-By: Claude Opus 5.5 --- ui/litellm-dashboard/next.config.mjs | 9 +- .../investigations/InvestigationStatus.tsx | 128 ++++++++++++++---- .../InvestigationsView.integration.test.tsx | 18 +++ .../investigations/InvestigationsView.tsx | 13 +- .../investigations/InvestigationsWelcome.tsx | 2 +- .../src/lib/http/client.test.ts | 10 ++ ui/litellm-dashboard/src/lib/http/client.ts | 8 +- 7 files changed, 160 insertions(+), 28 deletions(-) diff --git a/ui/litellm-dashboard/next.config.mjs b/ui/litellm-dashboard/next.config.mjs index e7a63206017..d4fdc7af36f 100644 --- a/ui/litellm-dashboard/next.config.mjs +++ b/ui/litellm-dashboard/next.config.mjs @@ -12,7 +12,14 @@ const nextConfig = { ? { async rewrites() { return { - beforeFiles: [{ source: "/ui/:path*", destination: "/:path*" }], + beforeFiles: [ + { + source: "/:path*", + has: [{ type: "header", key: "content-type", value: "application/json.*" }], + destination: `${devProxyUrl}/:path*`, + }, + { source: "/ui/:path*", destination: "/:path*" }, + ], fallback: [{ source: "/:path*", destination: `${devProxyUrl}/:path*` }], }; }, diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStatus.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStatus.tsx index 1f26a455e5f..c8064a80b4e 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStatus.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationStatus.tsx @@ -1,45 +1,127 @@ "use client"; -import { Button } from "@/components/ui/button"; +import type { ReactNode } from "react"; +import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/cva.config"; import { ApiError } from "@/lib/http/client"; -export function InvestigationError({ - error, - queryError, - refresh, +const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens"; + +function CenteredState({ + role, + icon, + tone = "muted", + title, + description, + children, }: { - error: string; - queryError: unknown; - refresh: () => void; + role: "status" | "alert"; + icon: ReactNode; + tone?: "muted" | "destructive"; + title: string; + description: ReactNode; + children?: ReactNode; }) { - const unavailable = queryError instanceof ApiError && queryError.status === 404; - const queryMessage = queryError instanceof Error ? queryError.message : undefined; return ( -
- {unavailable - ? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment." - : error || queryMessage} - + + + ); +} + +export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) { + return ( +
+ {message} +
); } + export function InvestigationsLoading() { return ( -

- Loading investigations… -

+ } + title="Loading investigations…" + description="Fetching your investigations and their latest findings." + /> ); } + export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) { return ( -
- This investigation was not found.{" "} - -
+
); } diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx index 13f92f2be01..13443a6ef0d 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.integration.test.tsx @@ -482,6 +482,24 @@ it("allows retrying a failed trace readiness check without treating it as an emp expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible(); }); +it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => { + window.history.replaceState({}, "", "/lens/"); + testQueryClient.clear(); + const list = vi + .fn() + .mockRejectedValueOnce(new ApiError("Proxy timed out", 504, {})) + .mockResolvedValue({ lenses: [], workers: [], tracing_enabled: true }); + vi.mocked(apiClient.get).mockImplementation(async (path) => (path === "/lens" ? list() : { data: [] })); + const user = userEvent.setup(); + renderWithProviders(); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Couldn't load investigations"); + expect(alert).toHaveTextContent("Proxy timed out"); + await user.click(within(alert).getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); +}); + it("keeps saved investigations accessible when tracing is disabled", async () => { testQueryClient.clear(); vi.mocked(apiClient.get).mockImplementation(async (path) => { diff --git a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx index 0c493d169f7..8030c3c12c9 100644 --- a/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx +++ b/ui/litellm-dashboard/src/components/lens/investigations/InvestigationsView.tsx @@ -1,5 +1,10 @@ "use client"; -import { InvestigationMissing, InvestigationsLoading, InvestigationError } from "./InvestigationStatus"; +import { + InvestigationMissing, + InvestigationsLoadFailed, + InvestigationsLoading, + InvestigationError, +} from "./InvestigationStatus"; import { InvestigationNavigation } from "./InvestigationNavigation"; import { useInvestigationResults } from "./useInvestigationResults"; @@ -175,7 +180,11 @@ export function InvestigationsView({ setEditing={setEditing} /> )} - {(error || query.error) && } + {query.error && !query.data ? ( + + ) : ( + (error || query.error) && + )} {query.isLoading && } {loaded && showEmpty && ( +
{showPreview && }

Find what needs attention diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 3d28c154990..8c47ed70931 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -61,6 +61,16 @@ describe("createApiClient", () => { expect(init.body).toBe(JSON.stringify({ model_name: "gpt" })); }); + it("reports a non-JSON success body as an ApiError naming the path instead of a parse error", async () => { + const html = new Response("", { headers: { "Content-Type": "text/html" } }); + const client = createApiClient({ getBaseUrl: () => "", fetchImpl: vi.fn(async () => html) }); + + const error = await client.get("/lens").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).message).toBe("Expected JSON from /lens but the server returned text/html"); + }); + it("throws ApiError with the derived message and invokes onError on a non-2xx response", async () => { const fetchImpl = vi.fn(async () => errorResponse(403, { error: { message: "no access" } })); const onError = vi.fn(); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index 33d54c0fa28..f6a4f801213 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -184,7 +184,13 @@ export function createApiClient(config: ApiClientConfig): ApiClient { async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { const response = await fetchChecked(method, path, options); const text = await response.text(); - return (text ? JSON.parse(text) : undefined) as T; + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + const type = response.headers.get("content-type") ?? "an unknown content type"; + throw new ApiError(`Expected JSON from ${path} but the server returned ${type}`, response.status, text); + } } async function getBlob(path: string, options: RequestOptions = {}): Promise {