From fc44c89f61c01bc5d8a2e09bb93d428d3739fca4 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 11:07:10 -0700 Subject: [PATCH 1/4] feat(ui): persist workflow runs table and drawer state in the URL Search, status and type filters, page and page size now live in the query string, the run drawer is addressable with ?run=, and the Columns picker choice is saved per browser. The status filter now compares against the state the Status cell renders (metadata.state, falling back to status) and offers the rendered states as options. --- .../workflows/WorkflowRuns.test.tsx | 227 ++++++++++++- .../(dashboard)/workflows/WorkflowRuns.tsx | 313 +++++++++++------- 2 files changed, 416 insertions(+), 124 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx index 6707330a54c..d1236bc44e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -1,7 +1,9 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; import WorkflowRuns from "./WorkflowRuns"; vi.mock("@/components/networking", () => ({ @@ -35,7 +37,7 @@ const RUNS: FakeRun[] = [ ]; function mockFetch(runs: FakeRun[]) { - return vi.fn((url: string) => { + return vi.fn((url: string, _init: RequestInit) => { if (url.includes("/runs?limit")) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs }) }); } @@ -51,12 +53,14 @@ function mockFetch(runs: FakeRun[]) { afterEach(() => { vi.unstubAllGlobals(); + testQueryClient.clear(); + localStorage.clear(); }); describe("WorkflowRuns (migrated onto shared DataTable)", () => { it("renders one DataTable row per fetched run", async () => { vi.stubGlobal("fetch", mockFetch(RUNS)); - const { container } = render(); + const { container } = renderWithProviders(); expect(await screen.findByText("First run")).toBeInTheDocument(); expect(container.querySelectorAll("tr[data-row-id]")).toHaveLength(2); @@ -66,7 +70,7 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => { const user = userEvent.setup(); const fetchSpy = mockFetch(RUNS); vi.stubGlobal("fetch", fetchSpy); - render(); + renderWithProviders(); await user.click(await screen.findByText("First run")); @@ -77,7 +81,7 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => { it("shows the empty state when there are no runs", async () => { vi.stubGlobal("fetch", mockFetch([])); - render(); + renderWithProviders(); expect(await screen.findByText("No workflow runs yet")).toBeInTheDocument(); }); @@ -86,12 +90,12 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => { const user = userEvent.setup(); const fetchSpy = mockFetch(RUNS); vi.stubGlobal("fetch", fetchSpy); - render(); + renderWithProviders(); await user.click(await screen.findByText("First run")); await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(3)); - for (const [url, init] of fetchSpy.mock.calls as [string, RequestInit][]) { + for (const [url, init] of fetchSpy.mock.calls) { expect(init.headers, url).toEqual({ "x-litellm-api-key": "Bearer tok" }); } }); @@ -170,7 +174,7 @@ async function openDetailDrawer(events = DETAIL_EVENTS, messages = DETAIL_MESSAG const user = userEvent.setup(); const fetchSpy = mockDetailFetch(events, messages); vi.stubGlobal("fetch", fetchSpy); - render(); + renderWithProviders(); await user.click(await screen.findByText("First run")); const drawer = await screen.findByRole("dialog"); @@ -244,3 +248,210 @@ describe("WorkflowRuns detail drawer", () => { await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0)); }); }); + +const rowIds = (): string[] => + screen + .queryAllByRole("row") + .map((row) => row.getAttribute("data-row-id")) + .filter((id): id is string => id !== null); + +const lastUrlUpdate = (onUrlUpdate: ReturnType>) => + onUrlUpdate.mock.calls.at(-1)?.[0]; + +const renderRuns = (runs: FakeRun[], searchParams = "") => { + const fetchSpy = mockFetch(runs); + vi.stubGlobal("fetch", fetchSpy); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams, onUrlUpdate }); + return { fetchSpy, onUrlUpdate }; +}; + +const detailFetchUrls = (fetchSpy: ReturnType): string[] => + fetchSpy.mock.calls.map(([url]) => String(url)).filter((url) => !url.includes("/runs?limit")); + +const STATE_RUNS: FakeRun[] = [ + { + run_id: "run-labelled", + status: "completed", + workflow_type: "grill", + created_at: "2026-01-01T00:00:00Z", + metadata: { title: "Labelled run", state: "awaiting_review" }, + }, + { + run_id: "run-plain", + status: "completed", + workflow_type: "autofix", + created_at: "2026-01-02T00:00:00Z", + metadata: null, + }, +]; + +const MANY_RUNS: FakeRun[] = Array.from({ length: 60 }, (_, index) => ({ + run_id: `run-${String(index).padStart(2, "0")}`, + status: "running", + workflow_type: "grill", + created_at: "2026-01-01T00:00:00Z", + metadata: { title: `Run number ${index}` }, +})); + +describe("WorkflowRuns status filter", () => { + it("matches the state the status cell renders rather than the raw run status", async () => { + renderRuns(STATE_RUNS, "?filter_status=awaiting_review"); + + await waitFor(() => expect(rowIds()).toEqual(["run-labelled"])); + expect(screen.getByTestId("filter-chip-status")).toHaveTextContent("awaiting_review"); + }); + + it("leaves out a run whose rendered state differs from the selected raw status", async () => { + renderRuns(STATE_RUNS, "?filter_status=completed"); + + await waitFor(() => expect(rowIds()).toEqual(["run-plain"])); + }); + + it("offers the rendered states in the drawer and writes the chosen one to the URL", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(STATE_RUNS); + await screen.findByText("Labelled run"); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await chooseSelectOption(user, await screen.findByTestId("filter-status"), "awaiting_review"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_status")).toBe("awaiting_review")); + expect(rowIds()).toEqual(["run-labelled"]); + }); +}); + +describe("WorkflowRuns URL table state", () => { + it("applies the search from the URL", async () => { + renderRuns(RUNS, "?search=autofix"); + + await waitFor(() => expect(rowIds()).toEqual(["run-bbbbbbbb-2222"])); + expect(screen.getByTestId("datatable-search")).toHaveValue("autofix"); + }); + + it("writes the search to the URL as it is typed", async () => { + const { onUrlUpdate } = renderRuns(RUNS); + await screen.findByText("First run"); + + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "grill" } }); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("search")).toBe("grill")); + expect(rowIds()).toEqual(["run-aaaaaaaa-1111"]); + }); + + it("applies the type filter from ?filter_type", async () => { + renderRuns(RUNS, "?filter_type=auto"); + + await waitFor(() => expect(rowIds()).toEqual(["run-bbbbbbbb-2222"])); + expect(screen.getByTestId("filter-chip-workflow_type")).toHaveTextContent("auto"); + }); + + it("writes the type filter applied in the drawer to ?filter_type", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(RUNS); + await screen.findByText("First run"); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.type(await screen.findByPlaceholderText("Filter by type…"), "grill"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_type")).toBe("grill")); + expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("filter_workflow_type")).toBe(false); + expect(rowIds()).toEqual(["run-aaaaaaaa-1111"]); + }); + + it("opens the page named in the URL", async () => { + renderRuns(MANY_RUNS, "?page=2"); + + await waitFor(() => expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2")); + expect(rowIds()).toHaveLength(10); + expect(rowIds()[0]).toBe("run-50"); + }); + + it("uses the page size from the URL", async () => { + renderRuns(MANY_RUNS, "?page_size=100"); + + await waitFor(() => expect(rowIds()).toHaveLength(60)); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1"); + }); + + it("writes the page to the URL when paging forward", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(MANY_RUNS); + await screen.findByText("Run number 0"); + + await user.click(screen.getByRole("button", { name: "Go to next page" })); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("page")).toBe("2")); + expect(rowIds()[0]).toBe("run-50"); + }); +}); + +describe("WorkflowRuns ?run= drawer", () => { + it("opens the drawer for the run named in the URL and loads its detail", async () => { + const { fetchSpy } = renderRuns(RUNS, "?run=run-bbbbbbbb-2222"); + + const drawer = await screen.findByRole("dialog"); + await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + expect(within(drawer).getByText("run-bbbb")).toBeInTheDocument(); + expect(detailFetchUrls(fetchSpy)).toEqual([ + "/v1/workflows/runs/run-bbbbbbbb-2222/events", + "/v1/workflows/runs/run-bbbbbbbb-2222/messages", + ]); + }); + + it("pushes the clicked run id to the URL", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(RUNS); + + await user.click(await screen.findByText("First run")); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("run")).toBe("run-aaaaaaaa-1111")); + expect(lastUrlUpdate(onUrlUpdate)?.options.history).toBe("push"); + }); + + it("removes the run from the URL when the drawer is closed", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(RUNS, "?run=run-aaaaaaaa-1111"); + const drawer = await screen.findByRole("dialog"); + + await user.click(await within(drawer).findByRole("button", { name: /close/i })); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("run")).toBe(false)); + await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0)); + }); + + it("says the run was not found when the URL names a run that is not listed", async () => { + const { fetchSpy } = renderRuns(RUNS, "?run=run-missing"); + + const drawer = await screen.findByRole("dialog"); + expect(await within(drawer).findByText("Workflow run not found.")).toBeInTheDocument(); + expect(detailFetchUrls(fetchSpy)).toEqual([]); + }); +}); + +describe("WorkflowRuns column visibility", () => { + it("hides the columns saved as hidden for this table", async () => { + localStorage.setItem("litellm_table_columns_workflow-runs", JSON.stringify({ workflow_type: false })); + renderRuns(RUNS); + + await screen.findByText("First run"); + expect(screen.queryByRole("columnheader", { name: "Type" })).not.toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: "Status" })).toBeInTheDocument(); + }); + + it("saves a column hidden from the Columns picker", async () => { + const user = userEvent.setup(); + renderRuns(RUNS); + await screen.findByText("First run"); + + await user.click(screen.getByTestId("view-options-trigger")); + await user.click(await screen.findByTestId("view-option-workflow_type")); + + await waitFor(() => expect(screen.queryByRole("columnheader", { name: "Type" })).not.toBeInTheDocument()); + expect(JSON.parse(localStorage.getItem("litellm_table_columns_workflow-runs") ?? "{}")).toEqual({ + workflow_type: false, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index ba4c23fcc9c..7a1e60de446 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,12 +1,17 @@ import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { skipToken, useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { ArrowLeft, ChevronDown, RefreshCw } from "lucide-react"; -import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; +import { functionalUpdate, type ColumnDef } from "@tanstack/react-table"; +import { parseAsString, useQueryState } from "nuqs"; import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking"; import { DataTable, DataTableFilterDrawer, DataTableFilterField, DataTableToolbar, + usePersistedColumnVisibility, + useUrlTableState, + type UrlTableStateOptions, } from "@/components/shared/DataTable"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -59,6 +64,23 @@ interface WorkflowRunMessage { created_at: string; } +interface WorkflowRunDetail { + events: WorkflowRunEvent[]; + messages: WorkflowRunMessage[]; +} + +type RunFilterColumn = "status" | "workflow_type"; + +const TABLE_STATE_OPTIONS: UrlTableStateOptions = { + sortFields: [], + defaultSort: { id: "created_at", desc: true }, + defaultPageSize: 50, + filterColumns: ["status", "workflow_type"], + urlKeys: { filter_workflow_type: "filter_type" }, +}; + +const RUN_PARAM = parseAsString.withOptions({ history: "push" }); + // ── design tokens ───────────────────────────────────────────────────────────── const STATUS_DOT: Record = { @@ -69,7 +91,7 @@ const STATUS_DOT: Record = { failed: "bg-destructive", }; -const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; +const RUN_STATUS_OPTIONS: readonly RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; const STATUS_LABELS: Record = { pending: "Pending", running: "Running", @@ -78,6 +100,10 @@ const STATUS_LABELS: Record = { failed: "Failed", }; +const isRunStatus = (value: string): value is RunStatus => RUN_STATUS_OPTIONS.some((status) => status === value); + +const stateLabel = (state: string): string => (isRunStatus(state) ? STATUS_LABELS[state] : state); + const EVENT_COLOR: Record = { "step.started": { bar: "border-success/30 bg-success/10", text: "text-success" }, "step.failed": { bar: "border-destructive/30 bg-destructive/10", text: "text-destructive" }, @@ -119,6 +145,23 @@ function shortId(id: string): string { return id.slice(0, 8); } +function displayedState(run: WorkflowRun): string { + return run.metadata?.state ?? run.status; +} + +function bySequence(items: readonly T[] | undefined): T[] { + return [...(items ?? [])].sort((a, b) => a.sequence_number - b.sequence_number); +} + +async function fetchRunDetail(accessToken: string, runId: string, signal: AbortSignal): Promise { + const runUrl = `${proxyBaseUrl ?? ""}/v1/workflows/runs/${encodeURIComponent(runId)}`; + const init = { headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, signal }; + const [evRes, msgRes] = await Promise.all([fetch(`${runUrl}/events`, init), fetch(`${runUrl}/messages`, init)]); + const evData: { events?: WorkflowRunEvent[] } = evRes.ok ? await evRes.json() : {}; + const msgData: { messages?: WorkflowRunMessage[] } = msgRes.ok ? await msgRes.json() : {}; + return { events: bySequence(evData.events), messages: bySequence(msgData.messages) }; +} + // ── status dot ──────────────────────────────────────────────────────────────── const StatusDot: React.FC<{ status: RunStatus; className?: string }> = ({ status, className }) => ( @@ -376,19 +419,127 @@ const DetailSection: React.FC<{ ); +// ── run detail drawer body ──────────────────────────────────────────────────── + +const DrawerSpinner: React.FC = () => ( +
+ +
+); + +const DrawerCloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => ( + +); + +interface RunDetailBodyProps { + run: WorkflowRun | undefined; + runsLoading: boolean; + detail: WorkflowRunDetail | undefined; + detailLoading: boolean; + onClose: () => void; + onRefresh: () => void; +} + +const RunDetailBody: React.FC = ({ + run, + runsLoading, + detail, + detailLoading, + onClose, + onRefresh, +}) => { + if (run === undefined) { + return runsLoading ? ( + + ) : ( +
+ +

Workflow run not found.

+
+ ); + } + if (detailLoading) { + return ; + } + + const events = detail?.events ?? []; + const messages = detail?.messages ?? []; + + return ( +
+
+ + +
+ +
+ + {events.length} {events.length === 1 ? "event" : "events"} + + } + defaultOpen + > + + + + {messages.length === 0 ? ( +
No messages
+ ) : ( +
+ {messages.map((msg) => ( + + ))} +
+ )} +
+
+
+ ); +}; + // ── main component ──────────────────────────────────────────────────────────── const WorkflowRuns: React.FC = ({ accessToken }) => { const [runs, setRuns] = useState([]); - const [loadingRuns, setLoadingRuns] = useState(false); - const [selectedRun, setSelectedRun] = useState(null); - const [events, setEvents] = useState([]); - const [messages, setMessages] = useState([]); - const [loadingDetail, setLoadingDetail] = useState(false); - const [drawerOpen, setDrawerOpen] = useState(false); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); + const [loadingRuns, setLoadingRuns] = useState(Boolean(accessToken)); const [filtersOpen, setFiltersOpen] = useState(false); + const { search, setSearch, pagination, onPaginationChange, columnFilters, onColumnFiltersChange } = + useUrlTableState(TABLE_STATE_OPTIONS); + const { columnVisibility, onColumnVisibilityChange } = usePersistedColumnVisibility("workflow-runs"); + const [runId, setRunId] = useQueryState("run", RUN_PARAM); + const [shownRunId, setShownRunId] = useState(runId); + if (runId !== null && runId !== shownRunId) { + setShownRunId(runId); + } + const shownRun = runs.find((run) => run.run_id === shownRunId); + + const detailQueryOptions: UseQueryOptions = { + queryKey: ["workflow-run-detail", shownRun?.run_id], + queryFn: + accessToken && shownRun !== undefined + ? ({ signal }) => fetchRunDetail(accessToken, shownRun.run_id, signal) + : skipToken, + enabled: runId !== null, + refetchOnWindowFocus: false, + retry: false, + }; + const detailQuery = useQuery(detailQueryOptions); + + const closeRun = useCallback(() => void setRunId(null), [setRunId]); const fetchRuns = useCallback(async () => { if (!accessToken) return; @@ -407,49 +558,15 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { } }, [accessToken]); - const fetchRunDetail = useCallback( - async (run: WorkflowRun) => { - if (!accessToken) return; - setSelectedRun(run); - setDrawerOpen(true); - setLoadingDetail(true); - setEvents([]); - setMessages([]); - try { - const base = proxyBaseUrl ?? ""; - const [evRes, msgRes] = await Promise.all([ - fetch(`${base}/v1/workflows/runs/${run.run_id}/events`, { - headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, - }), - fetch(`${base}/v1/workflows/runs/${run.run_id}/messages`, { - headers: { [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}` }, - }), - ]); - const evData = evRes.ok ? await evRes.json() : { events: [] }; - const msgData = msgRes.ok ? await msgRes.json() : { messages: [] }; - setEvents( - [...(evData.events ?? [])].sort( - (a: WorkflowRunEvent, b: WorkflowRunEvent) => a.sequence_number - b.sequence_number, - ), - ); - setMessages( - [...(msgData.messages ?? [])].sort( - (a: WorkflowRunMessage, b: WorkflowRunMessage) => a.sequence_number - b.sequence_number, - ), - ); - } catch (err) { - console.error("workflow run detail fetch failed:", err); - } finally { - setLoadingDetail(false); - } - }, - [accessToken], - ); - useEffect(() => { fetchRuns(); }, [fetchRuns]); + const statusFilterItems = useMemo(() => { + const states = new Set([...RUN_STATUS_OPTIONS, ...runs.map(displayedState)]); + return Object.fromEntries([...states].map((state) => [state, stateLabel(state)])); + }, [runs]); + const columns = useMemo[]>( () => [ { @@ -481,7 +598,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { }, { id: "status", - accessorKey: "status", + accessorFn: displayedState, header: "Status", meta: { title: "Status" }, filterFn: "equalsString", @@ -490,7 +607,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { return (
- {run.metadata?.state ?? run.status} + {displayedState(run)}
); }, @@ -523,20 +640,24 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { loadingMessage="Loading workflow runs…" noDataMessage={
No workflow runs yet
} paginationMode="client" + pagination={pagination} + onPaginationChange={onPaginationChange} pageSizeOptions={[50, 100]} filterMode="client" columnFilters={columnFilters} - onColumnFiltersChange={setColumnFilters} - globalFilter={globalFilter} - onGlobalFilterChange={setGlobalFilter} - onRowClick={fetchRunDetail} + onColumnFiltersChange={onColumnFiltersChange} + globalFilter={search} + onGlobalFilterChange={(updater) => setSearch(functionalUpdate(updater, search))} + columnVisibility={columnVisibility} + onColumnVisibilityChange={onColumnVisibilityChange} + onRowClick={(run) => void setRunId(run.run_id)} size="compact" toolbar={(table) => ( <> = ({ accessToken }) => { <>