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}
-
(unavailable ? window.location.reload() : refresh())}>
- {unavailable ? "Reload page" : "Retry"}
+
+
+ {icon}
+
+
+
{title}
+
{description}
+
+ {children &&
{children}
}
+
+ );
+}
+
+function DocsLink() {
+ return (
+
+ Lens docs
+
+
+ );
+}
+
+function loadFailureMessage(queryError: unknown, unavailable: boolean): string {
+ if (unavailable)
+ return "This dashboard may be newer than the proxy. Reload the page, and if it persists, check the proxy deployment.";
+ if (queryError instanceof Error) return queryError.message;
+ return "Something went wrong while contacting the proxy.";
+}
+
+export function InvestigationsLoadFailed({ queryError, refresh }: { queryError: unknown; refresh: () => void }) {
+ const unavailable = queryError instanceof ApiError && queryError.status === 404;
+ return (
+ }
+ title={unavailable ? "Lens API is unavailable" : "Couldn't load investigations"}
+ description={loadFailureMessage(queryError, unavailable)}
+ >
+ (unavailable ? window.location.reload() : refresh())}>
+ {unavailable ? "Reload page" : "Try again"}
+
+
+
+ );
+}
+
+export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) {
+ return (
+
+ {message}
+
+ Retry
);
}
+
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.{" "}
- selectLens(null)}>
+ }
+ title="Investigation not found"
+ description="It may have been deleted, or the link points to a different proxy."
+ >
+ selectLens(null)}>
View all investigations
-
+
);
}
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 {