import { Button, Input } from "@heroui/react"; import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { bulkTagFabricNodes, deleteFabricNode, deleteFabricNodes, fetchFabricNodes, fetchTopologyTree, matchFabricNodes, patchFabricNodeTags, } from "../../services/api"; import { ListPager } from "../../components/ListPager"; import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import { openOrFocusModule } from "../../utils/moduleWindows"; import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types"; import { pageCount } from "../../utils/display"; const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; 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"; } const LEVEL_PRESETS = [ { value: "0", labelKey: "topoClassify.levelExternal" }, { value: "1", labelKey: "topoClassify.levelCore" }, { value: "1.1", labelKey: "topoClassify.levelCoreSub" }, { value: "2", labelKey: "topoClassify.levelAgg" }, { value: "2.1", labelKey: "topoClassify.levelAggSub" }, { value: "3", labelKey: "topoClassify.levelAccess" }, { value: "3.1", labelKey: "topoClassify.levelAccessSub" }, ] as const; const FILTER_MAJOR = [ { value: "", labelKey: "topoClassify.filterLevelAll" }, { value: "0", labelKey: "topoClassify.levelExternal" }, { value: "1", labelKey: "topoClassify.levelCore" }, { value: "2", labelKey: "topoClassify.levelAgg" }, { value: "3", labelKey: "topoClassify.levelAccess" }, ] as const; function formatLevel(level: number | null | undefined): string { if (level === null || level === undefined || Number.isNaN(Number(level))) return "—"; const n = Number(level); if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n)); return String(Math.round(n * 10) / 10); } function parseLevelInput(raw: string): number | null { const s = String(raw || "").trim(); if (!s) return null; const n = Number(s); if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid"); return Math.round(n * 10) / 10; } function levelPresetLabel(t: (k: string) => string, value: string): string { const hit = LEVEL_PRESETS.find((p) => p.value === value); return hit ? t(hit.labelKey) : value; } function managedSourceLabel(t: (k: string) => string, source: string): string { const s = String(source || "").trim().toLowerCase(); if (s === "manual") return t("topoClassify.sourceManual"); if (s === "ume_sync") return t("topoClassify.sourceUmeSync"); if (s === "lldp") return t("topoClassify.sourceLldp"); if (s === "topology") return t("topoClassify.sourceTopology"); if (s === "webcrt") return t("topoClassify.sourceWebcrt"); return source; } export function TopologyClassifyPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [keyword, setKeyword] = useState(""); const [filterMajor, setFilterMajor] = useState(""); const [filterRegion, setFilterRegion] = useState(""); const [unmatched, setUnmatched] = useState(""); const [linkStatus, setLinkStatus] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); 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 [assignLevel, setAssignLevel] = useState("1"); const [assignRegion, setAssignRegion] = useState(""); const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level"); const [selected, setSelected] = useState>({}); const [editingId, setEditingId] = useState(""); const [editLevel, setEditLevel] = useState(""); const [editRegion, setEditRegion] = useState(""); 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, "", filterRegion, unmatched, linkStatus, page, filterMajor, pageSize, ), queryFn: () => fetchFabricNodes({ keyword, levelMajor: filterMajor || undefined, regionFolderId: filterRegion, unmatched, linkStatus, page, pageSize, }), }); 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 === "level" || assignWhat === "both") { body.level = parseLevelInput(assignLevel); } 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: () => { let level: number | null; try { level = parseLevelInput(editLevel); } catch { throw new Error(t("topoClassify.levelInvalid")); } return patchFabricNodeTags(editingId, { level, 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 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 = pageCount(total, pageSize); const selectedCount = Object.values(selected).filter(Boolean).length; const highlight = useMemo(() => new Set(matchedIds), [matchedIds]); const startEdit = (n: FabricNodeSearchHit) => { setEditingId(n.id); setEditLevel(n.level == null ? "" : formatLevel(n.level)); 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 !== "level" ? ( ) : 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.colLevel")} {t("topoClassify.colRegion")} {t("topoClassify.colActions")}
toggleRow(n.id)} /> {n.name || n.id} {n.ip} {linkLabel(n.link_status)} {n.managed_source ? ` · ${managedSourceLabel(t, n.managed_source)}` : ""} {editing ? ( setEditLevel(e.target.value)} /> ) : ( formatLevel(n.level) )} {editing ? ( ) : ( (n.region_folder_id && regionName[n.region_folder_id]) || n.region_folder_id || "-" )} {editing ? (
) : (
)}
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
{ setPageSize(size); setPage(1); }} disabled={listQuery.isLoading} />
); }