This commit is contained in:
ryan-crabbe-berri 2026-09-27 19:15:33 +08:00 • committed by GitHub
commit 52a84a65f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 786 additions and 155 deletions

View file

@ -1205,9 +1205,6 @@
}
},
"src/app/(dashboard)/workflows/WorkflowRuns.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-syntax": {
"count": 3
},

View file

@ -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,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<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");
});
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<void>();
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(<WorkflowRuns accessToken="tok" />, { 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,
});
});
});

View file

@ -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,126 @@ const DetailSection: React.FC<{
</Collapsible>
);
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 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<WorkflowRunDetail> = {
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<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 +597,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
},
{
id: "status",
accessorKey: "status",
accessorFn: displayedState,
header: "Status",
meta: { title: "Status" },
filterFn: "equalsString",
@ -490,7 +606,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 +639,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 +673,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 +705,12 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
/>
{/* detail drawer */}
<Sheet open={drawerOpen} onOpenChange={setDrawerOpen}>
<Sheet
open={drawerOpen}
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 +719,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>

View file

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import type { ToolRow } from "@/components/networking";
@ -131,9 +132,11 @@ describe("ToolPoliciesPanel data loading", () => {
it("should not refetch when the window regains focus", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
</QueryClientProvider>,
<NuqsTestingAdapter>
<QueryClientProvider client={client}>
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
</QueryClientProvider>
</NuqsTestingAdapter>,
);
await waitForRows();

View file

@ -2,8 +2,9 @@ import React from "react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { renderWithProviders } from "../../../tests/test-utils";
import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils";
import type { ToolRow } from "@/components/networking";
import { ToolPoliciesTable } from "./ToolPoliciesTable";
@ -42,7 +43,14 @@ const TOOLS: ToolRow[] = [
},
];
const renderTable = (overrides: Partial<React.ComponentProps<typeof ToolPoliciesTable>> = {}) => {
const SHUFFLED_TOOLS: ToolRow[] = [TOOLS[1], TOOLS[2], TOOLS[0]];
const SAME_TIME_TOOLS: ToolRow[] = SHUFFLED_TOOLS.map((tool) => ({ ...tool, created_at: "2026-07-20T10:00:00Z" }));
const renderTable = (
overrides: Partial<React.ComponentProps<typeof ToolPoliciesTable>> = {},
urlOptions: Parameters<typeof renderWithProviders>[1] = {},
) => {
const props = {
data: TOOLS,
isLoading: false,
@ -55,7 +63,7 @@ const renderTable = (overrides: Partial<React.ComponentProps<typeof ToolPolicies
onOutputPolicyChange: vi.fn(),
...overrides,
};
renderWithProviders(<ToolPoliciesTable {...props} />);
renderWithProviders(<ToolPoliciesTable {...props} />, urlOptions);
return props;
};
@ -73,7 +81,7 @@ const pickFilter = async (
describe("ToolPoliciesTable sorting", () => {
it("should default to newest discovered first", () => {
renderTable();
renderTable({ data: SHUFFLED_TOOLS });
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
});
@ -201,3 +209,177 @@ describe("ToolPoliciesTable chrome", () => {
expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument();
});
});
const renderWithUrl = (searchParams: string, data: ToolRow[] = TOOLS) => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderTable({ data }, { searchParams, onUrlUpdate });
return onUrlUpdate;
};
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
const MANY_TOOLS: ToolRow[] = Array.from({ length: 55 }, (_, index) => ({
tool_id: `bulk-${String(index).padStart(2, "0")}`,
tool_name: `bulk_tool_${index}`,
input_policy: "untrusted",
output_policy: "untrusted",
call_count: index,
key_hash: `hash-${index}`,
created_at: new Date(Date.UTC(2026, 6, 1, 0, 0, 55 - index)).toISOString(),
}));
describe("ToolPoliciesTable URL state", () => {
it("applies the search from the URL", () => {
renderWithUrl("?search=weather");
expect(rowIds()).toEqual(["tool-1"]);
expect(screen.getByTestId("datatable-search")).toHaveValue("weather");
});
it("writes the search to the URL", async () => {
const onUrlUpdate = renderWithUrl("");
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "search" } });
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("search")).toBe("search"));
expect(rowIds()).toEqual(["tool-2"]);
});
it.each([
["filter_input_policy=blocked", "input_policy", ["tool-3"]],
["filter_output_policy=trusted", "output_policy", ["tool-2"]],
["filter_team_id=team-alpha", "team_id", ["tool-1"]],
["filter_key_alias=dev-key", "key_alias", ["tool-2"]],
])("applies ?%s as a column filter", (query, columnId, expected) => {
renderWithUrl(`?${query}`);
expect(rowIds()).toEqual(expected);
expect(screen.getByTestId(`filter-chip-${columnId}`)).toBeInTheDocument();
});
it("writes the filters applied in the drawer to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("");
await user.click(screen.getByTestId("datatable-filters-trigger"));
await pickFilter(user, "filter-output-policy", "untrusted");
await pickFilter(user, "filter-key-alias", "prod-key");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_output_policy")).toBe("untrusted"));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_key_alias")).toBe("prod-key");
expect(rowIds()).toEqual(["tool-1"]);
});
it("drops a filter from the URL when its chip is removed", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("?filter_team_id=team-alpha");
await user.click(screen.getByTestId("filter-chip-remove-team_id"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("filter_team_id")).toBe(false));
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
});
it("orders rows by the sort column and direction in the URL", () => {
renderWithUrl("?sort_by=call_count&sort_order=asc");
expect(rowIds()).toEqual(["tool-2", "tool-1", "tool-3"]);
});
it.each([
["input_policy", "asc", ["tool-3", "tool-2", "tool-1"]],
["output_policy", "desc", ["tool-3", "tool-1", "tool-2"]],
["team_id", "asc", ["tool-3", "tool-1", "tool-2"]],
["key_alias", "asc", ["tool-3", "tool-2", "tool-1"]],
])("orders rows by ?sort_by=%s&sort_order=%s", (column, order, expected) => {
renderWithUrl(`?sort_by=${column}&sort_order=${order}`, SAME_TIME_TOOLS);
expect(rowIds()).toEqual(expected);
});
it("falls back to newest first for an unknown sort column", () => {
renderWithUrl("?sort_by=user_agent&sort_order=desc", SHUFFLED_TOOLS);
expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]);
});
it("writes the sort to the URL when a header is clicked", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("");
await user.click(screen.getByTestId("sort-header-tool_name"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("sort_by")).toBe("tool_name"));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("sort_order")).toBe("asc");
});
it("drops the page from the URL when the sort changes", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("?page=2", MANY_TOOLS);
await user.click(screen.getByTestId("sort-header-tool_name"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("sort_by")).toBe("tool_name"));
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 the search changes", async () => {
const onUrlUpdate = renderWithUrl("?page=2", MANY_TOOLS);
fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "bulk_tool" } });
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("search")).toBe("bulk_tool"));
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 = renderWithUrl("?page=2", MANY_TOOLS);
await user.click(screen.getByTestId("datatable-filters-trigger"));
await pickFilter(user, "filter-input-policy", "untrusted");
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("filter_input_policy")).toBe("untrusted"));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("page")).toBe(false);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2");
});
it("opens the page named in the URL", () => {
renderWithUrl("?page=2", MANY_TOOLS);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
expect(rowIds()).toEqual(["bulk-50", "bulk-51", "bulk-52", "bulk-53", "bulk-54"]);
});
it("writes the page to the URL when paging forward", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("", MANY_TOOLS);
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("bulk-50");
});
it("uses the page size from the URL", () => {
renderWithUrl("?page_size=100", MANY_TOOLS);
expect(rowIds()).toHaveLength(55);
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1");
});
it("writes the page size chosen in the pager to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = renderWithUrl("", MANY_TOOLS);
await chooseSelectOption(user, screen.getByTestId("pagination-page-size"), "100");
await waitFor(() => expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("page_size")).toBe("100"));
expect(rowIds()).toHaveLength(55);
});
});

View file

@ -1,6 +1,6 @@
"use client";
import { ColumnFiltersState } from "@tanstack/react-table";
import { functionalUpdate } from "@tanstack/react-table";
import { Wrench } from "lucide-react";
import { useMemo, useState } from "react";
@ -10,6 +10,8 @@ import {
DataTableFilterDrawer,
DataTableFilterField,
DataTableToolbar,
useUrlTableState,
type UrlTableStateOptions,
} from "@/components/shared/DataTable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -18,6 +20,15 @@ import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
const ALL_VALUE = "all";
type ToolFilterColumn = "input_policy" | "output_policy" | "team_id" | "key_alias";
const TABLE_STATE_OPTIONS: UrlTableStateOptions<ToolFilterColumn> = {
sortFields: ["created_at", "tool_name", "input_policy", "output_policy", "call_count", "team_id", "key_alias"],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: 50,
filterColumns: ["input_policy", "output_policy", "team_id", "key_alias"],
};
const INPUT_POLICY_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Input Policies" },
...INPUT_POLICY_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
@ -76,8 +87,16 @@ export function ToolPoliciesTable({
onInputPolicyChange,
onOutputPolicyChange,
}: ToolPoliciesTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const {
search,
setSearch,
sorting,
onSortingChange,
pagination,
onPaginationChange,
columnFilters,
onColumnFiltersChange,
} = useUrlTableState(TABLE_STATE_OPTIONS);
const [filtersOpen, setFiltersOpen] = useState(false);
const columns = useMemo(() => {
@ -108,24 +127,27 @@ export function ToolPoliciesTable({
columns={columns}
getRowId={(row) => row.tool_id}
sortingMode="client"
defaultSorting={[{ id: "created_at", desc: true }]}
sorting={sorting}
onSortingChange={onSortingChange}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
pageSizeOptions={[50, 100]}
filterMode="client"
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
globalFilter={globalFilter}
onGlobalFilterChange={setGlobalFilter}
onColumnFiltersChange={onColumnFiltersChange}
globalFilter={search}
onGlobalFilterChange={(updater) => setSearch(functionalUpdate(updater, search))}
isLoading={isLoading}
loadingMessage="Loading tools…"
noDataMessage={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || globalFilter !== ""} />}
noDataMessage={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || search !== ""} />}
size="compact"
toolbar={(table) => (
<>
<DataTableToolbar
table={table}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Search by Tool Name"
onRefresh={onRefresh}
isRefreshing={isRefreshing}

View file

@ -2,6 +2,7 @@ import React from "react";
import { beforeEach, describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { renderWithProviders } from "../../tests/test-utils";
import ToolPoliciesView from "./ToolPoliciesView";
@ -69,4 +70,52 @@ describe("ToolPoliciesView", () => {
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
expect(screen.queryByText("Detail: my-tool")).not.toBeInTheDocument();
});
describe("?tool= deep link", () => {
const lastUrlUpdate = (onUrlUpdate: ReturnType<typeof vi.fn<OnUrlUpdateFunction>>) =>
onUrlUpdate.mock.calls.at(-1)?.[0];
it("should open the detail for the tool named in the URL", () => {
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather" });
expect(screen.getByText("Detail: get_weather")).toBeInTheDocument();
expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument();
});
it("should show the overview when the tool param is empty", () => {
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=" });
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
});
it("should push the selected tool to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ToolPoliciesView accessToken="token" />, { onUrlUpdate });
await user.click(screen.getByRole("button", { name: /select tool/i }));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.get("tool")).toBe("my-tool");
expect(lastUrlUpdate(onUrlUpdate)?.options.history).toBe("push");
});
it("should clear the tool from the URL when going back", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /back/i }));
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tool")).toBe(false);
expect(await screen.findByText("Tool Policies Overview")).toBeInTheDocument();
});
it("should keep the admin-only notice for a caller without access even with a tool in the URL", () => {
can.mockReturnValue(false);
renderWithProviders(<ToolPoliciesView accessToken="token" />, { searchParams: "?tool=get_weather" });
expect(screen.getByText(/only available to admin users/i)).toBeInTheDocument();
expect(screen.queryByText("Detail: get_weather")).not.toBeInTheDocument();
});
});
});

View file

@ -1,11 +1,12 @@
"use client";
import React, { useState } from "react";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback } from "react";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { ToolDetail } from "@/components/ToolDetail";
import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel";
type View = { type: "overview" } | { type: "detail"; toolName: string };
const TOOL_PARAM = parseAsString.withOptions({ history: "push" });
interface ToolPoliciesViewProps {
accessToken: string | null;
@ -13,15 +14,11 @@ interface ToolPoliciesViewProps {
export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) {
const canViewToolPolicies = useCan("viewToolPolicies");
const [view, setView] = useState<View>({ type: "overview" });
const [toolName, setToolName] = useQueryState("tool", TOOL_PARAM);
const handleSelectTool = (toolName: string) => {
setView({ type: "detail", toolName });
};
const handleSelectTool = useCallback((name: string) => void setToolName(name), [setToolName]);
const handleBack = () => {
setView({ type: "overview" });
};
const handleBack = useCallback(() => void setToolName(null), [setToolName]);
if (!canViewToolPolicies) {
return (
@ -34,8 +31,8 @@ export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps)
return (
<div className="p-6 w-full min-w-0 flex-1">
{view.type === "detail" ? (
<ToolDetail toolName={view.toolName} onBack={handleBack} accessToken={accessToken} />
{toolName ? (
<ToolDetail key={toolName} toolName={toolName} onBack={handleBack} accessToken={accessToken} />
) : (
<ToolPoliciesPanel accessToken={accessToken} onSelectTool={handleSelectTool} />
)}