diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index 62d778a..d1365c6 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -550,3 +550,51 @@ def _kv(text: str, pattern: str) -> str: if not m: return "" return str(m.group(1) or "").strip() + + +# Long media names → short canonical form (case-insensitive prefix). +_IFNAME_PREFIXES: tuple[tuple[str, str], ...] = ( + ("tengigabitethernet", "te"), + ("ten-gigabitethernet", "te"), + ("gigabitethernet", "gi"), + ("fastethernet", "fa"), + ("ethernet", "eth"), + ("xgigabitethernet", "xge"), + ("100ge", "100ge"), + ("40ge", "40ge"), + ("25ge", "25ge"), + ("10ge", "10ge"), + ("ge-trunk", "ge-trunk"), + ("eth-trunk", "eth-trunk"), + ("port-channel", "po"), + ("portchannel", "po"), + ("loopback", "lo"), + ("vlanif", "vlanif"), + ("vlan", "vlan"), + ("mgmteth", "mgmt"), + ("management", "mgmt"), + ("hundredgige", "hu"), + ("fiftygige", "fi"), + ("fortygige", "fo"), + ("twentyfivegige", "twe"), + ("twogigabitethernet", "tw"), +) + + +def normalize_ifname(name: str) -> str: + """Canonicalize interface names so Gi0/0 and GigabitEthernet0/0 share a key.""" + raw = str(name or "").strip() + if not raw: + return "" + s = re.sub(r"\s+", "", raw).lower() + s = s.replace("_", "/") + for long, short in _IFNAME_PREFIXES: + if s.startswith(long): + rest = s[len(long) :] + if rest.startswith((":", "/", "-")) or rest == "" or rest[0].isdigit(): + if rest.startswith(":"): + rest = rest[1:] + return f"{short}{rest}" + break + # Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1 + return s diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index c114efa..512d477 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -34,6 +34,7 @@ class TopologyNodeIn(BaseModel): label: str = "" x: float = 0.0 y: float = 0.0 + created_at: datetime | None = None class TopologyEdgeIn(BaseModel): @@ -46,6 +47,8 @@ class TopologyEdgeIn(BaseModel): stroke_color: str = Field(default="", max_length=32) stroke_width: int = Field(default=0, ge=0, le=12) line_style: str = Field(default="", max_length=16) + discovered_at: datetime | None = None + created_at: datetime | None = None class TopologyNodeOut(BaseModel): diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 13ea610..81a5494 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE from .ne_exec import execute_managed_ne_commands -from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command +from .topology_lldp import NeighborHit, normalize_ifname, parse_neighbor_output, pick_neighbor_command from .topology_schemas import ( TopologyDiscoverNeResult, TopologyDiscoverOut, @@ -251,6 +251,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph tid = str(e.target_node_id or "").strip() if sid not in node_ids or tid not in node_ids: raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes") + if sid == tid: + raise HTTPException(status_code=400, detail="edge_self_loop") src = str(e.source or "manual").strip().lower() or "manual" if src not in {"manual", "lldp", "cdp", "stale"}: raise HTTPException(status_code=400, detail="invalid_edge_source") @@ -262,27 +264,47 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph normalized_edges.append((e, src, color, width, line)) now = _utcnow() + prev_nodes = { + str(n.id): {"created_at": n.created_at} + for n in db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all() + } + prev_edges = { + str(e.id): {"created_at": e.created_at, "discovered_at": e.discovered_at} + for e in db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all() + } db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False) db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False) + db.expire_all() for n in nodes_in: + nid = str(n.id).strip() + prev = prev_nodes.get(nid) or {} + created = getattr(n, "created_at", None) or prev.get("created_at") or now db.add( TopologyNode( - id=str(n.id).strip(), + id=nid, map_id=row.id, managed_ne_id=str(n.managed_ne_id or "").strip(), ume_ne_id=str(n.ume_ne_id or "").strip(), label=str(n.label or "").strip()[:256], x=float(n.x or 0), y=float(n.y or 0), - created_at=now, + created_at=created, updated_at=now, ) ) for e, src, color, width, line in normalized_edges: + eid = str(e.id).strip() or uuid4().hex + prev = prev_edges.get(eid) or {} + created = getattr(e, "created_at", None) or prev.get("created_at") or now + client_discovered = getattr(e, "discovered_at", None) + if src in {"lldp", "cdp", "stale"}: + discovered = client_discovered or prev.get("discovered_at") or now + else: + discovered = None db.add( TopologyEdge( - id=str(e.id).strip() or uuid4().hex, + id=eid, map_id=row.id, source_node_id=str(e.source_node_id).strip(), target_node_id=str(e.target_node_id).strip(), @@ -292,8 +314,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph stroke_color=color, stroke_width=width, line_style=line, - discovered_at=now if src in {"lldp", "cdp", "stale"} else None, - created_at=now, + discovered_at=discovered, + created_at=created, updated_at=now, ) ) @@ -345,9 +367,11 @@ def _match_neighbor_to_node( def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]: + lp = normalize_ifname(local_port) + rp = normalize_ifname(remote_port) if a <= b: - return (a, b, local_port, remote_port) - return (b, a, remote_port, local_port) + return (a, b, lp, rp) + return (b, a, rp, lp) def _discover_target_for_node( diff --git a/tests/test_topology.py b/tests/test_topology.py index 6529fb2..1b8fd03 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -323,6 +323,75 @@ class TopologyServiceTests(unittest.TestCase): with self.assertRaises(HTTPException): svc.get_graph(self.db, mid) + def test_put_preserves_discovered_at_and_rejects_self_loop(self) -> None: + created = svc.create_map(self.db, TopologyMapCreate(name="Preserve", remark="")) + mid = created.id + first = svc.put_graph( + self.db, + mid, + TopologyGraphPut( + nodes=[ + TopologyNodeIn(id="n1", label="A", x=0, y=0), + TopologyNodeIn(id="n2", label="B", x=10, y=0), + ], + edges=[ + TopologyEdgeIn( + id="e1", + source_node_id="n1", + target_node_id="n2", + source="lldp", + source_port="Gi0/0", + target_port="Gi0/1", + ) + ], + ), + ) + discovered = first.edges[0].discovered_at + self.assertIsNotNone(discovered) + + second = svc.put_graph( + self.db, + mid, + TopologyGraphPut( + nodes=[ + TopologyNodeIn(id="n1", label="A", x=1, y=1), + TopologyNodeIn(id="n2", label="B", x=11, y=1), + ], + edges=[ + TopologyEdgeIn( + id="e1", + source_node_id="n1", + target_node_id="n2", + source="lldp", + source_port="Gi0/0", + target_port="Gi0/1", + stroke_color="#0ea5e9", + ) + ], + ), + ) + self.assertEqual(second.edges[0].discovered_at, discovered) + self.assertEqual(second.edges[0].stroke_color, "#0ea5e9") + + with self.assertRaises(HTTPException) as ctx: + svc.put_graph( + self.db, + mid, + TopologyGraphPut( + nodes=[TopologyNodeIn(id="n1", label="A", x=0, y=0)], + edges=[ + TopologyEdgeIn( + id="bad", + source_node_id="n1", + target_node_id="n1", + source="manual", + ) + ], + ), + ) + self.assertEqual(ctx.exception.detail, "edge_self_loop") + svc.delete_map(self.db, mid) + def test_discover_matches_by_name_and_skips_manual(self) -> None: suffix = uuid4().hex[:8] ne_a_id = f"nea-{suffix}" @@ -528,5 +597,20 @@ class TopologyServiceTests(unittest.TestCase): self.db.commit() +class IfnameNormalizeTests(unittest.TestCase): + def test_cisco_huawei_aliases(self) -> None: + self.assertEqual(lldp.normalize_ifname("GigabitEthernet0/0"), "gi0/0") + self.assertEqual(lldp.normalize_ifname("Gi0/0"), "gi0/0") + self.assertEqual(lldp.normalize_ifname("TenGigabitEthernet1/0/1"), "te1/0/1") + self.assertEqual(lldp.normalize_ifname("XGigabitEthernet0/0/1"), "xge0/0/1") + self.assertEqual(lldp.normalize_ifname("Eth-Trunk1"), "eth-trunk1") + self.assertEqual(lldp.normalize_ifname(""), "") + + def test_edge_pair_key_aliases_match(self) -> None: + a = svc._edge_pair_key("n1", "n2", "GigabitEthernet0/0", "Gi0/1") + b = svc._edge_pair_key("n1", "n2", "Gi0/0", "GigabitEthernet0/1") + self.assertEqual(a, b) + + if __name__ == "__main__": unittest.main() diff --git a/web/WEB.md b/web/WEB.md index 754b7a1..b09017b 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -124,7 +124,8 @@ src/ - 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标 - 支持对齐、网格吸附、轻量撤销/重做、链路图例 - 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线) -- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式在右键菜单中调整 +- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名 +- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化 ## WebCRT diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1b9a1f1..fc92f5d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -891,6 +891,8 @@ const en = { paletteHint: "Click or drag onto the canvas", filterPh: "Filter name / IP", save: "Save", + saveDirty: "Save *", + unsavedConfirm: "You have unsaved changes. Discard and switch maps?", saving: "Saving…", saved: "Topology saved", discover: "Discover all links", @@ -900,6 +902,10 @@ const en = { discoverFail: "Discovery failed: {{detail}}", discoverOneNeedNe: "This node is not linked to a scannable NE", selectMap: "Select or create a topology map", + renameNode: "Rename", + renameNodePrompt: "Node display name", + sourcePort: "Local port", + targetPort: "Remote port", canvasHint: "Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.", selected: "Selected", @@ -973,6 +979,7 @@ const en = { paletteLoading: "Loading…", discoverReport: "Discovery progress", discoverClose: "Close", + discoverCancel: "Cancel discovery", discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…", discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}", discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3e4faed..16292c4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -888,6 +888,8 @@ const zh = { paletteHint: "点击或拖到画布添加", filterPh: "筛选名称 / IP", save: "保存", + saveDirty: "保存 *", + unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?", saving: "保存中…", saved: "拓扑已保存", discover: "发现全量链路", @@ -897,6 +899,10 @@ const zh = { discoverFail: "发现失败:{{detail}}", discoverOneNeedNe: "该节点未关联可扫描网元", selectMap: "请选择或新建一张拓扑图", + renameNode: "重命名", + renameNodePrompt: "网元显示名称", + sourcePort: "本端端口", + targetPort: "对端端口", canvasHint: "工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。", selected: "已选节点", @@ -970,6 +976,7 @@ const zh = { paletteLoading: "加载中…", discoverReport: "发现进度", discoverClose: "关闭", + discoverCancel: "取消发现", discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…", discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}", discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}", diff --git a/web/src/index.css b/web/src/index.css index a502b76..bae3df5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2578,7 +2578,8 @@ pre { border-radius: 2px; } -.topo-ctx__style-row select { +.topo-ctx__style-row select, +.topo-ctx__style-input { flex: 1 1 auto; max-width: 108px; height: 24px; @@ -2591,6 +2592,11 @@ pre { padding: 0 6px; } +.topo-ctx__style-input { + max-width: 120px; + min-width: 0; +} + /* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */ .app-main .topo-ctx__item, .topo-ctx__item { diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index c010bd6..c861e3a 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -184,7 +184,7 @@ function NeNode({ data, selected }: NodeProps>) { /> -
{bits.join(" · ")}
+
{bits.join(" ? ")}
); } @@ -198,6 +198,7 @@ type EdgeStyleData = { stroke_color?: string; stroke_width?: number; line_style?: string; + discovered_at?: string | null; }; type EdgeLineStyle = "solid" | "dashed" | "dotted"; @@ -319,7 +320,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau })); const rfEdges: Edge[] = edges.map((e) => { const src = e.source || "manual"; - const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ "); + const label = [e.source_port, e.target_port].filter(Boolean).join(" ? "); const data: EdgeStyleData = { source: src, source_port: e.source_port || "", @@ -327,6 +328,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau 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( { @@ -366,6 +368,7 @@ function flowToGraphPayload(nodes: Node[], edges: Edge[]) { stroke_color: String(data.stroke_color || ""), stroke_width: Number(data.stroke_width || 0), line_style: String(data.line_style || ""), + discovered_at: data.discovered_at ?? null, }; }), }; @@ -377,6 +380,9 @@ export function TopologyPage() { const queryClient = useQueryClient(); const [mapId, setMapId] = useState(""); const [keyword, setKeyword] = useState(""); + const [debouncedKeyword, setDebouncedKeyword] = useState(""); + const [dirty, setDirty] = useState(false); + const [historyTick, setHistoryTick] = useState(0); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [hideIp, setHideIp] = useState(true); @@ -414,6 +420,52 @@ export function TopologyPage() { const redoRef = useRef([]); const historyLockRef = useRef(false); const connectClickRef = useRef(null); + const canUndo = historyTick >= 0 && historyRef.current.length > 0; + const canRedo = historyTick >= 0 && redoRef.current.length > 0; + + 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()), 300); + 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" }), @@ -441,10 +493,10 @@ export function TopologyPage() { }); const neQuery = useQuery({ - queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword], + queryKey: [...queryKeys.managedNeAll, "topology-palette", debouncedKeyword], queryFn: () => fetchManagedNe({ - keyword: keyword.trim(), + keyword: debouncedKeyword, vendor: "", connectStatus: "", page: 1, @@ -454,10 +506,10 @@ export function TopologyPage() { }); const umeQuery = useQuery({ - queryKey: ["umeInventoryNe", "topology-palette", keyword], + queryKey: ["umeInventoryNe", "topology-palette", debouncedKeyword], queryFn: () => fetchUmeNe({ - keyword: keyword.trim(), + keyword: debouncedKeyword, page: 1, pageSize: 100, }), @@ -478,7 +530,8 @@ export function TopologyPage() { setEdges(rfEdges); historyRef.current = []; redoRef.current = []; - dirtyRef.current = false; + clearDirty(); + bumpHistory(); historyLockRef.current = false; window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50); // edgeDefaults applied separately so changing defaults won't reload the whole graph. @@ -520,7 +573,8 @@ export function TopologyPage() { }, ]; redoRef.current = []; - }, [nodes, edges]); + bumpHistory(); + }, [nodes, edges, bumpHistory]); const undo = useCallback(() => { const prev = historyRef.current.pop(); @@ -532,9 +586,10 @@ export function TopologyPage() { historyLockRef.current = true; setNodes(prev.nodes); setEdges(prev.edges); - dirtyRef.current = true; + markDirty(); + bumpHistory(); historyLockRef.current = false; - }, [nodes, edges, setNodes, setEdges]); + }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]); const redo = useCallback(() => { const next = redoRef.current.pop(); @@ -546,9 +601,10 @@ export function TopologyPage() { historyLockRef.current = true; setNodes(next.nodes); setEdges(next.edges); - dirtyRef.current = true; + markDirty(); + bumpHistory(); historyLockRef.current = false; - }, [nodes, edges, setNodes, setEdges]); + }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]); const applyLayout = useCallback( async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => { @@ -563,19 +619,19 @@ export function TopologyPage() { pushHistory(); const next = layoutGraph(nodes, edges, kind, { onlyIds }); setNodes(next); - dirtyRef.current = true; + markDirty(); window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40); if (opts?.persist && mapId) { try { const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges)); - dirtyRef.current = false; + clearDirty(); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); } catch (err) { showError(String(err)); } } }, - [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t], + [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty], ); const applyAlign = useCallback( @@ -587,9 +643,9 @@ export function TopologyPage() { } pushHistory(); setNodes(alignNodes(nodes, ids, kind)); - dirtyRef.current = true; + markDirty(); }, - [nodes, setNodes, pushHistory, showError, t], + [nodes, setNodes, pushHistory, showError, t, markDirty], ); const renameMapMut = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => @@ -616,6 +672,7 @@ export function TopologyPage() { mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }), onSuccess: async (row) => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); + clearDirty(); setMapId(row.id); showOk(t("topology.newMap")); // Prompt rename right after create — default name is a placeholder. @@ -629,6 +686,7 @@ export function TopologyPage() { onSuccess: async (_out, id) => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); if (mapId === id) { + clearDirty(); setMapId(""); setNodes([]); setEdges([]); @@ -640,7 +698,7 @@ export function TopologyPage() { const saveMut = useMutation({ mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)), onSuccess: async (graph) => { - dirtyRef.current = false; + clearDirty(); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); showOk(t("topology.saved")); @@ -679,7 +737,7 @@ export function TopologyPage() { try { if (dirtyRef.current) { await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)); - dirtyRef.current = false; + clearDirty(); } const out = await discoverTopologyNeighborsStream( mapId, @@ -723,12 +781,12 @@ export function TopologyPage() { try { const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges)); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - dirtyRef.current = false; + clearDirty(); } catch { - dirtyRef.current = true; + markDirty(); } } else { - dirtyRef.current = false; + clearDirty(); } historyLockRef.current = true; setNodes(rfNodes); @@ -775,10 +833,21 @@ export function TopologyPage() { failed: discoverLiveResults.filter((r) => !r.ok).length, }; + 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 (!isValidConnection(connection)) return; pushHistory(); - dirtyRef.current = true; + markDirty(); connectClickRef.current = null; const data: EdgeStyleData = { source: "manual", @@ -787,6 +856,7 @@ export function TopologyPage() { stroke_color: "", stroke_width: 0, line_style: "", + discovered_at: null, }; setEdges((eds) => addEdge( @@ -803,7 +873,7 @@ export function TopologyPage() { ), ); }, - [setEdges, pushHistory, edgeDefaults], + [setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection], ); const addNodeAt = useCallback( @@ -817,7 +887,7 @@ export function TopologyPage() { const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id); if (!ne) return; pushHistory(); - dirtyRef.current = true; + markDirty(); setNodes((prev) => [ ...prev, { @@ -841,7 +911,7 @@ export function TopologyPage() { if (!ne) return; const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); pushHistory(); - dirtyRef.current = true; + markDirty(); setNodes((prev) => [ ...prev, { @@ -942,12 +1012,14 @@ export function TopologyPage() { const patchSelectedEdgeStyle = useCallback( ( - patch: Partial>, + patch: Partial< + Pick + >, opts?: { skipHistory?: boolean }, ) => { if (!selectedEdgeId) return; if (!opts?.skipHistory) pushHistory(); - dirtyRef.current = true; + markDirty(); setEdges((eds) => eds.map((e) => { if (e.id !== selectedEdgeId) return e; @@ -958,14 +1030,34 @@ export function TopologyPage() { 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 || "", }; - return withEdgeVisual({ ...e, data }, edgeDefaults); + const portLabel = [data.source_port, data.target_port].filter(Boolean).join(" ? "); + return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults); }), ); }, - [selectedEdgeId, setEdges, pushHistory, edgeDefaults], + [selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty], ); + 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 staleEdgeCount = useMemo( () => edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale") @@ -1020,7 +1112,7 @@ export function TopologyPage() { const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id)); if (nodeIds.size === 0 && edgeIds.size === 0) return; pushHistory(); - dirtyRef.current = true; + markDirty(); setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id))); setEdges((es) => es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)), @@ -1047,6 +1139,11 @@ export function TopologyPage() { 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(); @@ -1071,18 +1168,27 @@ export function TopologyPage() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges]); + }, [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); - return () => window.removeEventListener("scroll", onScroll, true); + window.addEventListener("mousedown", onPointerDown, true); + return () => { + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("mousedown", onPointerDown, true); + }; }, [ctxMenu, closeCtxMenu]); const removeNodeById = (nodeId: string) => { pushHistory(); - dirtyRef.current = true; + markDirty(); setNodes((ns) => ns.filter((n) => n.id !== nodeId)); setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId)); closeCtxMenu(); @@ -1090,7 +1196,7 @@ export function TopologyPage() { const removeEdgeById = (edgeId: string) => { pushHistory(); - dirtyRef.current = true; + markDirty(); setEdges((es) => es.filter((e) => e.id !== edgeId)); setSelectedEdgeId((cur) => (cur === edgeId ? null : cur)); closeCtxMenu(); @@ -1100,7 +1206,7 @@ export function TopologyPage() { const n = staleEdgeCount; if (n <= 0) return; pushHistory(); - dirtyRef.current = true; + markDirty(); setEdges((es) => es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"), ); @@ -1237,7 +1343,7 @@ export function TopologyPage() { name, ip: ne.ip_address || "", vendor: "ZTE", - meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`, + meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`, connect_status: ne.connection_status || "", }; }); @@ -1250,7 +1356,7 @@ export function TopologyPage() { name: ne.name || ne.ip_address, ip: ne.ip_address, vendor: ne.vendor, - meta: `${ne.ip_address} · ${ne.vendor}`, + meta: `${ne.ip_address} ? ${ne.vendor}`, connect_status: ne.connect_status, })); }, [paletteSource, neQuery.data, umeQuery.data]); @@ -1291,7 +1397,10 @@ export function TopologyPage() { @@ -1457,19 +1569,36 @@ export function TopologyPage() {
- -
{discovering ? ( @@ -1701,7 +1835,7 @@ export function TopologyPage() { .replace("{{total}}", String(discoverProgress.total)) .replace( "{{name}}", - discoverProgress.neName || discoverProgress.neIp || "…", + discoverProgress.neName || discoverProgress.neIp || "?", )}

) : null} @@ -1727,7 +1861,7 @@ export function TopologyPage() { {r.ok ? "OK" : "FAIL"}
- {r.ne_ip ? `${r.ne_ip} · ` : ""} + {r.ne_ip ? `${r.ne_ip} ? ` : ""} {r.ok ? t("topology.discoverNeOk") .replace("{{neighbors}}", String(r.neighbors)) @@ -1773,17 +1907,18 @@ export function TopologyPage() { onNodeDragStart={() => pushHistory()} onNodesChange={(changes) => { if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) { - dirtyRef.current = true; + markDirty(); } onNodesChange(changes); }} onEdgesChange={(changes) => { if (changes.some((c) => c.type !== "select")) { - dirtyRef.current = true; + markDirty(); } onEdgesChange(changes); }} onConnect={onConnect} + isValidConnection={isValidConnection} onNodeClick={onNodeClick} onEdgeClick={(_e, edge) => { setCtxMenu(null); @@ -1913,6 +2048,16 @@ export function TopologyPage() {
  • {t("topology.nodeMenu")}
  • +
  • + +
  • {selectedEdgeResolved ? (
    + +