import { useCallback, useEffect, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { NodeProjectMappingInput, ProjectInfo, RemoteNodeDiscoveredProject, RemoteNodeProjectDiscoveryResult } from "../api"; import { validateProjectPath } from "../utils/projectDetection"; 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; projectMappings: NodeProjectMappingInput[]; 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; onDiscoverRemoteProjects: (input: { url: string; apiKey?: string }) => Promise; addToast: (message: string, type?: ToastType) => void; projects: ProjectInfo[]; } interface FormErrors { name?: string; url?: string; maxConcurrent?: string; projectMappings: Record; } const MAX_CONCURRENT_MIN = 1; const MAX_CONCURRENT_MAX = 10; function validateInput(input: AddNodeInput, t: TFunction<"app">): FormErrors { const errors: FormErrors = { projectMappings: {} }; if (!input.name.trim()) { errors.name = t("nodes.nameRequired", "Name is required"); } if (input.type === "remote" && !input.url?.trim()) { errors.url = t("nodes.urlRequired", "URL is required for remote nodes"); } if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) { errors.maxConcurrent = t("nodes.concurrencyRange", "Concurrency must be between {{min}} and {{max}}", { min: MAX_CONCURRENT_MIN, max: MAX_CONCURRENT_MAX }); } for (const mapping of input.projectMappings) { const validation = validateProjectPath(mapping.path); if (!validation.valid) { errors.projectMappings[mapping.projectId] = validation.error ?? t("nodes.pathInvalid", "Path is invalid"); } } return errors; } type DiscoveryState = "idle" | "loading" | "success" | "error"; export function AddNodeModal({ isOpen, onClose, onSubmit, onDiscoverRemoteProjects, addToast, projects }: AddNodeModalProps) { const { t } = useTranslation("app"); 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 [selectedProjectPaths, setSelectedProjectPaths] = useState>({}); const [errors, setErrors] = useState({ projectMappings: {} }); const [isSubmitting, setIsSubmitting] = useState(false); const [discoveryState, setDiscoveryState] = useState("idle"); const [discoveryError, setDiscoveryError] = useState(null); const [discoveredProjects, setDiscoveredProjects] = useState([]); const resetForm = useCallback(() => { setName(""); setType("local"); setUrl(""); setApiKey(""); setMaxConcurrent(2); setApiKeyMode("auto-generate"); setSelectedProjectPaths({}); setErrors({ projectMappings: {} }); setIsSubmitting(false); setDiscoveryState("idle"); setDiscoveryError(null); setDiscoveredProjects([]); }, []); 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, projectMappings: Object.entries(selectedProjectPaths).map(([projectId, path]) => ({ projectId, path: path.trim() })), }), [apiKey, apiKeyMode, maxConcurrent, name, selectedProjectPaths, type, url]); const handleDiscoverProjects = useCallback(async () => { if (isSubmitting || discoveryState === "loading") return; const trimmedUrl = url.trim(); if (!trimmedUrl) { setErrors((current) => ({ ...current, url: t("nodes.urlRequired", "URL is required for remote nodes") })); return; } setDiscoveryState("loading"); setDiscoveryError(null); try { const response = await onDiscoverRemoteProjects({ url: trimmedUrl, apiKey: apiKeyMode === "provide" && apiKey.trim().length > 0 ? apiKey : undefined, }); setDiscoveredProjects(response.projects); setDiscoveryState("success"); setSelectedProjectPaths((current) => { if (Object.keys(current).length === 0) { return current; } const next = { ...current }; for (const project of projects) { if (!(project.id in next)) continue; const matches = response.projects.filter((remoteProject) => remoteProject.name === project.name); if (matches.length === 1) { next[project.id] = matches[0].path; } } return next; }); } catch (error) { setDiscoveryState("error"); setDiscoveredProjects([]); setDiscoveryError(error instanceof Error ? error.message : t("nodes.discoveryFailed", "Failed to discover remote projects")); } }, [apiKey, apiKeyMode, discoveryState, isSubmitting, onDiscoverRemoteProjects, projects, t, url]); useEffect(() => { if (type !== "remote") { setDiscoveryState("idle"); setDiscoveryError(null); setDiscoveredProjects([]); return; } setDiscoveryState("idle"); setDiscoveryError(null); setDiscoveredProjects([]); }, [apiKey, apiKeyMode, type, url]); const handleSubmit = useCallback(async () => { if (isSubmitting) return; const validationErrors = validateInput(input, t); setErrors(validationErrors); if ( validationErrors.name || validationErrors.url || validationErrors.maxConcurrent || Object.keys(validationErrors.projectMappings).length > 0 ) { return; } if (input.type === "remote" && discoveryState !== "success") { setDiscoveryError(t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node.")); return; } setIsSubmitting(true); try { await onSubmit(input); addToast(t("nodes.registered", "Node \"{{name}}\" registered", { name: input.name }), "success"); closeModal(); } catch (error) { const message = error instanceof Error ? error.message : t("nodes.registerFailed", "Failed to register node"); addToast(message, "error"); } finally { setIsSubmitting(false); } }, [addToast, closeModal, discoveryState, input, isSubmitting, onSubmit, t]); const toggleProjectSelection = (project: ProjectInfo) => { setSelectedProjectPaths((current) => { if (project.id in current) { const { [project.id]: _removed, ...remaining } = current; return remaining; } if (type === "remote" && discoveryState === "success") { const matches = discoveredProjects.filter((remoteProject) => remoteProject.name === project.name); if (matches.length === 1) { return { ...current, [project.id]: matches[0].path }; } return { ...current, [project.id]: "" }; } return { ...current, [project.id]: project.path }; }); }; const updateProjectPath = (projectId: string, path: string) => { setSelectedProjectPaths((current) => ({ ...current, [projectId]: path })); }; if (!isOpen) return null; return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.addNode", "Add Node")}>

{t("nodes.addNode", "Add Node")}

{t("nodes.description", "Register an existing Fusion node by providing its connection details and concurrency settings.")}

{type === "remote" && (
{apiKeyMode === "provide" && ( )}
{discoveryState === "success" && ( {discoveredProjects.length > 0 ? t("nodes.discoveredCount", "Discovered {{count}} remote project{{plural}}", { count: discoveredProjects.length, plural: discoveredProjects.length === 1 ? "" : "s" }) : t("nodes.noProjectsDiscovered", "No projects discovered on remote node.")} )} {discoveryState === "error" && discoveryError && ( {discoveryError} )} {discoveryState === "idle" && ( {t("nodes.discoverBeforeAdding", "Discover remote projects before adding this node.")} )}
{discoveryState === "success" && discoveredProjects.length > 0 && (
{discoveredProjects.map((project) => (
{project.name} {project.status}
{project.path}
))}
)}
)}

{t("nodes.attachProjects", "Attach Existing Projects")}

{t("nodes.attachProjectsHint", "Select existing projects to run on this node and provide the node-specific absolute path for each one.")}

{projects.length === 0 ? (

{t("nodes.noProjects", "No projects are currently registered.")}

) : (
{projects.map((project) => { const selected = project.id in selectedProjectPaths; const error = errors.projectMappings[project.id]; return (
{selected && ( )}
); })}
)}
); }