diff --git a/web/public/topo/ne-router.png b/web/public/topo/ne-router.png new file mode 100644 index 0000000..9d5ed53 Binary files /dev/null and b/web/public/topo/ne-router.png differ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 06125ff..3fce43c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -692,12 +692,14 @@ const en = { save: "Save", saving: "Saving…", saved: "Topology saved", - discover: "Discover links", + discover: "Discover all links", discovering: "Discovering…", + discoverOne: "Discover links", discovered: "Discovery done: +{{added}} updated {{updated}} missing {{stale}}", discoverFail: "Discovery failed: {{detail}}", + discoverOneNeedNe: "This node is not linked to a scannable NE", selectMap: "Select or create a topology map", - canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable)", + canvasHint: "Add NEs from the left; blue dashed=found, red dashed=missing (deletable); right-click a node for actions", selected: "Selected", selectedEdge: "Selected edge", openWebcrt: "Open terminal", @@ -709,6 +711,8 @@ const en = { edgeDiscovered: "Discovered", edgeStale: "Missing", fit: "Fit view", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", display: "Display", hideIp: "Hide IP", hideVendor: "Hide vendor", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1ef2cb5..b26ab7c 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -689,12 +689,14 @@ const zh = { save: "保存", saving: "保存中…", saved: "拓扑已保存", - discover: "发现链路", + discover: "发现全量链路", discovering: "发现中…", + discoverOne: "发现链路", discovered: "发现完成:新增 {{added}},更新 {{updated}},未发现 {{stale}}", discoverFail: "发现失败:{{detail}}", + discoverOneNeedNe: "该节点未关联可扫描网元", selectMap: "请选择或新建一张拓扑图", - canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删)", + canvasHint: "从左侧添加网元,拖动节点并连线;蓝虚线=发现链路,红虚线=本次未发现(可删);右键节点可操作", selected: "已选节点", selectedEdge: "已选链路", openWebcrt: "打开终端", @@ -706,6 +708,8 @@ const zh = { edgeDiscovered: "发现", edgeStale: "未发现", fit: "适应画布", + fullscreen: "全屏显示", + exitFullscreen: "退出全屏", display: "显示选项", hideIp: "隐藏 IP", hideVendor: "隐藏厂商", diff --git a/web/src/index.css b/web/src/index.css index 94888c6..55cadf6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -688,23 +688,30 @@ pre { vertical-align: middle; } +.app-main .help-hint__trigger, .help-hint__trigger { - width: 18px; - height: 18px; - padding: 0; - border: 1px solid #cbd5e1; - border-radius: 50%; - background: #f8fafc; + width: auto; + height: auto; + min-width: 0; + padding: 0 2px; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; color: #64748b; - font-size: 12px; + font-size: 13px; font-weight: 700; line-height: 1; cursor: pointer; } +.app-main .help-hint__trigger:hover, +.app-main .help-hint__trigger:focus-visible, .help-hint__trigger:hover, .help-hint__trigger:focus-visible { - border-color: #1565c0; + border: none; + background: transparent; + box-shadow: none; color: #1565c0; outline: none; } @@ -1931,6 +1938,7 @@ pre { flex-direction: column; gap: 8px; min-height: 0; + min-width: 0; } .topo-sidebar__section--grow { @@ -1983,28 +1991,84 @@ pre { padding: 0; overflow: auto; flex: 1; + min-width: 0; } -.topo-map-list li { +.topo-map-list li, +.topo-palette > li { + margin: 0 0 6px; + width: 100%; + min-width: 0; +} + +.topo-map-list li:last-child, +.topo-palette > li:last-child { + margin-bottom: 0; +} + +.topo-map-list__row, +.topo-palette__row { display: flex; - align-items: center; - gap: 4px; + align-items: stretch; + width: 100%; + box-sizing: border-box; + border: 1px solid #e2e8f0; border-radius: 8px; + background: #fff; + overflow: hidden; + min-width: 0; } -.topo-map-list li.is-active { +.topo-map-list li.is-active .topo-map-list__row { + border-color: #93c5fd; background: #e8f1fb; } +.topo-palette > li.is-on-canvas .topo-palette__row { + background: #f8fafc; + border-color: #e2e8f0; +} + .topo-map-list__item, .topo-palette__item { - flex: 1; + display: block; + flex: 1 1 auto; + width: 100%; + max-width: 100%; text-align: left; border: 0; background: transparent; - padding: 8px; + padding: 8px 10px; cursor: pointer; - border-radius: 8px; + border-radius: 0; + min-width: 0; + box-sizing: border-box; +} + +.app-main .topo-map-list__item, +.app-main .topo-map-list__icon, +.app-main .topo-palette__item { + border: none; + background: transparent; + border-radius: 0; + box-shadow: none; + width: auto; +} + +.app-main .topo-map-list__item, +.app-main .topo-palette__item { + display: block; + width: 100%; + padding: 8px 10px; +} + +.app-main .topo-map-list__icon { + width: 32px; + height: auto; + padding: 0; + color: #64748b; + font-size: 14px; + line-height: 1; } .topo-map-list__item:hover, @@ -2012,6 +2076,43 @@ pre { background: #f1f5f9; } +.topo-map-list li.is-active .topo-map-list__item:hover { + background: #dbeafe; +} + +.topo-map-list__actions { + display: flex; + align-items: stretch; + flex: 0 0 auto; + border-left: 1px solid #e2e8f0; +} + +.topo-map-list li.is-active .topo-map-list__actions { + border-left-color: #bfdbfe; +} + +.topo-map-list__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + min-height: 100%; + border: 0; + background: transparent; + color: #64748b; + cursor: pointer; +} + +.topo-map-list__icon:hover:not(:disabled) { + background: #f1f5f9; + color: #0f172a; +} + +.topo-map-list__icon:disabled { + opacity: 0.45; + cursor: default; +} + .topo-palette__item:disabled { opacity: 0.55; cursor: default; @@ -2019,6 +2120,8 @@ pre { .topo-palette__empty { padding: 8px; + box-sizing: border-box; + width: 100%; } .topo-map-list__name, @@ -2026,6 +2129,9 @@ pre { display: block; font-weight: 600; color: #0f172a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .topo-map-list__meta, @@ -2033,6 +2139,9 @@ pre { display: block; font-size: 12px; color: #64748b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .topo-main { @@ -2048,18 +2157,21 @@ pre { .topo-toolbar { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 12px; + padding: 8px 12px; border-bottom: 1px solid #e2e8f0; + flex-wrap: nowrap; } .topo-toolbar__left { display: flex; - flex-direction: column; - gap: 2px; + flex-direction: row; + align-items: center; + gap: 6px; min-width: 0; + flex: 1 1 auto; } .topo-toolbar__title { @@ -2069,16 +2181,23 @@ pre { min-width: 0; } +.topo-toolbar__title strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .topo-toolbar__actions { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 8px; + flex: 0 0 auto; } .topo-display-toggles { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; gap: 10px; margin-right: 4px; @@ -2095,20 +2214,49 @@ pre { white-space: nowrap; } -.topo-selection { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - background: #f8fafc; - border-bottom: 1px solid #e2e8f0; +.topo-ctx { + position: fixed; + z-index: 40; + min-width: 168px; + padding: 4px 0; + margin: 0; + list-style: none; + background: #0f172a; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 8px; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.28); } -.topo-selection__actions { - display: flex; - flex-wrap: wrap; - gap: 8px; +.topo-ctx__item { + display: block; + width: 100%; + padding: 8px 14px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; +} + +.topo-ctx__item:hover:not(:disabled) { + background: #1e293b; +} + +.topo-ctx__item:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.topo-ctx__item--danger:hover:not(:disabled) { + background: #7f1d1d; +} + +.topo-ctx__sep { + height: 1px; + margin: 4px 0; + background: #334155; } .topo-discover { @@ -2197,6 +2345,29 @@ pre { position: relative; flex: 1; min-height: 0; + background: #fff; +} + +.topo-canvas:fullscreen, +.topo-canvas.is-fullscreen:fullscreen { + width: 100vw; + height: 100vh; + background: #fff; +} + +/* Place fullscreen above zoom-in (+); Controls renders custom children last. */ +.topo-canvas .react-flow__controls { + display: flex; + flex-direction: column; +} + +.topo-canvas .react-flow__controls-button.topo-fs-control { + order: -1; +} + +.topo-canvas .react-flow__controls-button.topo-fs-control svg { + max-width: 14px; + max-height: 14px; } .topo-canvas__empty { @@ -2215,17 +2386,43 @@ pre { box-shadow: none; font-size: 11px; text-align: center; - --topo-vendor: #64748b; + /* Default asset is blue (= ZTE). */ + --topo-vendor: #0277bd; + --topo-icon-filter: none; } -.topo-node--cisco { --topo-vendor: #1d4ed8; } -.topo-node--huawei { --topo-vendor: #c62828; } -.topo-node--zte { --topo-vendor: #0277bd; } -.topo-node--juniper { --topo-vendor: #2e7d32; } -.topo-node--nokia { --topo-vendor: #00838f; } -.topo-node--ericsson { --topo-vendor: #f9a825; } -.topo-node--h3c { --topo-vendor: #ef6c00; } -.topo-node--other { --topo-vendor: #546e7a; } +.topo-node--cisco { + --topo-vendor: #1d4ed8; + --topo-icon-filter: hue-rotate(-8deg) saturate(1.15); +} +.topo-node--huawei { + --topo-vendor: #c62828; + --topo-icon-filter: hue-rotate(155deg) saturate(1.35); +} +.topo-node--zte { + --topo-vendor: #0277bd; + --topo-icon-filter: none; +} +.topo-node--juniper { + --topo-vendor: #2e7d32; + --topo-icon-filter: hue-rotate(95deg) saturate(1.2); +} +.topo-node--nokia { + --topo-vendor: #00838f; + --topo-icon-filter: hue-rotate(-28deg) saturate(1.1) brightness(0.98); +} +.topo-node--ericsson { + --topo-vendor: #f9a825; + --topo-icon-filter: hue-rotate(175deg) saturate(1.45) brightness(1.05); +} +.topo-node--h3c { + --topo-vendor: #ef6c00; + --topo-icon-filter: hue-rotate(165deg) saturate(1.4); +} +.topo-node--other { + --topo-vendor: #546e7a; + --topo-icon-filter: grayscale(0.85) brightness(0.95); +} .topo-node__glyph { position: relative; @@ -2252,14 +2449,20 @@ pre { transform: translate(-50%, -50%) !important; } -.topo-node.is-selected .topo-node__icon-body { - stroke: #0f172a; - stroke-width: 1.6; +.topo-node.is-selected .topo-node__glyph { + outline: 2px solid #2563eb; + outline-offset: 5px; + border-radius: 999px; +} + +.topo-node.is-selected .topo-node__icon { + transform: scale(1.08); + filter: var(--topo-icon-filter, none) drop-shadow(0 0 8px rgba(37, 99, 235, 0.55)); } .topo-node.is-selected .topo-node__caption { - color: #0f172a; - font-weight: 600; + color: #1d4ed8; + font-weight: 700; } .topo-node__icon { @@ -2267,30 +2470,11 @@ pre { width: 56px; height: 56px; margin: 0; + object-fit: contain; pointer-events: none; -} - -.topo-node__icon-shadow { - fill: rgba(15, 23, 42, 0.28); -} - -.topo-node__icon-rim { - fill: color-mix(in srgb, var(--topo-vendor, #64748b) 70%, #0f172a); -} - -.topo-node__icon-body { - fill: var(--topo-vendor, #64748b); - stroke: color-mix(in srgb, var(--topo-vendor, #64748b) 45%, #0f172a); - stroke-width: 1; -} - -.topo-node__icon-face { - fill: color-mix(in srgb, var(--topo-vendor, #64748b) 62%, #cbd5e1); -} - -.topo-node__icon-arrows { - fill: #ffffff; - stroke: none; + user-select: none; + filter: var(--topo-icon-filter, none); + transition: transform 0.12s ease, filter 0.12s ease; } /* Same plain-text style as edge port labels */ diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 7933bce..4ac565b 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -4,6 +4,7 @@ import { ReactFlow, Background, Controls, + ControlButton, MiniMap, addEdge, useEdgesState, @@ -31,6 +32,7 @@ import { updateTopologyMap, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; +import { HelpHint } from "../components/HelpHint"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows"; @@ -43,6 +45,27 @@ import type { UmeNeItem, } from "../types"; +function FullscreenIcon({ exit }: { exit?: boolean }) { + if (exit) { + return ( + + ); + } + return ( + + ); +} + type NeNodeData = { label: string; managed_ne_id: string; @@ -72,6 +95,10 @@ type TopoDisplayOpts = { hidePorts: boolean; }; +type CtxMenu = + | { kind: "node"; id: string; x: number; y: number } + | { kind: "edge"; id: string; x: number; y: number }; + const TopoDisplayContext = createContext({ hideIp: true, hideVendor: true, @@ -97,25 +124,16 @@ function vendorTone(vendor: string): string { return "other"; } -/** Classic Visio/Packet Tracer style router: disc + four outbound arrows. */ +/** Asset: /topo/ne-router.png (default blue = ZTE); other vendors tint via CSS. */ function RouterIcon() { return ( - + ); } @@ -223,6 +241,7 @@ export function TopologyPage() { const [keyword, setKeyword] = useState(""); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [ctxMenu, setCtxMenu] = useState(null); const [hideIp, setHideIp] = useState(true); const [hideVendor, setHideVendor] = useState(true); const [hidePorts, setHidePorts] = useState(true); @@ -243,10 +262,12 @@ export function TopologyPage() { edgesUpdated: 0, }); const [discoverError, setDiscoverError] = useState(""); + const [fullscreen, setFullscreen] = useState(false); const discoverAbortRef = useRef(null); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const rfRef = useRef, Edge> | null>(null); + const canvasRef = useRef(null); const dirtyRef = useRef(false); const displayOpts = useMemo( () => ({ hideIp, hideVendor, hidePorts }), @@ -369,84 +390,99 @@ export function TopologyPage() { onError: (err) => showError(String(err)), }); - const runDiscover = useCallback(async () => { - if (!mapId || discovering) return; - discoverAbortRef.current?.abort(); - const ac = new AbortController(); - discoverAbortRef.current = ac; - setDiscoverOpen(true); - setDiscovering(true); - setDiscoverReport(null); - setDiscoverLiveResults([]); - setDiscoverError(""); - setDiscoverProgress({ - index: 0, - total: nodes.filter((n) => Boolean(n.data.managed_ne_id)).length, - neName: "", - neIp: "", - edgesAdded: 0, - edgesUpdated: 0, - }); - try { - if (dirtyRef.current) { - await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)); - dirtyRef.current = false; + const runDiscover = useCallback( + async (neIds?: string[]) => { + if (!mapId || discovering) return; + const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean); + discoverAbortRef.current?.abort(); + const ac = new AbortController(); + discoverAbortRef.current = ac; + 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 = filterIds.length + ? scannable.filter( + (n) => + filterIds.includes(String(n.data.managed_ne_id || "")) || + filterIds.includes(String(n.data.ume_ne_id || "")), + ) + : scannable; + setDiscoverProgress({ + index: 0, + total: scoped.length, + neName: "", + neIp: "", + edgesAdded: 0, + edgesUpdated: 0, + }); + try { + if (dirtyRef.current) { + await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)); + dirtyRef.current = false; + } + const out = await discoverTopologyNeighborsStream( + mapId, + { + protocol: "auto", + ...(filterIds.length ? { ne_ids: filterIds } : {}), + }, + { + onStart: (ev) => { + setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 })); + }, + onNeStart: (ev) => { + setDiscoverProgress((p) => ({ + ...p, + index: ev.index, + total: ev.total, + neName: ev.ne_name || ev.ne_ip || ev.ne_id, + neIp: ev.ne_ip, + })); + }, + onNeResult: (ev) => { + if (ev.result) { + setDiscoverLiveResults((prev) => [...prev, ev.result]); + } + setDiscoverProgress((p) => ({ + ...p, + index: ev.index, + total: ev.total, + edgesAdded: ev.edges_added, + edgesUpdated: ev.edges_updated, + })); + }, + }, + ac.signal, + ); + if (out.graph) { + queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); + const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges); + setNodes(rfNodes); + setEdges(rfEdges); + dirtyRef.current = false; + } + setDiscoverReport(out); + await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); + 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) { + if (ac.signal.aborted) return; + setDiscoverError(String(err)); + showError(t("topology.discoverFail").replace("{{detail}}", String(err))); + } finally { + if (discoverAbortRef.current === ac) discoverAbortRef.current = null; + setDiscovering(false); } - const out = await discoverTopologyNeighborsStream( - mapId, - { protocol: "auto" }, - { - onStart: (ev) => { - setDiscoverProgress((p) => ({ ...p, total: ev.total, index: 0 })); - }, - onNeStart: (ev) => { - setDiscoverProgress((p) => ({ - ...p, - index: ev.index, - total: ev.total, - neName: ev.ne_name || ev.ne_ip || ev.ne_id, - neIp: ev.ne_ip, - })); - }, - onNeResult: (ev) => { - if (ev.result) { - setDiscoverLiveResults((prev) => [...prev, ev.result]); - } - setDiscoverProgress((p) => ({ - ...p, - index: ev.index, - total: ev.total, - edgesAdded: ev.edges_added, - edgesUpdated: ev.edges_updated, - })); - }, - }, - ac.signal, - ); - if (out.graph) { - queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph); - const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges); - setNodes(rfNodes); - setEdges(rfEdges); - dirtyRef.current = false; - } - setDiscoverReport(out); - await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); - 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) { - if (ac.signal.aborted) return; - setDiscoverError(String(err)); - showError(t("topology.discoverFail").replace("{{detail}}", String(err))); - } finally { - if (discoverAbortRef.current === ac) discoverAbortRef.current = null; - setDiscovering(false); - } - }, [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t]); + }, + [mapId, discovering, nodes, edges, queryClient, setNodes, setEdges, showOk, showError, t], + ); const discoverResults = discoverReport?.results?.length ? discoverReport.results @@ -558,10 +594,6 @@ export function TopologyPage() { () => nodes.find((n) => n.id === selectedNodeId) || null, [nodes, selectedNodeId], ); - const selectedEdge = useMemo( - () => edges.find((e) => e.id === selectedEdgeId) || null, - [edges, selectedEdgeId], - ); const staleEdgeCount = useMemo( () => edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale") @@ -569,18 +601,62 @@ export function TopologyPage() { [edges], ); - const removeSelected = () => { - if (selectedEdgeId) { - dirtyRef.current = true; - setEdges((es) => es.filter((e) => e.id !== selectedEdgeId)); - setSelectedEdgeId(null); - return; + 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({ padding: 0.15 }), 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)); } - if (!selectedNodeId) return; + }, [showError]); + + useEffect(() => { + if (!ctxMenu) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closeCtxMenu(); + }; + const onScroll = () => closeCtxMenu(); + window.addEventListener("keydown", onKey); + window.addEventListener("scroll", onScroll, true); + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("scroll", onScroll, true); + }; + }, [ctxMenu, closeCtxMenu]); + + const removeNodeById = (nodeId: string) => { dirtyRef.current = true; - setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId)); - setEdges((es) => es.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId)); - setSelectedNodeId(null); + setNodes((ns) => ns.filter((n) => n.id !== nodeId)); + setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId)); + setSelectedNodeId((cur) => (cur === nodeId ? null : cur)); + closeCtxMenu(); + }; + + const removeEdgeById = (edgeId: string) => { + dirtyRef.current = true; + setEdges((es) => es.filter((e) => e.id !== edgeId)); + setSelectedEdgeId((cur) => (cur === edgeId ? null : cur)); + closeCtxMenu(); }; const removeStaleEdges = () => { @@ -594,9 +670,10 @@ export function TopologyPage() { showOk(t("topology.staleRemoved").replace("{{count}}", String(n))); }; - const openWebcrt = () => { - const managedId = selectedNode?.data.managed_ne_id; - const umeId = selectedNode?.data.ume_ne_id; + const openWebcrtFor = (node: Node | null) => { + closeCtxMenu(); + const managedId = node?.data.managed_ne_id; + const umeId = node?.data.ume_ne_id; if (managedId) { openNewModuleWindow({ moduleId: "webcrt", @@ -614,9 +691,10 @@ export function TopologyPage() { showError(t("topology.noNeLink")); }; - const openNe = () => { - const managedId = selectedNode?.data.managed_ne_id; - const umeId = selectedNode?.data.ume_ne_id; + const openNeFor = (node: Node | null) => { + closeCtxMenu(); + const managedId = node?.data.managed_ne_id; + const umeId = node?.data.ume_ne_id; if (managedId) { openOrFocusModule({ moduleId: "managed-ne", @@ -634,6 +712,52 @@ export function TopologyPage() { showError(t("topology.noNeLink")); }; + const discoverOneFor = (node: Node | 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): { x: number; y: number } => { + const pad = 8; + const w = 180; + const h = 200; + 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 selectNodeOnly = useCallback( + (nodeId: string) => { + setSelectedEdgeId(null); + setSelectedNodeId(nodeId); + setNodes((ns) => ns.map((n) => ({ ...n, selected: n.id === nodeId }))); + setEdges((es) => es.map((e) => ({ ...e, selected: false }))); + }, + [setNodes, setEdges], + ); + + const selectEdgeOnly = useCallback( + (edgeId: string) => { + setSelectedNodeId(null); + setSelectedEdgeId(edgeId); + setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); + setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId }))); + }, + [setNodes, setEdges], + ); + + const clearSelection = useCallback(() => { + setSelectedNodeId(null); + setSelectedEdgeId(null); + setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); + setEdges((es) => es.map((e) => ({ ...e, selected: false }))); + }, [setNodes, setEdges]); + const maps = mapsQuery.data?.items || []; const onCanvasManagedIds = useMemo( () => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)), @@ -731,38 +855,42 @@ export function TopologyPage() {
    {maps.map((m) => (
  • - - - +
    + +
    + + +
    +
  • ))}
@@ -828,20 +956,22 @@ export function TopologyPage() { ? onCanvasManagedIds.has(item.managed_ne_id) : onCanvasUmeIds.has(item.ume_ne_id); return ( -
  • - +
  • +
    + +
  • ); }) @@ -857,22 +987,8 @@ export function TopologyPage() {
    {maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")} - {mapId ? ( - - ) : null} +
    - {t("topology.canvasHint")}
    @@ -945,41 +1061,6 @@ export function TopologyPage() {
    - {selectedNode ? ( -
    - - {t("topology.selected")}: {selectedNode.data.label} - {selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""} - -
    - - - -
    -
    - ) : selectedEdge ? ( -
    - - {t("topology.selectedEdge")}:{" "} - - {String((selectedEdge.data as { source?: string } | undefined)?.source || "manual")} - - {selectedEdge.label ? ` · ${String(selectedEdge.label)}` : ""} - -
    - -
    -
    - ) : null} - {discoverOpen ? (
    @@ -1047,7 +1128,7 @@ export function TopologyPage() {
    ) : null} -
    +
    {mapId ? ( { - dirtyRef.current = true; + if (changes.some((c) => c.type !== "select")) dirtyRef.current = true; onNodesChange(changes); }} onEdgesChange={(changes) => { - dirtyRef.current = true; + if (changes.some((c) => c.type !== "select")) dirtyRef.current = true; onEdgesChange(changes); }} onConnect={onConnect} onNodeClick={(_e, node) => { - setSelectedEdgeId(null); - setSelectedNodeId(node.id); + setCtxMenu(null); + selectNodeOnly(node.id); }} onEdgeClick={(_e, edge) => { - setSelectedNodeId(null); - setSelectedEdgeId(edge.id); + setCtxMenu(null); + selectEdgeOnly(edge.id); }} onPaneClick={() => { - setSelectedNodeId(null); - setSelectedEdgeId(null); + setCtxMenu(null); + clearSelection(); }} + onNodeContextMenu={(e, node) => { + e.preventDefault(); + selectNodeOnly(node.id); + const pos = placeCtxMenu(e.clientX, e.clientY); + setCtxMenu({ kind: "node", id: node.id, ...pos }); + }} + onEdgeContextMenu={(e, edge) => { + e.preventDefault(); + selectEdgeOnly(edge.id); + const pos = placeCtxMenu(e.clientX, e.clientY); + setCtxMenu({ kind: "edge", id: edge.id, ...pos }); + }} + onMoveStart={closeCtxMenu} onInit={(inst) => { rfRef.current = inst; }} @@ -1086,7 +1180,16 @@ export function TopologyPage() { elementsSelectable > - + + void toggleFullscreen()} + title={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")} + aria-label={fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")} + > + + + @@ -1095,6 +1198,73 @@ export function TopologyPage() { )}
    + + {ctxMenu ? ( +
      e.preventDefault()} + > + {ctxMenu.kind === "node" ? ( + <> +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • +
    • + +
    • + + ) : ( +
    • + +
    • + )} +
    + ) : null}
    ); }