mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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.
This commit is contained in:
parent
a0a275e486
commit
35f638b567
9 changed files with 150 additions and 37 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
/>
|
||||
)}
|
||||
</RunsToolbar>
|
||||
<TracesTimeline buckets={buckets} range={range} selection={zoom} onSelect={setZoom} />
|
||||
<TracesTimeline
|
||||
buckets={histogram.buckets}
|
||||
loading={histogram.isLoading}
|
||||
range={range}
|
||||
selection={zoom}
|
||||
onSelect={setZoom}
|
||||
/>
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={traces.isLoading || (checkHistory && history.isLoading)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
|
|
@ -52,7 +52,7 @@ describe("AgentTracesTable empty state", () => {
|
|||
});
|
||||
|
||||
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(
|
||||
<AgentTracesTable
|
||||
|
|
@ -66,7 +66,11 @@ describe("AgentTracesTable loading state", () => {
|
|||
/>,
|
||||
),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<TraceSummary>[] = [
|
|||
{formatRunTimestamp(row.original.start_time)}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "font-mono tabular-nums text-muted-foreground" },
|
||||
meta: {
|
||||
className: "font-mono tabular-nums text-muted-foreground",
|
||||
renderSkeleton: () => <Skeleton className="h-3 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
size: 160,
|
||||
header: "Agent",
|
||||
cell: ({ row }) => <AgentCell run={row.original} />,
|
||||
meta: {
|
||||
renderSkeleton: () => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="size-3.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
},
|
||||
{ id: "agent", size: 160, header: "Agent", cell: ({ row }) => <AgentCell run={row.original} /> },
|
||||
{ id: "input", header: "Input", cell: ({ row }) => <InputCell run={row.original} /> },
|
||||
{
|
||||
id: "agents",
|
||||
|
|
@ -142,24 +159,12 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
size: 32,
|
||||
header: "",
|
||||
cell: () => <ChevronRight className="size-3 text-muted-foreground/60" />,
|
||||
meta: { className: "px-0" },
|
||||
meta: { className: "px-0", renderSkeleton: () => null },
|
||||
},
|
||||
];
|
||||
|
||||
function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void }) {
|
||||
return (
|
||||
<tr ref={rowRef} aria-hidden data-testid="runs-placeholder" className="h-9 border-b border-border/60">
|
||||
<td className="px-3">
|
||||
<div className="h-2.5 w-28 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
|
||||
</td>
|
||||
<td className="px-3">
|
||||
<div className="h-2.5 w-24 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
|
||||
</td>
|
||||
<td className="px-3" colSpan={7}>
|
||||
<div className="h-2.5 w-2/5 animate-pulse rounded-sm bg-muted motion-reduce:animate-none" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
function PlaceholderRow({ index, rowRef }: { index: number; rowRef?: (node: Element | null) => void }) {
|
||||
return <InspectorTable.SkeletonRow ref={rowRef} index={index} data-testid="runs-placeholder" className="h-9" />;
|
||||
}
|
||||
|
||||
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) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />);
|
||||
return PLACEHOLDER_ROWS.map((row) => (
|
||||
<PlaceholderRow key={row} index={row} rowRef={row === 0 ? tailRef : undefined} />
|
||||
));
|
||||
}
|
||||
|
||||
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
|
||||
|
|
@ -216,12 +223,17 @@ export function AgentTracesTable({
|
|||
};
|
||||
const table = useReactTable(tableOptions);
|
||||
return (
|
||||
<InspectorTable.Root table={table} data-testid="runs-table" className="flex flex-col">
|
||||
<InspectorTable.Root table={table} data-testid="runs-table">
|
||||
<InspectorTable.Grid aria-label="Agent runs" aria-busy={isFetching} className="min-w-[900px] text-xs">
|
||||
<InspectorTable.Header />
|
||||
<InspectorTable.Body<TraceSummary>
|
||||
rowHeight={() => ROW_HEIGHT}
|
||||
after={autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
|
||||
after={
|
||||
<>
|
||||
{isLoading && SKELETON_ROWS.map((row) => <PlaceholderRow key={row} index={row} />)}
|
||||
{autoContinue && <LoadMoreRows isFetching={isFetching} onLoadMore={onLoadMore} />}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{(row) => (
|
||||
<InspectorTable.Row
|
||||
|
|
@ -233,7 +245,11 @@ export function AgentTracesTable({
|
|||
)}
|
||||
</InspectorTable.Body>
|
||||
</InspectorTable.Grid>
|
||||
{isLoading && <LoadingState title="Loading runs…" description="Fetching agent runs for this time range." />}
|
||||
{isLoading && (
|
||||
<p role="status" className="sr-only">
|
||||
Loading runs…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [draft, setDraft] = useState<Band | null>(null);
|
||||
|
|
@ -262,17 +265,19 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
|
|||
};
|
||||
|
||||
const labelFormat = edgeFormat(range);
|
||||
const showTooltip = hover !== null && !drag && !loading;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 border-b border-border bg-card px-3 pt-2 pb-1 outline-none select-none"
|
||||
data-testid="traces-timeline"
|
||||
aria-busy={loading}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<DotFieldRoot
|
||||
ref={areaRef}
|
||||
columns={buckets}
|
||||
columns={loading ? SKELETON_COLUMNS : buckets}
|
||||
band={band}
|
||||
hover={hover}
|
||||
role="presentation"
|
||||
|
|
@ -291,7 +296,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
|
|||
data-testid="timeline-cursor"
|
||||
/>
|
||||
)}
|
||||
<DotFieldCanvas />
|
||||
<DotFieldCanvas className={loading ? "animate-pulse motion-reduce:animate-none" : undefined} />
|
||||
<NowEdge />
|
||||
{buckets.map((b) => (
|
||||
<BucketBar key={b.startMs} bucket={b} />
|
||||
|
|
@ -309,7 +314,7 @@ export function TracesTimeline({ buckets, range, selection, onSelect }: TracesTi
|
|||
<div className="mt-1">
|
||||
<TickAxis range={range} />
|
||||
</div>
|
||||
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
{showTooltip && <BucketTooltip bucket={buckets[hover]} index={hover} />}
|
||||
{selection && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -26,8 +26,19 @@ const emptyBuckets = (range: TimeRange): Bucket[] => {
|
|||
}));
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>({ row, item, className, ...props }: RowProps<T>) {
|
|||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<tr aria-hidden data-slot="table-skeleton-row" className={cn("border-b border-border/60", className)} {...props}>
|
||||
{table.getVisibleLeafColumns().map((column, position) => {
|
||||
const meta = column.columnDef.meta;
|
||||
return (
|
||||
<TableCell key={column.id} className={cn("px-3 py-0", meta?.numeric && NUMERIC, meta?.className)}>
|
||||
{meta?.renderSkeleton ? (
|
||||
meta.renderSkeleton()
|
||||
) : (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"h-3",
|
||||
SKELETON_WIDTHS[(index + position) % SKELETON_WIDTHS.length],
|
||||
meta?.numeric && "ml-auto",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
interface IndentProps<T> {
|
||||
readonly row: TanStackRow<T>;
|
||||
readonly toggleLabel?: (expanded: boolean) => string;
|
||||
|
|
@ -176,4 +208,4 @@ function Indent<T>({ 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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Reference in a new issue