From 35f638b567b16357a909019eb948f5b877896d34 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 16:15:44 -0700 Subject: [PATCH] feat(ui): skeleton rows and dot field while Lens runs load Replace the centered spinner on the Lens traces list with column-shaped skeleton rows, and paint a muted placeholder profile in the timeline dot field until the first histogram arrives. InspectorTable gains a reusable SkeletonRow driven by column meta, and the load-more tail reuses it. --- .../AgentTracesSection.integration.test.tsx | 3 +- .../lens/traces/list/AgentTracesSection.tsx | 10 +++- .../traces/list/AgentTracesTable.test.tsx | 10 +++- .../lens/traces/list/AgentTracesTable.tsx | 60 ++++++++++++------- .../lens/traces/list/TracesTimeline.tsx | 15 +++-- .../lens/traces/list/useTraceHistogram.ts | 15 ++++- .../src/components/shared/InspectorTable.tsx | 34 ++++++++++- .../components/shared/dotField/dots.test.ts | 28 ++++++++- .../src/components/shared/dotField/dots.ts | 12 ++++ 9 files changed, 150 insertions(+), 37 deletions(-) diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx index ed5c7a1016e..175783b382b 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.integration.test.tsx @@ -581,9 +581,10 @@ describe("AgentTracesSection", () => { it("plots every loaded run on the timeline", async () => { serve(runs); renderWindowed(); + expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "true"); await screen.findAllByTestId("agent-trace-row"); - expect(screen.getByTestId("traces-timeline")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("traces-timeline")).toHaveAttribute("aria-busy", "false")); const counts = bucketRunCounts(); expect(counts).toHaveLength(60); expect(counts.reduce((a, b) => a + b, 0)).toBe(runs.length); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx index fb66f473984..61fd228cec6 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesSection.tsx @@ -109,7 +109,7 @@ export function AgentTracesSection({ () => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }), [startTime, endTime, isCustomDate, endMs], ); - const buckets = useTraceHistogram(accessToken, range, query, isActive); + const histogram = useTraceHistogram(accessToken, range, query, isActive); const runs = traces.traces; const runRefs = useMemo(() => runs.map(traceRefOf), [runs]); @@ -191,7 +191,13 @@ export function AgentTracesSection({ /> )} - + { }); describe("AgentTracesTable loading state", () => { - it("announces the first page load instead of an empty table", () => { + it("fills the first page load with skeleton rows instead of an empty table", () => { render( inList( { />, ), ); - expect(screen.getByRole("status", { name: "Loading runs…" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("Loading runs…"); + const placeholders = screen.getAllByTestId("runs-placeholder"); + expect(placeholders.length).toBeGreaterThanOrEqual(8); + const columnCount = screen.getAllByRole("columnheader").length; + expect(within(placeholders[0]).getAllByRole("cell", { hidden: true })).toHaveLength(columnCount); expect(screen.queryByText(/No runs/)).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx index fd0aeb0d350..d1d645d0446 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx @@ -7,12 +7,12 @@ import { useInView } from "react-intersection-observer"; import { InspectorTable, useInspectorTable } from "@/components/shared/InspectorTable"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp"; import { SpanIcon } from "../ui/SpanIcon"; import { StatusMark } from "../ui/StatusMark"; import { FrameworkLogo, traceFramework } from "../ui/TraceFramework"; -import { LoadingState } from "../../ui/LoadingState"; import type { TraceSummary } from "../types"; import { traceRefOf } from "../routing"; import { fmtMs, previewText, traceDisplayName, traceAgentNames } from "../utils"; @@ -40,6 +40,7 @@ const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id; const PREFETCH_MARGIN = "0px 0px 480px 0px"; const PLACEHOLDER_ROWS = [0, 1, 2]; +const SKELETON_ROWS = Array.from({ length: 12 }, (_, i) => i); const ROW_HEIGHT = 36; const MUTED_NUM = "font-mono text-muted-foreground"; const NUM = "font-mono text-foreground"; @@ -93,9 +94,25 @@ const RUN_COLUMNS: ColumnDef[] = [ {formatRunTimestamp(row.original.start_time)} ), - meta: { className: "font-mono tabular-nums text-muted-foreground" }, + meta: { + className: "font-mono tabular-nums text-muted-foreground", + renderSkeleton: () => , + }, + }, + { + id: "agent", + size: 160, + header: "Agent", + cell: ({ row }) => , + meta: { + renderSkeleton: () => ( +
+ + +
+ ), + }, }, - { id: "agent", size: 160, header: "Agent", cell: ({ row }) => }, { id: "input", header: "Input", cell: ({ row }) => }, { id: "agents", @@ -142,24 +159,12 @@ const RUN_COLUMNS: ColumnDef[] = [ size: 32, header: "", cell: () => , - meta: { className: "px-0" }, + meta: { className: "px-0", renderSkeleton: () => null }, }, ]; -function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void }) { - return ( - - -
- - -
- - -
- - - ); +function PlaceholderRow({ index, rowRef }: { index: number; rowRef?: (node: Element | null) => void }) { + return ; } function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadMore: () => void }) { @@ -168,7 +173,9 @@ function LoadMoreRows({ isFetching, onLoadMore }: { isFetching: boolean; onLoadM useEffect(() => { if (nearTail && !isFetching) onLoadMore(); }, [nearTail, isFetching, onLoadMore]); - return PLACEHOLDER_ROWS.map((row) => ); + return PLACEHOLDER_ROWS.map((row) => ( + + )); } function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) { @@ -216,12 +223,17 @@ export function AgentTracesTable({ }; const table = useReactTable(tableOptions); return ( - + rowHeight={() => ROW_HEIGHT} - after={autoContinue && } + after={ + <> + {isLoading && SKELETON_ROWS.map((row) => )} + {autoContinue && } + + } > {(row) => ( - {isLoading && } + {isLoading && ( +

+ Loading runs… +

+ )} {error && (
diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx index 43e1441ef4a..39f92475ef4 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/TracesTimeline.tsx @@ -6,7 +6,7 @@ import { useRef, useState, type RefObject } from "react"; import { useResizeObserver } from "usehooks-ts"; import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField"; -import type { DotBand, DotColumn } from "@/components/shared/dotField/dots"; +import { type DotBand, type DotColumn, skeletonColumns } from "@/components/shared/dotField/dots"; import { cn } from "@/lib/cva.config"; export const BUCKETS = 60; @@ -15,6 +15,7 @@ const MINUTE_MS = 60 * 1000; const HOUR_MS = 60 * MINUTE_MS; const DAY_MS = 24 * HOUR_MS; const EDGE_FORMAT = "MMM DD, HH:mm"; +const SKELETON_COLUMNS = skeletonColumns(BUCKETS); import type { TimeRange as TimeWindow } from "../api"; @@ -52,6 +53,8 @@ interface TracesTimelineProps { range: TimeWindow; selection: TimeWindow | null; onSelect: (selection: TimeWindow | null) => void; + /** Paint a muted placeholder profile until the first histogram arrives. */ + loading?: boolean; } function BucketBar({ bucket }: { bucket: Bucket }) { @@ -208,7 +211,7 @@ function TickAxis({ range }: { range: TimeWindow }) { } /** Histogram of runs over the window. Drag to select; drag the bracket or its edges to adjust; Esc clears. */ -export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTimelineProps) { +export function TracesTimeline({ buckets, range, selection, onSelect, loading = false }: TracesTimelineProps) { const [hover, setHover] = useState(null); const [drag, setDrag] = useState(null); const [draft, setDraft] = useState(null); @@ -262,17 +265,19 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi }; const labelFormat = edgeFormat(range); + const showTooltip = hover !== null && !drag && !loading; return (
)} - + {buckets.map((b) => ( @@ -309,7 +314,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
- {hover !== null && !drag && } + {showTooltip && } {selection && (