From dfbd098d65bee5ba996f4735d37d6d51f9fa8ad1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 21 Jul 2026 17:11:16 -0700 Subject: [PATCH] refactor(ui): migrate Tool Policies table onto the shared DataTable (#34176) * refactor(ui): migrate Tool Policies table onto the shared DataTable Splits the old components/ToolPolicies.tsx into a data-owning panel, a thin DataTable consumer and a getToolPoliciesTableColumns module, all under components/ToolPolicies/. The hand-rolled tremor table, sort dropdowns and Prev/Next pager are replaced by the shared DataTable in client mode, so sorting, pagination and filtering now come from TanStack rather than local state. Search moves to the toolbar global filter and the four facets (input policy, output policy, team, key) move into a filter drawer; the facets match exactly instead of by substring, so filtering on "trusted" no longer also matches "untrusted" Inline policy editing is preserved. The two policy columns still render a PolicySelect directly in the row, with the per-row-per-column saving state and the in-place row update kept in the panel that owns the data The 15s live-tail poll is removed in favour of the toolbar refresh action, which takes the auto-refresh out of the write path of the inline edits. The green live-tail banner goes with it. The panel now reads through React Query with window-focus and reconnect refetching disabled, so refresh stays manual; that also removes the effect that previously needed a set-state-in-effect suppression The metric cards, the Needs Review banner and the detail swap are unchanged. Review still scrolls to the row when it is on screen, but no longer jumps across pages, since the paginated order now lives inside the table Drops the unused userRole prop threaded from the route through the view into the table, and prunes the suppressions stranded by the file move * fix(ui): make Tool Policies inline saves safe against concurrent edits and refresh Two races in the inline policy editing path, both found by review. Saving state was a single tool name per column, so starting a second row's save re-enabled the first row while its PATCH was still in flight, and whichever save finished first cleared the indicator for whichever row was in the slot. Track the set of tool names currently saving per column instead, so each cell disables and re-enables on its own request A list fetch already in flight when a save landed would resolve afterwards and overwrite the row with its pre-save snapshot, silently reverting a policy the user had just changed and the server had already accepted. Cancel in-flight queries before writing the row, which is the documented React Query ordering for this; the stale response is then discarded and the refresh can be retried Tightens the test helpers that hid the second bug: policy values are now compared exactly rather than with toHaveTextContent, which substring-matches and so let "untrusted" satisfy an assertion for "trusted" --- ui/litellm-dashboard/eslint-suppressions.json | 17 - .../app/(dashboard)/tool-policies/page.tsx | 4 +- .../src/components/ToolPolicies.tsx | 553 ------------------ .../ToolPolicies/ToolPoliciesPanel.test.tsx | 321 ++++++++++ .../ToolPolicies/ToolPoliciesPanel.tsx | 212 +++++++ .../ToolPolicies/ToolPoliciesTable.test.tsx | 189 ++++++ .../ToolPolicies/ToolPoliciesTable.tsx | 199 +++++++ .../ToolPolicies/ToolPoliciesTableColumns.tsx | 141 +++++ .../src/components/ToolPoliciesView.test.tsx | 22 +- .../src/components/ToolPoliciesView.tsx | 7 +- 10 files changed, 1079 insertions(+), 586 deletions(-) delete mode 100644 ui/litellm-dashboard/src/components/ToolPolicies.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index a48276cd727..6072c8c725b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1497,23 +1497,6 @@ "count": 2 } }, - "src/components/ToolPolicies.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - }, - "react-hooks/static-components": { - "count": 7 - }, - "unused-imports/no-unused-imports": { - "count": 1 - } - }, "src/components/UIAccessControlForm.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx index 6aaebaab959..08fded8dca6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tool-policies/page.tsx @@ -4,6 +4,6 @@ import ToolPoliciesView from "@/components/ToolPoliciesView"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function ToolPolicies() { - const { accessToken, userRole } = useAuthorized(); - return ; + const { accessToken } = useAuthorized(); + return ; } diff --git a/ui/litellm-dashboard/src/components/ToolPolicies.tsx b/ui/litellm-dashboard/src/components/ToolPolicies.tsx deleted file mode 100644 index 4468334f813..00000000000 --- a/ui/litellm-dashboard/src/components/ToolPolicies.tsx +++ /dev/null @@ -1,553 +0,0 @@ -"use client"; - -import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; -import { Button, Switch, Tooltip } from "antd"; -import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import FilterComponent, { FilterOption } from "./molecules/filter"; -import { MetricCard } from "./GuardrailsMonitor/MetricCard"; -import { PolicySelect, INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./ToolPolicies/PolicySelect"; -import { fetchToolsList, updateToolPolicy, ToolRow } from "./networking"; - -function getUTCDateKey(date: Date): string { - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; -} - -function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean { - if (!createdAt) return false; - try { - const d = new Date(createdAt); - return getUTCDateKey(d) === utcDateKey; - } catch { - return false; - } -} - -function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number { - return tools.filter((t) => isCreatedInUTCDay(t.created_at, utcDateKey)).length; -} - -function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined { - const diff = newToday - newYesterday; - if (diff === 0) return undefined; - if (diff > 0) return `+${diff} since yesterday`; - return `${diff} since yesterday`; -} - -type SortField = "tool_name" | "input_policy" | "output_policy" | "team_id" | "key_alias" | "created_at" | "call_count"; - -interface FilterValues { - [key: string]: string; -} - -interface ToolPoliciesProps { - accessToken: string | null; - userRole?: string; - onSelectTool?: (toolName: string) => void; -} - -export const ToolPolicies: React.FC = ({ accessToken, onSelectTool }) => { - const [tools, setTools] = useState([]); - const [loading, setLoading] = useState(true); - const [isFetching, setIsFetching] = useState(false); - const [error, setError] = useState(null); - const [savingInput, setSavingInput] = useState(null); - const [savingOutput, setSavingOutput] = useState(null); - - const [searchTerm, setSearchTerm] = useState(""); - const [sortField, setSortField] = useState("created_at"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const [currentPage, setCurrentPage] = useState(1); - const [isLiveTail, setIsLiveTail] = useState(true); - const [activeFilters, setActiveFilters] = useState({}); - const pageSize = 50; - - const isFetchingDeferred = useDeferredValue(isFetching); - const isButtonLoading = isFetching || isFetchingDeferred; - - const load = useCallback(async () => { - if (!accessToken) return; - setIsFetching(true); - setError(null); - try { - const rows = await fetchToolsList(accessToken); - setTools(rows); - } catch (e: any) { - setError(e.message ?? "Failed to load tools"); - } finally { - setIsFetching(false); - setLoading(false); - } - }, [accessToken]); - - useEffect(() => { - load(); - }, [load]); - - useEffect(() => { - if (!isLiveTail) return; - const id = setInterval(load, 15000); - return () => clearInterval(id); - }, [isLiveTail, load]); - - const handleInputPolicyChange = async (toolName: string, newPolicy: string) => { - if (!accessToken) return; - setSavingInput(toolName); - try { - await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy }); - setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, input_policy: newPolicy } : t))); - } catch (e: any) { - alert(`Failed to update input policy: ${e.message}`); - } finally { - setSavingInput(null); - } - }; - - const handleOutputPolicyChange = async (toolName: string, newPolicy: string) => { - if (!accessToken) return; - setSavingOutput(toolName); - try { - await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy }); - setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, output_policy: newPolicy } : t))); - } catch (e: any) { - alert(`Failed to update output policy: ${e.message}`); - } finally { - setSavingOutput(null); - } - }; - - const handleSortChange = (field: SortField, newState: SortState) => { - if (newState === false) { - setSortField("created_at"); - setSortOrder("desc"); - } else { - setSortField(field); - setSortOrder(newState); - } - setCurrentPage(1); - }; - - const handleApplyFilters = (filters: FilterValues) => { - setActiveFilters(filters); - setCurrentPage(1); - }; - - const handleResetFilters = () => { - setActiveFilters({}); - setCurrentPage(1); - }; - - const teamOptions = Array.from(new Set(tools.map((t) => t.team_id).filter(Boolean))).map((v) => ({ - label: v as string, - value: v as string, - })); - const keyAliasOptions = Array.from(new Set(tools.map((t) => t.key_alias).filter(Boolean))).map((v) => ({ - label: v as string, - value: v as string, - })); - - const filterOptions: FilterOption[] = [ - { - name: "Input Policy", - label: "Input Policy", - options: INPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), - }, - { - name: "Output Policy", - label: "Output Policy", - options: OUTPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })), - }, - { - name: "Team Name", - label: "Team Name", - options: teamOptions, - }, - { - name: "Key Name", - label: "Key Name", - options: keyAliasOptions, - }, - ]; - - const { newToday, newYesterday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = - useMemo(() => { - const now = new Date(); - const todayKey = getUTCDateKey(now); - const yesterday = new Date(now); - yesterday.setUTCDate(yesterday.getUTCDate() - 1); - const yesterdayKey = getUTCDateKey(yesterday); - - const newToday = countToolsInUTCDay(tools, todayKey); - const newYesterday = countToolsInUTCDay(tools, yesterdayKey); - const trendSubtitle = getTrendSubtitle(newToday, newYesterday); - - const totalTools = tools.length; - const blockedCount = tools.filter((t) => t.input_policy === "blocked").length; - const activeTeamsCount = new Set(tools.map((t) => t.team_id).filter(Boolean)).size; - - const needsReviewTools = tools.filter( - (t) => isCreatedInUTCDay(t.created_at, todayKey) && t.input_policy === "untrusted", - ); - - return { - newToday, - newYesterday, - trendSubtitle, - totalTools, - blockedCount, - activeTeamsCount, - needsReviewTools, - }; - }, [tools]); - - const SortHeader = ({ label, field }: { label: string; field: SortField }) => ( -
- {label} - handleSortChange(field, s)} - /> -
- ); - - const filtered = tools.filter((t) => { - if (searchTerm) { - const q = searchTerm.toLowerCase(); - const matchesSearch = - t.tool_name.toLowerCase().includes(q) || - (t.team_id ?? "").toLowerCase().includes(q) || - (t.key_alias ?? "").toLowerCase().includes(q) || - (t.key_hash ?? "").toLowerCase().includes(q) || - t.input_policy.toLowerCase().includes(q) || - t.output_policy.toLowerCase().includes(q); - if (!matchesSearch) return false; - } - if (activeFilters["Input Policy"] && t.input_policy !== activeFilters["Input Policy"]) return false; - if (activeFilters["Output Policy"] && t.output_policy !== activeFilters["Output Policy"]) return false; - if (activeFilters["Team Name"] && t.team_id !== activeFilters["Team Name"]) return false; - if (activeFilters["Key Name"] && t.key_alias !== activeFilters["Key Name"]) return false; - return true; - }); - - const sorted = [...filtered].sort((a, b) => { - const av = (a as any)[sortField] ?? ""; - const bv = (b as any)[sortField] ?? ""; - if (av < bv) return sortOrder === "desc" ? 1 : -1; - if (av > bv) return sortOrder === "desc" ? -1 : 1; - return 0; - }); - - const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); - const paginated = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize); - - const scrollToToolRow = (toolId: string) => { - const idx = sorted.findIndex((t) => t.tool_id === toolId); - if (idx >= 0) { - const page = Math.floor(idx / pageSize) + 1; - if (page !== currentPage) setCurrentPage(page); - requestAnimationFrame(() => { - setTimeout(() => { - document.getElementById(`tool-row-${toolId}`)?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 100); - }); - } - }; - - return ( -
-

Tool Policies

- -
- - - - } - /> - - 0 ? "text-red-600" : undefined} - /> - 0 ? activeTeamsCount : "—"} /> -
- - {needsReviewTools.length > 0 && ( -
-

Needs Review

-

- {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy - decisions. -

-
- {needsReviewTools.map((t) => ( - - - {t.tool_name} - - - - ))} -
-
- )} - -
-
-
-
-
- { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - /> - - - -
- -
- Live Tail - -
- - -
- -
- - Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "} - {Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results - - - Page {currentPage} of {totalPages} - -
- - -
-
-
- -
- -
-
- - {isLiveTail && ( -
- Auto-refreshing every 15 seconds - -
- )} - - {error && ( -
{error}
- )} - - - - - - - - - - - - - - - - - - - - - - - Key Hash - - - - User Agent - - - - {loading ? ( - - - Loading tools… - - - ) : paginated.length === 0 ? ( - - - No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery. - - - ) : ( - paginated.map((tool) => ( - - - - - - - - - - - - - - -
- {(tool.call_count ?? 0).toLocaleString()} -
-
- - - - - - - - - {tool.key_alias ?? "-"} - - - - - - {tool.user_agent ?? "-"} - - - -
- )) - )} -
-
- - {totalPages > 1 && ( -
- - Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "} - {sorted.length} - -
- - -
-
- )} -
-
- ); -}; - -export default ToolPolicies; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx new file mode 100644 index 00000000000..721c215cfb1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -0,0 +1,321 @@ +import React from "react"; +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 { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import type { ToolRow } from "@/components/networking"; +import { ToolPoliciesPanel } from "./ToolPoliciesPanel"; + +const fetchToolsList = vi.fn(); +const updateToolPolicy = vi.fn(); + +vi.mock("@/components/networking", () => ({ + fetchToolsList: (...args: unknown[]) => fetchToolsList(...args), + updateToolPolicy: (...args: unknown[]) => updateToolPolicy(...args), +})); + +const fromBackend = vi.fn(); +vi.mock("@/components/molecules/notifications_manager", () => ({ + default: { fromBackend: (...args: unknown[]) => fromBackend(...args) }, +})); + +const NOW = new Date("2026-07-21T12:00:00Z"); + +const TOOLS: ToolRow[] = [ + { + tool_id: "tool-1", + tool_name: "get_weather", + input_policy: "untrusted", + output_policy: "untrusted", + call_count: 12, + team_id: "team-alpha", + key_alias: "prod-key", + key_hash: "hash-aaa", + user_agent: "curl/8.7.1", + created_at: "2026-07-21T10:00:00Z", + }, + { + tool_id: "tool-2", + tool_name: "search_web", + input_policy: "trusted", + output_policy: "trusted", + call_count: 5, + team_id: "team-beta", + key_alias: "dev-key", + key_hash: "hash-bbb", + created_at: "2026-07-20T10:00:00Z", + }, + { + tool_id: "tool-3", + tool_name: "delete_file", + input_policy: "blocked", + output_policy: "untrusted", + call_count: 100, + key_hash: "hash-ccc", + created_at: "2026-07-19T10:00:00Z", + }, +]; + +const row = (toolId: string): HTMLElement => { + const element = document.querySelector(`[data-row-id="${toolId}"]`); + if (element === null) throw new Error(`row ${toolId} is not rendered`); + return element as HTMLElement; +}; + +const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement => + within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1]; + +/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */ +const policyValue = (toolId: string, kind: "input" | "output"): string => + policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? ""; + +const isSaving = (toolId: string, kind: "input" | "output"): boolean => + policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false; + +const chooseOption = async (user: ReturnType, trigger: HTMLElement, label: string) => { + await user.click(trigger); + const option = await waitFor(() => { + const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find( + (element) => element.textContent === label, + ); + if (match === undefined) throw new Error(`option ${label} not open`); + return match as HTMLElement; + }); + await user.click(option); +}; + +const renderPanel = (onSelectTool = vi.fn()) => + renderWithProviders(); + +const waitForRows = () => waitFor(() => expect(document.querySelector('[data-row-id="tool-1"]')).not.toBeNull()); + +beforeEach(() => { + testQueryClient.clear(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + fetchToolsList.mockReset().mockResolvedValue(TOOLS); + updateToolPolicy.mockReset().mockResolvedValue({}); + fromBackend.mockReset(); + Element.prototype.scrollIntoView = vi.fn(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ToolPoliciesPanel data loading", () => { + it("should load tools once and never auto-refresh on a timer", async () => { + renderPanel(); + await waitForRows(); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + + expect(fetchToolsList).toHaveBeenCalledTimes(1); + }); + + it("should not refetch when the window regains focus", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await waitForRows(); + + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(fetchToolsList).toHaveBeenCalledTimes(1); + focusManager.setFocused(undefined); + }); + + it("should refetch when the toolbar refresh action is used", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await user.click(screen.getByTestId("datatable-refresh")); + + await waitFor(() => expect(fetchToolsList).toHaveBeenCalledTimes(2)); + }); + + it("should keep rows visible during a refresh instead of falling back to skeletons", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + fetchToolsList.mockReturnValue(new Promise(() => {})); + await user.click(screen.getByTestId("datatable-refresh")); + + expect(row("tool-1")).toBeInTheDocument(); + expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0); + }); + + it("should resolve the loading skeleton when there is no access token", async () => { + renderWithProviders(); + + await waitFor(() => expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0)); + expect(fetchToolsList).not.toHaveBeenCalled(); + expect(screen.getByText("No tools discovered")).toBeInTheDocument(); + }); + + it("should surface a load failure without wedging the skeleton", async () => { + fetchToolsList.mockRejectedValue(new Error("boom")); + renderPanel(); + + expect(await screen.findByRole("alert")).toHaveTextContent("boom"); + expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0); + }); +}); + +describe("ToolPoliciesPanel inline policy editing", () => { + it("should patch the input policy and update that row in place", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { input_policy: "trusted" }); + await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); + expect(fetchToolsList).toHaveBeenCalledTimes(1); + }); + + it("should patch the output policy from the output column", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "output"), "trusted"); + + expect(updateToolPolicy).toHaveBeenCalledWith("sk-token", "get_weather", { output_policy: "trusted" }); + }); + + it("should keep every in-flight row disabled when two rows are saved at once", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockReturnValue(new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + + expect(isSaving("tool-2", "input")).toBe(true); + expect(isSaving("tool-1", "input")).toBe(true); + }); + + it("should re-enable only the row whose save finished", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let finishFirst = () => {}; + updateToolPolicy + .mockImplementationOnce(() => new Promise((resolve) => (finishFirst = () => resolve()))) + .mockImplementationOnce(() => new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await chooseOption(user, policySelect("tool-2", "input"), "blocked"); + await act(async () => { + finishFirst(); + }); + + expect(isSaving("tool-1", "input")).toBe(false); + expect(isSaving("tool-2", "input")).toBe(true); + }); + + it("should not let an in-flight refresh clobber a policy that just saved", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let landStaleRefresh = () => {}; + renderPanel(); + await waitForRows(); + + fetchToolsList.mockImplementationOnce( + // resolves with the PRE-save snapshot, i.e. tool-1 still "untrusted" + () => new Promise((resolve) => (landStaleRefresh = () => resolve(TOOLS))), + ); + await user.click(screen.getByTestId("datatable-refresh")); + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + await waitFor(() => expect(policyValue("tool-1", "input")).toBe("trusted")); + + await act(async () => { + landStaleRefresh(); + }); + await act(async () => { + vi.advanceTimersByTime(100); + }); + + expect(policyValue("tool-1", "input")).toBe("trusted"); + }); + + it("should leave the row untouched and report the failure when the patch is rejected", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockRejectedValue(new Error("nope")); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + await waitFor(() => expect(fromBackend).toHaveBeenCalledWith("Failed to update input policy: nope")); + expect(policyValue("tool-1", "input")).toBe("untrusted"); + }); + + it("should disable only the one cell that is saving", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + updateToolPolicy.mockReturnValue(new Promise(() => {})); + renderPanel(); + await waitForRows(); + + await chooseOption(user, policySelect("tool-1", "input"), "trusted"); + + await waitFor(() => expect(isSaving("tool-1", "input")).toBe(true)); + expect(isSaving("tool-1", "output")).toBe(false); + expect(isSaving("tool-2", "input")).toBe(false); + }); +}); + +describe("ToolPoliciesPanel header chrome", () => { + it("should summarise the loaded tools in the metric cards", async () => { + renderPanel(); + await waitForRows(); + + const metric = (label: string): HTMLElement => { + const card = screen.getByText(label).closest("div.h-full"); + if (card === null) throw new Error(`metric ${label} missing`); + return card as HTMLElement; + }; + + expect(metric("Total Tools Discovered")).toHaveTextContent("3"); + expect(metric("Blocked Tools")).toHaveTextContent("1"); + expect(metric("Active Teams")).toHaveTextContent("2"); + expect(metric("New Today")).toHaveTextContent("1"); + }); + + it("should list only today's untrusted tools for review and scroll to the row", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + renderPanel(); + await waitForRows(); + + const banner = screen.getByText("Needs Review").closest("div"); + if (banner === null) throw new Error("needs review banner missing"); + expect(banner).toHaveTextContent("1 new tool discovered"); + expect(within(banner as HTMLElement).queryByText("delete_file")).not.toBeInTheDocument(); + + await user.click(within(banner as HTMLElement).getByRole("button", { name: "Review" })); + + expect(row("tool-1").scrollIntoView).toHaveBeenCalled(); + }); + + it("should hide the review banner when nothing needs a decision", async () => { + fetchToolsList.mockResolvedValue([{ ...TOOLS[0], input_policy: "trusted" }]); + renderPanel(); + await waitForRows(); + + expect(screen.queryByText("Needs Review")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx new file mode 100644 index 00000000000..1b559352469 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; +import React, { useCallback, useMemo, useState } from "react"; + +import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { fetchToolsList, ToolRow, updateToolPolicy } from "@/components/networking"; + +import { ToolPoliciesTable } from "./ToolPoliciesTable"; + +function getUTCDateKey(date: Date): string { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`; +} + +function isCreatedInUTCDay(createdAt: string | undefined, utcDateKey: string): boolean { + if (!createdAt) return false; + try { + return getUTCDateKey(new Date(createdAt)) === utcDateKey; + } catch { + return false; + } +} + +function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number { + return tools.filter((tool) => isCreatedInUTCDay(tool.created_at, utcDateKey)).length; +} + +function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined { + const diff = newToday - newYesterday; + if (diff === 0) return undefined; + return diff > 0 ? `+${diff} since yesterday` : `${diff} since yesterday`; +} + +function toMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +const withTool = (names: ReadonlySet, toolName: string): ReadonlySet => new Set([...names, toolName]); + +const withoutTool = (names: ReadonlySet, toolName: string): ReadonlySet => + new Set([...names].filter((name) => name !== toolName)); + +const TOOLS_QUERY_KEY = "tool-policies"; + +interface ToolPoliciesPanelProps { + accessToken: string | null; + onSelectTool: (toolName: string) => void; +} + +export const ToolPoliciesPanel: React.FC = ({ accessToken, onSelectTool }) => { + const queryClient = useQueryClient(); + const [savingInput, setSavingInput] = useState>(() => new Set()); + const [savingOutput, setSavingOutput] = useState>(() => new Set()); + + const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]); + + const queryOptions: UseQueryOptions = { + queryKey, + queryFn: async () => (accessToken === null ? [] : fetchToolsList(accessToken)), + enabled: accessToken !== null, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }; + const query = useQuery(queryOptions); + + const tools = useMemo(() => query.data ?? [], [query.data]); + + // Cancel first: a list fetch that started before this save would otherwise resolve afterwards + // and overwrite the row we just wrote with its pre-save snapshot. + const patchTool = useCallback( + async (toolName: string, patch: Partial) => { + await queryClient.cancelQueries({ queryKey }); + queryClient.setQueryData(queryKey, (previous) => + (previous ?? []).map((tool) => (tool.tool_name === toolName ? { ...tool, ...patch } : tool)), + ); + }, + [queryClient, queryKey], + ); + + const handleInputPolicyChange = useCallback( + async (toolName: string, newPolicy: string) => { + if (accessToken === null) return; + setSavingInput((previous) => withTool(previous, toolName)); + try { + await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy }); + await patchTool(toolName, { input_policy: newPolicy }); + } catch (e) { + NotificationsManager.fromBackend(`Failed to update input policy: ${toMessage(e, "unknown error")}`); + } finally { + setSavingInput((previous) => withoutTool(previous, toolName)); + } + }, + [accessToken, patchTool], + ); + + const handleOutputPolicyChange = useCallback( + async (toolName: string, newPolicy: string) => { + if (accessToken === null) return; + setSavingOutput((previous) => withTool(previous, toolName)); + try { + await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy }); + await patchTool(toolName, { output_policy: newPolicy }); + } catch (e) { + NotificationsManager.fromBackend(`Failed to update output policy: ${toMessage(e, "unknown error")}`); + } finally { + setSavingOutput((previous) => withoutTool(previous, toolName)); + } + }, + [accessToken, patchTool], + ); + + const { newToday, trendSubtitle, totalTools, blockedCount, activeTeamsCount, needsReviewTools } = useMemo(() => { + const now = new Date(); + const todayKey = getUTCDateKey(now); + const yesterday = new Date(now); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const today = countToolsInUTCDay(tools, todayKey); + + return { + newToday: today, + trendSubtitle: getTrendSubtitle(today, countToolsInUTCDay(tools, getUTCDateKey(yesterday))), + totalTools: tools.length, + blockedCount: tools.filter((tool) => tool.input_policy === "blocked").length, + activeTeamsCount: new Set(tools.map((tool) => tool.team_id).filter(Boolean)).size, + needsReviewTools: tools.filter( + (tool) => isCreatedInUTCDay(tool.created_at, todayKey) && tool.input_policy === "untrusted", + ), + }; + }, [tools]); + + const scrollToToolRow = (toolId: string) => { + document.querySelector(`[data-row-id="${CSS.escape(toolId)}"]`)?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }; + + return ( +
+

Tool Policies

+ +
+ + + + } + /> + + 0 ? "text-red-600" : undefined} + /> + 0 ? activeTeamsCount : "—"} /> +
+ + {needsReviewTools.length > 0 && ( +
+

Needs Review

+

+ {needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy + decisions. +

+
+ {needsReviewTools.map((tool) => ( + + + {tool.tool_name} + + + + ))} +
+
+ )} + + {query.isError && ( +
+ {toMessage(query.error, "Failed to load tools")} +
+ )} + + void query.refetch()} + onSelectTool={onSelectTool} + savingInput={savingInput} + savingOutput={savingOutput} + onInputPolicyChange={handleInputPolicyChange} + onOutputPolicyChange={handleOutputPolicyChange} + /> +
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx new file mode 100644 index 00000000000..9d32ad667bd --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.test.tsx @@ -0,0 +1,189 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders } from "../../../tests/test-utils"; +import type { ToolRow } from "@/components/networking"; +import { ToolPoliciesTable } from "./ToolPoliciesTable"; + +const TOOLS: ToolRow[] = [ + { + tool_id: "tool-1", + tool_name: "get_weather", + input_policy: "untrusted", + output_policy: "untrusted", + call_count: 12, + team_id: "team-alpha", + key_alias: "prod-key", + key_hash: "hash-aaa", + user_agent: "curl/8.7.1", + created_at: "2026-07-21T10:00:00Z", + }, + { + tool_id: "tool-2", + tool_name: "search_web", + input_policy: "trusted", + output_policy: "trusted", + call_count: 5, + team_id: "team-beta", + key_alias: "dev-key", + key_hash: "hash-bbb", + created_at: "2026-07-20T10:00:00Z", + }, + { + tool_id: "tool-3", + tool_name: "delete_file", + input_policy: "blocked", + output_policy: "untrusted", + call_count: 100, + key_hash: "hash-ccc", + created_at: "2026-07-19T10:00:00Z", + }, +]; + +const renderTable = (overrides: Partial> = {}) => { + const props = { + data: TOOLS, + isLoading: false, + isRefreshing: false, + onRefresh: vi.fn(), + onSelectTool: vi.fn(), + savingInput: new Set(), + savingOutput: new Set(), + onInputPolicyChange: vi.fn(), + onOutputPolicyChange: vi.fn(), + ...overrides, + }; + renderWithProviders(); + return props; +}; + +const rowIds = (): (string | null)[] => + Array.from(document.querySelectorAll("tbody tr[data-row-id]")).map((row) => row.getAttribute("data-row-id")); + +const pickFilter = async ( + user: ReturnType, + triggerTestId: string, + optionLabel: string, +): Promise => { + await user.click(screen.getByTestId(triggerTestId)); + await user.click(await screen.findByRole("option", { name: optionLabel })); +}; + +describe("ToolPoliciesTable sorting", () => { + it("should default to newest discovered first", () => { + renderTable(); + + expect(rowIds()).toEqual(["tool-1", "tool-2", "tool-3"]); + }); + + it("should sort by tool name when its header is used", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("sort-header-tool_name")); + + expect(rowIds()).toEqual(["tool-3", "tool-1", "tool-2"]); + }); +}); + +describe("ToolPoliciesTable search", () => { + it("should match on tool name", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "weather"); + + await waitFor(() => expect(rowIds()).toEqual(["tool-1"])); + }); + + it("should match on key hash", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "hash-bbb"); + + await waitFor(() => expect(rowIds()).toEqual(["tool-2"])); + }); + + it("should not match on user agent", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.type(screen.getByTestId("datatable-search"), "curl"); + + await waitFor(() => expect(rowIds()).toEqual([])); + expect(screen.getByText("No matching tools")).toBeInTheDocument(); + }); +}); + +describe("ToolPoliciesTable filters", () => { + it("should match an input policy exactly rather than as a substring", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await pickFilter(user, "filter-input-policy", "trusted"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(rowIds()).toEqual(["tool-2"])); + }); + + it("should filter by team", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await pickFilter(user, "filter-team", "team-alpha"); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(rowIds()).toEqual(["tool-1"])); + expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Team Name:"); + }); + + it("should offer only the teams and keys present in the loaded rows", async () => { + const user = userEvent.setup(); + renderTable(); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByTestId("filter-team")); + + const teams = (await screen.findAllByRole("option")).map((option) => option.textContent); + expect(teams).toEqual(["All Teams", "team-alpha", "team-beta"]); + }); +}); + +describe("ToolPoliciesTable chrome", () => { + it("should open the detail view from the tool name cell", async () => { + const user = userEvent.setup(); + const { onSelectTool } = renderTable(); + + await user.click(screen.getByRole("button", { name: /get_weather/ })); + + expect(onSelectTool).toHaveBeenCalledWith("get_weather"); + }); + + it("should refresh on demand", async () => { + const user = userEvent.setup(); + const { onRefresh } = renderTable(); + + await user.click(screen.getByTestId("datatable-refresh")); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it("should explain how discovery works when there are no tools at all", () => { + renderTable({ data: [] }); + + expect(screen.getByText("No tools discovered")).toBeInTheDocument(); + expect(screen.getByText(/tool_calls to start auto-discovery/)).toBeInTheDocument(); + }); + + it("should show skeleton rows while the first load is in flight", () => { + renderTable({ data: [], isLoading: true }); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("No tools discovered")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx new file mode 100644 index 00000000000..bbffb0d5ff5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTable.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { ColumnFiltersState } from "@tanstack/react-table"; +import { Wrench } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ToolRow } from "@/components/networking"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +import { INPUT_POLICY_OPTIONS, OUTPUT_POLICY_OPTIONS } from "./PolicySelect"; +import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns"; + +const ALL_VALUE = "all"; + +const toFilterValue = (value: string | null): string | undefined => + value === null || value === ALL_VALUE ? undefined : value; + +interface ToolPoliciesTableProps { + data: ToolRow[]; + isLoading: boolean; + isRefreshing: boolean; + onRefresh: () => void; + onSelectTool: (toolName: string) => void; + savingInput: ReadonlySet; + savingOutput: ReadonlySet; + onInputPolicyChange: (toolName: string, policy: string) => void; + onOutputPolicyChange: (toolName: string, policy: string) => void; +} + +function ToolPoliciesEmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
+ +
+
+ {filtered ? "No matching tools" : "No tools discovered"} +
+
+ {filtered + ? "No tools match your search or filters." + : "Make a chat completion that returns tool_calls to start auto-discovery."} +
+
+ ); +} + +function uniqueValues(rows: ToolRow[], pick: (row: ToolRow) => string | undefined): string[] { + return Array.from(new Set(rows.map(pick).filter((value): value is string => Boolean(value)))); +} + +export function ToolPoliciesTable({ + data, + isLoading, + isRefreshing, + onRefresh, + onSelectTool, + savingInput, + savingOutput, + onInputPolicyChange, + onOutputPolicyChange, +}: ToolPoliciesTableProps) { + const [globalFilter, setGlobalFilter] = useState(""); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const deps = { onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange }; + return getToolPoliciesTableColumns(deps); + }, [onSelectTool, savingInput, savingOutput, onInputPolicyChange, onOutputPolicyChange]); + + const teamOptions = useMemo(() => uniqueValues(data, (row) => row.team_id), [data]); + const keyAliasOptions = useMemo(() => uniqueValues(data, (row) => row.key_alias), [data]); + + return ( + row.tool_id} + sortingMode="client" + defaultSorting={[{ id: "created_at", desc: true }]} + paginationMode="client" + pageSizeOptions={[50, 100]} + filterMode="client" + columnFilters={columnFilters} + onColumnFiltersChange={setColumnFilters} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + isLoading={isLoading} + loadingMessage="Loading tools…" + noDataMessage={ 0 || globalFilter !== ""} />} + size="compact" + toolbar={(table) => ( + <> + setFiltersOpen(true)} + showViewOptions={false} + /> + + {({ get, set }) => ( + <> + + + + + + + + + + + + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx new file mode 100644 index 00000000000..29a4708a470 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesTableColumns.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Tooltip } from "antd"; + +import { ToolRow } from "@/components/networking"; +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells"; + +import { PolicySelect } from "./PolicySelect"; + +interface ToolPoliciesTableColumnsDeps { + onSelectTool: (toolName: string) => void; + savingInput: ReadonlySet; + savingOutput: ReadonlySet; + onInputPolicyChange: (toolName: string, policy: string) => void; + onOutputPolicyChange: (toolName: string, policy: string) => void; +} + +function TruncatedText({ value, className }: { value: string | undefined; className?: string }) { + const text = value ?? "-"; + return ( + + {text} + + ); +} + +export const getToolPoliciesTableColumns = ({ + onSelectTool, + savingInput, + savingOutput, + onInputPolicyChange, + onOutputPolicyChange, +}: ToolPoliciesTableColumnsDeps): ColumnDef[] => [ + { + id: "created_at", + accessorFn: (row) => row.created_at ?? "", + header: ({ column }) => , + size: 170, + enableGlobalFilter: false, + cell: ({ row }) => , + }, + { + id: "tool_name", + accessorFn: (row) => row.tool_name, + header: ({ column }) => , + minSize: 200, + cell: ({ row }) => ( + onSelectTool(row.original.tool_name)} + /> + ), + }, + { + id: "input_policy", + accessorFn: (row) => row.input_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Input Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "output_policy", + accessorFn: (row) => row.output_policy, + header: ({ column }) => , + size: 140, + filterFn: "equalsString", + meta: { title: "Output Policy", skeleton: "badge" }, + cell: ({ row }) => ( + + ), + }, + { + id: "call_count", + accessorFn: (row) => row.call_count ?? 0, + header: ({ column }) => , + size: 100, + enableGlobalFilter: false, + meta: { numeric: true }, + cell: ({ row }) => {(row.original.call_count ?? 0).toLocaleString()}, + }, + { + id: "team_id", + accessorFn: (row) => row.team_id ?? "", + header: ({ column }) => , + size: 160, + filterFn: "equalsString", + meta: { title: "Team Name" }, + cell: ({ row }) => , + }, + { + id: "key_hash", + accessorFn: (row) => row.key_hash ?? "", + header: "Key Hash", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "key_alias", + accessorFn: (row) => row.key_alias ?? "", + header: ({ column }) => , + size: 150, + filterFn: "equalsString", + meta: { title: "Key Name" }, + cell: ({ row }) => , + }, + { + id: "user_agent", + accessorFn: (row) => row.user_agent ?? "", + header: "User Agent", + size: 180, + enableSorting: false, + enableGlobalFilter: false, + cell: ({ row }) => ( + + ), + }, +]; diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx index 8b2b1d0e4b7..34c697a98d1 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -14,25 +14,27 @@ vi.mock("@/components/ToolDetail", () => ({ ), })); -vi.mock("@/components/ToolPolicies", () => ({ - ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => ( -
- Tool Policies Overview - -
- ), +vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({ + ToolPoliciesPanel: function ToolPoliciesPanelMock({ onSelectTool }: { onSelectTool: (name: string) => void }) { + return ( +
+ Tool Policies Overview + +
+ ); + }, })); describe("ToolPoliciesView", () => { it("should render the overview by default", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); }); it("should navigate to tool detail when a tool is selected", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /select tool/i })); @@ -42,7 +44,7 @@ describe("ToolPoliciesView", () => { it("should navigate back to overview when back is clicked", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); await user.click(screen.getByRole("button", { name: /select tool/i })); await user.click(screen.getByRole("button", { name: /back/i })); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index 31ea7c7f956..bdff40153b9 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -2,16 +2,15 @@ import React, { useState } from "react"; import { ToolDetail } from "@/components/ToolDetail"; -import { ToolPolicies } from "@/components/ToolPolicies"; +import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel"; type View = { type: "overview" } | { type: "detail"; toolName: string }; interface ToolPoliciesViewProps { accessToken: string | null; - userRole?: string; } -export default function ToolPoliciesView({ accessToken, userRole }: ToolPoliciesViewProps) { +export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) { const [view, setView] = useState({ type: "overview" }); const handleSelectTool = (toolName: string) => { @@ -27,7 +26,7 @@ export default function ToolPoliciesView({ accessToken, userRole }: ToolPolicies {view.type === "detail" ? ( ) : ( - + )} );