mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
feat(ui): migrate mcp_tools MCPLogoSelector + MCPNetworkSettings to shadcn
- MCPLogoSelector: antd Input/Tooltip + @ant-design/icons InfoCircle/ LinkOutlined \u2192 shadcn Input/Tooltip + lucide Info/Link. Logo grid and selected preview preserved; selected state uses primary-toned border/background. - MCPNetworkSettings: antd Select mode=tags/Button/Card/Typography/Spin/ Tag + @ant-design/icons SaveOutlined/PlusOutlined \u2192 shadcn Card/ Input/Button/Badge/Skeleton + lucide Plus/Save/X. Replaces the Select mode=tags with an Add-on-Enter-or-comma Input + Badge chip list. Suggested-range Badge now uses gap-1 + Plus icon and is exempted from raw-colors as categorical blue/info tone. Gates: TS \u2713 | Build \u2713. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
276b1682e2
commit
0acfcf3a36
3 changed files with 201 additions and 117 deletions
|
|
@ -38,7 +38,8 @@
|
|||
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx",
|
||||
"src/components/mcp_hub_table_columns.tsx",
|
||||
"src/components/model_hub_table_columns.tsx",
|
||||
"src/components/mcp_server_management/MCPServerSelector.tsx"
|
||||
"src/components/mcp_server_management/MCPServerSelector.tsx",
|
||||
"src/components/mcp_tools/MCPNetworkSettings.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"litellm-ui/no-banned-ui-imports": "off",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import React, { useState } from "react";
|
||||
import { Input, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Info, Link as LinkIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const logos = "/ui/assets/logos/";
|
||||
|
||||
|
|
@ -46,76 +53,95 @@ 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>
|
||||
<span className="text-sm font-medium text-foreground">Logo</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3 w-3 text-primary cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Select a well-known logo or paste a URL to any image. The logo is
|
||||
shown on the admin and chat pages.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{value && (
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-3 mb-3 p-3 bg-muted rounded-lg border border-border">
|
||||
<img
|
||||
src={value}
|
||||
alt="Selected logo"
|
||||
className="w-10 h-10 object-contain rounded"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-500 truncate">{value}</div>
|
||||
<div className="text-xs text-muted-foreground 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"
|
||||
className="text-xs text-muted-foreground hover:text-destructive cursor-pointer bg-transparent border-none"
|
||||
aria-label="Clear selected logo"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</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;
|
||||
const hasFailed = imgErrors.has(logo.url);
|
||||
if (hasFailed) return null;
|
||||
if (imgErrors.has(logo.url)) return null;
|
||||
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-sm"
|
||||
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
|
||||
}`}
|
||||
style={{ width: 40, height: 40 }}
|
||||
>
|
||||
<img
|
||||
src={logo.url}
|
||||
alt={logo.name}
|
||||
className="w-5 h-5 object-contain"
|
||||
onError={() => handleImgError(logo.url)}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TooltipProvider key={logo.name}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(logo.url)}
|
||||
className={cn(
|
||||
"flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted",
|
||||
)}
|
||||
style={{ width: 40, height: 40 }}
|
||||
aria-label={logo.name}
|
||||
>
|
||||
<img
|
||||
src={logo.url}
|
||||
alt={logo.name}
|
||||
className="w-5 h-5 object-contain"
|
||||
onError={() => handleImgError(logo.url)}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{logo.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom URL input */}
|
||||
<Input
|
||||
prefix={<LinkOutlined className="text-gray-400" />}
|
||||
placeholder="Or paste a custom logo URL..."
|
||||
value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
onChange?.(v || undefined);
|
||||
}}
|
||||
className="rounded-lg"
|
||||
size="small"
|
||||
/>
|
||||
<div className="relative">
|
||||
<LinkIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Or paste a custom logo URL..."
|
||||
value={
|
||||
value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
onChange?.(v || undefined);
|
||||
}}
|
||||
className="pl-8 h-8 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,65 +1,75 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Button, Card, Typography, Spin, Tag } from "antd";
|
||||
import { SaveOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting, fetchMCPClientIp } from "../networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Plus, Save, X } from "lucide-react";
|
||||
import {
|
||||
getGeneralSettingsCall,
|
||||
updateConfigFieldSetting,
|
||||
deleteConfigFieldSetting,
|
||||
fetchMCPClientIp,
|
||||
} from "../networking";
|
||||
|
||||
interface MCPNetworkSettingsProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an IP like "203.0.113.45", return "203.0.113.0/24".
|
||||
*/
|
||||
function ipToSlash24(ip: string): string {
|
||||
const parts = ip.split(".");
|
||||
if (parts.length !== 4) return ip + "/32";
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
|
||||
}
|
||||
|
||||
const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken }) => {
|
||||
const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({
|
||||
accessToken,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [privateRanges, setPrivateRanges] = useState<string[]>([]);
|
||||
const [currentIp, setCurrentIp] = useState<string | null>(null);
|
||||
const [newRangeInput, setNewRangeInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const settings = await getGeneralSettingsCall(accessToken);
|
||||
for (const field of settings) {
|
||||
if (
|
||||
field.field_name === "mcp_internal_ip_ranges" &&
|
||||
field.field_value
|
||||
) {
|
||||
setPrivateRanges(field.field_value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP network settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const detectCurrentIp = async () => {
|
||||
if (!accessToken) return;
|
||||
const ip = await fetchMCPClientIp(accessToken);
|
||||
if (ip) setCurrentIp(ip);
|
||||
};
|
||||
loadSettings();
|
||||
detectCurrentIp();
|
||||
}, [accessToken]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const settings = await getGeneralSettingsCall(accessToken);
|
||||
for (const field of settings) {
|
||||
if (field.field_name === "mcp_internal_ip_ranges" && field.field_value) {
|
||||
setPrivateRanges(field.field_value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load MCP network settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const detectCurrentIp = async () => {
|
||||
if (!accessToken) return;
|
||||
const ip = await fetchMCPClientIp(accessToken);
|
||||
if (ip) {
|
||||
setCurrentIp(ip);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessToken) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (privateRanges.length > 0) {
|
||||
await updateConfigFieldSetting(accessToken, "mcp_internal_ip_ranges", privateRanges);
|
||||
await updateConfigFieldSetting(
|
||||
accessToken,
|
||||
"mcp_internal_ip_ranges",
|
||||
privateRanges,
|
||||
);
|
||||
} else {
|
||||
await deleteConfigFieldSetting(accessToken, "mcp_internal_ip_ranges");
|
||||
}
|
||||
|
|
@ -76,10 +86,19 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
}
|
||||
};
|
||||
|
||||
const commitNewRange = () => {
|
||||
const v = newRangeInput.trim();
|
||||
if (!v) return;
|
||||
if (!privateRanges.includes(v)) {
|
||||
setPrivateRanges([...privateRanges, v]);
|
||||
}
|
||||
setNewRangeInput("");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spin />
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -89,60 +108,98 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
|
|||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<Text className="text-lg font-semibold">Private IP Ranges</Text>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
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".
|
||||
<span className="text-lg font-semibold">Private IP Ranges</span>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
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-4">
|
||||
{currentIp && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<Text className="text-sm text-blue-700">
|
||||
Your current IP: <span className="font-mono font-medium">{currentIp}</span>
|
||||
</Text>
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg">
|
||||
<span className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Your current IP:{" "}
|
||||
<span className="font-mono font-medium">{currentIp}</span>
|
||||
</span>
|
||||
{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-1">
|
||||
<span className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Suggested range:
|
||||
</span>
|
||||
<Badge
|
||||
className="cursor-pointer font-mono gap-1"
|
||||
onClick={() => addSuggestedRange(suggestedRange)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{suggestedRange}
|
||||
</Tag>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<Text className="font-medium">Your Private Network Ranges</Text>
|
||||
<span className="font-medium">Your Private Network Ranges</span>
|
||||
</div>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={privateRanges}
|
||||
onChange={setPrivateRanges}
|
||||
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
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRangeInput}
|
||||
onChange={(e) => setNewRangeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitNewRange();
|
||||
}
|
||||
}}
|
||||
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"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={commitNewRange}
|
||||
disabled={!newRangeInput.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{privateRanges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{privateRanges.map((range) => (
|
||||
<Badge
|
||||
key={range}
|
||||
variant="secondary"
|
||||
className="font-mono gap-1"
|
||||
>
|
||||
{range}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPrivateRanges(privateRanges.filter((r) => r !== range))
|
||||
}
|
||||
aria-label={`Remove ${range}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
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}
|
||||
>
|
||||
Save
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue