From a88a9bfcb2953ba6238e86e95198835330499e23 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 16:02:30 -0700 Subject: [PATCH 1/3] feat(ui): add filter drawer and column visibility to shared DataTable Adds server- and client-side column filtering to the shared DataTable via a filterMode prop that mirrors the existing sorting and pagination modes, a global search filter, a staged filter drawer ("Apply Filters" / "Reset") built on the shadcn Sheet primitive (added via `npx shadcn add sheet`), and a table-aware toolbar (search, refresh, active-filter chips, and a Columns menu). Also adds skeleton loading rows, an icon empty state, and renders toolbar, table, and pagination as a single card. Migrates the two pilot tables onto the design as a proof of concept: TeamVirtualKeysTable drives server-side filtering through the list API (key alias via the search box, user via the drawer), and WorkflowRuns filters client-side in memory. Both keep their existing behavioral tests green, with new coverage for the filter drawer, the toolbar, the global search, and the server-filter query mapping. --- ui/litellm-dashboard/eslint-metrics.json | 6 +- .../(dashboard)/workflows/WorkflowRuns.tsx | 105 ++++++++-- .../shared/DataTable/DataTable.test.tsx | 101 ++++++++- .../components/shared/DataTable/DataTable.tsx | 146 ++++++++++--- .../DataTable/DataTableFilterDrawer.test.tsx | 98 +++++++++ .../DataTable/DataTableFilterDrawer.tsx | 107 ++++++++++ .../shared/DataTable/DataTablePagination.tsx | 5 +- .../DataTable/DataTableToolbar.test.tsx | 107 ++++++++-- .../shared/DataTable/DataTableToolbar.tsx | 129 +++++++++--- .../shared/DataTable/DataTableViewOptions.tsx | 7 +- .../src/components/shared/DataTable/index.ts | 2 + .../src/components/shared/DataTable/types.ts | 11 + .../src/components/team/TeamInfo.test.tsx | 2 +- .../team/TeamVirtualKeysTable.test.tsx | 83 +++----- .../components/team/TeamVirtualKeysTable.tsx | 196 ++++++++---------- .../src/components/ui/sheet.tsx | 100 +++++++++ 16 files changed, 933 insertions(+), 272 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/sheet.tsx diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index dac15a21620..5fb331c0d6b 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { - "@typescript-eslint/no-explicit-any": 1977, - "complexity": 129, - "local/no-large-inline-object-arg": 509, + "@typescript-eslint/no-explicit-any": 1976, + "complexity": 128, + "local/no-large-inline-object-arg": 508, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 16 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index b668cdee0bd..119e61c0222 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,9 +1,16 @@ 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 type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; import { proxyBaseUrl } from "@/components/networking"; -import { DataTable } from "@/components/shared/DataTable"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; const { Text } = Typography; @@ -59,6 +66,9 @@ const STATUS_DOT: Record = { failed: "#ef4444", }; +const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; +const FILTER_ALL = "__all__"; + const EVENT_COLOR: Record = { "step.started": { bar: "#f0fdf4", border: "#86efac", text: "#16a34a" }, "step.failed": { bar: "#fef2f2", border: "#fca5a5", text: "#dc2626" }, @@ -482,6 +492,9 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { const [messages, setMessages] = useState([]); const [loadingDetail, setLoadingDetail] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); const fetchRuns = useCallback(async () => { if (!accessToken) return; @@ -547,7 +560,9 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { () => [ { id: "run", + accessorFn: (row) => `${runTitle(row)} ${row.run_id}`, header: "Run", + meta: { title: "Run" }, cell: ({ row }) => { const run = row.original; return ( @@ -564,13 +579,18 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { { accessorKey: "workflow_type", header: "Type", + meta: { title: "Type" }, + filterFn: "includesString", cell: ({ row }) => ( {row.original.workflow_type} ), }, { id: "status", + accessorKey: "status", header: "Status", + meta: { title: "Status" }, + filterFn: "equalsString", cell: ({ row }) => { const run = row.original; return ( @@ -586,6 +606,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { { accessorKey: "created_at", header: "Created", + meta: { title: "Created" }, cell: ({ row }) => {timeAgo(row.original.created_at)}, }, ], @@ -603,28 +624,11 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { }} > {/* page header */} -
-
-
Workflow Runs
-
- Durable state tracking for agents and automated workflows -
+
+
Workflow Runs
+
+ Durable state tracking for agents and automated workflows
-
= ({ accessToken }) => { } paginationMode="client" pageSizeOptions={[50, 100]} + filterMode="client" + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} onRowClick={fetchRunDetail} size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + /> + + {({ get, set }) => ( + <> + + + + + set("workflow_type", event.target.value)} + placeholder="Filter by type…" + /> + + + )} + + + )} /> {/* detail drawer */} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index df42c156975..c32dbe10455 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -29,6 +29,16 @@ const nameCellColumns: ColumnDef[] = [ }, ]; +const filterableColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + meta: { title: "Name" }, + filterFn: (row, columnId, value) => row.getValue(columnId) === value, + cell: ({ row }) => {row.original.name}, + }, +]; + const headerCycleColumns: ColumnDef[] = [ { accessorKey: "name", @@ -195,10 +205,93 @@ describe("DataTable pagination", () => { }); }); +describe("DataTable filtering", () => { + it("client mode filters rows by columnFilters", () => { + const { rerender } = render( + , + ); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + + rerender( + , + ); + expect(names()).toEqual(["Alice"]); + }); + + it("client global filter matches substrings across columns", () => { + const { rerender } = render( + , + ); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + + rerender( + , + ); + expect(names()).toEqual(["Alice"]); + }); + + it("server mode never filters locally even when columnFilters is set", () => { + render( + , + ); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); + + it("throws when server filtering is missing required props", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + /filterMode='server'/, + ); + spy.mockRestore(); + }); +}); + +describe("DataTable loading", () => { + it("renders skeleton rows while loading and real rows once loaded", () => { + const { rerender } = render(); + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByTestId("name-cell")).toBeNull(); + + rerender(); + expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0); + expect(names()).toEqual(["Charlie", "Alice", "Bob"]); + }); +}); + describe("DataTable column visibility", () => { it("hides a column when toggled off in the view-options menu", async () => { const user = userEvent.setup(); - render( + const { container } = render( { />, ); - expect(screen.getByText("Email")).toBeInTheDocument(); + expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull(); 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 waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).toBeNull()); await user.click(screen.getByTestId("view-option-email")); - await waitFor(() => expect(screen.getByText("Email")).toBeInTheDocument()); + await waitFor(() => expect(container.querySelector('th[data-header-id="email"]')).not.toBeNull()); }); it("omits columns that opt out of hiding from the menu", async () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 2e95ee170fa..dcc341f3c78 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -4,12 +4,14 @@ import { type Cell, type Column, type ColumnDef, + type ColumnFiltersState, type ColumnPinningState, type ColumnSizingState, type ExpandedState, flexRender, getCoreRowModel, getExpandedRowModel, + getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type Header, @@ -21,9 +23,11 @@ import { useReactTable, type VisibilityState, } from "@tanstack/react-table"; +import { SearchX } from "lucide-react"; import * as React from "react"; import { Fragment, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; import { Table as TableRoot, TableBody, @@ -37,7 +41,7 @@ import { cn } from "@/lib/cva.config"; import "./columnMeta"; import { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; -import type { ColumnPinnedSide, DataTableProps, DataTableSize, PaginationMode, SortingMode } from "./types"; +import type { ColumnPinnedSide, DataTableProps, DataTableSize, FilterMode, PaginationMode, SortingMode } from "./types"; const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role=checkbox], [data-row-click-exempt]"; @@ -60,14 +64,22 @@ export function validateDataTableConfig( props.pagination === undefined || props.onPaginationChange === undefined || props.rowCount === undefined; const serverPaginationIncomplete = props.paginationMode === "server" && serverPaginationPropsMissing; + const serverFilteringIncomplete = + props.filterMode === "server" && (props.columnFilters === undefined || props.onColumnFiltersChange === undefined); + const bothSortingSources = props.defaultSorting !== undefined && props.sorting !== undefined; + const bothFilterSources = props.defaultColumnFilters !== undefined && props.columnFilters !== undefined; return [ serverSortingIncomplete ? "sortingMode='server' requires both `sorting` and `onSortingChange`." : null, serverPaginationIncomplete ? "paginationMode='server' requires `pagination`, `onPaginationChange`, and `rowCount`." : null, + serverFilteringIncomplete ? "filterMode='server' requires both `columnFilters` and `onColumnFiltersChange`." : null, bothSortingSources ? "Provide either `defaultSorting` (uncontrolled) or `sorting` (controlled), not both." : null, + bothFilterSources + ? "Provide either `defaultColumnFilters` (uncontrolled) or `columnFilters` (controlled), not both." + : null, ].filter((message): message is string => message !== null); } @@ -93,9 +105,11 @@ function derivePinning(columns: ColumnDef[]): Colu function buildRowModels( sortingMode: SortingMode, paginationMode: PaginationMode, + filterMode: FilterMode, getRowCanExpand: ((row: Row) => boolean) | undefined, ): Partial> { return { + ...(filterMode === "client" ? { getFilteredRowModel: getFilteredRowModel() } : {}), ...(sortingMode === "client" ? { getSortedRowModel: getSortedRowModel() } : {}), ...(paginationMode === "client" ? { getPaginationRowModel: getPaginationRowModel() } : {}), ...(getRowCanExpand !== undefined ? { getRowCanExpand, getExpandedRowModel: getExpandedRowModel() } : {}), @@ -307,6 +321,49 @@ function MessageRow({ colSpan, children }: { colSpan: number; children: React.Re ); } +function DefaultEmptyState() { + return ( +
+
+ +
+
No results
+
No rows match your search or filters.
+
+ ); +} + +function SkeletonRows({ + rowCount, + columnCount, + size, + message, +}: { + rowCount: number; + columnCount: number; + size: DataTableSize; + message?: string; +}) { + const rowKeys = Array.from({ length: Math.max(rowCount, 1) }, (_, index) => index); + const columnKeys = Array.from({ length: Math.max(columnCount, 1) }, (_, index) => index); + return ( + + {rowKeys.map((rowKey) => ( + + {columnKeys.map((columnKey) => ( + + + {rowKey === 0 && columnKey === 0 && message !== undefined ? ( + {message} + ) : null} + + ))} + + ))} + + ); +} + function useControllable( controlled: T | undefined, controlledOnChange: OnChangeFn | undefined, @@ -334,6 +391,12 @@ function useDataTableInstance(props: DataTablePro onPaginationChange, rowCount, pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS, + filterMode = "none", + columnFilters, + onColumnFiltersChange, + defaultColumnFilters, + globalFilter, + onGlobalFilterChange, enableColumnResizing = false, columnResizeMode = "onEnd", defaultColumnVisibility, @@ -348,6 +411,12 @@ function useDataTableInstance(props: DataTablePro pageIndex: 0, pageSize: pageSizeOptions[0] ?? 25, }); + const filterState = useControllable( + columnFilters, + onColumnFiltersChange, + defaultColumnFilters ?? [], + ); + const globalFilterState = useControllable(globalFilter, onGlobalFilterChange, ""); const expandedState = useControllable(expanded, onExpandedChange, {}); const [columnVisibility, setColumnVisibility] = useState(defaultColumnVisibility ?? {}); const [columnSizing, setColumnSizing] = useState({}); @@ -360,6 +429,8 @@ function useDataTableInstance(props: DataTablePro state: { sorting: sortingState.value, pagination: paginationState.value, + columnFilters: filterState.value, + globalFilter: globalFilterState.value, expanded: expandedState.value, columnVisibility, columnSizing, @@ -367,16 +438,19 @@ function useDataTableInstance(props: DataTablePro initialState: { columnPinning }, manualSorting: sortingMode === "server", manualPagination: paginationMode === "server", + manualFiltering: filterMode === "server", enableSortingRemoval, enableColumnResizing, columnResizeMode, onSortingChange: sortingState.onChange, onPaginationChange: paginationState.onChange, + onColumnFiltersChange: filterState.onChange, + onGlobalFilterChange: globalFilterState.onChange, onExpandedChange: expandedState.onChange, onColumnVisibilityChange: setColumnVisibility, onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), - ...buildRowModels(sortingMode, paginationMode, expansionGuard), + ...buildRowModels(sortingMode, paginationMode, filterMode, expansionGuard), ...(getRowId !== undefined ? { getRowId } : {}), ...(paginationMode === "server" && rowCount !== undefined ? { rowCount } : {}), }; @@ -397,7 +471,8 @@ export function DataTable(props: DataTableProps(props: DataTableProps { if (isLoading) { - return {loadingMessage}; + return ( + + ); } if (rows.length === 0) { - return {noDataMessage}; + return {noDataMessage ?? }; } return rows.map((row) => ( (props: DataTableProps - {toolbar !== undefined &&
{toolbar(table)}
} -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - {renderBody()} - {footer !== undefined && {footer(table)}} - +
+ {toolbar !== undefined &&
{toolbar(table)}
} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + {renderBody()} + {footer !== undefined && {footer(table)}} + +
+ {paginationNode !== null &&
{paginationNode}
}
- {renderPagination()}
); } diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.test.tsx new file mode 100644 index 00000000000..0770c17cba6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.test.tsx @@ -0,0 +1,98 @@ +import type { ColumnDef, ColumnFiltersState } 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 } from "vitest"; + +import { DataTable } from "./DataTable"; +import { DataTableFilterDrawer } from "./DataTableFilterDrawer"; +import { DataTableToolbar } from "./DataTableToolbar"; + +interface Person { + id: string; + name: string; +} + +const DATA: Person[] = [ + { id: "a", name: "Alice" }, + { id: "b", name: "Bob" }, + { id: "c", name: "Carol" }, +]; + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + meta: { title: "Name" }, + filterFn: (row, columnId, value) => row.getValue(columnId) === value, + cell: ({ row }) => {row.original.name}, + }, +]; + +const names = (): (string | null)[] => screen.getAllByTestId("name-cell").map((el) => el.textContent); + +function Harness({ initialFilters }: { initialFilters?: ColumnFiltersState }) { + const [open, setOpen] = useState(false); + return ( + ( + <> + setOpen(true)} /> + + {({ get, set }) => ( + set("name", event.target.value)} + /> + )} + + + )} + /> + ); +} + +describe("DataTableFilterDrawer", () => { + it("stages edits and only commits them to the table on Apply", async () => { + const user = userEvent.setup(); + render(); + expect(names()).toEqual(["Alice", "Bob", "Carol"]); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.type(await screen.findByTestId("draft-name"), "Bob"); + + expect(names()).toEqual(["Alice", "Bob", "Carol"]); + expect(screen.queryByTestId("filter-chip-name")).toBeNull(); + + await user.click(screen.getByTestId("filter-drawer-apply")); + expect(names()).toEqual(["Bob"]); + expect(screen.getByTestId("filter-chip-name")).toHaveTextContent("Bob"); + }); + + it("seeds the draft from committed filters when opened", async () => { + const user = userEvent.setup(); + render(); + expect(names()).toEqual(["Bob"]); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + expect(await screen.findByTestId("draft-name")).toHaveValue("Bob"); + }); + + it("reset clears the committed filters and the draft", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByTestId("filter-drawer-reset")); + + expect(names()).toEqual(["Alice", "Bob", "Carol"]); + expect(screen.queryByTestId("filter-chip-name")).toBeNull(); + expect(screen.getByTestId("draft-name")).toHaveValue(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx new file mode 100644 index 00000000000..5b7460eb62c --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableFilterDrawer.tsx @@ -0,0 +1,107 @@ +"use client"; + +import type { ColumnFiltersState, Table } from "@tanstack/react-table"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; + +export interface FilterDraft { + get: (columnId: string) => unknown; + set: (columnId: string, value: unknown) => void; +} + +interface DataTableFilterDrawerProps { + table: Table; + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description?: React.ReactNode; + applyLabel?: string; + resetLabel?: string; + children: (draft: FilterDraft) => React.ReactNode; +} + +function isEmpty(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length === 0; + } + return value === undefined || value === null || value === ""; +} + +function toDraft(filters: ColumnFiltersState): Record { + return Object.fromEntries(filters.map((filter) => [filter.id, filter.value])); +} + +function toFilters(draft: Record): ColumnFiltersState { + return Object.entries(draft) + .filter(([, value]) => !isEmpty(value)) + .map(([id, value]) => ({ id, value })); +} + +export function DataTableFilterDrawer({ + table, + open, + onOpenChange, + title = "Filters", + description, + applyLabel = "Apply Filters", + resetLabel = "Reset", + children, +}: DataTableFilterDrawerProps) { + const [draft, setDraft] = React.useState>(() => toDraft(table.getState().columnFilters)); + const [wasOpen, setWasOpen] = React.useState(open); + + if (open !== wasOpen) { + setWasOpen(open); + if (open) { + setDraft(toDraft(table.getState().columnFilters)); + } + } + + const helpers: FilterDraft = { + get: (columnId) => draft[columnId], + set: (columnId, value) => setDraft((previous) => ({ ...previous, [columnId]: value })), + }; + + const apply = () => { + table.setColumnFilters(toFilters(draft)); + onOpenChange(false); + }; + + const reset = () => { + setDraft({}); + table.setColumnFilters([]); + }; + + return ( + + + + {title} + {description !== undefined && {description}} + +
+ {children(helpers)} +
+ + + + +
+
+ ); +} + +export function DataTableFilterField({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx index 5a30b12f27f..7802465ef74 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTablePagination.tsx @@ -37,7 +37,7 @@ export function DataTablePagination({ const lastPage = Math.max(pageCount - 1, 0); return ( -
+
Rows per page onSearchChange(event.target.value)} + placeholder={searchPlaceholder} + className="h-8 w-56 pl-8" + data-testid="datatable-search" + /> +
)} - {onToggleFilters !== undefined && ( - + {filters.map((filter) => ( + + {labelFor(filter.id)}: + {valueFor(filter.id, filter.value)} + + + ))} + {filters.length > 0 && ( + + )} +
+
+ {children} + {onRefresh !== undefined && ( + + )} + {showViewOptions && } + {onOpenFilters !== 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 index ab56aafe7b5..f462481ef9c 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTableViewOptions.tsx @@ -2,7 +2,7 @@ import { Menu } from "@base-ui/react/menu"; import type { Table } from "@tanstack/react-table"; -import { Check, SlidersHorizontal } from "lucide-react"; +import { Check, Columns3 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -24,7 +24,7 @@ export function DataTableViewOptions({ table, label = "View", className } - + {label} } @@ -44,7 +44,8 @@ export function DataTableViewOptions({ table, label = "View", className } - {column.columnDef.meta?.title ?? column.id} + {column.columnDef.meta?.title ?? + (typeof column.columnDef.header === "string" ? column.columnDef.header : column.id)} ))} diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts index 49a4430bbee..c4218f6051a 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/index.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/index.ts @@ -1,6 +1,7 @@ import "./columnMeta"; export { DataTable, DataTableConfigError, validateDataTableConfig } from "./DataTable"; +export { DataTableFilterDrawer, DataTableFilterField, type FilterDraft } from "./DataTableFilterDrawer"; export { DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "./DataTablePagination"; export { DataTableToolbar } from "./DataTableToolbar"; export { DataTableViewOptions } from "./DataTableViewOptions"; @@ -11,6 +12,7 @@ export type { ColumnResizeMode, DataTableProps, DataTableSize, + FilterMode, 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 index 8fa6f21c4d3..045cfa895e1 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/types.ts +++ b/ui/litellm-dashboard/src/components/shared/DataTable/types.ts @@ -1,5 +1,6 @@ import type { ColumnDef, + ColumnFiltersState, ExpandedState, OnChangeFn, PaginationState, @@ -13,6 +14,7 @@ import type * as React from "react"; export type SortingMode = "none" | "client" | "server"; export type PaginationMode = "none" | "client" | "server"; +export type FilterMode = "none" | "client" | "server"; export type ColumnResizeMode = "onEnd" | "onChange"; export type DataTableSize = "compact" | "default"; export type ColumnPinnedSide = "left" | "right"; @@ -24,6 +26,7 @@ export interface DataTableProps { isLoading?: boolean; loadingMessage?: string; + skeletonRowCount?: number; noDataMessage?: React.ReactNode; sortingMode?: SortingMode; @@ -38,6 +41,14 @@ export interface DataTableProps { rowCount?: number; pageSizeOptions?: number[]; + filterMode?: FilterMode; + columnFilters?: ColumnFiltersState; + onColumnFiltersChange?: OnChangeFn; + defaultColumnFilters?: ColumnFiltersState; + + globalFilter?: string; + onGlobalFilterChange?: OnChangeFn; + enableColumnResizing?: boolean; columnResizeMode?: ColumnResizeMode; defaultColumnVisibility?: VisibilityState; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 4ccfb891417..f0e27607ffb 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -583,7 +583,7 @@ describe("TeamInfoView", () => { await waitFor(() => { expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); }); - expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Columns" })).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 fd81aaaad99..ec6e97a1d23 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.test.tsx @@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { KeyResponse } from "../key_team_helpers/key_list"; import { Organization } from "../networking"; @@ -13,18 +11,6 @@ vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: vi.fn(), })); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: vi.fn(), -})); - -vi.mock("../key_team_helpers/filter_helpers", () => ({ - fetchTeamFilterOptions: vi.fn().mockResolvedValue({ - keyAliases: [], - organizationIds: [], - userIds: [], - }), -})); - vi.mock("../key_team_helpers/fetch_available_models_team_key", () => ({ getModelDisplayName: vi.fn((model: string) => model), })); @@ -39,7 +25,6 @@ vi.mock("../templates/key_info_view", () => ({ })); const mockUseKeys = useKeys as MockedFunction; -const mockUseAuthorized = useAuthorized as MockedFunction; const createMockKey = (overrides: Partial = {}): KeyResponse => ({ @@ -85,7 +70,6 @@ describe("TeamVirtualKeysTable", () => { beforeEach(() => { vi.clearAllMocks(); - mockUseAuthorized.mockReturnValue({ accessToken: "test-token" } as any); mockUseKeys.mockReturnValue({ data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, isPending: false, @@ -262,30 +246,50 @@ describe("TeamVirtualKeysTable", () => { await waitFor(() => expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.anything())); }); - it("resets the sort order to the default when filters are reset", async () => { + it("maps the User ID drawer filter to a server-side useKeys query and clears it", async () => { const user = userEvent.setup(); - const result = { + mockUseKeys.mockReturnValue({ 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); + } as unknown as ReturnType); renderWithProviders(); - await user.click(await screen.findByTestId("sort-header-created_at")); + await user.click(await screen.findByTestId("datatable-filters-trigger")); + const drawerBody = await screen.findByTestId("filter-drawer-body"); + const userInput = drawerBody.querySelector("input") as HTMLElement; + await user.type(userInput, "user-42"); + await user.click(screen.getByTestId("filter-drawer-apply")); + await waitFor(() => - expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortOrder: "asc" })), + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })), ); - await user.click(screen.getByRole("button", { name: "Reset Filters" })); + await user.click(screen.getByTestId("datatable-clear-filters")); await waitFor(() => - expect(mockUseKeys).toHaveBeenLastCalledWith( - 1, - 50, - expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }), - ), + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: undefined })), + ); + }); + + it("maps the search box to a server-side key-alias query", async () => { + const user = userEvent.setup(); + mockUseKeys.mockReturnValue({ + data: { keys: [createMockKey()], total_count: 1, current_page: 1, total_pages: 1 }, + isPending: false, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + renderWithProviders(); + + await user.type(await screen.findByTestId("datatable-search"), "check-002"); + + await waitFor( + () => + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "check-002" })), + { timeout: 2000 }, ); }); @@ -304,7 +308,7 @@ describe("TeamVirtualKeysTable", () => { }); }); - it("should show No keys found when keys array is empty", async () => { + it("should show the empty state when keys array is empty", async () => { mockUseKeys.mockReturnValue({ data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 } as KeysResponse, isPending: false, @@ -315,26 +319,7 @@ describe("TeamVirtualKeysTable", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("No keys found")).toBeInTheDocument(); - }); - }); - - it("should fetch team-scoped filter options for Key Alias, Organization ID, and User ID", async () => { - const mockFetchTeamFilterOptions = vi.mocked(fetchTeamFilterOptions); - mockFetchTeamFilterOptions.mockResolvedValue({ - keyAliases: ["alice_key_team1", "charlie_key_team1"], - organizationIds: ["org-123"], - userIds: [ - { id: "user-1", email: "alice@example.com" }, - { id: "user-2", email: "charlie@example.com" }, - ], - }); - - // Use unique teamId to avoid cache hit from previous tests (refetchOnMount: false) - renderWithProviders(); - - await waitFor(() => { - expect(mockFetchTeamFilterOptions).toHaveBeenCalledWith("test-token", "team-filter-options-test"); + expect(screen.getByText("No rows match your search or filters.")).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index 73f524e13f8..e008de12dd4 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -1,21 +1,25 @@ "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import { DataTable, DataTablePagination, DataTableSortHeader } from "@/components/shared/DataTable"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableSortHeader, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { Input } from "@/components/ui/input"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; -import { ColumnDef, PaginationState, SortingState } from "@tanstack/react-table"; +import { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { Badge, Icon, Text } from "@tremor/react"; import { Popover, Tooltip, Typography } from "antd"; +import debounce from "lodash/debounce"; 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"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; -import FilterComponent, { FilterOption } from "../molecules/filter"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; -import { useQuery } from "@tanstack/react-query"; -import { fetchTeamFilterOptions } from "../key_team_helpers/filter_helpers"; -import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; interface TeamVirtualKeysTableProps { teamId: string; @@ -30,18 +34,41 @@ interface TeamVirtualKeysTableProps { 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(DEFAULT_SORTING); const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50, }); - const [filters, setFilters] = useState>({ - "Organization ID": "", - "Key Alias": "", - "User ID": "", - }); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + + const debouncedSetSearch = useMemo( + () => + debounce((value: string) => { + setSearchQuery(value); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, 300), + [], + ); + useEffect(() => () => debouncedSetSearch.cancel(), [debouncedSetSearch]); + const handleSearchChange = useCallback( + (value: string) => { + setSearchInput(value); + debouncedSetSearch(value); + }, + [debouncedSetSearch], + ); + + const getFilterValue = useCallback( + (columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; + }, + [columnFilters], + ); const sortBy = sorting.length > 0 ? sorting[0].id : "created_at"; const sortOrder = sorting.length > 0 ? (sorting[0].desc ? "desc" : "asc") : "desc"; @@ -56,9 +83,8 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi refetch, } = useKeys(pageIndex + 1, pageSize, { teamID: teamId, - organizationID: filters["Organization ID"]?.trim() || undefined, - selectedKeyAlias: filters["Key Alias"]?.trim() || undefined, - userID: filters["User ID"]?.trim() || undefined, + selectedKeyAlias: searchQuery.trim() || undefined, + userID: getFilterValue("user_id"), sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, expand: "user", @@ -95,18 +121,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi [teamId, teamAlias, organization], ); - const teamFilterOptionsQuery = useQuery({ - queryKey: ["teamFilterOptions", teamId, accessToken], - queryFn: async () => fetchTeamFilterOptions(accessToken, teamId), - enabled: !!accessToken && !!teamId, - staleTime: 30000, // 30 seconds - align with useKeys - }); - const teamFilterOptions = teamFilterOptionsQuery.data || { - keyAliases: [], - organizationIds: [], - userIds: [], - }; - const handleStorageChange = useCallback(() => { refetch?.(); }, [refetch]); @@ -116,76 +130,17 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi return () => window.removeEventListener("storage", handleStorageChange); }, [handleStorageChange]); - 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"], - })); + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); }, []); - const handleFilterReset = useCallback(() => { - setFilters({ - "Organization ID": "", - "Key Alias": "", - "User ID": "", - }); - setSorting(DEFAULT_SORTING); - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }, []); - - const filterOptions: FilterOption[] = useMemo( - () => [ - { - name: "Organization ID", - label: "Organization ID", - isSearchable: true, - searchFn: async (searchText: string) => { - const { organizationIds } = teamFilterOptions; - if (!organizationIds.length) return []; - const lower = searchText.toLowerCase(); - const filtered = lower ? organizationIds.filter((id) => id.toLowerCase().includes(lower)) : organizationIds; - return filtered.map((id) => ({ label: id, value: id })); - }, - }, - { - name: "Key Alias", - label: "Key Alias", - isSearchable: true, - searchFn: async (searchText: string) => { - const { keyAliases } = teamFilterOptions; - const lower = searchText.toLowerCase(); - const filtered = lower ? keyAliases.filter((alias) => alias.toLowerCase().includes(lower)) : keyAliases; - return filtered.map((alias) => ({ label: alias, value: alias })); - }, - }, - { - name: "User ID", - label: "User ID", - isSearchable: true, - searchFn: async (searchText: string) => { - const { userIds } = teamFilterOptions; - const lower = searchText.toLowerCase(); - const filtered = lower - ? userIds.filter((u) => u.id.toLowerCase().includes(lower) || u.email.toLowerCase().includes(lower)) - : userIds; - return filtered.map((u) => ({ - label: u.email ? `${u.id} (${u.email})` : u.id, - value: u.id, - })); - }, - }, - ], - [teamFilterOptions], - ); - const columns: ColumnDef[] = useMemo( () => [ { id: "token", accessorKey: "token", + meta: { title: "Key ID" }, header: ({ column }) => , size: 120, enableSorting: true, @@ -196,6 +151,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "key_alias", accessorKey: "key_alias", + meta: { title: "Key Alias" }, header: ({ column }) => , size: 150, enableSorting: true, @@ -268,6 +224,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "created_at", accessorKey: "created_at", + meta: { title: "Created At" }, header: ({ column }) => , size: 120, enableSorting: true, @@ -335,6 +292,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "updated_at", accessorKey: "updated_at", + meta: { title: "Updated At" }, header: ({ column }) => , size: 120, enableSorting: true, @@ -359,6 +317,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "spend", accessorKey: "spend", + meta: { title: "Spend (USD)" }, header: ({ column }) => , size: 100, enableSorting: true, @@ -367,6 +326,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi { id: "max_budget", accessorKey: "max_budget", + meta: { title: "Budget (USD)" }, header: ({ column }) => , size: 110, enableSorting: true, @@ -503,27 +463,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi onDelete={refetch} /> ) : ( -
-
- -
- -
- setTablePagination((prev) => ({ ...prev, pageIndex: nextPage }))} - onPageSizeChange={(nextSize) => setTablePagination({ pageIndex: 0, pageSize: nextSize })} - isLoading={isLoading || isFetching} - /> -
- +
null} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={handleColumnFiltersChange} enableColumnResizing columnResizeMode="onChange" isLoading={isLoading || isFetching} loadingMessage="Loading keys..." - noDataMessage="No keys found" maxBodyHeight="75vh" size="compact" + toolbar={(table) => ( + <> + refetch?.()} + isRefreshing={isFetching} + onOpenFilters={() => setFiltersOpen(true)} + filterLabels={{ user_id: "User ID" }} + /> + + {({ get, set }) => ( + + set("user_id", event.target.value)} + placeholder="Filter by user ID…" + /> + + )} + + + )} />
)} diff --git a/ui/litellm-dashboard/src/components/ui/sheet.tsx b/ui/litellm-dashboard/src/components/ui/sheet.tsx new file mode 100644 index 00000000000..b619c927bbb --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/sheet.tsx @@ -0,0 +1,100 @@ +"use client"; + +import * as React from "react"; +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; + +import { cn } from "@/lib/cva.config"; +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return ; +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return ; +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return ; +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return ; +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + } + > + + Close + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { + return ( + + ); +} + +export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; From ae55170343daf29c700b8dd504be303d66915376 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 10 Jul 2026 18:05:49 -0700 Subject: [PATCH 2/3] fix(ui): show filter-select label and shape loading skeletons per column The workflow-runs Status filter leaked the internal "__all__" sentinel as its displayed value because Base UI's Select.Value renders the raw value when no items map or children function is given. Drop the sentinel and use Base UI's native null handling: a null "All statuses" item plus a placeholder, with an items map so a real selection renders its capitalized label rather than the raw status string The shared DataTable rendered every loading-skeleton cell as one identical half-width bar, which read as a rigid grid instead of the table beneath it. Vary the skeleton width per column and add a per-column skeleton shape hint (text or twoLine) on ColumnMeta so identity columns like the workflow "Run" cell get a two-line skeleton that matches their real content --- .../(dashboard)/workflows/WorkflowRuns.tsx | 23 ++++++++----- .../shared/DataTable/DataTable.test.tsx | 17 ++++++++++ .../components/shared/DataTable/DataTable.tsx | 32 ++++++++++++++----- .../components/shared/DataTable/columnMeta.ts | 3 +- .../src/components/shared/DataTable/types.ts | 1 + 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index 119e61c0222..9afa07251c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -67,7 +67,13 @@ const STATUS_DOT: Record = { }; const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; -const FILTER_ALL = "__all__"; +const STATUS_LABELS: Record = { + pending: "Pending", + running: "Running", + paused: "Paused", + completed: "Completed", + failed: "Failed", +}; const EVENT_COLOR: Record = { "step.started": { bar: "#f0fdf4", border: "#86efac", text: "#16a34a" }, @@ -562,7 +568,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { id: "run", accessorFn: (row) => `${runTitle(row)} ${row.run_id}`, header: "Run", - meta: { title: "Run" }, + meta: { title: "Run", skeleton: "twoLine" }, cell: ({ row }) => { const run = row.original; return ( @@ -674,17 +680,18 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { <>