import { useCallback, useEffect, useMemo, useState } from "react"; import type { ToastType } from "../hooks/useToast"; 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) { 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 [includeClaudeCli, setIncludeClaudeCli] = useState(false); const [includeDroidCli, setIncludeDroidCli] = useState(false); const [persistentStorage, setPersistentStorage] = useState(true); const [resourceCpus, setResourceCpus] = useState(2); const [resourceMemoryMb, setResourceMemoryMb] = useState(4096); const [showAdvanced, setShowAdvanced] = useState(false); const [dockerHost, setDockerHost] = useState(""); const [dockerContext, setDockerContext] = useState(""); const [tlsVerify, setTlsVerify] = useState(false); const [advancedEnv, setAdvancedEnv] = useState(""); const [advancedMounts, setAdvancedMounts] = useState(""); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const resetForm = useCallback(() => { setName(""); setType("local"); setUrl(""); setApiKey(""); setMaxConcurrent(2); setApiKeyMode("auto-generate"); setIncludeClaudeCli(false); setIncludeDroidCli(false); setPersistentStorage(true); setResourceCpus(2); setResourceMemoryMb(4096); setShowAdvanced(false); setDockerHost(""); setDockerContext(""); setTlsVerify(false); setAdvancedEnv(""); setAdvancedMounts(""); 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(() => { const parsedEnvOverrides = Object.fromEntries( advancedEnv .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const index = line.indexOf("="); if (index <= 0) return [line, ""]; return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; }) ); const parsedMounts = advancedMounts .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const [hostPath = "", containerPath = "", mode = "rw"] = line.split(":"); return { hostPath, containerPath, mode: mode === "ro" ? "ro" : "rw" as "ro" | "rw" }; }) .filter((mount) => mount.hostPath && mount.containerPath); return { name: name.trim(), type, url: type === "remote" ? url.trim() || undefined : undefined, apiKey: type === "remote" && apiKeyMode === "provide" ? apiKey || undefined : undefined, maxConcurrent, apiKeyMode, extraClis: [includeClaudeCli ? "claude-cli" : null, includeDroidCli ? "droid-cli" : null].filter(Boolean) as Array<"claude-cli" | "droid-cli">, persistentStorage, resourceSizing: { cpus: Number.isFinite(resourceCpus) ? resourceCpus : undefined, memoryMB: Number.isFinite(resourceMemoryMb) ? resourceMemoryMb : undefined, }, dockerAdvanced: showAdvanced ? { host: dockerHost.trim() || undefined, context: dockerContext.trim() || undefined, tlsVerify, envOverrides: parsedEnvOverrides, volumeMounts: parsedMounts, } : undefined, }; }, [advancedEnv, advancedMounts, apiKey, apiKeyMode, dockerContext, dockerHost, includeClaudeCli, includeDroidCli, maxConcurrent, name, persistentStorage, resourceCpus, resourceMemoryMb, showAdvanced, tlsVerify, 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

Provision a managed Docker node with guided defaults, then expand Advanced for host/TLS/env/mount overrides.

{type === "remote" && (
{apiKeyMode === "provide" && ( )}
)}
Optional CLI Tools
{showAdvanced && (