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 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-03 18:21:38 -07:00
parent 7ecdb25f6e
commit 07eb0dd10f
7 changed files with 160 additions and 28 deletions

View file

@ -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*` }],
};
},

View file

@ -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 (
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
{unavailable
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
: error || queryMessage}
<Button variant="ghost" size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Retry"}
<div
role={role}
className="m-auto flex max-w-sm flex-col items-center gap-3 py-16 text-center animate-in fade-in-0 duration-300 motion-reduce:animate-none"
>
<span
aria-hidden="true"
className={cn(
"flex size-10 items-center justify-center rounded-full",
tone === "destructive" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
)}
>
{icon}
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children && <div className="mt-1 flex items-center gap-2">{children}</div>}
</div>
);
}
function DocsLink() {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Lens docs
<ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
);
}
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 (
<CenteredState
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
title={unavailable ? "Lens API is unavailable" : "Couldn't load investigations"}
description={loadFailureMessage(queryError, unavailable)}
>
<Button size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Try again"}
</Button>
<DocsLink />
</CenteredState>
);
}
export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) {
return (
<div
role="alert"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<span className="min-w-0">{message}</span>
<Button variant="ghost" size="sm" onClick={refresh}>
Retry
</Button>
</div>
);
}
export function InvestigationsLoading() {
return (
<p role="status" className="py-8 text-sm text-muted-foreground">
Loading investigations…
</p>
<CenteredState
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading investigations…"
description="Fetching your investigations and their latest findings."
/>
);
}
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
return (
<div role="alert" className="text-sm">
This investigation was not found.{" "}
<Button variant="link" onClick={() => selectLens(null)}>
<CenteredState
role="alert"
icon={<SearchX className="size-5" />}
title="Investigation not found"
description="It may have been deleted, or the link points to a different proxy."
>
<Button size="sm" onClick={() => selectLens(null)}>
View all investigations
</Button>
</div>
</CenteredState>
);
}

View file

@ -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(<InvestigationsView accessToken="test" />);
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) => {

View file

@ -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) && <InvestigationError error={error} queryError={query.error} refresh={refresh} />}
{query.error && !query.data ? (
<InvestigationsLoadFailed queryError={query.error} refresh={refresh} />
) : (
(error || query.error) && <InvestigationError message={error || query.error?.message || ""} refresh={refresh} />
)}
{query.isLoading && <InvestigationsLoading />}
{loaded && showEmpty && (
<InvestigationsWelcome

View file

@ -40,7 +40,7 @@ export function InvestigationsWelcome({
className: "col-start-2 w-fit sm:col-start-auto",
});
return (
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
<section aria-labelledby="lens-welcome" className="m-auto w-full max-w-2xl py-10">
{showPreview && <LensPreviewButton />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention

View file

@ -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("<!DOCTYPE html><html></html>", { 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();

View file

@ -184,7 +184,13 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
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<Blob> {