mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor: more ui improvements
This commit is contained in:
parent
fffe8253ea
commit
41d24b0c91
8 changed files with 467 additions and 324 deletions
|
|
@ -390,6 +390,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
config Json @default("{}")
|
||||
user_id String?
|
||||
team_id String?
|
||||
agent_id String?
|
||||
project_id String?
|
||||
permissions Json @default("{}")
|
||||
max_parallel_requests Int?
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
config Json @default("{}")
|
||||
user_id String?
|
||||
team_id String?
|
||||
agent_id String?
|
||||
project_id String?
|
||||
permissions Json @default("{}")
|
||||
max_parallel_requests Int?
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
config Json @default("{}")
|
||||
user_id String?
|
||||
team_id String?
|
||||
agent_id String?
|
||||
project_id String?
|
||||
permissions Json @default("{}")
|
||||
max_parallel_requests Int?
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import Usage from "@/components/usage";
|
|||
import UserDashboard from "@/components/user_dashboard";
|
||||
import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
|
||||
import VectorStoreManagement from "@/components/vector_store_management";
|
||||
import ToolPolicies from "@/components/ToolPolicies";
|
||||
import ToolPoliciesView from "@/components/ToolPoliciesView";
|
||||
import SpendLogsTable from "@/components/view_logs";
|
||||
import ViewUserDashboard from "@/components/view_users";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
|
@ -550,7 +550,7 @@ function CreateKeyPageContent() {
|
|||
) : page == "vector-stores" ? (
|
||||
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
) : page == "tool-policies" ? (
|
||||
<ToolPolicies accessToken={accessToken} userRole={userRole} />
|
||||
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "guardrails-monitor" ? (
|
||||
<GuardrailsMonitorView accessToken={accessToken} />
|
||||
) : page == "new_usage" ? (
|
||||
|
|
|
|||
317
ui/litellm-dashboard/src/components/ToolDetail.tsx
Normal file
317
ui/litellm-dashboard/src/components/ToolDetail.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeftOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Select, Spin } from "antd";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import { PolicySelect } from "@/components/ToolPolicies/PolicySelect";
|
||||
import {
|
||||
deleteToolPolicyOverride,
|
||||
fetchToolDetail,
|
||||
keyListCall,
|
||||
teamListCall,
|
||||
updateToolPolicy,
|
||||
type ToolPolicyOverrideRow,
|
||||
} from "@/components/networking";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
|
||||
interface ToolDetailProps {
|
||||
toolName: string;
|
||||
onBack: () => void;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
interface TeamOption {
|
||||
team_id: string;
|
||||
team_alias?: string;
|
||||
}
|
||||
|
||||
interface KeyOption {
|
||||
token: string;
|
||||
key_alias?: string;
|
||||
}
|
||||
|
||||
const TOOL_DETAIL_QUERY_KEY = "tool-detail";
|
||||
|
||||
export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [overrideSaving, setOverrideSaving] = useState(false);
|
||||
const [policySaving, setPolicySaving] = useState(false);
|
||||
const [blockScope, setBlockScope] = useState<"team" | "key">("team");
|
||||
const [blockTeamId, setBlockTeamId] = useState<string | null>(null);
|
||||
const [blockKey, setBlockKey] = useState<KeyOption | null>(null);
|
||||
|
||||
const { data: detail, isLoading: detailLoading, error: detailError } = useQuery({
|
||||
queryKey: [TOOL_DETAIL_QUERY_KEY, toolName],
|
||||
queryFn: () => fetchToolDetail(accessToken!, toolName),
|
||||
enabled: !!accessToken && !!toolName,
|
||||
});
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ["teams-list-tool-detail"],
|
||||
queryFn: () => teamListCall(accessToken!, null, null),
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
|
||||
const { data: keysData } = useQuery({
|
||||
queryKey: ["keys-list-tool-detail"],
|
||||
queryFn: () => keyListCall(accessToken!, null, null, null, null, null, 1, 100),
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
|
||||
const teams: Team[] = useMemo(() => {
|
||||
const arr = Array.isArray(teamsData) ? teamsData : teamsData?.data ?? [];
|
||||
return arr.map((t: { team_id?: string; id?: string; team_alias?: string }) => ({
|
||||
team_id: t.team_id ?? t.id ?? "",
|
||||
team_alias: t.team_alias ?? t.team_id ?? "",
|
||||
models: [],
|
||||
max_budget: null,
|
||||
budget_duration: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
organization_id: "",
|
||||
created_at: "",
|
||||
keys: [],
|
||||
members_with_roles: [],
|
||||
spend: 0,
|
||||
}));
|
||||
}, [teamsData]);
|
||||
|
||||
const keys: KeyOption[] = useMemo(() => {
|
||||
const keysRes = keysData?.keys ?? keysData?.data ?? [];
|
||||
return keysRes.map((k: { token?: string; api_key?: string; key_hash?: string; key_alias?: string }) => ({
|
||||
token: k.token ?? k.api_key ?? k.key_hash ?? "",
|
||||
key_alias: k.key_alias ?? (k.token ?? k.api_key ?? k.key_hash)?.toString?.()?.substring?.(0, 8),
|
||||
}));
|
||||
}, [keysData]);
|
||||
|
||||
const invalidateDetail = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: [TOOL_DETAIL_QUERY_KEY, toolName] });
|
||||
}, [queryClient, toolName]);
|
||||
|
||||
const handlePolicyChange = useCallback(
|
||||
async (name: string, newPolicy: string) => {
|
||||
if (!accessToken) return;
|
||||
setPolicySaving(true);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, name, newPolicy);
|
||||
invalidateDetail();
|
||||
} catch (e: unknown) {
|
||||
alert(`Failed to update policy: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setPolicySaving(false);
|
||||
}
|
||||
},
|
||||
[accessToken, invalidateDetail]
|
||||
);
|
||||
|
||||
const handleAddOverride = useCallback(async () => {
|
||||
if (!accessToken || !toolName) return;
|
||||
const isTeam = blockScope === "team";
|
||||
if (isTeam && !blockTeamId) return;
|
||||
if (!isTeam && !blockKey?.token) return;
|
||||
setOverrideSaving(true);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, toolName, "blocked", {
|
||||
team_id: isTeam ? blockTeamId : undefined,
|
||||
key_hash: !isTeam ? blockKey!.token : undefined,
|
||||
key_alias: !isTeam ? blockKey!.key_alias : undefined,
|
||||
});
|
||||
invalidateDetail();
|
||||
setBlockTeamId(null);
|
||||
setBlockKey(null);
|
||||
} catch (e: unknown) {
|
||||
alert(`Failed to add override: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setOverrideSaving(false);
|
||||
}
|
||||
}, [accessToken, toolName, blockScope, blockTeamId, blockKey, invalidateDetail]);
|
||||
|
||||
const handleRemoveOverride = useCallback(
|
||||
async (override: ToolPolicyOverrideRow) => {
|
||||
if (!accessToken || !toolName) return;
|
||||
setOverrideSaving(true);
|
||||
try {
|
||||
await deleteToolPolicyOverride(accessToken, toolName, {
|
||||
team_id: override.team_id ?? undefined,
|
||||
key_hash: override.key_hash ?? undefined,
|
||||
});
|
||||
invalidateDetail();
|
||||
} catch (e: unknown) {
|
||||
alert(`Failed to remove override: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setOverrideSaving(false);
|
||||
}
|
||||
},
|
||||
[accessToken, toolName, invalidateDetail]
|
||||
);
|
||||
|
||||
if (detailLoading && !detail) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailError && !detail) {
|
||||
return (
|
||||
<div>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4">
|
||||
Back to Tool Policies
|
||||
</Button>
|
||||
<p className="text-red-600">Failed to load tool details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { tool, overrides } = detail;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="pl-0 mb-4"
|
||||
>
|
||||
Back to Tool Policies
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1 flex-wrap">
|
||||
<ToolOutlined className="text-xl text-gray-400" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 font-mono">{tool.tool_name}</h1>
|
||||
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-gray-100 text-gray-700 border border-gray-200">
|
||||
{tool.origin ?? "—"}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||
{(tool.call_count ?? 0).toLocaleString()} calls
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Global policy</h2>
|
||||
<PolicySelect
|
||||
value={tool.call_policy}
|
||||
toolName={tool.tool_name}
|
||||
saving={policySaving}
|
||||
onChange={handlePolicyChange}
|
||||
size="middle"
|
||||
minWidth={140}
|
||||
stopPropagation={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{overrides.length > 0 && (
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Blocked for team or key</h2>
|
||||
<ul className="border rounded-md divide-y divide-gray-100 bg-red-50/30">
|
||||
{overrides.map((ov) => (
|
||||
<li
|
||||
key={ov.override_id}
|
||||
className="flex items-center justify-between px-3 py-2.5 text-sm"
|
||||
>
|
||||
<span className="text-gray-700">
|
||||
{ov.team_id ? `Team: ${ov.team_id}` : ""}
|
||||
{ov.team_id && ov.key_hash ? " · " : ""}
|
||||
{ov.key_hash ? `Key: ${ov.key_alias || ov.key_hash.substring(0, 8)}` : ""}
|
||||
{!ov.team_id && !ov.key_hash ? "—" : ""}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
disabled={overrideSaving}
|
||||
onClick={() => handleRemoveOverride(ov)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3">Block for team or key</h2>
|
||||
<div className="flex flex-col gap-4 max-w-md">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block mb-2">Scope</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "team"}
|
||||
onChange={() => setBlockScope("team")}
|
||||
className="align-middle"
|
||||
/>
|
||||
Team
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "key"}
|
||||
onChange={() => setBlockScope("key")}
|
||||
className="align-middle"
|
||||
/>
|
||||
Key
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block mb-2">
|
||||
{blockScope === "team" ? "Team" : "Key"}
|
||||
</span>
|
||||
{blockScope === "team" ? (
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
value={blockTeamId ?? undefined}
|
||||
onChange={(id) => setBlockTeamId(id || null)}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
placeholder="Select key"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={blockKey ? blockKey.token : undefined}
|
||||
onChange={(token) => {
|
||||
const k = keys.find((x) => x.token === token);
|
||||
setBlockKey(k ?? null);
|
||||
}}
|
||||
options={keys.map((k) => ({
|
||||
value: k.token,
|
||||
label: k.key_alias || k.token?.substring?.(0, 12) || k.token,
|
||||
}))}
|
||||
className="w-full"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
disabled={overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)}
|
||||
loading={overrideSaving}
|
||||
onClick={handleAddOverride}
|
||||
>
|
||||
Block for {blockScope}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Modal, Select, Switch, Tooltip } from "antd";
|
||||
import { Button, Switch, Tooltip } from "antd";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
|
||||
import { TimeCell } from "./view_logs/time_cell";
|
||||
import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
|
||||
import FilterComponent, { FilterOption } from "./molecules/filter";
|
||||
import { MetricCard } from "./GuardrailsMonitor/MetricCard";
|
||||
import TeamDropdown from "./common_components/team_dropdown";
|
||||
import { PolicySelect, POLICY_OPTIONS } from "./ToolPolicies/PolicySelect";
|
||||
import {
|
||||
fetchToolDetail,
|
||||
fetchToolsList,
|
||||
updateToolPolicy,
|
||||
deleteToolPolicyOverride,
|
||||
ToolRow,
|
||||
ToolDetailResponse,
|
||||
ToolPolicyOverrideRow,
|
||||
} from "./networking";
|
||||
import { teamListCall, keyListCall } from "./networking";
|
||||
|
||||
// --- Date helpers (UTC) for "new tools" counts ---
|
||||
function getUTCDateKey(date: Date): string {
|
||||
|
|
@ -46,15 +41,6 @@ function getTrendSubtitle(newToday: number, newYesterday: number): string | unde
|
|||
return `${diff} since yesterday`;
|
||||
}
|
||||
|
||||
const POLICY_OPTIONS = [
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
|
||||
] as const;
|
||||
|
||||
type PolicyValue = "trusted" | "blocked";
|
||||
|
||||
const policyStyle = (p: string) => POLICY_OPTIONS.find((o) => o.value === p) ?? POLICY_OPTIONS[1];
|
||||
|
||||
type SortField = "tool_name" | "call_policy" | "team_id" | "key_alias" | "created_at" | "call_count";
|
||||
|
||||
interface FilterValues {
|
||||
|
|
@ -64,99 +50,16 @@ interface FilterValues {
|
|||
interface ToolPoliciesProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
onSelectTool?: (toolName: string) => void;
|
||||
}
|
||||
|
||||
const PolicySelect: React.FC<{
|
||||
value: string;
|
||||
toolName: string;
|
||||
saving: boolean;
|
||||
onChange: (toolName: string, policy: string) => void;
|
||||
}> = ({ value, toolName, saving, onChange }) => {
|
||||
const style = policyStyle(value);
|
||||
return (
|
||||
<Select
|
||||
size="small"
|
||||
value={value}
|
||||
disabled={saving}
|
||||
loading={saving}
|
||||
onChange={(v) => onChange(toolName, v)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
minWidth: 110,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
styles={{
|
||||
selector: {
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.color,
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 4,
|
||||
},
|
||||
}}
|
||||
popupMatchSelectWidth={false}
|
||||
options={POLICY_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: o.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: o.color,
|
||||
display: "inline-block",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{o.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface TeamOption {
|
||||
team_id: string;
|
||||
team_alias?: string;
|
||||
}
|
||||
|
||||
interface KeyOption {
|
||||
token: string;
|
||||
key_alias?: string;
|
||||
}
|
||||
|
||||
export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
||||
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 [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailToolName, setDetailToolName] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<ToolDetailResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [teams, setTeams] = useState<TeamOption[]>([]);
|
||||
const [keys, setKeys] = useState<KeyOption[]>([]);
|
||||
const [overrideSaving, setOverrideSaving] = useState(false);
|
||||
const [blockScope, setBlockScope] = useState<"team" | "key">("team");
|
||||
const [blockTeamId, setBlockTeamId] = useState<string | null>(null);
|
||||
const [blockKey, setBlockKey] = useState<{ token: string; key_alias?: string } | null>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
|
@ -199,9 +102,6 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
try {
|
||||
await updateToolPolicy(accessToken, toolName, newPolicy);
|
||||
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, call_policy: newPolicy } : t)));
|
||||
if (detailToolName === toolName && detail) {
|
||||
setDetail((d) => (d ? { ...d, tool: { ...d.tool, call_policy: newPolicy } } : null));
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(`Failed to update policy: ${e.message}`);
|
||||
} finally {
|
||||
|
|
@ -209,91 +109,6 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const openDetailModal = useCallback(
|
||||
async (toolName: string) => {
|
||||
if (!accessToken) return;
|
||||
setDetailToolName(toolName);
|
||||
setDetailModalOpen(true);
|
||||
setDetail(null);
|
||||
setDetailLoading(true);
|
||||
setBlockTeamId(null);
|
||||
setBlockKey(null);
|
||||
try {
|
||||
const [detailRes, teamsRes, keysRes] = await Promise.all([
|
||||
fetchToolDetail(accessToken, toolName),
|
||||
teamListCall(accessToken, null, null),
|
||||
keyListCall(accessToken, null, null, null, null, null, 1, 100),
|
||||
]);
|
||||
setDetail(detailRes);
|
||||
const teamsArray = Array.isArray(teamsRes) ? teamsRes : teamsRes?.data ?? [];
|
||||
setTeams(
|
||||
teamsArray.map((t: any) => ({ team_id: t.team_id ?? t.id, team_alias: t.team_alias ?? t.team_id }))
|
||||
);
|
||||
const keysArray = keysRes?.keys ?? keysRes?.data ?? [];
|
||||
setKeys(
|
||||
keysArray.map((k: any) => ({
|
||||
token: k.token ?? k.api_key ?? k.key_hash ?? "",
|
||||
key_alias: k.key_alias ?? k.token?.substring?.(0, 8),
|
||||
}))
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Failed to load tool detail");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[accessToken]
|
||||
);
|
||||
|
||||
const closeDetailModal = useCallback(() => {
|
||||
setDetailModalOpen(false);
|
||||
setDetailToolName(null);
|
||||
setDetail(null);
|
||||
}, []);
|
||||
|
||||
const handleAddOverride = useCallback(async () => {
|
||||
if (!accessToken || !detailToolName) return;
|
||||
const isTeam = blockScope === "team";
|
||||
if (isTeam && !blockTeamId) return;
|
||||
if (!isTeam && !blockKey?.token) return;
|
||||
setOverrideSaving(true);
|
||||
try {
|
||||
await updateToolPolicy(accessToken, detailToolName, "blocked", {
|
||||
team_id: isTeam ? blockTeamId! : undefined,
|
||||
key_hash: !isTeam ? blockKey!.token : undefined,
|
||||
key_alias: !isTeam ? blockKey!.key_alias : undefined,
|
||||
});
|
||||
const refreshed = await fetchToolDetail(accessToken, detailToolName);
|
||||
setDetail(refreshed);
|
||||
setBlockTeamId(null);
|
||||
setBlockKey(null);
|
||||
} catch (e: any) {
|
||||
alert(`Failed to add override: ${e.message}`);
|
||||
} finally {
|
||||
setOverrideSaving(false);
|
||||
}
|
||||
}, [accessToken, detailToolName, blockScope, blockTeamId, blockKey]);
|
||||
|
||||
const handleRemoveOverride = useCallback(
|
||||
async (override: ToolPolicyOverrideRow) => {
|
||||
if (!accessToken || !detailToolName) return;
|
||||
setOverrideSaving(true);
|
||||
try {
|
||||
await deleteToolPolicyOverride(accessToken, detailToolName, {
|
||||
team_id: override.team_id ?? undefined,
|
||||
key_hash: override.key_hash ?? undefined,
|
||||
});
|
||||
const refreshed = await fetchToolDetail(accessToken, detailToolName);
|
||||
setDetail(refreshed);
|
||||
} catch (e: any) {
|
||||
alert(`Failed to remove override: ${e.message}`);
|
||||
} finally {
|
||||
setOverrideSaving(false);
|
||||
}
|
||||
},
|
||||
[accessToken, detailToolName]
|
||||
);
|
||||
|
||||
const handleSortChange = (field: SortField, newState: SortState) => {
|
||||
if (newState === false) {
|
||||
setSortField("created_at");
|
||||
|
|
@ -431,7 +246,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
<div className="w-full">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
|
||||
|
||||
{/* Summary cards */}
|
||||
|
|
@ -644,10 +459,10 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
<TableCell className="py-0.5 max-h-8 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDetailModal(tool.tool_name)}
|
||||
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-none focus:ring-0"
|
||||
>
|
||||
<Tooltip title="Click to view details and block for team/key">
|
||||
<Tooltip title={onSelectTool ? "Click to view details and block for team/key" : tool.tool_name}>
|
||||
<span>{tool.tool_name}</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
|
@ -660,8 +475,10 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
onChange={handlePolicyChange}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-0.5 max-h-8 text-right tabular-nums text-sm font-mono text-gray-700">
|
||||
{(tool.call_count ?? 0).toLocaleString()}
|
||||
<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">
|
||||
<Tooltip title={tool.team_id ?? "-"}>
|
||||
|
|
@ -718,134 +535,6 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken }) => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool detail modal: view tool, global policy, overrides, block for team/key */}
|
||||
<Modal
|
||||
title={detailToolName ? `Tool: ${detailToolName}` : "Tool details"}
|
||||
open={detailModalOpen}
|
||||
onCancel={closeDetailModal}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailLoading ? (
|
||||
<p className="text-gray-500 py-4">Loading…</p>
|
||||
) : detail ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>
|
||||
<strong>Origin:</strong> {detail.tool.origin ?? "—"}
|
||||
</span>
|
||||
<span>
|
||||
<strong># Calls:</strong> {(detail.tool.call_count ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block text-sm text-gray-700 mb-1">Global policy</strong>
|
||||
<PolicySelect
|
||||
value={detail.tool.call_policy}
|
||||
toolName={detail.tool.tool_name}
|
||||
saving={saving === detail.tool.tool_name}
|
||||
onChange={handlePolicyChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail.overrides.length > 0 && (
|
||||
<div>
|
||||
<strong className="block text-sm text-gray-700 mb-2">Blocked for team/key</strong>
|
||||
<ul className="border rounded-md divide-y divide-gray-100">
|
||||
{detail.overrides.map((ov) => (
|
||||
<li
|
||||
key={ov.override_id}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm bg-red-50/50"
|
||||
>
|
||||
<span>
|
||||
{ov.team_id ? `Team: ${ov.team_id}` : ""}
|
||||
{ov.team_id && ov.key_hash ? " · " : ""}
|
||||
{ov.key_hash ? `Key: ${ov.key_alias || ov.key_hash.substring(0, 8)}` : ""}
|
||||
{!ov.team_id && !ov.key_hash ? "—" : ""}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
disabled={overrideSaving}
|
||||
onClick={() => handleRemoveOverride(ov)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<strong className="block text-sm text-gray-700 mb-2">Block for team or key</strong>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "team"}
|
||||
onChange={() => setBlockScope("team")}
|
||||
className="mr-1"
|
||||
/>
|
||||
Team
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "key"}
|
||||
onChange={() => setBlockScope("key")}
|
||||
className="mr-1"
|
||||
/>
|
||||
Key
|
||||
</label>
|
||||
</div>
|
||||
{blockScope === "team" ? (
|
||||
<div className="min-w-[200px]">
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
value={blockTeamId}
|
||||
onChange={(id) => setBlockTeamId(id ?? null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
placeholder="Select key"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
value={blockKey ? blockKey.token : undefined}
|
||||
onChange={(token) => {
|
||||
const k = keys.find((x) => x.token === token);
|
||||
setBlockKey(k ?? null);
|
||||
}}
|
||||
options={keys.map((k) => ({
|
||||
value: k.token,
|
||||
label: k.key_alias || k.token?.substring?.(0, 12) || k.token,
|
||||
}))}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
disabled={
|
||||
overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)
|
||||
}
|
||||
loading={overrideSaving}
|
||||
onClick={handleAddOverride}
|
||||
>
|
||||
Block for {blockScope}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 py-4">No data</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
|
||||
// DB policy values: "trusted" | "untrusted" | "dual_llm" | "blocked" — we expose all except dual_llm in the Policy dropdown
|
||||
export const POLICY_OPTIONS = [
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
|
||||
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
|
||||
] as const;
|
||||
|
||||
export const policyStyle = (p: string) =>
|
||||
POLICY_OPTIONS.find((o) => o.value === p) ?? POLICY_OPTIONS[1];
|
||||
|
||||
export interface PolicySelectProps {
|
||||
value: string;
|
||||
toolName: string;
|
||||
saving: boolean;
|
||||
onChange: (toolName: string, policy: string) => void;
|
||||
size?: "small" | "middle";
|
||||
minWidth?: number;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
export const PolicySelect: React.FC<PolicySelectProps> = ({
|
||||
value,
|
||||
toolName,
|
||||
saving,
|
||||
onChange,
|
||||
size = "small",
|
||||
minWidth = 110,
|
||||
stopPropagation = true,
|
||||
}) => {
|
||||
const style = policyStyle(value);
|
||||
return (
|
||||
<Select
|
||||
size={size}
|
||||
value={value}
|
||||
disabled={saving}
|
||||
loading={saving}
|
||||
onChange={(v) => onChange(toolName, v)}
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
style={{
|
||||
minWidth,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
styles={{
|
||||
selector: {
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.color,
|
||||
borderRadius: 999,
|
||||
fontSize: size === "small" ? 11 : 12,
|
||||
fontWeight: 600,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 4,
|
||||
},
|
||||
}}
|
||||
popupMatchSelectWidth={false}
|
||||
options={POLICY_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: o.color,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: o.color,
|
||||
display: "inline-block",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{o.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
44
ui/litellm-dashboard/src/components/ToolPoliciesView.tsx
Normal file
44
ui/litellm-dashboard/src/components/ToolPoliciesView.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ToolDetail } from "@/components/ToolDetail";
|
||||
import { ToolPolicies } from "@/components/ToolPolicies";
|
||||
|
||||
type View =
|
||||
| { type: "overview" }
|
||||
| { type: "detail"; toolName: string };
|
||||
|
||||
interface ToolPoliciesViewProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export default function ToolPoliciesView({ accessToken, userRole }: ToolPoliciesViewProps) {
|
||||
const [view, setView] = useState<View>({ type: "overview" });
|
||||
|
||||
const handleSelectTool = (toolName: string) => {
|
||||
setView({ type: "detail", toolName });
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setView({ type: "overview" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
{view.type === "detail" ? (
|
||||
<ToolDetail
|
||||
toolName={view.toolName}
|
||||
onBack={handleBack}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
) : (
|
||||
<ToolPolicies
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
onSelectTool={handleSelectTool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue