mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(ui): share one centered loading state across Lens traces and investigations
This commit is contained in:
parent
b99c6d40d2
commit
a0a275e486
7 changed files with 87 additions and 25 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
|
import { ArrowUpRight, SearchX, TriangleAlert } from "lucide-react";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { LoadingState } from "../ui/LoadingState";
|
||||||
import { StateMessage } from "../ui/StateMessage";
|
import { StateMessage } from "../ui/StateMessage";
|
||||||
|
|
||||||
import { ApiError } from "@/lib/http/client";
|
import { ApiError } from "@/lib/http/client";
|
||||||
|
|
@ -63,9 +64,7 @@ export function InvestigationError({ message, refresh }: { message: string; refr
|
||||||
|
|
||||||
export function InvestigationsLoading() {
|
export function InvestigationsLoading() {
|
||||||
return (
|
return (
|
||||||
<StateMessage
|
<LoadingState
|
||||||
role="status"
|
|
||||||
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
|
|
||||||
title="Loading investigations…"
|
title="Loading investigations…"
|
||||||
description="Fetching your investigations and their latest findings."
|
description="Fetching your investigations and their latest findings."
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,26 @@ it("runs with saved settings from Run now without opening setup, then accepts an
|
||||||
expect(sentBody(proxy.post, "/lens/lens/runs")).toEqual([{ agent_name: "billing", lookback_hours: 24 }]);
|
expect(sentBody(proxy.post, "/lens/lens/runs")).toEqual([{ agent_name: "billing", lookback_hours: 24 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a loading state until the investigation list arrives", async () => {
|
||||||
|
window.history.replaceState({}, "", "/lens/");
|
||||||
|
testQueryClient.clear();
|
||||||
|
let resolveList: (list: unknown) => void = () => {};
|
||||||
|
proxy.get.mockImplementation((path) => {
|
||||||
|
if (path === "/lens")
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveList = resolve;
|
||||||
|
});
|
||||||
|
return Promise.resolve({ traces: true, requests: false, data: [] });
|
||||||
|
});
|
||||||
|
renderWithProviders(withPreview(<InvestigationsView />, vi.fn()));
|
||||||
|
expect(await screen.findByRole("status", { name: "Loading investigations…" })).toBeVisible();
|
||||||
|
expect(screen.queryByRole("region", { name: "Get Lens running" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => resolveList({ lenses: [], workers: [], tracing_enabled: true }));
|
||||||
|
expect(await screen.findByRole("region", { name: "Get Lens running" })).toBeVisible();
|
||||||
|
expect(screen.queryByRole("status", { name: "Loading investigations…" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("offers the interactive demo without starting an investigation", async () => {
|
it("offers the interactive demo without starting an investigation", async () => {
|
||||||
window.history.replaceState({}, "", "/lens/");
|
window.history.replaceState({}, "", "/lens/");
|
||||||
testQueryClient.clear();
|
testQueryClient.clear();
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,25 @@ describe("RunView", () => {
|
||||||
expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close");
|
expect(screen.getByLabelText("Keyboard shortcuts")).toHaveTextContent("↑/↓ step←/→ foldEsc close");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a loading state until the first trace arrives", async () => {
|
||||||
|
let resolveTrace: (trace: Trace) => void = () => {};
|
||||||
|
vi.mocked(agentTraceCall).mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Trace>((resolve) => {
|
||||||
|
resolveTrace = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithProviders(
|
||||||
|
<RoutedRunView traceId={research.summary.trace_id} accessToken="sk-test" onBack={vi.fn()} embedded />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status", { name: "Loading trace…" })).toBeVisible();
|
||||||
|
expect(screen.queryByTestId("run-view")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => resolveTrace(research));
|
||||||
|
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
|
||||||
|
expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
|
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let resolveSwarm: (trace: Trace) => void = () => {};
|
let resolveSwarm: (trace: Trace) => void = () => {};
|
||||||
|
|
@ -161,7 +180,7 @@ describe("RunView", () => {
|
||||||
|
|
||||||
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
|
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
|
||||||
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
|
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
|
||||||
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("status", { name: "Loading trace…" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
|
||||||
await user.keyboard("{ArrowDown}");
|
await user.keyboard("{ArrowDown}");
|
||||||
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
|
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|
||||||
import { cn } from "@/lib/cva.config";
|
import { cn } from "@/lib/cva.config";
|
||||||
|
|
||||||
import { useTracesApi } from "../../api";
|
import { useTracesApi } from "../../api";
|
||||||
|
import { LoadingState } from "../../../ui/LoadingState";
|
||||||
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
|
import { classifyTraceReadFailure, traceReadRetry, traceReadRetryDelay } from "../../list/traceReadFailure";
|
||||||
import { type RunSelection, traceKey } from "../../routing";
|
import { type RunSelection, traceKey } from "../../routing";
|
||||||
import type { Trace } from "../../types";
|
import type { Trace } from "../../types";
|
||||||
|
|
@ -32,22 +32,8 @@ function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
|
||||||
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
|
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunLoading({ embedded }: { embedded: boolean }) {
|
function RunLoading() {
|
||||||
return (
|
return <LoadingState title="Loading trace…" description="Fetching this run and its steps." />;
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading trace"
|
|
||||||
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
|
|
||||||
>
|
|
||||||
{embedded ? (
|
|
||||||
[72, 48, 88, 60, 80].map((w) => (
|
|
||||||
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
|
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
|
||||||
|
|
@ -85,7 +71,7 @@ export function RunView(props: RunViewProps) {
|
||||||
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
|
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
|
<Suspense fallback={<RunLoading />}>
|
||||||
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
|
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,26 @@ describe("AgentTracesTable empty state", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AgentTracesTable loading state", () => {
|
||||||
|
it("announces the first page load instead of an empty table", () => {
|
||||||
|
render(
|
||||||
|
inList(
|
||||||
|
<AgentTracesTable
|
||||||
|
traces={[]}
|
||||||
|
isLoading
|
||||||
|
error={null}
|
||||||
|
hasMore={false}
|
||||||
|
onLoadMore={vi.fn()}
|
||||||
|
rangeEmpty
|
||||||
|
onSetUpTracing={vi.fn()}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("status", { name: "Loading runs…" })).toBeVisible();
|
||||||
|
expect(screen.queryByText(/No runs/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("AgentTracesTable virtualization", () => {
|
describe("AgentTracesTable virtualization", () => {
|
||||||
const template = (traceList as TracePage).data[0] as TraceSummary;
|
const template = (traceList as TracePage).data[0] as TraceSummary;
|
||||||
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
|
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation
|
||||||
import { SpanIcon } from "../ui/SpanIcon";
|
import { SpanIcon } from "../ui/SpanIcon";
|
||||||
import { StatusMark } from "../ui/StatusMark";
|
import { StatusMark } from "../ui/StatusMark";
|
||||||
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
|
import { FrameworkLogo, traceFramework } from "../ui/TraceFramework";
|
||||||
|
import { LoadingState } from "../../ui/LoadingState";
|
||||||
import type { TraceSummary } from "../types";
|
import type { TraceSummary } from "../types";
|
||||||
import { traceRefOf } from "../routing";
|
import { traceRefOf } from "../routing";
|
||||||
import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils";
|
import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils";
|
||||||
|
|
@ -215,7 +216,7 @@ export function AgentTracesTable({
|
||||||
};
|
};
|
||||||
const table = useReactTable(tableOptions);
|
const table = useReactTable(tableOptions);
|
||||||
return (
|
return (
|
||||||
<InspectorTable.Root table={table} data-testid="runs-table">
|
<InspectorTable.Root table={table} data-testid="runs-table" className="flex flex-col">
|
||||||
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
|
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
|
||||||
<InspectorTable.Header />
|
<InspectorTable.Header />
|
||||||
<InspectorTable.Body<TraceSummary>
|
<InspectorTable.Body<TraceSummary>
|
||||||
|
|
@ -232,7 +233,7 @@ export function AgentTracesTable({
|
||||||
)}
|
)}
|
||||||
</InspectorTable.Body>
|
</InspectorTable.Body>
|
||||||
</InspectorTable.Grid>
|
</InspectorTable.Grid>
|
||||||
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>}
|
{isLoading && <LoadingState title="Loading runs…" description="Fetching agent runs for this time range." />}
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
|
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
17
ui/litellm-dashboard/src/components/lens/ui/LoadingState.tsx
Normal file
17
ui/litellm-dashboard/src/components/lens/ui/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { StateMessage, type StateMessageProps } from "./StateMessage";
|
||||||
|
|
||||||
|
type LoadingStateProps = Omit<StateMessageProps, "role" | "icon" | "tone">;
|
||||||
|
|
||||||
|
export function LoadingState({ title, ...props }: LoadingStateProps) {
|
||||||
|
return (
|
||||||
|
<StateMessage
|
||||||
|
role="status"
|
||||||
|
aria-label={title}
|
||||||
|
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
|
||||||
|
title={title}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue