feat: LAD A-Team CIS Compliance Report, React SPA, i18n, report management, explorer UX fixes
- Professional Oracle-format compliance report with RAG remediation from ADB vector store - React 19 SPA at /app/ (16 pages, TypeScript, Vite, Zustand, i18n pt/en 625 keys) - Report delete endpoint, embed individual report files into vector store - Terraform ZIP download (client-side, pure JS) - Explorer silent polling during start/stop (no flickering) - HTML report and compliance report sections start minimized - Token auth fix for compliance report CSV download links
This commit is contained in:
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
523
frontend-react/src/pages/config/McpServersPage.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '@/stores/app';
|
||||
import { useI18n } from '@/i18n';
|
||||
import { mcpApi, type McpServerFull, type McpServerPayload, type McpToolDef } from '@/api/endpoints/mcp';
|
||||
import {
|
||||
Plug, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight,
|
||||
Power, PowerOff, Search, Wrench, Terminal, Globe, FileCode2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ── Msg ── */
|
||||
function Msg({ type, text }: { type: 's' | 'e' | 'i'; text: string }) {
|
||||
const bg = type === 's' ? 'var(--gnl)' : type === 'e' ? 'var(--rdl)' : 'var(--bg2)';
|
||||
const fg = type === 's' ? 'var(--gn)' : type === 'e' ? 'var(--rd)' : 'var(--t2)';
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs" style={{ background: bg, color: fg }}>
|
||||
{type === 's' && <Check size={14} />}
|
||||
{type === 'e' && <X size={14} />}
|
||||
{type === 'i' && <Loader2 size={14} className="animate-spin" />}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Type badge ── */
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
const cfg: Record<string, { icon: React.ReactNode; bg: string; fg: string }> = {
|
||||
stdio: { icon: <Terminal size={10} />, bg: 'color-mix(in srgb, var(--ac) 12%, transparent)', fg: 'var(--ac)' },
|
||||
sse: { icon: <Globe size={10} />, bg: 'color-mix(in srgb, var(--gn) 12%, transparent)', fg: 'var(--gn)' },
|
||||
module: { icon: <FileCode2 size={10} />, bg: 'color-mix(in srgb, #e67e22 12%, transparent)', fg: '#e67e22' },
|
||||
};
|
||||
const c = cfg[type] || cfg.stdio;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.62rem] font-semibold" style={{ background: c.bg, color: c.fg }}>
|
||||
{c.icon} {type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
export default function McpServersPage() {
|
||||
const { adbCfg } = useAppStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [servers, setServers] = useState<McpServerFull[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<McpServerFull | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formMsg, setFormMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Form fields
|
||||
const [sName, setSName] = useState('');
|
||||
const [sDesc, setSDesc] = useState('');
|
||||
const [sType, setSType] = useState<'stdio' | 'sse' | 'module'>('stdio');
|
||||
const [sCmd, setSCmd] = useState('');
|
||||
const [sArgs, setSArgs] = useState('');
|
||||
const [sUrl, setSUrl] = useState('');
|
||||
const [sAdb, setSAdb] = useState('');
|
||||
|
||||
// Table
|
||||
const [tableMsg, setTableMsg] = useState<{ type: 's' | 'e' | 'i'; text: string } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState<Record<string, boolean>>({});
|
||||
const [discovering, setDiscovering] = useState<Record<string, boolean>>({});
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
const data = await mcpApi.list();
|
||||
setServers(data);
|
||||
} catch {} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchServers(); }, [fetchServers]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setFormOpen(false);
|
||||
setSName('');
|
||||
setSDesc('');
|
||||
setSType('stdio');
|
||||
setSCmd('');
|
||||
setSArgs('');
|
||||
setSUrl('');
|
||||
setSAdb('');
|
||||
setFormMsg(null);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const startEdit = (srv: McpServerFull) => {
|
||||
setEditing(srv);
|
||||
setSName(srv.name);
|
||||
setSDesc(srv.description || '');
|
||||
setSType((srv.server_type || 'stdio') as 'stdio' | 'sse' | 'module');
|
||||
setSCmd(srv.command || '');
|
||||
setSArgs(srv.args ? JSON.stringify(srv.args) : '');
|
||||
setSUrl(srv.url || '');
|
||||
setSAdb(srv.linked_adb_id || '');
|
||||
setFormOpen(true);
|
||||
setFormMsg(null);
|
||||
setTimeout(() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!sName.trim()) { setFormMsg({ type: 'e', text: t('mcp.fillName') }); return; }
|
||||
|
||||
let args: string[] | null = null;
|
||||
if (sArgs.trim()) {
|
||||
try { args = JSON.parse(sArgs); } catch { setFormMsg({ type: 'e', text: t('mcp.invalidArgs') }); return; }
|
||||
}
|
||||
|
||||
const body: McpServerPayload = {
|
||||
name: sName.trim(),
|
||||
description: sDesc.trim() || null,
|
||||
server_type: sType,
|
||||
command: sType !== 'sse' ? (sCmd.trim() || null) : null,
|
||||
url: sType === 'sse' ? (sUrl.trim() || null) : null,
|
||||
args,
|
||||
linked_adb_id: sAdb || null,
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setFormMsg({ type: 'i', text: editing ? t('mcp.updating') : t('mcp.saving') });
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await mcpApi.update(editing.id, body);
|
||||
} else {
|
||||
await mcpApi.create(body);
|
||||
}
|
||||
setFormMsg({ type: 's', text: `Server ${editing ? 'atualizado' : 'registrado'} com sucesso!` });
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTimeout(resetForm, 1200);
|
||||
} catch (err) {
|
||||
setFormMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao salvar' });
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setConfirmDelete(null);
|
||||
setTableMsg({ type: 'i', text: 'Excluindo server...' });
|
||||
try {
|
||||
await mcpApi.remove(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: 'Server excluido com sucesso!' });
|
||||
if (editing?.id === id) resetForm();
|
||||
setTimeout(() => setTableMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao excluir' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
setToggling((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
await mcpApi.toggle(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
} finally {
|
||||
setToggling((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (id: string) => {
|
||||
setDiscovering((p) => ({ ...p, [id]: true }));
|
||||
setTableMsg({ type: 'i', text: t('mcp.discovering') });
|
||||
try {
|
||||
const res = await mcpApi.discoverTools(id);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
setTableMsg({ type: 's', text: `${res.discovered} tool(s) descoberta(s), ${res.total} total` });
|
||||
setExpandedTools((p) => ({ ...p, [id]: true }));
|
||||
setTimeout(() => setTableMsg(null), 4000);
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro ao descobrir tools' });
|
||||
} finally {
|
||||
setDiscovering((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTool = async (srvId: string, idx: number) => {
|
||||
const srv = servers.find((s) => s.id === srvId);
|
||||
if (!srv?.tools) return;
|
||||
const tool = srv.tools[idx];
|
||||
const toolName = typeof tool === 'string' ? tool : (tool as McpToolDef).name;
|
||||
if (!confirm(`Remover tool "${toolName}"?`)) return;
|
||||
const newTools = [...srv.tools];
|
||||
newTools.splice(idx, 1);
|
||||
try {
|
||||
await mcpApi.updateTools(srvId, newTools);
|
||||
await fetchServers();
|
||||
useAppStore.getState().loadData();
|
||||
} catch (err) {
|
||||
setTableMsg({ type: 'e', text: err instanceof Error ? err.message : 'Erro' });
|
||||
}
|
||||
};
|
||||
|
||||
const isFormVisible = formOpen || !!editing;
|
||||
const inputCls = "w-full px-3 py-2 rounded-lg text-[.78rem] outline-none transition-colors focus:ring-1";
|
||||
const inputStyle = { background: 'var(--bg2)', border: '1.5px solid var(--bd)', color: 'var(--t1)' };
|
||||
const labelCls = "block";
|
||||
const labelStyle = {};
|
||||
const hintCls = "text-[.64rem] mt-0.5";
|
||||
const hintStyle = { color: 'var(--t4)' };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={24} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<h1>{t('mcp.title')}</h1>
|
||||
<div className="subtitle">{t('mcp.subtitle')}</div>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<span className="count">
|
||||
{servers.length} {servers.length === 1 ? 'server' : 'servers'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Server cards */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="icon" style={{ background: 'var(--gnl)' }}>
|
||||
<Plug size={16} style={{ color: 'var(--gn)' }} />
|
||||
</div>
|
||||
<h2>{t('mcp.registered')}</h2>
|
||||
</div>
|
||||
|
||||
{tableMsg && <div className="px-5 py-2"><Msg type={tableMsg.type} text={tableMsg.text} /></div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--t4)' }} />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Plug size={28} style={{ color: 'var(--t4)', margin: '0 auto 8px' }} />
|
||||
<p className="text-xs" style={{ color: 'var(--t4)' }}>{t('mcp.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{servers.map((srv) => {
|
||||
const tools: McpToolDef[] = Array.isArray(srv.tools) ? srv.tools : [];
|
||||
const isExpanded = expandedTools[srv.id];
|
||||
const adb = srv.linked_adb_id ? adbCfg.find((a) => a.id === srv.linked_adb_id) : null;
|
||||
return (
|
||||
<div
|
||||
key={srv.id}
|
||||
className="px-5 py-4 transition-colors"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
background: editing?.id === srv.id ? 'color-mix(in srgb, var(--gn) 5%, var(--bg1))' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<strong className="text-[.88rem]" style={{ color: 'var(--t1)' }}>{srv.name}</strong>
|
||||
<TypeBadge type={srv.server_type} />
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[.6rem] font-semibold"
|
||||
style={{
|
||||
background: srv.is_active ? 'var(--gnl)' : 'var(--rdl)',
|
||||
color: srv.is_active ? 'var(--gn)' : 'var(--rd)',
|
||||
}}
|
||||
>
|
||||
{srv.is_active ? <Power size={8} /> : <PowerOff size={8} />}
|
||||
{srv.is_active ? t('mcp.active') : t('mcp.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => startEdit(srv)} className="btn btn-secondary btn-sm">
|
||||
<Pencil size={10} /> {t('mcp.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(srv.id)}
|
||||
disabled={toggling[srv.id]}
|
||||
className="btn btn-secondary btn-sm"
|
||||
>
|
||||
{toggling[srv.id] ? <Loader2 size={10} className="animate-spin" /> : srv.is_active ? <PowerOff size={10} /> : <Power size={10} />}
|
||||
{srv.is_active ? t('mcp.deactivate') : t('mcp.activate')}
|
||||
</button>
|
||||
{confirmDelete === srv.id ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleDelete(srv.id)} className="btn btn-sm" style={{ background: 'var(--rd)', color: '#fff' }}>
|
||||
<Check size={10} /> {t('common.yes')}
|
||||
</button>
|
||||
<button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-sm">
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmDelete(srv.id)} className="btn btn-danger btn-sm">
|
||||
<Trash2 size={10} /> {t('mcp.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{srv.description && (
|
||||
<div className="text-[.72rem] mb-2" style={{ color: 'var(--t3)' }}>{srv.description}</div>
|
||||
)}
|
||||
|
||||
{/* Details row */}
|
||||
<div className="flex gap-4 flex-wrap text-[.72rem] mb-2" style={{ color: 'var(--t4)' }}>
|
||||
{srv.command && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.command')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.url && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.url')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.url}</code>
|
||||
</div>
|
||||
)}
|
||||
{srv.module_path && (
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.module')}</span>{' '}
|
||||
<code className="text-[.66rem]" style={{ fontFamily: 'var(--fm)' }}>{srv.module_path}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-semibold" style={{ color: 'var(--t3)' }}>{t('mcp.adb')}</span>{' '}
|
||||
{adb ? (
|
||||
<span className="px-1.5 py-0.5 rounded text-[.6rem] font-semibold" style={{ background: 'var(--gnl)', color: 'var(--gn)' }}>{adb.config_name}</span>
|
||||
) : t('mcp.noAdb')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools section */}
|
||||
<div className="mt-2" style={{ borderTop: '1px solid var(--bd)', paddingTop: 8 }}>
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer select-none mb-1"
|
||||
onClick={() => setExpandedTools((p) => ({ ...p, [srv.id]: !p[srv.id] }))}
|
||||
>
|
||||
<Wrench size={12} style={{ color: 'var(--t2)' }} />
|
||||
<span className="text-[.74rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{t('mcp.tools')} ({tools.length})
|
||||
</span>
|
||||
{isExpanded ? <ChevronUp size={12} style={{ color: 'var(--t4)' }} /> : <ChevronDown size={12} style={{ color: 'var(--t4)' }} />}
|
||||
</div>
|
||||
|
||||
{isExpanded && tools.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{tools.map((tool, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5 rounded-md text-[.72rem]" style={{ background: 'var(--bg)', border: '1px solid var(--bd)' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<strong style={{ color: 'var(--t1)' }}>{typeof tool === 'string' ? tool : tool.name}</strong>
|
||||
{typeof tool === 'object' && tool.description && (
|
||||
<span style={{ color: 'var(--t4)' }}> — {tool.description}</span>
|
||||
)}
|
||||
{typeof tool === 'object' && tool.input_schema && (tool.input_schema as { properties?: Record<string, unknown> }).properties && (
|
||||
<div className="text-[.64rem] mt-0.5" style={{ color: 'var(--t4)' }}>
|
||||
Params: {Object.keys((tool.input_schema as { properties: Record<string, unknown> }).properties).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveTool(srv.id, i)}
|
||||
className="flex-shrink-0 px-1.5 py-0.5 rounded text-[.6rem] font-semibold transition-colors"
|
||||
style={{ color: 'var(--rd)', background: 'color-mix(in srgb, var(--rd) 8%, transparent)' }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && tools.length === 0 && (
|
||||
<div className="text-[.72rem] py-1" style={{ color: 'var(--t4)' }}>{t('mcp.noTools')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleDiscover(srv.id)}
|
||||
disabled={discovering[srv.id]}
|
||||
className="btn btn-success btn-sm"
|
||||
>
|
||||
{discovering[srv.id] ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
||||
{t('mcp.discoverTools')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div ref={formRef} className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2.5 px-5 py-3.5 cursor-pointer select-none" onClick={() => { if (!editing) setFormOpen(!formOpen); }}>
|
||||
<ChevronRight size={14} style={{ color: 'var(--t3)', transition: 'transform .2s', transform: isFormVisible ? 'rotate(90deg)' : 'rotate(0)' }} />
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? <Pencil size={14} style={{ color: 'var(--gn)' }} /> : <Plus size={14} style={{ color: 'var(--gn)' }} />}
|
||||
<span className="text-[.76rem] font-semibold" style={{ color: 'var(--t2)' }}>
|
||||
{editing ? `${t('mcp.editServer')} — ${editing.name}` : t('mcp.newServer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFormVisible && (
|
||||
<div className="px-5 pb-5 flex flex-col gap-4" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<p className="text-[.7rem] pt-3" style={{ color: 'var(--t4)' }}>
|
||||
{editing
|
||||
? <>Editando: <strong style={{ color: 'var(--t2)' }}>{editing.name}</strong></>
|
||||
: t('mcp.configDesc')}
|
||||
</p>
|
||||
|
||||
{formMsg && <Msg type={formMsg.type} text={formMsg.text} />}
|
||||
|
||||
{/* Name + description */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.name')}</label>
|
||||
<input type="text" value={sName} onChange={(e) => setSName(e.target.value)} placeholder="CIS Benchmark Server" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.description')}</label>
|
||||
<input type="text" value={sDesc} onChange={(e) => setSDesc(e.target.value)} placeholder="Executa checks CIS 3.0" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server type */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.serverType')}</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{(['stdio', 'sse', 'module'] as const).map((tp) => {
|
||||
const icons = { stdio: <Terminal size={12} />, sse: <Globe size={12} />, module: <FileCode2 size={12} /> };
|
||||
const labels = { stdio: 'stdio', sse: 'SSE (HTTP)', module: 'Python Module' };
|
||||
const selected = sType === tp;
|
||||
return (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => setSType(tp)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[.72rem] font-semibold transition-all"
|
||||
style={{
|
||||
background: selected ? 'color-mix(in srgb, var(--gn) 15%, transparent)' : 'var(--bg2)',
|
||||
color: selected ? 'var(--gn)' : 'var(--t4)',
|
||||
border: `1.5px solid ${selected ? 'var(--gn)' : 'var(--bd)'}`,
|
||||
}}
|
||||
>
|
||||
{icons[tp]} {labels[tp]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional fields */}
|
||||
{sType !== 'sse' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.commandLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.commandHint')}</p>
|
||||
<input type="text" value={sCmd} onChange={(e) => setSCmd(e.target.value)} placeholder="python3 server.py" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.argsLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.argsHint')}</p>
|
||||
<input type="text" value={sArgs} onChange={(e) => setSArgs(e.target.value)} placeholder='["--config", "/path/to/config"]' className={inputCls} style={{ ...inputStyle, fontFamily: 'var(--fm)', fontSize: '.72rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sType === 'sse' && (
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.urlLabel')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.urlHint')}</p>
|
||||
<input type="text" value={sUrl} onChange={(e) => setSUrl(e.target.value)} placeholder="http://localhost:8001/sse" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADB link */}
|
||||
<div>
|
||||
<label className={labelCls} style={labelStyle}>{t('mcp.linkAdb')}</label>
|
||||
<p className={hintCls} style={{ ...hintStyle, marginBottom: 4 }}>{t('mcp.linkAdbHint')}</p>
|
||||
<select value={sAdb} onChange={(e) => setSAdb(e.target.value)} className={inputCls} style={inputStyle}>
|
||||
<option value="">{t('mcp.noAdb')}</option>
|
||||
{adbCfg.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.config_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={handleSave} disabled={saving} className="btn btn-success">
|
||||
{saving && <Loader2 size={14} className="animate-spin" />}
|
||||
{editing ? t('mcp.saveChanges') : t('mcp.saveServer')}
|
||||
</button>
|
||||
{editing && (
|
||||
<button onClick={resetForm} className="btn btn-secondary">
|
||||
{t('mcp.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user