mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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=<run_id>, 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.
This commit is contained in:
parent
8bb4317c75
commit
fc44c89f61
2 changed files with 416 additions and 124 deletions
|
|
@ -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(<WorkflowRuns accessToken="tok" />);
|
||||
const { container } = renderWithProviders(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
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(<WorkflowRuns accessToken="tok" />);
|
||||
renderWithProviders(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
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(<WorkflowRuns accessToken="tok" />);
|
||||
renderWithProviders(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
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(<WorkflowRuns accessToken="tok" />);
|
||||
renderWithProviders(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
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(<WorkflowRuns accessToken="tok" />);
|
||||
renderWithProviders(<WorkflowRuns accessToken="tok" />);
|
||||
|
||||
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<typeof vi.fn<OnUrlUpdateFunction>>) =>
|
||||
onUrlUpdate.mock.calls.at(-1)?.[0];
|
||||
|
||||
const renderRuns = (runs: FakeRun[], searchParams = "") => {
|
||||
const fetchSpy = mockFetch(runs);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
renderWithProviders(<WorkflowRuns accessToken="tok" />, { searchParams, onUrlUpdate });
|
||||
return { fetchSpy, onUrlUpdate };
|
||||
};
|
||||
|
||||
const detailFetchUrls = (fetchSpy: ReturnType<typeof mockFetch>): 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<RunFilterColumn> = {
|
||||
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<RunStatus, string> = {
|
||||
|
|
@ -69,7 +91,7 @@ const STATUS_DOT: Record<RunStatus, string> = {
|
|||
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<RunStatus, string> = {
|
||||
pending: "Pending",
|
||||
running: "Running",
|
||||
|
|
@ -78,6 +100,10 @@ const STATUS_LABELS: Record<RunStatus, string> = {
|
|||
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<string, { bar: string; text: string }> = {
|
||||
"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<T extends { sequence_number: number }>(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<WorkflowRunDetail> {
|
||||
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<{
|
|||
</Collapsible>
|
||||
);
|
||||
|
||||
// ── run detail drawer body ────────────────────────────────────────────────────
|
||||
|
||||
const DrawerSpinner: React.FC = () => (
|
||||
<div className="flex justify-center py-20">
|
||||
<UiLoadingSpinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DrawerCloseButton: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-0 text-xs font-normal text-muted-foreground hover:bg-transparent"
|
||||
onClick={onClose}
|
||||
>
|
||||
<ArrowLeft />
|
||||
close
|
||||
</Button>
|
||||
);
|
||||
|
||||
interface RunDetailBodyProps {
|
||||
run: WorkflowRun | undefined;
|
||||
runsLoading: boolean;
|
||||
detail: WorkflowRunDetail | undefined;
|
||||
detailLoading: boolean;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const RunDetailBody: React.FC<RunDetailBodyProps> = ({
|
||||
run,
|
||||
runsLoading,
|
||||
detail,
|
||||
detailLoading,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}) => {
|
||||
if (run === undefined) {
|
||||
return runsLoading ? (
|
||||
<DrawerSpinner />
|
||||
) : (
|
||||
<div className="px-7 py-6">
|
||||
<DrawerCloseButton onClose={onClose} />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Workflow run not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (detailLoading) {
|
||||
return <DrawerSpinner />;
|
||||
}
|
||||
|
||||
const events = detail?.events ?? [];
|
||||
const messages = detail?.messages ?? [];
|
||||
|
||||
return (
|
||||
<div className="px-7 py-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<DrawerCloseButton onClose={onClose} />
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<MetadataCard run={run} />
|
||||
<div className="divide-y overflow-hidden rounded-lg border">
|
||||
<DetailSection
|
||||
title="Timeline"
|
||||
meta={
|
||||
<>
|
||||
{events.length} {events.length === 1 ? "event" : "events"}
|
||||
</>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<GanttTimeline run={run} events={events} />
|
||||
</DetailSection>
|
||||
<DetailSection title="Messages" meta={messages.length}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="py-3 font-mono text-xs text-muted-foreground">No messages</div>
|
||||
) : (
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<MessageRow key={msg.message_id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
|
||||
const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||
const [loadingRuns, setLoadingRuns] = useState(false);
|
||||
const [selectedRun, setSelectedRun] = useState<WorkflowRun | null>(null);
|
||||
const [events, setEvents] = useState<WorkflowRunEvent[]>([]);
|
||||
const [messages, setMessages] = useState<WorkflowRunMessage[]>([]);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
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<WorkflowRunDetail> = {
|
||||
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<WorkflowRunsProps> = ({ 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<ColumnDef<WorkflowRun, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -481,7 +598,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
accessorFn: displayedState,
|
||||
header: "Status",
|
||||
meta: { title: "Status" },
|
||||
filterFn: "equalsString",
|
||||
|
|
@ -490,7 +607,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot status={run.status} className="size-[7px]" />
|
||||
<span className="text-xs capitalize text-muted-foreground">{run.metadata?.state ?? run.status}</span>
|
||||
<span className="text-xs capitalize text-muted-foreground">{displayedState(run)}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -523,20 +640,24 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
loadingMessage="Loading workflow runs…"
|
||||
noDataMessage={<div className="py-6 text-center text-[13px] text-muted-foreground">No workflow runs yet</div>}
|
||||
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) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search runs…"
|
||||
onRefresh={fetchRuns}
|
||||
isRefreshing={loadingRuns}
|
||||
|
|
@ -553,18 +674,18 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
<>
|
||||
<DataTableFilterField label="Status">
|
||||
<Select
|
||||
items={STATUS_LABELS}
|
||||
items={statusFilterItems}
|
||||
value={(get("status") as string) || null}
|
||||
onValueChange={(value: string | null) => set("status", value ?? "")}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full" data-testid="filter-status">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>All statuses</SelectItem>
|
||||
{RUN_STATUS_OPTIONS.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{STATUS_LABELS[status]}
|
||||
{Object.entries(statusFilterItems).map(([state, label]) => (
|
||||
<SelectItem key={state} value={state}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -585,7 +706,12 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
/>
|
||||
|
||||
{/* detail drawer */}
|
||||
<Sheet open={drawerOpen} onOpenChange={setDrawerOpen}>
|
||||
<Sheet
|
||||
open={runId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeRun();
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
showCloseButton={false}
|
||||
className="overflow-y-auto p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-[680px]"
|
||||
|
|
@ -594,59 +720,14 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
|
|||
<SheetDescription className="sr-only">
|
||||
Metadata, timeline and messages for the selected workflow run
|
||||
</SheetDescription>
|
||||
{!selectedRun ? null : loadingDetail ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<UiLoadingSpinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-7 py-6">
|
||||
{/* drawer close + refresh */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-0 text-xs font-normal text-muted-foreground hover:bg-transparent"
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
<ArrowLeft />
|
||||
close
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchRunDetail(selectedRun)}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* metadata card — top */}
|
||||
<MetadataCard run={selectedRun} />
|
||||
|
||||
{/* collapsible sections */}
|
||||
<div className="divide-y overflow-hidden rounded-lg border">
|
||||
<DetailSection
|
||||
title="Timeline"
|
||||
meta={
|
||||
<>
|
||||
{events.length} {events.length === 1 ? "event" : "events"}
|
||||
</>
|
||||
}
|
||||
defaultOpen
|
||||
>
|
||||
<GanttTimeline run={selectedRun} events={events} />
|
||||
</DetailSection>
|
||||
<DetailSection title="Messages" meta={messages.length}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="py-3 font-mono text-xs text-muted-foreground">No messages</div>
|
||||
) : (
|
||||
<div>
|
||||
{messages.map((msg) => (
|
||||
<MessageRow key={msg.message_id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RunDetailBody
|
||||
run={shownRun}
|
||||
runsLoading={loadingRuns}
|
||||
detail={detailQuery.data}
|
||||
detailLoading={detailQuery.isFetching}
|
||||
onClose={closeRun}
|
||||
onRefresh={() => void detailQuery.refetch()}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue