mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
Merge pull request #34469 from BerriAI/litellm_/blissful-torvalds-5a5be3
refactor(ui): migrate mcp-servers, tag-management, tool-policies to shadcn
This commit is contained in:
commit
5e98e8f196
31 changed files with 2760 additions and 1903 deletions
|
|
@ -812,15 +812,7 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -835,11 +827,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
@ -878,9 +865,6 @@
|
|||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -921,11 +905,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
|
|
@ -973,9 +952,6 @@
|
|||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
|
||||
|
|
@ -985,9 +961,6 @@
|
|||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -995,17 +968,11 @@
|
|||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
|
||||
|
|
@ -1031,9 +998,6 @@
|
|||
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
|
||||
|
|
@ -1046,9 +1010,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -1056,9 +1017,6 @@
|
|||
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
|
||||
|
|
@ -1071,9 +1029,6 @@
|
|||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -1801,9 +1756,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2341,11 +2293,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -2511,20 +2458,7 @@
|
|||
}
|
||||
},
|
||||
"src/components/ToolDetail.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/ToolPolicies/PolicySelect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React from "react";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { Info, Link as LinkIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import githubLogo from "../../../../../public/assets/logos/github.svg";
|
||||
import slackLogo from "../../../../../public/assets/logos/slack.svg";
|
||||
|
|
@ -61,72 +63,83 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
|
|||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Logo</span>
|
||||
<Tooltip title="Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.">
|
||||
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{value && (
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<Logo
|
||||
src={selectedWellKnown?.src ?? value}
|
||||
label="Selected"
|
||||
className="w-10 h-10 object-contain rounded-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-500 truncate">{value}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.(undefined)}
|
||||
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<TooltipProvider>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Logo</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 cursor-help text-muted-foreground" aria-label="About the logo" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Well-known logo grid */}
|
||||
<div className="grid grid-cols-10 gap-1.5 mb-3">
|
||||
{WELL_KNOWN_LOGOS.map((logo) => {
|
||||
const isSelected = value === logo.url;
|
||||
return (
|
||||
<Tooltip key={logo.name} title={logo.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(logo.url)}
|
||||
className={`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer
|
||||
${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 shadow-xs"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
|
||||
}`}
|
||||
style={{ width: 40, height: 40 }}
|
||||
>
|
||||
<img src={logo.src} alt={logo.name} className="w-5 h-5 object-contain" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{/* Preview */}
|
||||
{value && (
|
||||
<div className="mb-3 flex items-center gap-3 rounded-lg border border-border bg-muted p-3">
|
||||
<Logo
|
||||
src={selectedWellKnown?.src ?? value}
|
||||
label="Selected"
|
||||
className="h-10 w-10 rounded-sm object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs text-muted-foreground">{value}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange?.(undefined)}
|
||||
className="cursor-pointer border-none bg-transparent text-xs text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Well-known logo grid */}
|
||||
<div className="mb-3 grid grid-cols-10 gap-1.5">
|
||||
{WELL_KNOWN_LOGOS.map((logo) => {
|
||||
const isSelected = value === logo.url;
|
||||
return (
|
||||
<Tooltip key={logo.name}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(logo.url)}
|
||||
className={cn(
|
||||
"flex size-10 cursor-pointer items-center justify-center rounded-lg border p-2 transition-all",
|
||||
isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
<img src={logo.src} alt={logo.name} className="h-5 w-5 object-contain" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{logo.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom URL input */}
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<LinkIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Or paste a custom logo URL..."
|
||||
value={value && !selectedWellKnown ? value : ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
onChange?.(v || undefined);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Custom URL input */}
|
||||
<Input
|
||||
prefix={<LinkOutlined className="text-gray-400" />}
|
||||
placeholder="Or paste a custom logo URL..."
|
||||
value={value && !selectedWellKnown ? value : ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
onChange?.(v || undefined);
|
||||
}}
|
||||
className="rounded-lg"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
import {
|
||||
getGeneralSettingsCall,
|
||||
updateConfigFieldSetting,
|
||||
deleteConfigFieldSetting,
|
||||
fetchMCPClientIp,
|
||||
} from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getGeneralSettingsCall: vi.fn(),
|
||||
updateConfigFieldSetting: vi.fn(),
|
||||
deleteConfigFieldSetting: vi.fn(),
|
||||
fetchMCPClientIp: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderSettings = () => render(<MCPNetworkSettings accessToken="tok" />);
|
||||
|
||||
describe("MCPNetworkSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue(null);
|
||||
vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("renders the stored private ranges once settings load", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument();
|
||||
expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores unrelated config fields", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "some_other_setting", field_value: ["should-not-show"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
|
||||
await screen.findByText("Private IP Ranges");
|
||||
expect(screen.queryByText("should-not-show")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suggests the caller's /24 range from the detected client IP", async () => {
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("203.0.113.45")).toBeInTheDocument();
|
||||
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes the suggested range as a control a keyboard user can reach and activate", async () => {
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
|
||||
|
||||
renderSettings();
|
||||
const suggested = await screen.findByRole("button", { name: /203\.0\.113\.0\/24/ });
|
||||
|
||||
suggested.focus();
|
||||
expect(suggested).toHaveFocus();
|
||||
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("adds the suggested range to the list when clicked, and stops suggesting it", async () => {
|
||||
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
|
||||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByText("203.0.113.0/24"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves the configured ranges", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
|
||||
]);
|
||||
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]),
|
||||
);
|
||||
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the setting instead of saving an empty list", async () => {
|
||||
renderSettings();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges"));
|
||||
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Button, Card, Typography, Spin, Tag } from "antd";
|
||||
import { SaveOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { Save, Plus, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import {
|
||||
getGeneralSettingsCall,
|
||||
|
|
@ -9,8 +13,6 @@ import {
|
|||
fetchMCPClientIp,
|
||||
} from "@/components/networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MCPNetworkSettingsProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
|
@ -29,6 +31,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [privateRanges, setPrivateRanges] = useState<string[]>([]);
|
||||
const [currentIp, setCurrentIp] = useState<string | null>(null);
|
||||
const [rangeDraft, setRangeDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
|
|
@ -82,10 +85,22 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
}
|
||||
};
|
||||
|
||||
// Commas separate entries, matching the old tokenised input.
|
||||
const commitDraft = () => {
|
||||
const added = rangeDraft
|
||||
.split(",")
|
||||
.map((r) => r.trim())
|
||||
.filter((r) => r !== "" && !privateRanges.includes(r));
|
||||
if (added.length > 0) {
|
||||
setPrivateRanges([...privateRanges, ...added]);
|
||||
}
|
||||
setRangeDraft("");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spin />
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -96,55 +111,76 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
<div className="space-y-6 p-4">
|
||||
<DeprecationBanner featureName="MCP Network Settings and the internal-network-only flag" />
|
||||
<div>
|
||||
<Text className="text-lg font-semibold">Private IP Ranges</Text>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-lg font-semibold">Private IP Ranges</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers.
|
||||
Callers from any other IP can only see servers marked "Available on Public Internet".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="p-6">
|
||||
{currentIp && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<Text className="text-sm text-blue-700">
|
||||
<div className="mb-4 rounded-lg bg-muted p-3">
|
||||
<p className="text-sm">
|
||||
Your current IP: <span className="font-mono font-medium">{currentIp}</span>
|
||||
</Text>
|
||||
</p>
|
||||
{suggestedRange && !privateRanges.includes(suggestedRange) && (
|
||||
<div className="mt-1">
|
||||
<Text className="text-sm text-blue-600">Suggested range: </Text>
|
||||
<Tag
|
||||
className="cursor-pointer font-mono"
|
||||
color="blue"
|
||||
icon={<PlusOutlined />}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p className="text-sm">Suggested range: </p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-mono"
|
||||
onClick={() => addSuggestedRange(suggestedRange)}
|
||||
>
|
||||
<Plus />
|
||||
{suggestedRange}
|
||||
</Tag>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<Text className="font-medium">Your Private Network Ranges</Text>
|
||||
<div className="mb-2 flex items-center">
|
||||
<p className="text-sm font-medium">Your Private Network Ranges</p>
|
||||
</div>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={privateRanges}
|
||||
onChange={setPrivateRanges}
|
||||
{privateRanges.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{privateRanges.map((range) => (
|
||||
<Badge key={range} variant="secondary" className="font-mono">
|
||||
{range}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${range}`}
|
||||
onClick={() => setPrivateRanges(privateRanges.filter((r) => r !== range))}
|
||||
className="ml-1 cursor-pointer"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={rangeDraft}
|
||||
placeholder="Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8"
|
||||
tokenSeparators={[","]}
|
||||
className="w-full"
|
||||
size="large"
|
||||
allowClear
|
||||
onChange={(e) => setRangeDraft(e.target.value)}
|
||||
onBlur={commitDraft}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { type FC, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { Dropdown, Tooltip, Typography, Tag } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
MoreOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MCPServerCardProps {
|
||||
server: MCPServer;
|
||||
// Per-user env-var fields this user still needs to fill in for this server.
|
||||
|
|
@ -73,8 +74,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
const needsAttention = missing.length > 0;
|
||||
|
||||
const cardClass = needsAttention
|
||||
? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md"
|
||||
: "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md";
|
||||
? "border-2 border-destructive/40 bg-destructive/5 hover:border-destructive/60 hover:shadow-md"
|
||||
: "border border-border bg-card hover:shadow-md";
|
||||
|
||||
const url = server.url || "";
|
||||
const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" };
|
||||
|
|
@ -105,174 +106,198 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps["items"] = [];
|
||||
if (onRecheckHealth) {
|
||||
menuItems.push({
|
||||
key: "test-connection",
|
||||
label: "Test Connection",
|
||||
icon: <ThunderboltOutlined />,
|
||||
disabled: isRechecking,
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
onRecheckHealth();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
if (menuItems.length > 0) {
|
||||
menuItems.push({ key: "divider", type: "divider" });
|
||||
}
|
||||
menuItems.push({
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
onDelete();
|
||||
},
|
||||
});
|
||||
}
|
||||
const hasMenu = !!onRecheckHealth || !!onDelete;
|
||||
|
||||
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
|
||||
// recheck-health Tag), so a real <button> wrapper would produce invalid
|
||||
// recheck-health Badge), so a real <button> wrapper would produce invalid
|
||||
// nested-interactive HTML. The role + tabIndex + Enter/Space handler keeps
|
||||
// the whole card clickable and keyboard-accessible.
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-400 ${cardClass}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{candidateLogo ? (
|
||||
<Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-gray-100 font-semibold text-gray-500">
|
||||
{(name || "?").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
cardClass,
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="block w-full truncate text-left font-semibold text-gray-900" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
|
||||
{alias && <span className="truncate">{alias}</span>}
|
||||
{alias && <span className="text-gray-300">·</span>}
|
||||
<Tooltip title={server.server_id}>
|
||||
<span className="font-mono text-blue-600">{server.server_id.slice(0, 7)}</span>
|
||||
</Tooltip>
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{candidateLogo ? (
|
||||
<Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-muted font-semibold text-muted-foreground">
|
||||
{(name || "?").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="block w-full truncate text-left font-semibold" title={name}>
|
||||
{name}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{alias && <span className="truncate">{alias}</span>}
|
||||
{alias && <span>·</span>}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="font-mono text-primary">{server.server_id.slice(0, 7)}</span>}
|
||||
/>
|
||||
<TooltipContent>{server.server_id}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{hasMenu && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
onKeyDown={stop}
|
||||
aria-label="Server actions"
|
||||
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Ellipsis className="size-5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{onRecheckHealth && (
|
||||
<DropdownMenuItem
|
||||
disabled={isRechecking}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onRecheckHealth();
|
||||
}}
|
||||
>
|
||||
<Zap />
|
||||
Test Connection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{menuItems.length > 0 && (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={["click"]} placement="bottomRight">
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
onKeyDown={stop}
|
||||
aria-label="Server actions"
|
||||
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600"
|
||||
>
|
||||
<MoreOutlined style={{ fontSize: 20 }} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtitle ? (
|
||||
<Tooltip title={subtitleTooltip}>
|
||||
<Text className="truncate font-mono text-xs text-gray-500" ellipsis>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// Defensive placeholder: keep the row even when no identifier is
|
||||
// available so the tag row stays vertically aligned across the grid.
|
||||
<div className="h-[18px]" aria-hidden />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={onRecheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Tag className="m-0">{displayTransport.toUpperCase()}</Tag>
|
||||
<Tag className="m-0">{authType}</Tag>
|
||||
{oauthFlowUnset && (
|
||||
<Tooltip title="This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow Type so LiteLLM authenticates it as you intend.">
|
||||
<Tag color="warning" className="m-0">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ExclamationCircleFilled />
|
||||
OAuth flow not set
|
||||
</span>
|
||||
</Tag>
|
||||
{subtitle ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<p className="truncate font-mono text-xs text-muted-foreground">{subtitle}</p>} />
|
||||
<TooltipContent>{subtitleTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// Defensive placeholder: keep the row even when no identifier is
|
||||
// available so the badge row stays vertically aligned across the grid.
|
||||
<div className="h-[18px]" aria-hidden />
|
||||
)}
|
||||
<Tag color={isPublic ? "green" : "orange"} className="m-0">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${isPublic ? "bg-green-500" : "bg-orange-500"}`} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HealthChip
|
||||
status={status}
|
||||
isLoadingHealth={isLoadingHealth}
|
||||
isRechecking={isRechecking}
|
||||
onRecheck={onRecheckHealth}
|
||||
lastCheck={server.last_health_check}
|
||||
error={server.health_check_error}
|
||||
dotClass={healthTone.dot}
|
||||
/>
|
||||
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
|
||||
<Badge variant="outline">{authType}</Badge>
|
||||
{oauthFlowUnset && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline">
|
||||
<CircleAlert />
|
||||
OAuth flow not set
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow
|
||||
Type so LiteLLM authenticates it as you intend.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Badge variant="outline">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", isPublic ? "bg-green-500" : "bg-orange-500")} />
|
||||
{isPublic ? "Public" : "Internal"}
|
||||
</span>
|
||||
</Tag>
|
||||
{accessGroups.slice(0, 2).map((g) => (
|
||||
<Tooltip key={g} title={g}>
|
||||
<Tag className="m-0 max-w-[120px] truncate">{g}</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
{accessGroups.length > 2 && (
|
||||
<Tooltip title={accessGroups.slice(2).join(", ")}>
|
||||
<Tag className="m-0">+{accessGroups.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{accessGroups.slice(0, 2).map((g) => (
|
||||
<Tooltip key={g}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge variant="outline" className="max-w-[120px] truncate">
|
||||
{g}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{g}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{accessGroups.length > 2 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Badge variant="outline">+{accessGroups.length - 2}</Badge>} />
|
||||
<TooltipContent>{accessGroups.slice(2).join(", ")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(server.is_byok || needsAttention) && (
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{server.is_byok && <ByokRow connected={!!server.has_user_credential} onConnect={onByokConnect} />}
|
||||
{needsAttention && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-semibold mb-1">Missing user fields:</div>
|
||||
{(server.is_byok || needsAttention) && (
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{server.is_byok && <ByokRow connected={!!server.has_user_credential} onConnect={onByokConnect} />}
|
||||
{needsAttention && (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-destructive">
|
||||
<CircleAlert className="size-3.5" />
|
||||
{missing.length} user field
|
||||
{missing.length === 1 ? "" : "s"} missing
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className="mb-1 font-semibold">Missing user fields:</div>
|
||||
<ul className="ml-3">
|
||||
{missing.map((m) => (
|
||||
<li key={m}>• {m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-red-700">
|
||||
<ExclamationCircleFilled />
|
||||
{missing.length} user field
|
||||
{missing.length === 1 ? "" : "s"} missing
|
||||
</span>
|
||||
</Tooltip>
|
||||
{onOpenFillFields && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onOpenFillFields();
|
||||
}}
|
||||
className="rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-red-700"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onOpenFillFields && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onOpenFillFields();
|
||||
}}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -297,46 +322,45 @@ const HealthChip: FC<HealthChipProps> = ({
|
|||
}) => {
|
||||
if (isLoadingHealth || isRechecking) {
|
||||
return (
|
||||
<Tag className="m-0">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-gray-300" />
|
||||
Checking
|
||||
</span>
|
||||
</Tag>
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground" />
|
||||
Checking
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const tooltip = (
|
||||
<div className="max-w-xs">
|
||||
<div className="font-semibold mb-1">Health: {status}</div>
|
||||
{lastCheck && <div className="text-xs mb-1">Last check: {new Date(lastCheck).toLocaleString()}</div>}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-red-300 mb-1">Error</div>
|
||||
<div className="wrap-break-word">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!lastCheck && !error && <div className="text-xs text-gray-400">No health data</div>}
|
||||
{onRecheck && <div className="mt-1 text-xs text-gray-300">Click to recheck</div>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top">
|
||||
<Tag
|
||||
className={`m-0 ${onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
onClick={
|
||||
onRecheck
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onRecheck();
|
||||
}
|
||||
: undefined
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}
|
||||
onClick={
|
||||
onRecheck
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onRecheck();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", dotClass)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</Tag>
|
||||
/>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="mb-1 font-semibold">Health: {status}</div>
|
||||
{lastCheck && <div className="mb-1 text-xs">Last check: {new Date(lastCheck).toLocaleString()}</div>}
|
||||
{error && (
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 font-medium">Error</div>
|
||||
<div className="wrap-break-word">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{!lastCheck && !error && <div className="text-xs">No health data</div>}
|
||||
{onRecheck && <div className="mt-1 text-xs">Click to recheck</div>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -350,22 +374,22 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
|||
if (connected) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-gray-500">BYOK credential</span>
|
||||
<span className="text-muted-foreground">BYOK credential</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700">
|
||||
<CheckOutlined style={{ fontSize: 10 }} /> Connected
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
<Check /> Connected
|
||||
</Badge>
|
||||
{onConnect && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onConnect();
|
||||
}}
|
||||
className="text-xs text-gray-400 transition-colors hover:text-blue-600"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -373,20 +397,19 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
|
|||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-gray-500">BYOK credential</span>
|
||||
<span className="text-muted-foreground">BYOK credential</span>
|
||||
{onConnect ? (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
onConnect();
|
||||
}}
|
||||
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker";
|
||||
import { fetchOpenAPIRegistry } from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchOpenAPIRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
const stripe: OpenAPIRegistryEntry = {
|
||||
name: "stripe",
|
||||
title: "Stripe",
|
||||
description: "Payments API",
|
||||
icon_url: "https://cdn.example.com/stripe.svg",
|
||||
spec_url: "https://example.com/stripe.json",
|
||||
};
|
||||
|
||||
const github: OpenAPIRegistryEntry = {
|
||||
name: "github",
|
||||
title: "GitHub",
|
||||
description: "Code hosting API",
|
||||
icon_url: "https://cdn.example.com/github.svg",
|
||||
spec_url: "https://example.com/github.json",
|
||||
};
|
||||
|
||||
describe("OpenAPIQuickPicker", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders one selectable entry per registry API", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Popular APIs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the whole registry entry to onSelect when one is clicked", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={onSelect} />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Stripe/ }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(stripe);
|
||||
});
|
||||
|
||||
it("renders nothing when the registry is empty", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] });
|
||||
|
||||
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it("renders nothing when the registry fetch fails", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it("does not fetch without an access token", () => {
|
||||
render(<OpenAPIQuickPicker accessToken={null} selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
expect(fetchOpenAPIRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a letter avatar when the icon fails to load", async () => {
|
||||
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] });
|
||||
|
||||
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
|
||||
|
||||
fireEvent.error(await screen.findByAltText("Stripe"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Spin } from "antd";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { fetchOpenAPIRegistry } from "@/components/networking";
|
||||
|
||||
export interface OpenAPIKeyTool {
|
||||
|
|
@ -49,9 +50,9 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-700">Popular APIs</span>
|
||||
<span className="text-sm font-medium">Popular APIs</span>
|
||||
<div className="flex justify-center py-6">
|
||||
<Spin size="small" />
|
||||
<UiLoadingSpinner className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -61,7 +62,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-700 block mb-2">Popular APIs</span>
|
||||
<span className="mb-2 block text-sm font-medium">Popular APIs</span>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{apis.map((api) => {
|
||||
|
|
@ -73,32 +74,30 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
|
|||
type="button"
|
||||
title={api.description}
|
||||
onClick={() => onSelect(api)}
|
||||
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer
|
||||
${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 shadow-xs"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
|
||||
}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border p-3 transition-all",
|
||||
isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{imgFailed ? (
|
||||
<span className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground">
|
||||
{api.title.charAt(0)}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
src={api.icon_url}
|
||||
alt={api.title}
|
||||
className="w-7 h-7 object-contain"
|
||||
className="h-7 w-7 object-contain"
|
||||
onError={() => handleImgError(api.name)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 text-center leading-tight font-medium">{api.title}</span>
|
||||
<span className="text-center text-xs leading-tight font-medium text-muted-foreground">{api.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
|
||||
describe("TruePassthroughWarning", () => {
|
||||
it("warns when auth type is true_passthrough", () => {
|
||||
render(<TruePassthroughWarning authType={AUTH_TYPE.TRUE_PASSTHROUGH} />);
|
||||
|
||||
expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for any other auth type", () => {
|
||||
const { container } = render(<TruePassthroughWarning authType={AUTH_TYPE.OAUTH2} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when no auth type is set", () => {
|
||||
const { container } = render(<TruePassthroughWarning authType={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Alert } from "antd";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
||||
|
||||
/**
|
||||
|
|
@ -10,12 +11,15 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types";
|
|||
export default function TruePassthroughWarning({ authType }: { authType?: string | null }) {
|
||||
if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null;
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-4 rounded-lg"
|
||||
message="True Passthrough disables LiteLLM authentication for this server"
|
||||
description="Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers should still authenticate to LiteLLM."
|
||||
/>
|
||||
<Alert className="mb-4">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>True Passthrough disables LiteLLM authentication for this server</AlertTitle>
|
||||
<AlertDescription>
|
||||
Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization
|
||||
header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply,
|
||||
and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers
|
||||
should still authenticate to LiteLLM.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import React from "react";
|
||||
import { Button, Spin, Alert, Collapse } from "antd";
|
||||
import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
||||
interface MCPConnectionStatusProps {
|
||||
formValues: Record<string, any>;
|
||||
|
|
@ -31,27 +34,26 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-blue-600" />
|
||||
<Title>Connection Status</Title>
|
||||
<CircleCheck className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Connection Status</h3>
|
||||
</div>
|
||||
|
||||
{!canFetchTools && (formValues.url || formValues.spec_path) && (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<ToolOutlined className="text-2xl mb-2" />
|
||||
<Text>Complete required fields to test connection</Text>
|
||||
<br />
|
||||
<Text className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</Text>
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">Complete required fields to test connection</p>
|
||||
<p className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canFetchTools && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<Text className="text-gray-700 font-medium">
|
||||
<p className="text-sm font-medium">
|
||||
{isLoadingTools
|
||||
? "Testing connection to MCP server..."
|
||||
: tools.length > 0
|
||||
|
|
@ -61,97 +63,84 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
|
|||
? "Ready to submit"
|
||||
: "Connection failed"
|
||||
: "Ready to test connection"}
|
||||
</Text>
|
||||
<br />
|
||||
<Text className="text-gray-500 text-sm">Server: {formValues.url || formValues.spec_path}</Text>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Server: {formValues.url || formValues.spec_path}</p>
|
||||
</div>
|
||||
|
||||
{isLoadingTools && (
|
||||
<div className="flex items-center text-blue-600">
|
||||
<Spin size="small" className="mr-2" />
|
||||
<Text className="text-blue-600">Connecting...</Text>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<UiLoadingSpinner className="size-4" />
|
||||
<p className="text-sm">Connecting...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircleOutlined className="mr-1" />
|
||||
<Text className="text-green-600 font-medium">Connected</Text>
|
||||
<div className="flex items-center gap-1">
|
||||
<CircleCheck className="size-4" />
|
||||
<p className="text-sm font-medium">Connected</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsError && !isPreviewForbidden && (
|
||||
<div className="flex items-center text-red-600">
|
||||
<ExclamationCircleOutlined className="mr-1" />
|
||||
<Text className="text-red-600 font-medium">Failed</Text>
|
||||
<div className="flex items-center gap-1 text-destructive">
|
||||
<CircleAlert className="size-4" />
|
||||
<p className="text-sm font-medium">Failed</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingTools && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Spin size="large" />
|
||||
<Text className="ml-3">Testing connection and loading tools...</Text>
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm">Testing connection and loading tools...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsError && isPreviewForbidden && (
|
||||
<Alert message="Tool preview unavailable" description={toolsError} type="info" showIcon />
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Tool preview unavailable</AlertTitle>
|
||||
<AlertDescription>{toolsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{toolsError && !isPreviewForbidden && (
|
||||
<Alert
|
||||
message="Connection Failed"
|
||||
description={
|
||||
<div>
|
||||
<div>{toolsError}</div>
|
||||
{toolsErrorStackTrace && (
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "stack-trace",
|
||||
label: "Stack Trace",
|
||||
children: (
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontSize: "12px",
|
||||
fontFamily: "monospace",
|
||||
margin: 0,
|
||||
padding: "8px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
maxHeight: "400px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{toolsErrorStackTrace}
|
||||
</pre>
|
||||
),
|
||||
},
|
||||
]}
|
||||
style={{ marginTop: "12px" }}
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Connection Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>{toolsError}</div>
|
||||
{toolsErrorStackTrace && (
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="link" size="sm" className="h-auto p-0">
|
||||
Stack Trace
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchTools} size="small">
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 max-h-100 overflow-auto rounded-sm bg-muted p-2 font-mono text-xs break-words whitespace-pre-wrap">
|
||||
{toolsErrorStackTrace}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<div className="mt-3">
|
||||
<Button variant="outline" size="sm" onClick={fetchTools}>
|
||||
<RefreshCw />
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isLoadingTools && tools.length === 0 && !toolsError && (
|
||||
<div className="text-center py-6 text-gray-500 border rounded-lg border-dashed">
|
||||
<CheckCircleOutlined className="text-2xl mb-2 text-green-500" />
|
||||
<Text className="text-green-600 font-medium">Connection successful!</Text>
|
||||
<br />
|
||||
<Text className="text-gray-500">No tools found for this MCP server</Text>
|
||||
<div className="rounded-lg border border-dashed py-6 text-center">
|
||||
<CircleCheck className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm font-medium">Connection successful!</p>
|
||||
<p className="text-sm text-muted-foreground">No tools found for this MCP server</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MCPDiscovery from "./mcp_discovery";
|
||||
import { fetchDiscoverableMCPServers } from "@/components/networking";
|
||||
import type { DiscoverableMCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
fetchDiscoverableMCPServers: vi.fn(),
|
||||
}));
|
||||
|
||||
const githubServer = {
|
||||
name: "github",
|
||||
title: "GitHub",
|
||||
description: "Code hosting",
|
||||
category: "Developer Tools",
|
||||
icon_url: "",
|
||||
} as DiscoverableMCPServer;
|
||||
|
||||
const slackServer = {
|
||||
name: "slack",
|
||||
title: "Slack",
|
||||
description: "Team chat",
|
||||
category: "Communication",
|
||||
icon_url: "",
|
||||
} as DiscoverableMCPServer;
|
||||
|
||||
const defaultProps = {
|
||||
isVisible: true,
|
||||
onClose: vi.fn(),
|
||||
onSelectServer: vi.fn(),
|
||||
onCustomServer: vi.fn(),
|
||||
accessToken: "tok",
|
||||
};
|
||||
|
||||
describe("MCPDiscovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({
|
||||
servers: [githubServer, slackServer],
|
||||
categories: ["Developer Tools", "Communication"],
|
||||
});
|
||||
});
|
||||
|
||||
// Each category name renders twice: once as a filter pill (a button) and once
|
||||
// as the heading of its group. Only the heading is not a button.
|
||||
const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON");
|
||||
|
||||
it("lists every discoverable server grouped under its category", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText("GitHub")).toBeInTheDocument();
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
expect(groupHeading("Developer Tools")).toHaveLength(1);
|
||||
expect(groupHeading("Communication")).toHaveLength(1);
|
||||
expect(screen.getByText("Add MCP Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the list down to the chosen category", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
await screen.findByText("GitHub");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Communication" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the list by the search term", async () => {
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
await screen.findByText("GitHub");
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat");
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Slack")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands the picked server back to the caller", async () => {
|
||||
const onSelectServer = vi.fn();
|
||||
render(<MCPDiscovery {...defaultProps} onSelectServer={onSelectServer} />);
|
||||
|
||||
await userEvent.click(await screen.findByText("GitHub"));
|
||||
|
||||
expect(onSelectServer).toHaveBeenCalledWith(githubServer);
|
||||
});
|
||||
|
||||
it("offers a custom-server escape hatch", async () => {
|
||||
const onCustomServer = vi.fn();
|
||||
render(<MCPDiscovery {...defaultProps} onCustomServer={onCustomServer} />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" }));
|
||||
|
||||
expect(onCustomServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a fetch failure", async () => {
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down"));
|
||||
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the custom-server link when nothing matches", async () => {
|
||||
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] });
|
||||
|
||||
render(<MCPDiscovery {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/No servers found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch while hidden", () => {
|
||||
render(<MCPDiscovery {...defaultProps} isVisible={false} />);
|
||||
|
||||
expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Modal, Input, Typography } from "antd";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { fetchDiscoverableMCPServers } from "@/components/networking";
|
||||
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types";
|
||||
import { mcpLogoImg } from "./create_mcp_server";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
|
||||
const { Search } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface MCPDiscoveryProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -16,12 +18,21 @@ interface MCPDiscoveryProps {
|
|||
accessToken: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"];
|
||||
const INITIAL_COLORS = [
|
||||
"bg-blue-500",
|
||||
"bg-emerald-500",
|
||||
"bg-amber-500",
|
||||
"bg-red-500",
|
||||
"bg-violet-500",
|
||||
"bg-pink-500",
|
||||
"bg-cyan-500",
|
||||
"bg-lime-500",
|
||||
];
|
||||
|
||||
function getInitialAvatar(name: string) {
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length;
|
||||
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] };
|
||||
return { initial, backgroundClass: INITIAL_COLORS[colorIndex] };
|
||||
}
|
||||
|
||||
const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
||||
|
|
@ -91,214 +102,126 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
|
|||
}, [filteredServers]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center justify-between pb-4 border-b border-gray-100">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={resolveLogoSrc(mcpLogoImg)}
|
||||
alt="MCP Logo"
|
||||
className="w-8 h-8 object-contain"
|
||||
style={{
|
||||
height: "20px",
|
||||
width: "20px",
|
||||
marginRight: "8px",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add MCP Server</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCustomServer}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium"
|
||||
>
|
||||
+ Custom Server
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
open={isVisible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={1000}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
{/* Filter pills */}
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
{["All", ...categories].map((cat) => {
|
||||
const isSelected = selectedCategory === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 4,
|
||||
border: isSelected ? "1px solid #111827" : "1px solid #e5e7eb",
|
||||
background: isSelected ? "#111827" : "#fff",
|
||||
color: isSelected ? "#fff" : "#4b5563",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 500 : 400,
|
||||
lineHeight: "20px",
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<Search
|
||||
placeholder="Search servers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ marginBottom: 16 }}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 6,
|
||||
background: "#f9fafb",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
|
||||
<Text>Failed to load servers: {error}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredServers.length === 0 && (
|
||||
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
|
||||
<Text>
|
||||
No servers found.{" "}
|
||||
<a onClick={onCustomServer} style={{ color: "#2563eb", cursor: "pointer" }}>
|
||||
Add a custom server
|
||||
</a>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server list grouped by category — 2 columns */}
|
||||
{!loading &&
|
||||
!error &&
|
||||
Object.entries(groupedServers).map(([category, categoryServers]) => (
|
||||
<div key={category} style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: "#9ca3af",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
padding: "6px 0",
|
||||
borderBottom: "1px solid #f3f4f6",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0 16px",
|
||||
}}
|
||||
>
|
||||
{categoryServers.map((server) => {
|
||||
const avatar = getInitialAvatar(server.title || server.name);
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
onClick={() => onSelectServer(server)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.1s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#f9fafb";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
{server.icon_url ? (
|
||||
<img
|
||||
src={resolveLogoSrc(server.icon_url)}
|
||||
alt={server.title}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
objectFit: "contain",
|
||||
flexShrink: 0,
|
||||
marginRight: 12,
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
const next = target.nextElementSibling as HTMLElement;
|
||||
if (next) next.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 4,
|
||||
backgroundColor: avatar.backgroundColor,
|
||||
color: "#fff",
|
||||
display: server.icon_url ? "none" : "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
flexShrink: 0,
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{avatar.initial}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: "#111827",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{server.title || server.name}
|
||||
</span>
|
||||
<span style={{ color: "#d1d5db", fontSize: 14, flexShrink: 0, marginLeft: 8 }}>›</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Dialog open={isVisible} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[1000px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={resolveLogoSrc(mcpLogoImg)} alt="MCP Logo" className="mr-2 size-5 object-contain" />
|
||||
<DialogTitle className="text-xl font-semibold">Add MCP Server</DialogTitle>
|
||||
</div>
|
||||
<Button variant="link" size="sm" onClick={onCustomServer}>
|
||||
+ Custom Server
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
{/* Filter pills */}
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{["All", ...categories].map((cat) => {
|
||||
const isSelected = selectedCategory === cat;
|
||||
return (
|
||||
<Button
|
||||
key={cat}
|
||||
size="sm"
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<InputGroup className="mb-4 w-full">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search servers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{loading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">Failed to load servers: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filteredServers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="text-sm">
|
||||
No servers found.{" "}
|
||||
<Button variant="link" size="sm" onClick={onCustomServer}>
|
||||
Add a custom server
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server list grouped by category — 2 columns */}
|
||||
{!loading &&
|
||||
!error &&
|
||||
Object.entries(groupedServers).map(([category, categoryServers]) => (
|
||||
<div key={category} className="mb-4">
|
||||
<div className="mb-1 border-b border-border py-1.5 text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
|
||||
{category}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
{categoryServers.map((server) => {
|
||||
const avatar = getInitialAvatar(server.title || server.name);
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
onClick={() => onSelectServer(server)}
|
||||
className="flex cursor-pointer items-center rounded-md px-2.5 py-2 transition-colors hover:bg-accent"
|
||||
>
|
||||
{server.icon_url ? (
|
||||
<img
|
||||
src={resolveLogoSrc(server.icon_url)}
|
||||
alt={server.title}
|
||||
className="mr-3 size-5 shrink-0 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = "none";
|
||||
const next = target.nextElementSibling as HTMLElement;
|
||||
if (next) next.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"mr-3 size-5 shrink-0 items-center justify-center rounded-sm text-[11px] font-semibold text-white",
|
||||
avatar.backgroundClass,
|
||||
server.icon_url ? "hidden" : "flex",
|
||||
)}
|
||||
>
|
||||
{avatar.initial}
|
||||
</div>
|
||||
<span className="flex-1 truncate text-sm">{server.title || server.name}</span>
|
||||
<span className="ml-2 shrink-0 text-sm text-muted-foreground">›</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
|
||||
const tools = [
|
||||
{ name: "search", description: "Search the index" },
|
||||
{ name: "fetch", description: "Fetch a document" },
|
||||
];
|
||||
|
||||
describe("MCPServerCostConfig", () => {
|
||||
it("renders the default cost field with the current value", () => {
|
||||
render(<MCPServerCostConfig value={{ default_cost_per_query: 0.02 }} tools={[]} />);
|
||||
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200");
|
||||
});
|
||||
|
||||
it("reports the edited default cost as a number", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} onChange={onChange} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 });
|
||||
});
|
||||
|
||||
it("disables the default cost field when disabled", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} disabled />);
|
||||
|
||||
expect(screen.getByPlaceholderText("0.0000")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides the per-tool section when the server exposes no tools", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={[]} />);
|
||||
|
||||
expect(screen.queryByText("Available Tools")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a per-tool override for every tool once tools are loaded", async () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={tools} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Available Tools"));
|
||||
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("Search the index")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("merges a per-tool override into the existing cost map", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<MCPServerCostConfig
|
||||
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { fetch: 0.2 } }}
|
||||
tools={tools}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Available Tools"));
|
||||
await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
default_cost_per_query: 0.01,
|
||||
tool_name_to_cost_per_query: { fetch: 0.2, search: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("summarises the configured costs", () => {
|
||||
render(
|
||||
<MCPServerCostConfig
|
||||
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { search: 0.25 } }}
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument();
|
||||
expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no summary when nothing is configured", () => {
|
||||
render(<MCPServerCostConfig value={{}} tools={tools} />);
|
||||
|
||||
expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import React from "react";
|
||||
import { Tooltip, InputNumber, Collapse, Badge } from "antd";
|
||||
import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import React, { useState } from "react";
|
||||
import { Info, DollarSign, Wrench } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerCostConfigProps {
|
||||
|
|
@ -11,6 +14,47 @@ interface MCPServerCostConfigProps {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CostInputProps {
|
||||
value: number | null | undefined;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (cost: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Costs are shown to four decimal places when idle, but the field keeps the raw
|
||||
* keystrokes while it is being edited so partial input like "0." survives.
|
||||
*/
|
||||
const CostInput: React.FC<CostInputProps> = ({ value, placeholder, disabled, className, onChange }) => {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const display = draft ?? (value === null || value === undefined ? "" : value.toFixed(4));
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setDraft(next);
|
||||
const parsed = Number(next);
|
||||
onChange(next.trim() === "" || Number.isNaN(parsed) ? null : parsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputGroup className={className}>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>$</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
value={display}
|
||||
onFocus={() => setDraft(value === null || value === undefined ? "" : String(value))}
|
||||
onBlur={() => setDraft(null)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
||||
value = {},
|
||||
onChange,
|
||||
|
|
@ -37,124 +81,126 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarOutlined className="text-green-600" />
|
||||
<Title>Cost Configuration</Title>
|
||||
<Tooltip title="Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Cost per Query ($)
|
||||
<Tooltip title="Default cost charged for each tool call to this server.">
|
||||
<InfoCircleOutlined className="ml-1 text-gray-400" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
placeholder="0.0000"
|
||||
value={value.default_cost_per_query}
|
||||
onChange={handleDefaultCostChange}
|
||||
disabled={disabled}
|
||||
style={{ width: "200px" }}
|
||||
addonBefore="$"
|
||||
/>
|
||||
<Text className="block mt-1 text-gray-500 text-sm">
|
||||
Set a default cost for all tool calls to this server
|
||||
</Text>
|
||||
<TooltipProvider>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<DollarSign className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Cost Configuration</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className="size-4 text-muted-foreground" aria-label="About cost configuration" />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{tools.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Tool-Specific Costs ($)
|
||||
<Tooltip title="Override the default cost for specific tools. Leave blank to use the default rate.">
|
||||
<InfoCircleOutlined className="ml-1 text-gray-400" />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
Default Cost per Query ($)
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About the default cost" />
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Default cost charged for each tool call to this server.</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center">
|
||||
<ToolOutlined className="mr-2 text-blue-500" />
|
||||
<span className="font-medium">Available Tools</span>
|
||||
<Badge
|
||||
count={tools.length}
|
||||
style={{
|
||||
backgroundColor: "#52c41a",
|
||||
marginLeft: "8px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{tools.map((tool, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<Text className="font-medium text-gray-900">{tool.name}</Text>
|
||||
{tool.description && (
|
||||
<Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={0.0001}
|
||||
precision={4}
|
||||
placeholder="Use default"
|
||||
value={value.tool_name_to_cost_per_query?.[tool.name]}
|
||||
onChange={(cost) => handleToolCostChange(tool.name, cost)}
|
||||
disabled={disabled}
|
||||
style={{ width: "120px" }}
|
||||
addonBefore="$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
<CostInput
|
||||
value={value.default_cost_per_query}
|
||||
placeholder="0.0000"
|
||||
disabled={disabled}
|
||||
className="w-50"
|
||||
onChange={handleDefaultCostChange}
|
||||
/>
|
||||
<p className="mt-1 block text-sm text-muted-foreground">
|
||||
Set a default cost for all tool calls to this server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{tools.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium">
|
||||
Tool-Specific Costs ($)
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About per-tool costs" />
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Override the default cost for specific tools. Leave blank to use the default rate.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Collapsible className="rounded-lg border border-border">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button type="button" className="flex w-full items-center gap-2 p-3 text-left">
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">Available Tools</span>
|
||||
<Badge variant="secondary">{tools.length}</Badge>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CollapsibleContent>
|
||||
<div className="max-h-64 space-y-3 overflow-y-auto p-3">
|
||||
{tools.map((tool, index) => (
|
||||
<div key={index} className="flex items-center justify-between rounded-lg bg-muted p-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{tool.name}</p>
|
||||
{tool.description && (
|
||||
<p className="mt-1 block text-sm text-muted-foreground">{tool.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<CostInput
|
||||
value={value.tool_name_to_cost_per_query?.[tool.name]}
|
||||
placeholder="Use default"
|
||||
disabled={disabled}
|
||||
className="w-40"
|
||||
onChange={(cost) => handleToolCostChange(tool.name, cost)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(value.default_cost_per_query ||
|
||||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
|
||||
<div className="mt-6 rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm font-medium">Cost Summary:</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{value.default_cost_per_query && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query
|
||||
</p>
|
||||
)}
|
||||
{value.tool_name_to_cost_per_query &&
|
||||
Object.entries(value.tool_name_to_cost_per_query).map(
|
||||
([toolName, cost]) =>
|
||||
cost !== null &&
|
||||
cost !== undefined && (
|
||||
<p key={toolName} className="text-sm text-muted-foreground">
|
||||
• {toolName}: ${cost.toFixed(4)} per query
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(value.default_cost_per_query ||
|
||||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Text className="text-blue-800 font-medium">Cost Summary:</Text>
|
||||
<div className="mt-2 space-y-1">
|
||||
{value.default_cost_per_query && (
|
||||
<Text className="text-blue-700">
|
||||
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query
|
||||
</Text>
|
||||
)}
|
||||
{value.tool_name_to_cost_per_query &&
|
||||
Object.entries(value.tool_name_to_cost_per_query).map(
|
||||
([toolName, cost]) =>
|
||||
cost !== null &&
|
||||
cost !== undefined && (
|
||||
<Text key={toolName} className="text-blue-700">
|
||||
• {toolName}: ${cost.toFixed(4)} per query
|
||||
</Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import MCPServerCostDisplay from "./mcp_server_cost_display";
|
||||
|
||||
describe("MCPServerCostDisplay", () => {
|
||||
it("explains that calls are free when no cost config exists", () => {
|
||||
render(<MCPServerCostDisplay costConfig={null} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats a config with only a null default cost as unconfigured", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: null }} />);
|
||||
|
||||
expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a zero default cost rather than falling back to the empty state", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0 }} />);
|
||||
|
||||
expect(screen.getByText("Default Cost per Query")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.0000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the default cost to four decimal places and summarises it", () => {
|
||||
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0.0125 }} />);
|
||||
|
||||
expect(screen.getByText("$0.0125")).toBeInTheDocument();
|
||||
expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists each tool-specific cost and counts them in the summary", () => {
|
||||
render(
|
||||
<MCPServerCostDisplay
|
||||
costConfig={{ tool_name_to_cost_per_query: { search: 0.5, fetch: 0.25, skipped: null } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.5000 per query")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.2500 per query")).toBeInTheDocument();
|
||||
expect(screen.queryByText("skipped")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
|
||||
|
||||
interface MCPServerCostDisplayProps {
|
||||
|
|
@ -15,12 +14,12 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
|
||||
if (!hasCostConfig) {
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="mt-6 border-t border-border pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<Text className="text-gray-600">
|
||||
<div className="rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -28,28 +27,28 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<div className="mt-6 border-t border-border pt-6">
|
||||
<div className="space-y-4">
|
||||
{hasDefaultCost &&
|
||||
costConfig?.default_cost_per_query !== undefined &&
|
||||
costConfig?.default_cost_per_query !== null && (
|
||||
<div>
|
||||
<Text className="font-medium">Default Cost per Query</Text>
|
||||
<div className="text-green-600 font-mono">${costConfig.default_cost_per_query.toFixed(4)}</div>
|
||||
<p className="text-sm font-medium">Default Cost per Query</p>
|
||||
<div className="font-mono text-sm">${costConfig.default_cost_per_query.toFixed(4)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
|
||||
<div>
|
||||
<Text className="font-medium">Tool-Specific Costs</Text>
|
||||
<p className="text-sm font-medium">Tool-Specific Costs</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(costConfig.tool_name_to_cost_per_query).map(
|
||||
([toolName, cost]) =>
|
||||
cost !== null &&
|
||||
cost !== undefined && (
|
||||
<div key={toolName} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<Text className="font-medium">{toolName}</Text>
|
||||
<Text className="text-green-600 font-mono">${cost.toFixed(4)} per query</Text>
|
||||
<div key={toolName} className="flex items-center justify-between rounded-lg bg-muted p-3">
|
||||
<p className="text-sm font-medium">{toolName}</p>
|
||||
<p className="font-mono text-sm">${cost.toFixed(4)} per query</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
|
@ -57,20 +56,20 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Text className="text-blue-800 font-medium">Cost Summary:</Text>
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm font-medium">Cost Summary:</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{hasDefaultCost &&
|
||||
costConfig?.default_cost_per_query !== undefined &&
|
||||
costConfig?.default_cost_per_query !== null && (
|
||||
<Text className="text-blue-700">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
|
||||
<Text className="text-blue-700">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MCPServerView } from "./mcp_server_view";
|
||||
import type { MCPServer } from "@/components/mcp_tools/types";
|
||||
|
||||
vi.mock(".", () => ({
|
||||
MCPToolsViewer: () => <div>tools viewer</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./mcp_server_edit", () => ({
|
||||
default: () => <div>edit form</div>,
|
||||
EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state",
|
||||
}));
|
||||
|
||||
const baseServer = {
|
||||
server_id: "srv-1",
|
||||
server_name: "demo server",
|
||||
alias: "demo_alias",
|
||||
description: "A demo MCP server",
|
||||
transport: "http",
|
||||
url: "https://example.com/mcp",
|
||||
auth_type: "api_key",
|
||||
} as MCPServer;
|
||||
|
||||
const renderView = (overrides: Partial<MCPServer> = {}, props: Record<string, unknown> = {}) =>
|
||||
render(
|
||||
<MCPServerView
|
||||
mcpServer={{ ...baseServer, ...overrides } as MCPServer}
|
||||
onBack={vi.fn()}
|
||||
isProxyAdmin
|
||||
isEditing={false}
|
||||
accessToken="tok"
|
||||
userRole="Admin"
|
||||
userID="u1"
|
||||
availableAccessGroups={[]}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("MCPServerView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Name, alias and description each label the header and a Settings row, so
|
||||
// only the server id is unique to the header.
|
||||
it("shows the server identity in the header", () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("srv-1")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("demo server").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to a placeholder name when the server has neither name nor alias", () => {
|
||||
renderView({ server_name: undefined, alias: undefined });
|
||||
|
||||
expect(screen.getByText("Unnamed Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// "Transport" and "Authentication" label both an Overview card and a Settings
|
||||
// row, so only Overview-exclusive labels identify the Overview panel.
|
||||
it("summarises the connection on the Overview tab", () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText("Host URL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("offers a Settings tab to proxy admins only", () => {
|
||||
renderView();
|
||||
expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Settings tab from non-admins", () => {
|
||||
renderView({}, { isProxyAdmin: false });
|
||||
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the tools viewer on the MCP Tools tab", async () => {
|
||||
renderView();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" }));
|
||||
|
||||
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the read-only settings summary before editing", async () => {
|
||||
renderView({ allow_all_keys: true, available_on_public_internet: false });
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Allow All Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enabled")).toBeInTheDocument();
|
||||
expect(screen.getByText("Internal only")).toBeInTheDocument();
|
||||
expect(screen.queryByText("edit form")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps in the edit form when Edit Settings is pressed", async () => {
|
||||
renderView();
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" }));
|
||||
|
||||
expect(await screen.findByText("edit form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens straight into the edit form when isEditing is set", async () => {
|
||||
renderView({}, { isEditing: true });
|
||||
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("edit form")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on the tab named by initialTabIndex", async () => {
|
||||
renderView({}, { initialTabIndex: 1 });
|
||||
|
||||
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the server list when Back is pressed", async () => {
|
||||
const onBack = vi.fn();
|
||||
renderView({}, { onBack });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the allowed tools, or says all tools are enabled", async () => {
|
||||
renderView({ allowed_tools: ["search", "fetch"] });
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("search")).toBeInTheDocument();
|
||||
expect(screen.getByText("fetch")).toBeInTheDocument();
|
||||
expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says all tools are enabled when no allowlist is stored", async () => {
|
||||
renderView({ allowed_tools: [] });
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByText("All tools enabled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline";
|
||||
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react";
|
||||
import { ArrowLeft, Eye, EyeOff } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
|
||||
// TODO: Move Tools viewer from index file
|
||||
|
|
@ -11,7 +14,6 @@ import MCPServerCostDisplay from "./mcp_server_cost_display";
|
|||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { Button as AntdButton } from "antd";
|
||||
|
||||
interface MCPServerViewProps {
|
||||
mcpServer: MCPServer;
|
||||
|
|
@ -86,335 +88,306 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const getTransportBadge = (transport: string) => {
|
||||
const label = transport.toUpperCase();
|
||||
return (
|
||||
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const getTransportBadge = (transport: string) => <Badge variant="outline">{transport.toUpperCase()}</Badge>;
|
||||
|
||||
const getAuthBadge = (authType: string) => {
|
||||
return (
|
||||
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
|
||||
{authType}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const getAuthBadge = (authType: string) => <Badge variant="outline">{authType}</Badge>;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-full">
|
||||
<div className="max-w-full p-4">
|
||||
<div className="mb-6">
|
||||
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}>
|
||||
<Button variant="ghost" className="mb-4" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
Back to All Servers
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Title className="text-2xl">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</Title>
|
||||
<AntdButton
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
<h1 className="text-2xl font-semibold">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy server name"
|
||||
onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")}
|
||||
className={`transition-all duration-200 ${
|
||||
copiedStates["mcp-server_name"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
/>
|
||||
>
|
||||
{copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
</Button>
|
||||
{mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && (
|
||||
<span className="ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600 border border-gray-200 font-mono">
|
||||
<Badge variant="secondary" className="ml-2 font-mono">
|
||||
{mcpServer.alias}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Text className="text-gray-400 font-mono text-xs">{mcpServer.server_id}</Text>
|
||||
<AntdButton
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<p className="font-mono text-xs text-muted-foreground">{mcpServer.server_id}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy server id"
|
||||
onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")}
|
||||
className={`transition-all duration-200 ${
|
||||
copiedStates["mcp-server-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-300 hover:text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
/>
|
||||
>
|
||||
{copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
|
||||
</Button>
|
||||
</div>
|
||||
{mcpServer.description && <Text className="text-gray-500 mt-2">{mcpServer.description}</Text>}
|
||||
{mcpServer.description && <p className="mt-2 text-sm text-muted-foreground">{mcpServer.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* TODO: magic number for index */}
|
||||
<TabGroup index={selectedTabIndex} onIndexChange={setSelectedTabIndex}>
|
||||
<TabList className="mb-4">
|
||||
{[
|
||||
<Tab key="overview">Overview</Tab>,
|
||||
<Tab key="tools">MCP Tools</Tab>,
|
||||
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []),
|
||||
]}
|
||||
</TabList>
|
||||
<Tabs value={String(selectedTabIndex)} onValueChange={(v: unknown) => setSelectedTabIndex(Number(v))}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="0" className="flex-none">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="1" className="flex-none">
|
||||
MCP Tools
|
||||
</TabsTrigger>
|
||||
{isProxyAdmin && (
|
||||
<TabsTrigger value="2" className="flex-none">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Overview Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-4">
|
||||
<Card className="p-4">
|
||||
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Transport</Text>
|
||||
<div className="mt-3">
|
||||
{getTransportBadge(
|
||||
handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined),
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Authentication</Text>
|
||||
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Host URL</Text>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Text className="break-all overflow-wrap-anywhere font-mono text-sm">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
</Text>
|
||||
{/* Only proxy admins may reveal the raw URL — non-admins
|
||||
receive a sanitized server object from the backend
|
||||
with `url=null`, but hide the toggle anyway as
|
||||
defense-in-depth in case the URL ever leaks back
|
||||
into the response. */}
|
||||
{hasToken && isProxyAdmin && (
|
||||
<button
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
|
||||
>
|
||||
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Card className="mt-4 p-4">
|
||||
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Cost Configuration</Text>
|
||||
{/* Overview Panel */}
|
||||
<TabsContent value="0">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Transport</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
{/* Tool Panel */}
|
||||
<TabPanel>
|
||||
<MCPToolsViewer
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
auth_type={mcpServer.auth_type}
|
||||
oauth2_flow={mcpServer.oauth2_flow}
|
||||
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
</TabPanel>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Authentication</p>
|
||||
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
|
||||
</Card>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>MCP Server Settings</Title>
|
||||
{editing ? null : (
|
||||
<Button variant="light" onClick={() => setEditing(true)}>
|
||||
Edit Settings
|
||||
<Card className="p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Host URL</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<p className="overflow-wrap-anywhere font-mono text-sm break-all">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
</p>
|
||||
{/* Only proxy admins may reveal the raw URL — non-admins
|
||||
receive a sanitized server object from the backend
|
||||
with `url=null`, but hide the toggle anyway as
|
||||
defense-in-depth in case the URL ever leaks back
|
||||
into the response. */}
|
||||
{hasToken && isProxyAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
>
|
||||
{showFullUrl ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<MCPServerEdit
|
||||
mcpServer={mcpServer}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSuccess={handleSuccess}
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Server Name</Text>
|
||||
<div className="col-span-2 text-sm text-gray-900">
|
||||
{mcpServer.server_name || <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-4 p-4">
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
|
||||
<div className="mt-3">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tool Panel */}
|
||||
<TabsContent value="1">
|
||||
<MCPToolsViewer
|
||||
serverId={mcpServer.server_id}
|
||||
accessToken={accessToken}
|
||||
auth_type={mcpServer.auth_type}
|
||||
oauth2_flow={mcpServer.oauth2_flow}
|
||||
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
|
||||
dcr_bridge={mcpServer.dcr_bridge}
|
||||
tokenUrl={mcpServer.token_url}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
serverAlias={mcpServer.alias}
|
||||
extraHeaders={mcpServer.extra_headers}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<TabsContent value="2">
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">MCP Server Settings</h2>
|
||||
{editing ? null : (
|
||||
<Button variant="outline" onClick={() => setEditing(true)}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<MCPServerEdit
|
||||
mcpServer={mcpServer}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSuccess={handleSuccess}
|
||||
availableAccessGroups={availableAccessGroups}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Server Name</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.server_name || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Alias</Text>
|
||||
<div className="col-span-2 text-sm font-mono text-gray-900">
|
||||
{mcpServer.alias || <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Alias</p>
|
||||
<div className="col-span-2 font-mono text-sm">
|
||||
{mcpServer.alias || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Description</Text>
|
||||
<div className="col-span-2 text-sm text-gray-900">
|
||||
{mcpServer.description || <span className="text-gray-400">—</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Description</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.description || <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">URL</Text>
|
||||
<div className="col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
|
||||
>
|
||||
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">URL</p>
|
||||
<div className="col-span-2 flex items-center gap-2 font-mono text-sm break-all">
|
||||
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
|
||||
{hasToken && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
|
||||
onClick={() => setShowFullUrl(!showFullUrl)}
|
||||
>
|
||||
{showFullUrl ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Transport</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Transport</p>
|
||||
<div className="col-span-2">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Authentication</p>
|
||||
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Extra Headers</p>
|
||||
<div className="col-span-2 text-sm">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Allow All Keys</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allow_all_keys ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Network Access</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.available_on_public_internet ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Public
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-orange-500" />
|
||||
Internal only
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{handleAuth(mcpServer.auth_type) === "oauth2" && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Delegate Auth to Upstream</p>
|
||||
<div className="col-span-2">
|
||||
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Authentication</Text>
|
||||
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Extra Headers</Text>
|
||||
<div className="col-span-2 text-sm text-gray-900">
|
||||
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
|
||||
mcpServer.extra_headers.join(", ")
|
||||
{mcpServer.delegate_auth_to_upstream ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Enabled (PKCE passthrough)
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Allow All Keys</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allow_all_keys ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Network Access</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.available_on_public_internet ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Public
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-orange-500"></span>
|
||||
Internal only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{handleAuth(mcpServer.auth_type) === "oauth2" && (
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Delegate Auth to Upstream</Text>
|
||||
)}
|
||||
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
|
||||
Array.isArray(mcpServer.extra_headers) &&
|
||||
mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && (
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">OAuth Pass-through</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.delegate_auth_to_upstream ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Enabled (PKCE passthrough)
|
||||
</span>
|
||||
{mcpServer.oauth_passthrough ? (
|
||||
<Badge variant="outline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
|
||||
Disabled
|
||||
</span>
|
||||
<Badge variant="outline">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
|
||||
Array.isArray(mcpServer.extra_headers) &&
|
||||
mcpServer.extra_headers.some(
|
||||
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
|
||||
) && (
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">OAuth Pass-through</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.oauth_passthrough ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Access Groups</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mcpServer.mcp_access_groups.map((group: any, index: number) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{typeof group === "string" ? group : group?.name ?? ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Access Groups</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mcpServer.mcp_access_groups.map((group: any, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-gray-100 text-gray-700 border border-gray-200"
|
||||
>
|
||||
{typeof group === "string" ? group : group?.name ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Allowed Tools</Text>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mcpServer.allowed_tools.map((tool: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center text-xs font-mono font-medium px-2 py-0.5 rounded-sm bg-blue-50 text-blue-700 border border-blue-200"
|
||||
>
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-green-50 text-green-700 border border-green-200">
|
||||
All tools enabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-3 grid grid-cols-3 gap-4">
|
||||
<Text className="text-sm font-medium text-gray-500">Cost</Text>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Allowed Tools</p>
|
||||
<div className="col-span-2">
|
||||
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{mcpServer.allowed_tools.map((tool: string, index: number) => (
|
||||
<Badge key={index} variant="secondary" className="font-mono">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline">All tools enabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 py-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost</p>
|
||||
<div className="col-span-2">
|
||||
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { render, waitFor, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { render, waitFor, screen, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MCPServers from "./mcp_servers";
|
||||
|
|
@ -307,36 +308,15 @@ describe("MCPServers", () => {
|
|||
expect(screen.getByText("Team B Server")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
|
||||
|
||||
// Find the team select dropdown by looking for the "Team" label
|
||||
// Find the team select by its "Team" label, then the combobox it labels
|
||||
const teamLabel = screen.getByText("Team");
|
||||
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
|
||||
expect(teamSelectContainer).toBeTruthy();
|
||||
const teamSelect = within(teamLabel.parentElement!).getByRole("combobox");
|
||||
|
||||
// Open the dropdown by clicking on the selector
|
||||
const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector");
|
||||
expect(selectSelector).toBeTruthy();
|
||||
await userEvent.click(teamSelect);
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(selectSelector!);
|
||||
});
|
||||
|
||||
// Wait for dropdown to open
|
||||
await waitFor(
|
||||
() => {
|
||||
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
|
||||
expect(dropdownOptions.length).toBeGreaterThan(0);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Find and click on "Team A" option
|
||||
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
|
||||
const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A"));
|
||||
expect(teamAOption).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(teamAOption!);
|
||||
});
|
||||
// Pick the "Team A" option once the listbox opens
|
||||
const teamAOption = await screen.findByText("Team A");
|
||||
await userEvent.click(teamAOption);
|
||||
|
||||
// Wait for filtering to complete
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
|
||||
import { CircleHelp, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import NewBadge from "@/components/common_components/NewBadge";
|
||||
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
|
||||
|
|
@ -75,7 +88,6 @@ const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => {
|
|||
}
|
||||
};
|
||||
|
||||
const { Text: AntdText, Title: AntdTitle } = Typography;
|
||||
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
||||
|
||||
// Server id stashed by the Tools tab before an OBO OAuth redirect, read once at
|
||||
|
|
@ -95,8 +107,6 @@ const readToolsOAuthServerId = (): string | null => {
|
|||
}
|
||||
};
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
|
||||
|
||||
|
|
@ -240,6 +250,15 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
}, [serversWithHealth]);
|
||||
|
||||
// Get unique MCP access groups from all servers
|
||||
const teamSelectItems = React.useMemo(
|
||||
() => ({
|
||||
all: isInternalUser ? "All Available Servers" : "All Servers",
|
||||
personal: "Personal",
|
||||
...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])),
|
||||
}),
|
||||
[isInternalUser, uniqueTeams],
|
||||
);
|
||||
|
||||
const uniqueMcpAccessGroups = React.useMemo(() => {
|
||||
if (!serversWithHealth) return [];
|
||||
return Array.from(
|
||||
|
|
@ -251,6 +270,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
);
|
||||
}, [serversWithHealth]);
|
||||
|
||||
const accessGroupSelectItems = React.useMemo(
|
||||
() => ({
|
||||
all: "All Access Groups",
|
||||
...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])),
|
||||
}),
|
||||
[uniqueMcpAccessGroups],
|
||||
);
|
||||
|
||||
// Filtering logic for both team and access group
|
||||
const filterServers = useCallback(
|
||||
(teamId: string, group: string) => {
|
||||
|
|
@ -390,131 +417,135 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6">
|
||||
<Modal
|
||||
open={isDeleteModalOpen}
|
||||
title="Delete MCP Server?"
|
||||
onOk={confirmDelete}
|
||||
okText={isDeletingServer ? "Deleting..." : "Delete"}
|
||||
onCancel={cancelDelete}
|
||||
cancelText="Cancel"
|
||||
cancelButtonProps={{ disabled: isDeletingServer }}
|
||||
okButtonProps={{ danger: true }}
|
||||
confirmLoading={isDeletingServer}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<AntdText className="text-gray-600">
|
||||
This action is permanent and cannot be undone. All associated configurations will be removed.
|
||||
</AntdText>
|
||||
<TooltipProvider>
|
||||
<div className="h-full w-full p-6">
|
||||
<AlertDialog open={isDeleteModalOpen} onOpenChange={(open) => !open && cancelDelete()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete MCP Server?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This action is permanent and cannot be undone. All associated configurations will be removed.
|
||||
</p>
|
||||
|
||||
{serverToDelete && (
|
||||
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
{serverToDelete.server_name && (
|
||||
<Descriptions.Item label={<span className="text-gray-500 text-sm">Name</span>}>
|
||||
<AntdText strong className="text-sm">
|
||||
{serverToDelete.server_name}
|
||||
</AntdText>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label={<span className="text-gray-500 text-sm">ID</span>}>
|
||||
<AntdText code className="text-xs">
|
||||
{serverToDelete.server_id}
|
||||
</AntdText>
|
||||
</Descriptions.Item>
|
||||
{serverToDelete.url && (
|
||||
<Descriptions.Item label={<span className="text-gray-500 text-sm">URL</span>}>
|
||||
<AntdText code className="text-xs break-all">
|
||||
{serverToDelete.url}
|
||||
</AntdText>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
{serverToDelete && (
|
||||
<dl className="mt-3 space-y-1 rounded-lg border border-border bg-muted p-4">
|
||||
{serverToDelete.server_name && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">Name</dt>
|
||||
<dd className="text-sm font-semibold">{serverToDelete.server_name}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">ID</dt>
|
||||
<dd className="font-mono text-xs">{serverToDelete.server_id}</dd>
|
||||
</div>
|
||||
{serverToDelete.url && (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-sm text-muted-foreground">URL</dt>
|
||||
<dd className="font-mono text-xs break-all">{serverToDelete.url}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
<CreateMCPServer
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
accessToken={accessToken}
|
||||
onCreateSuccess={handleCreateSuccess}
|
||||
isModalVisible={isModalVisible}
|
||||
setModalVisible={setModalVisible}
|
||||
availableAccessGroups={uniqueMcpAccessGroups}
|
||||
prefillData={prefillData}
|
||||
onBackToDiscovery={() => {
|
||||
setModalVisible(false);
|
||||
setPrefillData(null);
|
||||
setDiscoveryVisible(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Title>MCP Servers</Title>
|
||||
{filteredServers.length > 0 && (
|
||||
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200">
|
||||
{filteredServers.length}
|
||||
</span>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingServer}>Cancel</AlertDialogCancel>
|
||||
<Button variant="destructive" disabled={isDeletingServer} onClick={confirmDelete}>
|
||||
{isDeletingServer ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<CreateMCPServer
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
accessToken={accessToken}
|
||||
onCreateSuccess={handleCreateSuccess}
|
||||
isModalVisible={isModalVisible}
|
||||
setModalVisible={setModalVisible}
|
||||
availableAccessGroups={uniqueMcpAccessGroups}
|
||||
prefillData={prefillData}
|
||||
onBackToDiscovery={() => {
|
||||
setModalVisible(false);
|
||||
setPrefillData(null);
|
||||
setDiscoveryVisible(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">MCP Servers</h1>
|
||||
{filteredServers.length > 0 && <Badge variant="secondary">{filteredServers.length}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Configure and manage your MCP servers</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
|
||||
+ Add New MCP Server
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminRole(userRole) && (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setPrefillData(null);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
+ Submit MCP Server
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
|
||||
+ Add New MCP Server
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminRole(userRole) && (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setPrefillData(null);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
+ Submit MCP Server
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MCPDiscovery
|
||||
isVisible={isDiscoveryVisible}
|
||||
onClose={() => setDiscoveryVisible(false)}
|
||||
onSelectServer={(server: DiscoverableMCPServer) => {
|
||||
setPrefillData(server);
|
||||
setDiscoveryVisible(false);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
onCustomServer={() => {
|
||||
setPrefillData(null);
|
||||
setDiscoveryVisible(false);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<TabGroup className="w-full h-full">
|
||||
<TabList className="flex justify-between mt-2 w-full items-center">
|
||||
<div className="flex">
|
||||
<Tab>All Servers</Tab>
|
||||
<Tab>Toolsets</Tab>
|
||||
<Tab>Connect</Tab>
|
||||
{isAdminRole(userRole) && <Tab>Semantic Filter</Tab>}
|
||||
{isAdminRole(userRole) && <Tab>Network Settings</Tab>}
|
||||
<MCPDiscovery
|
||||
isVisible={isDiscoveryVisible}
|
||||
onClose={() => setDiscoveryVisible(false)}
|
||||
onSelectServer={(server: DiscoverableMCPServer) => {
|
||||
setPrefillData(server);
|
||||
setDiscoveryVisible(false);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
onCustomServer={() => {
|
||||
setPrefillData(null);
|
||||
setDiscoveryVisible(false);
|
||||
setModalVisible(true);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
<Tabs defaultValue="servers" className="mt-2 w-full">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="servers" className="flex-none">
|
||||
All Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="toolsets" className="flex-none">
|
||||
Toolsets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="connect" className="flex-none">
|
||||
Connect
|
||||
</TabsTrigger>
|
||||
{isAdminRole(userRole) && (
|
||||
<Tab>
|
||||
<TabsTrigger value="semantic-filter" className="flex-none">
|
||||
Semantic Filter
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdminRole(userRole) && (
|
||||
<TabsTrigger value="network-settings" className="flex-none">
|
||||
Network Settings
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdminRole(userRole) && (
|
||||
<TabsTrigger value="submitted" className="flex-none">
|
||||
<span className="flex items-center gap-2">
|
||||
Submitted MCPs <NewBadge />
|
||||
</span>
|
||||
</Tab>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
</TabsList>
|
||||
<TabsContent value="servers">
|
||||
{selectedServerId ? (
|
||||
<MCPServerView
|
||||
key={selectedServerId}
|
||||
|
|
@ -532,94 +563,117 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<div className="w-full h-full">
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200">
|
||||
<div className="flex items-center gap-6 rounded-lg border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">Team</Text>
|
||||
<Select value={selectedTeam} onChange={handleTeamChange} style={{ width: 220 }} size="middle">
|
||||
<Option value="all">
|
||||
<span className="font-medium">
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Team</p>
|
||||
<Select
|
||||
items={teamSelectItems}
|
||||
value={selectedTeam}
|
||||
onValueChange={(v: string | null) => handleTeamChange(v ?? "all")}
|
||||
>
|
||||
<SelectTrigger className="w-55">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{isInternalUser ? "All Available Servers" : "All Servers"}
|
||||
</span>
|
||||
</Option>
|
||||
<Option value="personal">
|
||||
<span className="font-medium">Personal</span>
|
||||
</Option>
|
||||
{uniqueTeams.map((team) => (
|
||||
<Option key={team.team_id} value={team.team_id}>
|
||||
<span className="font-medium">{team.team_alias || team.team_id}</span>
|
||||
</Option>
|
||||
))}
|
||||
</SelectItem>
|
||||
<SelectItem value="personal">Personal</SelectItem>
|
||||
{uniqueTeams.map((team) => (
|
||||
<SelectItem key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias || team.team_id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-200"></div>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">
|
||||
<p className="flex items-center text-sm font-medium whitespace-nowrap text-muted-foreground">
|
||||
Access Group
|
||||
<Tooltip title="An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers.">
|
||||
<QuestionCircleOutlined style={{ marginLeft: 4, color: "#9ca3af" }} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<CircleHelp
|
||||
className="ml-1 size-3.5 text-muted-foreground"
|
||||
aria-label="About access groups"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
An MCP Access Group is a set of users or teams that have permission to access specific MCP
|
||||
servers. Use access groups to control and organize who can connect to which servers.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
</p>
|
||||
<Select
|
||||
items={accessGroupSelectItems}
|
||||
value={selectedMcpAccessGroup}
|
||||
onChange={handleMcpAccessGroupChange}
|
||||
style={{ width: 220 }}
|
||||
size="middle"
|
||||
onValueChange={(v: string | null) => handleMcpAccessGroupChange(v ?? "all")}
|
||||
>
|
||||
<Option value="all">
|
||||
<span className="font-medium">All Access Groups</span>
|
||||
</Option>
|
||||
{uniqueMcpAccessGroups.map((group) => (
|
||||
<Option key={group} value={group}>
|
||||
<span className="font-medium">{group}</span>
|
||||
</Option>
|
||||
))}
|
||||
<SelectTrigger className="w-55">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Access Groups</SelectItem>
|
||||
{uniqueMcpAccessGroups.map((group) => (
|
||||
<SelectItem key={group} value={group}>
|
||||
{group}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
placeholder="Search by name, alias, URL, or ID"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
<InputGroup className="max-w-80">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search by name, alias, URL, or ID"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="whitespace-nowrap text-sm font-medium text-gray-600">Sort</Text>
|
||||
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Sort</p>
|
||||
<Select
|
||||
items={SORT_OPTIONS}
|
||||
value={sortKey}
|
||||
onChange={(v: SortKey) => setSortKey(v)}
|
||||
style={{ width: 220 }}
|
||||
size="middle"
|
||||
onValueChange={(v: string | null) => setSortKey((v ?? "created_desc") as SortKey)}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
<SelectTrigger className="w-55">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="ml-auto text-xs text-gray-500">
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{displayedServers.length} of {filteredServers.length} servers
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 w-full">
|
||||
{isLoadingServers ? (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-200 bg-white p-12">
|
||||
<Spin tip="Loading MCP servers..." />
|
||||
<div className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Loading MCP servers...</p>
|
||||
</div>
|
||||
) : displayedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-12">
|
||||
<Empty
|
||||
description={
|
||||
filteredServers.length === 0
|
||||
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
|
||||
: "No servers match the current filters or search."
|
||||
}
|
||||
/>
|
||||
<div className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredServers.length === 0
|
||||
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
|
||||
: "No servers match the current filters or search."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
|
@ -650,59 +704,59 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
</TabsContent>
|
||||
<TabsContent value="toolsets">
|
||||
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
</TabsContent>
|
||||
<TabsContent value="connect">
|
||||
<MCPConnect />
|
||||
</TabPanel>
|
||||
</TabsContent>
|
||||
{isAdminRole(userRole) && (
|
||||
<TabPanel>
|
||||
<TabsContent value="semantic-filter">
|
||||
<MCPSemanticFilterSettings accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdminRole(userRole) && (
|
||||
<TabPanel>
|
||||
<TabsContent value="network-settings">
|
||||
<MCPNetworkSettings accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdminRole(userRole) && (
|
||||
<TabPanel>
|
||||
<TabsContent value="submitted">
|
||||
<MCPSubmissionsTab accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
</TabsContent>
|
||||
)}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</Tabs>
|
||||
|
||||
{byokModalServer && (
|
||||
<ByokCredentialModal
|
||||
server={byokModalServer}
|
||||
open={!!byokModalServer}
|
||||
onClose={() => setByokModalServer(null)}
|
||||
onSuccess={(_serverId) => {
|
||||
refetch();
|
||||
setByokModalServer(null);
|
||||
{byokModalServer && (
|
||||
<ByokCredentialModal
|
||||
server={byokModalServer}
|
||||
open={!!byokModalServer}
|
||||
onClose={() => setByokModalServer(null)}
|
||||
onSuccess={(_serverId) => {
|
||||
refetch();
|
||||
setByokModalServer(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */}
|
||||
<UserEnvVarsModal
|
||||
server={activeEnvVarsServer}
|
||||
open={!!activeEnvVarsServer}
|
||||
accessToken={accessToken}
|
||||
onClose={() => {
|
||||
setEnvVarsModalServer(null);
|
||||
setDeepLinkServerId(null);
|
||||
}}
|
||||
onSaved={() => {
|
||||
// Refresh the bulk status so the red "N user fields missing" footer
|
||||
// on each card clears once the user has filled in their values.
|
||||
refetchEnvVarStatus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */}
|
||||
<UserEnvVarsModal
|
||||
server={activeEnvVarsServer}
|
||||
open={!!activeEnvVarsServer}
|
||||
accessToken={accessToken}
|
||||
onClose={() => {
|
||||
setEnvVarsModalServer(null);
|
||||
setDeepLinkServerId(null);
|
||||
}}
|
||||
onSaved={() => {
|
||||
// Refresh the bulk status so the red "N user fields missing" footer
|
||||
// on each card clears once the user has filled in their values.
|
||||
refetchEnvVarStatus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
|
||||
import { Wrench, CircleCheck, Search, Pencil } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel";
|
||||
import { TOOL_DISPLAY_NAME_PATTERN } from "./utils";
|
||||
|
||||
|
|
@ -67,86 +74,74 @@ const ToolRow: React.FC<ToolRowProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border transition-colors ${
|
||||
isEnabled
|
||||
? "bg-blue-50 border-blue-300 hover:border-blue-400"
|
||||
: "bg-gray-50 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
className={cn(
|
||||
"rounded-lg border transition-colors",
|
||||
isEnabled ? "border-primary/40 bg-accent" : "border-border bg-muted",
|
||||
)}
|
||||
>
|
||||
<div className="p-4 cursor-pointer" onClick={() => onToggle(tool.name)}>
|
||||
<div className="cursor-pointer p-4" onClick={() => onToggle(tool.name)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox checked={isEnabled} onChange={() => onToggle(tool.name)} />
|
||||
<Checkbox checked={isEnabled} onCheckedChange={() => onToggle(tool.name)} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="font-medium text-gray-900">{toolNameToDisplayName[tool.name] || tool.name}</Text>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
isEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{isEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
{toolNameToDisplayName[tool.name] && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800">
|
||||
Custom name
|
||||
</span>
|
||||
)}
|
||||
<p className="text-sm font-medium">{toolNameToDisplayName[tool.name] || tool.name}</p>
|
||||
<Badge variant={isEnabled ? "secondary" : "outline"}>{isEnabled ? "Enabled" : "Disabled"}</Badge>
|
||||
{toolNameToDisplayName[tool.name] && <Badge variant="secondary">Custom name</Badge>}
|
||||
</div>
|
||||
{(toolNameToDescription[tool.name] || tool.description) && (
|
||||
<Text className="text-gray-500 text-sm block mt-1">
|
||||
<p className="mt-1 block text-sm text-muted-foreground">
|
||||
{toolNameToDescription[tool.name] || tool.description}
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
<Text className="text-gray-400 text-xs block mt-1">
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={(e) => onToggleExpand(tool.name, e)}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
isEditExpanded ? "bg-blue-100 text-blue-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
title="Edit display name and description"
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
<Pencil />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isEditExpanded && (
|
||||
<div
|
||||
className="px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg"
|
||||
className="space-y-3 rounded-b-lg border-t border-border bg-muted px-4 pt-3 pb-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<Text className="text-xs font-medium text-gray-600 mb-1 block">Display Name</Text>
|
||||
<p className="mb-1 block text-xs font-medium">Display Name</p>
|
||||
<Input
|
||||
placeholder={tool.name}
|
||||
value={toolNameToDisplayName[tool.name] || ""}
|
||||
onChange={(e) => onDisplayNameChange(tool.name, e.target.value)}
|
||||
status={isDisplayNameInvalid ? "error" : undefined}
|
||||
aria-invalid={isDisplayNameInvalid || undefined}
|
||||
/>
|
||||
{isDisplayNameInvalid ? (
|
||||
<Text className="text-xs text-red-500 mt-1 block">
|
||||
<p className="mt-1 block text-xs text-destructive">
|
||||
Only letters, digits, underscores, and hyphens are allowed (no spaces).
|
||||
</Text>
|
||||
</p>
|
||||
) : (
|
||||
<Text className="text-xs text-gray-400 mt-1 block">
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
Override how this tool's name appears to users. Leave blank to use original.
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="text-xs font-medium text-gray-600 mb-1 block">Description</Text>
|
||||
<Input.TextArea
|
||||
<p className="mb-1 block text-xs font-medium">Description</p>
|
||||
<Textarea
|
||||
className="field-sizing-fixed"
|
||||
placeholder={tool.description || "No description"}
|
||||
value={toolNameToDescription[tool.name] || ""}
|
||||
onChange={(e) => onDescriptionChange(tool.name, e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Text className="text-xs text-gray-400 mt-1 block">
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
Override the tool description shown to users. Leave blank to use original.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -398,65 +393,62 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolOutlined className="text-blue-600" />
|
||||
<Title>Tool Configuration</Title>
|
||||
{tools.length > 0 && (
|
||||
<Badge
|
||||
count={tools.length}
|
||||
style={{
|
||||
backgroundColor: "#52c41a",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Wrench className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium">Tool Configuration</h3>
|
||||
{tools.length > 0 && <Badge variant="secondary">{tools.length}</Badge>}
|
||||
</div>
|
||||
{tools.length > 0 && (
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ label: "Risk Groups", value: "crud" },
|
||||
{ label: "Flat List", value: "flat" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === "crud" ? "default" : "outline"}
|
||||
onClick={() => setViewMode("crud")}
|
||||
>
|
||||
Risk Groups
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === "flat" ? "default" : "outline"}
|
||||
onClick={() => setViewMode("flat")}
|
||||
>
|
||||
Flat List
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-blue-800 text-sm">
|
||||
<div className="rounded-lg border border-border bg-muted p-3">
|
||||
<p className="text-sm">
|
||||
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to
|
||||
invoke. Unchecked tools will be blocked from execution.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoadingTools && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Spin size="large" />
|
||||
<Text className="ml-3">Loading tools from spec...</Text>
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<UiLoadingSpinner className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm">Loading tools from spec...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{toolsError && !isLoadingTools && isPreviewForbidden && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<Text className="text-sm text-blue-800">{toolsError}</Text>
|
||||
<div className="rounded-lg border border-border bg-muted p-4">
|
||||
<p className="text-sm">{toolsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsError && !isLoadingTools && !isPreviewForbidden && (
|
||||
<div className="text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50">
|
||||
<ToolOutlined className="text-2xl mb-2" />
|
||||
<Text className="text-red-600 font-medium">Unable to load tools</Text>
|
||||
<br />
|
||||
<Text className="text-sm text-red-500">{toolsError}</Text>
|
||||
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 py-6 text-center">
|
||||
<Wrench className="mx-auto mb-2 size-6 text-destructive" />
|
||||
<p className="text-sm font-medium text-destructive">Unable to load tools</p>
|
||||
<p className="text-sm text-destructive">{toolsError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -466,51 +458,50 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
tools.length === 0 &&
|
||||
canFetchTools &&
|
||||
(keyTools && keyTools.length > 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 border rounded-lg border-dashed">
|
||||
<ToolOutlined className="text-2xl mb-2" />
|
||||
<Text>No tools loaded from spec</Text>
|
||||
<Text className="text-sm block mt-1">Expected tools: {keyTools.map((t) => t.name).join(", ")}</Text>
|
||||
<div className="rounded-lg border border-dashed py-4 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools loaded from spec</p>
|
||||
<p className="mt-1 block text-sm">Expected tools: {keyTools.map((t) => t.name).join(", ")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<ToolOutlined className="text-2xl mb-2" />
|
||||
<Text>No tools available for configuration</Text>
|
||||
<br />
|
||||
<Text className="text-sm">Connect to an MCP server with tools to configure them</Text>
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools available for configuration</p>
|
||||
<p className="text-sm">Connect to an MCP server with tools to configure them</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Incomplete form state */}
|
||||
{!canFetchTools && (formValues.url || formValues.spec_path) && (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<ToolOutlined className="text-2xl mb-2" />
|
||||
<Text>Complete required fields to configure tools</Text>
|
||||
<br />
|
||||
<Text className="text-sm">Fill in URL, Transport, and Authentication to load available tools</Text>
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Wrench className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">Complete required fields to configure tools</p>
|
||||
<p className="text-sm">Fill in URL, Transport, and Authentication to load available tools</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools loaded successfully */}
|
||||
{!isLoadingTools && !toolsError && tools.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<Text className="text-green-700 font-medium">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted p-3">
|
||||
<CircleCheck className="size-4" />
|
||||
<p className="text-sm font-medium">
|
||||
{effectiveAllowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for
|
||||
user access
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search box shared by both views */}
|
||||
<Input
|
||||
placeholder="Search tools by name or description..."
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
/>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tools by name or description..."
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* CRUD grouped view */}
|
||||
{viewMode === "crud" && (
|
||||
|
|
@ -526,31 +517,25 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{viewMode === "flat" && (
|
||||
<>
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
|
||||
<SearchOutlined className="text-2xl mb-2" />
|
||||
<Text>No tools found matching "{toolSearchTerm}"</Text>
|
||||
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
|
||||
<Search className="mx-auto mb-2 size-6" />
|
||||
<p className="text-sm">No tools found matching "{toolSearchTerm}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pinnedFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Suggested tools</p>
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Suggested tools
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnableSuggested}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Button variant="link" size="sm" onClick={handleEnableSuggested}>
|
||||
Enable all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisableSuggested}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="link" size="sm" onClick={handleDisableSuggested}>
|
||||
Disable all
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{pinnedFiltered.map((tool) => (
|
||||
|
|
@ -572,24 +557,16 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
|
|||
{restFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-1 pt-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnableRest}
|
||||
className="text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Button variant="link" size="sm" onClick={handleEnableRest}>
|
||||
Enable all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisableRest}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="link" size="sm" onClick={handleDisableRest}>
|
||||
Disable all
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{restFiltered.map((tool) => (
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
|
|||
import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils";
|
||||
import { setSecureItem } from "@/utils/secureStorage";
|
||||
|
||||
import { Card, Title, Text } from "@tremor/react";
|
||||
import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined, LockOutlined } from "@ant-design/icons";
|
||||
import { Input, Button as AntdButton } from "antd";
|
||||
import { Bot, Wrench, Search, Key, Lock } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
const MCPToolsViewer = ({
|
||||
serverId,
|
||||
|
|
@ -285,79 +289,75 @@ const MCPToolsViewer = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen p-4 bg-white">
|
||||
<Card className="w-full rounded-xl shadow-md overflow-hidden">
|
||||
<div className="flex h-auto w-full gap-4">
|
||||
<div className="w-full p-4">
|
||||
<Card className="w-full overflow-hidden rounded-xl shadow-md">
|
||||
<div className="grid h-auto w-full grid-cols-4 gap-4">
|
||||
{/* Left Sidebar with Controls */}
|
||||
<div className="w-1/4 p-4 bg-gray-50 flex flex-col">
|
||||
<Title className="text-xl font-semibold mb-6 mt-2">MCP Tools</Title>
|
||||
<div className="col-span-1 flex flex-col bg-muted p-4">
|
||||
<h2 className="mt-2 mb-6 text-xl font-semibold">MCP Tools</h2>
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
{/* Extra Headers Input Section */}
|
||||
{hasExtraHeaders && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="mb-4 rounded-lg border border-border bg-card p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<KeyOutlined className="text-blue-600 mr-2" />
|
||||
<Text className="text-sm font-medium text-blue-800">Additional Headers</Text>
|
||||
<Key className="mr-2 size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Additional Headers</p>
|
||||
</div>
|
||||
<AntdButton
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => setShowHeaderInput(!showHeaderInput)}
|
||||
className="text-blue-700 p-0 h-auto"
|
||||
>
|
||||
<Button variant="link" size="sm" onClick={() => setShowHeaderInput(!showHeaderInput)}>
|
||||
{showHeaderInput ? "Hide" : "Configure"}
|
||||
</AntdButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
|
||||
<Text className="text-xs text-blue-700">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This server requires additional headers. Click "Configure" to provide values.
|
||||
</Text>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showHeaderInput && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{extraHeaders?.map((headerName) => (
|
||||
<div key={headerName}>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">{headerName}</label>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={`Enter ${headerName}`}
|
||||
value={passthroughHeaders[headerName] || ""}
|
||||
onChange={(e) => {
|
||||
setPassthroughHeaders({
|
||||
...passthroughHeaders,
|
||||
[headerName]: e.target.value,
|
||||
});
|
||||
}}
|
||||
prefix={<KeyOutlined className="text-gray-400" />}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
<label className="mb-1 block text-xs font-medium">{headerName}</label>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon>
|
||||
<Key className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder={`Enter ${headerName}`}
|
||||
value={passthroughHeaders[headerName] || ""}
|
||||
onChange={(e) => {
|
||||
setPassthroughHeaders({
|
||||
...passthroughHeaders,
|
||||
[headerName]: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
))}
|
||||
<AntdButton
|
||||
size="small"
|
||||
type="primary"
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
refetchTools();
|
||||
setShowHeaderInput(false);
|
||||
}}
|
||||
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
|
||||
className="w-full mt-2"
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
Load Tools
|
||||
</AntdButton>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<Text className="text-xs text-green-700 flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
<p className="flex items-center text-xs text-muted-foreground">
|
||||
<span className="mr-2 inline-block size-2 rounded-full bg-green-500" />
|
||||
{Object.keys(passthroughHeaders).length} header(s) configured
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -365,31 +365,29 @@ const MCPToolsViewer = ({
|
|||
|
||||
{/* Tool Selection - Show tools first */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<Text className="font-medium block mb-3 text-gray-700 flex items-center">
|
||||
<ToolOutlined className="mr-2" /> Available Tools
|
||||
<p className="mb-3 flex items-center text-sm font-medium">
|
||||
<Wrench className="mr-2 size-4" /> Available Tools
|
||||
{toolsData.length > 0 && (
|
||||
<span className="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{toolsData.length}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
{/* Passthrough auth gate — browser session token absent */}
|
||||
{usesBrowserHeldToken && !oauthToken && (
|
||||
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
|
||||
<LockOutlined className="text-2xl text-gray-400 mb-2" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
|
||||
<p className="text-xs text-gray-500 mb-3">Authenticate to view available tools</p>
|
||||
<AntdButton
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">Authentication required</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">Authenticate to view available tools</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={!accessToken}
|
||||
disabled={!accessToken || oauthStatus === "authorizing" || oauthStatus === "exchanging"}
|
||||
>
|
||||
Authorize
|
||||
</AntdButton>
|
||||
{oauthError && <p className="text-xs text-red-500 mt-2">{oauthError}</p>}
|
||||
</Button>
|
||||
{oauthError && <p className="mt-2 text-xs text-destructive">{oauthError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -399,22 +397,20 @@ const MCPToolsViewer = ({
|
|||
with no usable refresh token). A refreshable token is refreshed
|
||||
on the list call and never trips this gate. */}
|
||||
{(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && (
|
||||
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
|
||||
<LockOutlined className="text-2xl text-gray-400 mb-2" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">Authentication required</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Authenticate with the upstream provider to view available tools
|
||||
</p>
|
||||
<AntdButton
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={startAuthorizationCodeAuthorize}
|
||||
disabled={!accessToken}
|
||||
disabled={!accessToken || dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
|
||||
>
|
||||
Authorize
|
||||
</AntdButton>
|
||||
{dbOAuthError && <p className="text-xs text-red-500 mt-2">{dbOAuthError}</p>}
|
||||
</Button>
|
||||
{dbOAuthError && <p className="mt-2 text-xs text-destructive">{dbOAuthError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -423,32 +419,30 @@ const MCPToolsViewer = ({
|
|||
<>
|
||||
{toolsData.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
placeholder="Search tools..."
|
||||
prefix={<SearchOutlined className="text-gray-400" />}
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
allowClear
|
||||
className="rounded-lg"
|
||||
size="middle"
|
||||
/>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon>
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tools..."
|
||||
value={toolSearchTerm}
|
||||
onChange={(e) => setToolSearchTerm(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{toolsAreaLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
|
||||
<div className="relative mb-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-200"></div>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-8">
|
||||
<UiLoadingSpinner className="mb-3 size-6 text-muted-foreground" />
|
||||
<p className="text-xs font-medium">Loading tools...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{(mcpToolsResponse?.error || mcpToolsError) && !toolsAreaLoading && !toolsData.length && (
|
||||
<div className="p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200">
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
<p className="font-medium">
|
||||
Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message}
|
||||
</p>
|
||||
|
|
@ -460,10 +454,10 @@ const MCPToolsViewer = ({
|
|||
!mcpToolsResponse?.error &&
|
||||
!mcpToolsError &&
|
||||
(!toolsData || toolsData.length === 0) && (
|
||||
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
|
||||
<div className="mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2">
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex size-8 items-center justify-center rounded-full bg-muted">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-400"
|
||||
className="size-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -476,8 +470,8 @@ const MCPToolsViewer = ({
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">No tools available</p>
|
||||
<p className="text-xs text-gray-500">No tools found for this server</p>
|
||||
<p className="mb-1 text-xs font-medium">No tools available</p>
|
||||
<p className="text-xs text-muted-foreground">No tools found for this server</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -485,28 +479,22 @@ const MCPToolsViewer = ({
|
|||
{!toolsAreaLoading && !mcpToolsResponse?.error && toolsData.length > 0 && (
|
||||
<>
|
||||
{filteredTools.length === 0 ? (
|
||||
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
|
||||
<SearchOutlined className="text-2xl text-gray-400 mb-2" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p>
|
||||
<p className="text-xs text-gray-500">No tools match "{toolSearchTerm}"</p>
|
||||
<div className="rounded-lg border border-border bg-card p-4 text-center">
|
||||
<Search className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="mb-1 text-xs font-medium">No tools found</p>
|
||||
<p className="text-xs text-muted-foreground">No tools match "{toolSearchTerm}"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
|
||||
style={{
|
||||
maxHeight: "400px",
|
||||
scrollbarWidth: "auto",
|
||||
scrollbarColor: "#cbd5e0 #f7fafc",
|
||||
}}
|
||||
>
|
||||
<div className="mcp-tools-scrollable max-h-100 min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{filteredTools.map((tool: MCPTool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-xs ${
|
||||
className={cn(
|
||||
"cursor-pointer rounded-lg border p-3 transition-all hover:shadow-xs",
|
||||
selectedTool?.name === tool.name
|
||||
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
|
||||
: "border-gray-200 bg-white hover:border-gray-300"
|
||||
}`}
|
||||
? "border-primary bg-accent ring-1 ring-ring"
|
||||
: "border-border bg-card",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedTool(tool);
|
||||
setToolResult(null);
|
||||
|
|
@ -522,18 +510,18 @@ const MCPToolsViewer = ({
|
|||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-mono text-xs font-medium text-gray-900 truncate">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed">
|
||||
<h4 className="truncate font-mono text-xs font-medium">{tool.name}</h4>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tool.mcp_info.server_name}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTool?.name === tool.name && (
|
||||
<div className="mt-2 pt-2 border-t border-blue-200">
|
||||
<div className="flex items-center text-xs font-medium text-blue-700">
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<div className="flex items-center text-xs font-medium text-primary">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
|
@ -558,20 +546,20 @@ const MCPToolsViewer = ({
|
|||
</div>
|
||||
|
||||
{/* Main Testing Area */}
|
||||
<div className="w-3/4 flex flex-col bg-white">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<Title className="text-xl font-semibold mb-0">Tool Testing Playground</Title>
|
||||
<div className="col-span-3 flex flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h2 className="mb-0 text-xl font-semibold">Tool Testing Playground</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{!selectedTool ? (
|
||||
/* Empty State */
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<RobotOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
|
||||
<Text className="text-lg font-medium text-gray-600 mb-2">Select a Tool to Test</Text>
|
||||
<Text className="text-center text-gray-500 max-w-md">
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Bot className="mb-4 size-12" />
|
||||
<p className="mb-2 text-lg font-medium">Select a Tool to Test</p>
|
||||
<p className="max-w-md text-center text-sm">
|
||||
Choose a tool from the left sidebar to start testing its functionality with custom inputs.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Tool Test Panel */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Icon, Button, Col, Text, Grid } from "@tremor/react";
|
||||
import { RefreshIcon } from "@heroicons/react/outline";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TagInfoView from "./tag_info";
|
||||
import { modelInfoCall } from "@/components/networking";
|
||||
import { tagCreateCall, tagListCall, tagDeleteCall } from "@/components/networking";
|
||||
|
|
@ -139,22 +139,18 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
editTag={editTag}
|
||||
/>
|
||||
) : (
|
||||
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<div className="flex justify-between mt-2 w-full items-center mb-4">
|
||||
<div className="mt-2 h-[75vh] w-full gap-2 p-8">
|
||||
<div className="mt-2 mb-4 flex w-full items-center justify-between">
|
||||
<h1>Tag Management</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
|
||||
<Icon
|
||||
icon={RefreshIcon}
|
||||
variant="shadow"
|
||||
size="xs"
|
||||
className="self-center cursor-pointer"
|
||||
onClick={handleRefreshClick}
|
||||
/>
|
||||
{lastRefreshed && <p className="text-sm">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" aria-label="Refresh tags" onClick={handleRefreshClick}>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text className="mb-4">
|
||||
<div className="mb-4 text-sm">
|
||||
Click on a tag name to view and edit its details.
|
||||
<p>
|
||||
You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more
|
||||
|
|
@ -164,14 +160,14 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
</a>
|
||||
.
|
||||
</p>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Create New Tag
|
||||
</Button>
|
||||
|
||||
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
|
||||
<div>
|
||||
<TagTable
|
||||
data={tags}
|
||||
isLoading={isLoadingTags}
|
||||
|
|
@ -182,8 +178,8 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
onDelete={handleDelete}
|
||||
onSelectTag={setSelectedTagId}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Tag Modal */}
|
||||
<CreateTagModal
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { CodeOutlined, PlayCircleOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Input, Space, Tabs, Typography } from "antd";
|
||||
import { Code, CircleAlert, CirclePlay, Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ModelSelector from "@/components/common_components/ModelSelector";
|
||||
import { TestResult } from "./semanticFilterTestUtils";
|
||||
|
||||
|
|
@ -30,148 +34,132 @@ export default function MCPSemanticFilterTestPanel({
|
|||
testError,
|
||||
curlCommand,
|
||||
}: MCPSemanticFilterTestPanelProps) {
|
||||
const canRunTest = testQuery && testModel && filterEnabled;
|
||||
const testDisabled = isTesting || !canRunTest;
|
||||
|
||||
return (
|
||||
<Card title="Test Configuration" style={{ marginBottom: 16 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="test"
|
||||
items={[
|
||||
{
|
||||
key: "test",
|
||||
label: "Test",
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: "100%" }} size="large">
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
<PlayCircleOutlined /> Test Query
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
placeholder="Enter a test query to see which tools would be selected..."
|
||||
value={testQuery}
|
||||
onChange={(e) => setTestQuery(e.target.value)}
|
||||
rows={4}
|
||||
disabled={isTesting}
|
||||
/>
|
||||
</div>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Test Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="test">
|
||||
<TabsList>
|
||||
<TabsTrigger value="test" className="flex-none">
|
||||
Test
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="api" className="flex-none">
|
||||
API Usage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div>
|
||||
<ModelSelector
|
||||
accessToken={accessToken || ""}
|
||||
value={testModel}
|
||||
onChange={setTestModel}
|
||||
disabled={isTesting}
|
||||
showLabel={true}
|
||||
labelText="Select Model"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={onTest}
|
||||
loading={isTesting}
|
||||
disabled={!testQuery || !testModel || !filterEnabled}
|
||||
block
|
||||
>
|
||||
Test Filter
|
||||
</Button>
|
||||
|
||||
{!filterEnabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message="Semantic filtering is disabled"
|
||||
description="Enable semantic filtering and save settings to test the filter."
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message="Semantic filtering did not run"
|
||||
description={testError}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div>
|
||||
<Typography.Title level={5}>Results</Typography.Title>
|
||||
<Alert
|
||||
type={testResult.totalTools - testResult.selectedTools > 0 ? "success" : "warning"}
|
||||
message={`${testResult.selectedTools} of ${testResult.totalTools} tools selected`}
|
||||
description={`${testResult.totalTools - testResult.selectedTools} tools filtered out`}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
Selected Tools:
|
||||
</Typography.Text>
|
||||
<ul style={{ paddingLeft: 20, margin: 0 }}>
|
||||
{testResult.tools.map((tool, index) => (
|
||||
<li key={index} style={{ marginBottom: 4 }}>
|
||||
<Typography.Text>{tool}</Typography.Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{testResult.selectedTools > testResult.tools.length && (
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
|
||||
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "api",
|
||||
label: "API Usage",
|
||||
children: (
|
||||
<TabsContent value="test">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<CodeOutlined />
|
||||
<Typography.Text strong>API Usage</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
Use this curl command to test the semantic filter with your current configuration.
|
||||
</Typography.Text>
|
||||
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
Response headers to check:
|
||||
</Typography.Text>
|
||||
<ul style={{ paddingLeft: 20, margin: "0 0 12px 0" }}>
|
||||
<li>
|
||||
<Typography.Text>x-litellm-semantic-filter: shows total tools → selected tools</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: "block" }}>
|
||||
Example: 10→3
|
||||
</Typography.Text>
|
||||
</li>
|
||||
<li>
|
||||
<Typography.Text>x-litellm-semantic-filter-tools: CSV of selected tool names</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: "block" }}>
|
||||
Example: wikipedia-fetch,github-search,slack-post
|
||||
</Typography.Text>
|
||||
</li>
|
||||
</ul>
|
||||
<pre
|
||||
style={{
|
||||
background: "#f5f5f5",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
overflow: "auto",
|
||||
fontSize: 12,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{curlCommand}
|
||||
</pre>
|
||||
<p className="mb-2 flex items-center gap-1.5 font-medium">
|
||||
<CirclePlay className="size-4" /> Test Query
|
||||
</p>
|
||||
<Textarea
|
||||
className="field-sizing-fixed"
|
||||
placeholder="Enter a test query to see which tools would be selected..."
|
||||
value={testQuery}
|
||||
onChange={(e) => setTestQuery(e.target.value)}
|
||||
rows={4}
|
||||
disabled={isTesting}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<ModelSelector
|
||||
accessToken={accessToken || ""}
|
||||
value={testModel}
|
||||
onChange={setTestModel}
|
||||
disabled={isTesting}
|
||||
showLabel={true}
|
||||
labelText="Select Model"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" onClick={onTest} disabled={testDisabled}>
|
||||
<CirclePlay />
|
||||
Test Filter
|
||||
</Button>
|
||||
|
||||
{!filterEnabled && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertTitle>Semantic filtering is disabled</AlertTitle>
|
||||
<AlertDescription>Enable semantic filtering and save settings to test the filter.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Semantic filtering did not run</AlertTitle>
|
||||
<AlertDescription>{testError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div>
|
||||
<h5 className="mb-2 text-base font-medium">Results</h5>
|
||||
<Alert className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>
|
||||
{testResult.selectedTools} of {testResult.totalTools} tools selected
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{testResult.totalTools - testResult.selectedTools} tools filtered out
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div>
|
||||
<p className="mb-2 block font-medium">Selected Tools:</p>
|
||||
<ul className="m-0 list-disc pl-5">
|
||||
{testResult.tools.map((tool, index) => (
|
||||
<li key={index} className="mb-1">
|
||||
<span>{tool}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{testResult.selectedTools > testResult.tools.length && (
|
||||
<p className="mt-2 block text-sm text-muted-foreground">
|
||||
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Code className="size-4" />
|
||||
<p className="font-medium">API Usage</p>
|
||||
</div>
|
||||
<p className="mb-2 block text-sm text-muted-foreground">
|
||||
Use this curl command to test the semantic filter with your current configuration.
|
||||
</p>
|
||||
<p className="mb-2 block font-medium">Response headers to check:</p>
|
||||
<ul className="mt-0 mr-0 mb-3 ml-0 list-disc pl-5">
|
||||
<li>
|
||||
<span>x-litellm-semantic-filter: shows total tools → selected tools</span>
|
||||
<span className="block text-sm text-muted-foreground">Example: 10→3</span>
|
||||
</li>
|
||||
<li>
|
||||
<span>x-litellm-semantic-filter-tools: CSV of selected tool names</span>
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
Example: wikipedia-fetch,github-search,slack-post
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<pre className="m-0 overflow-auto rounded-sm bg-muted p-3 text-xs">{curlCommand}</pre>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
197
ui/litellm-dashboard/src/components/ToolDetail.test.tsx
Normal file
197
ui/litellm-dashboard/src/components/ToolDetail.test.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ToolDetail } from "./ToolDetail";
|
||||
import {
|
||||
deleteToolPolicyOverride,
|
||||
fetchToolDetail,
|
||||
fetchToolPolicyOptions,
|
||||
getToolUsageLogs,
|
||||
keyListCall,
|
||||
teamListCall,
|
||||
updateToolPolicy,
|
||||
type ToolDetailResponse,
|
||||
type ToolPolicyOption,
|
||||
type ToolPolicyOverrideRow,
|
||||
type ToolRow,
|
||||
type ToolUsageLogsResponse,
|
||||
} from "@/components/networking";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
deleteToolPolicyOverride: vi.fn(),
|
||||
fetchToolDetail: vi.fn(),
|
||||
fetchToolPolicyOptions: vi.fn(),
|
||||
getToolUsageLogs: vi.fn(),
|
||||
keyListCall: vi.fn(),
|
||||
teamListCall: vi.fn(),
|
||||
updateToolPolicy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common_components/team_dropdown", () => ({
|
||||
default: ({ onChange }: { onChange: (id: string) => void }) => (
|
||||
<button type="button" onClick={() => onChange("team-1")}>
|
||||
pick team
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
|
||||
LogViewer: ({ totalLogs }: { totalLogs: number }) => <div>log viewer ({totalLogs})</div>,
|
||||
}));
|
||||
|
||||
const detail = {
|
||||
tool: {
|
||||
tool_name: "search_docs",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "trusted",
|
||||
origin: "mcp",
|
||||
call_count: 42,
|
||||
user_agent: "litellm-python/1.0",
|
||||
created_at: "2026-03-04T10:00:00Z",
|
||||
},
|
||||
overrides: [],
|
||||
} as unknown as ToolDetailResponse;
|
||||
|
||||
const renderDetail = (onBack = vi.fn()) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToolDetail toolName="search_docs" onBack={onBack} accessToken="tok" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("ToolDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchToolDetail).mockResolvedValue(detail);
|
||||
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] });
|
||||
vi.mocked(teamListCall).mockResolvedValue({ data: [] });
|
||||
vi.mocked(keyListCall).mockResolvedValue({ keys: [] });
|
||||
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse);
|
||||
vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow);
|
||||
vi.mocked(deleteToolPolicyOverride).mockResolvedValue(
|
||||
undefined as unknown as { deleted: boolean; tool_name: string },
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the tool identity once loaded", async () => {
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("search_docs")).toBeInTheDocument();
|
||||
expect(screen.getByText("mcp")).toBeInTheDocument();
|
||||
expect(screen.getByText("42 calls")).toBeInTheDocument();
|
||||
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both policy panels with the tool's current policies", async () => {
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Input Policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output Policy")).toBeInTheDocument();
|
||||
expect(screen.getByText("untrusted")).toBeInTheDocument();
|
||||
expect(screen.getByText("trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the policy option descriptions when the backend supplies them", async () => {
|
||||
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({
|
||||
input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption],
|
||||
output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption],
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Outputs may be chained")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the list when Back is pressed", async () => {
|
||||
const onBack = vi.fn();
|
||||
renderDetail(onBack);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ }));
|
||||
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed detail load and still offers a way back", async () => {
|
||||
vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope"));
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the overrides panel when the tool has none", async () => {
|
||||
renderDetail();
|
||||
|
||||
await screen.findByText("Input Policy");
|
||||
expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists existing overrides and removes the chosen one", async () => {
|
||||
vi.mocked(fetchToolDetail).mockResolvedValue({
|
||||
...detail,
|
||||
overrides: [
|
||||
{
|
||||
override_id: "o1",
|
||||
team_id: "team-alpha",
|
||||
key_hash: null,
|
||||
key_alias: null,
|
||||
} as unknown as ToolPolicyOverrideRow,
|
||||
],
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", {
|
||||
team_id: "team-alpha",
|
||||
key_hash: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the block button disabled until a team is chosen, then blocks that team", async () => {
|
||||
renderDetail();
|
||||
|
||||
const blockButton = await screen.findByRole("button", { name: /Block for team/ });
|
||||
expect(blockButton).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "pick team" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled());
|
||||
await userEvent.click(screen.getByRole("button", { name: /Block for team/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateToolPolicy).toHaveBeenCalledWith(
|
||||
"tok",
|
||||
"search_docs",
|
||||
{ input_policy: "blocked" },
|
||||
{ team_id: "team-1", key_hash: undefined, key_alias: undefined },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("switches the block scope to a key", async () => {
|
||||
renderDetail();
|
||||
|
||||
await screen.findByText("Block for team or key");
|
||||
await userEvent.click(screen.getByRole("radio", { name: "Key" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the usage-log total through to the log viewer", async () => {
|
||||
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse);
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText("log viewer (7)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeftOutlined, HistoryOutlined, ToolOutlined } from "@ant-design/icons";
|
||||
import { ArrowLeft, History, Wrench } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Select, Spin } from "antd";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer";
|
||||
import type { LogEntry } from "@/components/GuardrailsMonitor/mockData";
|
||||
|
|
@ -32,6 +42,11 @@ interface KeyOption {
|
|||
key_alias?: string;
|
||||
}
|
||||
|
||||
interface KeyItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TOOL_DETAIL_QUERY_KEY = "tool-detail";
|
||||
|
||||
const LOGS_PAGE_SIZE = 50;
|
||||
|
|
@ -133,6 +148,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
}));
|
||||
}, [keysData]);
|
||||
|
||||
const keyItems: KeyItem[] = useMemo(
|
||||
() => keys.map((k) => ({ value: k.token, label: k.key_alias || k.token?.substring?.(0, 12) || k.token })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const invalidateDetail = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: [TOOL_DETAIL_QUERY_KEY, toolName] });
|
||||
}, [queryClient, toolName]);
|
||||
|
|
@ -218,7 +238,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
if (detailLoading && !detail) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin size="large" />
|
||||
<UiLoadingSpinner className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -226,10 +246,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
if (detailError && !detail) {
|
||||
return (
|
||||
<div>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4">
|
||||
<Button variant="link" onClick={onBack} className="mb-4 pl-0">
|
||||
<ArrowLeft />
|
||||
Back to Tool Policies
|
||||
</Button>
|
||||
<p className="text-red-600">Failed to load tool details.</p>
|
||||
<p className="text-destructive">Failed to load tool details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -246,40 +267,37 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4">
|
||||
<Button variant="link" onClick={onBack} className="mb-4 pl-0">
|
||||
<ArrowLeft />
|
||||
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 className="mb-1 flex flex-wrap items-center gap-3">
|
||||
<Wrench className="size-5 text-muted-foreground" />
|
||||
<h1 className="font-mono text-xl font-semibold">{tool.tool_name}</h1>
|
||||
<Badge variant="outline">{tool.origin ?? "—"}</Badge>
|
||||
<Badge variant="secondary">{(tool.call_count ?? 0).toLocaleString()} calls</Badge>
|
||||
</div>
|
||||
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600">
|
||||
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground">
|
||||
{tool.user_agent && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<dt className="font-medium text-gray-500 whitespace-nowrap">User Agent:</dt>
|
||||
<dd className="font-mono truncate max-w-[40ch]" title={tool.user_agent}>
|
||||
<dt className="font-medium whitespace-nowrap">User Agent:</dt>
|
||||
<dd className="max-w-[40ch] truncate font-mono" title={tool.user_agent}>
|
||||
{tool.user_agent}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{tool.created_at && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<dt className="font-medium text-gray-500 whitespace-nowrap">First Discovered:</dt>
|
||||
<dt className="font-medium whitespace-nowrap">First Discovered:</dt>
|
||||
<dd>{new Date(tool.created_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
{tool.last_used_at && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<dt className="font-medium text-gray-500 whitespace-nowrap">Last Used:</dt>
|
||||
<dt className="font-medium whitespace-nowrap">Last Used:</dt>
|
||||
<dd>{new Date(tool.last_used_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -291,9 +309,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
<div className="space-y-6">
|
||||
{/* Two-panel policy layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-1">Input Policy</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
|
||||
<h2 className="mb-1 text-sm font-semibold">Input Policy</h2>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{inputDesc ?? "Controls what data this tool is allowed to accept."}
|
||||
</p>
|
||||
<PolicySelect
|
||||
|
|
@ -308,9 +326,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
/>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-1">Output Policy</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
|
||||
<h2 className="mb-1 text-sm font-semibold">Output Policy</h2>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{outputDesc ?? "Controls how this tool's output is trusted by downstream tools."}
|
||||
</p>
|
||||
<PolicySelect
|
||||
|
|
@ -327,24 +345,18 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</div>
|
||||
|
||||
{overrides.length > 0 && (
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
|
||||
<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">
|
||||
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
|
||||
<h2 className="mb-3 text-sm font-semibold">Blocked for team or key</h2>
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{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">
|
||||
<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)}
|
||||
>
|
||||
<Button variant="link" size="sm" disabled={overrideSaving} onClick={() => handleRemoveOverride(ov)}>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
|
|
@ -353,13 +365,13 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
|
||||
<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">
|
||||
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
|
||||
<h2 className="mb-3 text-sm font-semibold">Block for team or key</h2>
|
||||
<div className="flex max-w-md flex-col gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block mb-2">Scope</span>
|
||||
<span className="mb-2 block text-sm font-medium">Scope</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "team"}
|
||||
|
|
@ -368,7 +380,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
/>
|
||||
Team
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={blockScope === "key"}
|
||||
|
|
@ -380,36 +392,34 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block mb-2">
|
||||
{blockScope === "team" ? "Team" : "Key"}
|
||||
</span>
|
||||
<span className="mb-2 block text-sm font-medium">{blockScope === "team" ? "Team" : "Key"}</span>
|
||||
{blockScope === "team" ? (
|
||||
<TeamDropdown 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 }}
|
||||
/>
|
||||
<Combobox
|
||||
items={keyItems}
|
||||
value={keyItems.find((k) => k.value === blockKey?.token) ?? null}
|
||||
onValueChange={(item: KeyItem | null) =>
|
||||
setBlockKey(keys.find((k) => k.token === item?.value) ?? null)
|
||||
}
|
||||
>
|
||||
<ComboboxInput placeholder="Select key" showClear className="w-full min-w-50" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No keys found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: KeyItem) => (
|
||||
<ComboboxItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
variant="destructive"
|
||||
disabled={overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)}
|
||||
loading={overrideSaving}
|
||||
onClick={handleAddOverride}
|
||||
>
|
||||
Block for {blockScope}
|
||||
|
|
@ -417,9 +427,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
|
||||
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
|
||||
<HistoryOutlined />
|
||||
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<History className="size-4" />
|
||||
Recent logs
|
||||
</h2>
|
||||
<LogViewer
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ describe("PolicySelect", () => {
|
|||
it("should be disabled when saving is true", () => {
|
||||
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={true} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
|
||||
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled when saving is false", () => {
|
||||
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={false} onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
|
||||
expect(screen.getByRole("combobox")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Select } from "antd";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export const INPUT_POLICY_OPTIONS = [
|
||||
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
|
||||
{ value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
|
||||
{ value: "trusted", label: "trusted", dot: "bg-green-500" },
|
||||
{ value: "blocked", label: "blocked", dot: "bg-red-500" },
|
||||
] as const;
|
||||
|
||||
export const OUTPUT_POLICY_OPTIONS = [
|
||||
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
|
||||
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
|
||||
{ value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
|
||||
{ value: "trusted", label: "trusted", dot: "bg-green-500" },
|
||||
] as const;
|
||||
|
||||
export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS;
|
||||
|
|
@ -36,56 +37,30 @@ export const PolicySelect: React.FC<PolicySelectProps> = ({
|
|||
onChange,
|
||||
policyType = "input",
|
||||
size = "small",
|
||||
minWidth = 110,
|
||||
stopPropagation = true,
|
||||
}) => {
|
||||
const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS;
|
||||
const style = policyStyle(value);
|
||||
const selected = 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,
|
||||
backgroundColor: style.bg,
|
||||
borderColor: style.border,
|
||||
color: style.color,
|
||||
borderRadius: 999,
|
||||
fontSize: size === "small" ? 11 : 12,
|
||||
}}
|
||||
popupMatchSelectWidth={false}
|
||||
options={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>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
<Select value={value} disabled={saving} onValueChange={(v: string | null) => v !== null && onChange(toolName, v)}>
|
||||
<SelectTrigger
|
||||
size={size === "small" ? "sm" : "default"}
|
||||
className="w-auto min-w-28"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
>
|
||||
<span className={cn("size-2 shrink-0 rounded-full", selected.dot)} />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={cn("size-2 shrink-0 rounded-full", o.dot)} />
|
||||
{o.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,21 +67,27 @@ const row = (toolId: string): 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 ?? "";
|
||||
/**
|
||||
* Exact selected-value text, read off the policy cell and stripped of anything
|
||||
* that is not a letter (the control draws a status dot and a chevron around the
|
||||
* label). Never assert with toHaveTextContent here: it substring-matches, so
|
||||
* "untrusted" satisfies "trusted".
|
||||
*/
|
||||
const policyValue = (toolId: string, kind: "input" | "output"): string => {
|
||||
const cell = policySelect(toolId, kind).closest("td");
|
||||
return (cell?.textContent ?? "").replace(/[^a-z]/gi, "");
|
||||
};
|
||||
|
||||
const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
|
||||
policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false;
|
||||
policySelect(toolId, kind).hasAttribute("disabled");
|
||||
|
||||
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => {
|
||||
await user.click(trigger);
|
||||
// The label also renders in the trigger once selected, so take the last match:
|
||||
// the popup is portalled after the table in document order.
|
||||
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;
|
||||
const matches = screen.getAllByText(label);
|
||||
return matches[matches.length - 1];
|
||||
});
|
||||
await user.click(option);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
|
||||
import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
|
||||
import type { ToolRow } from "@/components/networking";
|
||||
|
||||
const row: ToolRow = {
|
||||
tool_name: "search_docs",
|
||||
input_policy: "untrusted",
|
||||
output_policy: "trusted",
|
||||
call_count: 1234,
|
||||
team_id: "team-alpha",
|
||||
key_hash: "abc123def456",
|
||||
key_alias: "prod-key",
|
||||
user_agent: "litellm-python/1.0",
|
||||
created_at: "2026-03-04T10:00:00Z",
|
||||
} as ToolRow;
|
||||
|
||||
const defaultDeps = {
|
||||
onSelectTool: vi.fn(),
|
||||
savingInput: new Set<string>(),
|
||||
savingOutput: new Set<string>(),
|
||||
onInputPolicyChange: vi.fn(),
|
||||
onOutputPolicyChange: vi.fn(),
|
||||
};
|
||||
|
||||
// Renders the column definitions through a real TanStack table so each `cell`
|
||||
// renderer runs exactly as the DataTable runs it.
|
||||
function TableHarness({ columns, data }: { columns: ColumnDef<ToolRow>[]; data: ToolRow[] }) {
|
||||
const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
{r.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTable = (deps = {}, data: ToolRow[] = [row]) =>
|
||||
render(<TableHarness columns={getToolPoliciesTableColumns({ ...defaultDeps, ...deps })} data={data} />);
|
||||
|
||||
describe("getToolPoliciesTableColumns", () => {
|
||||
it("defines the expected columns in order", () => {
|
||||
const columns = getToolPoliciesTableColumns(defaultDeps);
|
||||
|
||||
expect(columns.map((c) => c.id)).toEqual([
|
||||
"created_at",
|
||||
"tool_name",
|
||||
"input_policy",
|
||||
"output_policy",
|
||||
"call_count",
|
||||
"team_id",
|
||||
"key_hash",
|
||||
"key_alias",
|
||||
"user_agent",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the row's identifying fields", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("search_docs")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-alpha")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod-key")).toBeInTheDocument();
|
||||
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats the call count with thousands separators", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a zero call count rather than a blank cell", () => {
|
||||
renderTable({}, [{ ...row, call_count: undefined } as ToolRow]);
|
||||
|
||||
expect(screen.getByText("0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to a dash for a missing key alias and user agent", () => {
|
||||
renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]);
|
||||
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("notifies the caller when the tool name is clicked", async () => {
|
||||
const onSelectTool = vi.fn();
|
||||
renderTable({ onSelectTool });
|
||||
|
||||
await userEvent.click(screen.getByText("search_docs"));
|
||||
|
||||
expect(onSelectTool).toHaveBeenCalledWith("search_docs");
|
||||
});
|
||||
|
||||
it("renders a policy control for each direction, showing the row's current policies", () => {
|
||||
renderTable();
|
||||
|
||||
expect(screen.getByText("untrusted")).toBeInTheDocument();
|
||||
expect(screen.getByText("trusted")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("disables only the input policy control while that direction is saving", () => {
|
||||
renderTable({ savingInput: new Set(["search_docs"]) });
|
||||
|
||||
const [input, output] = screen.getAllByRole("combobox");
|
||||
expect(input).toBeDisabled();
|
||||
expect(output).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables only the output policy control while that direction is saving", () => {
|
||||
renderTable({ savingOutput: new Set(["search_docs"]) });
|
||||
|
||||
const [input, output] = screen.getAllByRole("combobox");
|
||||
expect(input).toBeEnabled();
|
||||
expect(output).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
"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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import { PolicySelect } from "./PolicySelect";
|
||||
|
||||
|
|
@ -20,9 +20,12 @@ interface ToolPoliciesTableColumnsDeps {
|
|||
function TruncatedText({ value, className }: { value: string | undefined; className?: string }) {
|
||||
const text = value ?? "-";
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<span className={className}>{text}</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className={className}>{text}</span>} />
|
||||
<TooltipContent>{text}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue