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) => ( - - ))} -
- ), - }, - ]} - /> -
- )} - + )} + +
+
+ )} +
+
); };