import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { bulkTagFabricNodes, deleteFabricNode, deleteFabricNodes, fetchFabricNodes, fetchTopologyTree, generateTopologySlices, matchFabricNodes, patchFabricNodeTags, } from "../../services/api"; import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import { openOrFocusModule } from "../../utils/moduleWindows"; import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types"; const PAGE_SIZE = 50; function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] { if (!root) return []; return (root.children || []).filter((c) => String(c.kind) === "region"); } /** Prefer API flag; fall back so UI still works if backend is stale. */ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean { if (typeof n.deletable === "boolean") return n.deletable; const status = String(n.link_status || "").toLowerCase(); if (status === "ume" || status === "both") return false; if (status === "orphaned") return true; if (!String(n.managed_ne_id || "").trim()) { return !String(n.ume_ne_id || "").trim(); } if (n.managed_alive === false) return true; const src = String(n.managed_source || "").toLowerCase(); return src === "lldp" || src === "topology" || src === "webcrt"; } export function TopologyClassifyPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [keyword, setKeyword] = useState(""); const [filterRole, setFilterRole] = useState(""); const [filterRegion, setFilterRegion] = useState(""); const [unmatched, setUnmatched] = useState(""); const [linkStatus, setLinkStatus] = useState(""); const [page, setPage] = useState(1); const linkLabel = (status: string | undefined) => { switch (String(status || "").toLowerCase()) { case "managed": return t("topoClassify.linkManaged"); case "ume": return t("topoClassify.linkUme"); case "both": return t("topoClassify.linkBoth"); case "orphaned": return t("topoClassify.linkOrphaned"); default: return status || "—"; } }; const [regex, setRegex] = useState(""); const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name"); const [matchedIds, setMatchedIds] = useState([]); const [matchTotal, setMatchTotal] = useState(0); const [assignRole, setAssignRole] = useState("core"); const [assignRegion, setAssignRegion] = useState(""); const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role"); const [selected, setSelected] = useState>({}); const [editingId, setEditingId] = useState(""); const [editRole, setEditRole] = useState(""); const [editRegion, setEditRegion] = useState(""); const [sliceFolderId, setSliceFolderId] = useState(""); const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">( "core_agg", ); const [slicePreview, setSlicePreview] = useState(null); const [seedPhysical, setSeedPhysical] = useState(true); const [showSlices, setShowSlices] = useState(false); const treeQuery = useQuery({ queryKey: queryKeys.topologyTree, queryFn: fetchTopologyTree, }); const regions = useMemo(() => flattenRegions(treeQuery.data?.root), [treeQuery.data?.root]); const regionName = useMemo(() => { const m: Record = {}; for (const r of regions) m[r.id] = r.name; return m; }, [regions]); const listQuery = useQuery({ queryKey: queryKeys.fabricNodeInventory( keyword, filterRole, filterRegion, unmatched, linkStatus, page, ), queryFn: () => fetchFabricNodes({ keyword, role: filterRole, regionFolderId: filterRegion, unmatched, linkStatus, page, pageSize: PAGE_SIZE, }), }); const invalidateList = async () => { await queryClient.invalidateQueries({ queryKey: ["fabricNodeInventory"] }); }; const matchMut = useMutation({ mutationFn: () => matchFabricNodes({ pattern: regex, match_field: matchField, sample_limit: 80 }), onSuccess: (out) => { setMatchedIds(out.fabric_node_ids || []); setMatchTotal(out.total_matched || 0); const next: Record = {}; for (const id of out.fabric_node_ids || []) next[id] = true; setSelected(next); showOk(t("topoClassify.matchOk").replace("{{count}}", String(out.total_matched))); }, onError: (err) => showError(String(err)), }); const bulkMut = useMutation({ mutationFn: () => { const ids = Object.keys(selected).filter((k) => selected[k]); const body: Parameters[0] = { fabric_node_ids: ids.length ? ids : matchedIds, dry_run: false, }; if (assignWhat === "role" || assignWhat === "both") body.role = assignRole; if (assignWhat === "region" || assignWhat === "both") { body.region_folder_id = assignRegion || ""; } return bulkTagFabricNodes(body); }, onSuccess: async (out) => { showOk(t("topoClassify.bulkOk").replace("{{count}}", String(out.updated))); setMatchedIds([]); setMatchTotal(0); setSelected({}); await invalidateList(); }, onError: (err) => showError(String(err)), }); const patchMut = useMutation({ mutationFn: () => patchFabricNodeTags(editingId, { role: editRole, region_folder_id: editRegion || "", }), onSuccess: async () => { showOk(t("topoClassify.rowSaved")); setEditingId(""); await invalidateList(); }, onError: (err) => showError(String(err)), }); const deleteOneMut = useMutation({ mutationFn: (id: string) => deleteFabricNode(id), onSuccess: async (out) => { showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted))); setSelected({}); await invalidateList(); }, onError: (err) => showError(String(err)), }); const deleteBulkMut = useMutation({ mutationFn: (ids: string[]) => deleteFabricNodes(ids), onSuccess: async (out) => { showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted))); setSelected({}); await invalidateList(); }, onError: (err) => showError(String(err)), }); const slicePreviewMut = useMutation({ mutationFn: () => generateTopologySlices({ folder_id: sliceFolderId, template: sliceTemplate, dry_run: true, max_nodes: 300, seed_physical_cores: seedPhysical, }), onSuccess: (out) => setSlicePreview(out), onError: (err) => showError(String(err)), }); const sliceApplyMut = useMutation({ mutationFn: () => generateTopologySlices({ folder_id: sliceFolderId, template: sliceTemplate, dry_run: false, max_nodes: 300, seed_physical_cores: seedPhysical, }), onSuccess: async (out) => { showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length))); setSlicePreview(out); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); }, onError: (err) => showError(String(err)), }); const items = listQuery.data?.items || []; const deletableSelected = items .filter((n) => selected[n.id] && isFabricNodeDeletable(n)) .map((n) => n.id); const total = listQuery.data?.total || 0; const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const selectedCount = Object.values(selected).filter(Boolean).length; const highlight = useMemo(() => new Set(matchedIds), [matchedIds]); const startEdit = (n: FabricNodeSearchHit) => { setEditingId(n.id); setEditRole(n.role || ""); setEditRegion(n.region_folder_id || ""); }; const toggleRow = (id: string) => { setSelected((s) => ({ ...s, [id]: !s[id] })); }; const togglePage = (on: boolean) => { setSelected((s) => { const next = { ...s }; for (const n of items) next[n.id] = on; return next; }); }; return (

{t("topoClassify.title")}

{t("topoClassify.inventory")}

{ setKeyword(e.target.value); setPage(1); }} />
{t("topoClassify.regexTitle")} setRegex(e.target.value)} /> {matchTotal > 0 ? ( {t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))} ) : null}
{assignWhat !== "region" ? ( ) : null} {assignWhat !== "role" ? ( ) : null} ? {t("topoClassify.deleteHint")}
{items.map((n) => { const editing = editingId === n.id; return ( ); })} {items.length === 0 ? ( ) : null}
0 && items.every((n) => selected[n.id])} onChange={(e) => togglePage(e.target.checked)} aria-label={t("topoClassify.selectPage")} /> {t("topoClassify.colNe")} IP {t("topoClassify.colLink")} {t("topoClassify.colRole")} {t("topoClassify.colRegion")} {t("topoClassify.colActions")}
toggleRow(n.id)} /> {n.name || n.id} {n.ip} {linkLabel(n.link_status)} {n.managed_source ? ` · ${n.managed_source}` : ""} {editing ? ( ) : ( n.role || "-" )} {editing ? ( ) : ( (n.region_folder_id && regionName[n.region_folder_id]) || n.region_folder_id || "-" )} {editing ? (
) : (
)}
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
{t("common.pagerMeta") .replace("{{total}}", String(total)) .replace("{{page}}", String(page)) .replace("{{pages}}", String(pages))}
{showSlices ? ( <>

{t("topoClassify.slices")}

{slicePreview ? (

{t("topoClassify.sliceStats") .replace("{{maps}}", String(slicePreview.map_count)) .replace("{{overlap}}", String(slicePreview.overlap_node_count))}

    {slicePreview.maps.map((m) => (
  • {m.name} {" · "} {m.role} {" · "} {t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
  • ))}
) : null} ) : null}
); }