mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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"
This commit is contained in:
parent
20a4666ec6
commit
dfbd098d65
10 changed files with 1079 additions and 586 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <ToolPoliciesView accessToken={accessToken} userRole={userRole} />;
|
||||
const { accessToken } = useAuthorized();
|
||||
return <ToolPoliciesView accessToken={accessToken} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolPoliciesProps> = ({ accessToken, onSelectTool }) => {
|
||||
const [tools, setTools] = useState<ToolRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingInput, setSavingInput] = useState<string | null>(null);
|
||||
const [savingOutput, setSavingOutput] = useState<string | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLiveTail, setIsLiveTail] = useState(true);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterValues>({});
|
||||
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 }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{label}</span>
|
||||
<TableHeaderSortDropdown
|
||||
sortState={sortField === field ? sortOrder : false}
|
||||
onSortChange={(s) => handleSortChange(field, s)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
label="New Today"
|
||||
value={newToday}
|
||||
valueColor="text-green-600"
|
||||
subtitle={trendSubtitle}
|
||||
icon={
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard label="Total Tools Discovered" value={totalTools} />
|
||||
<MetricCard
|
||||
label="Blocked Tools"
|
||||
value={blockedCount}
|
||||
valueColor={blockedCount > 0 ? "text-red-600" : undefined}
|
||||
/>
|
||||
<MetricCard label="Active Teams" value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
|
||||
</div>
|
||||
|
||||
{needsReviewTools.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-amber-900 mb-1">Needs Review</h2>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
{needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy
|
||||
decisions.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{needsReviewTools.map((t) => (
|
||||
<span
|
||||
key={t.tool_id}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-amber-200 rounded-md text-sm"
|
||||
>
|
||||
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={t.tool_name}>
|
||||
{t.tool_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToToolRow(t.tool_id)}
|
||||
className="text-amber-700 hover:text-amber-900 font-medium text-xs whitespace-nowrap"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full box-border">
|
||||
<div className="border-b px-6 py-4 w-full max-w-full box-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0 w-full max-w-full box-border">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Tool Name"
|
||||
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">Live Tail</span>
|
||||
<Switch checked={isLiveTail} onChange={setIsLiveTail} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={isButtonLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-60"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${isButtonLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{isButtonLoading ? "Fetching" : "Fetch"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 whitespace-nowrap">
|
||||
<span>
|
||||
Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "}
|
||||
{Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results
|
||||
</span>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<FilterComponent
|
||||
options={filterOptions}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onResetFilters={handleResetFilters}
|
||||
buttonLabel="Filters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLiveTail && (
|
||||
<div className="bg-green-50 border-b border-green-100 px-6 py-2 flex items-center justify-between">
|
||||
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
|
||||
<button onClick={() => setIsLiveTail(false)} className="text-xs text-green-600 underline">
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-sm text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1 w-full">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Discovered" field="created_at" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Tool Name" field="tool_name" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Input Policy" field="input_policy" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Output Policy" field="output_policy" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="# Calls" field="call_count" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Team Name" field="team_id" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">Key Hash</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">
|
||||
<SortHeader label="Key Name" field="key_alias" />
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="py-1 h-8">User Agent</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
|
||||
Loading tools…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginated.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
|
||||
No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginated.map((tool) => (
|
||||
<TableRow key={tool.tool_id} id={`tool-row-${tool.tool_id}`} className="h-8 hover:bg-gray-50">
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<DateCell value={tool.created_at} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectTool?.(tool.tool_name)}
|
||||
className="text-left w-full font-mono text-xs max-w-[20ch] truncate block font-medium text-blue-600 hover:text-blue-800 hover:underline focus:outline-hidden focus:ring-0"
|
||||
>
|
||||
<Tooltip title={onSelectTool ? "Click to view details and block for team/key" : tool.tool_name}>
|
||||
<span>{tool.tool_name}</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<PolicySelect
|
||||
value={tool.input_policy}
|
||||
toolName={tool.tool_name}
|
||||
saving={savingInput === tool.tool_name}
|
||||
onChange={handleInputPolicyChange}
|
||||
policyType="input"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<PolicySelect
|
||||
value={tool.output_policy}
|
||||
toolName={tool.tool_name}
|
||||
saving={savingOutput === tool.tool_name}
|
||||
onChange={handleOutputPolicyChange}
|
||||
policyType="output"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8">
|
||||
<div className="flex items-center justify-end h-8 tabular-nums text-sm font-mono text-gray-700">
|
||||
{(tool.call_count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<IdCell value={tool.team_id} variant="plain" />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<IdCell value={tool.key_hash} />
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.key_alias ?? "-"}>
|
||||
<span className="max-w-[15ch] truncate block">{tool.key_alias ?? "-"}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 overflow-hidden whitespace-nowrap">
|
||||
<Tooltip title={tool.user_agent ?? "-"}>
|
||||
<span className="font-mono max-w-[20ch] truncate block text-xs text-gray-500">
|
||||
{tool.user_agent ?? "-"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t px-6 py-3 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>
|
||||
Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "}
|
||||
{sorted.length}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolPolicies;
|
||||
|
|
@ -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<typeof userEvent.setup>, 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(<ToolPoliciesPanel accessToken="sk-token" onSelectTool={onSelectTool} />);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<ToolPoliciesPanel accessToken="sk-token" onSelectTool={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(<ToolPoliciesPanel accessToken={null} onSelectTool={vi.fn()} />);
|
||||
|
||||
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<void>((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<ToolRow[]>((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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>, toolName: string): ReadonlySet<string> => new Set([...names, toolName]);
|
||||
|
||||
const withoutTool = (names: ReadonlySet<string>, toolName: string): ReadonlySet<string> =>
|
||||
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<ToolPoliciesPanelProps> = ({ accessToken, onSelectTool }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [savingInput, setSavingInput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [savingOutput, setSavingOutput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]);
|
||||
|
||||
const queryOptions: UseQueryOptions<ToolRow[]> = {
|
||||
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<ToolRow>) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
queryClient.setQueryData<ToolRow[]>(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 (
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<MetricCard
|
||||
label="New Today"
|
||||
value={newToday}
|
||||
valueColor="text-green-600"
|
||||
subtitle={trendSubtitle}
|
||||
icon={
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<MetricCard label="Total Tools Discovered" value={totalTools} />
|
||||
<MetricCard
|
||||
label="Blocked Tools"
|
||||
value={blockedCount}
|
||||
valueColor={blockedCount > 0 ? "text-red-600" : undefined}
|
||||
/>
|
||||
<MetricCard label="Active Teams" value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
|
||||
</div>
|
||||
|
||||
{needsReviewTools.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-sm font-semibold text-amber-900 mb-1">Needs Review</h2>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
{needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy
|
||||
decisions.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{needsReviewTools.map((tool) => (
|
||||
<span
|
||||
key={tool.tool_id}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-amber-200 rounded-md text-sm"
|
||||
>
|
||||
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={tool.tool_name}>
|
||||
{tool.tool_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToToolRow(tool.tool_id)}
|
||||
className="text-amber-700 hover:text-amber-900 font-medium text-xs whitespace-nowrap"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-sm text-sm text-red-700" role="alert">
|
||||
{toMessage(query.error, "Failed to load tools")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolPoliciesTable
|
||||
data={tools}
|
||||
isLoading={query.isLoading}
|
||||
isRefreshing={query.isFetching}
|
||||
onRefresh={() => void query.refetch()}
|
||||
onSelectTool={onSelectTool}
|
||||
savingInput={savingInput}
|
||||
savingOutput={savingOutput}
|
||||
onInputPolicyChange={handleInputPolicyChange}
|
||||
onOutputPolicyChange={handleOutputPolicyChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<React.ComponentProps<typeof ToolPoliciesTable>> = {}) => {
|
||||
const props = {
|
||||
data: TOOLS,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
onRefresh: vi.fn(),
|
||||
onSelectTool: vi.fn(),
|
||||
savingInput: new Set<string>(),
|
||||
savingOutput: new Set<string>(),
|
||||
onInputPolicyChange: vi.fn(),
|
||||
onOutputPolicyChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
renderWithProviders(<ToolPoliciesTable {...props} />);
|
||||
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<typeof userEvent.setup>,
|
||||
triggerTestId: string,
|
||||
optionLabel: string,
|
||||
): Promise<void> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>;
|
||||
savingOutput: ReadonlySet<string>;
|
||||
onInputPolicyChange: (toolName: string, policy: string) => void;
|
||||
onOutputPolicyChange: (toolName: string, policy: string) => void;
|
||||
}
|
||||
|
||||
function ToolPoliciesEmptyState({ filtered }: { filtered: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Wrench className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{filtered ? "No matching tools" : "No tools discovered"}
|
||||
</div>
|
||||
<div className="max-w-xs text-center text-sm text-muted-foreground">
|
||||
{filtered
|
||||
? "No tools match your search or filters."
|
||||
: "Make a chat completion that returns tool_calls to start auto-discovery."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ColumnFiltersState>([]);
|
||||
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 (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => 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={<ToolPoliciesEmptyState filtered={columnFilters.length > 0 || globalFilter !== ""} />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
<>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Search by Tool Name"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
showViewOptions={false}
|
||||
/>
|
||||
<DataTableFilterDrawer
|
||||
table={table}
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
title="Filters"
|
||||
description="Narrow down discovered tools"
|
||||
>
|
||||
{({ get, set }) => (
|
||||
<>
|
||||
<DataTableFilterField label="Input Policy">
|
||||
<Select
|
||||
value={(get("input_policy") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("input_policy", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-input-policy">
|
||||
<SelectValue placeholder="All Input Policies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Input Policies</SelectItem>
|
||||
{INPUT_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Output Policy">
|
||||
<Select
|
||||
value={(get("output_policy") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("output_policy", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-output-policy">
|
||||
<SelectValue placeholder="All Output Policies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Output Policies</SelectItem>
|
||||
{OUTPUT_POLICY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Team Name">
|
||||
<Select
|
||||
value={(get("team_id") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("team_id", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-team">
|
||||
<SelectValue placeholder="All Teams" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Teams</SelectItem>
|
||||
{teamOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key Name">
|
||||
<Select
|
||||
value={(get("key_alias") as string) ?? ALL_VALUE}
|
||||
onValueChange={(value) => set("key_alias", toFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-full" data-testid="filter-key-alias">
|
||||
<SelectValue placeholder="All Keys" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>All Keys</SelectItem>
|
||||
{keyAliasOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DataTableFilterField>
|
||||
</>
|
||||
)}
|
||||
</DataTableFilterDrawer>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string>;
|
||||
savingOutput: ReadonlySet<string>;
|
||||
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 (
|
||||
<Tooltip title={text}>
|
||||
<span className={className}>{text}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const getToolPoliciesTableColumns = ({
|
||||
onSelectTool,
|
||||
savingInput,
|
||||
savingOutput,
|
||||
onInputPolicyChange,
|
||||
onOutputPolicyChange,
|
||||
}: ToolPoliciesTableColumnsDeps): ColumnDef<ToolRow>[] => [
|
||||
{
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Discovered" />,
|
||||
size: 170,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "tool_name",
|
||||
accessorFn: (row) => row.tool_name,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Tool Name" />,
|
||||
minSize: 200,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.tool_name}
|
||||
titleClassName="font-mono text-xs font-normal text-primary"
|
||||
className="max-w-60"
|
||||
onClick={() => onSelectTool(row.original.tool_name)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "input_policy",
|
||||
accessorFn: (row) => row.input_policy,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Input Policy" />,
|
||||
size: 140,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Input Policy", skeleton: "badge" },
|
||||
cell: ({ row }) => (
|
||||
<PolicySelect
|
||||
value={row.original.input_policy}
|
||||
toolName={row.original.tool_name}
|
||||
saving={savingInput.has(row.original.tool_name)}
|
||||
onChange={onInputPolicyChange}
|
||||
policyType="input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "output_policy",
|
||||
accessorFn: (row) => row.output_policy,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Output Policy" />,
|
||||
size: 140,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Output Policy", skeleton: "badge" },
|
||||
cell: ({ row }) => (
|
||||
<PolicySelect
|
||||
value={row.original.output_policy}
|
||||
toolName={row.original.tool_name}
|
||||
saving={savingOutput.has(row.original.tool_name)}
|
||||
onChange={onOutputPolicyChange}
|
||||
policyType="output"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "call_count",
|
||||
accessorFn: (row) => row.call_count ?? 0,
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="# Calls" />,
|
||||
size: 100,
|
||||
enableGlobalFilter: false,
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <span className="font-mono">{(row.original.call_count ?? 0).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorFn: (row) => row.team_id ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Team Name" />,
|
||||
size: 160,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Team Name" },
|
||||
cell: ({ row }) => <IdCell value={row.original.team_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "key_hash",
|
||||
accessorFn: (row) => row.key_hash ?? "",
|
||||
header: "Key Hash",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.key_hash} />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorFn: (row) => row.key_alias ?? "",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Key Name" />,
|
||||
size: 150,
|
||||
filterFn: "equalsString",
|
||||
meta: { title: "Key Name" },
|
||||
cell: ({ row }) => <TruncatedText value={row.original.key_alias} className="block max-w-32 truncate" />,
|
||||
},
|
||||
{
|
||||
id: "user_agent",
|
||||
accessorFn: (row) => row.user_agent ?? "",
|
||||
header: "User Agent",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => (
|
||||
<TruncatedText
|
||||
value={row.original.user_agent}
|
||||
className="block max-w-40 truncate font-mono text-muted-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -14,25 +14,27 @@ vi.mock("@/components/ToolDetail", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ToolPolicies", () => ({
|
||||
ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => (
|
||||
<div>
|
||||
<span>Tool Policies Overview</span>
|
||||
<button onClick={() => onSelectTool("my-tool")}>Select Tool</button>
|
||||
</div>
|
||||
),
|
||||
vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({
|
||||
ToolPoliciesPanel: function ToolPoliciesPanelMock({ onSelectTool }: { onSelectTool: (name: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<span>Tool Policies Overview</span>
|
||||
<button onClick={() => onSelectTool("my-tool")}>Select Tool</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ToolPoliciesView", () => {
|
||||
it("should render the overview by default", () => {
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to tool detail when a tool is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
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(<ToolPoliciesView accessToken="token" userRole="Admin" />);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /select tool/i }));
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
|
|
|
|||
|
|
@ -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<View>({ type: "overview" });
|
||||
|
||||
const handleSelectTool = (toolName: string) => {
|
||||
|
|
@ -27,7 +26,7 @@ export default function ToolPoliciesView({ accessToken, userRole }: ToolPolicies
|
|||
{view.type === "detail" ? (
|
||||
<ToolDetail toolName={view.toolName} onBack={handleBack} accessToken={accessToken} />
|
||||
) : (
|
||||
<ToolPolicies accessToken={accessToken} userRole={userRole} onSelectTool={handleSelectTool} />
|
||||
<ToolPoliciesPanel accessToken={accessToken} onSelectTool={handleSelectTool} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue