import { Alert, Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react"; import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../../services/api"; import { HopProxyFields } from "./HopProxyFields"; import { AppModalShell } from "../../../components/ui/AppModalShell"; import { FieldSelect } from "../../../components/ui/FieldSelect"; import { queryKeys } from "../../../constants/queryKeys"; import { useI18n } from "../../../i18n"; import { patchHopVendorChange } from "../lib/hopProxy"; import { managedNeErrorText } from "../lib/errors"; import type { ManagedNeItem } from "../../../types"; import { applyHopTemplate, buildManagedNeSaveBody, deviceTypeForVendor, isValidMgmtAddress, emptyManagedNeForm, formFromManagedNe, allowsOpenExecPolicy, type ManagedNeFormState, } from "../lib/formState"; export type ManagedNeFormDialogProps = { open: boolean; /** null = create mode */ editing: ManagedNeItem | null; /** Prefill when creating (e.g. topology deep-link fields). Ignored when editing. */ initialValues?: Partial; onClose: () => void; onSaved: (item: ManagedNeItem) => void; }; export function ManagedNeFormDialog({ open, editing, initialValues, onClose, onSaved, }: ManagedNeFormDialogProps) { const { t } = useI18n(); const [form, setForm] = useState(emptyManagedNeForm); // Port is edited as text so the field can be cleared while typing. const [portDraft, setPortDraft] = useState("22"); const [submitted, setSubmitted] = useState(false); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, staleTime: 60_000, enabled: open, }); const vendors = metaQuery.data?.vendors ?? []; const execPolicyEnabled = Boolean(metaQuery.data?.exec_policy_enabled); const deviceTypes = useMemo(() => { const base = metaQuery.data?.device_types ?? []; const cur = String(form.device_type || "").trim(); if (cur && !base.includes(cur)) return [cur, ...base]; return base; }, [metaQuery.data?.device_types, form.device_type]); const ipText = form.ip_address.trim(); const portNum = /^\d+$/.test(portDraft.trim()) ? Number(portDraft.trim()) : NaN; const fieldErrors = { ip: !ipText ? t("managedNe.form.ipRequired") : !isValidMgmtAddress(ipText) ? t("managedNe.form.ipInvalid") : "", username: !form.username.trim() ? t("managedNe.form.usernameRequired") : "", port: portNum >= 1 && portNum <= 65535 ? "" : t("managedNe.form.portInvalid"), }; const hasFieldErrors = Object.values(fieldErrors).some(Boolean); const saveMutation = useMutation({ mutationFn: async () => { const body = buildManagedNeSaveBody(form, { editing: Boolean(editing), hopHostRequired: t("managedNe.hop.hostRequired"), hopUserRequired: t("managedNe.hop.userRequired"), hopPasswordRequired: t("managedNe.hop.passwordRequired"), execPolicyEnabled: Boolean(metaQuery.data?.exec_policy_enabled), }); if (editing) { return updateManagedNe(editing.id, body); } return createManagedNe(body); }, onSuccess: (item) => { onSaved(item); }, }); const editingId = editing?.id ?? ""; useEffect(() => { if (!open) return; const seed = editing ? formFromManagedNe(editing) : { ...emptyManagedNeForm(), ...(initialValues || {}) }; setForm(seed); setPortDraft(String(seed.port)); setSubmitted(false); saveMutation.reset(); // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create }, [open, editingId]); return ( {editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}

{t("managedNe.form.requiredHint")}

setForm({ ...form, name })} > {t("managedNe.form.nameConnectHint")} { const vendor = e.target.value; setForm((prev) => { const next = { ...prev, vendor }; const dt = String(prev.device_type || "").trim().toLowerCase(); const suggested = deviceTypeForVendor(vendor); // Follow the vendor while the type is a placeholder or still the old vendor's default. const followVendor = !dt || dt === deviceTypeForVendor(prev.vendor) || ((dt === "generic" || dt === "other" || dt === "linux") && suggested !== "generic"); if (followVendor) { next.device_type = suggested; if (!allowsOpenExecPolicy(suggested)) next.exec_policy = "readonly"; } return next; }); }} > {vendors.map((v) => ( ))} { const device_type = e.target.value; setForm((prev) => ({ ...prev, device_type, exec_policy: allowsOpenExecPolicy(device_type) ? prev.exec_policy : "readonly", })); }} > {deviceTypes.map((dt) => ( ))} {execPolicyEnabled ? ( <> setForm({ ...form, exec_policy: e.target.value as ManagedNeFormState["exec_policy"], }) } > {allowsOpenExecPolicy(form.device_type) ? ( <> ) : null}

{t("managedNe.execPolicy.hint")}

) : null} setForm({ ...form, ip_address })} > {submitted && fieldErrors.ip ? {fieldErrors.ip} : null} { setPortDraft(value); if (/^\d+$/.test(value.trim())) setForm((prev) => ({ ...prev, port: Number(value.trim()) })); }} > {submitted && fieldErrors.port ? {fieldErrors.port} : null} { const protocol = e.target.value; // Keep the well-known port in step with the protocol unless it was customised. const swapPort = protocol === "telnet" && portNum === 22 ? 23 : protocol === "ssh" && portNum === 23 ? 22 : 0; setForm((prev) => ({ ...prev, protocol, ...(swapPort ? { port: swapPort } : {}) })); if (swapPort) setPortDraft(String(swapPort)); }} > setForm({ ...form, username })} > {submitted && fieldErrors.username ? {fieldErrors.username} : null} setForm({ ...form, password })} > setForm({ ...form, tags })}> setForm({ ...form, remark })} >
{t("managedNe.hop.sectionTitle")} { setForm((prev) => ({ ...prev, hop_enabled, ...(hop_enabled ? { ...patchHopVendorChange(prev.hop_vendor, prev), ...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true), } : {}), })); }} > {t("managedNe.hop.enable")} {form.hop_enabled ? ( setForm((prev) => ({ ...prev, ...patch }))} hopPasswordRequired={!editing} hopPasswordOptional={Boolean(editing)} /> ) : null}
{saveMutation.isError ? ( {managedNeErrorText(saveMutation.error, t)} ) : null}
); }