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" ? ( ) : ( - + )} );