import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, Checkbox, Input, Label, Modal, TextField } from "@heroui/react"; import { batchApplyAccountManagedNe, batchApplyHopManagedNe, batchDeleteManagedNe, connectTestManagedNe, deleteUmeManagedNe, deleteManagedNe, fetchIdsByTag, fetchManagedNe, fetchManagedNeById, fetchManagedNeMeta, fetchManagedNeStats, importManagedNe, downloadManagedNeImportTemplate, syncUmeManagedNe, type ManagedNeStats, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; import { HopProxyFields, emptyHopProxyFields, type HopProxyFieldsState } from "../components/HopProxyFields"; import { AppModalShell } from "../components/ui/AppModalShell"; import { FieldSelect } from "../components/ui/FieldSelect"; 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 { ManagedNeFormDialog } from "./managedNe/ManagedNeFormDialog"; import { ManagedNeConnectDetailDialog } from "./managedNe/ManagedNeConnectDetailDialog"; import { connectStatusClass } from "./managedNe/connectStatus"; import { deviceTypeForVendor, emptyManagedNeForm, managedSourceKey, type ManagedNeFormState, } from "./managedNe/formState"; /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; type AccountState = { username: string; password: string; }; const emptyAccount = (): AccountState => ({ username: "", password: "", }); 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(10); 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 [formSeed, setFormSeed] = useState | undefined>(); 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 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, hop_enter_system_view: batchHop.hop_enter_system_view, }), 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, hop_enter_system_view: bulkHop.hop_enter_system_view, }); 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 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); setFormSeed(undefined); setModalOpen(true); }; const openEdit = (row: ManagedNeItem) => { setEditing(row); setFormSeed(undefined); 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 = emptyManagedNeForm(); const vendor = vendorRaw || base.vendor; setEditing(null); setFormSeed({ 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); }} /> { setVendorFilter(e.target.value); setPage(1); }} > {vendors.map((v) => ( ))} { setStatusFilter(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), })}
{ setPageSize(Number(e.target.value) || 10); setPage(1); }} >
{ setModalOpen(false); setEditing(null); setFormSeed(undefined); }} onSaved={async () => { const wasEdit = Boolean(editing); setModalOpen(false); setEditing(null); setFormSeed(undefined); showOk(wasEdit ? t("managedNe.form.updated") : t("managedNe.form.created")); await invalidateList(); }} /> setBatchHopOpen(false)} dismissible={!batchHopMutation.isPending} size="lg" > {t("managedNe.hop.batchTitle")}

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

setBatchHop((prev) => ({ ...prev, ...patch }))} />
setBatchAccountOpen(false)} dismissible={!batchAccountMutation.isPending} size="md" > {t("managedNe.account.batchTitle")}

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

setBatchAccount((prev) => ({ ...prev, username }))} > setBatchAccount((prev) => ({ ...prev, password }))} >
setBulkTagModalOpen(false)} dismissible={!bulkByTagMutation.isPending} size="lg" > {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")}` : ""} setBulkTagSelected(e.target.value)} > {statTags.map((tag) => ( ))} {bulkTagAction === "proxy" ? ( <>

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

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

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

setBulkAccount((prev) => ({ ...prev, username }))} > setBulkAccount((prev) => ({ ...prev, password }))} >
) : (

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

)}
setConnectDetailRow(null)} onRetestSubmitted={() => { void invalidateList(); }} />
); }