From f4e1e8eb68cc34b971c7cc71e77db18d99804184 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 15:03:44 -0700 Subject: [PATCH 01/35] feat(ui): add shared composable DataTable component Phase 0 of the dashboard table-standardization effort: one composable DataTable built on TanStack react-table and the shadcn-style primitives in components/ui/table.tsx (Base UI, Tailwind v4), plus its behavioral test suite. No existing tables are migrated in this change. The component owns the TanStack instance and a shadcn shell, and exposes composable slots (toolbar, pagination, footer) plus DataTableToolbar, DataTablePagination, DataTableViewOptions, and DataTableSortHeader. Sorting and pagination each use a single mode enum (none/client/server) so server modes only surface state via callbacks and never reorder or slice locally. columnMeta.ts defines the canonical ColumnMeta augmentation. The rendering shell imports only components/ui/table primitives; no tremor or antd. --- .../shared/DataTable/DataTable.test.tsx | 431 +++++++++++++++ .../components/shared/DataTable/DataTable.tsx | 497 ++++++++++++++++++ .../DataTable/DataTablePagination.test.tsx | 68 +++ .../shared/DataTable/DataTablePagination.tsx | 113 ++++ .../DataTable/DataTableSortHeader.test.tsx | 112 ++++ .../shared/DataTable/DataTableSortHeader.tsx | 96 ++++ .../DataTable/DataTableToolbar.test.tsx | 35 ++ .../shared/DataTable/DataTableToolbar.tsx | 55 ++ .../shared/DataTable/DataTableViewOptions.tsx | 55 ++ .../components/shared/DataTable/columnMeta.ts | 13 + .../src/components/shared/DataTable/index.ts | 16 + .../src/components/shared/DataTable/types.ts | 60 +++ 12 files changed, 1551 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/index.ts create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/types.ts diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx new file mode 100644 index 00000000000..df42c156975 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -0,0 +1,431 @@ +import type { ColumnDef, ExpandedState } from "@tanstack/react-table"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTable } from "./DataTable"; +import { DataTableSortHeader } from "./DataTableSortHeader"; +import { DataTableViewOptions } from "./DataTableViewOptions"; + +interface Person { + id: string; + name: string; + email: string; + flagged?: boolean; +} + +function person(id: string, name: string, flagged = false): Person { + return { id, name, email: `${name.toLowerCase()}@x.io`, flagged }; +} + +const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent); + +const nameCellColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +const headerCycleColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const dropdownSortColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.original.name}, + }, +]; + +const nameEmailColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const pinnedColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + meta: { pinned: "left" }, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, +]; + +const rowClickColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, + { + id: "actions", + header: "Actions", + cell: () => ( +
+ + +
+ ), + }, +]; + +const expansionColumns: ColumnDef[] = [ + { + id: "expander", + header: "", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => {row.original.name}, + }, +]; + +const CHARLIE_ALICE_BOB: Person[] = [person("c", "Charlie"), person("a", "Alice"), person("b", "Bob")]; + +describe("DataTable sorting", () => { + it("client mode reorders rows when the sort header is clicked", async () => { + const user = userEvent.setup(); + render(); + + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + await user.click(screen.getByTestId("sort-header-name")); + expect(names()).toEqual(["Alice", "Bob", "Charlie"]); + }); + + it("server mode fires the callback but never reorders locally", async () => { + const user = userEvent.setup(); + const onSortingChange = vi.fn(); + render( + , + ); + + // sorting state says ascending, but server mode must render data as given + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + await user.click(screen.getByTestId("sort-header-name")); + expect(onSortingChange).toHaveBeenCalledTimes(1); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); + + it("dropdown-tristate variant sorts ascending, descending, then resets", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Ascending")); + expect(names()).toEqual(["Alice", "Bob", "Charlie"]); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Descending")); + expect(names()).toEqual(["Charlie", "Bob", "Alice"]); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Reset")); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); +}); + +describe("DataTable pagination", () => { + const fivePeople: Person[] = Array.from({ length: 5 }, (_, i) => person(String(i), `P${i}`)); + + it("client mode slices rows and advances pages", async () => { + const user = userEvent.setup(); + render(); + + expect(names()).toEqual(["P0", "P1"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 5"); + + await user.click(screen.getByTestId("pagination-next")); + expect(names()).toEqual(["P2", "P3"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 3-4 of 5"); + }); + + it("server mode shows X-Y of Z from rowCount and does NOT slice the given rows", async () => { + const user = userEvent.setup(); + const onPaginationChange = vi.fn(); + const pageSlice: Person[] = [person("10", "P10"), person("11", "P11"), person("12", "P12")]; + render( + , + ); + + expect(names()).toEqual(["P10", "P11", "P12"]); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 11-20 of 25"); + + await user.click(screen.getByTestId("pagination-next")); + expect(onPaginationChange).toHaveBeenCalledTimes(1); + }); +}); + +describe("DataTable column visibility", () => { + it("hides a column when toggled off in the view-options menu", async () => { + const user = userEvent.setup(); + render( + } + />, + ); + + expect(screen.getByText("Email")).toBeInTheDocument(); + await user.click(screen.getByTestId("view-options-trigger")); + await user.click(await screen.findByTestId("view-option-email")); + await waitFor(() => expect(screen.queryByText("Email")).not.toBeInTheDocument()); + + await user.click(screen.getByTestId("view-option-email")); + await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument()); + }); + + it("omits columns that opt out of hiding from the menu", async () => { + const user = userEvent.setup(); + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + enableHiding: false, + cell: ({ row }) => {row.original.name}, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => {row.original.email}, + }, + ]; + render( + } + />, + ); + + await user.click(screen.getByTestId("view-options-trigger")); + expect(await screen.findByTestId("view-option-email")).toBeInTheDocument(); + expect(screen.queryByTestId("view-option-name")).toBeNull(); + }); +}); + +describe("DataTable pinned columns", () => { + it("applies sticky positioning to a pinned column only", () => { + const { container } = render(); + + const pinnedHead = container.querySelector('th[data-header-id="name"]'); + const normalHead = container.querySelector('th[data-header-id="email"]'); + + expect(pinnedHead?.style.position).toBe("sticky"); + expect(pinnedHead?.style.left).toBe("0px"); + expect(normalHead?.style.position).toBe(""); + }); +}); + +describe("DataTable row click guard", () => { + it("fires onRowClick from a plain cell but not from interactive elements", async () => { + const user = userEvent.setup(); + const onRowClick = vi.fn(); + render(); + + await user.click(screen.getByTestId("name-cell")); + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledWith(expect.objectContaining({ id: "a" })); + + await user.click(screen.getByTestId("row-button")); + expect(onRowClick).toHaveBeenCalledTimes(1); + + await user.click(screen.getByTestId("row-input")); + expect(onRowClick).toHaveBeenCalledTimes(1); + }); +}); + +describe("DataTable expansion", () => { + const subComponent = ({ row }: { row: { original: Person } }) => ( +
details for {row.original.name}
+ ); + + it("toggles the sub-row in uncontrolled mode", async () => { + const user = userEvent.setup(); + render( + row.id} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + />, + ); + + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.getByTestId("sub-row")).toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); + + it("toggles the sub-row in controlled mode driven by parent state", async () => { + const user = userEvent.setup(); + const Harness = () => { + const [expanded, setExpanded] = useState({}); + return ( + row.id} + expanded={expanded} + onExpandedChange={setExpanded} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + /> + ); + }; + render(); + + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.getByTestId("sub-row")).toBeInTheDocument(); + await user.click(screen.getByTestId("expand-a")); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); + + it("stays collapsed in controlled mode when the parent ignores the change", async () => { + const user = userEvent.setup(); + const onExpandedChange = vi.fn(); + render( + row.id} + expanded={{}} + onExpandedChange={onExpandedChange} + getRowCanExpand={() => true} + renderSubComponent={subComponent} + />, + ); + + await user.click(screen.getByTestId("expand-a")); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("sub-row")).not.toBeInTheDocument(); + }); +}); + +describe("DataTable row styling and footer", () => { + it("applies rowClassName to the matching row only", () => { + const data = [person("a", "Alice", true), person("b", "Bob", false)]; + const { container } = render( + row.id} + rowClassName={(row) => (row.original.flagged ? "flagged-row" : "")} + />, + ); + + expect(container.querySelector('tr[data-row-id="a"]')?.className).toContain("flagged-row"); + expect(container.querySelector('tr[data-row-id="b"]')?.className).not.toContain("flagged-row"); + }); + + it("renders the footer slot inside a tfoot element", () => { + render( + ( + + Total: 3 + + )} + />, + ); + + expect(screen.getByTestId("footer-row").closest("tfoot")).not.toBeNull(); + }); +}); + +describe("DataTable layout", () => { + it("exposes resize handles with stable selectors only when resizing is enabled", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelectorAll("[data-resizer][data-header-id]").length).toBe(2); + + rerender(); + expect(container.querySelectorAll("[data-resizer]").length).toBe(0); + }); + + it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { + const { container } = render(); + expect(container.querySelector("thead")?.className).toContain("sticky"); + const scroller = container.querySelector('[data-slot="table-container"]')?.parentElement as HTMLElement; + expect(scroller.style.maxHeight).toBe("240px"); + }); +}); + +describe("DataTable misconfiguration guards", () => { + it("throws when server sorting is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /sortingMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when server pagination is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /paginationMode='server'/, + ); + spy.mockRestore(); + }); + + it("throws when both defaultSorting and sorting are provided", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => + render( + , + ), + ).toThrow(/defaultSorting/); + spy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx new file mode 100644 index 00000000000..7655454f381 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -0,0 +1,497 @@ +"use client"; + +import { + type Cell, + type Column, + type ColumnDef, + type ColumnPinningState, + type ColumnSizingState, + type ExpandedState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + type Header, + type OnChangeFn, + type Row, + type RowData, + type Table, + type TableOptions, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table"; +import * as React from "react"; +import { Fragment, useState } from "react"; + +import { + Table as TableRoot, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/cva.config"; + +import "./columnMeta"; +import { DataTablePagination } from "./DataTablePagination"; +import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types"; + +const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; + +const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]"; + +const noop = () => {}; + +export class DataTableConfigError extends Error { + constructor(messages: readonly string[]) { + super(`DataTable misconfiguration:\n- ${messages.join("\n- ")}`); + this.name = "DataTableConfigError"; + } +} + +export function validateDataTableConfig( + props: DataTableProps, +): readonly string[] { + const serverSortingIncomplete = + props.sortingMode === "server" && (props.sorting === undefined || props.onSortingChange === undefined); + + const serverPaginationPropsMissing = + props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined; + const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing; + + const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined; + + return [ + serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null, + serverPaginationIncomplete + ? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`." + : null, + bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null, + ].filter((message): message is string => message !== null); +} + +function columnDefId(column: ColumnDef): string | undefined { + if ("id" in column && typeof column.id === "string") { + return column.id; + } + if ("accessorKey" in column && column.accessorKey != null) { + return String(column.accessorKey); + } + return undefined; +} + +function derivePinning(columns: ColumnDef[]): ColumnPinningState { + const collect = (side: ColumnPinnedSide): string[] => + columns + .filter((column) => column.meta?.pinned === side) + .map(columnDefId) + .filter((id): id is string => id !== undefined); + return { left: collect("left"), right: collect("right") }; +} + +function buildRowModels( + sortingMode: SortingMode, + paginationMode: PaginationMode, + getRowCanExpand: ((row: Row) => boolean) | undefined, +): Partial> { + return { + ...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}), + ...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}), + ...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}), + }; +} + +function stickyZIndex(isPinned: boolean, isHeader: boolean): number { + if (isPinned && isHeader) { + return 30; + } + if (isHeader) { + return 20; + } + return 10; +} + +function pinnedShadow(pinned: false | ColumnPinnedSide): string { + if (pinned === "left") { + return "shadow-[inset_-1px_0_0_var(--color-border)]"; + } + if (pinned === "right") { + return "shadow-[inset_1px_0_0_var(--color-border)]"; + } + return ""; +} + +function computeStickyStyle( + column: Column, + isHeader: boolean, + stickyHeader: boolean, +): { style: React.CSSProperties; className: string } { + const pinned = column.getIsPinned(); + const stickyTop = isHeader && stickyHeader; + if (!pinned && !stickyTop) { + return { style: {}, className: "" }; + } + + const left = pinned === "left" ? column.getStart("left") : undefined; + const right = pinned === "right" ? column.getAfter("right") : undefined; + + const style: React.CSSProperties = { + position: "sticky", + zIndex: stickyZIndex(pinned !== false, isHeader), + ...(stickyTop ? { top: 0 } : {}), + ...(left !== undefined ? { left } : {}), + ...(right !== undefined ? { right } : {}), + }; + + return { style, className: cn(pinned ? "bg-background" : "", pinnedShadow(pinned)) }; +} + +function widthStyle( + column: Column, + enableColumnResizing: boolean, +): React.CSSProperties | undefined { + if (enableColumnResizing || column.columnDef.size !== undefined) { + return { width: column.getSize() }; + } + return undefined; +} + +interface HeadCellProps { + header: Header; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableHeadCell({ header, size, stickyHeader, enableColumnResizing }: HeadCellProps) { + const { column } = header; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, true, stickyHeader); + const canResize = enableColumnResizing && column.getCanResize(); + + return ( + + {header.isPlaceholder ? null : ( +
+ {flexRender(column.columnDef.header, header.getContext())} +
+ )} + {canResize && ( +
column.resetSize()} + className={cn( + "absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none hover:bg-border", + column.getIsResizing() ? "bg-primary" : "", + )} + /> + )} + + ); +} + +interface BodyCellProps { + cell: Cell; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; +} + +function DataTableBodyCell({ cell, size, stickyHeader, enableColumnResizing }: BodyCellProps) { + const { column } = cell; + const meta = column.columnDef.meta; + const sticky = computeStickyStyle(column, false, stickyHeader); + + return ( + + {flexRender(column.columnDef.cell, cell.getContext())} + + ); +} + +interface BodyRowProps { + row: Row; + size: DataTableSize; + stickyHeader: boolean; + enableColumnResizing: boolean; + onRowClick?: (row: TData) => void; + rowClassName?: (row: Row) => string; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; +} + +function DataTableBodyRow({ + row, + size, + stickyHeader, + enableColumnResizing, + onRowClick, + rowClassName, + renderSubComponent, +}: BodyRowProps) { + const clickable = onRowClick !== undefined; + const cells = row.getVisibleCells(); + + const handleClick = (event: React.MouseEvent) => { + if (onRowClick === undefined) { + return; + } + const target = event.target as HTMLElement | null; + if (target === null || !event.currentTarget.contains(target)) { + return; + } + if (target.closest(INTERACTIVE_SELECTOR) !== null) { + return; + } + onRowClick(row.original); + }; + + return ( + + + {cells.map((cell) => ( + + ))} + + {renderSubComponent !== undefined && row.getIsExpanded() && ( + + + {renderSubComponent({ row })} + + + )} + + ); +} + +function MessageRow({ colSpan, children }: { colSpan: number; children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function useControllable( + controlled: T | undefined, + controlledOnChange: OnChangeFn | undefined, + initial: T, +): { value: T; onChange: OnChangeFn } { + const [internal, setInternal] = useState(initial); + if (controlled !== undefined) { + return { value: controlled, onChange: controlledOnChange ?? noop }; + } + return { value: internal, onChange: setInternal }; +} + +function useDataTableInstance(props: DataTableProps): Table { + const { + data, + columns, + getRowId, + sortingMode = "none", + sorting, + onSortingChange, + defaultSorting, + enableSortingRemoval = false, + paginationMode = "none", + pagination, + onPaginationChange, + rowCount, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + enableColumnResizing = false, + columnResizeMode = "onEnd", + defaultColumnVisibility, + getRowCanExpand, + renderSubComponent, + expanded, + onExpandedChange, + } = props; + + const sortingState = useControllable(sorting, onSortingChange, defaultSorting ?? []); + const paginationState = useControllable(pagination, onPaginationChange, { + pageIndex: 0, + pageSize: pageSizeOptions[0] ?? 25, + }); + const expandedState = useControllable(expanded, onExpandedChange, {}); + const [columnVisibility, setColumnVisibility] = useState(defaultColumnVisibility ?? {}); + const [columnSizing, setColumnSizing] = useState({}); + const columnPinning = React.useMemo(() => derivePinning(columns), [columns]); + const expansionGuard = renderSubComponent !== undefined ? getRowCanExpand : undefined; + + const tableOptions: TableOptions = { + data, + columns, + state: { + sorting: sortingState.value, + pagination: paginationState.value, + expanded: expandedState.value, + columnVisibility, + columnSizing, + }, + initialState: { columnPinning }, + manualSorting: sortingMode === "server", + manualPagination: paginationMode === "server", + enableSortingRemoval, + enableColumnResizing, + columnResizeMode, + onSortingChange: sortingState.onChange, + onPaginationChange: paginationState.onChange, + onExpandedChange: expandedState.onChange, + onColumnVisibilityChange: setColumnVisibility, + onColumnSizingChange: setColumnSizing, + getCoreRowModel: getCoreRowModel(), + ...buildRowModels(sortingMode, paginationMode, expansionGuard), + ...(getRowId !== undefined ? { getRowId } : {}), + ...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}), + }; + + return useReactTable(tableOptions); +} + +export function DataTable(props: DataTableProps) { + // Validate once at construction so a misconfig surfaces immediately instead of on every render. + useState(() => { + const errors = validateDataTableConfig(props); + if (errors.length > 0) { + throw new DataTableConfigError(errors); + } + return null; + }); + + const { + isLoading = false, + loadingMessage = "Loading…", + noDataMessage = "No results", + paginationMode = "none", + rowCount, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + enableColumnResizing = false, + onRowClick, + rowClassName, + renderSubComponent, + maxBodyHeight, + size = "default", + toolbar, + paginationSlot, + footer, + } = props; + + const table = useDataTableInstance(props); + + const rows = table.getRowModel().rows; + const visibleColumnCount = table.getVisibleLeafColumns().length; + const stickyHeader = maxBodyHeight !== undefined; + const tableStyle = enableColumnResizing ? { width: table.getTotalSize() } : undefined; + + const renderPagination = (): React.ReactNode => { + if (paginationSlot !== undefined) { + return paginationSlot(table); + } + if (paginationMode === "none") { + return null; + } + const current = table.getState().pagination; + const total = paginationMode === "server" ? rowCount ?? 0 : table.getPrePaginationRowModel().rows.length; + return ( + table.setPageIndex(next)} + onPageSizeChange={(next) => table.setPageSize(next)} + pageSizeOptions={pageSizeOptions} + isLoading={isLoading} + /> + ); + }; + + const renderBody = (): React.ReactNode => { + if (isLoading) { + return {loadingMessage}; + } + if (rows.length === 0) { + return {noDataMessage}; + } + return rows.map((row) => ( + + )); + }; + + return ( +
+ {toolbar !== undefined &&
{toolbar(table)}
} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + {renderBody()} + {footer !== undefined && {footer(table)}} + +
+ {renderPagination()} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx new file mode 100644 index 00000000000..e5bd4a55b53 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTablePagination } from "./DataTablePagination"; + +const baseProps = { + page: 0, + pageSize: 25, + rowCount: 100, + onPageChange: () => {}, + onPageSizeChange: () => {}, +}; + +describe("DataTablePagination", () => { + it("renders the current range from plain props", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 100"); + }); + + it("computes the range for a middle page and clamps the end to rowCount", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 91-100 of 100"); + }); + + it("disables the previous controls on the first page", () => { + render(); + expect(screen.getByTestId("pagination-first")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + expect(screen.getByTestId("pagination-next")).toBeEnabled(); + }); + + it("disables the next controls on the last page", () => { + render(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-last")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeEnabled(); + }); + + it("advances by one page when next is clicked", async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + render(); + await user.click(screen.getByTestId("pagination-next")); + expect(onPageChange).toHaveBeenCalledWith(2); + }); + + it("jumps to the last page index when last is clicked", async () => { + const user = userEvent.setup(); + const onPageChange = vi.fn(); + render(); + await user.click(screen.getByTestId("pagination-last")); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + it("shows an empty state and disables all navigation when there are no rows", () => { + render(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("No results"); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + }); + + it("disables navigation while loading", () => { + render(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + expect(screen.getByTestId("pagination-prev")).toBeDisabled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx new file mode 100644 index 00000000000..5a30b12f27f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { cn } from "@/lib/cva.config"; + +export const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; + +export interface DataTablePaginationProps { + page: number; + pageSize: number; + rowCount: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + pageSizeOptions?: number[]; + isLoading?: boolean; + className?: string; +} + +export function DataTablePagination({ + page, + pageSize, + rowCount, + onPageChange, + onPageSizeChange, + pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + isLoading = false, + className, +}: DataTablePaginationProps) { + const pageCount = pageSize > 0 ? Math.ceil(rowCount / pageSize) : 0; + const start = rowCount === 0 ? 0 : page * pageSize + 1; + const end = Math.min((page + 1) * pageSize, rowCount); + const canPrev = page > 0 && !isLoading; + const canNext = page < pageCount - 1 && !isLoading; + const lastPage = Math.max(pageCount - 1, 0); + + return ( +
+
+ Rows per page + +
+ +
+ + {rowCount === 0 ? "No results" : `Showing ${start}-${end} of ${rowCount}`} + +
+ + + + +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx new file mode 100644 index 00000000000..a6164307a78 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.test.tsx @@ -0,0 +1,112 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + type OnChangeFn, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; + +interface Item { + name: string; +} + +interface HarnessProps { + variant: DataTableSortVariant; + canSort?: boolean; + onSortingChange?: OnChangeFn; +} + +function SortHeaderHarness({ variant, canSort = true, onSortingChange }: HarnessProps) { + const [sorting, setSorting] = useState([]); + const columns: ColumnDef[] = [ + { + accessorKey: "name", + enableSorting: canSort, + header: ({ column }) => , + }, + ]; + const options = { + data: [{ name: "x" }], + columns, + state: { sorting }, + onSortingChange: (updater: SortingState | ((prev: SortingState) => SortingState)) => { + setSorting(updater); + onSortingChange?.(updater); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }; + const table = useReactTable(options); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + +
{flexRender(header.column.columnDef.header, header.getContext())}
+ ); +} + +describe("DataTableSortHeader", () => { + it("renders a plain label and no button when the column cannot sort", () => { + render(); + expect(screen.queryByTestId("sort-header-name")).toBeNull(); + expect(screen.getByText("Name")).toBeInTheDocument(); + }); + + it("header-cycle indicator advances none -> asc -> desc on click", async () => { + const user = userEvent.setup(); + render(); + const indicator = () => screen.getByTestId("sort-header-name").querySelector("[data-sort-indicator]"); + + expect(indicator()).toHaveAttribute("data-sort-indicator", "none"); + await user.click(screen.getByTestId("sort-header-name")); + expect(indicator()).toHaveAttribute("data-sort-indicator", "asc"); + await user.click(screen.getByTestId("sort-header-name")); + expect(indicator()).toHaveAttribute("data-sort-indicator", "desc"); + }); + + it("dropdown-tristate sets ascending, descending, and reset from the menu", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Descending")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="desc"]')).not.toBeNull(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Ascending")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="asc"]')).not.toBeNull(); + + await user.click(screen.getByTestId("sort-trigger-name")); + await user.click(await screen.findByText("Reset")); + expect(screen.getByTestId("sort-trigger-name").querySelector('[data-sort-indicator="none"]')).not.toBeNull(); + }); + + it("dropdown-tristate trigger stops the click from reaching an outer handler", async () => { + const user = userEvent.setup(); + const onOuterClick = vi.fn(); + render( +
+ +
, + ); + + await user.click(screen.getByTestId("sort-trigger-name")); + expect(onOuterClick).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx new file mode 100644 index 00000000000..1cf09ce4f47 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableSortHeader.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Menu } from "@base-ui/react/menu"; +import type { Column, SortDirection } from "@tanstack/react-table"; +import { ChevronDown, ChevronsUpDown, ChevronUp, X } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/cva.config"; + +export type DataTableSortVariant = "header-cycle" | "dropdown-tristate"; + +interface DataTableSortHeaderProps { + column: Column; + title: React.ReactNode; + variant?: DataTableSortVariant; + className?: string; +} + +function SortIndicator({ sorted }: { sorted: false | SortDirection }) { + if (sorted === "asc") { + return ; + } + if (sorted === "desc") { + return ; + } + return ; +} + +const MENU_ITEM_CLASS = + "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"; + +export function DataTableSortHeader({ + column, + title, + variant = "header-cycle", + className, +}: DataTableSortHeaderProps) { + const sorted = column.getIsSorted(); + + if (!column.getCanSort()) { + return {title}; + } + + if (variant === "dropdown-tristate") { + return ( +
+ {title} + + event.stopPropagation()} + className={cn( + "inline-flex size-6 items-center justify-center rounded-md hover:bg-muted", + sorted ? "text-primary" : "text-muted-foreground", + )} + > + + + } + /> + + + + column.toggleSorting(false)}> + Ascending + + column.toggleSorting(true)}> + Descending + + column.clearSorting()}> + Reset + + + + + +
+ ); + } + + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx new file mode 100644 index 00000000000..5d1f5340f5d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTableToolbar } from "./DataTableToolbar"; + +describe("DataTableToolbar", () => { + it("renders slotted action children", () => { + render( + + + , + ); + expect(screen.getByTestId("toolbar-action")).toBeInTheDocument(); + }); + + it("shows the reset button only when there are active filters", async () => { + const user = userEvent.setup(); + const onResetFilters = vi.fn(); + const { rerender } = render(); + expect(screen.queryByText("Reset Filters")).toBeNull(); + + rerender(); + await user.click(screen.getByText("Reset Filters")); + expect(onResetFilters).toHaveBeenCalledTimes(1); + }); + + it("wires the filters toggle button", async () => { + const user = userEvent.setup(); + const onToggleFilters = vi.fn(); + render(); + await user.click(screen.getByText("Filters")); + expect(onToggleFilters).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..80b4ecc7edd --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableToolbar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Search } from "lucide-react"; +import type * as React from "react"; + +import { FilterInput } from "@/components/common_components/Filters/FilterInput"; +import { FiltersButton } from "@/components/common_components/Filters/FiltersButton"; +import { ResetFiltersButton } from "@/components/common_components/Filters/ResetFiltersButton"; +import { cn } from "@/lib/cva.config"; + +interface DataTableToolbarProps { + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + filtersActive?: boolean; + hasActiveFilters?: boolean; + onToggleFilters?: () => void; + onResetFilters?: () => void; + children?: React.ReactNode; + className?: string; +} + +export function DataTableToolbar({ + searchValue, + onSearchChange, + searchPlaceholder = "Search", + filtersActive = false, + hasActiveFilters = false, + onToggleFilters, + onResetFilters, + children, + className, +}: DataTableToolbarProps) { + const showReset = onResetFilters !== undefined && hasActiveFilters; + + return ( +
+
+ {onSearchChange !== undefined && ( + + )} + {onToggleFilters !== undefined && ( + + )} + {showReset && } +
+ {children !== undefined &&
{children}
} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..ab56aafe7b5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Menu } from "@base-ui/react/menu"; +import type { Table } from "@tanstack/react-table"; +import { Check, SlidersHorizontal } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface DataTableViewOptionsProps { + table: Table; + label?: string; + className?: string; +} + +export function DataTableViewOptions({ table, label = "View", className }: DataTableViewOptionsProps) { + const hideableColumns = table.getAllLeafColumns().filter((column) => column.getCanHide()); + + if (hideableColumns.length === 0) { + return null; + } + + return ( + + + + {label} + + } + /> + + + + {hideableColumns.map((column) => ( + column.toggleVisibility(checked)} + closeOnClick={false} + data-testid={`view-option-${column.id}`} + className="relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-7 capitalize outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground" + > + + + + {column.columnDef.meta?.title ?? column.id} + + ))} + + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts new file mode 100644 index 00000000000..46e72226038 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/columnMeta.ts @@ -0,0 +1,13 @@ +import type { RowData } from "@tanstack/react-table"; + +import type { ColumnPinnedSide } from "./types"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + numeric?: boolean; + className?: string; + headerClassName?: string; + title?: string; + pinned?: ColumnPinnedSide; + } +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts new file mode 100644 index 00000000000..49a4430bbee --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -0,0 +1,16 @@ +import "./columnMeta"; + +export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable"; +export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; +export { DataTableToolbar } from "./DataTableToolbar"; +export { DataTableViewOptions } from "./DataTableViewOptions"; +export { DataTableSortHeader, type DataTableSortVariant } from "./DataTableSortHeader"; +export type { DataTablePaginationProps } from "./DataTablePagination"; +export type { + ColumnPinnedSide, + ColumnResizeMode, + DataTableProps, + DataTableSize, + PaginationMode, + SortingMode, +} from "./types"; diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts new file mode 100644 index 00000000000..8fa6f21c4d3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -0,0 +1,60 @@ +import type { + ColumnDef, + ExpandedState, + OnChangeFn, + PaginationState, + Row, + RowData, + SortingState, + Table, + VisibilityState, +} from "@tanstack/react-table"; +import type * as React from "react"; + +export type SortingMode = "none" | "client" | "server"; +export type PaginationMode = "none" | "client" | "server"; +export type ColumnResizeMode = "onEnd" | "onChange"; +export type DataTableSize = "compact" | "default"; +export type ColumnPinnedSide = "left" | "right"; + +export interface DataTableProps { + data: TData[]; + columns: ColumnDef[]; + getRowId?: (row: TData, index: number, parent?: Row) => string; + + isLoading?: boolean; + loadingMessage?: string; + noDataMessage?: React.ReactNode; + + sortingMode?: SortingMode; + sorting?: SortingState; + onSortingChange?: OnChangeFn; + defaultSorting?: SortingState; + enableSortingRemoval?: boolean; + + paginationMode?: PaginationMode; + pagination?: PaginationState; + onPaginationChange?: OnChangeFn; + rowCount?: number; + pageSizeOptions?: number[]; + + enableColumnResizing?: boolean; + columnResizeMode?: ColumnResizeMode; + defaultColumnVisibility?: VisibilityState; + + getRowCanExpand?: (row: Row) => boolean; + renderSubComponent?: (props: { row: Row }) => React.ReactElement; + expanded?: ExpandedState; + onExpandedChange?: OnChangeFn; + + onRowClick?: (row: TData) => void; + + rowClassName?: (row: Row) => string; + + maxBodyHeight?: number | string; + size?: DataTableSize; + + toolbar?: (table: Table) => React.ReactNode; + paginationSlot?: (table: Table) => React.ReactNode; + footer?: (table: Table) => React.ReactNode; +} From b0ff698addc9246a28f0f247669d487f43bb608f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 15:21:52 -0700 Subject: [PATCH 02/35] refactor(ui): migrate Workflow Runs table onto shared DataTable Proof-of-concept consumer for the shared DataTable added in the previous commit. Swaps the antd Table in the Workflow Runs page for DataTable in client-pagination mode, keeping the existing cell renderers, row-click drawer, and empty state. Adds a focused test that the rows render through DataTable, a row click routes the detail fetch to the correct run, and the empty state shows. --- .../workflows/WorkflowRuns.test.tsx | 81 +++++++++++ .../(dashboard)/workflows/WorkflowRuns.tsx | 136 +++++++++--------- 2 files changed, 149 insertions(+), 68 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx new file mode 100644 index 00000000000..e73abfe7cd6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import WorkflowRuns from "./WorkflowRuns"; + +vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); + +interface FakeRun { + run_id: string; + status: string; + workflow_type: string; + created_at: string; + metadata: { title?: string; state?: string } | null; +} + +const RUNS: FakeRun[] = [ + { + run_id: "run-aaaaaaaa-1111", + status: "completed", + workflow_type: "grill", + created_at: "2026-01-01T00:00:00Z", + metadata: { title: "First run", state: "done" }, + }, + { + run_id: "run-bbbbbbbb-2222", + status: "running", + workflow_type: "autofix", + created_at: "2026-01-02T00:00:00Z", + metadata: null, + }, +]; + +function mockFetch(runs: FakeRun[]) { + return vi.fn((url: string) => { + if (url.includes("/runs?limit")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) }); + } + if (url.includes("/events")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ events: [] }) }); + } + if (url.includes("/messages")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages: [] }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("WorkflowRuns (migrated onto shared DataTable)", () => { + it("renders one DataTable row per fetched run", async () => { + vi.stubGlobal("fetch", mockFetch(RUNS)); + const { container } = render(); + + expect(await screen.findByText("First run")).toBeInTheDocument(); + expect(container.querySelectorAll("tr[data-row-id]")).toHaveLength(2); + }); + + it("opens the detail drawer for the clicked run by firing its detail fetch", async () => { + const user = userEvent.setup(); + const fetchSpy = mockFetch(RUNS); + vi.stubGlobal("fetch", fetchSpy); + render(); + + await user.click(await screen.findByText("First run")); + + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("run-aaaaaaaa-1111/events"), expect.anything()), + ); + }); + + it("shows the empty state when there are no runs", async () => { + vi.stubGlobal("fetch", mockFetch([])); + render(); + + expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index 2aecbece2e3..b668cdee0bd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Button, Collapse, Drawer, Empty, Spin, Table, Tooltip, Typography } from "antd"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button, Collapse, Drawer, Empty, Spin, Tooltip, Typography } from "antd"; import { ReloadOutlined } from "@ant-design/icons"; +import type { ColumnDef } from "@tanstack/react-table"; import { proxyBaseUrl } from "@/components/networking"; +import { DataTable } from "@/components/shared/DataTable"; const { Text } = Typography; @@ -541,48 +543,54 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { fetchRuns(); }, [fetchRuns]); - const columns = [ - { - title: "Run", - dataIndex: "run_id", - key: "run", - render: (_: string, run: WorkflowRun) => ( -
- -
-
{runTitle(run)}
-
{shortId(run.run_id)}
-
-
- ), - }, - { - title: "Type", - dataIndex: "workflow_type", - key: "workflow_type", - render: (v: string) => {v}, - }, - { - title: "Status", - dataIndex: "status", - key: "status", - render: (status: RunStatus, run: WorkflowRun) => { - const state = run.metadata?.state; - return ( -
- - {state ?? status} -
- ); + const columns = useMemo[]>( + () => [ + { + id: "run", + header: "Run", + cell: ({ row }) => { + const run = row.original; + return ( +
+ +
+
{runTitle(run)}
+
{shortId(run.run_id)}
+
+
+ ); + }, }, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - render: (v: string) => {timeAgo(v)}, - }, - ]; + { + accessorKey: "workflow_type", + header: "Type", + cell: ({ row }) => ( + {row.original.workflow_type} + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const run = row.original; + return ( +
+ + + {run.metadata?.state ?? run.status} + +
+ ); + }, + }, + { + accessorKey: "created_at", + header: "Created", + cell: ({ row }) => {timeAgo(row.original.created_at)}, + }, + ], + [], + ); return (
= ({ accessToken }) => {
- {/* runs table — matches logs page density */} -
- ({ - onClick: () => fetchRunDetail(run), - style: { cursor: "pointer" }, - })} - locale={{ - emptyText: ( - No workflow runs yet} - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - ), - }} - className="[&_.ant-table-cell]:py-0.5 [&_.ant-table-thead_.ant-table-cell]:py-1" - style={{ border: "none" }} - /> - + run.run_id} + isLoading={loadingRuns} + loadingMessage="Loading workflow runs…" + noDataMessage={ + No workflow runs yet} + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> + } + paginationMode="client" + pageSizeOptions={[50, 100]} + onRowClick={fetchRunDetail} + size="compact" + /> {/* detail drawer */} Date: Thu, 9 Jul 2026 16:08:18 -0700 Subject: [PATCH 03/35] refactor(ui): migrate Team Info virtual keys table onto shared DataTable Second proof-of-concept consumer for the shared DataTable. Replaces the hand-rolled tremor table in the Team Info Virtual Keys tab with DataTable in server-sort and server-pagination mode plus column resizing; the file drops about 150 lines. Sortable headers now use DataTableSortHeader, pagination is a detached DataTablePagination driven by the page state, the id-cell still opens the key drawer, and the body scrolls under a sticky header via maxBodyHeight. Two behavior changes: the pagination control is the standardized bar (row range plus page-size select) rather than the old Previous/Next buttons, and a sort header cycles ascending/descending without a third unsorted state, which also removes a latent case where clearing the sort left the server sorted. Updates the TeamVirtualKeysTable and TeamInfo tests to the new pagination, adds a test that a sort-header click routes to useKeys as a server sort, and lowers the no-large-inline-object-arg metric by one and the file's no-nested-ternary suppression from two to one to match the leaner code. --- ui/litellm-dashboard/eslint-metrics.json | 2 +- ui/litellm-dashboard/eslint-suppressions.json | 2 +- .../src/components/team/TeamInfo.test.tsx | 8 +- .../team/TeamVirtualKeysTable.test.tsx | 32 ++- .../components/team/TeamVirtualKeysTable.tsx | 235 +++--------------- 5 files changed, 74 insertions(+), 205 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index fcf60934f64..ad60a0d0c89 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, - "local/no-large-inline-object-arg": 519, + "local/no-large-inline-object-arg": 518, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index fe8f182c106..6d310a97dbf 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2323,7 +2323,7 @@ }, "src/components/team/TeamVirtualKeysTable.tsx": { "no-nested-ternary": { - "count": 2 + "count": 1 }, "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 46eac1c5772..4ccfb891417 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -536,7 +536,7 @@ describe("TeamInfoView", () => { await user.click(virtualKeysTab); await waitFor(() => { - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-5 of 5"); }); }); @@ -584,9 +584,9 @@ describe("TeamInfoView", () => { expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); }); expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); - expect(screen.getByText("Page 1 of 1")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Previous" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1"); + expect(screen.getByTestId("pagination-prev")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-next")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 954f5ea98c5..9eb0282580b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -163,7 +163,7 @@ describe("TeamVirtualKeysTable", () => { expect(screen.getByText("bob_key_team1")).toBeInTheDocument(); }); - it("should show Page X of Y when multiple pages exist", async () => { + it("should show the current range from total_count when multiple pages exist", async () => { mockUseKeys.mockReturnValue({ data: { keys: [createMockKey()], @@ -179,7 +179,7 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100"); }); }); @@ -203,17 +203,39 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 100"); }); - const nextButton = screen.getByRole("button", { name: "Next" }); - await user.click(nextButton); + await user.click(screen.getByTestId("pagination-next")); await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.objectContaining({ teamID: "team-1" })); }); }); + it("routes a sort-header click to useKeys as a server-side sort", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue({ + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 } as KeysResponse, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId("sort-header-created_at")).toBeInTheDocument()); + await user.click(screen.getByTestId("sort-header-created_at")); + + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "created_at", sortOrder: "asc" }), + ), + ); + }); + it("should show Loading keys when isPending", async () => { mockUseKeys.mockReturnValue({ data: undefined, diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index b7128e642a5..909608d630f 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -1,20 +1,12 @@ -// TO-DO: Standardize tables eventually - "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - PaginationState, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; +import { DataTable, DataTablePagination, DataTableSortHeader } from "@/components/shared/DataTable"; +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table"; +import { Badge, Icon, Text } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; -import { Popover, Skeleton, Tooltip, Typography } from "antd"; +import { Popover, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; @@ -83,7 +75,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi })); }, [keys?.keys, organization?.organization_id]); - const pageCount = keys?.total_pages ?? 0; + const rowCount = keys?.total_count ?? 0; const [expandedAccordions, setExpandedAccordions] = useState>({}); const currentTeam: Team = useMemo( @@ -200,7 +192,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "token", accessorKey: "token", - header: "Key ID", + header: ({ column }) => , size: 100, enableSorting: true, cell: (info) => ( @@ -210,7 +202,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "key_alias", accessorKey: "key_alias", - header: "Key Alias", + header: ({ column }) => , size: 150, enableSorting: true, cell: (info) => { @@ -282,7 +274,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "created_at", accessorKey: "created_at", - header: "Created At", + header: ({ column }) => , size: 120, enableSorting: true, cell: (info) => , @@ -349,7 +341,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "updated_at", accessorKey: "updated_at", - header: "Updated At", + header: ({ column }) => , size: 120, enableSorting: true, cell: (info) => , @@ -383,7 +375,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "spend", accessorKey: "spend", - header: "Spend (USD)", + header: ({ column }) => , size: 100, enableSorting: true, cell: (info) => , @@ -391,7 +383,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "max_budget", accessorKey: "max_budget", - header: "Budget (USD)", + header: ({ column }) => , size: 110, enableSorting: true, cell: (info) => ( @@ -529,22 +521,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [sorting, handleFilterChange], ); - const table = useReactTable({ - data: displayKeys, - columns, - columnResizeMode: "onChange", - columnResizeDirection: "ltr", - state: { sorting, pagination: tablePagination }, - onSortingChange: handleSortingChange, - onPaginationChange: setTablePagination, - getCoreRowModel: getCoreRowModel(), - // getSortedRowModel not needed — manualSorting: true delegates sorting to the server - enableSorting: true, - manualSorting: true, // Server sorts via useKeys. Avoid redundant client-side sort - manualPagination: true, - pageCount: pageCount, - }); - return (
{selectedKey ? ( @@ -566,165 +542,36 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi />
-
-
- {isLoading || isFetching ? ( - - ) : ( - - Page {pageIndex + 1} of {table.getPageCount()} - - )} - - {isLoading || isFetching ? ( - - ) : ( - - )} - - {isLoading || isFetching ? ( - - ) : ( - - )} -
-
-
-
-
-
- - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer) (resizer as HTMLElement).style.opacity = "0.5"; - }} - onMouseLeave={() => { - const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`); - if (resizer && !header.column.getIsResizing()) - (resizer as HTMLElement).style.opacity = "0"; - }} - onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined} - > -
-
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={`resizer ${table.options.columnResizeDirection} ${ - header.column.getIsResizing() ? "isResizing" : "" - }`} - style={{ - position: "absolute", - right: 0, - top: 0, - height: "100%", - width: "5px", - background: header.column.getIsResizing() ? "#3b82f6" : "transparent", - cursor: "col-resize", - userSelect: "none", - touchAction: "none", - opacity: header.column.getIsResizing() ? 1 : 0, - }} - /> -
- - ))} - - ))} - - - {isLoading || isFetching ? ( - - -
-

Loading keys...

-
-
-
- ) : displayKeys.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - 3 - ? "px-0" - : "" - }`} - > - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No keys found

-
-
-
- )} -
-
-
-
+
+ setTablePagination((prev) => ({ ...prev, pageIndex: nextPage }))} + onPageSizeChange={(nextSize) => setTablePagination({ pageIndex: 0, pageSize: nextSize })} + isLoading={isLoading || isFetching} + />
+ + null} + enableColumnResizing + columnResizeMode="onChange" + isLoading={isLoading || isFetching} + loadingMessage="Loading keys..." + noDataMessage="No keys found" + maxBodyHeight="75vh" + size="compact" + /> )} From 1b96bfacec0542ce123922f2b3450958a1ba18c8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 16:58:43 -0700 Subject: [PATCH 04/35] refactor(ui): widen Key ID and Created By columns, drop Last Active info icon Follow-up polish on the migrated Team Info virtual keys table: widen the Key ID column by 20px (100 -> 120), nearly double Created By (70 -> 130) so the name and popover fit, and remove the Last Active header info icon (and its now unused InfoCircleOutlined import). --- .../components/team/TeamVirtualKeysTable.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 909608d630f..778b82a8fc5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -5,7 +5,6 @@ import { DataTable, DataTablePagination, DataTableSortHeader } from "@/component import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table"; import { Badge, Icon, Text } from "@tremor/react"; -import { InfoCircleOutlined } from "@ant-design/icons"; import { Popover, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -193,7 +192,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi id: "token", accessorKey: "token", header: ({ column }) => , - size: 100, + size: 120, enableSorting: true, cell: (info) => ( setSelectedKey(info.row.original)} /> @@ -283,7 +282,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi id: "created_by", accessorKey: "created_by", header: "Created By", - size: 70, + size: 130, enableSorting: false, cell: (info) => { const userId = info.getValue() as string | null; @@ -349,17 +348,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "last_active", accessorKey: "last_active", - header: () => ( - - Last Active - - - - - ), + header: "Last Active", size: 130, enableSorting: false, cell: (info) => , From 1612df18a6fdbc3a3f6cb2eabbdf105cd2c4b14e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 17:19:21 -0700 Subject: [PATCH 05/35] refactor(ui): reuse exported DEFAULT_PAGE_SIZE_OPTIONS in DataTable Drop the duplicate local DEFAULT_PAGE_SIZE_OPTIONS in DataTable.tsx and import the one already exported from DataTablePagination.tsx, removing the divergence risk if the canonical list changes. --- .../src/components/shared/DataTable/DataTable.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 7655454f381..2e95ee170fa 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -36,11 +36,9 @@ import { import { cn } from "@/lib/cva.config"; import "./columnMeta"; -import { DataTablePagination } from "./DataTablePagination"; +import { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types"; -const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100]; - const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]"; const noop = () => {}; From 3e1383f529b796fc557e7886f76c66c63f2427c7 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 17:45:05 -0700 Subject: [PATCH 06/35] fix(ui): reset page and sort correctly in Team virtual keys table Addresses three issues in the migrated Team Info virtual keys table, all pre-existing behavior carried over from the tremor version: - Changing the sort now resets to page 1. Previously handleSortingChange routed through handleFilterChange with skipDebounce=true, which skipped the pageIndex reset, so sorting while on a later page asked the server for that page of the newly sorted results (an arbitrary slice). - Reset Filters now restores the default sort. It previously reset the filter fields and page but never touched the sorting state that actually drives the query, so the sort indicator and server order persisted. - Removes the dead Sort By / Sort Order keys from the filters object; sort is derived solely from the sorting state, so those keys were written but never read. Sort now lives in one place. Adds regression tests for the page-reset-on-sort and sort-reset-on-filter-reset behaviors (both fail if either fix is reverted). --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../team/TeamVirtualKeysTable.test.tsx | 53 +++++++++++++++++++ .../components/team/TeamVirtualKeysTable.tsx | 38 ++++--------- 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 036af5bc789..0900584f5ce 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1980, "complexity": 128, - "local/no-large-inline-object-arg": 511, + "local/no-large-inline-object-arg": 509, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx index 9eb0282580b..fd81aaaad99 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -236,6 +236,59 @@ describe("TeamVirtualKeysTable", () => { ); }); + it("resets to the first page when the sort changes", async () => { + const user = userEvent.setup(); + mockUseKeys.mockImplementation( + (page: number) => + ({ + data: { + keys: [createMockKey({ token: `sk-p${page}`, key_alias: `page${page}_key` })], + total_count: 100, + current_page: page, + total_pages: 2, + }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + }) as unknown as ReturnType, + ); + + renderWithProviders(); + + await user.click(await screen.findByTestId("pagination-next")); + await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(2, 50, expect.anything())); + + await user.click(screen.getByTestId("sort-header-created_at")); + await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything())); + }); + + it("resets the sort order to the default when filters are reset", async () => { + const user = userEvent.setup(); + const result = { + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType; + mockUseKeys.mockReturnValue(result); + + renderWithProviders(); + + await user.click(await screen.findByTestId("sort-header-created_at")); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortOrder: "asc" })), + ); + + await user.click(screen.getByRole("button", { name: "Reset Filters" })); + await waitFor(() => + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }), + ), + ); + }); + it("should show Loading keys when isPending", async () => { mockUseKeys.mockReturnValue({ data: undefined, diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 778b82a8fc5..73f524e13f8 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -27,10 +27,12 @@ interface TeamVirtualKeysTableProps { * TeamVirtualKeysTable – variant of VirtualKeysTable scoped to a single team. * Displays all virtual keys belonging to the team with same format and styling. */ +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVirtualKeysTableProps) { const { accessToken } = useAuthorized(); const [selectedKey, setSelectedKey] = useState(null); - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); + const [sorting, setSorting] = useState(DEFAULT_SORTING); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50, @@ -39,8 +41,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Organization ID": "", "Key Alias": "", "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", }); const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; @@ -116,18 +116,14 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi return () => window.removeEventListener("storage", handleStorageChange); }, [handleStorageChange]); - const handleFilterChange = useCallback((newFilters: Record, skipDebounce = false) => { + const handleFilterChange = useCallback((newFilters: Record) => { setFilters((prev) => ({ ...prev, "Organization ID": newFilters["Organization ID"] ?? prev["Organization ID"], "Key Alias": newFilters["Key Alias"] ?? prev["Key Alias"], "User ID": newFilters["User ID"] ?? prev["User ID"], - "Sort By": newFilters["Sort By"] ?? prev["Sort By"] ?? "created_at", - "Sort Order": newFilters["Sort Order"] ?? prev["Sort Order"] ?? "desc", })); - if (!skipDebounce) { - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - } + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); const handleFilterReset = useCallback(() => { @@ -135,9 +131,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi "Organization ID": "", "Key Alias": "", "User ID": "", - "Sort By": "created_at", - "Sort Order": "desc", }); + setSorting(DEFAULT_SORTING); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); @@ -492,23 +487,10 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [expandedAccordions], ); - const handleSortingChange = useCallback( - (updaterOrValue: React.SetStateAction) => { - const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - setSorting(newSorting); - if (newSorting?.length > 0) { - const sortState = newSorting[0]; - handleFilterChange( - { - "Sort By": sortState.id, - "Sort Order": sortState.desc ? "desc" : "asc", - }, - true, - ); - } - }, - [sorting, handleFilterChange], - ); + const handleSortingChange = useCallback((updaterOrValue: React.SetStateAction) => { + setSorting(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); return (
From 1e8c2f724090079d5fc3ad12d6a2563710c985ea Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 20:23:49 -0700 Subject: [PATCH 07/35] fix(mcp): fail closed and surface semantic filter context window errors Resolves LIT-4284 When the embedding model exceeded its context window, the MCP semantic tool filter silently passed all tools through and reported N->N success in the filter header; when the overflow happened while embedding tool descriptions at router build time, the hook was never registered at all and filtering was silently disabled Semantic filtering now fails closed on context window overflows: the request is rejected with HTTP 400 and a message that names the embedding model and advises switching to one with a larger context window or disabling the filter. Build time overflows are recorded on the filter so the hook still registers and blocks MCP tool requests with the same actionable error while leaving native-only requests untouched. The dashboard test panel renders the backend message in an error banner instead of a success state. OpenAI's embedding overflow message (maximum input length is N tokens) now maps to ContextWindowExceededError --- .../exception_mapping_utils.py | 3 + .../mcp_server/semantic_tool_filter.py | 47 ++++ .../proxy/hooks/mcp_semantic_filter/hook.py | 9 + .../test_exception_mapping_utils.py | 12 + .../mcp_server/test_semantic_tool_filter.py | 266 ++++++++++++++++++ .../MCPSemanticFilterSettings.tsx | 3 + .../MCPSemanticFilterTestPanel.test.tsx | 15 + .../MCPSemanticFilterTestPanel.tsx | 12 + .../semanticFilterTestUtils.test.ts | 32 +++ .../semanticFilterTestUtils.ts | 5 + 10 files changed, 404 insertions(+) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 9dc202c4717..fdab3d5b9d4 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -95,6 +95,9 @@ class ExceptionCheckers: if "current length is" in _error_str_lowercase and "while limit is" in _error_str_lowercase: return True + if "maximum input length is" in _error_str_lowercase and "tokens" in _error_str_lowercase: + return True + return False @staticmethod diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index f24d5715e83..eb86206c566 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -7,6 +7,8 @@ Filters MCP tools semantically for /chat/completions and /responses endpoints. from typing import TYPE_CHECKING, Any, Dict, List, Optional from litellm._logging import verbose_logger +from litellm.exceptions import ContextWindowExceededError +from litellm.litellm_core_utils.exception_mapping_utils import ExceptionCheckers from litellm.proxy._experimental.mcp_server.utils import MCP_TOOL_PREFIX_SEPARATOR if TYPE_CHECKING: @@ -15,6 +17,33 @@ if TYPE_CHECKING: from litellm.router import Router +class SemanticToolFilterContextWindowError(Exception): + """Raised when the embedding model exceeds its context window, so semantic filtering cannot run.""" + + def __init__(self, embedding_model: str, stage: str, original_error: str): + self.embedding_model = embedding_model + self.stage = stage + self.original_error = original_error + super().__init__( + f"MCP semantic tool filtering could not run: embedding model '{embedding_model}' " + f"exceeded its context window while embedding {stage}. " + f"The request was blocked instead of silently passing all tools through. " + f"Switch to an embedding model with a larger context window, or disable " + f"semantic tool filtering. Original error: {original_error}" + ) + + +def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> bool: + """Detect a context-window overflow anywhere in an exception's cause chain.""" + if error is None or depth == 0: + return False + if isinstance(error, ContextWindowExceededError): + return True + if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): + return True + return _is_context_window_error(error.__cause__, depth - 1) + + class SemanticMCPToolFilter: """Filters MCP tools using semantic similarity to reduce context window size.""" @@ -42,6 +71,7 @@ class SemanticMCPToolFilter: self.embedding_model = embedding_model self.router_instance = litellm_router_instance self.tool_router: Optional["SemanticRouter"] = None + self.context_window_error: Optional[str] = None self._tool_map: Dict[str, Any] = {} # MCPTool objects or OpenAI function dicts async def build_router_from_mcp_registry(self) -> None: @@ -111,6 +141,7 @@ class SemanticMCPToolFilter: return try: + self.context_window_error = None # Convert tools to routes routes = [] self._tool_map = {} @@ -143,6 +174,9 @@ class SemanticMCPToolFilter: except Exception as e: verbose_logger.error(f"Failed to build semantic router: {e}") self.tool_router = None + if _is_context_window_error(e): + self.context_window_error = str(e) + return raise async def filter_tools( @@ -169,6 +203,13 @@ class SemanticMCPToolFilter: if not available_tools: return available_tools + if self.context_window_error is not None: + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the MCP tool descriptions during semantic router build", + original_error=self.context_window_error, + ) + if not query or not query.strip(): return available_tools @@ -189,6 +230,12 @@ class SemanticMCPToolFilter: return self._get_tools_by_names(matched_tool_names, available_tools) except Exception as e: + if _is_context_window_error(e): + raise SemanticToolFilterContextWindowError( + embedding_model=self.embedding_model, + stage="the user query", + original_error=str(e), + ) from e verbose_logger.error(f"Semantic tool filter failed: {e}", exc_info=True) return available_tools diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index 7379096bf9b..bad6ef44ccd 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -7,6 +7,8 @@ Reduces context window size and improves tool selection accuracy. from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from fastapi import HTTPException + from litellm._logging import verbose_proxy_logger from litellm.constants import ( DEFAULT_MCP_SEMANTIC_FILTER_EMBEDDING_MODEL, @@ -14,6 +16,9 @@ from litellm.constants import ( DEFAULT_MCP_SEMANTIC_FILTER_TOP_K, ) from litellm.integrations.custom_logger import CustomLogger +from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, +) if TYPE_CHECKING: from litellm.caching.caching import DualCache @@ -294,6 +299,8 @@ class SemanticToolFilterHook(CustomLogger): ) return data + except SemanticToolFilterContextWindowError as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) from e except Exception as e: verbose_proxy_logger.error(f"Failed to expand MCP references: {e}", exc_info=True) return None @@ -366,6 +373,8 @@ class SemanticToolFilterHook(CustomLogger): return data + except SemanticToolFilterContextWindowError as e: + raise HTTPException(status_code=400, detail={"error": str(e)}) from e except Exception as e: verbose_proxy_logger.warning(f"Semantic tool filter hook failed: {e}. Proceeding with all tools.") return None diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index f441270be7c..1fcee1b1c42 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -78,6 +78,18 @@ context_window_test_cases = [ "CerebrasException - Please reduce the length of the messages or completion. Current length is 50000 while limit is 40000", True, ), + ( + "Invalid 'input[0]': maximum input length is 8192 tokens.", + True, + ), + ( + "OpenAIException - Error code: 400 - {'error': {'message': \"Invalid 'input[0]': maximum input length is 8192 tokens.\", 'type': 'invalid_request_error'}}", + True, + ), + ( + "Invalid 'metadata': maximum input length is 512 characters.", + False, + ), # Negative cases (should return False) ("A generic API error occurred.", False), ("Invalid API Key provided.", False), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 82c0aa3ccda..edf6921de1e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -1356,3 +1356,269 @@ def test_truncate_csv_at_tool_name_boundary_edges(): assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=5) == "ab,cd" assert _truncate_csv_at_tool_name_boundary(tool_names_csv="ab,cd,ef", max_length=4) == "ab" assert _truncate_csv_at_tool_name_boundary(tool_names_csv="single_name_longer_than_cap", max_length=10) == "" + + +def _make_context_window_raising_router(state): + """ + Mock litellm Router whose embedding call raises ContextWindowExceededError + once state["raise_context_error"] is flipped to True. + """ + import litellm + from litellm.types.utils import Embedding, EmbeddingResponse + + def mock_embedding_sync(*args, **kwargs): + if state["raise_context_error"]: + raise litellm.ContextWindowExceededError( + message="Invalid 'input[0]': maximum input length is 8192 tokens.", + model="text-embedding-3-small", + llm_provider="openai", + ) + return EmbeddingResponse( + data=[Embedding(embedding=[0.1] * 1536, index=0, object="embedding")], + model="text-embedding-3-small", + object="list", + usage={"prompt_tokens": 10, "total_tokens": 10}, + ) + + async def mock_embedding_async(*args, **kwargs): + return mock_embedding_sync(*args, **kwargs) + + mock_router = Mock() + mock_router.embedding = mock_embedding_sync + mock_router.aembedding = mock_embedding_async + return mock_router + + +def _make_context_window_filter(state, top_k: int = 3): + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticMCPToolFilter, + ) + + return SemanticMCPToolFilter( + embedding_model="text-embedding-3-small", + litellm_router_instance=_make_context_window_raising_router(state), + top_k=top_k, + similarity_threshold=0.3, + enabled=True, + ) + + +@pytest.mark.asyncio +async def test_semantic_filter_fails_closed_on_query_time_context_window_error(): + """ + Regression test (LIT-4284): a context-window overflow while embedding the + user query must fail closed with a typed error instead of silently + returning all tools (previously reported as N->N "success"). + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, + ) + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + assert filter_instance.tool_router is not None + + state["raise_context_error"] = True + with pytest.raises(SemanticToolFilterContextWindowError) as exc_info: + await filter_instance.filter_tools(query="send an email", available_tools=tools) + + message = str(exc_info.value) + assert "context window" in message + assert "text-embedding-3-small" in message + print("âś… Query-time context window overflow fails closed") + + +@pytest.mark.asyncio +async def test_semantic_filter_records_build_time_context_window_error(): + """ + Regression test (LIT-4284): a context-window overflow while embedding the + tool descriptions at router-build time must be recorded (not raised out of + the build, which previously left the hook unregistered and filtering + silently disabled) and must fail subsequent filtering closed. + """ + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + SemanticToolFilterContextWindowError, + ) + + state = {"raise_context_error": True} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + + assert filter_instance.tool_router is None + assert filter_instance.context_window_error is not None + + with pytest.raises(SemanticToolFilterContextWindowError) as exc_info: + await filter_instance.filter_tools(query="send an email", available_tools=tools) + + message = str(exc_info.value) + assert "context window" in message + assert "tool descriptions" in message + print("âś… Build-time context window overflow is recorded and fails closed") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_fails_closed_on_context_window_error(): + """ + Regression test (LIT-4284): the pre-call hook must reject the request with + an actionable HTTP 400 when the embedding model overflows its context + window, instead of forwarding all tools and emitting an N->N success + header. + """ + from fastapi import HTTPException + + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(tools) + hook = SemanticToolFilterHook(filter_instance) + + state["raise_context_error"] = True + data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Send an email"}], + "tools": tools, + "metadata": {}, + } + + with pytest.raises(HTTPException) as exc_info: + await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="completion", + ) + + assert exc_info.value.status_code == 400 + error_message = exc_info.value.detail["error"] + assert "context window" in error_message + assert "text-embedding-3-small" in error_message + assert "larger context window" in error_message + print("âś… Hook fails closed with actionable 400 on context window overflow") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_window_error(): + """ + Regression test (LIT-4284): the litellm_proxy MCP expansion path (driven + by the dashboard test panel via /v1/responses) must also fail closed with + an actionable HTTP 400 instead of being swallowed by the expansion + catch-all. + """ + from fastapi import HTTPException + + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": False} + filter_instance = _make_context_window_filter(state) + + registry_tools = [ + MCPTool(name=f"srv-tool_{i}", description=f"Registry tool {i}", inputSchema={"type": "object"}) + for i in range(5) + ] + filter_instance._build_router(registry_tools) + + expanded_tools = [ + { + "type": "function", + "name": f"srv-tool_{i}", + "description": f"Registry tool {i}", + "parameters": {"type": "object", "properties": {}}, + } + for i in range(5) + ] + + hook = SemanticToolFilterHook(filter_instance) + hook._expand_mcp_tools = AsyncMock( # type: ignore[method-assign] + return_value=expanded_tools + ) + + state["raise_context_error"] = True + data = { + "model": "gpt-4", + "input": [{"role": "user", "content": "Send an email", "type": "message"}], + "tools": [ + { + "type": "mcp", + "server_url": "litellm_proxy", + "require_approval": "never", + } + ], + "metadata": {}, + } + + with pytest.raises(HTTPException) as exc_info: + await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="aresponses", + ) + + assert exc_info.value.status_code == 400 + error_message = exc_info.value.detail["error"] + assert "context window" in error_message + assert "larger context window" in error_message + print("âś… Expansion path fails closed with actionable 400 on context window overflow") + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools(): + """ + A recorded build-time context-window error must only block requests that + rely on MCP tool filtering; requests carrying only native tools pass + through untouched. + """ + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + state = {"raise_context_error": True} + filter_instance = _make_context_window_filter(state) + + mcp_tools = [ + MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + for i in range(3) + ] + filter_instance._build_router(mcp_tools) + assert filter_instance.context_window_error is not None + + hook = SemanticToolFilterHook(filter_instance) + + native_tools = [ + { + "type": "function", + "function": {"name": "local_fn", "description": "A local function", "parameters": {}}, + } + ] + data = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Send an email"}], + "tools": native_tools, + "metadata": {}, + } + + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="completion", + ) + + assert result is not None + assert result["tools"] == native_tools + print("âś… Native-only requests pass through despite recorded build error") diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx index 38b1420f97e..888ffa43261 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings.tsx @@ -46,6 +46,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi const [testQuery, setTestQuery] = useState(""); const [testModel, setTestModel] = useState("gpt-4o"); const [testResult, setTestResult] = useState(null); + const [testError, setTestError] = useState(null); const [isTesting, setIsTesting] = useState(false); const schema = data?.field_schema; @@ -113,6 +114,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi testQuery, setIsTesting, setTestResult, + setTestError, }); }; @@ -285,6 +287,7 @@ export default function MCPSemanticFilterSettings({ accessToken }: MCPSemanticFi onTest={handleTest} filterEnabled={!!values.enabled} testResult={testResult} + testError={testError} curlCommand={getCurlCommand(testModel, testQuery)} /> diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx index 280a28e4765..c35caffc4be 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.test.tsx @@ -27,6 +27,7 @@ const buildProps = (overrides: Partial { expect(screen.queryByText("Results")).not.toBeInTheDocument(); }); + it("should render an error banner with the backend message when testError is set", () => { + const testError = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. Switch to an embedding model with a larger context window, or disable semantic tool filtering."; + render(); + + expect(screen.getByText("Semantic filtering did not run")).toBeInTheDocument(); + expect(screen.getByText(testError)).toBeInTheDocument(); + }); + + it("should not render the error banner when testError is null", () => { + render(); + expect(screen.queryByText("Semantic filtering did not run")).not.toBeInTheDocument(); + }); + it("should show the curl command in the API Usage tab", async () => { const user = userEvent.setup(); const curlCommand = "curl --location 'http://localhost:4000/v1/responses' --header 'Authorization: Bearer sk-1234'"; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx index 74850020aa8..21aded8733c 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx @@ -13,6 +13,7 @@ interface MCPSemanticFilterTestPanelProps { onTest: () => void; filterEnabled: boolean; testResult: TestResult | null; + testError: string | null; curlCommand: string; } @@ -26,6 +27,7 @@ export default function MCPSemanticFilterTestPanel({ onTest, filterEnabled, testResult, + testError, curlCommand, }: MCPSemanticFilterTestPanelProps) { return ( @@ -82,6 +84,16 @@ export default function MCPSemanticFilterTestPanel({ /> )} + {testError && ( + + )} + {testResult && (
Results diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts index abb18edb053..5880cca3f8f 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.test.ts @@ -27,12 +27,14 @@ describe("getCurlCommand", () => { describe("runSemanticFilterTest", () => { const mockSetIsTesting = vi.fn(); const mockSetTestResult = vi.fn(); + const mockSetTestError = vi.fn(); const baseArgs = { accessToken: "test-token", testModel: "gpt-4o", testQuery: "find relevant files", setIsTesting: mockSetIsTesting, setTestResult: mockSetTestResult, + setTestError: mockSetTestError, }; beforeEach(() => { @@ -112,4 +114,34 @@ describe("runSemanticFilterTest", () => { expect(NotificationManager.error).toHaveBeenCalledWith("Failed to test semantic filter"); expect(mockSetIsTesting).toHaveBeenLastCalledWith(false); }); + + it("should surface the backend error message via setTestError when the API call fails", async () => { + const backendMessage = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query."; + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error(backendMessage)); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenLastCalledWith(backendMessage); + }); + + it("should clear the previous test error before making a new request", async () => { + vi.mocked(testMCPSemanticFilter).mockResolvedValueOnce({ + data: {}, + headers: { filter: "5->2", tools: "tool-a,tool-b" }, + }); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenCalledTimes(1); + expect(mockSetTestError).toHaveBeenCalledWith(null); + }); + + it("should fall back to a generic message when the thrown error has no message", async () => { + vi.mocked(testMCPSemanticFilter).mockRejectedValueOnce(new Error("")); + + await runSemanticFilterTest(baseArgs); + + expect(mockSetTestError).toHaveBeenLastCalledWith("Failed to test semantic filter"); + }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts index 97fc1465d56..065014e2869 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/MCPSemanticFilterSettings/semanticFilterTestUtils.ts @@ -29,12 +29,14 @@ export const runSemanticFilterTest = async ({ testQuery, setIsTesting, setTestResult, + setTestError, }: { accessToken: string; testModel: string; testQuery: string; setIsTesting: (value: boolean) => void; setTestResult: (result: TestResult | null) => void; + setTestError: (error: string | null) => void; }) => { if (!testQuery || !testModel || !accessToken) { NotificationManager.error("Please enter a query and select a model"); @@ -43,6 +45,7 @@ export const runSemanticFilterTest = async ({ setIsTesting(true); setTestResult(null); + setTestError(null); try { const { headers } = await testMCPSemanticFilter(accessToken, testModel, testQuery); @@ -57,6 +60,8 @@ export const runSemanticFilterTest = async ({ NotificationManager.success("Semantic filter test completed successfully"); } catch (error) { console.error("Test failed:", error); + const message = error instanceof Error && error.message ? error.message : "Failed to test semantic filter"; + setTestError(message); NotificationManager.error("Failed to test semantic filter"); } finally { setIsTesting(false); From 898182b0e6e34111489f414cbdffb0343de25b38 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 20:35:57 -0700 Subject: [PATCH 08/35] fix(mcp): redact provider error from client-facing semantic filter message Keep the full provider exception in server-side logs only; the client receives a fixed actionable message. Also follow implicit exception context when detecting context window overflows and pin the detection variants plus the redaction in tests --- .../mcp_server/semantic_tool_filter.py | 8 +++- .../mcp_server/test_semantic_tool_filter.py | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index eb86206c566..0a73420e9d2 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -29,7 +29,7 @@ class SemanticToolFilterContextWindowError(Exception): f"exceeded its context window while embedding {stage}. " f"The request was blocked instead of silently passing all tools through. " f"Switch to an embedding model with a larger context window, or disable " - f"semantic tool filtering. Original error: {original_error}" + f"semantic tool filtering." ) @@ -41,7 +41,7 @@ def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> return True if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): return True - return _is_context_window_error(error.__cause__, depth - 1) + return _is_context_window_error(error.__cause__ or error.__context__, depth - 1) class SemanticMCPToolFilter: @@ -231,6 +231,10 @@ class SemanticMCPToolFilter: except Exception as e: if _is_context_window_error(e): + verbose_logger.error( + f"Semantic tool filter embedding exceeded its context window: {e}", + exc_info=True, + ) raise SemanticToolFilterContextWindowError( embedding_model=self.embedding_model, stage="the user query", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index edf6921de1e..9bc0a525326 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -1510,6 +1510,7 @@ async def test_semantic_filter_hook_fails_closed_on_context_window_error(): assert "context window" in error_message assert "text-embedding-3-small" in error_message assert "larger context window" in error_message + assert "maximum input length" not in error_message print("âś… Hook fails closed with actionable 400 on context window overflow") @@ -1575,6 +1576,7 @@ async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_windo error_message = exc_info.value.detail["error"] assert "context window" in error_message assert "larger context window" in error_message + assert "maximum input length" not in error_message print("âś… Expansion path fails closed with actionable 400 on context window overflow") @@ -1622,3 +1624,43 @@ async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools(): assert result is not None assert result["tools"] == native_tools print("âś… Native-only requests pass through despite recorded build error") + + +def test_is_context_window_error_detection_variants(): + """ + _is_context_window_error must detect the overflow in every shape it + reaches filter_tools in: the raw typed exception, the encoder's + explicitly chained ValueError wrapper, an implicitly chained wrapper, + and a bare error whose message carries a known overflow phrase; a + generic error must not match. + """ + import litellm + from litellm.proxy._experimental.mcp_server.semantic_tool_filter import ( + _is_context_window_error, + ) + + cwe = litellm.ContextWindowExceededError( + message="Invalid 'input[0]': maximum input length is 8192 tokens.", + model="text-embedding-3-small", + llm_provider="openai", + ) + assert _is_context_window_error(cwe) + + try: + raise ValueError("Internal_litellm_router API call failed") from cwe + except ValueError as explicitly_chained: + assert _is_context_window_error(explicitly_chained) + + try: + try: + raise litellm.ContextWindowExceededError( + message="overflow", model="m", llm_provider="openai" + ) + except litellm.ContextWindowExceededError: + raise ValueError("wrapper without explicit chaining") + except ValueError as implicitly_chained: + assert _is_context_window_error(implicitly_chained) + + assert _is_context_window_error(ValueError("Invalid 'input[0]': maximum input length is 8192 tokens.")) + assert not _is_context_window_error(ValueError("A generic API error occurred.")) + assert not _is_context_window_error(None) From 38efe9872010ab96add30d64fcca7891a5d89384 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 9 Jul 2026 23:06:47 -0700 Subject: [PATCH 09/35] refactor(mcp): make context window detection iterative for the recursion gate The code-quality recursive_detector CI step bans recursive functions under litellm/; walk the exception cause chain with a bounded loop instead --- .../mcp_server/semantic_tool_filter.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py index 0a73420e9d2..e12c6cdbd56 100644 --- a/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py +++ b/litellm/proxy/_experimental/mcp_server/semantic_tool_filter.py @@ -33,15 +33,18 @@ class SemanticToolFilterContextWindowError(Exception): ) -def _is_context_window_error(error: Optional[BaseException], depth: int = 5) -> bool: +def _is_context_window_error(error: Optional[BaseException], max_depth: int = 5) -> bool: """Detect a context-window overflow anywhere in an exception's cause chain.""" - if error is None or depth == 0: - return False - if isinstance(error, ContextWindowExceededError): - return True - if ExceptionCheckers.is_error_str_context_window_exceeded(str(error)): - return True - return _is_context_window_error(error.__cause__ or error.__context__, depth - 1) + current = error + for _ in range(max_depth): + if current is None: + return False + if isinstance(current, ContextWindowExceededError): + return True + if ExceptionCheckers.is_error_str_context_window_exceeded(str(current)): + return True + current = current.__cause__ or current.__context__ + return False class SemanticMCPToolFilter: From d2b7996170c16767b1f08d9327fd2f96c9422d78 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 00:57:31 -0700 Subject: [PATCH 10/35] test(ui): pin deriveErrorMessage against the ProxyException wire shape Both automated reviewers assumed the semantic filter 400 reaches the browser as FastAPI's flat detail dict and would render as raw JSON in the test panel banner. The proxy converts a pre-call hook HTTPException into a ProxyException that serializes as {"error": {"message": ...}}, which deriveErrorMessage unpacks first; pin that contract with direct tests --- .../src/lib/http/client.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 6d50f99feca..e0b5a73d11a 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { createApiClient, ApiError } from "./client"; +import { createApiClient, ApiError, deriveErrorMessage } from "./client"; const okResponse = (data: unknown): Response => ({ ok: true, status: 200, text: async () => JSON.stringify(data) }) as unknown as Response; @@ -101,3 +101,22 @@ describe("createApiClient", () => { } }); }); + +describe("deriveErrorMessage", () => { + it("extracts error.message from a ProxyException body, the shape the proxy emits for a pre-call hook HTTPException", () => { + const actionable = + "MCP semantic tool filtering could not run: embedding model 'text-embedding-3-small' exceeded its context window while embedding the user query. The request was blocked instead of silently passing all tools through. Switch to an embedding model with a larger context window, or disable semantic tool filtering."; + const wireBody = { + error: { message: actionable, type: "None", param: "None", code: "400" }, + }; + expect(deriveErrorMessage(wireBody)).toBe(actionable); + }); + + it("returns error directly when it is a plain string", () => { + expect(deriveErrorMessage({ error: "flat error text" })).toBe("flat error text"); + }); + + it("falls back to a string detail field", () => { + expect(deriveErrorMessage({ detail: "detail text" })).toBe("detail text"); + }); +}); From 5a654c5c61a7c77bd176757f10461c0dbd9d841b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 09:30:33 -0700 Subject: [PATCH 11/35] refactor(ui): full-height sidebar shell with content-scoped top bar Move the admin dashboard to a standard fixed-viewport shell. The sidebar is now full-height with its own scrolling nav (fixed logo header, pinned footer) and the top bar sits only over the content, so the page can no longer scroll past the end of the sidebar The brand, version, collapse toggle, and account menu move into the sidebar; the AI Gateway/Chat switch, docs/blog/community links, notifications, and worker switcher stay in the top bar. The sidebar is rebuilt on a new shadcn ui/sidebar primitive that uses the existing design-system tokens instead of the antd Menu This is a pure move-around of the sidebar, header, and content with no behavioral change intended. Chat keeps its own shell and navbar and is deliberately out of scope --- .../components/SidebarProvider.tsx | 9 +- .../src/app/(dashboard)/hooks/useLogout.ts | 19 + .../src/app/(dashboard)/layout.tsx | 44 +- .../src/components/DashboardHeader.tsx | 74 ++ .../Navbar/UserDropdown/UserDropdown.tsx | 74 +- .../src/components/SidebarUsageCard.tsx | 145 ++++ .../src/components/leftnav.test.tsx | 81 +- .../src/components/leftnav.tsx | 731 +++++++++--------- .../src/components/ui/sidebar.tsx | 203 +++++ 9 files changed, 926 insertions(+), 454 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts create mode 100644 ui/litellm-dashboard/src/components/DashboardHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/SidebarUsageCard.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/sidebar.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx index b21759f136d..d14357b5026 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/SidebarProvider.tsx @@ -9,9 +9,15 @@ interface SidebarProviderProps { setPage: (page: string) => void; defaultSelectedKey: string; sidebarCollapsed: boolean; + onToggleCollapsed?: () => void; } -const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: SidebarProviderProps) => { +const SidebarProvider = ({ + setPage, + defaultSelectedKey, + sidebarCollapsed, + onToggleCollapsed, +}: SidebarProviderProps) => { const { accessToken } = useAuthorized(); const [enabledPagesInternalUsers, setEnabledPagesInternalUsers] = useState(null); const [enableProjectsUI, setEnableProjectsUI] = useState(false); @@ -72,6 +78,7 @@ const SidebarProvider = ({ setPage, defaultSelectedKey, sidebarCollapsed }: Side setPage={setPage} defaultSelectedKey={defaultSelectedKey} collapsed={sidebarCollapsed} + onToggleCollapsed={onToggleCollapsed} enabledPagesInternalUsers={enabledPagesInternalUsers} enableProjectsUI={enableProjectsUI} enableChatUI={enableChatUI} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts new file mode 100644 index 00000000000..8da057ef9be --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useLogout.ts @@ -0,0 +1,19 @@ +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; +import useProxySettings from "@/app/(dashboard)/hooks/proxySettings/useProxySettings"; + +/** + * Shared sign-out handler. Used by both the top navbar and the sidebar footer so + * the two entry points can never drift on which client state gets cleared. + */ +export function useLogout(accessToken: string | null): () => void { + const proxySettings = useProxySettings(accessToken); + + return () => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = proxySettings.PROXY_LOGOUT_URL || ""; + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index b8eb4e66ed0..d2e8ea4e540 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import React, { Suspense, useState, useRef, useEffect } from "react"; -import Navbar from "@/components/navbar"; +import { DashboardHeader } from "@/components/DashboardHeader"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { useAuth } from "@/contexts/AuthContext"; @@ -102,34 +102,36 @@ function DashboardShell({ children }: { children: React.ReactNode }) { const { mode } = usePluginMode(); const page = legacyKeyForPathname(pathname) || searchParams.get("page") || "api-keys"; + const isGateway = mode === "ai-gateway"; const navigateToPage = (newPage: string) => { const migratedRoute = MIGRATED_PAGES[newPage]; router.push(migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(newPage)); }; + // Standard app shell: the viewport is fixed height and never scrolls. The + // sidebar owns its own scroll and the content column scrolls independently, + // so the page can't be dragged past the end of the nav. return ( -
- setSidebarCollapsed((v) => !v)} - /> - - -
- {mode !== "ai-gateway" ? ( -
- -
+
+ {isGateway && ( + setSidebarCollapsed((v) => !v)} + /> + )} +
+ + + + {isGateway ? ( +
{children}
) : ( - <> -
- -
-
{children}
- +
+ +
)}
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx new file mode 100644 index 00000000000..d765cdd45cb --- /dev/null +++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import { getBreadcrumb } from "@/components/leftnav"; +import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown"; +import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; +import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell"; +import ViewSwitcher from "@/components/Navbar/ViewSwitcher"; +import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown"; +import { useWorker } from "@/hooks/useWorker"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { clearTokenCookies } from "@/utils/cookieUtils"; +import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; + +interface DashboardHeaderProps { + page: string; +} + +// Top bar for the dashboard shell. Sits only over the content column (the brand +// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout. +export function DashboardHeader({ page }: DashboardHeaderProps) { + const { section, title } = getBreadcrumb(page); + const { isControlPlane, selectedWorker } = useWorker(); + const showWorkerSwitch = isControlPlane && selectedWorker !== null; + const hideCommunityLinks = useDisableShowPrompts(); + + const handleWorkerSwitch = (workerId: string) => { + clearTokenCookies(); + clearStoredReturnUrl(); + localStorage.removeItem("litellm_selected_worker_id"); + localStorage.removeItem("litellm_worker_url"); + window.location.href = `/ui/login?worker=${encodeURIComponent(workerId)}`; + }; + + return ( +
+ + +
+ {showWorkerSwitch && ( + <> + + + + )} + + Docs + + + {!hideCommunityLinks && } + + + + +
+
+ ); +} + +export default DashboardHeader; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index bd197f3ec8b..dba4c97dbe3 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -20,6 +20,8 @@ import { } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { Button, Divider, Dropdown, Space, Switch, Tag, Tooltip, Typography } from "antd"; +import { ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/cva.config"; import React, { useEffect, useState } from "react"; const { Text } = Typography; @@ -59,9 +61,14 @@ function initialsFromIdentity(email: string | null, userId: string | null): stri interface UserDropdownProps { onLogout: () => void; + // "navbar" (default): compact top-right trigger. "sidebar": full-width footer + // trigger whose menu opens upward, for the redesigned sidebar dock. + variant?: "navbar" | "sidebar"; + // Sidebar rail mode: render the avatar only (no name/role). + collapsed?: boolean; } -const UserDropdown: React.FC = ({ onLogout }) => { +const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); @@ -219,6 +226,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { return ( (
@@ -230,24 +238,54 @@ const UserDropdown: React.FC = ({ onLogout }) => {
)} > - + + {initials} + + {!collapsed && ( + <> + + {displayName} + {userRole && {userRole}} + + + + )} + + ) : ( + + )}
); }; diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx new file mode 100644 index 00000000000..51bdf5de208 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -0,0 +1,145 @@ +import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; +import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { cn } from "@/lib/cva.config"; +import { useQuery } from "@tanstack/react-query"; +import { Award, ChevronDown, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { getRemainingUsers } from "./networking"; + +interface SidebarUsageCardProps { + accessToken: string | null; + collapsed: boolean; + onExpandRail: () => void; +} + +interface Meter { + label: string; + used: number; + total: number; +} + +const formatExpiration = (daysRemaining: number | null): string => { + if (daysRemaining === null) return "No expiration"; + if (daysRemaining < 0) return "Expired"; + if (daysRemaining === 0) return "Expires today"; + if (daysRemaining === 1) return "1 day remaining"; + if (daysRemaining < 30) return `${daysRemaining} days remaining`; + if (daysRemaining < 60) return "1 month remaining"; + return `${Math.floor(daysRemaining / 30)} months remaining`; +}; + +const meterBarClass = (pct: number): string => { + if (pct > 100) return "bg-destructive"; + if (pct >= 90) return "bg-amber-500"; + return "bg-sidebar-primary"; +}; + +const Meter = ({ label, used, total }: Meter) => { + const pct = total > 0 ? (used / total) * 100 : 0; + return ( +
+
+ {label} + + {used.toLocaleString()} + / {total.toLocaleString()} + +
+
+
+
+
+ ); +}; + +type RemainingUsage = NonNullable>>; + +const remainingUsersQuery = (accessToken: string | null) => ({ + queryKey: ["sidebarRemainingUsers", accessToken] as const, + queryFn: () => getRemainingUsers(accessToken as string), + enabled: Boolean(accessToken), + retry: false as const, + staleTime: 5 * 60 * 1000, +}); + +const buildMeters = (data: RemainingUsage | null): Meter[] => { + if (!data) return []; + return [ + ...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []), + ...(data.total_teams != null ? [{ label: "Teams", used: data.total_teams_used, total: data.total_teams }] : []), + ]; +}; + +/** + * Bottom-dock "Enterprise usage" card for the sidebar. Backed only by data + * LiteLLM actually exposes: seat (user) and team allocations from the license, + * plus the license expiry. There is no plan-level spend or request cap, so the + * design's Spend / API-request meters are intentionally omitted. + */ +export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) { + const disableUsageIndicator = useDisableUsageIndicator(); + const [open, setOpen] = useState(true); + const licenseInfo = useLicenseInfo(accessToken).data ?? null; + const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken)); + const data = usageData ?? null; + + const hasData = data !== null && (data.total_users !== null || data.total_teams !== null); + const noUsableData = !isLoading && !hasData; + if (disableUsageIndicator || !accessToken || noUsableData) { + return null; + } + + if (collapsed) { + return ( + + ); + } + + const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null; + const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan"; + const meters = buildMeters(data); + + return ( +
+ + + {open && ( +
+ {isLoading && meters.length === 0 ? ( +
+ Loading… +
+ ) : ( + meters.map((m) => ) + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index dac4a35ebb6..96a895f15d7 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; -import Sidebar from "./leftnav"; +import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; vi.mock("../utils/roles", () => { return { @@ -56,6 +56,23 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => { }; }); +// The redesigned sidebar reads the custom logo from ThemeContext; the test tree +// has no ThemeProvider, so stub the hook. +vi.mock("@/contexts/ThemeContext", () => ({ + useTheme: () => ({ logoUrl: null, faviconUrl: null, setLogoUrl: vi.fn(), setFaviconUrl: vi.fn() }), +})); + +// Version tag + logout target come from network hooks; keep them inert in unit tests. +vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({ + useHealthReadinessDetails: () => ({ data: undefined }), +})); +vi.mock("@/app/(dashboard)/hooks/useLogout", () => ({ + useLogout: () => vi.fn(), +})); + +const collectNavKeys = (): string[] => + menuGroups.flatMap((group) => group.items.flatMap((item) => [item.key, ...(item.children ?? []).map((c) => c.key)])); + describe("Sidebar (leftnav)", () => { const defaultProps = { setPage: vi.fn(), @@ -117,33 +134,11 @@ describe("Sidebar (leftnav)", () => { }); }); it("has no duplicate keys among all menu items and their children", () => { - // Helper to recursively extract all keys from Ant Design Menu items - function getAllKeysFromMenu(wrapper: HTMLElement): string[] { - const allKeys: string[] = []; - // Ant Design renders key as data-menu-id or inside attributes, but for this case, we look for text as fallback. - // For a generic check, here we fetch ids from rendered list items, and also descend into submenus - const items = wrapper.querySelectorAll("[data-menu-id]"); - items.forEach((item) => { - const dataMenuId = item.getAttribute("data-menu-id"); - if (dataMenuId) { - allKeys.push(dataMenuId); - } - }); - return allKeys; - } - - const { container } = renderWithProviders(); - const allRenderedKeys = getAllKeysFromMenu(container); - - const keySet = new Set(); - const duplicates: string[] = []; - for (const key of allRenderedKeys) { - if (keySet.has(key)) { - duplicates.push(key); - } - keySet.add(key); - } - expect(duplicates).toHaveLength(0); + // React keys must be unique across the whole nav config, otherwise the + // active-item highlight and group expansion collide. + const keys = collectNavKeys(); + const duplicates = keys.filter((key, i) => keys.indexOf(key) !== i); + expect(duplicates).toEqual([]); }); describe("Admin Viewer parity", () => { @@ -231,4 +226,34 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Organizations")).toBeInTheDocument(); }); + + it("marks the selected page's nav item active", () => { + renderWithProviders(); + const logs = screen.getByText("Logs").closest("a"); + expect(logs).toHaveAttribute("data-active", "true"); + // A different item must not be active. + expect(screen.getByText("Virtual Keys").closest("a")).not.toHaveAttribute("data-active"); + }); + + it("hides labels but keeps items when collapsed to the rail", () => { + const { container } = renderWithProviders(); + expect(container.querySelector('[data-slot="sidebar"]')).toHaveAttribute("data-collapsed", "true"); + // Items still render (icons), so navigation is reachable in rail mode. + expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + }); +}); + +describe("getBreadcrumb", () => { + it("resolves a top-level page to its section + title", () => { + expect(getBreadcrumb("api-keys")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + expect(getBreadcrumb("logs")).toEqual({ section: "Observability", title: "Logs" }); + }); + + it("resolves a nested child page to its parent section", () => { + expect(getBreadcrumb("search-tools")).toEqual({ section: "AI Gateway", title: "Search Tools" }); + }); + + it("falls back to a prettified title with no section for unknown pages", () => { + expect(getBreadcrumb("some-unknown-page")).toEqual({ section: null, title: "Some Unknown Page" }); + }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 74b45942006..90bdca06595 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,38 +1,68 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; +import { useLogout } from "@/app/(dashboard)/hooks/useLogout"; +import { getProxyBaseUrl } from "@/components/networking"; +import { useTheme } from "@/contexts/ThemeContext"; +import { Button } from "@/components/ui/button"; import { - ApiOutlined, - ApartmentOutlined, - AppstoreOutlined, - AuditOutlined, - BankOutlined, - BarChartOutlined, - BgColorsOutlined, - BlockOutlined, - BookOutlined, - CommentOutlined, - CreditCardOutlined, - DatabaseOutlined, - ExperimentOutlined, - ExportOutlined, - FileTextOutlined, - FolderOutlined, - KeyOutlined, - LineChartOutlined, - PlayCircleOutlined, - RobotOutlined, - SafetyOutlined, - SearchOutlined, - SettingOutlined, - TagsOutlined, - TeamOutlined, - ToolOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { MenuProps } from "antd"; -import { ConfigProvider, Layout, Menu } from "antd"; -import { useMemo } from "react"; + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarSeparator, + sidebarMenuButtonVariants, +} from "@/components/ui/sidebar"; +import { + Activity, + BarChart3, + Bell, + Blocks, + Bot, + BookOpen, + Building2, + Boxes, + ChevronRight, + Code2, + Database, + ExternalLink, + FileText, + FlaskConical, + Folder, + HeartPulse, + KeyRound, + LayoutGrid, + MessageSquare, + Network, + Palette, + PanelLeftClose, + PanelLeftOpen, + PlayCircle, + Route, + ScrollText, + Search, + Server, + Settings as SettingsIcon, + Shield, + ShieldCheck, + Tags, + Terminal, + User, + Users, + Wallet, + Wrench, + Workflow, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { cn } from "@/lib/cva.config"; import { all_admin_roles, internalUserRoles, @@ -43,15 +73,17 @@ import { } from "../utils/roles"; import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; -import UsageIndicator from "./UsageIndicator"; +import SidebarUsageCard from "./SidebarUsageCard"; +import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; -const { Sider } = Layout; -// Define the props type +const ICON = { strokeWidth: 1.75 } as const; + interface SidebarProps { setPage: (page: string) => void; defaultSelectedKey: string; collapsed?: boolean; + onToggleCollapsed?: () => void; enabledPagesInternalUsers?: string[] | null; enableProjectsUI?: boolean; enableChatUI?: boolean; @@ -61,7 +93,6 @@ interface SidebarProps { allowVectorStoresForTeamAdmins?: boolean; } -// Menu item configuration interface MenuItem { key: string; page: string; @@ -72,29 +103,25 @@ interface MenuItem { external_url?: string; } -// Group configuration interface MenuGroup { groupLabel: string; items: MenuItem[]; roles?: string[]; } -// Menu groups organized by category - defined outside component for export +// Menu groups organized by category - defined outside component for export. +// Shape (key/page/label/roles/children) is consumed by page_utils.ts; only the +// icons changed to lucide as part of the sidebar redesign. const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ - { - key: "api-keys", - page: "api-keys", - label: "Virtual Keys", - icon: , - }, + { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, { key: "llm-playground", page: "llm-playground", label: "Playground", - icon: , + icon: , roles: rolesWithWriteAccess, }, { @@ -105,96 +132,51 @@ const menuGroups: MenuGroup[] = [ Chat ), - icon: , + icon: , }, { key: "models", page: "models", label: "Models + Endpoints", - icon: , - // Admin Viewer can view models read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, { key: "agentic", page: "agentic", label: "Agentic", - icon: , + icon: , children: [ { key: "agents", page: "agents", label: "Agents", - icon: , - // Admin Viewer can view agents read-only (write actions are - // hidden inside the page); Playground above stays write-only. + icon: , roles: rolesAllowedToViewWriteScopedPages, }, - { - key: "workflows", - page: "workflows", - label: "Workflow Runs", - icon: , - }, - { - key: "memory", - page: "memory", - label: "Memory", - icon: , - }, + { key: "workflows", page: "workflows", label: "Workflow Runs", icon: }, + { key: "memory", page: "memory", label: "Memory", icon: }, ], }, - { - key: "mcp-servers", - page: "mcp-servers", - label: "MCP Servers", - icon: , - }, - { - key: "skills", - page: "skills", - label: "Skills", - icon: , - roles: all_admin_roles, - }, - { - key: "guardrails", - page: "guardrails", - label: "Guardrails", - icon: , - }, + { key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: }, + { key: "skills", page: "skills", label: "Skills", icon: , roles: all_admin_roles }, + { key: "guardrails", page: "guardrails", label: "Guardrails", icon: }, { key: "policies", page: "policies", - label: Policies, - icon: , + label: "Policies", + icon: , roles: all_admin_roles, }, { key: "tools", page: "tools", label: "Tools", - icon: , + icon: , children: [ - { - key: "search-tools", - page: "search-tools", - label: "Search Tools", - icon: , - }, - { - key: "vector-stores", - page: "vector-stores", - label: "Vector Stores", - icon: , - }, - { - key: "tool-policies", - page: "tool-policies", - label: "Tool Policies", - icon: , - }, + { key: "search-tools", page: "search-tools", label: "Search Tools", icon: }, + { key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: }, + { key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: }, ], }, ], @@ -205,21 +187,16 @@ const menuGroups: MenuGroup[] = [ { key: "new_usage", page: "new_usage", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], label: "Usage", }, - { - key: "logs", - page: "logs", - label: "Logs", - icon: , - }, + { key: "logs", page: "logs", label: "Logs", icon: }, { key: "guardrails-monitor", page: "guardrails-monitor", label: "Guardrails Monitor", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, ], @@ -227,12 +204,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "ACCESS CONTROL", items: [ - { - key: "teams", - page: "teams", - label: "Teams", - icon: , - }, + { key: "teams", page: "teams", label: "Teams", icon: }, { key: "projects", page: "projects", @@ -241,102 +213,62 @@ const menuGroups: MenuGroup[] = [ Projects ), - icon: , - roles: all_admin_roles, - }, - { - key: "users", - page: "users", - label: "Internal Users", - icon: , + icon: , roles: all_admin_roles, }, + { key: "users", page: "users", label: "Internal Users", icon: , roles: all_admin_roles }, { key: "organizations", page: "organizations", label: "Organizations", - icon: , + icon: , roles: all_admin_roles, }, { key: "access-groups", page: "access-groups", label: "Access Groups", - icon: , - roles: all_admin_roles, - }, - { - key: "budgets", - page: "budgets", - label: "Budgets", - icon: , + icon: , roles: all_admin_roles, }, + { key: "budgets", page: "budgets", label: "Budgets", icon: , roles: all_admin_roles }, ], }, { groupLabel: "DEVELOPER TOOLS", items: [ - { - key: "api_ref", - page: "api_ref", - label: "API Reference", - icon: , - }, - { - key: "model-hub-table", - page: "model-hub-table", - label: "AI Hub", - icon: , - }, - + { key: "api_ref", page: "api_ref", label: "API Reference", icon: }, + { key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: }, { key: "learning-resources", page: "learning-resources", label: "Learning Resources", - icon: , + icon: , external_url: "https://models.litellm.ai/cookbook", }, { key: "experimental", page: "experimental", label: "Experimental", - icon: , + icon: , children: [ - { - key: "caching", - page: "caching", - label: "Caching", - icon: , - roles: all_admin_roles, - }, - { - key: "prompts", - page: "prompts", - label: "Prompts", - icon: , - roles: all_admin_roles, - }, + { key: "caching", page: "caching", label: "Caching", icon: , roles: all_admin_roles }, + { key: "prompts", page: "prompts", label: "Prompts", icon: , roles: all_admin_roles }, { key: "transform-request", page: "transform-request", label: "API Playground", - icon: , + icon: , roles: [...all_admin_roles, ...internalUserRoles], }, { key: "tag-management", page: "tag-management", label: "Tag Management", - icon: , + icon: , roles: all_admin_roles, }, - { - key: "4", - page: "usage", - label: "Old Usage", - icon: , - }, + { key: "4", page: "usage", label: "Old Usage", icon: }, ], }, ], @@ -353,21 +285,21 @@ const menuGroups: MenuGroup[] = [ Settings ), - icon: , + icon: , roles: all_admin_roles, children: [ { key: "router-settings", page: "router-settings", label: "Router Settings", - icon: , + icon: , roles: all_admin_roles, }, { key: "logging-and-alerts", page: "logging-and-alerts", label: "Logging & Alerts", - icon: , + icon: , roles: all_admin_roles, }, { @@ -381,33 +313,78 @@ const menuGroups: MenuGroup[] = [ ), - icon: , + icon: , roles: all_admin_roles, }, { key: "cost-tracking", page: "cost-tracking", label: "Cost Tracking", - icon: , - roles: all_admin_roles, - }, - { - key: "ui-theme", - page: "ui-theme", - label: "UI Theme", - icon: , + icon: , roles: all_admin_roles, }, + { key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: , roles: all_admin_roles }, ], }, ], }, ]; -const Sidebar: React.FC = ({ +const findParentKey = (page: string): string | null => { + for (const group of menuGroups) { + for (const item of group.items) { + if (item.children?.some((c) => c.page === page || c.key === page)) return item.key; + } + } + return null; +}; + +const findMenuItemKey = (page: string): string => { + for (const group of menuGroups) { + for (const item of group.items) { + if (item.page === page) return item.key; + const child = item.children?.find((c) => c.page === page); + if (child) return child.key; + } + } + return "api-keys"; +}; + +const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : item.key); + +const SECTION_DISPLAY: Record = { + "AI GATEWAY": "AI Gateway", + OBSERVABILITY: "Observability", + "ACCESS CONTROL": "Access Control", + "DEVELOPER TOOLS": "Developer Tools", + SETTINGS: "Settings", +}; + +const prettify = (key: string): string => + key + .split(/[-_]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + +// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config. +export const getBreadcrumb = (page: string): { section: string | null; title: string } => { + for (const group of menuGroups) { + for (const item of group.items) { + const section = SECTION_DISPLAY[group.groupLabel] ?? group.groupLabel; + if (item.page === page) + return { section, title: typeof item.label === "string" ? item.label : prettify(item.key) }; + const child = item.children?.find((c) => c.page === page); + if (child) return { section, title: typeof child.label === "string" ? child.label : prettify(child.key) }; + } + } + return { section: null, title: prettify(page) }; +}; + +const Sidebar_: React.FC = ({ setPage, defaultSelectedKey, collapsed = false, + onToggleCollapsed, enabledPagesInternalUsers, enableProjectsUI, enableChatUI, @@ -419,8 +396,31 @@ const Sidebar: React.FC = ({ const { userId, accessToken, userRole } = useAuthorized(); const { data: organizations } = useOrganizations(); const { data: teams } = useTeams(); + const { logoUrl } = useTheme(); + const { data: healthData } = useHealthReadinessDetails(accessToken); + const logout = useLogout(accessToken); + + const baseUrl = getProxyBaseUrl(); + const version = healthData?.litellm_version; + const selectedKey = findMenuItemKey(defaultSelectedKey); + + const [openGroups, setOpenGroups] = useState>(() => { + const parent = findParentKey(defaultSelectedKey); + return new Set(parent ? [parent] : []); + }); + + // Keep the active page's parent group expanded as the user navigates, using the + // "adjust state during render" pattern rather than an effect (avoids a + // setState-in-effect render cascade). + const [prevSelectedKey, setPrevSelectedKey] = useState(defaultSelectedKey); + if (defaultSelectedKey !== prevSelectedKey) { + setPrevSelectedKey(defaultSelectedKey); + const parent = findParentKey(defaultSelectedKey); + if (parent && !openGroups.has(parent)) { + setOpenGroups((prev) => new Set(prev).add(parent)); + } + } - // Check if user is an org_admin const isOrgAdmin = useMemo(() => { if (!userId || !organizations) return false; return organizations.some((org: Organization) => @@ -428,83 +428,21 @@ const Sidebar: React.FC = ({ ); }, [userId, organizations]); - // Check if user is a team admin for any team const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); - // The parent (legacy root page or dashboard layout) owns navigation for both - // migrated and legacy pages; the sidebar only reports the selected page. - const navigateToPage = (page: string) => setPage(page); - - // Wrap label in so every nav item supports right-click → "Open in new tab" - // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: React.ReactNode, page: string, externalUrl?: string): React.ReactNode => { - if (externalUrl) { - return ( - e.stopPropagation()} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - } - const migratedRoute = MIGRATED_PAGES[page]; - const href = migratedRoute ? migratedHref(migratedRoute) : legacyPageHref(page); - return ( - { - if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { - e.stopPropagation(); - return; - } - e.preventDefault(); - }} - style={{ color: "inherit", textDecoration: "none" }} - > - {label} - - ); - }; - - // Filter items based on user role and enabled pages for internal users const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { const isAdmin = isAdminRole(userRole); - - // Debug logging - if (enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - } - return items - .map((item) => ({ - ...item, - children: item.children ? filterItemsByRole(item.children) : undefined, - })) + .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { - // Special handling for organizations and users menu items - allow org_admins if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; - } + if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - - // Hide Projects page if enableProjectsUI is not enabled if (item.key === "projects" && !enableProjectsUI) return false; - - // Hide Chat page if enableChatUI is not enabled if (item.key === "chat" && !enableChatUI) return false; - - // Hide agents and vector-stores pages for non-admin users when disabled, - // unless allow_*_for_team_admins is on and the user is a team admin. if ( !isAdmin && item.key === "agents" && @@ -519,160 +457,181 @@ const Sidebar: React.FC = ({ !(allowVectorStoresForTeamAdmins && isTeamAdmin) ) return false; - - // Existing role check if (item.roles && !item.roles.includes(userRole)) return false; - - // Check enabled pages for internal users (non-admins) - if (!isAdmin && enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined) { - // If item has children, check if any children are visible + if (!isAdmin && enabledPagesInternalUsers != null) { if (item.children && item.children.length > 0) { const hasVisibleChildren = item.children.some((child) => enabledPagesInternalUsers.includes(child.page)); - if (hasVisibleChildren) { - return true; - } + if (hasVisibleChildren) return true; } - - const isIncluded = enabledPagesInternalUsers.includes(item.page); - return isIncluded; + return enabledPagesInternalUsers.includes(item.page); } - return true; }); }; - // Build menu items with groups - const buildMenuItems = (): MenuProps["items"] => { - const items: MenuProps["items"] = []; + const visibleGroups = menuGroups + .filter((group) => !group.roles || group.roles.includes(userRole)) + .map((group) => ({ groupLabel: group.groupLabel, items: filterItemsByRole(group.items) })) + .filter((group) => group.items.length > 0); - menuGroups.forEach((group) => { - // Check if group has role restriction - if (group.roles && !group.roles.includes(userRole)) { - return; - } - - const filteredItems = filterItemsByRole(group.items); - if (filteredItems.length === 0) return; - - // Add group with items - items.push({ - type: "group", - label: collapsed ? null : ( - - {group.groupLabel} - - ), - children: filteredItems.map((item) => ({ - key: item.key, - icon: item.icon, - label: renderNavLink(item.label, item.page, item.external_url), - children: item.children?.map((child) => ({ - key: child.key, - icon: child.icon, - label: renderNavLink(child.label, child.page, child.external_url), - onClick: () => { - if (child.external_url) { - window.open(child.external_url, "_blank"); - } else { - navigateToPage(child.page); - } - }, - })), - onClick: !item.children - ? () => { - if (item.external_url) { - window.open(item.external_url, "_blank"); - } else { - navigateToPage(item.page); - } - } - : undefined, - })), - }); - }); - - return items; - }; - - // Find selected menu key - const findMenuItemKey = (page: string): string => { - for (const group of menuGroups) { - for (const item of group.items) { - if (item.page === page) return item.key; - if (item.children) { - const child = item.children.find((c) => c.page === page); - if (child) return child.key; - } - } + const toggleGroup = (key: string) => { + if (collapsed) { + onToggleCollapsed?.(); + setOpenGroups((prev) => new Set(prev).add(key)); + return; } - return "api-keys"; + setOpenGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); }; - const selectedMenuKey = findMenuItemKey(defaultSelectedKey); + const handleLeafClick = (e: React.MouseEvent, item: MenuItem) => { + if (item.external_url) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + setPage(item.page); + }; + + const renderLeaf = (item: MenuItem, isChild: boolean) => { + const active = selectedKey === item.key; + const size = isChild ? "sub" : "default"; + const label = {item.label}; + + if (item.external_url) { + return ( + + {item.icon} + {label} + + + ); + } + + const href = MIGRATED_PAGES[item.page] ? migratedHref(MIGRATED_PAGES[item.page]) : legacyPageHref(item.page); + return ( + handleLeafClick(e, item)} + title={collapsed ? labelText(item) : undefined} + data-active={active || undefined} + className={cn(sidebarMenuButtonVariants({ isActive: active, size }))} + > + {item.icon} + {label} + + ); + }; + + const renderItem = (item: MenuItem) => { + const isGroup = !!item.children && item.children.length > 0; + if (!isGroup) { + return {renderLeaf(item, false)}; + } + + const active = selectedKey === item.key; + const open = openGroups.has(item.key); + return ( + + toggleGroup(item.key)} + title={collapsed ? labelText(item) : undefined} + > + {item.icon} + {item.label} + + + {open && ( + + {item.children!.map((child) => ( + {renderLeaf(child, true)} + ))} + + )} + + ); + }; + + const logoSrc = logoUrl || `${baseUrl}/get_image`; return ( - - - - + +
+
+ + LiteLLM + + {version && ( + + v{version} + + )} +
+ {onToggleCollapsed && ( + + )} +
+
+ + + {visibleGroups.map((group, gi) => ( + + {gi > 0 && } + {group.groupLabel} + {group.items.map((item) => renderItem(item))} + + ))} + + + + {isAdminRole(userRole) && ( + onToggleCollapsed?.()} /> - - {isAdminRole(userRole) && !collapsed && } - - + )} + + + ); }; -export default Sidebar; +export default Sidebar_; -// Also export menuGroups for advanced use cases export { menuGroups }; diff --git a/ui/litellm-dashboard/src/components/ui/sidebar.tsx b/ui/litellm-dashboard/src/components/ui/sidebar.tsx new file mode 100644 index 00000000000..5ac82788d87 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/sidebar.tsx @@ -0,0 +1,203 @@ +"use client"; + +import * as React from "react"; +import { type VariantProps } from "cva"; + +import { cn, cva } from "@/lib/cva.config"; + +type SidebarContextValue = { collapsed: boolean }; +const SidebarContext = React.createContext({ collapsed: false }); + +export function useSidebar(): SidebarContextValue { + return React.useContext(SidebarContext); +} + +const Sidebar = React.forwardRef & { collapsed?: boolean }>( + ({ className, collapsed = false, children, ...props }, ref) => ( + + + + ), +); +Sidebar.displayName = "Sidebar"; + +const SidebarHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +); +SidebarHeader.displayName = "SidebarHeader"; + +const SidebarContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +