From 06d9802ed6c604e283f7c18e305a5a04cc5d6eb7 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 16 Sep 2026 11:07:15 -0700 Subject: [PATCH] feat(ui): persist tool policies view and table state in the URL The selected tool is now ?tool= (pushed, so Back returns to the list), and the tool table keeps search, the input/output policy, team and key filters, sort and pagination in the query string. --- .../ToolPolicies/ToolPoliciesPanel.test.tsx | 9 +- .../ToolPolicies/ToolPoliciesTable.test.tsx | 120 +++++++++++++++++- .../ToolPolicies/ToolPoliciesTable.tsx | 42 ++++-- .../src/components/ToolPoliciesView.test.tsx | 49 +++++++ .../src/components/ToolPoliciesView.tsx | 19 ++- 5 files changed, 213 insertions(+), 26 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx index a2d3539a58b..c2d71dbb62e 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -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( - - - , + + + + + , ); await waitForRows(); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx index 50f673fbd6c..f65b5aa18da 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx @@ -2,6 +2,7 @@ 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 type { ToolRow } from "@/components/networking"; @@ -42,7 +43,10 @@ const TOOLS: ToolRow[] = [ }, ]; -const renderTable = (overrides: Partial> = {}) => { +const renderTable = ( + overrides: Partial> = {}, + urlOptions: Parameters[1] = {}, +) => { const props = { data: TOOLS, isLoading: false, @@ -55,7 +59,7 @@ const renderTable = (overrides: Partial); + renderWithProviders(, urlOptions); return props; }; @@ -201,3 +205,115 @@ describe("ToolPoliciesTable chrome", () => { expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument(); }); }); + +const renderWithUrl = (searchParams: string, data: ToolRow[] = TOOLS) => { + const onUrlUpdate = vi.fn(); + renderTable({ data }, { searchParams, onUrlUpdate }); + return onUrlUpdate; +}; + +const lastUrlUpdate = (onUrlUpdate: ReturnType>) => + 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 in the URL", () => { + renderWithUrl("?sort_by=call_count&sort_order=desc"); + + expect(rowIds()).toEqual(["tool-3", "tool-1", "tool-2"]); + }); + + it("falls back to newest first for an unknown sort column", () => { + renderWithUrl("?sort_by=user_agent&sort_order=desc"); + + 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("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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx index 90093668e1d..d7584de05a8 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx @@ -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 = { + 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([]); + 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={ 0 || globalFilter !== ""} />} + noDataMessage={ 0 || search !== ""} />} size="compact" toolbar={(table) => ( <> { expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); expect(screen.queryByText("Detail: my-tool")).not.toBeInTheDocument(); }); + + describe("?tool= deep link", () => { + const lastUrlUpdate = (onUrlUpdate: ReturnType>) => + onUrlUpdate.mock.calls.at(-1)?.[0]; + + it("should open the detail for the tool named in the URL", () => { + renderWithProviders(, { 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(, { 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(); + renderWithProviders(, { 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(); + renderWithProviders(, { 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(, { searchParams: "?tool=get_weather" }); + + expect(screen.getByText(/only available to admin users/i)).toBeInTheDocument(); + expect(screen.queryByText("Detail: get_weather")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index 06462017574..605d2215939 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -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({ 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 (
- {view.type === "detail" ? ( - + {toolName ? ( + ) : ( )}