import { useCallback, useEffect, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; 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 }, t: TFunction<"app">): FormErrors { const errors: FormErrors = {}; if (!input.name.trim()) { errors.name = t("nodes.validation.nameRequired", "Node name is required"); } if (!input.host.trim()) { errors.host = t("nodes.validation.hostRequired", "Host / IP address is required"); } const portNum = Number(input.port); if (input.port && (isNaN(portNum) || portNum < 1 || portNum > 65535)) { errors.port = t("nodes.validation.portRange", "Port must be between 1 and 65535"); } if (!Number.isFinite(input.maxConcurrent) || input.maxConcurrent < MAX_CONCURRENT_MIN || input.maxConcurrent > MAX_CONCURRENT_MAX) { errors.maxConcurrent = t("nodes.validation.concurrencyRange", `Concurrency must be between {{min}} and {{max}}`, { min: MAX_CONCURRENT_MIN, max: 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 { t } = useTranslation("app"); 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 }, t); 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: t("nodes.errors.connectFailed", "Failed to connect") })); throw new Error(error.error || `HTTP ${response.status}`); } node = await response.json() as NodeInfo; } addToast(t("nodes.success.connected", `Connected to "{{name}}"`, { name: node.name }), "success"); onConnected(node); onClose(); } catch (error) { const message = error instanceof Error ? error.message : t("nodes.errors.connectToNode", "Failed to connect to node"); addToast(message, "error"); } finally { setIsSubmitting(false); } }, [addToast, apiKey, constructedUrl, host, isSubmitting, maxConcurrent, name, onClose, onConnected, onSubmit, port, t]); if (!open) return null; return (
event.stopPropagation()} role="dialog" aria-modal="true" aria-label={t("nodes.modal.title", "Connect to Node")} >

{t("nodes.modal.title", "Connect to Node")}

setName(event.target.value)} placeholder={t("nodes.placeholders.name", "Build Server")} disabled={isSubmitting} aria-invalid={Boolean(errors.name)} /> {errors.name && {errors.name}}
setHost(event.target.value)} placeholder={t("nodes.placeholders.host", "192.0.2.10 or my-server.local")} disabled={isSubmitting} aria-invalid={Boolean(errors.host)} /> {errors.host && {errors.host}}
setPort(event.target.value)} min={1} max={65535} disabled={isSubmitting} aria-invalid={Boolean(errors.port)} /> {errors.port && {errors.port}}
{constructedUrl && (
{t("nodes.fields.url", "URL")}: {constructedUrl}
)}
setApiKey(event.target.value)} placeholder={t("nodes.placeholders.optional", "Optional")} disabled={isSubmitting} />
setMaxConcurrent(Number(event.target.value))} min={MAX_CONCURRENT_MIN} max={MAX_CONCURRENT_MAX} disabled={isSubmitting} aria-invalid={Boolean(errors.maxConcurrent)} /> {errors.maxConcurrent && {errors.maxConcurrent}}
); }