netx/web/src/pages/TopologyPage.tsx
oliver a412ca7cbc Preserve NE icon white textures when applying vendor tint colors.
Layer the original PNG under a color blend tint so luminosity highlights stay visible while brand hues remain editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:34:29 +08:00

4131 lines
157 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
ControlButton,
MiniMap,
addEdge,
useEdgesState,
useNodesState,
Handle,
Position,
ConnectionMode,
SelectionMode,
type Connection,
type Edge,
type Node,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
addTopologyViewNodes,
createFabricManualEdge,
createTopologyFolder,
createTopologyView,
deleteTopologyFolder,
deleteTopologyMap,
fetchLldpDiscoverJob,
fetchManagedNe,
fetchManagedNeById,
fetchTopologyGraph,
fetchTopologyTree,
fetchUmeNe,
patchTopologyEdgeStyle,
patchTopologyPositions,
projectTopologyNeighbors,
removeTopologyViewNodes,
searchFabricNodes,
startLldpDiscover,
updateTopologyFolder,
updateTopologyMap,
} from "../services/api";
import { queryKeys } from "../constants/queryKeys";
import { HelpHint } from "../components/HelpHint";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
TopologyDiscoverJob,
TopologyDiscoverNeResult,
TopologyDiscoverOut,
TopologyEdgeItem,
TopologyNodeItem,
TopologyTreeFolderItem,
TopologyTreeViewItem,
TopologyViewEdgeItem,
TopologyViewGraph,
TopologyViewNodeItem,
TopologyViewKind,
TopologyViewRole,
} from "../types";
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
import { ParallelEdge } from "./topology/ParallelEdge";
import {
buildLinkDisplayEdges,
formatPortPairLabel,
isAggregateEdgeId,
physicalIdsForDisplayEdge,
type LinkEdgeData,
type LinkMember,
} from "./topology/linkDisplay";
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
function findViewInRegion(
regions: TopologyTreeFolderItem[],
viewId: string,
): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null {
for (const region of regions) {
const view = (region.views || []).find((v) => v.id === viewId);
if (view) return { region, view };
}
return null;
}
function regionDisplayName(region: TopologyTreeFolderItem | null | undefined): string {
if (!region) return "";
return region.name || "";
}
function formatUpdatedAt(value?: string | null): string {
if (!value) return "";
try {
return new Date(value).toLocaleString();
} catch {
return String(value);
}
}
const SNAP_GRID: [number, number] = [16, 16];
/**
* onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView
* skips those. includeHiddenNodes uses declared width/height so fit still works.
*/
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
const FIT_VIEW_OPTS_FS = { padding: 0.15, includeHiddenNodes: true } as const;
const UNDO_MAX = 40;
const PALETTE_DND = "application/x-netx-topo-palette";
function FullscreenIcon({ exit }: { exit?: boolean }) {
if (exit) {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm12 0h-2v3h-3v2h5v-5zM7 5h3V3H5v5h2V5zm10 0v3h2V3h-5v2h3z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
const SEP = " / ";
/** Left-panel collapse / expand control (simple chevron). */
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function PencilIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
d="M12 5v14M5 12h14"
/>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
d="M6 6l12 12M18 6L6 18"
/>
</svg>
);
}
function RegionGlyph({ size = 16 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<path
d="M4 8.5L12 4l8 4.5v7L12 20l-8-4.5v-7z"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<path d="M12 4v16M4 8.5l8 4.5 8-4.5" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.7" />
</svg>
);
}
function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
const r = String(role || "core").toLowerCase();
if (r === "aggregation") {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<rect x="4" y="5" width="16" height="4" rx="1.5" fill="currentColor" opacity="0.9" />
<rect x="6" y="11" width="12" height="3.5" rx="1.2" fill="currentColor" opacity="0.65" />
<rect x="8" y="16.5" width="8" height="3" rx="1" fill="currentColor" opacity="0.45" />
</svg>
);
}
if (r === "access") {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<circle cx="7" cy="12" r="2.4" fill="currentColor" />
<circle cx="17" cy="7" r="2.2" fill="currentColor" opacity="0.8" />
<circle cx="17" cy="17" r="2.2" fill="currentColor" opacity="0.8" />
<path d="M9.4 12H14.5M14.5 12L16 8.4M14.5 12L16 15.6" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
<rect x="7" y="3.5" width="10" height="17" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="8" r="2.2" fill="currentColor" />
<path d="M9 13.5h6M9 16.5h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: string;
connect_status: string;
/** Canvas navigation node (not a fabric NE). */
kind?: "ne" | "region" | "layer";
folder_id?: string;
view_id?: string;
role?: TopologyViewRole | string;
subtitle?: string;
node_count?: number;
};
type HistorySnap = { nodes: Node<NeNodeData>[]; edges: Edge[] };
type PaletteSource = "managed" | "ume";
type PaletteItem = {
key: string;
source: PaletteSource;
managed_ne_id: string;
ume_ne_id: string;
name: string;
ip: string;
vendor: string;
meta: string;
connect_status: string;
};
type TopoDisplayOpts = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
connectMode: boolean;
};
type CtxMenu =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number };
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
connectMode: false,
});
function newId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
}
/**
* Icon tone for /topo/ne-router.png (base = blue).
* Unmanaged (no managed/ume) and unknown vendors → gray.
* UME-linked NEs default to ZTE when vendor is empty/unknown.
*/
function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string {
const hasManaged = Boolean(String(managedNeId || "").trim());
const hasUme = Boolean(String(umeNeId || "").trim());
if (!hasManaged && !hasUme) return "gray";
const v = String(vendor || "").trim().toLowerCase();
if (!v || v === "other" || v === "unknown" || v === "generic") {
return hasUme ? "zte" : "gray";
}
if (v.includes("cisco")) return "cisco";
if (v.includes("huawei")) return "huawei";
if (v.includes("zte")) return "zte";
if (v.includes("juniper")) return "juniper";
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
if (v.includes("ericsson")) return "ericsson";
if (v.includes("h3c") || v.includes("comware")) return "h3c";
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
if (v.includes("mikrotik")) return "mikrotik";
return hasUme ? "zte" : "gray";
}
function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
if (!r.ok) return "fail";
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
if (r.parser_stub || unmatchedCount > 0) return "warn";
return "ok";
}
function normalizeSearchText(s: string): string {
return String(s || "")
.toLowerCase()
.replace(/[\s_\-./]+/g, "");
}
/** Case-insensitive substring + loose subsequence (e.g. r1 ? router-1). */
function fuzzyIncludes(haystack: string, needle: string): boolean {
const h = normalizeSearchText(haystack);
const n = normalizeSearchText(needle);
if (!n) return true;
if (!h) return false;
if (h.includes(n)) return true;
let i = 0;
for (const ch of h) {
if (ch === n[i]) i += 1;
if (i >= n.length) return true;
}
return false;
}
function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
const tokens = String(query || "")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (!tokens.length) return false;
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
}
/**
* Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode.
* Color comes from --topo-icon-color (vendor palette in Display settings).
*/
function RouterIcon() {
return (
<span className="topo-node__icon" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/ne-router.png" alt="" draggable={false} />
<span className="topo-node__icon-tint" />
</span>
);
}
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
const TOPO_NODE_W = 160;
const TOPO_NODE_H = 88;
/** Must match `.topo-node__glyph` (56×56, top-centered) + `--center` handles. */
const TOPO_ICON = 56;
const TOPO_HANDLE_X = TOPO_NODE_W / 2;
const TOPO_HANDLE_Y = TOPO_ICON / 2;
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const { hideIp, hideVendor, connectMode } = useContext(TopoDisplayContext);
const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
const secondary = [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
return (
<div
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
connectMode ? " is-connect-mode" : ""
}`}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode}
/>
<RouterIcon />
</div>
<div className="topo-node__caption">
<span className="topo-node__caption-name">{name}</span>
{secondary.length ? (
<span className="topo-node__caption-meta">{secondary.join(SEP)}</span>
) : null}
</div>
</div>
);
});
const nodeTypes = { neNode: NeNode };
const edgeTypes = { topoParallel: ParallelEdge };
type EdgeStyleData = LinkEdgeData;
type EdgeLineStyle = "solid" | "dashed" | "dotted";
type EdgeSourceKind = "manual" | "discovered" | "stale";
type EdgeDefaultStyle = {
stroke_color: string;
stroke_width: number;
line_style: EdgeLineStyle;
};
type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched";
const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors";
const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
const CANVAS_BG_KEY = "netx.topology.canvasBg";
const DEFAULT_CANVAS_BG = "#0f172a";
/** Previous light default — migrate so existing sessions pick up dark canvas. */
const LEGACY_CANVAS_BG = "#dbeafe";
const LABEL_COLORS_KEY = "netx.topology.labelColors";
const VENDOR_COLORS_KEY = "netx.topology.vendorColors";
const VENDOR_TONE_KEYS = [
"cisco",
"huawei",
"zte",
"juniper",
"nokia",
"ericsson",
"h3c",
"ruijie",
"mikrotik",
"gray",
] as const;
type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
type VendorColors = Record<VendorToneKey, string>;
type LabelColors = { name: string; edgeLabel: string };
const DEFAULT_LABEL_COLORS: LabelColors = {
name: "#f1f5f9",
edgeLabel: "#e2e8f0",
};
const DEFAULT_VENDOR_COLORS: VendorColors = {
cisco: "#049fd9",
huawei: "#cf0a2c",
zte: "#0091da",
juniper: "#84b135",
nokia: "#124191",
ericsson: "#1e3a5f",
h3c: "#7ac143",
ruijie: "#7c3aed",
mikrotik: "#ea580c",
gray: "#94a3b8",
};
function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value);
}
function loadCanvasBg(): string {
try {
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
if (raw === LEGACY_CANVAS_BG) {
persistCanvasBg(DEFAULT_CANVAS_BG);
return DEFAULT_CANVAS_BG;
}
if (isHexColor(raw)) return raw;
} catch {
/* ignore */
}
return DEFAULT_CANVAS_BG;
}
function persistCanvasBg(value: string) {
try {
localStorage.setItem(CANVAS_BG_KEY, value);
} catch {
/* ignore */
}
}
function loadLabelColors(): LabelColors {
try {
const raw = localStorage.getItem(LABEL_COLORS_KEY);
if (!raw) return { ...DEFAULT_LABEL_COLORS };
const parsed = JSON.parse(raw) as Partial<LabelColors>;
return {
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
? String(parsed.edgeLabel).toLowerCase()
: DEFAULT_LABEL_COLORS.edgeLabel,
};
} catch {
return { ...DEFAULT_LABEL_COLORS };
}
}
function persistLabelColors(value: LabelColors) {
try {
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
function loadVendorColors(): VendorColors {
try {
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
const parsed = JSON.parse(raw) as Partial<VendorColors>;
const out = { ...DEFAULT_VENDOR_COLORS };
for (const key of VENDOR_TONE_KEYS) {
const c = String(parsed?.[key] || "").trim().toLowerCase();
if (isHexColor(c)) out[key] = c;
}
return out;
} catch {
return { ...DEFAULT_VENDOR_COLORS };
}
}
function persistVendorColors(value: VendorColors) {
try {
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
function canvasDotColor(bg: string): string {
const hex = String(bg || "").replace("#", "");
if (hex.length !== 6) return "#93c5fd";
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55 ? "#93c5fd" : "#64748b";
}
function loadBoolFlag(key: string, defaultValue: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw === null) return defaultValue;
return raw === "1" || raw === "true";
} catch {
return defaultValue;
}
}
function persistBoolFlag(key: string, value: boolean) {
try {
localStorage.setItem(key, value ? "1" : "0");
} catch {
/* ignore */
}
}
function loadAutoLayoutAfterDiscover(): boolean {
return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
}
function persistAutoLayoutAfterDiscover(value: boolean) {
persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
}
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" },
};
function sourceKind(source: string): EdgeSourceKind {
const src = (source || "manual").toLowerCase();
if (src === "stale" || src === "missing") return "stale";
if (src === "lldp" || src === "cdp") return "discovered";
return "manual";
}
function loadEdgeDefaults(): EdgeDefaults {
try {
const raw = localStorage.getItem(EDGE_DEFAULTS_KEY);
if (!raw) return { ...BUILTIN_EDGE_DEFAULTS, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale } };
const parsed = JSON.parse(raw) as Partial<EdgeDefaults>;
const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => {
const base = BUILTIN_EDGE_DEFAULTS[kind];
const cur = parsed?.[kind];
const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color;
const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width));
const line = String(cur?.line_style || base.line_style).toLowerCase();
const line_style: EdgeLineStyle =
line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style;
return { stroke_color: color, stroke_width: width, line_style };
};
return { manual: pick("manual"), discovered: pick("discovered"), stale: pick("stale") };
} catch {
return {
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
};
}
}
function persistEdgeDefaults(next: EdgeDefaults) {
try {
localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
function dashForLineStyle(lineStyle: string): string | undefined {
const s = (lineStyle || "").trim().toLowerCase();
if (s === "dashed") return "6 4";
if (s === "dotted") return "2 2";
return undefined;
}
function edgeStyleBySource(
source: string,
defaults: EdgeDefaults,
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
const d = defaults[sourceKind(source)];
return {
stroke: d.stroke_color,
strokeWidth: d.stroke_width,
strokeDasharray: dashForLineStyle(d.line_style),
};
}
function resolveEdgeStyle(
data: EdgeStyleData | undefined,
defaults: EdgeDefaults,
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
const base = edgeStyleBySource(data?.source || "manual", defaults);
const color = String(data?.stroke_color || "").trim();
const width = Number(data?.stroke_width || 0);
const line = String(data?.line_style || "").trim().toLowerCase();
return {
stroke: color || base.stroke,
strokeWidth: width > 0 ? width : base.strokeWidth,
strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray,
};
}
function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
const data = (edge.data || {}) as EdgeStyleData;
const style = resolveEdgeStyle(data, defaults);
// RF EdgeText defaults labelShowBg=true with a white rect — keep digits only.
return { ...edge, style, markerEnd: undefined, labelShowBg: false };
}
function graphToFlow(
nodes: TopologyViewNodeItem[],
edges: TopologyViewEdgeItem[],
defaults: EdgeDefaults,
) {
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
id: n.fabric_node_id,
type: "neNode",
position: { x: n.x || 0, y: n.y || 0 },
width: TOPO_NODE_W,
height: TOPO_NODE_H,
// Predetermined handles must match DOM anchors (icon center), not the 160px box edges —
// otherwise edges float in the gap and never touch the router glyph.
handles: [
{ type: "target", position: Position.Left, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
{ type: "source", position: Position.Right, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y },
],
data: {
label: n.label || n.name || n.ip || n.fabric_node_id,
managed_ne_id: n.managed_ne_id || "",
ume_ne_id: n.ume_ne_id || "",
ne_ip: n.ip || "",
vendor: n.vendor || "",
connect_status: n.connect_status || "",
},
}));
const rfEdges: Edge[] = edges.map((e) => {
const src =
e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
const label = formatPortPairLabel(e.a_port || "", e.b_port || "");
const data: EdgeStyleData = {
source: src,
source_port: e.a_port || "",
target_port: e.b_port || "",
stroke_color: e.stroke_color || "",
stroke_width: Number(e.stroke_width || 0),
line_style: e.line_style || "",
discovered_at: e.discovered_at ?? null,
};
return withEdgeVisual(
{
id: e.id,
source: e.a_node_id,
target: e.b_node_id,
type: "straight",
label: label || undefined,
animated: false,
data,
},
defaults,
);
});
return { rfNodes, rfEdges };
}
function flowToPositions(nodes: Node<NeNodeData>[]) {
return nodes.map((n) => ({
fabric_node_id: n.id,
x: n.position.x,
y: n.position.y,
label: n.data.label || "",
}));
}
/** Stable signature so live poll only re-applies when the server graph actually changed. */
function graphFingerprint(graph: TopologyViewGraph): string {
const nodes = [...graph.nodes]
.map(
(n) =>
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
)
.sort()
.join("|");
const edges = [...graph.edges]
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
.sort()
.join("|");
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}`;
}
function applyViewGraph(
graph: TopologyViewGraph,
defaults: EdgeDefaults,
setNodes: (ns: Node<NeNodeData>[]) => void,
setEdges: (es: Edge[]) => void,
localPositions?: Map<string, { x: number; y: number }>,
) {
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
const merged = localPositions?.size
? rfNodes.map((n) => {
const p = localPositions.get(n.id);
return p ? { ...n, position: { ...p } } : n;
})
: rfNodes;
setNodes(merged);
setEdges(rfEdges);
return { rfNodes: merged, rfEdges };
}
export function TopologyPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
const [mapId, setMapId] = useState<string>("");
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
/** Hover sync key between left tree and right hex browser: `region:<id>` | `view:<id>`. */
const [hotBrowseKey, setHotBrowseKey] = useState("");
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
try {
const raw = localStorage.getItem(TREE_EXPAND_KEY);
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
} catch {
return {};
}
});
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [treeNeQuery, setTreeNeQuery] = useState("");
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
const treeSearchRef = useRef<HTMLDivElement | null>(null);
const [dirty, setDirty] = useState(false);
const [historyTick, setHistoryTick] = useState(0);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [ctxMenu, setCtxMenu] = useState<CtxMenu | 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),
);
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>("select");
const [snapToGrid, setSnapToGrid] = useState(true);
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() =>
loadBoolFlag(DISCOVER_AUTO_ADD_KEY, false),
);
const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() =>
loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, false),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [addNeOpen, setAddNeOpen] = useState(false);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
const [paletteAdding, setPaletteAdding] = useState(false);
const [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
const [discoverLiveResults, setDiscoverLiveResults] = useState<TopologyDiscoverNeResult[]>([]);
const [discoverProgress, setDiscoverProgress] = useState({
index: 0,
total: 0,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
const [discoverError, setDiscoverError] = useState("");
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 findJustLocatedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const dirtyRef = useRef(false);
const appliedMapIdRef = useRef("");
const appliedGraphFpRef = useRef("");
const nodesRef = useRef<Node<NeNodeData>[]>([]);
const historyRef = useRef<HistorySnap[]>([]);
const redoRef = useRef<HistorySnap[]>([]);
const historyLockRef = useRef(false);
const connectClickRef = useRef<string | null>(null);
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
nodesRef.current = nodes;
const markDirty = useCallback(() => {
dirtyRef.current = true;
setDirty(true);
}, []);
const clearDirty = useCallback(() => {
dirtyRef.current = false;
setDirty(false);
}, []);
const bumpHistory = useCallback(() => {
setHistoryTick((n) => n + 1);
}, []);
const confirmDiscardIfDirty = useCallback(() => {
if (!dirtyRef.current) return true;
return window.confirm(t("topology.unsavedConfirm"));
}, [t]);
const selectMap = useCallback(
(id: string) => {
if (id === mapId) return;
if (!confirmDiscardIfDirty()) return;
setMapId(id);
},
[mapId, confirmDiscardIfDirty],
);
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 displayOpts = useMemo(
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
[hideIp, hideVendor, hidePorts, toolMode],
);
const displayEdges = useMemo(() => {
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) =>
withEdgeVisual(e, edgeDefaults),
);
return built.map((e) => {
const d = (e.data || {}) as EdgeStyleData;
const selected =
e.id === selectedEdgeId ||
Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId));
return {
...e,
selected,
// Empty string clears RF's previous edge-text; undefined can leave a stale label.
label: e.label ? String(e.label) : "",
animated: edgeFlow,
};
});
}, [edges, expandPhysicalLinks, hidePorts, scaleBundleWidth, edgeFlow, edgeDefaults, selectedEdgeId]);
const treeQuery = useQuery({
queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree,
refetchInterval: liveSync ? 5000 : false,
refetchIntervalInBackground: false,
});
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
return () => window.clearTimeout(timer);
}, [treeNeQuery]);
const treeNeSearchQuery = useQuery({
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
enabled: debouncedTreeNeQuery.length >= 1,
});
const graphQuery = useQuery({
queryKey: queryKeys.topologyGraph(mapId),
queryFn: () => fetchTopologyGraph(mapId),
enabled: Boolean(mapId),
refetchInterval: liveSync && mapId ? 3000 : false,
refetchIntervalInBackground: false,
});
useEffect(() => {
try {
localStorage.setItem(TREE_EXPAND_KEY, JSON.stringify(expandedIds));
} catch {
/* ignore */
}
}, [expandedIds]);
useEffect(() => {
try {
if (mapId) localStorage.setItem(LAST_LEAF_KEY, mapId);
else localStorage.removeItem(LAST_LEAF_KEY);
} catch {
/* ignore */
}
}, [mapId]);
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 treeRoot = treeQuery.data?.root || null;
const regions = useMemo(() => treeRoot?.children || [], [treeRoot]);
useEffect(() => {
if (!treeRoot || !regions.length) return;
if (mapId) {
const hit = findViewInRegion(regions, mapId);
if (hit && selectedFolderId !== hit.region.id) {
setSelectedFolderId(hit.region.id);
setExpandedIds((prev) => ({ ...prev, [hit.region.id]: true }));
}
return;
}
// Keep root (all regions) as the default landing view — do not auto-pick regions[0].
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
setSelectedFolderId("");
}
}, [mapId, selectedFolderId, treeRoot, regions]);
const canvasMode = Boolean(mapId);
const activeRegion = useMemo(() => {
if (!selectedFolderId) return null;
return regions.find((c) => c.id === selectedFolderId) || null;
}, [regions, selectedFolderId]);
const activeView = useMemo(() => {
if (!mapId) return null;
return findViewInRegion(regions, mapId)?.view || null;
}, [mapId, regions]);
const browseEntries = useMemo(
(): TopologyTreeViewItem[] => activeRegion?.views || [],
[activeRegion],
);
const goRegion = useCallback(
(folderId: string) => {
if (!confirmDiscardIfDirty()) return;
setMapId("");
setSelectedFolderId(folderId);
setExpandedIds((p) => ({ ...p, [folderId]: true }));
clearDirty();
setNodes([]);
setEdges([]);
},
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
);
const goRoot = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
setMapId("");
setSelectedFolderId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
const goCanvas = useCallback(
(viewId: string, folderId?: string) => {
if (!confirmDiscardIfDirty()) return;
const hit = findViewInRegion(regions, viewId);
const regionId = folderId || hit?.region.id || selectedFolderId;
if (regionId) {
setSelectedFolderId(regionId);
setExpandedIds((p) => ({ ...p, [regionId]: true }));
}
setMapId(viewId);
},
[confirmDiscardIfDirty, regions, selectedFolderId],
);
const goBackBrowse = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
setMapId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
// Deep link from classify search: /topology?view=&ne=
useEffect(() => {
const viewId = String(searchParams.get("view") || "").trim();
const neId = String(searchParams.get("ne") || "").trim();
if (!viewId || !regions.length) return;
const hit = findViewInRegion(regions, viewId);
if (!hit) return;
setSelectedFolderId(hit.region.id);
setExpandedIds((p) => ({ ...p, [hit.region.id]: true }));
setMapId(viewId);
if (neId) setPendingHighlightNe(neId);
setSearchParams({}, { replace: true });
}, [regions, searchParams, setSearchParams]);
useEffect(() => {
appliedMapIdRef.current = "";
appliedGraphFpRef.current = "";
}, [mapId]);
useEffect(() => {
if (!canvasMode) return;
if (!mapId || !graphQuery.data) return;
const entering = appliedMapIdRef.current !== mapId;
// Live poll while on the same map: apply server graph only if no unsaved local edits.
if (!entering && dirtyRef.current) return;
const fp = graphFingerprint(graphQuery.data);
if (!entering && fp === appliedGraphFpRef.current) return;
const selectedIds = entering
? new Set<string>()
: new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
const nextNodes =
selectedIds.size > 0
? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n))
: rfNodes;
historyLockRef.current = true;
setNodes(nextNodes);
setEdges(rfEdges);
appliedGraphFpRef.current = fp;
if (entering) {
appliedMapIdRef.current = mapId;
historyRef.current = [];
redoRef.current = [];
clearDirty();
bumpHistory();
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 50);
}
historyLockRef.current = false;
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
useEffect(() => {
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
}, [edgeDefaults, setEdges]);
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 = {
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
};
persistEdgeDefaults(next);
setEdgeDefaults(next);
}, []);
const pushHistory = useCallback(() => {
if (historyLockRef.current) return;
historyRef.current = [
...historyRef.current.slice(-(UNDO_MAX - 1)),
{
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
},
];
redoRef.current = [];
bumpHistory();
}, [nodes, edges, bumpHistory]);
const undo = useCallback(() => {
const prev = historyRef.current.pop();
if (!prev) return;
redoRef.current.push({
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
});
historyLockRef.current = true;
setNodes(prev.nodes);
setEdges(prev.edges);
markDirty();
bumpHistory();
historyLockRef.current = false;
}, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
const redo = useCallback(() => {
const next = redoRef.current.pop();
if (!next) return;
historyRef.current.push({
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
edges: edges.map((e) => ({ ...e })),
});
historyLockRef.current = true;
setNodes(next.nodes);
setEdges(next.edges);
markDirty();
bumpHistory();
historyLockRef.current = false;
}, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
const applyLayout = useCallback(
async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => {
const onlyIds =
opts?.onlySelected
? new Set(nodes.filter((n) => n.selected).map((n) => n.id))
: undefined;
if (onlyIds && onlyIds.size === 0) {
showError(t("topology.layoutNeedSelection"));
return;
}
pushHistory();
const next = layoutGraph(nodes, edges, kind, { onlyIds });
setNodes(next);
markDirty();
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 40);
if (opts?.persist && mapId) {
try {
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
clearDirty();
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
} catch (err) {
showError(String(err));
}
}
},
[nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty],
);
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 renameMapMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
updateTopologyMap(id, { name }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.renamed"));
},
onError: (err) => showError(String(err)),
});
const promptRenameMap = useCallback(
(id: string, currentName: string) => {
const next = window.prompt(t("topology.renamePrompt"), currentName);
if (next == null) return;
const name = next.trim();
if (!name || name === currentName) return;
renameMapMut.mutate({ id, name });
},
[renameMapMut, t],
);
const createRegionMut = useMutation({
mutationFn: (name: string) =>
createTopologyFolder({
name,
kind: "region",
parent_id: treeQuery.data?.root?.id,
}),
onSuccess: async (folder) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
setSelectedFolderId(folder.id);
setMapId("");
setExpandedIds((prev) => ({ ...prev, [folder.id]: true }));
showOk(t("topology.regionCreated"));
},
onError: (err) => showError(String(err)),
});
const renameRegionMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
updateTopologyFolder(id, { name }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
showOk(t("topology.regionRenamed"));
},
onError: (err) => showError(String(err)),
});
const promptRenameRegion = useCallback(
(id: string, currentName: string) => {
const next = window.prompt(t("topology.renameRegionPrompt"), currentName);
if (next == null) return;
const name = next.trim();
if (!name || name === currentName) return;
renameRegionMut.mutate({ id, name });
},
[renameRegionMut, t],
);
const createMapMut = useMutation({
mutationFn: (input: { folder_id: string; kind: TopologyViewKind; name: string }) =>
createTopologyView(input),
onSuccess: async (row) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
clearDirty();
if (row.folder_id) setSelectedFolderId(row.folder_id);
setMapId(row.id);
showOk(t("topology.newMap"));
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
},
onError: (err) => showError(String(err)),
});
const deleteMapMut = useMutation({
mutationFn: (id: string) => deleteTopologyMap(id),
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
if (mapId === id) {
clearDirty();
setMapId("");
setNodes([]);
setEdges([]);
}
},
onError: (err) => showError(String(err)),
});
const deleteFolderMut = useMutation({
mutationFn: (id: string) => deleteTopologyFolder(id, false),
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
if (selectedFolderId === id) {
setSelectedFolderId("");
setMapId("");
}
showOk(t("topology.regionDeleted"));
},
onError: (err) => showError(String(err)),
});
const promptNewRegion = useCallback(() => {
const name = window.prompt(t("topology.newRegionPrompt"), t("topology.newRegionName"));
if (!name?.trim()) return;
createRegionMut.mutate(name.trim());
}, [createRegionMut, t]);
const promptNewCustom = useCallback(
(folderId: string) => {
const name = window.prompt(t("topology.newCustomPrompt"), t("topology.newCustomName"));
if (!name?.trim()) return;
createMapMut.mutate({
folder_id: folderId,
kind: "custom",
name: name.trim(),
});
},
[createMapMut, t],
);
const saveMut = useMutation({
mutationFn: async () => {
if (!mapId) throw new Error(t("topology.selectMap"));
// Positions patch only updates listed nodes; sync canvas removals first.
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));
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 runDiscover = useCallback(
async (neIds?: string[]) => {
if (!mapId || discovering) return;
// Topology canvas: single-NE (or explicit id list) only — bulk collect lives under Network → LLDP.
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
if (!filterIds.length) {
showError(t("topology.discoverOneNeedNe"));
return;
}
setDiscoverOpen(true);
setDiscovering(true);
setDiscoverReport(null);
setDiscoverLiveResults([]);
setDiscoverError("");
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
const scoped = scannable.filter(
(n) =>
filterIds.includes(String(n.data.managed_ne_id || "")) ||
filterIds.includes(String(n.data.ume_ne_id || "")),
);
setDiscoverProgress({
index: 0,
total: scoped.length,
neName: "",
neIp: "",
edgesAdded: 0,
edgesUpdated: 0,
});
try {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
const ne_ids = filterIds;
if (!scoped.length) {
throw new Error(t("topology.discoverOneNeedNe"));
}
const jobStart = await startLldpDiscover({
scope: "ne_ids",
ne_ids,
concurrency: 4,
auto_add_unmatched: discoverAutoAddUnmatched,
trigger_mode: "topology",
});
let job: TopologyDiscoverJob = jobStart;
for (let i = 0; i < 600; i++) {
await new Promise((r) => window.setTimeout(r, 500));
job = await fetchLldpDiscoverJob(jobStart.id);
setDiscoverProgress((p) => ({
...p,
index: job.done,
total: job.total || p.total,
edgesAdded: job.edges_added,
edgesUpdated: job.edges_updated,
neName: job.items?.[job.items.length - 1]?.ne_name || p.neName,
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (job.status === "done" || job.status === "failed") break;
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
const projected = discoverProjectNeighbors
? await projectTopologyNeighbors(mapId)
: await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
appliedMapIdRef.current = mapId;
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
// Keep existing node positions when we did not auto-layout.
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
rfNodes = rfNodes.map((n) => {
const p = localPos.get(n.id);
return p ? { ...n, position: { ...p } } : n;
});
let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
didAutoLayout = true;
try {
const graph = await patchTopologyPositions(mapId, flowToPositions(rfNodes));
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
clearDirty();
} catch {
markDirty();
}
} else {
clearDirty();
}
historyLockRef.current = true;
setNodes(rfNodes);
setEdges(rfEdges);
historyLockRef.current = false;
if (didAutoLayout) {
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS }), 50);
}
const out: TopologyDiscoverOut = {
map_id: mapId,
protocol: "lldp",
scanned: job.done,
edges_added: job.edges_added,
edges_updated: job.edges_updated,
edges_stale: job.edges_missing ?? job.edges_stale,
results: (job.items || []) as TopologyDiscoverNeResult[],
graph: projected,
};
setDiscoverReport(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary });
showOk(
t("topology.discovered")
.replace("{{added}}", String(out.edges_added))
.replace("{{updated}}", String(out.edges_updated))
.replace("{{stale}}", String(out.edges_stale || 0)),
);
} catch (err) {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
} finally {
setDiscovering(false);
}
},
[mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty],
);
const discoverResults = discoverReport?.results?.length
? discoverReport.results
: discoverLiveResults;
const discoverCounts = useMemo(() => {
let ok = 0;
let warn = 0;
let fail = 0;
for (const r of discoverResults) {
const k = discoverResultKind(r);
if (k === "fail") fail += 1;
else if (k === "warn") warn += 1;
else ok += 1;
}
return { ok, warn, fail, issues: warn + fail };
}, [discoverResults]);
const discoverSummary = discoverReport
? {
scanned: discoverReport.scanned,
added: discoverReport.edges_added,
updated: discoverReport.edges_updated,
missing: discoverReport.edges_stale || 0,
failed: discoverCounts.fail,
}
: {
scanned: discoverLiveResults.length,
added: discoverProgress.edgesAdded,
updated: discoverProgress.edgesUpdated,
missing: 0,
failed: discoverCounts.fail,
};
const discoverPct =
discovering && discoverProgress.total > 0
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
: discoverReport
? 100
: 0;
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;
pushHistory();
connectClickRef.current = null;
void (async () => {
try {
await createFabricManualEdge({
a_node_id: String(connection.source || ""),
b_node_id: String(connection.target || ""),
a_port: "",
b_port: "",
});
const graph = await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
historyLockRef.current = false;
clearDirty();
} catch (err) {
showError(String(err));
}
})();
},
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
);
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!mapId) {
showError(t("topology.selectMap"));
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,
],
);
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 || "");
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);
if (active) {
window.setTimeout(() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS_FS }), 80);
}
};
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 removeSelected = useCallback(async () => {
if (!mapId) return;
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
if (!nodeIds.length && !edgeIds.size) return;
pushHistory();
try {
if (nodeIds.length) {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, nodeIds);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
} else if (edgeIds.size) {
setEdges((eds) => eds.filter((e) => !edgeIds.has(e.id)));
markDirty();
}
setSelectedEdgeId(null);
} catch (err) {
showError(String(err));
}
}, [mapId, nodes, edges, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, markDirty, showError]);
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();
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 = () => 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]);
const removeNodeById = async (nodeId: string) => {
if (!mapId) return;
pushHistory();
closeCtxMenu();
try {
if (dirtyRef.current) {
await patchTopologyPositions(mapId, flowToPositions(nodes));
clearDirty();
}
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
appliedMapIdRef.current = mapId;
historyLockRef.current = true;
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
} catch (err) {
showError(String(err));
}
};
const removeEdgeById = (edgeId: string) => {
pushHistory();
markDirty();
const display = displayEdges.find((e) => e.id === edgeId);
const ids = new Set(physicalIdsForDisplayEdge(display, edges));
if (!ids.size) ids.add(edgeId);
setEdges((es) => es.filter((e) => !ids.has(e.id)));
setSelectedEdgeId((cur) => (cur && (ids.has(cur) || cur === edgeId) ? null : cur));
closeCtxMenu();
};
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 placeholders / incomplete rows → full NE edit form (vendor, hop, etc.).
const needsSetup = !hasIp || src === "lldp";
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 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 focusNode = useCallback(
(nodeId: string, additive: boolean) => {
setSelectedEdgeId(null);
setNodes((ns) =>
ns.map((n) => {
if (additive) {
if (n.id === nodeId) return { ...n, selected: !n.selected };
return n;
}
return { ...n, selected: n.id === nodeId };
}),
);
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
},
[setNodes, setEdges],
);
const locateNode = useCallback(
(nodeId: string) => {
focusNode(nodeId, false);
setSearchHitIds([nodeId]);
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
window.setTimeout(() => {
rfRef.current?.fitView({
nodes: [{ id: nodeId }],
padding: 0.45,
duration: 280,
includeHiddenNodes: true,
});
}, 30);
searchHitTimerRef.current = window.setTimeout(() => {
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
searchHitTimerRef.current = null;
}, 2200);
},
[focusNode],
);
const jumpToTreeSearchHit = useCallback(
(
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => {
const views = hit.views || [];
const onCurrent =
Boolean(mapId) &&
(views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id));
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
if (nodes.some((n) => n.id === hit.id)) {
locateNode(hit.id);
} else {
setPendingHighlightNe(hit.id);
}
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
return;
}
const target =
view ||
views.find((v) => String(v.kind) === "physical") ||
views[0] ||
null;
if (!target) {
showError(t("topology.treeSearchNotOnMap"));
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 }));
}
setMapId(target.view_id);
setPendingHighlightNe(hit.id);
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
},
[
mapId,
nodes,
locateNode,
showError,
t,
confirmDiscardIfDirty,
regions,
],
);
useEffect(() => {
if (!treeSearchOpen) return;
const onDoc = (e: MouseEvent) => {
const el = treeSearchRef.current;
if (el && e.target instanceof Element && !el.contains(e.target)) {
setTreeSearchOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [treeSearchOpen]);
useEffect(() => {
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
const nodeId = pendingHighlightNe;
if (!nodes.some((n) => n.id === nodeId)) return;
setPendingHighlightNe("");
window.setTimeout(() => locateNode(nodeId), 80);
}, [pendingHighlightNe, canvasMode, nodes, locateNode]);
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 locatePaletteItem = useCallback(
(item: PaletteItem) => {
const node = nodes.find((n) =>
item.source === "managed"
? n.data.managed_ne_id === item.managed_ne_id
: n.data.ume_ne_id === item.ume_ne_id,
);
if (!node) {
showError(t("topology.findNoMatch"));
return;
}
locateNode(node.id);
},
[nodes, locateNode, showError, t],
);
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 (toolMode === "connect") {
const prev = connectClickRef.current;
if (!prev) {
connectClickRef.current = node.id;
focusNode(node.id, false);
return;
}
if (prev !== node.id) {
onConnect({ source: prev, target: node.id, sourceHandle: null, targetHandle: null });
}
connectClickRef.current = null;
return;
}
focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey);
},
[toolMode, focusNode, onConnect],
);
const outsidePeers = graphQuery.data?.outside_peers || [];
const activeLeafName = useMemo(() => {
if (!mapId) return "";
if (activeView?.name) return activeView.name;
return graphQuery.data?.view?.name || "";
}, [mapId, activeView, graphQuery.data?.view?.name]);
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
if (activeRegion) return regionDisplayName(activeRegion);
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);
return (
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
{sidebarCollapsed ? (
<div className="topo-sidebar__rail-wrap">
<button
type="button"
className="topo-sidebar__rail"
title={t("topology.expandSidebar")}
aria-label={t("topology.expandSidebar")}
onClick={() => setSidebarCollapsed(false)}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
<SidebarFoldIcon expand />
</span>
</button>
<button
type="button"
className={`topo-sidebar__rail topo-sidebar__rail--live${liveSync ? " is-on" : ""}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
aria-label={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
onClick={() => setLiveSync((v) => !v)}
>
<span className="topo-sidebar__rail-live-dot" aria-hidden="true" />
</button>
</div>
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<input
className="input"
type="search"
value={treeNeQuery}
placeholder={t("topology.treeSearchPh")}
aria-label={t("topology.treeSearch")}
onChange={(e) => {
setTreeNeQuery(e.target.value);
setTreeSearchOpen(true);
}}
onFocus={() => setTreeSearchOpen(true)}
/>
<button
type="button"
className="topo-sidebar__icon-btn"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
aria-label={t("topology.newRegion")}
>
<PlusIcon />
</button>
<button
type="button"
className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={() => setSidebarCollapsed(true)}
>
<SidebarFoldIcon />
</button>
</div>
<div className="topo-sidebar__live">
<button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={() => setLiveSync((v) => !v)}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
<p className="topo-tree-search__hint muted">…</p>
) : !(treeNeSearchQuery.data?.items || []).length ? (
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
) : (
(treeNeSearchQuery.data?.items || []).map((hit) => {
const views = hit.views || [];
const title = hit.name || hit.ip || hit.id.slice(0, 8);
return (
<div key={hit.id} className="topo-tree-search__item">
<button
type="button"
className="topo-tree-search__ne"
onClick={() => jumpToTreeSearchHit(hit)}
title={
views.length
? t("topology.locateOnCanvas")
: t("topology.treeSearchNotOnMap")
}
>
<span className="topo-tree-search__name">{title}</span>
{hit.ip ? <span className="topo-tree-search__ip muted">{hit.ip}</span> : null}
{!views.length ? (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchNoViews")}
</span>
) : (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchViewCount").replace(
"{{count}}",
String(views.length),
)}
</span>
)}
</button>
{views.length > 1 ? (
<div className="topo-tree-search__views">
{views.map((v) => (
<button
key={`${hit.id}-${v.view_id}`}
type="button"
className="topo-tree-search__view"
onClick={() => jumpToTreeSearchHit(hit, v)}
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
>
{v.view_name || v.view_id.slice(0, 8)}
</button>
))}
</div>
) : null}
</div>
);
})
)}
{(treeNeSearchQuery.data?.total || 0) > 30 ? (
<p className="topo-tree-search__hint muted">
{t("topology.treeSearchTruncated").replace(
"{{total}}",
String(treeNeSearchQuery.data?.total || 0),
)}
</p>
) : null}
</div>
) : null}
</div>
{!treeRoot ? (
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
) : regions.length === 0 ? (
<p className="panel__hint">{t("topology.emptyMaps")}</p>
) : (
<ul className="topo-map-list topo-region-list">
{regions.map((region) => {
const open = expandedIds[region.id] ?? false;
const regionActive = selectedFolderId === region.id && !mapId;
const regionBranch = selectedFolderId === region.id && Boolean(mapId);
const regionHot = hotBrowseKey === `region:${region.id}`;
return (
<li
key={region.id}
className={`topo-region-list__block${
regionActive ? " is-active" : regionBranch ? " is-branch-active" : ""
}${regionHot ? " is-hot" : ""}`}
>
<div
className="topo-map-list__row topo-region-list__row"
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
onClick={() => {
if (!confirmDiscardIfDirty()) return;
const nextOpen = !(expandedIds[region.id] ?? false);
setMapId("");
setSelectedFolderId(region.id);
setExpandedIds((p) => ({ ...p, [region.id]: nextOpen }));
clearDirty();
setNodes([]);
setEdges([]);
}}
title={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
>
<span className="topo-map-list__name">
<span className="topo-region-list__glyph" aria-hidden="true">
<RegionGlyph size={14} />
</span>
<span className="topo-map-list__title">
{regionDisplayName(region)}
</span>
<span className="topo-map-list__count">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views.length),
)}
</span>
</span>
</button>
<div className="topo-map-list__actions">
<button
type="button"
className="topo-map-list__icon"
title={t("topology.renameRegion")}
aria-label={t("topology.renameRegion")}
disabled={!!region.is_system || renameRegionMut.isPending}
onClick={() => promptRenameRegion(region.id, region.name)}
>
<PencilIcon />
</button>
<button
type="button"
className="topo-map-list__icon"
title={t("topology.deleteRegion")}
aria-label={t("topology.deleteRegion")}
disabled={!!region.is_system}
onClick={() => {
const msg = t("topology.deleteRegionConfirm").replace(
"{{name}}",
region.name,
);
if (window.confirm(msg)) deleteFolderMut.mutate(region.id);
}}
>
<CloseIcon />
</button>
</div>
</div>
{open ? (
<ul className="topo-map-list topo-region-list__maps">
{(region.views || []).map((v) => {
const isPhysical = String(v.kind) === "physical";
const viewHot = hotBrowseKey === `view:${v.id}`;
return (
<li
key={v.id}
className={`${mapId === v.id ? "is-active" : ""}${
viewHot ? " is-hot" : ""
}`}
>
<div
className="topo-map-list__row"
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
onClick={() => goCanvas(v.id, region.id)}
onDoubleClick={() => promptRenameMap(v.id, v.name)}
title={t("topology.renameHint")}
>
<span className="topo-map-list__name">
<span
className={`topo-region-list__glyph topo-dir__icon--${
isPhysical ? "core" : "aggregation"
}`}
aria-hidden="true"
>
<LayerGlyph
role={isPhysical ? "core" : "aggregation"}
size={13}
/>
</span>
<span className="topo-map-list__title">
{v.name}
{mapId === v.id && dirty ? " *" : ""}
</span>
<span className="topo-map-list__count">
{v.node_count || 0}N
</span>
</span>
</button>
<div className="topo-map-list__actions">
<button
type="button"
className="topo-map-list__icon"
title={t("topology.rename")}
aria-label={t("topology.rename")}
disabled={renameMapMut.isPending}
onClick={() => promptRenameMap(v.id, v.name)}
>
<PencilIcon />
</button>
<button
type="button"
className="topo-map-list__icon"
title={t("topology.deleteMap")}
aria-label={t("topology.deleteMap")}
disabled={isPhysical}
onClick={() => {
const msg = t("topology.deleteMapConfirm").replace(
"{{name}}",
v.name,
);
if (window.confirm(msg)) deleteMapMut.mutate(v.id);
}}
>
<CloseIcon />
</button>
</div>
</div>
</li>
);
})}
</ul>
) : null}
</li>
);
})}
</ul>
)}
{mapId && outsidePeers.length > 0 && (
<p className="panel__hint">
{t("topology.outsidePeers").replace("{{count}}", String(outsidePeers.length))}
</p>
)}
</div>
</>
)}
</aside>
<main className="topo-main">
{canvasMode ? (
<div className="topo-toolbar">
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
{activeRegion ? (
<>
<span className="topo-breadcrumb__sep">/</span>
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(activeRegion.id)}
>
{regionDisplayName(activeRegion)}
</button>
</>
) : null}
{activeView ? (
<>
<span className="topo-breadcrumb__sep">/</span>
<span className="topo-breadcrumb__current">
{activeView.name}
{dirty ? " *" : ""}
</span>
</>
) : null}
</div>
{selectedNodes.length > 0 || selectedEdgeId ? (
<span className="topo-toolbar__meta">
{selectedNodes.length > 0
? t("topology.selectedCount").replace("{{count}}", String(selectedNodes.length))
: t("topology.selectedEdge")}
</span>
) : null}
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
</div>
<div className="topo-toolbar__actions">
<button
type="button"
className="btn btn--sm"
onClick={() => {
setKeyword("");
setPaletteSelectedKeys([]);
setAddNeOpen(true);
}}
>
{t("topology.addNe")}
</button>
<button type="button" className="btn btn--sm btn--ghost" onClick={() => goBackBrowse()}>
{t("topology.backUp")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canUndo}
onClick={undo}
title="Ctrl+Z"
>
{t("topology.undo")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canRedo}
onClick={redo}
title="Ctrl+Y"
>
{t("topology.redo")}
</button>
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={saveMut.isPending || !dirty}
onClick={() => saveMut.mutate()}
title="Ctrl+S"
>
{saveMut.isPending
? t("topology.saving")
: dirty
? t("topology.saveDirty")
: t("topology.save")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={nodes.length === 0}
onClick={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })}
>
{t("topology.fit")}
</button>
</div>
</div>
<div className="topo-toolbar__row topo-toolbar__row--tools">
<div className="topo-toolbar__panel" aria-label={t("topology.toolModes")}>
<div className="topo-tools" 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-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
aria-pressed={toolMode === mode}
onClick={() => {
setToolMode(mode);
connectClickRef.current = null;
}}
>
<span className="topo-tools__label">{label}</span>
<kbd className="topo-tools__key">{key}</kbd>
</button>
))}
</div>
<details className="topo-toolbar__display" ref={displayMenuRef}>
<summary>{t("topology.display")}</summary>
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
<label className="topo-display-toggles__item">
<input type="checkbox" checked={hideIp} onChange={(e) => setHideIp(e.target.checked)} />
{t("topology.hideIp")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hideVendor}
onChange={(e) => setHideVendor(e.target.checked)}
/>
{t("topology.hideVendor")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hidePorts}
onChange={(e) => setHidePorts(e.target.checked)}
/>
{t("topology.hidePorts")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={expandPhysicalLinks}
onChange={(e) => setExpandPhysicalLinks(e.target.checked)}
/>
{t("topology.expandPhysicalLinks")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={scaleBundleWidth}
disabled={expandPhysicalLinks}
onChange={(e) => {
const next = e.target.checked;
setScaleBundleWidth(next);
persistBoolFlag(SCALE_BUNDLE_WIDTH_KEY, next);
}}
/>
{t("topology.scaleBundleWidth")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={edgeFlow}
onChange={(e) => setEdgeFlow(e.target.checked)}
/>
{t("topology.edgeFlow")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={snapToGrid}
onChange={(e) => setSnapToGrid(e.target.checked)}
/>
{t("topology.snapGrid")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={autoLayoutAfterDiscover}
onChange={(e) => {
const next = e.target.checked;
setAutoLayoutAfterDiscover(next);
persistAutoLayoutAfterDiscover(next);
}}
/>
{t("topology.autoLayoutDiscover")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={discoverAutoAddUnmatched}
onChange={(e) => {
const next = e.target.checked;
setDiscoverAutoAddUnmatched(next);
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
}}
/>
{t("topology.discoverAutoAddUnmatched")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={discoverProjectNeighbors}
onChange={(e) => {
const next = e.target.checked;
setDiscoverProjectNeighbors(next);
persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
}}
/>
{t("topology.discoverProjectNeighbors")}
</label>
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
<div className="topo-display-defaults__head">
<strong>{t("topology.canvasBg")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setCanvasBg(DEFAULT_CANVAS_BG);
persistCanvasBg(DEFAULT_CANVAS_BG);
}}
>
{t("topology.canvasBgReset")}
</button>
</div>
<div className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span>
<input
type="color"
value={canvasBg}
title={t("topology.canvasBgColor")}
onChange={(e) => {
const next = e.target.value;
setCanvasBg(next);
persistCanvasBg(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={canvasBg}
spellCheck={false}
aria-label={t("topology.canvasBgColor")}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
setCanvasBg(raw);
if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase());
}}
onBlur={() => {
if (!isHexColor(canvasBg)) {
setCanvasBg(loadCanvasBg());
}
}}
/>
</div>
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
<div className="topo-display-defaults__head">
<strong>{t("topology.textColors")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setLabelColors({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}}
>
{t("topology.textColorsReset")}
</button>
</div>
{(
[
["name", t("topology.labelColor")],
["edgeLabel", t("topology.edgeLabelColor")],
] as const
).map(([key, label]) => (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={labelColors[key]}
title={label}
onChange={(e) => {
const next = { ...labelColors, [key]: e.target.value.toLowerCase() };
setLabelColors(next);
persistLabelColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={labelColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...labelColors, [key]: raw };
setLabelColors(next);
if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() });
}}
onBlur={() => {
if (!isHexColor(labelColors[key])) {
setLabelColors(loadLabelColors());
}
}}
/>
</div>
))}
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
<div className="topo-display-defaults__head">
<strong>{t("topology.vendorColors")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setVendorColors({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}}
>
{t("topology.vendorColorsReset")}
</button>
</div>
{VENDOR_TONE_KEYS.map((key) => {
const label = t(`topology.vendorTone.${key}`);
return (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={vendorColors[key]}
title={label}
onChange={(e) => {
const next = { ...vendorColors, [key]: e.target.value.toLowerCase() };
setVendorColors(next);
persistVendorColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={vendorColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...vendorColors, [key]: raw };
setVendorColors(next);
if (isHexColor(raw)) {
persistVendorColors({ ...next, [key]: raw.toLowerCase() });
}
}}
onBlur={() => {
if (!isHexColor(vendorColors[key])) {
setVendorColors(loadVendorColors());
}
}}
/>
</div>
);
})}
</div>
<div className="topo-display-defaults">
<div className="topo-display-defaults__head">
<strong>{t("topology.edgeDefaults")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={resetEdgeDefaults}>
{t("topology.edgeDefaultsReset")}
</button>
</div>
{(
[
["manual", t("topology.edgeManual")],
["discovered", t("topology.edgeDiscovered")],
["stale", t("topology.edgeStale")],
] as const
).map(([kind, label]) => {
const d = edgeDefaults[kind];
return (
<div key={kind} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={d.stroke_color}
title={t("topology.edgeColor")}
onChange={(e) => updateEdgeDefault(kind, { stroke_color: e.target.value })}
/>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeLineStyle")}
value={d.line_style}
onChange={(e) =>
updateEdgeDefault(kind, {
line_style: e.target.value as EdgeLineStyle,
})
}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
<select
className="topo-toolbar__select"
aria-label={t("topology.edgeWidth")}
value={String(d.stroke_width)}
onChange={(e) =>
updateEdgeDefault(kind, { stroke_width: Number(e.target.value) || 2 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</div>
);
})}
</div>
</div>
</details>
</div>
<div className="topo-toolbar__group" aria-label={t("topology.layout")}>
<select
className="topo-toolbar__select"
aria-label={t("topology.layout")}
disabled={!mapId || nodes.length === 0}
defaultValue=""
onChange={(e) => {
const v = e.target.value as LayoutKind | "selected-tb" | "";
e.target.value = "";
if (!v) return;
if (v === "selected-tb") void applyLayout("hierarchical-tb", { onlySelected: true });
else void applyLayout(v);
}}
>
<option value="" disabled>
{t("topology.layout")}
</option>
<option value="hierarchical-tb">{t("topology.layoutHierarchicalTb")}</option>
<option value="hierarchical-lr">{t("topology.layoutHierarchicalLr")}</option>
<option value="force">{t("topology.layoutForce")}</option>
<option value="grid">{t("topology.layoutGrid")}</option>
<option value="radial">{t("topology.layoutRadial")}</option>
<option value="selected-tb">{t("topology.layoutSelected")}</option>
</select>
<select
className="topo-toolbar__select"
aria-label={t("topology.align")}
disabled={selectedNodes.length < 2}
defaultValue=""
onChange={(e) => {
const v = e.target.value as Parameters<typeof alignNodes>[2] | "";
e.target.value = "";
if (v) applyAlign(v);
}}
>
<option value="" disabled>
{t("topology.align")}
</option>
<option value="left">{t("topology.alignLeft")}</option>
<option value="right">{t("topology.alignRight")}</option>
<option value="top">{t("topology.alignTop")}</option>
<option value="bottom">{t("topology.alignBottom")}</option>
<option value="h-center">{t("topology.alignHCenter")}</option>
<option value="v-center">{t("topology.alignVCenter")}</option>
<option value="h-distribute">{t("topology.alignHDistribute")}</option>
<option value="v-distribute">{t("topology.alignVDistribute")}</option>
</select>
</div>
<div
className="topo-toolbar__group topo-toolbar__group--find"
aria-label={t("topology.findNode")}
ref={findBoxRef}
>
<input
className="topo-toolbar__find"
value={canvasQuery}
onChange={(e) => setCanvasQuery(e.target.value)}
onFocus={() => {
if (canvasQuery.trim()) setFindOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!canvasHits.length) return;
setFindOpen(true);
setFindActiveIdx((i) => (i + 1) % canvasHits.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
if (!canvasHits.length) return;
setFindOpen(true);
setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length);
return;
}
if (e.key === "Escape") {
setFindOpen(false);
return;
}
if (e.key === "Enter") {
e.preventDefault();
findOnCanvas();
}
}}
placeholder={t("topology.findNodePh")}
disabled={!mapId || nodes.length === 0}
aria-label={t("topology.findNode")}
aria-autocomplete="list"
aria-expanded={findOpen}
/>
{findOpen && canvasQuery.trim() ? (
<div className="topo-find-suggest" role="listbox">
{canvasHits.length === 0 ? (
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
) : (
canvasHits.slice(0, 12).map((n, idx) => (
<button
key={n.id}
type="button"
role="option"
aria-selected={idx === findActiveIdx}
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
onMouseEnter={() => setFindActiveIdx(idx)}
onClick={() => findOnCanvas(n.id)}
>
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
<span className="topo-find-suggest__meta">
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
</span>
</button>
))
)}
{canvasHits.length > 12 ? (
<div className="topo-find-suggest__more">
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
) : null}
{discoverOpen ? (
<div className="topo-discover">
<div className="topo-discover__head">
<strong>{t("topology.discoverReport")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
if (discovering) {
return;
}
setDiscoverOpen(false);
}}
>
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
</button>
</div>
{discovering && discoverProgress.total > 0 ? (
<div className="topo-discover__progress" aria-live="polite">
<div className="topo-discover__bar">
<div className="topo-discover__bar-fill" style={{ width: `${discoverPct}%` }} />
</div>
<div className="topo-discover__progress-meta">
<span>
{t("topology.discoverProgressLive")
.replace("{{index}}", String(discoverProgress.index))
.replace("{{total}}", String(discoverProgress.total))
.replace(
"{{name}}",
discoverProgress.neName || discoverProgress.neIp || "?",
)}
</span>
<span>{discoverPct}%</span>
</div>
</div>
) : null}
{discovering && discoverProgress.total <= 0 ? (
<p className="panel__hint panel__hint--live">{t("topology.discoverNoTargets")}</p>
) : null}
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
) : null}
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
{discoverResults.length > 0 || discoverReport ? (
<>
<p className="topo-discover__summary">
{t("topology.discoverSummary")
.replace("{{scanned}}", String(discoverSummary.scanned))
.replace("{{added}}", String(discoverSummary.added))
.replace("{{updated}}", String(discoverSummary.updated))
.replace("{{stale}}", String(discoverSummary.missing))
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<p className="panel__hint">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() =>
openOrFocusModule({
moduleId: "network",
path: "/network/topology/lldp",
})
}
>
{t("topology.discoverGoLldp")}
</button>
</p>
</>
) : null}
</div>
) : null}
{!canvasMode ? (
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
<div className="topo-browser__head">
<div>
{activeRegion ? (
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
<span className="topo-breadcrumb__sep">/</span>
<span className="topo-breadcrumb__current">{regionDisplayName(activeRegion)}</span>
</div>
) : (
<strong>{titleText}</strong>
)}
<p className="topo-browser__sub">
{activeRegion
? t("topology.browserRegionSub").replace(
"{{count}}",
String(browseEntries.length),
)
: t("topology.browserRegionsSub").replace(
"{{count}}",
String(regions.length),
)}
</p>
</div>
<button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={() => setLiveSync((v) => !v)}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
</div>
{!activeRegion ? (
regions.length === 0 ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
>
{t("topology.newRegion")}
</button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{regions.map((region, idx) => (
<button
key={region.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
selectedFolderId === region.id ? " is-selected" : ""
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
onClick={() => goRegion(region.id)}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
<span className="topo-region-hex__meta">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views?.length || 0),
)}
</span>
</span>
</button>
))}
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
</button>
</div>
)
) : browseEntries.length === 0 ? (
<div className="topo-browser__grid topo-browser__grid--regions">
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{browseEntries.map((v, idx) => {
const isPhysical = String(v.kind) === "physical";
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
return (
<button
key={v.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${tone}${
mapId === v.id ? " is-selected" : ""
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
onClick={() => goCanvas(v.id, activeRegion.id)}
title={t("topology.openMap")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{v.name}</span>
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
</span>
</button>
);
})}
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
)}
</div>
) : (
<div
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${toolMode === "pan" ? " is-pan-mode" : ""}`}
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,
} as CSSProperties
}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>
{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={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })}
>
{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 ? (
<TopoDisplayContext.Provider value={displayOpts}>
<ReactFlow
nodes={nodes.map((n) =>
searchHitIds.includes(n.id)
? { ...n, className: "is-search-hit" }
: { ...n, className: undefined },
)}
edges={displayEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onlyRenderVisibleElements
connectionMode={ConnectionMode.Loose}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
proOptions={{ hideAttribution: true }}
minZoom={0.05}
maxZoom={4}
nodesDraggable={toolBehavior.nodesDraggable}
nodesConnectable={toolBehavior.nodesConnectable}
elementsSelectable={toolBehavior.elementsSelectable}
panOnDrag={toolBehavior.panOnDrag}
selectionOnDrag={toolBehavior.selectionOnDrag}
panOnScroll={toolBehavior.panOnScroll}
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode="Shift"
snapToGrid={snapToGrid}
snapGrid={SNAP_GRID}
onNodeDragStart={() => {
pushHistory();
}}
onNodesChange={(changes) => {
if (
changes.some(
(c) => c.type === "position" || c.type === "remove" || c.type === "add",
)
) {
markDirty();
}
onNodesChange(changes);
}}
onEdgesChange={(changes) => {
const physical = changes.filter((c) => {
if (c.type === "select") return false;
if ("id" in c && isAggregateEdgeId(String(c.id))) return false;
return true;
});
if (physical.some((c) => c.type !== "select")) {
markDirty();
}
if (physical.length) onEdgesChange(physical);
}}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onEdgeClick={(_e, edge) => {
setCtxMenu(null);
focusEdge(edge.id);
}}
onPaneClick={() => {
setCtxMenu(null);
clearSelection();
}}
onNodeContextMenu={(e, node) => {
e.preventDefault();
const multi = selectedNodeIds.length > 1 && selectedNodeIds.includes(node.id);
if (!multi) focusNode(node.id, false);
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu(
multi ? { kind: "selection", ...pos } : { kind: "node", id: node.id, ...pos },
);
}}
onEdgeContextMenu={(e, edge) => {
e.preventDefault();
focusEdge(edge.id);
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 260, h: 280 });
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
}}
onSelectionContextMenu={(e) => {
e.preventDefault();
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos });
}}
onMoveStart={closeCtxMenu}
onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
if (nodesRef.current.length > 0) {
window.setTimeout(() => inst.fitView({ ...FIT_VIEW_OPTS }), 0);
}
}}
fitView
fitViewOptions={{ ...FIT_VIEW_OPTS }}
deleteKeyCode={null}
edgesFocusable
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color={canvasDotColor(canvasBg)}
bgColor={canvasBg}
/>
{toolMode === "connect" ? (
<div className="topo-mode-hint" role="status">
{t("topology.connectHint")}
</div>
) : null}
<Controls showInteractive>
<ControlButton
className="topo-fs-control"
onClick={() => void toggleFullscreen()}
title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
>
<FullscreenIcon exit={fullscreen} />
</ControlButton>
</Controls>
<MiniMap pannable zoomable />
</ReactFlow>
</TopoDisplayContext.Provider>
) : (
<div className="topo-canvas__empty">{t("topology.treeLoading")}</div>
)}
</div>
)}
</main>
{addNeOpen && canvasMode ? (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
setAddNeOpen(false);
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={() => setAddNeOpen(false)}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => {
setPaletteSource("managed");
setPaletteSelectedKeys([]);
}}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => {
setPaletteSource("ume");
setPaletteSelectedKeys([]);
}}
>
{t("topology.paletteUme")}
</button>
</div>
<input
className="input"
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPaletteSelectedKeys([]);
}}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
setPaletteSelectedKeys(paletteVisible.map((item) => item.key));
return;
}
setPaletteSelectedKeys([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
setPaletteSelectedKeys((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
setPaletteSelectedKeys((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={() => setAddNeOpen(false)}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={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);
}}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</div>
) : null}
{ctxMenu ? (
<ul
className={`topo-ctx${ctxMenu.kind === "edge" ? " topo-ctx--edge" : ""}`}
style={{ left: ctxMenu.x, top: ctxMenu.y }}
role="menu"
onContextMenu={(e) => e.preventDefault()}
>
{ctxMenu.kind === "selection" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.selectionMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => removeSelected()}
>
{t("topology.removeSelected").replace("{{count}}", String(selectedNodes.length))}
</button>
</li>
</>
) : ctxMenu.kind === "node" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.nodeMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={renameSelectedNode}
>
{t("topology.renameNode")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering}
onClick={() => discoverOneFor(selectedNode)}
>
{t("topology.discoverOne")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => openWebcrtFor(selectedNode)}
>
{t("topology.openWebcrt")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => openNeFor(selectedNode)}
>
{t("topology.openNe")}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => removeNodeById(ctxMenu.id)}
>
{t("topology.removeNode")}
</button>
</li>
</>
) : (
<>
<li className="topo-ctx__section" role="none">
<div className="topo-ctx__style" onMouseDown={(e) => e.stopPropagation()}>
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
{selectedEdgeResolved ? (
<div className="topo-ctx__style-grid">
{Number(selectedEdgeData.member_count || 1) > 1 ? (
<div className="topo-ctx__style-members">
<div className="topo-ctx__style-title">
{t("topology.linkMembers").replace(
"{{count}}",
String(selectedEdgeData.member_count || 0),
)}
</div>
<ul className="topo-ctx__member-list">
{(selectedEdgeData.members || []).map((m) => (
<li key={m.id}>
{formatPortPairLabel(m.a_port, m.b_port) || m.id.slice(0, 8)}
</li>
))}
</ul>
{!expandPhysicalLinks ? (
<button
type="button"
className="btn btn--sm"
onClick={() => {
setExpandPhysicalLinks(true);
closeCtxMenu();
}}
>
{t("topology.expandPhysicalLinks")}
</button>
) : null}
</div>
) : null}
<label className="topo-ctx__style-row">
<span>
{(selectedEdgeSourceNode?.data.label ||
selectedEdgeSourceNode?.data.ne_ip ||
t("topology.endpointA")) +
" · " +
t("topology.port")}
</span>
<input
type="text"
className="topo-ctx__style-input"
value={selectedEdgeData.source_port || ""}
placeholder="?"
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
onFocus={() => pushHistory()}
onChange={(e) =>
patchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
}
/>
</label>
<label className="topo-ctx__style-row">
<span>
{(selectedEdgeTargetNode?.data.label ||
selectedEdgeTargetNode?.data.ne_ip ||
t("topology.endpointB")) +
" · " +
t("topology.port")}
</span>
<input
type="text"
className="topo-ctx__style-input"
value={selectedEdgeData.target_port || ""}
placeholder="?"
disabled={Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks}
onFocus={() => pushHistory()}
onChange={(e) =>
patchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })
}
/>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeColor")}</span>
<input
type="color"
value={selectedEdgeResolved.stroke}
onFocus={() => pushHistory()}
onChange={(e) =>
patchSelectedEdgeStyle({ stroke_color: e.target.value }, { skipHistory: true })
}
/>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeLineStyle")}</span>
<select
value={
selectedEdgeData.line_style ||
(selectedEdgeResolved.strokeDasharray ? "dashed" : "solid")
}
onChange={(e) => patchSelectedEdgeStyle({ line_style: e.target.value })}
>
<option value="solid">{t("topology.edgeLineSolid")}</option>
<option value="dashed">{t("topology.edgeLineDashed")}</option>
<option value="dotted">{t("topology.edgeLineDotted")}</option>
</select>
</label>
<label className="topo-ctx__style-row">
<span>{t("topology.edgeWidth")}</span>
<select
value={String(
Number(selectedEdgeData.stroke_width || 0) > 0
? Number(selectedEdgeData.stroke_width)
: Math.round(Number(selectedEdgeResolved.strokeWidth || 2)),
)}
onChange={(e) =>
patchSelectedEdgeStyle({ stroke_width: Number(e.target.value) || 0 })
}
>
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
<option key={w} value={w}>
{w}px
</option>
))}
</select>
</label>
</div>
) : null}
</div>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => openPortTrafficForEdge(selectedEdge)}
>
{t("topology.openPortTraffic")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() =>
patchSelectedEdgeStyle({ stroke_color: "", stroke_width: 0, line_style: "" })
}
>
{t("topology.edgeStyleReset")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item topo-ctx__item--danger"
role="menuitem"
onClick={() => removeEdgeById(ctxMenu.id)}
>
{t("topology.removeEdge")}
</button>
</li>
</>
)}
</ul>
) : null}
</div>
);
}