feat(admin): 移除后台 Agent/技能/MCP 手动创建按钮,改为仅支持 URL 导入/AI 开发/AI 安装 - 后台 Agent管理/技能管理/MCP服务 移除 创建/添加 按钮 - 后端上传/创建按操作者角色设定默认值:管理员=官方+已发布+免费;用户=用户+待审核+定价自选 - 应用市场 Hub 导入新增 mcp.so / MCPMarket 平台,导入表单增加定价输入(用户可选收费) - 清理全部演示数据(market_apps/mcp_servers/mcp_tool_configs/evolution_skills)
This commit is contained in:
1 parent
d62848ee98
commit
27ba91ae5e
8 files changed
+7414
-6115
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+616
-601
File diff suppressed because it is too large.
Load diff
+701
-702
File diff suppressed because it is too large.
Load diff
+405
-404
@@ -1,404 +1,405 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
const styles: Record<string, React.CSSProperties> = {
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
|
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
|
||||||
title: { fontSize: 24, fontWeight: 700, color: '#1a1a2e' },
|
title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
|
||||||
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
||||||
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
|
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
|
||||||
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
||||||
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
|
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
|
||||||
statValue: { fontSize: 28, fontWeight: 700, color: '#1a1a2e' },
|
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
|
||||||
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
|
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
|
||||||
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
|
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
|
||||||
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
|
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
|
||||||
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
|
||||||
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
|
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
|
||||||
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
|
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
|
||||||
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
|
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
|
||||||
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
|
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
|
||||||
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
|
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
|
||||||
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
||||||
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
|
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
|
||||||
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
|
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
|
||||||
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1a1a2e' },
|
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' },
|
||||||
formGroup: { marginBottom: 16 },
|
formGroup: { marginBottom: 16 },
|
||||||
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
|
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
|
||||||
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
|
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
|
||||||
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
|
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
|
||||||
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
|
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
|
||||||
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
|
||||||
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
|
||||||
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
|
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
|
||||||
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
|
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
|
||||||
detailsItem: { fontSize: 13 },
|
detailsItem: { fontSize: 13 },
|
||||||
detailsLabel: { color: '#6b7280', marginBottom: 4 },
|
detailsLabel: { color: '#6b7280', marginBottom: 4 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRANSPORT_TYPES = [
|
const TRANSPORT_TYPES = [
|
||||||
{ value: '', label: 'All Types' },
|
{ value: '', label: '全部类型' },
|
||||||
{ value: 'stdio', label: 'STDIO' },
|
{ value: 'stdio', label: 'STDIO' },
|
||||||
{ value: 'sse', label: 'SSE' },
|
{ value: 'sse', label: 'SSE' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_TYPES = [
|
const STATUS_TYPES = [
|
||||||
{ value: '', label: 'All Status' },
|
{ value: '', label: '全部状态' },
|
||||||
{ value: 'connected', label: 'Connected' },
|
{ value: 'connected', label: '已连接' },
|
||||||
{ value: 'disconnected', label: 'Disconnected' },
|
{ value: 'disconnected', label: '未连接' },
|
||||||
{ value: 'error', label: 'Error' },
|
{ value: 'error', label: '错误' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface MCPServer {
|
interface MCPServer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
transport_type: string;
|
transport_type: string;
|
||||||
transport_type_label: string;
|
transport_type_label: string;
|
||||||
command: string;
|
command: string;
|
||||||
args: Record<string, any>;
|
args: Record<string, any>;
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
url: string;
|
url: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
last_connected_at: string;
|
last_connected_at: string;
|
||||||
tools_count: number;
|
tools_count: number;
|
||||||
resources_count: number;
|
resources_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminMcp() {
|
export default function AdminMcp() {
|
||||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||||
const [stats, setStats] = useState<any>({});
|
const [stats, setStats] = useState<any>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterTransport, setFilterTransport] = useState('');
|
const [filterTransport, setFilterTransport] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
|
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
|
||||||
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|
||||||
const showToast = (message: string, type: string = 'success') => {
|
const showToast = (message: string, type: string = 'success') => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
setTimeout(() => setToast(null), 3000);
|
setTimeout(() => setToast(null), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await adminAPI.getAdminMcpStats() as any;
|
const result = await adminAPI.getAdminMcpStats() as any;
|
||||||
if (result.code === 0) setStats(result.data);
|
if (result.code === 0) setStats(result.data);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchServers = useCallback(async () => {
|
const fetchServers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params: any = { page, page_size: pageSize };
|
const params: any = { page, page_size: pageSize };
|
||||||
if (search) params.search = search;
|
if (search) params.search = search;
|
||||||
if (filterTransport) params.transport_type = filterTransport;
|
if (filterTransport) params.transport_type = filterTransport;
|
||||||
if (filterStatus) params.status = filterStatus;
|
if (filterStatus) params.status = filterStatus;
|
||||||
const result = await adminAPI.getAdminMcpServers(params) as any;
|
const result = await adminAPI.getAdminMcpServers(params) as any;
|
||||||
if (result.code === 0) {
|
if (result.code === 0) {
|
||||||
setServers(result.data.items || []);
|
setServers(result.data.items || []);
|
||||||
setTotal(result.data.total || 0);
|
setTotal(result.data.total || 0);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Failed to load servers', 'error');
|
showToast('加载服务失败', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search, filterTransport, filterStatus]);
|
}, [page, search, filterTransport, filterStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchServers();
|
fetchServers();
|
||||||
}, [fetchStats, fetchServers]);
|
}, [fetchStats, fetchServers]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingServer(null);
|
setEditingServer(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (server: MCPServer) => {
|
const handleEdit = (server: MCPServer) => {
|
||||||
setEditingServer(server);
|
setEditingServer(server);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (server: MCPServer) => {
|
const handleDelete = async (server: MCPServer) => {
|
||||||
if (!confirm(`Delete MCP server "${server.name}"?`)) return;
|
if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
|
||||||
try {
|
try {
|
||||||
await adminAPI.deleteAdminMcpServer(server.id);
|
await adminAPI.deleteAdminMcpServer(server.id);
|
||||||
showToast('Server deleted');
|
showToast('服务已删除');
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Delete failed', 'error');
|
showToast('删除失败', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = async (server: MCPServer) => {
|
const handleToggle = async (server: MCPServer) => {
|
||||||
try {
|
try {
|
||||||
await adminAPI.toggleAdminMcpServer(server.id);
|
await adminAPI.toggleAdminMcpServer(server.id);
|
||||||
showToast(server.is_enabled ? 'Disabled' : 'Enabled');
|
showToast(server.is_enabled ? '已禁用' : '已启用');
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Toggle failed', 'error');
|
showToast('切换失败', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async (server: MCPServer) => {
|
const handleTest = async (server: MCPServer) => {
|
||||||
try {
|
try {
|
||||||
const result = await adminAPI.testAdminMcpServer(server.id) as any;
|
const result = await adminAPI.testAdminMcpServer(server.id) as any;
|
||||||
if (result.code === 0) {
|
if (result.code === 0) {
|
||||||
showToast(result.data.success ? 'Connection successful' : `Failed: ${result.data.message}`, result.data.success ? 'success' : 'error');
|
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('Test failed', 'error');
|
showToast('测试失败', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (formData: any) => {
|
const handleSubmit = async (formData: any) => {
|
||||||
try {
|
try {
|
||||||
if (editingServer) {
|
if (editingServer) {
|
||||||
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
|
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
|
||||||
showToast('Server updated');
|
showToast('服务已更新');
|
||||||
} else {
|
} else {
|
||||||
await adminAPI.createAdminMcpServer(formData);
|
await adminAPI.createAdminMcpServer(formData);
|
||||||
showToast('Server created');
|
showToast('服务已创建');
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
fetchServers();
|
fetchServers();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showToast(e.message || 'Operation failed', 'error');
|
showToast(e.message || '操作失败', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const colors: Record<string, { bg: string; color: string }> = {
|
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
|
||||||
connected: { bg: '#dcfce7', color: '#166534' },
|
const colors: Record<string, { bg: string; color: string }> = {
|
||||||
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
|
connected: { bg: '#dcfce7', color: '#166534' },
|
||||||
error: { bg: '#fee2e2', color: '#991b1b' },
|
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
|
||||||
};
|
error: { bg: '#fee2e2', color: '#991b1b' },
|
||||||
const c = colors[status] || colors.disconnected;
|
};
|
||||||
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{status}</span>;
|
const c = colors[status] || colors.disconnected;
|
||||||
};
|
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
|
||||||
|
};
|
||||||
const getTransportBadge = (type: string) => {
|
|
||||||
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
|
const getTransportBadge = (type: string) => {
|
||||||
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type.toUpperCase()}</span>;
|
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
|
||||||
};
|
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
|
||||||
|
};
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
return (
|
|
||||||
<div style={styles.container}>
|
return (
|
||||||
{toast && (
|
<div style={styles.container}>
|
||||||
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
|
{toast && (
|
||||||
{toast.message}
|
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
|
||||||
</div>
|
{toast.message}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<div style={styles.header}>
|
|
||||||
<h1 style={styles.title}>MCP Servers</h1>
|
<div style={styles.header}>
|
||||||
<button style={styles.addBtn} onClick={handleCreate}>+ Add Server</button>
|
<h1 style={styles.title}>MCP 服务管理</h1>
|
||||||
</div>
|
{/* 手动添加服务入口已移除:MCP 仅支持通过 URL 导入(见应用市场「导入」)或由 AI 聊天自动部署安装 */}
|
||||||
|
</div>
|
||||||
<div style={styles.statsRow}>
|
|
||||||
<div style={styles.statCard}>
|
<div style={styles.statsRow}>
|
||||||
<div style={styles.statLabel}>Total Servers</div>
|
<div style={styles.statCard}>
|
||||||
<div style={styles.statValue}>{stats.total_servers || 0}</div>
|
<div style={styles.statLabel}>服务总数</div>
|
||||||
</div>
|
<div style={styles.statValue}>{stats.total_servers || 0}</div>
|
||||||
<div style={styles.statCard}>
|
</div>
|
||||||
<div style={styles.statLabel}>Enabled</div>
|
<div style={styles.statCard}>
|
||||||
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
|
<div style={styles.statLabel}>已启用</div>
|
||||||
</div>
|
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
|
||||||
<div style={styles.statCard}>
|
</div>
|
||||||
<div style={styles.statLabel}>Connected</div>
|
<div style={styles.statCard}>
|
||||||
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
|
<div style={styles.statLabel}>已连接</div>
|
||||||
</div>
|
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
|
||||||
<div style={styles.statCard}>
|
</div>
|
||||||
<div style={styles.statLabel}>By Transport</div>
|
<div style={styles.statCard}>
|
||||||
<div style={{ fontSize: 13, color: '#374151' }}>
|
<div style={styles.statLabel}>传输方式分布</div>
|
||||||
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
|
<div style={{ fontSize: 13, color: '#374151' }}>
|
||||||
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
|
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
|
||||||
))}
|
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style={styles.toolbar}>
|
|
||||||
<input
|
<div style={styles.toolbar}>
|
||||||
style={styles.searchInput}
|
<input
|
||||||
placeholder="Search servers..."
|
style={styles.searchInput}
|
||||||
value={search}
|
placeholder="搜索服务..."
|
||||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
value={search}
|
||||||
/>
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
|
/>
|
||||||
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
|
||||||
</select>
|
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
|
</select>
|
||||||
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
|
||||||
</select>
|
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
<table style={styles.table}>
|
|
||||||
<thead>
|
<table style={styles.table}>
|
||||||
<tr>
|
<thead>
|
||||||
<th style={styles.th}>Name</th>
|
<tr>
|
||||||
<th style={styles.th}>Transport</th>
|
<th style={styles.th}>名称</th>
|
||||||
<th style={styles.th}>Status</th>
|
<th style={styles.th}>传输方式</th>
|
||||||
<th style={styles.th}>Tools</th>
|
<th style={styles.th}>状态</th>
|
||||||
<th style={styles.th}>Last Connected</th>
|
<th style={styles.th}>工具数</th>
|
||||||
<th style={styles.th}>Enabled</th>
|
<th style={styles.th}>最后连接</th>
|
||||||
<th style={styles.th}>Actions</th>
|
<th style={styles.th}>启用</th>
|
||||||
</tr>
|
<th style={styles.th}>操作</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{loading ? (
|
<tbody>
|
||||||
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>Loading...</td></tr>
|
{loading ? (
|
||||||
) : servers.length === 0 ? (
|
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
|
||||||
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>No MCP servers found</td></tr>
|
) : servers.length === 0 ? (
|
||||||
) : servers.map(s => (
|
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
|
||||||
<tr key={s.id}>
|
) : servers.map(s => (
|
||||||
<td style={styles.td}>
|
<tr key={s.id}>
|
||||||
<div style={{ fontWeight: 600 }}>{s.name}</div>
|
<td style={styles.td}>
|
||||||
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
|
<div style={{ fontWeight: 600 }}>{s.name}</div>
|
||||||
</td>
|
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
|
||||||
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
|
</td>
|
||||||
<td style={styles.td}>{getStatusBadge(s.status)}</td>
|
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
|
||||||
<td style={styles.td}>{s.tools_count || '-'}</td>
|
<td style={styles.td}>{getStatusBadge(s.status)}</td>
|
||||||
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : 'Never'}</td>
|
<td style={styles.td}>{s.tools_count || '-'}</td>
|
||||||
<td style={styles.td}>
|
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
|
||||||
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
|
<td style={styles.td}>
|
||||||
{s.is_enabled ? 'Yes' : 'No'}
|
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
|
||||||
</span>
|
{s.is_enabled ? '是' : '否'}
|
||||||
</td>
|
</span>
|
||||||
<td style={styles.td}>
|
</td>
|
||||||
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>Edit</button>
|
<td style={styles.td}>
|
||||||
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
|
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
|
||||||
{s.is_enabled ? 'Disable' : 'Enable'}
|
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
|
||||||
</button>
|
{s.is_enabled ? '禁用' : '启用'}
|
||||||
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>Test</button>
|
</button>
|
||||||
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>Del</button>
|
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
|
||||||
</td>
|
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</tbody>
|
))}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
{totalPages > 1 && (
|
|
||||||
<div style={styles.pagination}>
|
{totalPages > 1 && (
|
||||||
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</button>
|
<div style={styles.pagination}>
|
||||||
<span style={{ padding: '8px 12px', fontSize: 14 }}>Page {page} / {totalPages}</span>
|
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||||
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>Next</button>
|
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
|
||||||
</div>
|
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
|
|
||||||
</div>
|
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
|
|
||||||
const [form, setForm] = useState({
|
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
|
||||||
name: server?.name || '',
|
const [form, setForm] = useState({
|
||||||
description: server?.description || '',
|
name: server?.name || '',
|
||||||
transport_type: server?.transport_type || 'stdio',
|
description: server?.description || '',
|
||||||
command: server?.command || '',
|
transport_type: server?.transport_type || 'stdio',
|
||||||
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
|
command: server?.command || '',
|
||||||
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
|
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
|
||||||
url: server?.url || '',
|
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
|
||||||
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
|
url: server?.url || '',
|
||||||
});
|
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||||
|
});
|
||||||
const handleChange = (field: string, value: string) => {
|
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
const handleChange = (field: string, value: string) => {
|
||||||
};
|
setForm(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
const data: any = {
|
e.preventDefault();
|
||||||
name: form.name,
|
const data: any = {
|
||||||
description: form.description,
|
name: form.name,
|
||||||
transport_type: form.transport_type,
|
description: form.description,
|
||||||
};
|
transport_type: form.transport_type,
|
||||||
|
};
|
||||||
if (form.transport_type === 'stdio') {
|
|
||||||
data.command = form.command;
|
if (form.transport_type === 'stdio') {
|
||||||
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
|
data.command = form.command;
|
||||||
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
|
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
|
||||||
} else {
|
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
|
||||||
data.url = form.url;
|
} else {
|
||||||
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
|
data.url = form.url;
|
||||||
}
|
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
|
||||||
|
}
|
||||||
onSubmit(data);
|
|
||||||
};
|
onSubmit(data);
|
||||||
|
};
|
||||||
return (
|
|
||||||
<div style={styles.modalOverlay} onClick={onClose}>
|
return (
|
||||||
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
<div style={styles.modalOverlay} onClick={onClose}>
|
||||||
<div style={styles.modalTitle}>{server ? 'Edit MCP Server' : 'Add MCP Server'}</div>
|
<div style={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
<form onSubmit={handleSubmit}>
|
<div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
|
||||||
<div style={styles.formGroup}>
|
<form onSubmit={handleSubmit}>
|
||||||
<label style={styles.label}>Name *</label>
|
<div style={styles.formGroup}>
|
||||||
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="e.g., Filesystem Server" />
|
<label style={styles.label}>名称 *</label>
|
||||||
</div>
|
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
|
||||||
<div style={styles.formGroup}>
|
</div>
|
||||||
<label style={styles.label}>Description</label>
|
<div style={styles.formGroup}>
|
||||||
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="Optional description" />
|
<label style={styles.label}>描述</label>
|
||||||
</div>
|
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
|
||||||
<div style={styles.formGroup}>
|
</div>
|
||||||
<label style={styles.label}>Transport Type</label>
|
<div style={styles.formGroup}>
|
||||||
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
|
<label style={styles.label}>传输类型</label>
|
||||||
<option value="stdio">STDIO (command-based)</option>
|
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
|
||||||
<option value="sse">SSE (HTTP-based)</option>
|
<option value="stdio">STDIO(命令行方式)</option>
|
||||||
</select>
|
<option value="sse">SSE(HTTP 方式)</option>
|
||||||
</div>
|
</select>
|
||||||
|
</div>
|
||||||
{form.transport_type === 'stdio' ? (
|
|
||||||
<>
|
{form.transport_type === 'stdio' ? (
|
||||||
<div style={styles.formGroup}>
|
<>
|
||||||
<label style={styles.label}>Command</label>
|
<div style={styles.formGroup}>
|
||||||
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="e.g., npx @modelcontextprotocol/server-filesystem" />
|
<label style={styles.label}>命令</label>
|
||||||
</div>
|
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
|
||||||
<div style={styles.formGroup}>
|
</div>
|
||||||
<label style={styles.label}>Args (JSON)</label>
|
<div style={styles.formGroup}>
|
||||||
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
|
<label style={styles.label}>参数(JSON)</label>
|
||||||
</div>
|
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
|
||||||
<div style={styles.formGroup}>
|
</div>
|
||||||
<label style={styles.label}>Env Vars (JSON)</label>
|
<div style={styles.formGroup}>
|
||||||
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
|
<label style={styles.label}>环境变量(JSON)</label>
|
||||||
</div>
|
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
|
||||||
</>
|
</div>
|
||||||
) : (
|
</>
|
||||||
<>
|
) : (
|
||||||
<div style={styles.formGroup}>
|
<>
|
||||||
<label style={styles.label}>SSE URL</label>
|
<div style={styles.formGroup}>
|
||||||
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
|
<label style={styles.label}>SSE 地址</label>
|
||||||
</div>
|
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
|
||||||
<div style={styles.formGroup}>
|
</div>
|
||||||
<label style={styles.label}>Headers (JSON)</label>
|
<div style={styles.formGroup}>
|
||||||
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
|
<label style={styles.label}>请求头(JSON)</label>
|
||||||
</div>
|
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
<div style={styles.modalActions}>
|
|
||||||
<button type="button" style={styles.cancelBtn} onClick={onClose}>Cancel</button>
|
<div style={styles.modalActions}>
|
||||||
<button type="submit" style={styles.submitBtn}>{server ? 'Update' : 'Create'}</button>
|
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
|
||||||
</div>
|
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
+665
-667
File diff suppressed because it is too large.
Load diff
+1637
-1606
File diff suppressed because it is too large.
Load diff
+1609
-1578
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user