import { useCallback, useEffect, useMemo, useState } from "react"; import type { ToastType } from "../hooks/useToast"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import "./AddNodeModal.css"; export interface AddNodeInput { name: string; type: "local" | "remote"; url?: string; apiKey?: string; maxConcurrent: number; apiKeyMode?: "auto-generate" | "provide"; extraClis?: Array<"claude-cli" | "droid-cli">; persistentStorage?: boolean; resourceSizing?: { cpus?: number; memoryMB?: number; }; dockerAdvanced?: { host?: string; context?: string; tlsVerify?: boolean; envOverrides?: Record; volumeMounts?: Array<{ hostPath: string; containerPath: string; mode: "ro" | "rw" }>; }; } interface AddNodeModalProps { isOpen: boolean; onClose: () => void; onSubmit: (input: AddNodeInput) => Promise; addToast: (message: string, type?: ToastType) => void; } interface FormErrors { name?: string; url?: string; maxConcurrent?: string; } const MAX_CONCURRENT_MIN = 1; const MAX_CONCURRENT_MAX = 10; function validateInput(input: AddNodeInput): FormErrors { const errors: FormErrors = {}; if (!input.name.trim()) { errors.name = "Name is required"; } if (input.type === "remote" && !input.url?.trim()) { errors.url = "URL is required for remote nodes"; } if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) { errors.maxConcurrent = `Concurrency must be between ${MAX_CONCURRENT_MIN} and ${MAX_CONCURRENT_MAX}`; } return errors; } export function AddNodeModal({ isOpen, onClose, onSubmit, addToast }: AddNodeModalProps) { useMobileScrollLock(isOpen); const [name, setName] = useState(""); const [type, setType] = useState<"local" | "remote">("local"); const [url, setUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState(2); const [apiKeyMode, setApiKeyMode] = useState<"auto-generate" | "provide">("auto-generate"); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const resetForm = useCallback(() => { setName(""); setType("local"); setUrl(""); setApiKey(""); setMaxConcurrent(2); setApiKeyMode("auto-generate"); setErrors({}); setIsSubmitting(false); }, []); const closeModal = useCallback(() => { if (isSubmitting) return; resetForm(); onClose(); }, [isSubmitting, onClose, resetForm]); useEffect(() => { if (!isOpen) { resetForm(); return; } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); closeModal(); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [closeModal, isOpen, resetForm]); const input = useMemo(() => ({ name: name.trim(), type, url: type === "remote" ? url.trim() || undefined : undefined, apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined, maxConcurrent, apiKeyMode, }), [apiKey, apiKeyMode, maxConcurrent, name, type, url]); const handleSubmit = useCallback(async () => { if (isSubmitting) return; const validationErrors = validateInput(input); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) { return; } setIsSubmitting(true); try { await onSubmit(input); addToast(`Node "${input.name}" registered`, "success"); closeModal(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to register node"; addToast(message, "error"); } finally { setIsSubmitting(false); } }, [addToast, closeModal, input, isSubmitting, onSubmit]); if (!isOpen) return null; return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add Node">

Add Node

Register an existing Fusion node by providing its connection details and concurrency settings.

{type === "remote" && (
{apiKeyMode === "provide" && ( )}
)}
); }