netx/web/src/pages/TopologyPage.tsx
oliver b1aee86701 Split topology page into modules and hooks; add SVG/XML export.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:00:38 +08:00

2390 lines
81 KiB
TypeScript

import {
DISCOVER_AUTO_ADD_KEY,
DISCOVER_PROJECT_NEIGHBORS_KEY,
PALETTE_DND,
SCALE_BUNDLE_WIDTH_KEY,
SEP,
SHOW_PLACEHOLDER_BADGE_KEY,
WORLD_MAP_ENABLED,
} from "./topology/constants";
import type { CtxMenu, PaletteItem, PaletteSource } from "./topology/pageTypes";
import { clampSidebarWidth, loadSidebarWidth, saveSidebarWidth } from "./topology/sidebarLayout";
import {
canvasBgLuminance,
canvasFloatChromeVars,
findViewInRegion,
isWorldFlatViewName,
regionDisplayName,
} from "./topology/treeUtils";
import {
loadAutoLayoutAfterDiscover,
loadBoolFlag,
loadCanvasBg,
loadLabelColors,
loadVendorColors,
persistBoolFlag,
type LabelColors,
type VendorColors,
} from "./topology/displayPrefs";
import {
BUILTIN_EDGE_DEFAULTS,
type EdgeDefaultStyle,
type EdgeDefaults,
type EdgeSourceKind,
type EdgeStyleData,
loadEdgeDefaults,
persistEdgeDefaults,
resolveEdgeStyle,
withEdgeVisual,
} from "./topology/edgeStyle";
import { applyViewGraph, flowToPositions } from "./topology/graphFlow";
import { isLocalPendingEdgeId, newLocalEdgeId } from "./topology/idUtils";
import { fuzzyIncludes, isPlaceholderSource, nodeMatchesQuery } from "./topology/searchUtils";
import { TopologyDiscoverPanel } from "./topology/TopologyDiscoverPanel";
import { TopologyHexBrowser } from "./topology/TopologyHexBrowser";
import { TopologyToolbar } from "./topology/TopologyToolbar";
import { TopologyViewTools } from "./topology/TopologyViewTools";
import { TopologySidebar } from "./topology/TopologySidebar";
import { TopologyModals } from "./topology/TopologyModals";
import { TopologyCtxMenu } from "./topology/TopologyCtxMenu";
import { useTopologyHistory } from "./topology/hooks/useTopologyHistory";
import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover";
import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav";
import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas";
import {
lazy,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Connection,
Edge,
Node,
} from "@xyflow/react";
import {
addTopologyViewNodes,
createFabricManualEdge,
createTopologyPlaceholder,
deleteFabricEdges,
purgePlaceholderFabricNodes,
fetchLldpCollectDashboard,
fetchManagedNe,
fetchManagedNeById,
fetchTopologyGraph,
fetchUmeNe,
patchTopologyEdgeStyle,
patchTopologyPositions,
removeTopologyViewNodes,
applyUmeTopologyToFabric,
} 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,
TopologyViewItem,
} from "../types";
import { alignNodes } from "./topology/layoutGraph";
import {
formatPortPairLabel,
isAggregateEdgeId,
physicalIdsForDisplayEdge,
} from "./topology/linkDisplay";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
import type { NeNodeData } from "./topology/TopologyReactFlowView";
import {
buildExportGraph,
exportTopologySvg,
exportTopologyXml,
} from "./topology/exportTopology";
/** xyflow + canvas nodes — only loaded when a map canvas is open. */
const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView"));
export function TopologyPage() {
const { t, locale } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null);
const ctxMenuRef = useRef<HTMLUListElement | null>(null);
const [hideIp, setHideIp] = useState(true);
const [hideVendor, setHideVendor] = useState(true);
const [hidePorts, setHidePorts] = useState(true);
/** Default: logical aggregate of parallel links; expand to see each physical edge. */
const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false);
/** When aggregating, optionally thicken the line by member count (off = single-line width). */
const [scaleBundleWidth, setScaleBundleWidth] = useState(() =>
loadBoolFlag(SCALE_BUNDLE_WIDTH_KEY, false),
);
/** TOPO/LLDP corner badge hidden by default; toggle in Display menu. */
const [showPlaceholderBadge, setShowPlaceholderBadge] = useState(() =>
loadBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, false),
);
const [edgeFlow, setEdgeFlow] = useState(false);
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
const [labelColors, setLabelColors] = useState<LabelColors>(() => loadLabelColors());
const [vendorColors, setVendorColors] = useState<VendorColors>(() => loadVendorColors());
const [toolMode, setToolMode] = useState<ToolMode>("pan");
const [snapToGrid, setSnapToGrid] = useState(true);
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() =>
loadBoolFlag(DISCOVER_AUTO_ADD_KEY, true),
);
const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() =>
loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, true),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => loadSidebarWidth());
const [sidebarResizing, setSidebarResizing] = useState(false);
const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null);
const topoPageRef = useRef<HTMLDivElement | null>(null);
const [addNeOpen, setAddNeOpen] = useState(false);
const [outsidePeersOpen, setOutsidePeersOpen] = useState(false);
const [outsidePeerSelectedIds, setOutsidePeerSelectedIds] = useState<string[]>([]);
const [outsidePeerQuery, setOutsidePeerQuery] = useState("");
const [outsidePeersAdding, setOutsidePeersAdding] = useState(false);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
const [paletteAdding, setPaletteAdding] = useState(false);
const [createNeDialog, setCreateNeDialog] = useState<{
flowX: number;
flowY: number;
name: string;
ip_address: string;
} | null>(null);
const [createNeBusy, setCreateNeBusy] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
const [liveSync, setLiveSync] = useState(false);
const [canvasQuery, setCanvasQuery] = useState("");
const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
const [findOpen, setFindOpen] = useState(false);
const [findActiveIdx, setFindActiveIdx] = useState(0);
const searchHitTimerRef = useRef<number | null>(null);
const findBoxRef = useRef<HTMLDivElement | null>(null);
const displayMenuRef = useRef<HTMLDetailsElement | null>(null);
const exportMenuRef = useRef<HTMLDetailsElement | null>(null);
const [exporting, setExporting] = useState(false);
const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState<HTMLDivElement | null>(null);
const findJustLocatedRef = useRef(false);
const [nodes, setNodes] = useState<Node<NeNodeData>[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
const {
dirty,
canUndo,
canRedo,
dirtyRef,
historyRef,
redoRef,
historyLockRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
markDirty,
clearDirty,
bumpHistory,
confirmDiscardIfDirty,
pushHistory,
undo,
redo,
} = useTopologyHistory(nodes, edges, setNodes, setEdges, t);
const tree = useTopologyTreeNav({
liveSync,
searchParams,
setSearchParams,
locale,
queryClient,
confirmDiscardIfDirty,
clearDirty,
showOk,
showError,
t,
setNodes,
setEdges,
setCtxMenu,
});
const {
mapId,
setMapId,
setWorldFocusFolderId,
worldViewId,
selectedFolderId,
setSelectedFolderId,
hotBrowseKey,
setHotBrowseKey,
pendingHighlightNe,
setPendingHighlightNe,
expandedIds,
setExpandedIds,
treeNeQuery,
setTreeNeQuery,
debouncedTreeNeQuery,
setDebouncedTreeNeQuery,
treeSearchOpen,
setTreeSearchOpen,
treeSearchRef,
newRootDialog,
setNewRootDialog,
treeQuery,
treeFlatMap,
treeNeSearchQuery,
treeRoot,
regions,
treeLoading,
treeFailed,
rootFolderId,
canvasMode,
activeRegion,
activeView,
browseEntries,
hexBrowseRegion,
umeWorldHexModules,
primaryViewOfFolder,
goUmeWorldNav,
goRegion,
goRoot,
goCanvas,
goBackBrowse,
renameMapMut,
promptRenameMap,
createRegionMut,
renameRegionMut,
promptRenameRegion,
deleteFolderMut,
promptNewRegion,
submitNewRoot,
promptNewSubRegion,
onTreeToggleExpand,
onTreeHotBrowseKey,
onTreeClearHotBrowseKey,
onTreeDeleteFolder,
onTreeDeleteMap,
breadcrumbFolders,
} = tree;
const connectClickRef = useRef<string | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 150);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
const canvas = useTopologyCanvas({
mapId,
treeFlatMap,
liveSync,
canvasMode,
activeView,
nodes,
edges,
setNodes,
setEdges,
edgeDefaults,
toolMode,
hideIp,
hideVendor,
hidePorts,
showPlaceholderBadge,
expandPhysicalLinks,
scaleBundleWidth,
edgeFlow,
selectedEdgeId,
setSelectedEdgeId,
setSearchHitIds,
searchHitTimerRef,
queryClient,
dirtyRef,
historyLockRef,
historyRef,
redoRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
clearDirty,
bumpHistory,
showError,
t,
});
const {
rfRef,
canvasRef,
nodesRef,
appliedMapIdRef,
pendingFitRef,
isWorldFlatCanvasRef,
worldTransformRef,
graphQuery,
isWorldFlatCanvas,
canvasZoom,
setCanvasZoom,
worldVisualLod,
displayOpts,
displayEdges,
scheduleFlatViewportRefresh,
fitCanvas,
focusNode,
locateNode,
worldScatter,
showWorldScatter,
canvasGraphLoading,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,
truncateBannerText,
activeLeafName,
worldDockMe,
worldTotal,
graphTruncated,
truncateReason,
} = canvas;
const discover = useTopologyDiscover({
mapId,
nodes,
edges,
graphNodes: graphQuery.data?.nodes,
edgeDefaults,
autoLayoutAfterDiscover,
discoverAutoAddUnmatched,
discoverProjectNeighbors,
queryClient,
setNodes,
setEdges,
dirtyRef,
pendingEdgeDeletesRef,
pendingEdgeCreatesRef,
historyLockRef,
appliedMapIdRef,
markDirty,
clearDirty,
showOk,
showError,
t,
});
const {
discoverOpen,
setDiscoverOpen,
discovering,
discoverReport,
discoverProgress,
discoverError,
discoverResults,
discoverSummary,
discoverPct,
runDiscover,
cancelDiscover,
} = discover;
// Keep canvas "auto placeholder" in sync with topology LLDP collect policy (shared strategy).
useEffect(() => {
let alive = true;
void fetchLldpCollectDashboard()
.then((dash) => {
if (!alive || !dash?.policy) return;
const next = Boolean(dash.policy.auto_add_unmatched);
setDiscoverAutoAddUnmatched(next);
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
})
.catch(() => {
/* keep local default */
});
return () => {
alive = false;
};
}, []);
const neQuery = useQuery({
queryKey: [...queryKeys.managedNeAll, "topology-add-ne", debouncedKeyword],
queryFn: () =>
fetchManagedNe({
keyword: debouncedKeyword,
vendor: "",
connectStatus: "",
page: 1,
pageSize: 100,
}),
enabled: addNeOpen && paletteSource === "managed",
});
const umeQuery = useQuery({
queryKey: ["umeInventoryNe", "topology-add-ne", debouncedKeyword],
queryFn: () =>
fetchUmeNe({
keyword: debouncedKeyword,
page: 1,
pageSize: 100,
}),
enabled: addNeOpen && paletteSource === "ume",
});
const updateEdgeDefault = useCallback((kind: EdgeSourceKind, patch: Partial<EdgeDefaultStyle>) => {
setEdgeDefaults((prev) => {
const next: EdgeDefaults = {
...prev,
[kind]: { ...prev[kind], ...patch },
};
persistEdgeDefaults(next);
return next;
});
}, []);
const resetEdgeDefaults = useCallback(() => {
const next: EdgeDefaults = {
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
};
persistEdgeDefaults(next);
setEdgeDefaults(next);
}, []);
const applyAlign = useCallback(
(kind: Parameters<typeof alignNodes>[2]) => {
const ids = nodes.filter((n) => n.selected).map((n) => n.id);
if (ids.length < 2) {
showError(t("topology.alignNeedSelection"));
return;
}
pushHistory();
setNodes(alignNodes(nodes, ids, kind));
markDirty();
},
[nodes, setNodes, pushHistory, showError, t, markDirty],
);
const applyWorldMut = useMutation({
mutationFn: () => applyUmeTopologyToFabric(),
onSuccess: async () => {
showOk(t("topology.worldApplyOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
pendingFitRef.current = true;
},
onError: (err) => showError(String(err)),
});
const saveMut = useMutation({
mutationFn: async () => {
if (!mapId) throw new Error(t("topology.selectMap"));
const createIds = [...pendingEdgeCreatesRef.current];
for (const id of createIds) {
const e = edges.find((x) => x.id === id);
if (!e) continue;
const d = (e.data || {}) as EdgeStyleData;
await createFabricManualEdge({
a_node_id: e.source,
b_node_id: e.target,
a_port: String(d.source_port || ""),
b_port: String(d.target_port || ""),
});
}
pendingEdgeCreatesRef.current.clear();
const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id));
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
}
pendingEdgeDeletesRef.current.clear();
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = nodes.map((n) => n.id);
const localSet = new Set(localIds);
const serverSet = new Set(serverIds);
const isRegionNode = (id: string) => id.startsWith("region:");
const toAdd = localIds.filter((id) => !serverSet.has(id) && !isRegionNode(id));
const toRemove = serverIds.filter((id) => !localSet.has(id) && !isRegionNode(id));
// Region icons are synthetic placements — persist via positions only.
// Add first so a save that both adds and removes never flashes an empty view.
if (toAdd.length) {
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
}
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
return patchTopologyPositions(mapId, flowToPositions(nodes));
},
onSuccess: async (graph) => {
clearDirty();
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved"));
},
onError: (err) => showError(String(err)),
});
const isValidConnection = useCallback(
(connection: Connection | Edge) => {
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target || source === target) return false;
return !edges.some((e) => e.source === source && e.target === target);
},
[edges],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!mapId || !isValidConnection(connection)) return;
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target) return;
pushHistory();
connectClickRef.current = null;
const id = newLocalEdgeId();
const data: EdgeStyleData = {
source: "manual",
source_port: "",
target_port: "",
stroke_color: "",
stroke_width: 0,
line_style: "",
discovered_at: null,
};
const edge = withEdgeVisual(
{
id,
source,
target,
// Center anchors only — omit handle ids so attach matches discovered edges after Save.
sourceHandle: null,
targetHandle: null,
type: "straight",
animated: false,
data,
},
edgeDefaults,
);
pendingEdgeCreatesRef.current.add(id);
setEdges((es) => [...es, edge]);
markDirty();
},
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
);
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!mapId) {
showError(t("topology.selectMap"));
return false;
}
if (isWorldFlatViewName(activeView?.name)) {
showError(t("topology.worldMapNoDirectNes"));
return false;
}
if (items.length === 0 || paletteAdding) return false;
const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean));
const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean));
const managedIds = [
...new Set(
items
.filter((item) => item.source === "managed")
.map((item) => item.managed_ne_id)
.filter((id) => id && !onCanvasManaged.has(id)),
),
];
const umeIds = [
...new Set(
items
.filter((item) => item.source === "ume")
.map((item) => item.ume_ne_id)
.filter((id) => id && !onCanvasUme.has(id)),
),
];
if (managedIds.length === 0 && umeIds.length === 0) return false;
setPaletteAdding(true);
try {
const graph = await addTopologyViewNodes(mapId, {
managed_ne_ids: managedIds,
ume_ne_ids: umeIds,
layout: "grid",
});
const managedSet = new Set(managedIds);
const umeSet = new Set(umeIds);
const added = graph.nodes.filter(
(n) =>
(n.managed_ne_id && managedSet.has(n.managed_ne_id)) ||
(n.ume_ne_id && umeSet.has(n.ume_ne_id)),
);
if (added.length > 0) {
const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
await patchTopologyPositions(
mapId,
added.map((n, i) => ({
fabric_node_id: n.fabric_node_id,
x: start.x + (i % cols) * 180,
y: start.y + Math.floor(i / cols) * 120,
label: n.label || n.name || "",
})),
);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
historyLockRef.current = true;
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
const count = added.length || managedIds.length + umeIds.length;
showOk(t("topology.addSelectedDone").replace("{{count}}", String(count)));
return count > 0;
} catch (err) {
showError(String(err));
return false;
} finally {
setPaletteAdding(false);
}
},
[
mapId,
nodes,
paletteAdding,
setNodes,
setEdges,
showError,
showOk,
t,
queryClient,
edgeDefaults,
clearDirty,
activeView?.name,
],
);
const addNodeAt = useCallback(
async (item: PaletteItem, position: { x: number; y: number }) => {
await addPaletteItems([item], position);
},
[addPaletteItems],
);
const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => {
e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item));
e.dataTransfer.effectAllowed = "copy";
};
const onCanvasDragOver = (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(PALETTE_DND)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onCanvasDrop = (e: React.DragEvent) => {
e.preventDefault();
const raw = e.dataTransfer.getData(PALETTE_DND);
if (!raw || !rfRef.current) return;
try {
const item = JSON.parse(raw) as PaletteItem;
const pos = rfRef.current.screenToFlowPosition({ x: e.clientX, y: e.clientY });
addNodeAt(item, pos);
} catch {
/* ignore */
}
};
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
const selectedNode = selectedNodes[0] || null;
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
const selectedEdge = useMemo(() => {
if (!selectedEdgeId) return null;
return (
displayEdges.find((e) => e.id === selectedEdgeId) ||
edges.find((e) => e.id === selectedEdgeId) ||
null
);
}, [selectedEdgeId, displayEdges, edges]);
const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData;
const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null;
const selectedEdgeSourceNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.source) : null;
const selectedEdgeTargetNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.target) : null;
const patchSelectedEdgeStyle = useCallback(
(
patch: Partial<
Pick<EdgeStyleData, "stroke_color" | "stroke_width" | "line_style" | "source_port" | "target_port">
>,
opts?: { skipHistory?: boolean },
) => {
if (!selectedEdgeId || !mapId) return;
const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge;
const targetIds = physicalIdsForDisplayEdge(display, edges);
if (!targetIds.length) return;
// Port edits only make sense on a single physical link.
if (
(isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) &&
(patch.source_port !== undefined || patch.target_port !== undefined)
) {
return;
}
if (!opts?.skipHistory) pushHistory();
setEdges((eds) =>
eds.map((e) => {
if (!targetIds.includes(e.id)) return e;
const prev = (e.data || {}) as EdgeStyleData;
const data: EdgeStyleData = {
...prev,
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
stroke_width:
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
};
const portLabel = formatPortPairLabel(
data.source_port || "",
data.target_port || "",
data.display_label,
);
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
}),
);
const edge = edges.find((e) => e.id === targetIds[0]);
const prev = (edge?.data || {}) as EdgeStyleData;
for (const fabricEdgeId of targetIds) {
void patchTopologyEdgeStyle(mapId, {
fabric_edge_id: fabricEdgeId,
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
stroke_width:
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
}).catch((err) => showError(String(err)));
}
},
[selectedEdgeId, selectedEdge, mapId, edges, displayEdges, setEdges, pushHistory, edgeDefaults, showError],
);
const renameSelectedNode = useCallback(() => {
if (!selectedNode) return;
const next = window.prompt(t("topology.renameNodePrompt"), selectedNode.data.label || "");
if (next == null) return;
const label = next.trim();
if (!label || label === selectedNode.data.label) {
setCtxMenu(null);
return;
}
pushHistory();
markDirty();
setNodes((ns) =>
ns.map((n) => (n.id === selectedNode.id ? { ...n, data: { ...n.data, label } } : n)),
);
setCtxMenu(null);
}, [selectedNode, t, pushHistory, markDirty, setNodes]);
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
useEffect(() => {
const syncFs = () => {
const el = canvasRef.current;
const active = Boolean(el && document.fullscreenElement === el);
setFullscreen(active);
};
document.addEventListener("fullscreenchange", syncFs);
return () => document.removeEventListener("fullscreenchange", syncFs);
}, []);
const toggleFullscreen = useCallback(async () => {
const el = canvasRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (err) {
showError(String(err));
}
}, [showError]);
const clearSelection = useCallback(() => {
setSelectedEdgeId(null);
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
connectClickRef.current = null;
}, [setNodes, setEdges]);
const selectAllNodes = useCallback(() => {
setSelectedEdgeId(null);
setNodes((ns) => ns.map((n) => ({ ...n, selected: true })));
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
}, [setNodes, setEdges]);
const queueDeleteEdges = useCallback(
(
edgeIds: string[],
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
) => {
if (!edgeIds.length) return false;
if (!opts?.skipConfirm) {
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
if (!window.confirm(confirmMsg)) return false;
}
pushHistory();
for (const id of edgeIds) {
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
pendingEdgeCreatesRef.current.delete(id);
} else {
pendingEdgeDeletesRef.current.add(id);
}
}
const idSet = new Set(edgeIds);
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
markDirty();
showOk(
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
);
return true;
},
[pushHistory, setEdges, markDirty, showOk, t],
);
const removeSelected = useCallback(() => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const selectedDisplay = displayEdges.filter((e) => e.selected);
const edgeIds = new Set<string>();
for (const de of selectedDisplay) {
for (const id of physicalIdsForDisplayEdge(de, edges)) edgeIds.add(id);
}
for (const e of edges) {
if (e.selected) edgeIds.add(e.id);
}
if (!nodeIds.length && !edgeIds.size) return;
if (nodeIds.length && edgeIds.size) {
const msg = t("topology.deleteSelectionConfirm")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size));
if (!window.confirm(msg)) return;
pushHistory();
const nodeSet = new Set(nodeIds);
for (const id of edgeIds) {
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
pendingEdgeCreatesRef.current.delete(id);
} else {
pendingEdgeDeletesRef.current.add(id);
}
}
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
setEdges((es) =>
es.filter(
(e) =>
!edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target),
),
);
setSelectedEdgeId(null);
markDirty();
showOk(
t("topology.selectionDeleted")
.replace("{{nodes}}", String(nodeIds.length))
.replace("{{edges}}", String(edgeIds.size)),
);
return;
}
if (!nodeIds.length && edgeIds.size) {
queueDeleteEdges([...edgeIds]);
return;
}
pushHistory();
const nodeSet = new Set(nodeIds);
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
// Drop incident edges from the canvas only — do not queue Fabric deletes.
setEdges((es) => {
const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target));
for (const e of es) {
if (next.some((x) => x.id === e.id)) continue;
pendingEdgeCreatesRef.current.delete(e.id);
}
return next;
});
setSelectedEdgeId(null);
markDirty();
}, [
mapId,
nodes,
edges,
displayEdges,
setNodes,
setEdges,
pushHistory,
markDirty,
showOk,
queueDeleteEdges,
t,
]);
const removeEdgeById = (edgeId: string) => {
const display = displayEdges.find((e) => e.id === edgeId);
const ids = physicalIdsForDisplayEdge(display, edges);
const list = ids.length ? ids : [edgeId];
queueDeleteEdges(list);
closeCtxMenu();
};
const staleEdgeIds = useMemo(() => {
return edges
.filter((e) => {
const src = String((e.data as EdgeStyleData | undefined)?.source || "");
return src === "stale";
})
.map((e) => e.id);
}, [edges]);
const removeStaleEdges = useCallback(() => {
if (!staleEdgeIds.length) return;
queueDeleteEdges(staleEdgeIds, {
confirmKey: "topology.removeStaleHint",
okKey: "topology.staleRemoved",
});
}, [staleEdgeIds, queueDeleteEdges]);
const addOutsidePeers = useCallback(
async (fabricNodeIds: string[]): Promise<boolean> => {
if (!mapId) return false;
const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))];
if (!ids.length || outsidePeersAdding) return false;
setOutsidePeersAdding(true);
try {
const onCanvas = new Set(nodes.map((n) => n.id));
const toAdd = ids.filter((id) => !onCanvas.has(id));
if (!toAdd.length) {
setOutsidePeersOpen(false);
setOutsidePeerSelectedIds([]);
return false;
}
const graph = await addTopologyViewNodes(mapId, {
fabric_node_ids: toAdd,
layout: "grid",
});
if (graph.truncate_reason === "membership_frozen") {
showError(t("topology.truncatedFrozen"));
return false;
}
const addedSet = new Set(toAdd);
const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id));
if (added.length > 0) {
const peerList = graphQuery.data?.outside_peers || [];
const viaAnchors = new Map(
peerList
.filter((p) => addedSet.has(p.fabric_node_id))
.map((p) => [p.fabric_node_id, p.via_node_id] as const),
);
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
await patchTopologyPositions(
mapId,
added.map((n, i) => {
const viaId = viaAnchors.get(n.fabric_node_id);
const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined;
const base = viaNode
? { x: viaNode.position.x + 200, y: viaNode.position.y }
: { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
return {
fabric_node_id: n.fabric_node_id,
x: base.x + (i % cols) * 180,
y: base.y + Math.floor(i / cols) * 120,
label: n.label || n.name || "",
};
}),
);
}
const refreshed = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
historyLockRef.current = true;
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
const count = added.length || toAdd.length;
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count)));
setOutsidePeerSelectedIds([]);
setOutsidePeersOpen(false);
return count > 0;
} catch (err) {
showError(String(err));
return false;
} finally {
setOutsidePeersAdding(false);
}
},
[
mapId,
nodes,
graphQuery.data?.outside_peers,
outsidePeersAdding,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
queryClient,
showOk,
showError,
t,
],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || (e.target as HTMLElement)?.isContentEditable) {
return;
}
if (e.key === "Escape") {
closeCtxMenu();
clearSelection();
connectClickRef.current = null;
setToolMode((m) => (m === "connect" ? "select" : m));
if (displayMenuRef.current?.open) displayMenuRef.current.open = false;
setFindOpen(false);
return;
}
const mode = toolModeFromKey(e.key);
if (mode && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
setToolMode(mode);
connectClickRef.current = null;
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
undo();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
selectAllNodes();
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
e.preventDefault();
void removeSelected();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
useEffect(() => {
if (!ctxMenu) return;
const onScroll = (e: Event) => {
const t = e.target;
if (t instanceof Node && ctxMenuRef.current?.contains(t)) return;
closeCtxMenu();
};
const onPointerDown = (e: MouseEvent) => {
const el = e.target as HTMLElement | null;
if (el?.closest?.(".topo-ctx")) return;
closeCtxMenu();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("mousedown", onPointerDown, true);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("mousedown", onPointerDown, true);
};
}, [ctxMenu, closeCtxMenu]);
useLayoutEffect(() => {
if (!ctxMenu || !ctxMenuRef.current) return;
const el = ctxMenuRef.current;
const pad = 8;
const rect = el.getBoundingClientRect();
let x = ctxMenu.x;
let y = ctxMenu.y;
if (x + rect.width > window.innerWidth - pad) {
x = Math.max(pad, window.innerWidth - rect.width - pad);
}
if (y + rect.height > window.innerHeight - pad) {
y = Math.max(pad, window.innerHeight - rect.height - pad);
}
if (x !== ctxMenu.x || y !== ctxMenu.y) {
setCtxMenu((prev) => (prev ? { ...prev, x, y } : null));
}
}, [ctxMenu]);
const removeNodeById = (nodeId: string) => {
if (!mapId) return;
pushHistory();
closeCtxMenu();
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => {
const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId);
for (const e of es) {
if (next.some((x) => x.id === e.id)) continue;
pendingEdgeCreatesRef.current.delete(e.id);
}
return next;
});
markDirty();
};
const purgePlaceholderById = useCallback(
async (nodeId: string) => {
if (!mapId) return;
const node = nodes.find((n) => n.id === nodeId);
if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) {
return;
}
if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return;
closeCtxMenu();
try {
if (dirtyRef.current) {
const createIds = [...pendingEdgeCreatesRef.current];
for (const id of createIds) {
const e = edges.find((x) => x.id === id);
if (!e || e.source === nodeId || e.target === nodeId) continue;
const d = (e.data || {}) as EdgeStyleData;
await createFabricManualEdge({
a_node_id: e.source,
b_node_id: e.target,
a_port: String(d.source_port || ""),
b_port: String(d.target_port || ""),
});
}
pendingEdgeCreatesRef.current.clear();
const pendingEdges = [...pendingEdgeDeletesRef.current].filter(
(id) => !isLocalPendingEdgeId(id),
);
if (pendingEdges.length) {
await deleteFabricEdges(pendingEdges);
}
pendingEdgeDeletesRef.current.clear();
const serverIds = (graphQuery.data?.nodes || [])
.map((n) => n.fabric_node_id)
.filter(Boolean);
const localIds = new Set(nodes.map((n) => n.id));
const toRemove = serverIds.filter((id) => !localIds.has(id) && id !== nodeId);
if (toRemove.length) {
await removeTopologyViewNodes(mapId, toRemove);
}
await patchTopologyPositions(
mapId,
flowToPositions(nodes.filter((n) => n.id !== nodeId)),
);
}
await purgePlaceholderFabricNodes([nodeId]);
const graph = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
const localPos = new Map(
nodes.filter((n) => n.id !== nodeId).map((n) => [n.id, n.position]),
);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
pendingEdgeDeletesRef.current = new Set();
pendingEdgeCreatesRef.current = new Set();
historyRef.current = [];
redoRef.current = [];
bumpHistory();
clearDirty();
showOk(t("topology.deletePlaceholderDone"));
} catch (err) {
showError(String(err));
}
},
[
mapId,
nodes,
edges,
graphQuery.data,
closeCtxMenu,
queryClient,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
bumpHistory,
showOk,
showError,
t,
],
);
const openNeInventory = (opts: {
neId?: string;
create?: boolean;
name?: string;
ip_address?: string;
vendor?: string;
}) => {
const q = new URLSearchParams();
if (opts.neId) {
q.set("ne_id", opts.neId);
} else if (opts.create) {
q.set("create", "1");
if (opts.name) q.set("name", opts.name);
if (opts.ip_address) q.set("ip_address", opts.ip_address);
if (opts.vendor) q.set("vendor", opts.vendor);
}
const qs = q.toString();
openOrFocusModule({
moduleId: "ne",
path: qs ? `/ne?${qs}` : "/ne",
});
};
const openWebcrtFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu();
if (!node) return;
const managedId = String(node.data.managed_ne_id || "").trim();
const umeId = String(node.data.ume_ne_id || "").trim();
if (managedId) {
void (async () => {
try {
const ne = await fetchManagedNeById(managedId);
const src = String(ne.source || "").trim().toLowerCase();
const hasIp = Boolean(String(ne.ip_address || "").trim());
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
// LLDP / topology placeholders / incomplete rows → full NE edit form.
const needsSetup = !hasIp || src === "lldp" || src === "topology";
if (!needsSetup) {
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
});
return;
}
showOk(t("topology.completeNeFirst"));
openNeInventory({ neId: managedId });
} catch (err) {
showError(String(err));
}
})();
return;
}
if (umeId) {
openOrFocusModule({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
});
return;
}
// No inventory link — open NE create with topology hint fields.
showOk(t("topology.completeNeFirst"));
openNeInventory({
create: true,
name: String(node.data.label || "").trim(),
ip_address: String(node.data.ne_ip || "").trim(),
vendor: String(node.data.vendor || "").trim(),
});
};
const openNeFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu();
const managedId = String(node?.data.managed_ne_id || "").trim();
const umeId = String(node?.data.ume_ne_id || "").trim();
if (managedId) {
openNeInventory({ neId: managedId });
return;
}
if (umeId) {
openOrFocusModule({
moduleId: "ume",
path: "/ume",
});
return;
}
showError(t("topology.noNeLink"));
};
const openPortTrafficForEdge = (edge: Edge | null) => {
closeCtxMenu();
if (!edge) {
showError(t("topology.noNeLink"));
return;
}
const srcNode = nodes.find((n) => n.id === edge.source);
const data = (edge.data || {}) as EdgeStyleData;
const ifname = String(data.source_port || "").trim();
const managedId = String(srcNode?.data.managed_ne_id || "").trim();
const umeId = String(srcNode?.data.ume_ne_id || "").trim();
if (managedId) {
const q = new URLSearchParams({ ne_id: managedId, source: "managed" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
if (umeId) {
const q = new URLSearchParams({ ne_id: umeId, source: "ume" });
if (ifname) q.set("ifname", ifname);
openOrFocusModule({
moduleId: "network",
path: `/network/tasks/port-traffic?${q.toString()}`,
});
return;
}
showError(t("topology.noNeLink"));
};
const discoverOneFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu();
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
if (!id) {
showError(t("topology.discoverOneNeedNe"));
return;
}
void runDiscover([id]);
};
const openCreateNeAt = (flowX: number, flowY: number) => {
if (isWorldFlatViewName(activeView?.name)) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
closeCtxMenu();
setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" });
};
const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => {
const pad = 8;
const w = size?.w ?? 200;
const h = size?.h ?? 240;
const x = Math.min(clientX, window.innerWidth - w - pad);
const y = Math.min(clientY, window.innerHeight - h - pad);
return { x: Math.max(pad, x), y: Math.max(pad, y) };
};
const submitCreateNe = useCallback(async () => {
if (!mapId || !createNeDialog) return;
const name = createNeDialog.name.trim();
if (!name) {
showError(t("topology.createNeNameRequired"));
return;
}
setCreateNeBusy(true);
try {
const graph = await createTopologyPlaceholder(mapId, {
name,
ip_address: createNeDialog.ip_address.trim(),
x: createNeDialog.flowX,
y: createNeDialog.flowY,
});
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
const created = graph.nodes.find(
(n) =>
String(n.name || "").trim() === name &&
Math.abs(Number(n.x) - createNeDialog.flowX) < 0.5 &&
Math.abs(Number(n.y) - createNeDialog.flowY) < 0.5,
);
if (created?.fabric_node_id) {
focusNode(created.fabric_node_id, false);
}
setCreateNeDialog(null);
showOk(t("topology.createNeDone").replace("{{name}}", name));
void queryClient.invalidateQueries({ queryKey: ["managedNe"] });
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
} catch (err) {
showError(String(err));
} finally {
setCreateNeBusy(false);
}
}, [
mapId,
createNeDialog,
queryClient,
edgeDefaults,
setNodes,
setEdges,
clearDirty,
showError,
showOk,
t,
focusNode,
]);
const pendingLocateWorldRef = useRef<{
id: string;
worldX?: number;
worldY?: number;
} | null>(null);
const jumpToTreeSearchHit = useCallback(
(
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => {
const views = hit.views || [];
const worldCoords =
hit.world_x != null && hit.world_y != null
? { worldX: Number(hit.world_x), worldY: Number(hit.world_y) }
: undefined;
const onCurrent =
Boolean(mapId) &&
(views.some((v) => v.view_id === mapId) ||
nodes.some((n) => n.id === hit.id) ||
(isWorldFlatCanvasRef.current && worldCoords != null));
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
if (isWorldFlatCanvasRef.current) {
locateNode(hit.id, worldCoords);
} else if (nodes.some((n) => n.id === hit.id)) {
locateNode(hit.id);
} else {
setPendingHighlightNe(hit.id);
}
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
return;
}
const target =
view ||
(WORLD_MAP_ENABLED
? views.find((v) => isWorldFlatViewName(v.view_name))
: undefined) ||
views.find((v) => !isWorldFlatViewName(v.view_name) && String(v.kind) === "physical") ||
views.find((v) => !isWorldFlatViewName(v.view_name)) ||
views[0] ||
null;
if (!target) {
showError(t("topology.treeSearchNotOnMap"));
return;
}
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) {
showError(t("topology.worldMapOffline"));
return;
}
if (!confirmDiscardIfDirty()) return;
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
if (regionId) {
setSelectedFolderId(regionId);
setExpandedIds((p) => ({ ...p, [regionId]: true }));
}
if (WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name) && worldCoords) {
pendingLocateWorldRef.current = { id: hit.id, ...worldCoords };
} else {
pendingLocateWorldRef.current = null;
}
setMapId(target.view_id);
setPendingHighlightNe(hit.id);
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
},
[
mapId,
nodes,
locateNode,
showError,
t,
confirmDiscardIfDirty,
regions,
setSelectedFolderId,
setMapId,
setPendingHighlightNe,
setTreeNeQuery,
setDebouncedTreeNeQuery,
setTreeSearchOpen,
setExpandedIds,
],
);
useEffect(() => {
if (!pendingHighlightNe || !canvasMode) return;
const nodeId = pendingHighlightNe;
const pendingWorld = pendingLocateWorldRef.current;
if (
pendingWorld &&
pendingWorld.id === nodeId &&
isWorldFlatCanvasRef.current &&
rfRef.current
) {
pendingLocateWorldRef.current = null;
setPendingHighlightNe("");
window.setTimeout(
() =>
locateNode(nodeId, {
worldX: pendingWorld.worldX,
worldY: pendingWorld.worldY,
}),
80,
);
return;
}
if (!nodes.length || !nodes.some((n) => n.id === nodeId)) return;
pendingLocateWorldRef.current = null;
setPendingHighlightNe("");
window.setTimeout(() => locateNode(nodeId), 80);
}, [pendingHighlightNe, canvasMode, nodes, locateNode, mapId, graphQuery.data]);
const canvasHits = useMemo(() => {
const q = canvasQuery.trim();
if (!q) return [];
return nodes.filter((n) => nodeMatchesQuery(n, q));
}, [canvasQuery, nodes]);
useEffect(() => {
const q = canvasQuery.trim();
if (!q) {
setFindActiveIdx(0);
setFindOpen(false);
if (findJustLocatedRef.current) {
findJustLocatedRef.current = false;
return;
}
setSearchHitIds([]);
return;
}
setFindOpen(true);
setSearchHitIds(canvasHits.map((n) => n.id));
setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0));
}, [canvasQuery, canvasHits]);
const findOnCanvas = useCallback(
(nodeId?: string) => {
const q = canvasQuery.trim();
if (!q) return;
if (!canvasHits.length) {
showError(t("topology.findNoMatch"));
return;
}
const id =
nodeId ||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
canvasHits[0].id;
findJustLocatedRef.current = true;
setFindOpen(false);
setCanvasQuery("");
setFindActiveIdx(0);
locateNode(id);
},
[canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t],
);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (findBoxRef.current && target && !findBoxRef.current.contains(target)) {
setFindOpen(false);
}
const details = displayMenuRef.current;
if (details?.open && target && !details.contains(target)) {
details.open = false;
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const focusEdge = useCallback(
(edgeId: string) => {
setSelectedEdgeId(edgeId);
setNodes((ns) => ns.map((n) => ({ ...n, selected: false })));
// Selection is applied in displayEdges; keep store flags clear.
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
},
[setNodes, setEdges],
);
const onNodeClick = useCallback(
(e: React.MouseEvent, node: Node<NeNodeData>) => {
setCtxMenu(null);
if (node.data.kind === "region") {
const viewId = String(node.data.view_id || "").trim();
const folderId = String(node.data.folder_id || "").trim();
if (viewId) {
goCanvas(viewId, folderId || undefined);
return;
}
if (folderId) {
goRegion(folderId);
return;
}
}
if (toolMode === "connect") {
focusNode(node.id, false);
return;
}
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
},
[toolMode, focusNode, goCanvas, goRegion],
);
const onNodeDoubleClick = useCallback(
(_e: React.MouseEvent, node: Node<NeNodeData>) => {
// Regions already drill on single click; keep dblclick as no-op alias.
if (node.data.kind === "region") {
const viewId = String(node.data.view_id || "").trim();
const folderId = String(node.data.folder_id || "").trim();
if (viewId) goCanvas(viewId, folderId || undefined);
else if (folderId) goRegion(folderId);
}
},
[goCanvas, goRegion],
);
const outsidePeers = graphQuery.data?.outside_peers || [];
const outsidePeerNameById = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) {
const label = String(n.data?.label || n.data?.subtitle || "").trim();
m.set(n.id, label || n.id.slice(0, 8));
}
return m;
}, [nodes]);
const outsidePeersVisible = useMemo(() => {
const q = outsidePeerQuery.trim().toLowerCase();
if (!q) return outsidePeers;
return outsidePeers.filter((p) => {
const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id;
const bits = [p.name, p.ip, viaName, p.fabric_node_id];
return bits.some((b) => String(b || "").toLowerCase().includes(q));
});
}, [outsidePeers, outsidePeerQuery, outsidePeerNameById]);
const runTopologyExport = useCallback(
async (format: "svg" | "xml") => {
const view: TopologyViewItem | undefined =
graphQuery.data?.view ??
(activeView
? {
id: activeView.id,
name: activeView.name,
remark: "",
node_count: activeView.node_count,
kind: activeView.kind,
role: activeView.role,
sort_order: activeView.sort_order,
}
: undefined);
if (!view) {
showError(t("topology.selectMap"));
return;
}
if (format !== "xml" && nodes.length === 0 && !isWorldFlatCanvas) {
showError(t("topology.exportEmpty"));
return;
}
setExporting(true);
exportMenuRef.current?.removeAttribute("open");
const filenameBase = activeLeafName || view.name || "topology";
try {
if (format === "svg") {
await exportTopologySvg(
nodes,
displayEdges,
filenameBase,
{
labelColor: labelColors.name,
edgeLabelColor: labelColors.edgeLabel,
vendorColors,
hideIp,
hideVendor,
},
filenameBase,
);
showOk(t("topology.exportedSvg"));
} else {
exportTopologyXml(buildExportGraph(view, nodes, edges, graphQuery.data), filenameBase);
showOk(t("topology.exportedXml"));
}
} catch (err) {
showError(t("topology.exportFailed").replace("{{detail}}", String(err)));
} finally {
setExporting(false);
}
},
[
activeView,
graphQuery.data,
nodes,
edges,
displayEdges,
isWorldFlatCanvas,
activeLeafName,
labelColors,
vendorColors,
hideIp,
hideVendor,
showError,
showOk,
t,
],
);
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
if (activeRegion) return regionDisplayName(activeRegion, t);
return t("topology.rootName");
}, [canvasMode, activeLeafName, activeRegion, t]);
const onCanvasManagedIds = useMemo(
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
[nodes],
);
const onCanvasUmeIds = useMemo(
() => new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)),
[nodes],
);
const palette = useMemo((): PaletteItem[] => {
if (paletteSource === "ume") {
return (umeQuery.data?.items || []).map((ne) => {
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
return {
key: `ume:${ne.ne_id}`,
source: "ume" as const,
managed_ne_id: "",
ume_ne_id: ne.ne_id,
name,
ip: ne.ip_address || "",
vendor: "ZTE",
meta: `${ne.ip_address || "-"}${SEP}${ne.ne_type || "UME"}`,
connect_status: ne.connection_status || "",
};
});
}
return (neQuery.data?.items || []).map((ne) => ({
key: `managed:${ne.id}`,
source: "managed" as const,
managed_ne_id: ne.id,
ume_ne_id: "",
name: ne.name || ne.ip_address,
ip: ne.ip_address,
vendor: ne.vendor,
meta: `${ne.ip_address}${SEP}${ne.vendor}`,
connect_status: ne.connect_status,
}));
}, [paletteSource, neQuery.data, umeQuery.data]);
const paletteVisible = useMemo(() => {
const tokens = keyword
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
let list = palette;
if (tokens.length) {
list = list.filter((item) => {
const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
});
}
return list.filter((item) =>
item.source === "managed"
? !onCanvasManagedIds.has(item.managed_ne_id)
: !onCanvasUmeIds.has(item.ume_ne_id),
);
}, [palette, onCanvasManagedIds, onCanvasUmeIds, keyword]);
const paletteLoading =
(paletteSource === "managed" && neQuery.isLoading) ||
(paletteSource === "ume" && umeQuery.isLoading);
const onSidebarResizeDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (sidebarCollapsed) return;
e.preventDefault();
sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth };
setSidebarResizing(true);
const onMove = (ev: MouseEvent) => {
const drag = sidebarDragRef.current;
if (!drag) return;
const pageW = topoPageRef.current?.clientWidth || window.innerWidth;
const next = clampSidebarWidth(drag.startW + (ev.clientX - drag.startX), pageW);
setSidebarWidth(next);
};
const onUp = () => {
sidebarDragRef.current = null;
setSidebarResizing(false);
document.body.style.cursor = "";
document.body.style.userSelect = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setSidebarWidth((w) => {
const pageW = topoPageRef.current?.clientWidth || window.innerWidth;
const next = clampSidebarWidth(w, pageW);
saveSidebarWidth(next);
return next;
});
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[sidebarCollapsed, sidebarWidth],
);
return (
<div
ref={topoPageRef}
className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}${
sidebarResizing ? " is-sidebar-resizing" : ""
}`}
style={
sidebarCollapsed
? undefined
: ({ "--topo-sidebar-w": `${sidebarWidth}px` } as CSSProperties)
}
>
<TopologySidebar
collapsed={sidebarCollapsed}
resizing={sidebarResizing}
treeSearchRef={treeSearchRef}
treeNeQuery={treeNeQuery}
onTreeNeQueryChange={setTreeNeQuery}
treeSearchOpen={treeSearchOpen}
onTreeSearchOpen={setTreeSearchOpen}
debouncedTreeNeQuery={debouncedTreeNeQuery}
treeSearchFetching={treeNeSearchQuery.isFetching}
treeSearchItems={treeNeSearchQuery.data?.items || []}
treeSearchTotal={treeNeSearchQuery.data?.total || 0}
onJumpToSearchHit={jumpToTreeSearchHit}
promptNewRegion={promptNewRegion}
createRegionPending={createRegionMut.isPending}
onCollapse={() => setSidebarCollapsed(true)}
onExpand={() => setSidebarCollapsed(false)}
treeRoot={treeRoot}
treeLoading={treeLoading}
treeFailed={treeFailed}
treeError={treeQuery.error}
onTreeRetry={() => void treeQuery.refetch()}
regions={regions}
treeNav={{
treeRootId: String(treeRoot?.id || ""),
expandedIds,
mapId,
worldViewId,
selectedFolderId,
hotBrowseKey,
dirty,
onToggleExpand: onTreeToggleExpand,
onHotBrowseKey: onTreeHotBrowseKey,
onClearHotBrowseKey: onTreeClearHotBrowseKey,
onWorldFocusClear: () => setWorldFocusFolderId(""),
goUmeWorldNav,
goRegion,
goCanvas,
promptRenameRegion,
promptRenameMap,
renameRegionPending: renameRegionMut.isPending,
renameMapPending: renameMapMut.isPending,
onDeleteFolder: onTreeDeleteFolder,
deleteFolderPending: deleteFolderMut.isPending,
onDeleteMap: onTreeDeleteMap,
}}
onResizeDown={onSidebarResizeDown}
/>
<main className="topo-main">
{canvasMode ? (
<TopologyToolbar
breadcrumbFolders={breadcrumbFolders}
activeView={activeView}
activeRegion={activeRegion}
rootFolderId={rootFolderId}
dirty={dirty}
selectedNodes={selectedNodes}
selectedEdgeId={selectedEdgeId}
liveSync={liveSync}
onLiveSyncToggle={() => setLiveSync((v) => !v)}
isWorldFlatCanvas={isWorldFlatCanvas}
onAddNe={() => {
if (isWorldFlatCanvas) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
setKeyword("");
setPaletteSelectedKeys([]);
setAddNeOpen(true);
}}
onCreateNe={(flowX, flowY) => {
if (isWorldFlatCanvas) {
showError(t("topology.worldMapNoDirectNes"));
return;
}
openCreateNeAt(flowX, flowY);
}}
nodes={nodes}
rfRef={rfRef}
onBackUp={goBackBrowse}
canUndo={canUndo}
onUndo={undo}
canRedo={canRedo}
onRedo={redo}
savePending={saveMut.isPending}
onSave={() => saveMut.mutate()}
onFit={fitCanvas}
exportMenuRef={exportMenuRef}
exporting={exporting}
onExport={runTopologyExport}
mapId={mapId}
staleEdgeCount={staleEdgeIds.length}
onRemoveStale={removeStaleEdges}
toolMode={toolMode}
onToolModeChange={setToolMode}
onConnectClickReset={() => {
connectClickRef.current = null;
}}
fullscreen={fullscreen}
viewToolsToolbarSlotRef={setViewToolsToolbarSlot}
outsidePeerCount={outsidePeers.length}
onOpenOutsidePeers={() => {
setOutsidePeerQuery("");
setOutsidePeerSelectedIds([]);
setOutsidePeersOpen(true);
}}
goRoot={goRoot}
goRegion={goRegion}
primaryViewOfFolder={primaryViewOfFolder}
/>
) : null}
{discoverOpen ? (
<TopologyDiscoverPanel
discovering={discovering}
discoverProgress={discoverProgress}
discoverPct={discoverPct}
discoverReport={discoverReport}
discoverError={discoverError}
hasResults={discoverResults.length > 0 || Boolean(discoverReport)}
discoverSummary={discoverSummary}
onCancelDiscover={cancelDiscover}
onClose={() => setDiscoverOpen(false)}
/>
) : null}
{!canvasMode ? (
<TopologyHexBrowser
titleText={titleText}
breadcrumbFolders={breadcrumbFolders}
hexBrowseRegion={hexBrowseRegion}
umeWorldHexModules={umeWorldHexModules}
browseEntries={browseEntries}
regions={regions}
rootFolderId={rootFolderId}
selectedFolderId={selectedFolderId}
mapId={mapId}
hotBrowseKey={hotBrowseKey}
treeLoading={treeLoading}
treeFailed={treeFailed}
treeError={treeQuery.error}
createRegionPending={createRegionMut.isPending}
onTreeRetry={() => void treeQuery.refetch()}
onHotBrowseKey={setHotBrowseKey}
onClearHotBrowseKey={(key) => setHotBrowseKey((k) => (k === key ? "" : k))}
goRoot={goRoot}
goRegion={goRegion}
goUmeWorldNav={goUmeWorldNav}
goCanvas={goCanvas}
promptNewRegion={promptNewRegion}
promptNewSubRegion={promptNewSubRegion}
onSelectFolder={setSelectedFolderId}
/>
) : (
<div
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${
toolMode === "pan" ? " is-pan-mode" : ""
}${canvasBgLuminance(canvasBg) > 0.55 ? " is-canvas-light" : ""}`}
ref={canvasRef}
style={
{
"--topo-canvas-bg": canvasBg,
"--topo-label-color": labelColors.name,
"--topo-edge-label-color": labelColors.edgeLabel,
"--topo-vendor-cisco": vendorColors.cisco,
"--topo-vendor-huawei": vendorColors.huawei,
"--topo-vendor-zte": vendorColors.zte,
"--topo-vendor-juniper": vendorColors.juniper,
"--topo-vendor-nokia": vendorColors.nokia,
"--topo-vendor-ericsson": vendorColors.ericsson,
"--topo-vendor-h3c": vendorColors.h3c,
"--topo-vendor-ruijie": vendorColors.ruijie,
"--topo-vendor-mikrotik": vendorColors.mikrotik,
"--topo-vendor-gray": vendorColors.gray,
...canvasFloatChromeVars(canvasBg),
} as CSSProperties
}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>
{truncateBannerText ? (
<div className="topo-truncate-banner" role="status">
{truncateBannerText}
</div>
) : null}
<TopologyViewTools
fullscreen={fullscreen}
toolbarSlot={viewToolsToolbarSlot}
displayMenuRef={displayMenuRef}
findBoxRef={findBoxRef}
hideIp={hideIp}
onHideIpChange={setHideIp}
hideVendor={hideVendor}
onHideVendorChange={setHideVendor}
showPlaceholderBadge={showPlaceholderBadge}
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
hidePorts={hidePorts}
onHidePortsChange={setHidePorts}
expandPhysicalLinks={expandPhysicalLinks}
onExpandPhysicalLinksChange={setExpandPhysicalLinks}
scaleBundleWidth={scaleBundleWidth}
onScaleBundleWidthChange={setScaleBundleWidth}
edgeFlow={edgeFlow}
onEdgeFlowChange={setEdgeFlow}
snapToGrid={snapToGrid}
onSnapToGridChange={setSnapToGrid}
autoLayoutAfterDiscover={autoLayoutAfterDiscover}
onAutoLayoutAfterDiscoverChange={setAutoLayoutAfterDiscover}
discoverAutoAddUnmatched={discoverAutoAddUnmatched}
onDiscoverAutoAddUnmatchedChange={setDiscoverAutoAddUnmatched}
discoverProjectNeighbors={discoverProjectNeighbors}
onDiscoverProjectNeighborsChange={setDiscoverProjectNeighbors}
canvasBg={canvasBg}
onCanvasBgChange={setCanvasBg}
labelColors={labelColors}
onLabelColorsChange={setLabelColors}
vendorColors={vendorColors}
onVendorColorsChange={setVendorColors}
edgeDefaults={edgeDefaults}
onUpdateEdgeDefault={updateEdgeDefault}
onResetEdgeDefaults={resetEdgeDefaults}
selectedNodeCount={selectedNodes.length}
onAlign={applyAlign}
mapId={mapId}
nodeCount={nodes.length}
canvasQuery={canvasQuery}
onCanvasQueryChange={setCanvasQuery}
findOpen={findOpen}
onFindOpenChange={setFindOpen}
findActiveIdx={findActiveIdx}
onFindActiveIdxChange={setFindActiveIdx}
canvasHits={canvasHits}
onFindOnCanvas={findOnCanvas}
/>
{fullscreen ? (
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
{(
[
["select", t("topology.toolSelect"), "V"],
["pan", t("topology.toolPan"), "H"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => (
<button
key={mode}
type="button"
className={`topo-fs-toolbar__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
aria-pressed={toolMode === mode}
onClick={() => {
setToolMode(mode);
connectClickRef.current = null;
}}
>
{label}
<kbd>{key}</kbd>
</button>
))}
<button
type="button"
className="topo-fs-toolbar__btn"
title={t("topology.fit")}
onClick={() => fitCanvas()}
>
{t("topology.fit")}
</button>
<button
type="button"
className="topo-fs-toolbar__btn"
title={t("topology.exitFullscreen")}
onClick={() => void toggleFullscreen()}
>
{t("topology.exitFullscreen")}
</button>
</div>
) : null}
{treeRoot ? (
<>
{isWorldFlatCanvas ? (
<div className="topo-world-hud" role="status">
<span>
{t("topology.worldHud")
.replace("{{total}}", String(worldTotal || worldScatter.length || 0))
.replace("{{cached}}", String(nodes.length))
.replace("{{zoom}}", canvasZoom.toFixed(3))
.replace("{{lod}}", worldVisualLod)}
</span>
{graphTruncated ? (
<span className="muted"> · {truncateReason || "truncated"}</span>
) : null}
</div>
) : null}
<Suspense
fallback={
<div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</div>
}
>
<TopologyReactFlowView
displayOpts={displayOpts}
nodes={nodes}
setNodes={setNodes}
edges={edges}
setEdges={setEdges}
displayEdges={displayEdges}
searchHitIds={searchHitIds}
isWorldFlatCanvas={isWorldFlatCanvas}
worldVisualLod={worldVisualLod}
toolBehavior={toolBehavior}
toolMode={toolMode}
snapToGrid={snapToGrid}
canvasBg={canvasBg}
worldScatter={worldScatter}
showWorldScatter={showWorldScatter}
fullscreen={fullscreen}
connectHint={t("topology.connectHint")}
fullscreenLabel={t("topology.fullscreen")}
exitFullscreenLabel={t("topology.exitFullscreen")}
worldTransform={worldTransformRef.current || graphQuery.data?.world_transform}
nodesRef={nodesRef}
rfRef={rfRef}
pendingFitRef={pendingFitRef}
markDirty={markDirty}
pushHistory={pushHistory}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
focusEdge={focusEdge}
focusNode={focusNode}
clearSelection={clearSelection}
setCtxMenu={setCtxMenu}
placeCtxMenu={placeCtxMenu}
selectedNodeIds={selectedNodeIds}
closeCtxMenu={closeCtxMenu}
setCanvasZoom={setCanvasZoom}
scheduleFlatViewportRefresh={scheduleFlatViewportRefresh}
toggleFullscreen={toggleFullscreen}
/>
</Suspense>
</>
) : treeLoading ? (
<div className="topo-canvas__empty topo-canvas__status" role="status">
<span className="topo-loading-spinner" aria-hidden="true" />
<p>{t("topology.treeLoading")}</p>
</div>
) : treeFailed ? (
<div className="topo-canvas__empty">
<p className="muted">{t("topology.treeLoadFailed")}</p>
<div className="topo-browser__empty-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => void treeQuery.refetch()}
>
{t("topology.treeRetry")}
</button>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</button>
</div>
</div>
) : (
<div className="topo-canvas__empty">
<p className="muted">{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending}
>
{t("topology.newRegion")}
</button>
</div>
)}
{treeRoot && canvasGraphLoading ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
<p className="topo-canvas__overlay-title">{t("topology.graphLoading")}</p>
</div>
) : null}
{treeRoot && canvasGraphEmpty ? (
<div
className={`topo-canvas__overlay topo-canvas__overlay--empty${
worldNeedsApply ? " is-interactive" : ""
}`}
role="status"
>
<p className="topo-canvas__overlay-title">
{worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}
</p>
<p className="topo-canvas__overlay-hint">
{worldNeedsApply
? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe))
: t("topology.canvasEmptyHint")}
</p>
{worldNeedsApply ? (
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
<button
type="button"
className="btn btn--sm"
disabled={applyWorldMut.isPending}
onClick={() => applyWorldMut.mutate()}
>
{applyWorldMut.isPending
? t("topology.worldApplying")
: t("topology.worldApply")}
</button>
</div>
) : null}
</div>
) : null}
{treeRoot && canvasGraphRefreshing ? (
<div className="topo-canvas__refresh" role="status" aria-live="polite">
<span className="topo-loading-spinner" aria-hidden="true" />
{t("topology.graphRefreshing")}
</div>
) : null}
</div>
)}
</main>
<TopologyModals
canvasMode={canvasMode}
newRootDialog={newRootDialog}
onNewRootNameChange={(name) => setNewRootDialog({ name })}
onCloseNewRoot={() => setNewRootDialog(null)}
onSubmitNewRoot={submitNewRoot}
createRegionPending={createRegionMut.isPending}
createNeDialog={createNeDialog}
createNeBusy={createNeBusy}
onCreateNeChange={(patch) =>
setCreateNeDialog((prev) => (prev ? { ...prev, ...patch } : prev))
}
onCloseCreateNe={() => setCreateNeDialog(null)}
onSubmitCreateNe={submitCreateNe}
outsidePeersOpen={outsidePeersOpen}
outsidePeers={outsidePeers}
outsidePeersVisible={outsidePeersVisible}
outsidePeerQuery={outsidePeerQuery}
onOutsidePeerQueryChange={setOutsidePeerQuery}
outsidePeerSelectedIds={outsidePeerSelectedIds}
onOutsidePeerSelectedIdsChange={setOutsidePeerSelectedIds}
outsidePeerNameById={outsidePeerNameById}
outsidePeersAdding={outsidePeersAdding}
onCloseOutsidePeers={() => setOutsidePeersOpen(false)}
onAddOutsidePeers={addOutsidePeers}
addNeOpen={addNeOpen}
paletteSource={paletteSource}
onPaletteSourceChange={(source) => {
setPaletteSource(source);
setPaletteSelectedKeys([]);
}}
keyword={keyword}
onKeywordChange={(value) => {
setKeyword(value);
setPaletteSelectedKeys([]);
}}
paletteVisible={paletteVisible}
paletteSelectedKeys={paletteSelectedKeys}
onPaletteSelectedKeysChange={setPaletteSelectedKeys}
paletteLoading={paletteLoading}
paletteAdding={paletteAdding}
onCloseAddNe={() => setAddNeOpen(false)}
onPaletteDragStart={onPaletteDragStart}
onAddSelectedPalette={async () => {
const selected = paletteVisible.filter((item) =>
paletteSelectedKeys.includes(item.key),
);
if (selected.length === 0) return;
const ok = await addPaletteItems(selected);
if (!ok) return;
setPaletteSelectedKeys([]);
setAddNeOpen(false);
}}
/>
{ctxMenu ? (
<TopologyCtxMenu
ctxMenu={ctxMenu}
menuRef={ctxMenuRef}
portalTarget={canvasRef.current}
fullscreen={fullscreen}
selectedNodeCount={selectedNodes.length}
selectedNode={selectedNode}
selectedEdge={selectedEdge}
selectedEdgeData={selectedEdgeData}
selectedEdgeResolved={selectedEdgeResolved}
selectedEdgeSourceNode={selectedEdgeSourceNode || null}
selectedEdgeTargetNode={selectedEdgeTargetNode || null}
expandPhysicalLinks={expandPhysicalLinks}
discovering={discovering}
isWorldFlatCanvas={isWorldFlatCanvas}
createRegionPending={createRegionMut.isPending}
onClose={closeCtxMenu}
onToggleFullscreen={toggleFullscreen}
onRemoveSelected={removeSelected}
onOpenCreateNe={openCreateNeAt}
onPromptNewSubRegion={promptNewSubRegion}
onRenameSelectedNode={renameSelectedNode}
onDiscoverOne={discoverOneFor}
onOpenWebcrt={openWebcrtFor}
onOpenNe={openNeFor}
onPurgePlaceholder={purgePlaceholderById}
onRemoveNode={removeNodeById}
onExpandPhysicalLinks={() => setExpandPhysicalLinks(true)}
onPushHistory={pushHistory}
onPatchSelectedEdgeStyle={patchSelectedEdgeStyle}
onOpenPortTraffic={openPortTrafficForEdge}
onRemoveEdge={removeEdgeById}
/>
) : null}
</div>
);
}