mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
feat(ui): add persistent columns and loading skeletons to Lens runs (#44579)
Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
02f61c9c42
commit
9cc15e9320
5 changed files with 257 additions and 29 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import { Inspector } from "@/components/shared/Inspector";
|
||||
|
|
@ -51,6 +52,30 @@ describe("AgentTracesTable empty state", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable loading state", () => {
|
||||
it("fills the first page load with skeleton rows 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")).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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable virtualization", () => {
|
||||
const template = (traceList as TracePage).data[0] as TraceSummary;
|
||||
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
|
||||
|
|
@ -84,3 +109,71 @@ describe("AgentTracesTable virtualization", () => {
|
|||
expect(screen.queryByText("question 0")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentTracesTable column picker", () => {
|
||||
const runs = (traceList as TracePage).data as TraceSummary[];
|
||||
const renderRuns = () =>
|
||||
renderWithProviders(
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={runs}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const headers = () => screen.getAllByRole("columnheader").map((header) => header.textContent);
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("hides a column from the header picker and keeps it hidden after a remount", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderRuns();
|
||||
const before = headers().length;
|
||||
expect(headers()).toContain("Cost");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
expect(screen.queryByTestId("view-option-time")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-option-agent")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("view-option-open")).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByTestId("view-option-cost"));
|
||||
|
||||
expect(headers()).not.toContain("Cost");
|
||||
expect(headers()).toHaveLength(before - 1);
|
||||
const [firstRow] = screen.getAllByTestId("agent-trace-row");
|
||||
expect(within(firstRow).getAllByRole("cell")).toHaveLength(before - 1);
|
||||
|
||||
unmount();
|
||||
renderRuns();
|
||||
expect(headers()).not.toContain("Cost");
|
||||
expect(headers()).toHaveLength(before - 1);
|
||||
});
|
||||
|
||||
it("shapes loading skeletons to the columns still visible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderRuns();
|
||||
await user.click(screen.getByRole("button", { name: "Columns" }));
|
||||
await user.click(await screen.findByTestId("view-option-cost"));
|
||||
unmount();
|
||||
|
||||
render(
|
||||
inList(
|
||||
<AgentTracesTable
|
||||
traces={[]}
|
||||
isLoading
|
||||
error={null}
|
||||
hasMore={false}
|
||||
onLoadMore={vi.fn()}
|
||||
rangeEmpty
|
||||
onSetUpTracing={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const columnCount = screen.getAllByRole("columnheader").length;
|
||||
const [placeholder] = screen.getAllByTestId("runs-placeholder");
|
||||
expect(within(placeholder).getAllByRole("cell", { hidden: true })).toHaveLength(columnCount);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { ArrowDown, ChevronRight } from "lucide-react";
|
|||
import { useEffect } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { DataTableViewOptions } from "@/components/shared/DataTable/DataTableViewOptions";
|
||||
import { usePersistedColumnVisibility } from "@/components/shared/DataTable/usePersistedColumnVisibility";
|
||||
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";
|
||||
|
|
@ -39,6 +42,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";
|
||||
|
|
@ -79,6 +83,7 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
{
|
||||
id: "time",
|
||||
size: 170,
|
||||
enableHiding: false,
|
||||
header: () => (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||
Time <ArrowDown className="size-2.5" />
|
||||
|
|
@ -92,9 +97,27 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
{formatRunTimestamp(row.original.start_time)}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "font-mono tabular-nums text-muted-foreground" },
|
||||
meta: {
|
||||
title: "Time",
|
||||
className: "font-mono tabular-nums text-muted-foreground",
|
||||
renderSkeleton: () => <Skeleton className="h-3 w-24" />,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
size: 160,
|
||||
enableHiding: false,
|
||||
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",
|
||||
|
|
@ -139,26 +162,15 @@ const RUN_COLUMNS: ColumnDef<TraceSummary>[] = [
|
|||
{
|
||||
id: "open",
|
||||
size: 32,
|
||||
header: "",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => <DataTableViewOptions table={table} label="Columns" iconOnly />,
|
||||
cell: () => <ChevronRight className="size-3 text-muted-foreground/60" />,
|
||||
meta: { className: "px-0" },
|
||||
meta: { className: "px-0", headerClassName: "px-1", 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 }) {
|
||||
|
|
@ -167,7 +179,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 }) {
|
||||
|
|
@ -207,11 +221,14 @@ export function AgentTracesTable({
|
|||
const isEmpty = settled && !hasMore && traces.length === 0;
|
||||
const canContinue = settled && hasMore;
|
||||
const autoContinue = canContinue && traces.length > 0;
|
||||
const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("lens-traces");
|
||||
const tableOptions: TableOptions<TraceSummary> = {
|
||||
data: traces,
|
||||
columns: RUN_COLUMNS,
|
||||
getRowId: runKey,
|
||||
autoResetAll: false,
|
||||
state: { columnVisibility },
|
||||
onColumnVisibilityChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
};
|
||||
const table = useReactTable(tableOptions);
|
||||
|
|
@ -221,7 +238,12 @@ export function AgentTracesTable({
|
|||
<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 +255,11 @@ export function AgentTracesTable({
|
|||
)}
|
||||
</InspectorTable.Body>
|
||||
</InspectorTable.Grid>
|
||||
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,21 @@ import type { Table } from "@tanstack/react-table";
|
|||
import { Check, Columns3 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface DataTableViewOptionsProps<TData> {
|
||||
table: Table<TData>;
|
||||
label?: string;
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({ table, label = "View", className }: DataTableViewOptionsProps<TData>) {
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
label = "View",
|
||||
iconOnly = false,
|
||||
className,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide());
|
||||
|
||||
if (hideableColumns.length === 0) {
|
||||
|
|
@ -23,10 +30,26 @@ export function DataTableViewOptions<TData>({ table, label = "View", className }
|
|||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className={className} data-testid="view-options-trigger">
|
||||
<Columns3 />
|
||||
{label}
|
||||
</Button>
|
||||
iconOnly ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
"text-muted-foreground/60 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-testid="view-options-trigger"
|
||||
>
|
||||
<Columns3 />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" className={className} data-testid="view-options-trigger">
|
||||
<Columns3 />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
|
|
|
|||
|
|
@ -104,3 +104,57 @@ describe("InspectorTable tree", () => {
|
|||
expect(screen.getByText("Open: b1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const SKELETON_COLUMNS: ColumnDef<Node>[] = [
|
||||
{ id: "name", header: "Name", meta: { renderSkeleton: () => <span data-testid="custom-skeleton" /> } },
|
||||
{ id: "size", header: "Size", meta: { numeric: true } },
|
||||
];
|
||||
|
||||
function SkeletonTree({ visibility }: { visibility: Record<string, boolean> }) {
|
||||
const tableOptions: TableOptions<Node> = {
|
||||
data: TREE,
|
||||
columns: SKELETON_COLUMNS,
|
||||
getRowId: (node) => node.id,
|
||||
state: { columnVisibility: visibility },
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
};
|
||||
const table = useReactTable(tableOptions);
|
||||
return (
|
||||
<Inspector.Root
|
||||
items={[]}
|
||||
itemKey={(node: Node) => node.id}
|
||||
selected={null}
|
||||
onSelectedChange={() => {}}
|
||||
noun="node"
|
||||
storageKey="inspector-skeleton-test"
|
||||
>
|
||||
<InspectorTable.Root table={table}>
|
||||
<InspectorTable.Grid aria-label="Nodes">
|
||||
<InspectorTable.Header />
|
||||
<InspectorTable.Body<Node>
|
||||
rowHeight={() => 36}
|
||||
after={<InspectorTable.SkeletonRow index={0} data-testid="skeleton-row" />}
|
||||
>
|
||||
{(row) => <InspectorTable.Row row={row} item={row.original} />}
|
||||
</InspectorTable.Body>
|
||||
</InspectorTable.Grid>
|
||||
</InspectorTable.Root>
|
||||
</Inspector.Root>
|
||||
);
|
||||
}
|
||||
|
||||
describe("InspectorTable.SkeletonRow", () => {
|
||||
it("renders one hidden cell per visible leaf column and honors renderSkeleton", () => {
|
||||
render(<SkeletonTree visibility={{}} />);
|
||||
const [row] = screen.getAllByTestId("skeleton-row");
|
||||
expect(row).toHaveAttribute("aria-hidden", "true");
|
||||
expect(within(row).getAllByRole("cell", { hidden: true })).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("custom-skeleton")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("follows column visibility", () => {
|
||||
render(<SkeletonTree visibility={{ size: false }} />);
|
||||
const [row] = screen.getAllByTestId("skeleton-row");
|
||||
expect(within(row).getAllByRole("cell", { hidden: true })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue