diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..3573bb637e1 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1205,9 +1205,6 @@ } }, "src/app/(dashboard)/workflows/WorkflowRuns.tsx": { - "no-nested-ternary": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 }, 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..3281bea07d8 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,300 @@ 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"); + }); + + it("writes the page size chosen in the pager to the URL", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(MANY_RUNS); + await screen.findByText("Run number 0"); + + await chooseSelectOption(user, screen.getByTestId("pagination-page-size"), "100"); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("page_size")).toBe("100")); + expect(rowIds()).toHaveLength(60); + }); + + it("drops the page from the URL when the search changes", async () => { + const { onUrlUpdate } = renderRuns(MANY_RUNS, "?page=2"); + await waitFor(() => expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2")); + + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "Run number" } }); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("search")).toBe("Run number")); + expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("page")).toBe(false); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); + + it("drops the page from the URL when a drawer filter is applied", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(MANY_RUNS, "?page=2"); + await waitFor(() => expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2")); + + 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("page")).toBe(false); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2"); + }); +}); + +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([]); + }); + + it("keeps the drawer closed when the run param is empty", async () => { + const { fetchSpy } = renderRuns(RUNS, "?run="); + + await screen.findByText("First run"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(detailFetchUrls(fetchSpy)).toEqual([]); + }); + + it("shows a spinner rather than not-found while the runs list is still loading", async () => { + const listGate = Promise.withResolvers(); + const fallback = mockFetch(RUNS); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init: RequestInit) => { + if (url.includes("/runs?limit")) await listGate.promise; + return fallback(url, init); + }), + ); + renderWithProviders(, { searchParams: "?run=run-bbbbbbbb-2222" }); + + const drawer = await screen.findByRole("dialog"); + expect(within(drawer).queryByText("Workflow run not found.")).not.toBeInTheDocument(); + expect(within(drawer).queryByText("Timeline")).not.toBeInTheDocument(); + + listGate.resolve(); + + await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + expect(within(drawer).queryByText("Workflow run not found.")).not.toBeInTheDocument(); + }); + + it("removes the run from the URL when the drawer is dismissed with Escape", async () => { + const user = userEvent.setup(); + const { onUrlUpdate } = renderRuns(RUNS, "?run=run-aaaaaaaa-1111"); + const drawer = await screen.findByRole("dialog"); + await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + + await user.keyboard("{Escape}"); + + await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("run")).toBe(false)); + await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0)); + }); + + it("encodes the run id in the detail request paths", async () => { + const encodedRun: FakeRun = { ...RUNS[0], run_id: "run/a b", metadata: { title: "Slashed run" } }; + const { fetchSpy } = renderRuns([encodedRun], "?run=run%2Fa%20b"); + + const drawer = await screen.findByRole("dialog"); + await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + expect(detailFetchUrls(fetchSpy)).toEqual([ + "/v1/workflows/runs/run%2Fa%20b/events", + "/v1/workflows/runs/run%2Fa%20b/messages", + ]); + }); +}); + +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..bef2483f15a 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,126 @@ const DetailSection: React.FC<{ ); +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 drawerOpen = Boolean(runId); + const [shownRunId, setShownRunId] = useState(runId); + if (runId && 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: drawerOpen, + refetchOnWindowFocus: false, + retry: false, + }; + const detailQuery = useQuery(detailQueryOptions); + + const closeRun = useCallback(() => void setRunId(null), [setRunId]); const fetchRuns = useCallback(async () => { if (!accessToken) return; @@ -407,49 +557,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 +597,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { }, { id: "status", - accessorKey: "status", + accessorFn: displayedState, header: "Status", meta: { title: "Status" }, filterFn: "equalsString", @@ -490,7 +606,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { return (
- {run.metadata?.state ?? run.status} + {displayedState(run)}
); }, @@ -523,20 +639,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 }) => { <>