mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
7ecdb25f6e
commit
07eb0dd10f
7 changed files with 160 additions and 28 deletions
|
|
@ -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*` }],
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue