mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
Merge c4539b5a5f into f4308bc124
This commit is contained in:
commit
52a84a65f6
8 changed files with 786 additions and 155 deletions
|
|
@ -1205,9 +1205,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/workflows/WorkflowRuns.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 3
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue