import { ChevronDown, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { DockerHostConfig, ManagedDockerNodeInput } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { DockerTargetSelector } from "./DockerTargetSelector"; import "./DockerNodeOnboardingModal.css"; interface DockerNodeOnboardingModalProps { isOpen: boolean; onClose: () => void; onSubmit: (input: ManagedDockerNodeInput) => Promise; addToast: (message: string, type?: ToastType) => void; } interface FormErrors { name?: string; reachableUrl?: string; memoryMB?: string; cpus?: string; } interface KeyValueRow { key: string; value: string; } interface MountRow { hostPath: string; containerPath: string; mode: "ro" | "rw"; } const DEFAULT_URL = "http://localhost:4040"; export function DockerNodeOnboardingModal({ isOpen, onClose, onSubmit, addToast: _addToast }: DockerNodeOnboardingModalProps) { const [name, setName] = useState(""); const [hostConfig, setHostConfig] = useState({}); const [reachableUrl, setReachableUrl] = useState(DEFAULT_URL); const [apiKeyMode, setApiKeyMode] = useState<"auto" | "manual">("auto"); const [apiKey, setApiKey] = useState(""); const [includeClaudeCli, setIncludeClaudeCli] = useState(false); const [includeDroidCli, setIncludeDroidCli] = useState(false); const [persistentStorage, setPersistentStorage] = useState(true); const [memoryMB, setMemoryMB] = useState(4096); const [cpus, setCpus] = useState(2); const [showAdvanced, setShowAdvanced] = useState(false); const [imageName, setImageName] = useState("runfusion/fusion"); const [imageTag, setImageTag] = useState("latest"); const [envRows, setEnvRows] = useState([]); const [mountRows, setMountRows] = useState([]); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); const resetForm = useCallback(() => { setName(""); setHostConfig({}); setReachableUrl(DEFAULT_URL); setApiKeyMode("auto"); setApiKey(""); setIncludeClaudeCli(false); setIncludeDroidCli(false); setPersistentStorage(true); setMemoryMB(4096); setCpus(2); setShowAdvanced(false); setImageName("runfusion/fusion"); setImageTag("latest"); setEnvRows([]); setMountRows([]); setErrors({}); setSubmitting(false); }, []); const closeModal = useCallback(() => { if (submitting) return; resetForm(); onClose(); }, [onClose, resetForm, submitting]); useEffect(() => { if (!isOpen) { resetForm(); return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); closeModal(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [closeModal, isOpen, resetForm]); const input = useMemo(() => ({ nodeId: null, name: name.trim(), imageName: imageName.trim() || "runfusion/fusion", imageTag: imageTag.trim() || "latest", hostConfig: { context: hostConfig.context?.trim() || undefined, host: hostConfig.host?.trim() || undefined, tlsVerify: hostConfig.tlsVerify, tlsCaPath: hostConfig.tlsCaPath?.trim() || undefined, tlsCertPath: hostConfig.tlsCertPath?.trim() || undefined, tlsKeyPath: hostConfig.tlsKeyPath?.trim() || undefined, }, envVars: Object.fromEntries( envRows .map((row) => [row.key.trim(), row.value] as const) .filter(([key]) => Boolean(key)), ), volumeMounts: mountRows .map((mount) => ({ hostPath: mount.hostPath.trim(), containerPath: mount.containerPath.trim(), mode: mount.mode, })) .filter((mount) => mount.hostPath && mount.containerPath), resourceSizing: { memoryMB, cpus }, extraClis: [includeClaudeCli ? "claude-cli" : null, includeDroidCli ? "droid-cli" : null].filter(Boolean) as Array< "claude-cli" | "droid-cli" >, persistentStorage, reachableUrl: reachableUrl.trim() || null, apiKey: apiKeyMode === "manual" ? apiKey.trim() || null : null, }), [ apiKey, apiKeyMode, cpus, hostConfig, envRows, imageName, imageTag, includeClaudeCli, includeDroidCli, memoryMB, mountRows, name, persistentStorage, reachableUrl, ]); const addEnvRow = useCallback(() => { setEnvRows((current) => [...current, { key: "", value: "" }]); }, []); const updateEnvRow = useCallback((index: number, next: KeyValueRow) => { setEnvRows((current) => current.map((row, rowIndex) => (rowIndex === index ? next : row))); }, []); const removeEnvRow = useCallback((index: number) => { setEnvRows((current) => current.filter((_, rowIndex) => rowIndex !== index)); }, []); const addMountRow = useCallback(() => { setMountRows((current) => [...current, { hostPath: "", containerPath: "", mode: "rw" }]); }, []); const updateMountRow = useCallback((index: number, next: MountRow) => { setMountRows((current) => current.map((row, rowIndex) => (rowIndex === index ? next : row))); }, []); const removeMountRow = useCallback((index: number) => { setMountRows((current) => current.filter((_, rowIndex) => rowIndex !== index)); }, []); const handleSubmit = useCallback(async () => { if (submitting) return; const nextErrors: FormErrors = {}; if (!input.name || input.name.length > 64) { nextErrors.name = "Name is required and must be 64 characters or fewer"; } if (!input.reachableUrl) { nextErrors.reachableUrl = "URL is required"; } if (memoryMB < 512) { nextErrors.memoryMB = "Memory must be at least 512 MB"; } if (cpus < 0.5) { nextErrors.cpus = "CPUs must be at least 0.5"; } setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { return; } setSubmitting(true); try { await onSubmit(input); closeModal(); } catch { // Error toast is handled by parent submit handler. } finally { setSubmitting(false); } }, [closeModal, cpus, input, memoryMB, onSubmit, submitting]); if (!isOpen) return null; return (
event.stopPropagation()} >

Provision Docker Node

Required Settings

{errors.name &&
{errors.name}
} {errors.reachableUrl &&
{errors.reachableUrl}
}
{apiKeyMode === "manual" && ( )}
{errors.memoryMB &&
{errors.memoryMB}
} {errors.cpus &&
{errors.cpus}
}
Environment Variables
{envRows.map((row, index) => (
updateEnvRow(index, { key: event.target.value, value: row.value, }) } /> updateEnvRow(index, { key: row.key, value: event.target.value, }) } />
))}
Volume Mounts
{mountRows.map((row, index) => (
updateMountRow(index, { hostPath: event.target.value, containerPath: row.containerPath, mode: row.mode, }) } /> updateMountRow(index, { hostPath: row.hostPath, containerPath: event.target.value, mode: row.mode, }) } />
))}
); }