netx/web/src/pages/topology/TopologyReactFlowView.tsx
oliver 497a5e048e Add topology situational overlays and harden live sync.
Show connect/alarm/level on the canvas with display toggles, refresh overlay fields even on dirty edits, fix connect-test dots and create-place messaging, and deep-link node alarms into the network alarms page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:13:15 +08:00

576 lines
19 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, useContext, type Dispatch, type SetStateAction } from "react";
import {
ReactFlow,
Background,
BackgroundVariant,
Controls,
ControlButton,
MiniMap,
Handle,
Position,
ConnectionLineType,
ConnectionMode,
SelectionMode,
applyNodeChanges,
applyEdgeChanges,
type Connection,
type Edge,
type Node,
type NodeChange,
type EdgeChange,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
import { ParallelEdge } from "./ParallelEdge";
import { isAggregateEdgeId } from "./linkDisplay";
import type { ToolMode, ToolModeBehavior } from "./toolMode";
import type { TopologyViewRole, TopologyWorldTransform } from "../../types";
import { isPlaceholderSource } from "./searchUtils";
import { nodeIconTone } from "./vendorTone";
import { levelToneKey } from "./levelTone";
import type { NodeAlarmSummary } from "./alarmOverlay";
export { isPlaceholderSource };
export type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: string;
connect_status: string;
managed_source?: string;
/** Fabric classify level (from graph). */
level?: number | null;
/** Fabric role alias (external|core|aggregation|access|edge). */
fabric_role?: string;
kind?: "ne" | "region" | "layer";
folder_id?: string;
view_id?: string;
role?: TopologyViewRole | string;
subtitle?: string;
node_count?: number;
};
export type TopoDisplayOpts = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
connectMode: boolean;
showPlaceholderBadge: boolean;
showConnectStatus: boolean;
showAlarmOverlay: boolean;
showLevelColors: boolean;
worldVisualLod: "dot" | "pin" | "full";
};
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
connectMode: false,
showPlaceholderBadge: false,
showConnectStatus: true,
showAlarmOverlay: true,
showLevelColors: true,
worldVisualLod: "full",
});
const TopoAlarmContext = createContext<ReadonlyMap<string, NodeAlarmSummary>>(new Map());
const emptyAlarmMap: ReadonlyMap<string, NodeAlarmSummary> = new Map();
const SEP = " / ";
function normalizeConnectStatus(raw: string | undefined): "pass" | "fail" | "testing" | "unknown" {
const s = String(raw || "").trim().toLowerCase();
if (s === "pass" || s === "ok") return "pass";
if (s === "fail" || s === "error") return "fail";
if (s === "testing") return "testing";
return "unknown";
}
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>
);
}
function RegionCanvasIcon() {
return (
<span className="topo-node__icon topo-node__icon--region" aria-hidden="true">
<img className="topo-node__icon-art" src="/topo/region-building.png" alt="" draggable={false} />
</span>
);
}
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>
);
}
const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNodeData>>) {
const {
hideIp,
hideVendor,
connectMode,
showPlaceholderBadge,
showConnectStatus,
showAlarmOverlay,
showLevelColors,
worldVisualLod,
} = useContext(TopoDisplayContext);
const alarmMap = useContext(TopoAlarmContext);
const isRegion = data.kind === "region";
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
const showBadge = placeholder && showPlaceholderBadge;
const connect = normalizeConnectStatus(data.connect_status);
const levelKey = levelToneKey(data.level, data.fabric_role);
const alarm = !isRegion && showAlarmOverlay ? alarmMap.get(id) : undefined;
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
const secondary = isRegion
? []
: [
hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
hideVendor || !data.vendor ? "" : data.vendor,
].filter(Boolean);
const statusClass = [
`topo-node--${tone}`,
selected ? "is-selected" : "",
connectMode && !isRegion ? "is-connect-mode" : "",
showBadge ? "is-placeholder" : "",
isRegion ? "is-region" : "",
showConnectStatus && !isRegion ? `is-connect-${connect}` : "",
showLevelColors && !isRegion && levelKey ? `is-level-${levelKey}` : "",
alarm ? `has-alarm has-alarm--${alarm.severity}` : "",
]
.filter(Boolean)
.join(" ");
if (!isRegion && worldVisualLod === "dot") {
return (
<div className={`topo-node topo-node--dot ${statusClass}`} title={name}>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pixel" aria-hidden="true" />
</div>
);
}
if (!isRegion && worldVisualLod === "pin") {
return (
<div className={`topo-node topo-node--pin ${statusClass}`} title={name}>
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
<span className="topo-node__pin" aria-hidden="true" />
</div>
);
}
return (
<div
className={`topo-node ${statusClass}`}
title={
alarm
? `${name} · alarm ${alarm.severity}×${alarm.count}`
: isRegion
? name
: showBadge
? data.managed_source || "placeholder"
: name
}
>
<div className="topo-node__glyph">
<Handle
type="target"
position={Position.Left}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
<Handle
type="source"
position={Position.Right}
className="topo-node__handle topo-node__handle--center"
isConnectable={connectMode && !isRegion}
/>
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
{showConnectStatus && !isRegion ? (
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
) : null}
{alarm ? (
<span className={`topo-node__alarm topo-node__alarm--${alarm.severity}`} aria-hidden>
{alarm.count > 99 ? "99+" : alarm.count}
</span>
) : showBadge ? (
<span className="topo-node__badge" aria-hidden>
{String(data.managed_source || "ph").slice(0, 4)}
</span>
) : null}
</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 };
const SNAP_GRID: [number, number] = [16, 16];
const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
function canvasBgRgb(bg: string): { r: number; g: number; b: number } | null {
const hex = String(bg || "").replace("#", "").trim();
if (hex.length !== 6) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
if (![r, g, b].every((n) => Number.isFinite(n))) return null;
return { r, g, b };
}
function canvasDotColor(bg: string): string {
const rgb = canvasBgRgb(bg);
if (!rgb) return "#93c5fd";
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return lum > 0.55 ? "#93c5fd" : "#64748b";
}
/** MiniMap colors follow the canvas bg so a light map doesn't get a dark inset. */
function minimapTheme(bg: string): {
bgColor: string;
nodeColor: string;
nodeStrokeColor: string;
maskColor: string;
maskStrokeColor: string;
} {
const rgb = canvasBgRgb(bg) || { r: 11, g: 18, b: 32 };
const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
const light = lum > 0.55;
return {
bgColor: bg || (light ? "#e8eef6" : "#0b1220"),
nodeColor: "#3b82f6",
nodeStrokeColor: light ? "#93c5fd" : "#1e3a5f",
maskColor: light ? "rgba(15, 23, 42, 0.14)" : "rgba(2, 8, 23, 0.62)",
maskStrokeColor: light ? "rgba(59, 130, 246, 0.55)" : "rgba(148, 163, 184, 0.45)",
};
}
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
x: number;
y: number;
width: number;
height: number;
} | null {
if (!wt) return null;
const scale = Number(wt.scale) || 1;
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
return { x: 0, y: 0, width, height };
}
type CtxMenuLike =
| { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; id: string; x: number; y: number }
| { kind: "selection"; x: number; y: number }
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
export type TopologyReactFlowViewProps = {
displayOpts: TopoDisplayOpts;
alarmByNodeId?: ReadonlyMap<string, NodeAlarmSummary>;
nodes: Node<NeNodeData>[];
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
edges: Edge[];
setEdges: Dispatch<SetStateAction<Edge[]>>;
displayEdges: Edge[];
searchHitIds: string[];
isWorldFlatCanvas: boolean;
worldVisualLod: "dot" | "pin" | "full";
toolBehavior: ToolModeBehavior;
toolMode: ToolMode;
snapToGrid: boolean;
canvasBg: string;
worldScatter: WorldScatterPoint[];
showWorldScatter: boolean;
fullscreen: boolean;
connectHint: string;
fullscreenLabel: string;
exitFullscreenLabel: string;
worldTransform: TopologyWorldTransform | null | undefined;
nodesRef: { current: Node<NeNodeData>[] };
rfRef: { current: ReactFlowInstance<Node<NeNodeData>, Edge> | null };
pendingFitRef: { current: boolean };
markDirty: () => void;
pushHistory: () => void;
onConnect: (connection: Connection) => void;
isValidConnection: (connection: Connection | Edge) => boolean;
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
onNodeDoubleClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
focusEdge: (id: string) => void;
focusNode: (id: string, additive: boolean) => void;
clearSelection: () => void;
setCtxMenu: (menu: CtxMenuLike | null) => void;
placeCtxMenu: (
x: number,
y: number,
size?: { w?: number; h?: number },
) => { x: number; y: number };
selectedNodeIds: string[];
closeCtxMenu: () => void;
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
readOnly?: boolean;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
const {
displayOpts,
alarmByNodeId,
nodes,
setNodes,
setEdges,
displayEdges,
searchHitIds,
isWorldFlatCanvas,
worldVisualLod,
toolBehavior,
toolMode,
snapToGrid,
canvasBg,
worldScatter,
showWorldScatter,
fullscreen,
connectHint,
fullscreenLabel,
exitFullscreenLabel,
worldTransform,
nodesRef,
rfRef,
pendingFitRef,
markDirty,
pushHistory,
onConnect,
isValidConnection,
onNodeClick,
onNodeDoubleClick,
focusEdge,
focusNode,
clearSelection,
setCtxMenu,
placeCtxMenu,
selectedNodeIds,
closeCtxMenu,
setCanvasZoom,
scheduleFlatViewportRefresh,
toggleFullscreen,
} = props;
return (
<TopoDisplayContext.Provider value={displayOpts}>
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
<ReactFlow
nodes={
isWorldFlatCanvas && worldVisualLod !== "full"
? []
: nodes.map((n) => {
const isRegion = n.data.kind === "region";
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
const sized =
!isRegion && lod === "dot"
? { ...n, width: 6, height: 6 }
: !isRegion && lod === "pin"
? { ...n, width: 14, height: 14 }
: n;
return searchHitIds.includes(n.id)
? { ...sized, selected: true, className: "is-search-hit" }
: { ...sized, className: undefined };
})
}
edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onlyRenderVisibleElements={isWorldFlatCanvas ? worldVisualLod === "dot" : true}
connectionMode={ConnectionMode.Loose}
connectionLineType={ConnectionLineType.Straight}
connectionLineStyle={{ stroke: "#38bdf8", strokeWidth: 2 }}
defaultEdgeOptions={{ type: "straight", labelShowBg: false }}
proOptions={{ hideAttribution: true }}
// Composed metro canvases can span 40k–70k; default 0.05 clips fitView.
minZoom={isWorldFlatCanvas || nodes.length >= 400 ? 0.002 : 0.05}
maxZoom={isWorldFlatCanvas ? 12 : 4}
nodesDraggable={
isWorldFlatCanvas
? worldVisualLod === "full" && toolBehavior.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: NodeChange<Node<NeNodeData>>[]) => {
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
markDirty();
}
setNodes((nds) => applyNodeChanges(changes, nds));
}}
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
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) setEdges((eds) => applyEdgeChanges(physical, eds));
}}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
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);
// Modest estimate; useLayoutEffect measures real height and clamps into state.
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 280, h: 320 });
setCtxMenu({ kind: "edge", id: edge.id, ...pos });
}}
onSelectionContextMenu={(e) => {
e.preventDefault();
const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos });
}}
onPaneContextMenu={(e) => {
e.preventDefault();
if (!rfRef.current) return;
const flow = rfRef.current.screenToFlowPosition({
x: e.clientX,
y: e.clientY,
});
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
setCtxMenu({
kind: "pane",
...pos,
flowX: flow.x,
flowY: flow.y,
});
}}
onMoveStart={closeCtxMenu}
onMove={(_e, vp) => {
if (isWorldFlatCanvas) setCanvasZoom(vp.zoom);
}}
onMoveEnd={(_e, vp) => {
if (!isWorldFlatCanvas) return;
setCanvasZoom(vp.zoom);
scheduleFlatViewportRefresh();
}}
onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
if (pendingFitRef.current) {
pendingFitRef.current = false;
window.requestAnimationFrame(() => {
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
if (bounds) {
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
} else if (nodesRef.current.length > 0) {
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
}
setCanvasZoom(inst.getZoom());
});
}
}}
fitView={false}
deleteKeyCode={null}
edgesFocusable
>
<WorldScatterLayer
points={worldScatter}
mode={worldVisualLod === "pin" ? "pin" : "dot"}
visible={showWorldScatter}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color={canvasDotColor(canvasBg)}
bgColor={canvasBg}
/>
{toolMode === "connect" ? (
<div className="topo-mode-hint" role="status">
{connectHint}
</div>
) : null}
<Controls showInteractive>
<ControlButton
className="topo-fs-control"
onClick={() => void toggleFullscreen()}
title={fullscreen ? exitFullscreenLabel : fullscreenLabel}
aria-label={fullscreen ? exitFullscreenLabel : fullscreenLabel}
>
<FullscreenIcon exit={fullscreen} />
</ControlButton>
</Controls>
{!isWorldFlatCanvas ? (
<MiniMap
pannable
zoomable
{...minimapTheme(canvasBg)}
nodeStrokeWidth={1}
maskStrokeWidth={1}
/>
) : null}
</ReactFlow>
</TopoAlarmContext.Provider>
</TopoDisplayContext.Provider>
);
}