import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, 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 { ListPager } from "../../components/ListPager"; 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 { useTableColumnFilters } from "../../hooks/useTableColumnFilters"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import { useAuth } from "../auth/AuthContext"; import type { ManagedNeImportResult, ManagedNeItem } from "../../types"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; import { openOrFocusModule } from "../../utils/moduleWindows"; import { ManagedNeFormDialog } from "./components/ManagedNeFormDialog"; import { ManagedNeConnectDetailDialog } from "./components/ManagedNeConnectDetailDialog"; import { connectStatusClass } from "./lib/connectStatus"; import { managedNeErrorText } from "./lib/errors"; import { deviceTypeForVendor, emptyManagedNeForm, managedSourceKey, type ManagedNeFormState, } from "./lib/formState"; /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; const MANAGED_NE_ENDPOINT = "/v1/managed-ne"; const CONNECT_STATUSES = ["pass", "fail", "testing", "unknown"] as const; type AccountState = { username: string; password: string; }; type BulkTagAction = "proxy" | "test" | "account"; const emptyAccount = (): AccountState => ({ username: "", password: "", }); function hopPayload(hop: HopProxyFieldsState) { return { hop_vendor: hop.hop_vendor, hop_host: hop.hop_host.trim(), hop_port: hop.hop_port, hop_protocol: hop.hop_protocol, hop_username: hop.hop_username.trim(), hop_password: hop.hop_password, hop_command_template: hop.hop_command_template.trim(), hop_vrf: hop.hop_vrf.trim(), hop_target_auth_mode: hop.hop_target_auth_mode, hop_enter_system_view: hop.hop_enter_system_view, }; } 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 errText = (err: unknown) => managedNeErrorText(err, t); const fail = (err: unknown) => showError(errText(err)); 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); const [importResult, setImportResult] = useState(null); // --- bulk-by-tag dialog --- const [bulkTagModalOpen, setBulkTagModalOpen] = useState(false); const [bulkTagAction, setBulkTagAction] = useState("proxy"); const [bulkTagSelected, setBulkTagSelected] = useState(""); // "" = all, "__no_tag__" = no-tag NEs const [bulkAccount, setBulkAccount] = useState(emptyAccount); const [bulkHop, setBulkHop] = useState(() => emptyHopProxyFields()); const [resolvingIds, setResolvingIds] = useState(false); // A filter change hides rows, so drop the selection rather than act on rows the user cannot see. const columns = useTableColumnFilters(MANAGED_NE_ENDPOINT, () => { setPage(1); setSelected([]); }); 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(columns.key, page, pageSize), queryFn: () => fetchManagedNe({ keyword: "", vendor: "", connectStatus: "", page, pageSize, columnFilters: columns.filters, }), 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); // Deleting the last rows of the last page must not strand the user on an empty page. if (listQuery.data && page > pages) setPage(pages); /** List, stats, column filter options and the Network devices view all derive from managed_ne. */ const invalidateList = () => Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }), queryClient.invalidateQueries({ queryKey: queryKeys.managedNeStats }), queryClient.invalidateQueries({ queryKey: ["tableFilterOptions", MANAGED_NE_ENDPOINT] }), queryClient.invalidateQueries({ queryKey: queryKeys.cliTargetsAll }), ]); const deleteMutation = useMutation({ mutationFn: deleteManagedNe, onSuccess: async (_res, id) => { setSelected((prev) => prev.filter((x) => x !== id)); showOk(t("managedNe.form.deleted")); await invalidateList(); }, onError: fail, }); const connectMutation = useMutation({ mutationFn: connectTestManagedNe, onSuccess: async (res) => { showOk(t("managedNe.connect.submitted", { n: res.submitted })); await invalidateList(); }, onError: fail, }); const batchDeleteMutation = useMutation({ mutationFn: batchDeleteManagedNe, onSuccess: async (res) => { setSelected([]); showOk(t("managedNe.batchDeleteDone", { n: res.deleted })); await invalidateList(); }, onError: fail, }); const batchHopMutation = useMutation({ mutationFn: () => batchApplyHopManagedNe(selected, hopPayload(batchHop)), onSuccess: async (res) => { setBatchHopOpen(false); setBatchHop(emptyHopProxyFields()); showOk(t("managedNe.hop.batchDone", { n: res.updated })); await invalidateList(); }, onError: fail, }); 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: fail, }); 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 invalidateList(); }, onError: fail, }); const umeDeleteMutation = useMutation({ mutationFn: deleteUmeManagedNe, onSuccess: async (res) => { showOk(t("managedNe.umeSync.deletedDone", { n: res.deleted })); await invalidateList(); }, onError: fail, }); const importMutation = useMutation({ mutationFn: importManagedNe, onSuccess: async (res) => { showOk( t("managedNe.importResult.done", { inserted: res.inserted, updated: res.updated, failed: res.failed.length, }), ); if (res.failed.length) setImportResult(res); await invalidateList(); }, onError: fail, }); const bulkByTagMutation = useMutation({ mutationFn: async (params: { action: BulkTagAction; ids: string[] }) => { const { action, ids } = params; if (action === "test") { const res = await connectTestManagedNe(ids); return { type: action, n: res.submitted }; } if (action === "account") { const res = await batchApplyAccountManagedNe(ids, { username: bulkAccount.username.trim(), password: bulkAccount.password, }); return { type: action, n: res.updated }; } const res = await batchApplyHopManagedNe(ids, hopPayload(bulkHop)); return { type: action, n: res.updated }; }, onSuccess: async (res) => { 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 invalidateList(); }, onError: fail, }); const credsOk = metaQuery.data?.credentials_configured ?? false; const listItems = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]); const pageIds = listItems.map((x) => x.id); const selectedOnPage = pageIds.filter((id) => selected.includes(id)).length; const allSelected = pageIds.length > 0 && selectedOnPage === pageIds.length; const someSelected = selectedOnPage > 0 && !allSelected; 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(managedNeErrorText(err, t)); } 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, t]); const toggleSelect = (id: string) => { setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const toggleSelectAll = () => { if (allSelected) { const ids = new Set(pageIds); setSelected((prev) => prev.filter((id) => !ids.has(id))); } else { setSelected((prev) => [...new Set([...prev, ...pageIds])]); } }; 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]); // Tag cards double as a quick filter on the tags column ("" = untagged). const tagsFilter = columns.filters.tags ?? null; const activeTagCard = tagsFilter === null ? "__all__" : tagsFilter.mode === "include" && tagsFilter.values.length === 1 ? tagsFilter.values[0] || "__no_tag__" : ""; const applyTagCard = (key: string) => { if (key === "__all__") columns.setFilter("tags", null); else columns.setFilter("tags", { mode: "include", values: [key === "__no_tag__" ? "" : key] }); }; const bulkScopeCount = bulkTagSelected === "" ? stats?.total : perTag[bulkTagSelected]?.total ?? (bulkTagSelected === "__no_tag__" ? stats?.no_tag_count : 0); const sourceLabel = (source: string) => { const key = managedSourceKey(source); return key ? t(`managedNe.source.${key}`) : String(source || "").trim() || t("managedNe.source.manual"); }; const statusLabel = (status: string) => (CONNECT_STATUSES as readonly string[]).includes(status) ? t(`managedNe.stats.${status}`) : status; const runBulkByTag = async () => { if (bulkTagAction === "proxy") { if (!bulkHop.hop_host.trim()) { showError(t("managedNe.hop.hostRequired")); return; } if (!bulkHop.hop_username.trim()) { showError(t("managedNe.hop.userRequired")); return; } } if (bulkTagAction === "account" && !bulkAccount.username.trim() && !bulkAccount.password) { showError(t("managedNe.account.usernameOrPasswordRequired")); return; } setResolvingIds(true); let ids: string[]; try { ids = (await fetchIdsByTag(bulkTagSelected === "" ? null : bulkTagSelected)).ids; } catch (err) { fail(err); return; } finally { setResolvingIds(false); } if (ids.length === 0) { showError(t("managedNe.stats.noMatchingNe")); return; } if (!window.confirm(t("managedNe.stats.confirm", { n: ids.length }))) return; bulkByTagMutation.mutate({ action: bulkTagAction, ids }); }; const bulkBusy = resolvingIds || bulkByTagMutation.isPending; return (
{!credsOk && metaQuery.isSuccess ? (

{t("managedNe.credsNotConfigured")}

) : null}

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

{stats ? ( <>

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

{tagCardItems.map((x) => ( ))}
) : statsQuery.isError ? (

{errText(statsQuery.error)}

) : (

{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); }} />
{listQuery.isError ? (

{t("managedNe.loadFailed", { error: errText(listQuery.error) })}

) : null}
{listItems.map((row) => ( ))} {!listItems.length && !listQuery.isFetching && !listQuery.isError ? ( ) : null}
{ if (input) input.indeterminate = someSelected; }} checked={allSelected} disabled={!pageIds.length} onChange={toggleSelectAll} /> {columns.column("name", t("managedNe.col.name"))} {columns.column("vendor", t("managedNe.col.vendor"))} {columns.column("deviceType", t("managedNe.col.deviceType"))} {columns.column("source", t("managedNe.col.source"), sourceLabel)} {columns.column("tags", t("managedNe.col.tags"))} {columns.column("ip", t("managedNe.col.ip"))} {columns.column("username", t("managedNe.col.user"))} {columns.column("connectStatus", t("managedNe.col.connect"), statusLabel)} {t("managedNe.col.testedAt")} {t("managedNe.col.actions")}
toggleSelect(row.id)} /> {row.name || row.ip_address} {row.vendor} {row.device_type} {sourceLabel(row.source || "")} {row.tags ? row.tags.split(/\s+/).filter(Boolean).map((tag) => ( {tag} )) : "—"} {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 || "—"} {statusLabel(row.connect_status)} {row.connect_tested_at ? formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true }) : "—"}

{columns.active ? t("managedNe.emptyFiltered") : t("common.empty")}

{ setPageSize(size); setPage(1); }} disabled={listQuery.isLoading} meta={ <> {selected.length ? ( · {t("managedNe.selectedCount", { n: selected.length })} ) : null} {listQuery.isFetching ? · {t("common.refreshing")} : null} } />
{ 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 })}

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

setBatchHop((prev) => ({ ...prev, ...patch }))} hopPasswordRequired={false} hopPasswordOptional />
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={!bulkBusy} 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) => ( ))} {bulkScopeCount !== undefined ? (

{t("managedNe.stats.scopeCount", { n: bulkScopeCount })}

) : null} {bulkTagAction === "proxy" ? ( <>

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

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

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

setBulkAccount((prev) => ({ ...prev, username }))} > setBulkAccount((prev) => ({ ...prev, password }))} >
) : null}
setImportResult(null)} size="md"> {t("managedNe.importResult.title")} {importResult ? ( <>

{t("managedNe.importResult.done", { inserted: importResult.inserted, updated: importResult.updated, failed: importResult.failed.length, })}

{t("managedNe.importResult.failedTitle", { n: importResult.failed.length })}

{importResult.failed.map((item) => ( ))}
{t("managedNe.importResult.row")} {t("managedNe.importResult.reason")}
{item.row} {errText(new Error(item.reason))}
) : null}
setConnectDetailRow(null)} onRetestSubmitted={() => { void invalidateList(); }} />
); }