From bb388b25664c6f23379b2efeca02ce16eb145184 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 23 Jul 2026 10:19:04 -0700 Subject: [PATCH] refactor(ui): migrate workflow runs to shadcn (#34370) * test(ui): characterise the workflow runs detail drawer before migrating it Pins the drawer's behaviour against the current antd implementation: the metadata fields it surfaces, the timeline ordered by sequence number, the empty-events copy, the messages section staying collapsed until opened, the in-drawer refresh refetching, and the close control dismissing it. Every assertion is role/text based so the same file can stay green once the component moves off antd, without being edited. * refactor(ui): migrate workflow runs to shadcn Replaces the antd Drawer, Collapse, Button, Spin, Tooltip and Empty on the Workflow Runs page with the installed Base UI primitives (Sheet, Collapsible, Button, UiLoadingSpinner, Tooltip) and lucide icons, and moves the page's hardcoded hex colours, fonts and geometry onto design tokens and utility classes so the page can be themed. The only inline styles left are the gantt bars' computed left/width, which are runtime values. Behaviour is unchanged: the drawer's characterisation tests were written against the antd version in the previous commit and pass here without being edited. Retires the file's now-unused antd no-restricted-imports suppression. --- ui/litellm-dashboard/eslint-suppressions.json | 3 - .../workflows/WorkflowRuns.test.tsx | 150 ++++- .../(dashboard)/workflows/WorkflowRuns.tsx | 630 +++++++----------- 3 files changed, 378 insertions(+), 405 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6335de32147..1bd3fceb6ff 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2134,9 +2134,6 @@ "no-nested-ternary": { "count": 1 }, - "no-restricted-imports": { - "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 1aee3fcc8ab..6707330a54c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -96,3 +96,151 @@ describe("WorkflowRuns (migrated onto shared DataTable)", () => { } }); }); + +interface FakeEvent { + event_id: string; + event_type: string; + step_name: string; + sequence_number: number; + created_at: string; + data: Record | null; +} + +interface FakeMessage { + message_id: string; + role: string; + content: string; + sequence_number: number; + created_at: string; +} + +const DETAIL_RUN = { + run_id: "run-aaaaaaaa-1111", + status: "completed", + workflow_type: "grill", + created_at: "2026-01-01T00:00:00Z", + metadata: { title: "First run", state: "done", pr_url: "https://example.com/pr/1", worktree_path: "/tmp/wt" }, +}; + +const DETAIL_EVENTS: FakeEvent[] = [ + { + event_id: "ev-2", + event_type: "hook.waiting", + step_name: "review", + sequence_number: 2, + created_at: "2026-01-01T00:00:05Z", + data: null, + }, + { + event_id: "ev-1", + event_type: "step.started", + step_name: "plan", + sequence_number: 1, + created_at: "2026-01-01T00:00:01Z", + data: { attempt: 1 }, + }, +]; + +const DETAIL_MESSAGES: FakeMessage[] = [ + { + message_id: "msg-1", + role: "user", + content: "kick off the run", + sequence_number: 1, + created_at: "2026-01-01T00:00:02Z", + }, +]; + +function mockDetailFetch(events: FakeEvent[], messages: FakeMessage[]) { + return vi.fn((url: string) => { + if (url.includes("/runs?limit")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ runs: [DETAIL_RUN] }) }); + } + if (url.includes("/events")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ events }) }); + } + if (url.includes("/messages")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ messages }) }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); +} + +async function openDetailDrawer(events = DETAIL_EVENTS, messages = DETAIL_MESSAGES) { + const user = userEvent.setup(); + const fetchSpy = mockDetailFetch(events, messages); + vi.stubGlobal("fetch", fetchSpy); + render(); + + await user.click(await screen.findByText("First run")); + const drawer = await screen.findByRole("dialog"); + await waitFor(() => expect(within(drawer).getByText("Timeline")).toBeInTheDocument()); + return { user, fetchSpy, drawer }; +} + +describe("WorkflowRuns detail drawer", () => { + it("shows the run's identity and metadata fields", async () => { + const { drawer } = await openDetailDrawer(); + + expect(within(drawer).getAllByText("First run")).toHaveLength(2); + expect(within(drawer).getByText("run-aaaa")).toBeInTheDocument(); + expect(within(drawer).getByText("grill")).toBeInTheDocument(); + expect(within(drawer).getByText("completed")).toBeInTheDocument(); + expect(within(drawer).getByText("done")).toBeInTheDocument(); + expect(within(drawer).getByText("/tmp/wt")).toBeInTheDocument(); + expect(within(drawer).getByRole("link", { name: "https://example.com/pr/1" })).toHaveAttribute( + "href", + "https://example.com/pr/1", + ); + }); + + it("renders every event in the timeline, ordered by sequence number", async () => { + const { drawer } = await openDetailDrawer(); + + expect(within(drawer).getByText("2 events")).toBeInTheDocument(); + + const stepLabels = within(drawer) + .getAllByText(/^(plan|review)$/) + .map((el) => el.textContent); + expect(stepLabels).toEqual(["plan", "review"]); + + expect(within(drawer).getByText("step.started")).toBeInTheDocument(); + expect(within(drawer).getByText("hook.waiting")).toBeInTheDocument(); + }); + + it("says no events were recorded when the run has none", async () => { + const { drawer } = await openDetailDrawer([], DETAIL_MESSAGES); + + expect(within(drawer).getByText("No events recorded")).toBeInTheDocument(); + }); + + it("keeps the messages section collapsed until it is opened", async () => { + const { user, drawer } = await openDetailDrawer(); + + expect(within(drawer).queryByText("kick off the run")).not.toBeInTheDocument(); + + await user.click(within(drawer).getByRole("button", { name: /Messages/ })); + + expect(await within(drawer).findByText("kick off the run")).toBeInTheDocument(); + expect(within(drawer).getByText("[user]")).toBeInTheDocument(); + }); + + it("refetches events and messages when the drawer's refresh button is clicked", async () => { + const { user, fetchSpy, drawer } = await openDetailDrawer(); + + const eventFetches = () => fetchSpy.mock.calls.filter(([url]) => String(url).includes("/events")).length; + expect(eventFetches()).toBe(1); + + await user.click(within(drawer).getByRole("button", { name: /refresh/i })); + + await waitFor(() => expect(eventFetches()).toBe(2)); + }); + + it("dismisses the drawer when its close control is clicked", async () => { + const { user, drawer } = await openDetailDrawer(); + + await user.click(within(drawer).getByRole("button", { name: /close/i })); + + await waitFor(() => expect(screen.queryAllByRole("dialog")).toHaveLength(0)); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx index 7354b7479f4..5efd56257e2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/WorkflowRuns.tsx @@ -1,6 +1,5 @@ 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 { ArrowLeft, ChevronDown, RefreshCw } from "lucide-react"; import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table"; import { getGlobalLitellmHeaderName, proxyBaseUrl } from "@/components/networking"; import { @@ -9,10 +8,14 @@ import { DataTableFilterField, DataTableToolbar, } from "@/components/shared/DataTable"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; - -const { Text } = Typography; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import { cn } from "@/lib/cva.config"; interface WorkflowRunsProps { accessToken: string | null; @@ -59,11 +62,11 @@ interface WorkflowRunMessage { // ── design tokens ───────────────────────────────────────────────────────────── const STATUS_DOT: Record = { - pending: "#a1a1aa", - running: "#3b82f6", - paused: "#f59e0b", - completed: "#22c55e", - failed: "#ef4444", + pending: "bg-gray-400", + running: "bg-blue-500", + paused: "bg-amber-500", + completed: "bg-green-500", + failed: "bg-red-500", }; const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"]; @@ -75,15 +78,15 @@ const STATUS_LABELS: Record = { failed: "Failed", }; -const EVENT_COLOR: Record = { - "step.started": { bar: "#f0fdf4", border: "#86efac", text: "#16a34a" }, - "step.failed": { bar: "#fef2f2", border: "#fca5a5", text: "#dc2626" }, - "hook.waiting": { bar: "#fffbeb", border: "#fcd34d", text: "#d97706" }, - "hook.received": { bar: "#eff6ff", border: "#93c5fd", text: "#2563eb" }, +const EVENT_COLOR: Record = { + "step.started": { bar: "border-green-300 bg-green-50", text: "text-green-600" }, + "step.failed": { bar: "border-red-300 bg-red-50", text: "text-red-600" }, + "hook.waiting": { bar: "border-amber-300 bg-amber-50", text: "text-amber-600" }, + "hook.received": { bar: "border-blue-300 bg-blue-50", text: "text-blue-600" }, }; function eventStyle(type: string) { - return EVENT_COLOR[type] ?? { bar: "#f4f4f5", border: "#d4d4d8", text: "#52525b" }; + return EVENT_COLOR[type] ?? { bar: "border-border bg-muted", text: "text-muted-foreground" }; } // ── helpers ─────────────────────────────────────────────────────────────────── @@ -118,17 +121,8 @@ function shortId(id: string): string { // ── status dot ──────────────────────────────────────────────────────────────── -const StatusDot: React.FC<{ status: RunStatus; size?: number }> = ({ status, size = 8 }) => ( - +const StatusDot: React.FC<{ status: RunStatus; className?: string }> = ({ status, className }) => ( + ); // ── truncated text value ────────────────────────────────────────────────────── @@ -138,25 +132,14 @@ const TRUNCATE_AT = 120; const TruncatedValue: React.FC<{ value: string }> = ({ value }) => { const [expanded, setExpanded] = useState(false); if (value.length <= TRUNCATE_AT) { - return {value}; + return {value}; } return ( - + {expanded ? value : value.slice(0, TRUNCATE_AT) + "…"} - + ); }; @@ -179,67 +162,24 @@ const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => { ); return ( -
+
{/* title bar */} -
- - {runTitle(run)} - +
+ + {runTitle(run)} + {shortId(run.run_id)} - - {run.workflow_type} - + {run.workflow_type}
{/* key fields grid */} -
+
- {run.status} + {run.status} - {timeAgo(run.created_at)} + {timeAgo(run.created_at)} {meta.pr_url && ( @@ -248,7 +188,7 @@ const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => { href={String(meta.pr_url)} target="_blank" rel="noopener noreferrer" - style={{ color: "#2563eb", textDecoration: "none", wordBreak: "break-all" }} + className="break-all text-primary underline-offset-4 hover:underline" > {String(meta.pr_url)} @@ -280,9 +220,9 @@ const MetadataCard: React.FC<{ run: WorkflowRun }> = ({ run }) => { }; const FieldPair: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( -
- {label} - {children} +
+ {label} + {children}
); @@ -293,11 +233,7 @@ const GanttTimeline: React.FC<{ events: WorkflowRunEvent[]; }> = ({ run, events }) => { if (events.length === 0) { - return ( -
- No events recorded -
- ); + return
No events recorded
; } const runStart = new Date(run.created_at).getTime(); @@ -307,187 +243,139 @@ const GanttTimeline: React.FC<{ const totalDur = fmtDuration(lastTime - runStart); return ( -
- {/* ruler */} -
-
-
- {[0, 100].map((pct) => ( - - {pct === 0 ? "0" : totalDur} - - ))} + +
+ {/* ruler */} +
+
+
+ 0 + {totalDur} +
-
- {/* outer run bar */} -
-
- {runTitle(run)} + {/* outer run bar */} +
+
{runTitle(run)}
+
+ {totalDur} +
-
- {totalDur} -
-
- {/* event rows */} -
- {events.map((ev) => { - const evTime = new Date(ev.created_at).getTime(); - const leftPct = ((evTime - runStart) / totalSpan) * 100; + {/* event rows */} +
+ {events.map((ev) => { + const evTime = new Date(ev.created_at).getTime(); + const leftPct = ((evTime - runStart) / totalSpan) * 100; - const nextIdx = events.findIndex((e) => e.sequence_number > ev.sequence_number); - const nextTime = - nextIdx >= 0 ? new Date(events[nextIdx].created_at).getTime() : lastTime + Math.max(totalSpan * 0.12, 500); - const widthPct = Math.max(8, ((nextTime - evTime) / totalSpan) * 100); - const style = eventStyle(ev.event_type); - const dur = fmtDuration(nextTime - evTime); + const nextIdx = events.findIndex((e) => e.sequence_number > ev.sequence_number); + const nextTime = + nextIdx >= 0 + ? new Date(events[nextIdx].created_at).getTime() + : lastTime + Math.max(totalSpan * 0.12, 500); + const widthPct = Math.max(8, ((nextTime - evTime) / totalSpan) * 100); + const style = eventStyle(ev.event_type); + const dur = fmtDuration(nextTime - evTime); - return ( - -
- {ev.step_name || ev.event_type} -
-
- -
- type: - {ev.event_type} -
-
- step: - {ev.step_name} -
-
- seq: - {ev.sequence_number} -
-
- time: - {timeAgo(ev.created_at)} -
- {ev.data && Object.keys(ev.data).length > 0 && ( + return ( + +
{ev.step_name || ev.event_type}
+
+ + + } + > + {ev.event_type} + {dur && {dur}} + + +
- data: - {JSON.stringify(ev.data)} + type: + {ev.event_type}
- )} -
- } - > -
- {ev.event_type} - {dur && {dur}} -
-
-
-
- ); - })} +
+ step: + {ev.step_name} +
+
+ seq: + {ev.sequence_number} +
+
+ time: + {timeAgo(ev.created_at)} +
+ {ev.data && Object.keys(ev.data).length > 0 && ( +
+ data: + {JSON.stringify(ev.data)} +
+ )} +
+ + +
+ + ); + })} +
-
+
); }; // ── message row ─────────────────────────────────────────────────────────────── -const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => { - const roleColor: Record = { - user: "#2563eb", - assistant: "#16a34a", - system: "#7c3aed", - tool_result: "#d97706", - }; - const color = roleColor[msg.role] ?? "#52525b"; - - return ( -
- [{msg.role}] -
- - {msg.content} - - - {timeAgo(msg.created_at)} - -
-
- ); +const ROLE_COLOR: Record = { + user: "text-blue-600", + assistant: "text-green-600", + system: "text-violet-600", + tool_result: "text-amber-600", }; +const MessageRow: React.FC<{ msg: WorkflowRunMessage }> = ({ msg }) => ( +
+ [{msg.role}] +
+ {msg.content} + {timeAgo(msg.created_at)} +
+
+); + +// ── collapsible section ─────────────────────────────────────────────────────── + +const DetailSection: React.FC<{ + title: string; + meta: React.ReactNode; + defaultOpen?: boolean; + children: React.ReactNode; +}> = ({ title, meta, defaultOpen = false, children }) => ( + + + + + {title} + {meta} + + + {children} + +); + // ── main component ──────────────────────────────────────────────────────────── const WorkflowRuns: React.FC = ({ accessToken }) => { @@ -572,11 +460,11 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { cell: ({ row }) => { const run = row.original; return ( -
- +
+
-
{runTitle(run)}
-
{shortId(run.run_id)}
+
{runTitle(run)}
+
{shortId(run.run_id)}
); @@ -588,7 +476,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { meta: { title: "Type" }, filterFn: "includesString", cell: ({ row }) => ( - {row.original.workflow_type} + {row.original.workflow_type} ), }, { @@ -600,11 +488,9 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { cell: ({ row }) => { const run = row.original; return ( -
- - - {run.metadata?.state ?? run.status} - +
+ + {run.metadata?.state ?? run.status}
); }, @@ -613,26 +499,18 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { accessorKey: "created_at", header: "Created", meta: { title: "Created" }, - cell: ({ row }) => {timeAgo(row.original.created_at)}, + cell: ({ row }) => {timeAgo(row.original.created_at)}, }, ], [], ); return ( -
+
{/* page header */} -
-
Workflow Runs
-
+
+
Workflow Runs
+
Durable state tracking for agents and automated workflows
@@ -643,12 +521,7 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { getRowId={(run) => run.run_id} isLoading={loadingRuns} loadingMessage="Loading workflow runs…" - noDataMessage={ - No workflow runs yet} - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - } + noDataMessage={
No workflow runs yet
} paginationMode="client" pageSizeOptions={[50, 100]} filterMode="client" @@ -712,115 +585,70 @@ const WorkflowRuns: React.FC = ({ accessToken }) => { /> {/* detail drawer */} - setDrawerOpen(false)} - width={680} - title={null} - closable={false} - bodyStyle={{ padding: 0 }} - styles={{ body: { padding: 0 } }} - > - {!selectedRun ? null : loadingDetail ? ( -
- -
- ) : ( -
- {/* drawer close + refresh */} -
- - + + + Workflow run details + + Metadata, timeline and messages for the selected workflow run + + {!selectedRun ? null : loadingDetail ? ( +
+
+ ) : ( +
+ {/* drawer close + refresh */} +
+ + +
- {/* metadata card — top */} - + {/* metadata card — top */} + - {/* collapsible sections */} - - Timeline - - {events.length} {events.length === 1 ? "event" : "events"} - - - ), - children: ( -
- + {/* collapsible sections */} +
+ + {events.length} {events.length === 1 ? "event" : "events"} + + } + defaultOpen + > + + + + {messages.length === 0 ? ( +
No messages
+ ) : ( +
+ {messages.map((msg) => ( + + ))}
- ), - }, - { - key: "messages", - label: ( - - Messages - - {messages.length} - - - ), - children: - messages.length === 0 ? ( -
- No messages -
- ) : ( -
- {messages.map((msg) => ( - - ))} -
- ), - }, - ]} - /> -
- )} - + )} + +
+
+ )} +
+
); };