import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { batchApplyAccountManagedNe, batchApplyHopManagedNe, batchDeleteManagedNe, connectTestManagedNe, createManagedNe, deleteUmeManagedNe, deleteManagedNe, fetchIdsByTag, fetchManagedNe, fetchManagedNeById, fetchManagedNeMeta, fetchManagedNeStats, importManagedNe, downloadManagedNeImportTemplate, syncUmeManagedNe, updateManagedNe, type ManagedNeStats, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; import { queryKeys } from "../constants/queryKeys"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { useAuth } from "../auth/AuthContext"; import type { ManagedNeItem } from "../types"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; import { openOrFocusModule } from "../utils/moduleWindows"; import { defaultHopTemplate, isAutoHopTemplate, patchHopVendorChange, type HopVendor, } from "../utils/hopProxy"; /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; type FormState = { name: string; vendor: string; device_type: string; ip_address: string; port: number; protocol: string; username: string; password: string; tags: string; remark: string; hop_enabled: boolean; hop_vendor: HopVendor; hop_host: string; hop_port: number; hop_protocol: string; hop_username: string; hop_password: string; hop_command_template: string; hop_vrf: string; hop_target_auth_mode: "bastion_managed" | "manual"; }; type AccountState = { username: string; password: string; }; function deviceTypeForVendor(vendor: string): string { if (vendor === "ZTE") return "zte_zxros"; if (vendor === "Huawei") return "huawei"; if (vendor === "Cisco") return "cisco_ios"; if (vendor === "Juniper") return "juniper_junos"; if (vendor === "Nokia") return "nokia_sros"; return "generic"; } const emptyForm = (): FormState => ({ name: "", vendor: "ZTE", device_type: "zte_zxros", ip_address: "", port: 22, protocol: "ssh", username: "", password: "", tags: "", remark: "", hop_enabled: false, hop_vendor: "zte", hop_host: "", hop_port: 22, hop_protocol: "ssh", hop_username: "", hop_password: "", hop_command_template: defaultHopTemplate("zte", "ssh", ""), hop_vrf: "", hop_target_auth_mode: "bastion_managed", }); const emptyAccount = (): AccountState => ({ username: "", password: "", }); function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial { if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) { return {}; } return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; } function managedSourceKey( source: string | undefined, ): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" { const s = String(source || "").trim().toLowerCase(); if (!s) return "manual"; if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s; return ""; } function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { return ( {children} {required ? ( ) : null} ); } function connectStatusClass(status: string): string { if (status === "pass") return "pt-list-status--ok"; if (status === "fail") return "pt-list-status--failed"; if (status === "testing") return "pt-list-status--running"; return "pt-list-status--unknown"; } function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" { const s = String(status || "").toLowerCase(); if (s === "pass" || s === "ok") return "up"; if (s === "fail" || s === "error") return "down"; if (s === "testing") return "warn"; return "unknown"; } export function NePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const { hasScope, isAdmin } = useAuth(); const canWriteNe = isAdmin || hasScope("ne:write"); const queryClient = useQueryClient(); const importRef = useRef(null); const [searchParams, setSearchParams] = useSearchParams(); const deepLinkHandled = useRef(""); const [keyword, setKeyword] = useState(""); const [vendorFilter, setVendorFilter] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [selected, setSelected] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [batchHopOpen, setBatchHopOpen] = useState(false); const [batchAccountOpen, setBatchAccountOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [batchHop, setBatchHop] = useState(emptyHopProxyFields); const [batchAccount, setBatchAccount] = useState(emptyAccount); const [connectDetailRow, setConnectDetailRow] = useState(null); // --- bulk-by-tag dialog --- const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false); const [bulkTagAction, setBulkTagAction] = useState<"proxy" | "test" | "account">("proxy"); const [bulkTagSelected, setBulkTagSelected] = useState(""); // "" = all, "__no_tag__" = no-tag NEs const [bulkAccount, setBulkAccount] = useState(emptyAccount); const metaQuery = useQuery({ queryKey: queryKeys.managedNeMeta, queryFn: fetchManagedNeMeta, staleTime: 60_000, }); const statsQuery = useQuery({ queryKey: queryKeys.managedNeStats, queryFn: fetchManagedNeStats, staleTime: 10_000, }); const listQuery = useQuery({ queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize), queryFn: () => fetchManagedNe({ keyword, vendor: vendorFilter, connectStatus: statusFilter, page, pageSize, }), refetchInterval: (q) => { const items = q.state.data?.items || []; return items.some((x) => x.connect_status === "testing") ? 2000 : false; }, }); useEffect(() => { if (!connectDetailRow) return; const updated = listQuery.data?.items?.find((x) => x.id === connectDetailRow.id); if (!updated) return; if ( updated.connect_status !== connectDetailRow.connect_status || updated.connect_message !== connectDetailRow.connect_message || updated.connect_detail !== connectDetailRow.connect_detail || updated.connect_tested_at !== connectDetailRow.connect_tested_at ) { setConnectDetailRow(updated); } }, [listQuery.data, connectDetailRow]); const total = listQuery.data?.total ?? 0; const pages = pageCount(total, pageSize); const perPage = (n: number) => t("common.perPage", { n }); const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }); const saveMutation = useMutation({ mutationFn: async () => { const body = { name: form.name, vendor: form.vendor, device_type: form.device_type, ip_address: form.ip_address, port: form.port, protocol: form.protocol, username: form.username, tags: form.tags, remark: form.remark, hop_enabled: form.hop_enabled, hop_vendor: form.hop_vendor, hop_host: form.hop_host, hop_port: form.hop_port, hop_protocol: form.hop_protocol, hop_username: form.hop_username, hop_command_template: form.hop_command_template, hop_vrf: form.hop_vrf, hop_target_auth_mode: form.hop_target_auth_mode, ...(form.password ? { password: form.password } : {}), ...(form.hop_password ? { hop_password: form.hop_password } : {}), }; if (form.hop_enabled) { if (!form.hop_host.trim()) throw new Error(t("managedNe.hop.hostRequired")); if (!form.hop_username.trim()) throw new Error(t("managedNe.hop.userRequired")); if (!editing && !form.hop_password) throw new Error(t("managedNe.hop.passwordRequired")); } if (editing) { if (!form.password) delete (body as { password?: string }).password; if (!form.hop_password) delete (body as { hop_password?: string }).hop_password; return updateManagedNe(editing.id, body); } return createManagedNe({ ...body, password: form.password || "" }); }, onSuccess: async () => { setModalOpen(false); setEditing(null); setForm(emptyForm()); showOk(editing ? t("managedNe.form.updated") : t("managedNe.form.created")); await invalidateList(); }, onError: (err) => showError(String(err)), }); const deleteMutation = useMutation({ mutationFn: deleteManagedNe, onSuccess: async () => { showOk(t("managedNe.form.deleted")); await invalidateList(); }, onError: (err) => showError(String(err)), }); const connectMutation = useMutation({ mutationFn: connectTestManagedNe, onSuccess: async (res) => { showOk(t("managedNe.connect.submitted", { n: res.submitted })); await invalidateList(); }, onError: (err) => showError(String(err)), }); const batchDeleteMutation = useMutation({ mutationFn: batchDeleteManagedNe, onSuccess: async (res) => { setSelected([]); showOk(t("managedNe.batchDeleteDone", { n: res.deleted })); await invalidateList(); }, onError: (err) => showError(String(err)), }); const batchHopMutation = useMutation({ mutationFn: () => batchApplyHopManagedNe(selected, { hop_vendor: batchHop.hop_vendor, hop_host: batchHop.hop_host.trim(), hop_port: batchHop.hop_port, hop_protocol: batchHop.hop_protocol, hop_username: batchHop.hop_username.trim(), hop_password: batchHop.hop_password, hop_command_template: batchHop.hop_command_template.trim(), hop_vrf: batchHop.hop_vrf.trim(), hop_target_auth_mode: batchHop.hop_target_auth_mode, }), onSuccess: async (res) => { setBatchHopOpen(false); setBatchHop(emptyHopProxyFields()); showOk(t("managedNe.hop.batchDone", { n: res.updated })); await invalidateList(); }, onError: (err) => showError(String(err)), }); const batchAccountMutation = useMutation({ mutationFn: () => batchApplyAccountManagedNe(selected, { username: batchAccount.username.trim(), password: batchAccount.password, }), onSuccess: async (res) => { setBatchAccountOpen(false); setBatchAccount(emptyAccount()); showOk(t("managedNe.account.batchDone", { n: res.updated })); await invalidateList(); }, onError: (err) => showError(String(err)), }); const umeSyncMutation = useMutation({ mutationFn: syncUmeManagedNe, onSuccess: async (res) => { showOk( t("managedNe.umeSync.done", { inserted: res.inserted, updated: res.updated, deleted: res.deleted, total: res.total_inventory, }), ); await Promise.all([ invalidateList(), queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), ]); }, onError: (err) => showError(String(err)), }); const umeDeleteMutation = useMutation({ mutationFn: deleteUmeManagedNe, onSuccess: async (res) => { showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted })); await Promise.all([ invalidateList(), queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), ]); }, onError: (err) => showError(String(err)), }); const importMutation = useMutation({ mutationFn: importManagedNe, onSuccess: async (res) => { showOk( t("managedNe.importResult.done", { inserted: res.inserted, updated: res.updated, failed: res.failed.length, }), ); await invalidateList(); }, onError: (err) => showError(String(err)), }); // Bulk-by-tag: fetch ids then run proxy/test const [bulkHop, setBulkHop] = useState(() => emptyHopProxyFields()); const bulkByTagMutation = useMutation({ mutationFn: async (params: { action: "proxy" | "test" | "account"; tag: string }) => { const apiTag = params.tag === "" ? null : params.tag; const { ids } = await fetchIdsByTag(apiTag); if (ids.length === 0) throw new Error(t("managedNe.stats.loadingIds")); if (!window.confirm(t("managedNe.stats.confirm", { n: ids.length }))) return null; if (params.action === "test") { const res = await connectTestManagedNe(ids); return { type: "test" as const, n: res.submitted }; } if (params.action === "account") { const res = await batchApplyAccountManagedNe(ids, { username: bulkAccount.username.trim(), password: bulkAccount.password, }); return { type: "account" as const, n: res.updated }; } const res = await batchApplyHopManagedNe(ids, { hop_vendor: bulkHop.hop_vendor, hop_host: bulkHop.hop_host.trim(), hop_port: bulkHop.hop_port, hop_protocol: bulkHop.hop_protocol, hop_username: bulkHop.hop_username.trim(), hop_password: bulkHop.hop_password, hop_command_template: bulkHop.hop_command_template.trim(), hop_vrf: bulkHop.hop_vrf.trim(), hop_target_auth_mode: bulkHop.hop_target_auth_mode, }); return { type: "proxy" as const, n: res.updated }; }, onSuccess: async (res) => { if (!res) return; setBulkTagModalOpen(false); if (res.type === "test") showOk(t("managedNe.stats.testDone", { n: res.n })); else if (res.type === "account") showOk(t("managedNe.account.batchDone", { n: res.n })); else showOk(t("managedNe.stats.proxyDone", { n: res.n })); await Promise.all([ invalidateList(), queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), ]); }, onError: (err) => showError(String(err)), }); const vendors = metaQuery.data?.vendors ?? []; 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 credsOk = metaQuery.data?.credentials_configured ?? false; const allSelected = useMemo(() => { const items = listQuery.data?.items ?? []; return items.length > 0 && items.every((x) => selected.includes(x.id)); }, [listQuery.data?.items, selected]); const openCreate = () => { setEditing(null); setForm(emptyForm()); setModalOpen(true); }; const openEdit = (row: ManagedNeItem) => { setEditing(row); setForm({ name: row.name, vendor: row.vendor, device_type: row.device_type, ip_address: row.ip_address, port: row.port, protocol: row.protocol, username: row.username, password: "", tags: row.tags, remark: row.remark, hop_enabled: row.hop_enabled, hop_vendor: (["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte") as HopVendor, hop_host: row.hop_host, hop_port: row.hop_port, hop_protocol: row.hop_protocol, hop_username: row.hop_username, hop_password: "", hop_command_template: isAutoHopTemplate( row.hop_command_template, row.hop_vendor, row.hop_protocol, row.hop_vrf, ) ? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf) : row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf), hop_vrf: row.hop_vrf, hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed", }); setModalOpen(true); }; // Deep links from topology: /ne?ne_id=… (edit) or /ne?create=1&name=&ip_address=&vendor= useEffect(() => { const neId = String(searchParams.get("ne_id") || "").trim(); const wantCreate = searchParams.get("create") === "1"; if (!neId && !wantCreate) { deepLinkHandled.current = ""; return; } const handleKey = neId ? `edit:${neId}` : `create:${searchParams.toString()}`; if (deepLinkHandled.current === handleKey) return; deepLinkHandled.current = handleKey; const clearDeepLink = () => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); for (const key of ["ne_id", "create", "name", "ip_address", "vendor"]) { next.delete(key); } return next; }, { replace: true }, ); }; if (neId) { void (async () => { try { const row = await fetchManagedNeById(neId); openEdit(row); } catch (err) { showError(String(err)); } finally { clearDeepLink(); } })(); return; } const name = String(searchParams.get("name") || "").trim(); const ip = String(searchParams.get("ip_address") || "").trim(); const vendorRaw = String(searchParams.get("vendor") || "").trim(); const base = emptyForm(); const vendor = vendorRaw || base.vendor; setEditing(null); setForm({ ...base, name, vendor, device_type: deviceTypeForVendor(vendor), ip_address: ip, }); setModalOpen(true); clearDeepLink(); }, [searchParams, setSearchParams, showError]); const toggleSelect = (id: string) => { setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const toggleSelectAll = () => { const items = listQuery.data?.items ?? []; if (allSelected) { const pageIds = new Set(items.map((x) => x.id)); setSelected((prev) => prev.filter((id) => !pageIds.has(id))); } else { const ids = items.map((x) => x.id); setSelected((prev) => [...new Set([...prev, ...ids])]); } }; const stats: ManagedNeStats | undefined = statsQuery.data; const statTags: string[] = stats?.tags ?? []; const perTag = stats?.per_tag ?? {}; const tagCardItems = useMemo(() => { if (!stats) return []; const items: Array<{ key: string; title: string; total: number; by_status: Record }> = []; items.push({ key: "__all__", title: t("managedNe.stats.all"), total: stats.total, by_status: stats.by_status }); // no-tag first (if any) if ((stats.no_tag_count ?? 0) > 0 || perTag["__no_tag__"]) { items.push({ key: "__no_tag__", title: t("managedNe.stats.noTag"), total: perTag["__no_tag__"]?.total ?? stats.no_tag_count ?? 0, by_status: perTag["__no_tag__"]?.by_status ?? {}, }); } for (const tag of stats.tags || []) { const x = perTag[tag]; items.push({ key: tag, title: tag, total: x?.total ?? 0, by_status: x?.by_status ?? {}, }); } return items; }, [stats, perTag, t]); const listItems = listQuery.data?.items || []; const hasFilters = Boolean(keyword || vendorFilter || statusFilter); return (
{!credsOk ? (

{t("managedNe.credsNotConfigured")}

) : null}

{t("managedNe.stats.title")}

{stats ? (
{tagCardItems.map((x) => (
{x.title}
{t("managedNe.stats.total", { n: x.total })}
{(["pass", "fail", "testing", "unknown"] as const).map((s) => ( {t(`managedNe.stats.${s}`)} {x.by_status[s] ?? 0} ))}
))}
) : (

{t("managedNe.stats.loadingIds")}

)}

{t("managedNe.title")}

{SHOW_UME_MANAGED_SYNC ? ( <> ) : null} { const file = e.target.files?.[0]; e.target.value = ""; if (file) importMutation.mutate(file); }} />
{ setKeyword(e.target.value); setPage(1); }} />
{listQuery.isLoading ?

{t("common.refreshing")}

: null} {!listItems.length && !listQuery.isLoading ? (

{t("common.empty")}

) : (
{listItems.map((row) => { const srcKey = managedSourceKey(row.source); const srcLabel = srcKey ? t(`managedNe.source.${srcKey}`) : String(row.source || "").trim() || t("managedNe.source.manual"); return ( ); })}
{t("managedNe.col.name")} {t("managedNe.col.vendor")} {t("managedNe.col.deviceType")} {t("managedNe.col.source")} {t("managedNe.col.tags")} {t("managedNe.col.ip")} {t("managedNe.col.user")} {t("managedNe.col.connect")} {t("managedNe.col.testedAt")} {t("managedNe.col.actions")}
toggleSelect(row.id)} /> {row.name || row.ip_address} {row.vendor} {row.device_type} {srcLabel} {row.tags ? row.tags.split(/\s+/).map((tag) => ( {tag} )) : t("common.empty")} {row.ip_address}:{row.port}/{row.protocol} {row.hop_enabled ? ( {t( `managedNe.hop.badge.${["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`, )} ) : null} {row.username} {row.connect_status} {row.connect_tested_at ? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true }) : t("common.empty")}
)}
{t("common.pagerMeta", { total: String(total), page: String(page), pages: String(pages), })}
{modalOpen ? (
setModalOpen(false)}>
e.stopPropagation()}>

{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}

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

{t("managedNe.hop.sectionTitle")} {form.hop_enabled ? ( setForm((prev) => ({ ...prev, ...patch }))} hopPasswordRequired={!editing} hopPasswordOptional={Boolean(editing)} /> ) : null}
) : null} {batchHopOpen ? (
setBatchHopOpen(false)}>
e.stopPropagation()}>

{t("managedNe.hop.batchTitle")}

{t("managedNe.hop.batchHint", { n: selected.length })}

setBatchHop((prev) => ({ ...prev, ...patch }))} />
) : null} {batchAccountOpen ? (
setBatchAccountOpen(false)}>
e.stopPropagation()}>

{t("managedNe.account.batchTitle")}

{t("managedNe.account.batchHint", { n: selected.length })}

) : null} {bulkTagModalOpen ? (
setBulkTagModalOpen(false)}>
e.stopPropagation()}>

{bulkTagAction === "proxy" ? t("managedNe.stats.batchProxy") : bulkTagAction === "account" ? t("managedNe.account.batchByTag") : t("managedNe.stats.batchTest")} {bulkTagSelected && bulkTagSelected !== "__no_tag__" ? ` · ${bulkTagSelected}` : ""} {bulkTagSelected === "__no_tag__" ? ` · ${t("managedNe.stats.noTag")}` : ""} {bulkTagSelected === "" ? ` · ${t("managedNe.stats.allTag")}` : ""}

{bulkTagAction === "proxy" ? ( <>

{t("managedNe.hop.batchHint", { n: "?" })}

setBulkHop((prev) => ({ ...prev, ...patch }))} /> ) : bulkTagAction === "account" ? ( <>

{t("managedNe.account.batchByTagHint")}

) : (

{t("managedNe.stats.confirm", { n: "?" })}

)}
) : null} {connectDetailRow ? (
setConnectDetailRow(null)}>
e.stopPropagation()}>

{t("managedNe.connectDetailTitle")}

{connectDetailRow.name || connectDetailRow.ip_address} · {connectDetailRow.ip_address}: {connectDetailRow.port}/{connectDetailRow.protocol} {connectDetailRow.connect_tested_at ? ` · ${formatSystemTime(connectDetailRow.connect_tested_at, { assumeUtcNaive: true })}` : ""}

{connectDetailRow.connect_status} {connectDetailRow.connect_message ? ( — {connectDetailRow.connect_message} ) : null}

              {connectDetailRow.connect_detail?.trim() ||
                connectDetailRow.connect_message?.trim() ||
                t("managedNe.connectDetailEmpty")}
            
) : null}
); }