/
alexefan136
/
flowstack
Обзор
Документация
Войти
/
alexefan136
/
flowstack
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
ui/src/components/mcp/ServerFormModal.tsx
308 строк
10 KB
Alexander Efanov
upd fix
29 июл 2026, 23:26
29 июл 2026, 23:26
d8ac8ee
Код
Авторство
О чём код?
// src/components/mcp/ServerFormModal.tsx import { Globe, Loader2, Terminal, X } from "lucide-react"; import { useEffect, useState, type FormEvent } from "react"; import { useAppStore } from "../../lib/stores/app-store"; import { useMCPStore } from "../../lib/stores/mcp-store"; import type { CreateMCPServerRequest, MCPServer, } from "../../lib/types/mcp-types"; import { cn } from "../../lib/utils"; interface ServerFormModalProps { workspaceId: string; server?: MCPServer | null; onClose: () => void; } const TYPE_OPTIONS = [ { value: "stdio", label: "STDIO", icon: Terminal }, { value: "sse", label: "SSE", icon: Globe }, { value: "http", label: "HTTP", icon: Globe }, ] as const; export function ServerFormModal({ workspaceId, server, onClose, }: ServerFormModalProps) { const { createServer, updateServer } = useMCPStore(); const toast = useAppStore((s) => s.toast); const isEditing = Boolean(server); const [name, setName] = useState(server?.name ?? ""); const [description, setDescription] = useState(server?.description ?? ""); const [type, setType] = useState<string>(server?.type ?? "stdio"); const [command, setCommand] = useState(server?.command ?? ""); const [args, setArgs] = useState((server?.args ?? []).join(" ")); const [env, setEnv] = useState( server?.env ? Object.entries(server.env) .map(([k, v]) => `${k}=${v}`) .join("\n") : "", ); const [url, setUrl] = useState(server?.url ?? ""); const [isActive, setIsActive] = useState(server?.is_active ?? true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState<string | null>(null); const isStdio = type === "stdio"; // ✅ Escape закрывает модалку useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [onClose]); const parseEnv = (raw: string): Record<string, string> => { const result: Record<string, string> = {}; raw .split("\n") .map((l) => l.trim()) .filter((l) => l && l.includes("=")) .forEach((l) => { const idx = l.indexOf("="); const key = l.slice(0, idx).trim(); const value = l.slice(idx + 1).trim(); if (key) result[key] = value; }); return result; }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); if (!name.trim()) { setError("Укажите имя сервера"); return; } if (isStdio && !command.trim()) { setError("Укажите команду для STDIO"); return; } if (!isStdio && !url.trim()) { setError("Укажите URL для сетевого транспорта"); return; } const payload: CreateMCPServerRequest = { name: name.trim(), description: description.trim() || undefined, type: type as CreateMCPServerRequest["type"], is_active: isActive, ...(isStdio ? { command: command.trim(), args: args .split(" ") .map((a) => a.trim()) .filter(Boolean), env: parseEnv(env), } : { url: url.trim() }), }; setSubmitting(true); try { if (isEditing && server) { await updateServer(server.id, workspaceId, payload); toast(`Сервер "${name}" обновлён`, "success"); } else { await createServer(workspaceId, payload); toast(`Сервер "${name}" создан`, "success"); } onClose(); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка сохранения"); } finally { setSubmitting(false); } }; const labelClass = "block text-xs font-medium text-text-secondary mb-1.5"; return ( <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="absolute inset-0 bg-bg-overlay backdrop-blur-sm" onClick={onClose} aria-hidden="true" /> <div className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto glass-frosted rounded-2xl shadow-2xl toast-enter" role="dialog" aria-modal="true" aria-label={isEditing ? "Редактировать сервер" : "Новый MCP сервер"} > {/* Header */} <div className="flex items-center justify-between px-6 py-4 border-b border-border-default"> <h2 className="text-lg font-semibold text-text-primary"> {isEditing ? "Редактировать сервер" : "Новый MCP сервер"} </h2> <button onClick={onClose} className="btn btn-ghost btn-icon btn-sm" aria-label="Закрыть" > <X className="w-5 h-5" aria-hidden="true" /> </button> </div> {/* Form */} <form onSubmit={handleSubmit} className="p-6 space-y-5"> {error && ( <div className="px-4 py-3 rounded-lg bg-status-error-soft border border-status-error/20"> <p className="text-sm text-status-error">{error}</p> </div> )} <div> <label className={labelClass} htmlFor="mcp-name"> Имя сервера * </label> <input id="mcp-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="my-mcp-server" className="input" autoFocus /> </div> <div> <label className={labelClass} htmlFor="mcp-desc"> Описание </label> <input id="mcp-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Что делает этот сервер" className="input" /> </div> <div> <span className={labelClass}>Тип транспорта *</span> <div className="grid grid-cols-3 gap-2"> {TYPE_OPTIONS.map((opt) => { const Icon = opt.icon; const active = type === opt.value; return ( <button key={opt.value} type="button" onClick={() => setType(opt.value)} className={cn( "flex flex-col items-center gap-1.5 px-3 py-3 rounded-lg border transition", active ? "bg-accent-lime-soft border-border-accent text-text-accent" : "bg-(--glass-bg-default) border-border-subtle text-text-muted hover:border-border-strong", )} aria-pressed={active} > <Icon className="w-4 h-4" aria-hidden="true" /> <span className="text-xs font-medium">{opt.label}</span> </button> ); })} </div> </div> {isStdio ? ( <> <div> <label className={labelClass} htmlFor="mcp-command"> Команда * </label> <input id="mcp-command" value={command} onChange={(e) => setCommand(e.target.value)} placeholder="npx" className="input font-mono" /> </div> <div> <label className={labelClass} htmlFor="mcp-args"> Аргументы (через пробел) </label> <input id="mcp-args" value={args} onChange={(e) => setArgs(e.target.value)} placeholder="-y @modelcontextprotocol/server-filesystem /path" className="input font-mono" /> </div> <div> <label className={labelClass} htmlFor="mcp-env"> Переменные окружения (KEY=VALUE, по одной на строку) </label> <textarea id="mcp-env" value={env} onChange={(e) => setEnv(e.target.value)} placeholder={"API_KEY=xxx\nDEBUG=true"} rows={3} className="textarea font-mono resize-none" /> </div> </> ) : ( <div> <label className={labelClass} htmlFor="mcp-url"> URL * </label> <input id="mcp-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://localhost:3001/mcp" className="input font-mono" /> </div> )} <label className="flex items-center gap-3 cursor-pointer select-none"> <input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} className="w-4 h-4 rounded accent-accent-lime" /> <span className="text-sm text-text-secondary">Активен</span> </label> <div className="flex items-center gap-3 pt-2"> <button type="button" onClick={onClose} className="btn btn-ghost flex-1" > Отмена </button> <button type="submit" disabled={submitting} className="btn btn-primary flex-1" > {submitting && ( <Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" /> )} {isEditing ? "Сохранить" : "Создать"} </button> </div> </form> </div> </div> ); }