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 && ( { })); }; +export interface TraceHistogramResult { + buckets: Bucket[]; + /** True until the first histogram for this scope arrives; a range change keeps the previous one instead. */ + isLoading: boolean; +} + /** Matching runs per bucket across the whole range, counted by the server so every run is plotted, not just loaded ones. */ -export function useTraceHistogram(accessToken: string, range: TimeRange, q: string, enabled: boolean): Bucket[] { +export function useTraceHistogram( + accessToken: string, + range: TimeRange, + q: string, + enabled: boolean, +): TraceHistogramResult { const traces = useTracesApi(accessToken); const histogram = useQuery({ queryKey: ["agentTraceHistogram", traces.scope, range.startMs, range.endMs, q], @@ -36,5 +47,5 @@ export function useTraceHistogram(accessToken: string, range: TimeRange, q: stri placeholderData: keepPreviousData, select: toBuckets, }); - return histogram.data ?? emptyBuckets(range); + return { buckets: histogram.data ?? emptyBuckets(range), isLoading: histogram.isLoading }; } diff --git a/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx index d5443fe9ac1..059ad1673ee 100644 --- a/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/InspectorTable.tsx @@ -8,6 +8,7 @@ import { ChevronRight } from "lucide-react"; import { createContext, Fragment, useContext, useState, type ComponentProps, type ReactNode } from "react"; import { Inspector } from "@/components/shared/Inspector"; +import { Skeleton } from "@/components/ui/skeleton"; import { TableBody, TableCell, TableHead, TableHeader } from "@/components/ui/table"; import { cn } from "@/lib/cva.config"; @@ -137,6 +138,37 @@ function Row({ row, item, className, ...props }: RowProps) { ); } +const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]", "w-[48%]"] as const; + +type SkeletonRowProps = ComponentProps<"tr"> & { readonly index: number }; + +/** One placeholder row shaped by the visible columns: `meta.renderSkeleton` wins, numeric cells right-align. */ +function SkeletonRow({ index, className, ...props }: SkeletonRowProps) { + const { table } = useInspectorTable(); + return ( + + {table.getVisibleLeafColumns().map((column, position) => { + const meta = column.columnDef.meta; + return ( + + {meta?.renderSkeleton ? ( + meta.renderSkeleton() + ) : ( + + )} + + ); + })} + + ); +} + interface IndentProps { readonly row: TanStackRow; readonly toggleLabel?: (expanded: boolean) => string; @@ -176,4 +208,4 @@ function Indent({ row, toggleLabel = (expanded) => (expanded ? "Collapse" : " ); } -export const InspectorTable = { Root, Grid, Header, Body, Row, Indent } as const; +export const InspectorTable = { Root, Grid, Header, Body, Row, SkeletonRow, Indent } as const; diff --git a/ui/litellm-dashboard/src/components/shared/dotField/dots.test.ts b/ui/litellm-dashboard/src/components/shared/dotField/dots.test.ts index a3471f0b02d..d6dc822ae0d 100644 --- a/ui/litellm-dashboard/src/components/shared/dotField/dots.test.ts +++ b/ui/litellm-dashboard/src/components/shared/dotField/dots.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; -import { DOT_COLS, DOT_ROWS, NEUTRAL_COLOR, SERIES_COLORS, columnDots, litDots, seriesColor } from "./dots"; +import { + DOT_COLS, + DOT_ROWS, + NEUTRAL_COLOR, + SERIES_COLORS, + columnDots, + litDots, + seriesColor, + skeletonColumns, +} from "./dots"; const capacity = DOT_ROWS * DOT_COLS; const column = (total: number, failed = 0, series: string[] = []) => ({ total, failed, series }); @@ -66,3 +75,20 @@ describe("seriesColor", () => { expect(SERIES_COLORS).not.toContain(seriesColor("")); }); }); + +describe("skeletonColumns", () => { + it("fills every column with muted, varied, failure-free placeholders", () => { + const columns = skeletonColumns(60); + expect(columns).toHaveLength(60); + expect(columns.every((column) => column.total > 0 && column.failed === 0)).toBe(true); + expect(new Set(columns.map((column) => column.total)).size).toBeGreaterThan(3); + const max = Math.max(...columns.map((column) => column.total)); + const dots = columnDots(columns[0], max, 0); + expect(dots.filter((dot) => dot.kind === "series").every((dot) => dot.color === NEUTRAL_COLOR)).toBe(true); + expect(columns.every((column) => (column.opacity ?? 1) < 1)).toBe(true); + }); + + it("is deterministic so the placeholder does not jump between renders", () => { + expect(skeletonColumns(60)).toEqual(skeletonColumns(60)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/dotField/dots.ts b/ui/litellm-dashboard/src/components/shared/dotField/dots.ts index 5cfc4cf75af..9dd2440dbd5 100644 --- a/ui/litellm-dashboard/src/components/shared/dotField/dots.ts +++ b/ui/litellm-dashboard/src/components/shared/dotField/dots.ts @@ -24,6 +24,18 @@ export interface DotBand { const hash = (seed: number, value: number): number => Math.imul(seed ^ Math.imul(value, 0x9e3779b1), 0x85ebca6b) >>> 0; +const SKELETON_OPACITY = 0.35; + +/** Muted placeholder columns to paint while the real ones load: a gentle swell with per-column jitter. */ +export function skeletonColumns(count: number): readonly DotColumn[] { + return Array.from({ length: count }, (_, i) => ({ + total: 1 + Math.round(3 * (1 + Math.sin(i / 3.5))) + (hash(29, i) % 3), + failed: 0, + series: [], + opacity: SKELETON_OPACITY, + })); +} + export function seriesColor(name: string): string { if (!name) return NEUTRAL_COLOR; const code = Array.from(name).reduce((total, char) => hash(total, char.charCodeAt(0)), 7);
+ Loading runs… +