diff --git a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.test.tsx b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.test.tsx index dff968334f7..a99c44a98a6 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.test.tsx @@ -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( + , + ), + ); + 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( + , + ), + ); + 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( + , + ), + ); + const columnCount = screen.getAllByRole("columnheader").length; + const [placeholder] = screen.getAllByTestId("runs-placeholder"); + expect(within(placeholder).getAllByRole("cell", { hidden: true })).toHaveLength(columnCount); + }); +}); 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 7d860837bab..1d1334b7881 100644 --- a/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx +++ b/ui/litellm-dashboard/src/components/lens/traces/list/AgentTracesTable.tsx @@ -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[] = [ { id: "time", size: 170, + enableHiding: false, header: () => ( Time @@ -92,9 +97,27 @@ const RUN_COLUMNS: ColumnDef[] = [ {formatRunTimestamp(row.original.start_time)} ), - meta: { className: "font-mono tabular-nums text-muted-foreground" }, + meta: { + title: "Time", + className: "font-mono tabular-nums text-muted-foreground", + renderSkeleton: () => , + }, + }, + { + id: "agent", + size: 160, + enableHiding: false, + header: "Agent", + cell: ({ row }) => , + meta: { + renderSkeleton: () => ( +
+ + +
+ ), + }, }, - { id: "agent", size: 160, header: "Agent", cell: ({ row }) => }, { id: "input", header: "Input", cell: ({ row }) => }, { id: "agents", @@ -139,26 +162,15 @@ const RUN_COLUMNS: ColumnDef[] = [ { id: "open", size: 32, - header: "", + enableHiding: false, + header: ({ table }) => , cell: () => , - meta: { className: "px-0" }, + meta: { className: "px-0", headerClassName: "px-1", 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 }) { @@ -167,7 +179,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 }) { @@ -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 = { 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({ rowHeight={() => ROW_HEIGHT} - after={autoContinue && } + after={ + <> + {isLoading && SKELETON_ROWS.map((row) => )} + {autoContinue && } + + } > {(row) => ( - {isLoading &&
Loading runs…
} + {isLoading && ( +

+ Loading runs… +

+ )} {error && (
diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx index ca60e42d9aa..86d99fd72a6 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx @@ -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 { table: Table; label?: string; + iconOnly?: boolean; className?: string; } -export function DataTableViewOptions({ table, label = "View", className }: DataTableViewOptionsProps) { +export function DataTableViewOptions({ + table, + label = "View", + iconOnly = false, + className, +}: DataTableViewOptionsProps) { const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); if (hideableColumns.length === 0) { @@ -23,10 +30,26 @@ export function DataTableViewOptions({ table, label = "View", className } - - {label} - + iconOnly ? ( + + ) : ( + + ) } /> diff --git a/ui/litellm-dashboard/src/components/shared/InspectorTable.test.tsx b/ui/litellm-dashboard/src/components/shared/InspectorTable.test.tsx index 416d31bc940..8201248c078 100644 --- a/ui/litellm-dashboard/src/components/shared/InspectorTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/InspectorTable.test.tsx @@ -104,3 +104,57 @@ describe("InspectorTable tree", () => { expect(screen.getByText("Open: b1")).toBeInTheDocument(); }); }); + +const SKELETON_COLUMNS: ColumnDef[] = [ + { id: "name", header: "Name", meta: { renderSkeleton: () => } }, + { id: "size", header: "Size", meta: { numeric: true } }, +]; + +function SkeletonTree({ visibility }: { visibility: Record }) { + const tableOptions: TableOptions = { + data: TREE, + columns: SKELETON_COLUMNS, + getRowId: (node) => node.id, + state: { columnVisibility: visibility }, + getCoreRowModel: getCoreRowModel(), + }; + const table = useReactTable(tableOptions); + return ( + node.id} + selected={null} + onSelectedChange={() => {}} + noun="node" + storageKey="inspector-skeleton-test" + > + + + + + rowHeight={() => 36} + after={} + > + {(row) => } + + + + + ); +} + +describe("InspectorTable.SkeletonRow", () => { + it("renders one hidden cell per visible leaf column and honors renderSkeleton", () => { + render(); + 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(); + const [row] = screen.getAllByTestId("skeleton-row"); + expect(within(row).getAllByRole("cell", { hidden: true })).toHaveLength(1); + }); +}); 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;