import { useCallback, useEffect, useMemo, useState } from "react"; import type { NodeCreateInput, NodeInfo } from "../api"; export interface ConnectNodeInput { name: string; url: string; apiKey?: string; maxConcurrent: number; } interface ConnectNodeModalProps { open: boolean; onClose: () => void; onConnected: (node: NodeInfo) => void; addToast: (message: string, type?: "success" | "error") => void; /** Optional function to register the node (defaults to using fetch) */ onSubmit?: (input: ConnectNodeInput) => Promise; } interface FormErrors { name?: string; host?: string; port?: string; maxConcurrent?: string; } const DEFAULT_PORT = 3001; const MAX_CONCURRENT_MIN = 1; const MAX_CONCURRENT_MAX = 10; function validateInput(input: { name: string; host: string; port: string; maxConcurrent: number }): FormErrors { const errors: FormErrors = {}; if (!input.name.trim()) { errors.name = "Node name is required"; } if (!input.host.trim()) { errors.host = "Host / IP address is required"; } const portNum = Number(input.port); if (input.port && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { errors.port = "Port must be between 1 and 65535"; } 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; } function buildUrl(host: string, port: string): string { const cleanHost = host.trim().replace(/^https?:\/\//, "").split("/")[0]; const portNum = Number(port) || DEFAULT_PORT; return `http://${cleanHost}:${portNum}`; } export function ConnectNodeModal({ open, onClose, onConnected, addToast, onSubmit }: ConnectNodeModalProps) { const [name, setName] = useState(""); const [host, setHost] = useState(""); const [port, setPort] = useState(String(DEFAULT_PORT)); const [apiKey, setApiKey] = useState(""); const [maxConcurrent, setMaxConcurrent] = useState(2); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const resetForm = useCallback(() => { setName(""); setHost(""); setPort(String(DEFAULT_PORT)); setApiKey(""); setMaxConcurrent(2); setErrors({}); setIsSubmitting(false); }, []); useEffect(() => { if (!open) { resetForm(); } }, [open, resetForm]); useEffect(() => { if (!open) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); onClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [open, onClose]); const constructedUrl = useMemo(() => { if (!host.trim()) return ""; return buildUrl(host, port); }, [host, port]); const handleSubmit = useCallback(async () => { if (isSubmitting) return; const validationErrors = validateInput({ name, host, port, maxConcurrent }); setErrors(validationErrors); if (Object.keys(validationErrors).length > 0) { return; } setIsSubmitting(true); const input: ConnectNodeInput = { name: name.trim(), url: constructedUrl, apiKey: apiKey.trim() || undefined, maxConcurrent, }; try { let node: NodeInfo; if (onSubmit) { node = await onSubmit(input); } else { // Default: call the API directly const response = await fetch("/api/nodes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: input.name, type: "remote", url: input.url, apiKey: input.apiKey, maxConcurrent: input.maxConcurrent, } satisfies NodeCreateInput), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: "Failed to connect" })); throw new Error(error.error || `HTTP ${response.status}`); } node = await response.json() as NodeInfo; } addToast(`Connected to "${node.name}"`, "success"); onConnected(node); onClose(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to connect to node"; addToast(message, "error"); } finally { setIsSubmitting(false); } }, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port]); if (!open) return null; return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Connect to Node" >

Connect to Node

{constructedUrl && (
URL: {constructedUrl}
)}
); }