From 241093343e694b42b9f2cc6eef1ff6f943bf9a16 Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 5 Aug 2026 17:42:51 +0800 Subject: [PATCH] Add topology-canvas placeholder NE create (source=topology). Toolbar and pane context menu create placeholders that follow the same setup path as LLDP in NE management and WebCRT. Co-authored-by: Cursor --- netx_api/device_types.py | 17 ++ netx_api/ne_service_crud.py | 6 +- netx_api/ne_service_webcrt.py | 7 +- netx_api/topology_discover_common.py | 6 +- netx_api/topology_fabric_links.py | 4 +- netx_api/topology_fabric_peers.py | 10 +- netx_api/topology_inventory_lifecycle.py | 7 +- netx_api/topology_router.py | 11 + netx_api/topology_schemas.py | 9 + netx_api/topology_service.py | 2 + netx_api/topology_views.py | 2 + netx_api/topology_views_graph.py | 75 ++++++ tests/test_managed_ne.py | 40 ++++ tests/test_topology.py | 27 ++- web/src/i18n/en.ts | 17 +- web/src/i18n/zh.ts | 16 +- web/src/pages/NePage.tsx | 6 +- web/src/pages/TopologyPage.tsx | 214 +++++++++++++++++- web/src/pages/WebcrtPage.tsx | 4 +- .../pages/network/TopologyClassifyPage.tsx | 2 +- web/src/services/api.ts | 9 + 21 files changed, 460 insertions(+), 31 deletions(-) diff --git a/netx_api/device_types.py b/netx_api/device_types.py index ddade15..ce6bded 100644 --- a/netx_api/device_types.py +++ b/netx_api/device_types.py @@ -53,3 +53,20 @@ WEBCRT_NE_SOURCE = "webcrt" # ManagedNE.source for LLDP-discovered peers not yet in inventory (SSH shell, empty creds). LLDP_DISCOVERED_NE_SOURCE = "lldp" + +# ManagedNE.source for placeholders created manually on a topology canvas. +TOPOLOGY_NE_SOURCE = "topology" + +# Incomplete hosts that need setup (NE edit / WebCRT New Session claim) before ops use. +PLACEHOLDER_NE_SOURCES = frozenset( + { + LLDP_DISCOVERED_NE_SOURCE, + "lldp", + TOPOLOGY_NE_SOURCE, + "topology", + } +) + + +def is_placeholder_ne_source(source: str | None) -> bool: + return str(source or "").strip().lower() in PLACEHOLDER_NE_SOURCES diff --git a/netx_api/ne_service_crud.py b/netx_api/ne_service_crud.py index d90e95c..44ec6d6 100644 --- a/netx_api/ne_service_crud.py +++ b/netx_api/ne_service_crud.py @@ -8,7 +8,7 @@ from fastapi import HTTPException from sqlalchemy import or_ from sqlalchemy.orm import Session -from .device_types import LLDP_DISCOVERED_NE_SOURCE, SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES +from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES, is_placeholder_ne_source from .models import ManagedNE from .ne_crypto import encrypt_secret from .ne_hop_templates import default_bastion_username_template, default_hop_command_template @@ -175,9 +175,9 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed ) if any(k in data for k in hop_keys): _apply_hop_update(row, data) - # Filling IP on an LLDP placeholder promotes it into real inventory. + # Filling IP on an LLDP / topology placeholder promotes it into real inventory. if "ip_address" in data and str(row.ip_address or "").strip(): - if str(row.source or "").strip().lower() in {LLDP_DISCOVERED_NE_SOURCE, "lldp"}: + if is_placeholder_ne_source(row.source): row.source = "" row.updated_at = _now() # Keep linked fabric node identity in sync (name / IP / vendor). diff --git a/netx_api/ne_service_webcrt.py b/netx_api/ne_service_webcrt.py index a2dfa2c..c07dffb 100644 --- a/netx_api/ne_service_webcrt.py +++ b/netx_api/ne_service_webcrt.py @@ -6,7 +6,7 @@ from typing import Any from fastapi import HTTPException from sqlalchemy.orm import Session -from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE +from .device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE, is_placeholder_ne_source from .models import ManagedNE from .ne_crypto import encrypt_secret from .ne_schemas import ManagedNeCreate, ManagedNeOut @@ -26,14 +26,15 @@ from .ne_service_common import ( def _is_webcrt_claimable(row: ManagedNE) -> bool: - """LLDP placeholders / incomplete rows (no IP) can be promoted into WebCRT sessions.""" + """LLDP / topology placeholders / incomplete rows (no IP) can be promoted into WebCRT sessions.""" src = str(row.source or "").strip().lower() - if src in {LLDP_DISCOVERED_NE_SOURCE, "lldp"}: + if is_placeholder_ne_source(src): return True if src in {WEBCRT_NE_SOURCE, "webcrt", "ume_sync"}: return False return not str(row.ip_address or "").strip() + def _normalize_webcrt_device_type(device_type: str) -> str: dt = str(device_type or "").strip() low = dt.lower() diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py index 3f8a5e1..b4ca488 100644 --- a/netx_api/topology_discover_common.py +++ b/netx_api/topology_discover_common.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor -from .device_types import LLDP_DISCOVERED_NE_SOURCE +from .device_types import is_placeholder_ne_source from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE from .topology_common import _RAW_PREVIEW_MAX from .topology_schemas import ( @@ -26,8 +26,8 @@ def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str: def _is_lldp_placeholder_ne(ne: ManagedNE) -> bool: - """LLDP SSH placeholders have no credentials until the operator promotes them.""" - return str(ne.source or "").strip().lower() in {LLDP_DISCOVERED_NE_SOURCE, "lldp"} + """Incomplete placeholders (LLDP / topology) must not be used as discover targets.""" + return is_placeholder_ne_source(ne.source) def _managed_target_dict(ne: ManagedNE) -> dict[str, str]: diff --git a/netx_api/topology_fabric_links.py b/netx_api/topology_fabric_links.py index 85ff02d..1b46dfc 100644 --- a/netx_api/topology_fabric_links.py +++ b/netx_api/topology_fabric_links.py @@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor -from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE, is_placeholder_ne_source from .models import ( ManagedNE, TopoFabricEdge, @@ -477,7 +477,7 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]: _absorb(canon, [p]) db.flush() # Drop placeholder ManagedNE if nothing else references it. - if ph_ne is not None and str(ph_ne.source or "").strip().lower() == LLDP_DISCOVERED_NE_SOURCE: + if ph_ne is not None and is_placeholder_ne_source(ph_ne.source): still = ( db.query(TopoFabricNode) .filter(TopoFabricNode.managed_ne_id == ph_ne.id) diff --git a/netx_api/topology_fabric_peers.py b/netx_api/topology_fabric_peers.py index f78719a..aba133d 100644 --- a/netx_api/topology_fabric_peers.py +++ b/netx_api/topology_fabric_peers.py @@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor -from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE, is_placeholder_ne_source from .models import ( ManagedNE, TopoFabricEdge, @@ -70,11 +70,11 @@ def _managed_source(db: Session, ne_id: str | None) -> str: def _ne_inventory_score(ne: ManagedNE) -> int: - """Prefer real inventory over LLDP placeholders; never prefer WebCRT twins.""" + """Prefer real inventory over placeholders; never prefer WebCRT twins.""" src = str(ne.source or "").strip().lower() if src == WEBCRT_NE_SOURCE: return 0 - if src == LLDP_DISCOVERED_NE_SOURCE: + if is_placeholder_ne_source(src): return 1 return 2 @@ -84,7 +84,7 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int: WebCRT quick-connect intentionally allows duplicate IPs as separate ManagedNE rows; those must lose to real inventory NEs with the same address. - LLDP placeholders (SSH shell, empty creds) rank above WebCRT, below real NEs. + LLDP / topology placeholders (SSH shell, empty creds) rank above WebCRT, below real NEs. """ if str(n.ume_ne_id or "").strip(): return 3 @@ -94,7 +94,7 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int: src = _managed_source(db, mid) if src == WEBCRT_NE_SOURCE: return 1 - if src == LLDP_DISCOVERED_NE_SOURCE: + if is_placeholder_ne_source(src): return 2 return 4 diff --git a/netx_api/topology_inventory_lifecycle.py b/netx_api/topology_inventory_lifecycle.py index 10f8fbb..a565fc6 100644 --- a/netx_api/topology_inventory_lifecycle.py +++ b/netx_api/topology_inventory_lifecycle.py @@ -9,7 +9,7 @@ from fastapi import HTTPException from sqlalchemy.orm import Session from sqlalchemy.orm.attributes import flag_modified -from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE, is_placeholder_ne_source from .models import ( ManagedNE, TopoFabricEdge, @@ -28,6 +28,7 @@ _NON_INVENTORY_MANAGED_SOURCES = frozenset( WEBCRT_NE_SOURCE, "lldp", "webcrt", + "topology", } ) @@ -64,7 +65,9 @@ def managed_source_label(source: str | None) -> str: src = str(source or "").strip().lower() if src == UME_SYNC_SOURCE: return "ume_sync" - if src == LLDP_DISCOVERED_NE_SOURCE or src == "lldp": + if is_placeholder_ne_source(src): + if src in {"topology"}: + return "topology" return "lldp" if src == WEBCRT_NE_SOURCE or src == "webcrt": return "webcrt" diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 852a851..a1e3557 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -34,6 +34,7 @@ from .topology_schemas import ( SliceGenerateRequest, TopologyFolderCreate, TopologyFolderUpdate, + TopologyPlaceholderCreate, TopologyViewCreate, TopologyViewUpdate, ViewEdgeStylePatch, @@ -56,6 +57,7 @@ from .topology_views import ( add_nodes_to_view, bootstrap_topology_tree, create_folder, + create_topology_placeholder_on_view, create_view, delete_folder, delete_view, @@ -289,6 +291,15 @@ def api_add_nodes( return add_nodes_to_view(db, view_id, body).model_dump() +@router.post("/views/{view_id}/nodes/create-placeholder") +def api_create_topology_placeholder( + view_id: str, + body: TopologyPlaceholderCreate, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return create_topology_placeholder_on_view(db, view_id, body).model_dump() + + @router.post("/views/{view_id}/project-neighbors") def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return project_fabric_neighbors_to_view(db, view_id).model_dump() diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 835eb3c..06eaacf 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -356,6 +356,15 @@ class ViewNodesAdd(BaseModel): return_graph: bool = True +class TopologyPlaceholderCreate(BaseModel): + """Create a canvas placeholder ManagedNE (source=topology) and place it on the view.""" + + name: str = Field(min_length=1, max_length=256) + ip_address: str = "" + x: float = 0.0 + y: float = 0.0 + + class ViewNodesRemove(BaseModel): """Remove placements from a view (does not delete fabric). Filter and/or id list.""" diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 9dc29e4..9214965 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -37,6 +37,7 @@ from .topology_views import ( add_nodes_to_view, bootstrap_topology_tree, create_folder, + create_topology_placeholder_on_view, create_view, delete_folder, delete_view, @@ -65,6 +66,7 @@ __all__ = [ "add_nodes_to_view", "bootstrap_topology_tree", "create_folder", + "create_topology_placeholder_on_view", "create_view", "delete_folder", "delete_view", diff --git a/netx_api/topology_views.py b/netx_api/topology_views.py index 55cbb33..36d8bfb 100644 --- a/netx_api/topology_views.py +++ b/netx_api/topology_views.py @@ -4,6 +4,7 @@ from __future__ import annotations from .topology_views_graph import ( _place_fabric_ids_on_view, add_nodes_to_view, + create_topology_placeholder_on_view, get_view_graph, patch_view_edge_style, patch_view_positions, @@ -29,6 +30,7 @@ __all__ = [ "add_nodes_to_view", "bootstrap_topology_tree", "create_folder", + "create_topology_placeholder_on_view", "create_view", "delete_folder", "delete_view", diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index a390b9e..6721aa5 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -52,6 +52,7 @@ from .topology_schemas import ( TopologyFolderCreate, TopologyFolderOut, TopologyFolderUpdate, + TopologyPlaceholderCreate, TopologyTreeFolderOut, TopologyTreeOut, TopologyTreeViewOut, @@ -784,6 +785,80 @@ def add_nodes_to_view( ) +def create_topology_placeholder_on_view( + db: Session, + view_id: str, + body: TopologyPlaceholderCreate, +) -> TopologyViewGraphOut: + """Create a ManagedNE with source=topology, ensure fabric node, place on the view.""" + from .device_types import TOPOLOGY_NE_SOURCE + from .ne_service_common import _normalize_ip + + view = _get_view_or_404(db, view_id) + mem = _membership_for_view(view) + max_nodes = int(mem.get("max_nodes") or 300) + existing_count = ( + db.query(func.count(TopoViewNode.id)).filter(TopoViewNode.view_id == view.id).scalar() or 0 + ) + if int(existing_count) >= max_nodes: + raise HTTPException(status_code=400, detail="membership_max_nodes") + + display = str(body.name or "").strip()[:256] + if not display: + raise HTTPException(status_code=400, detail="name_required") + ip = _normalize_ip(body.ip_address)[:128] + now = _utcnow() + ne = ManagedNE( + id=uuid4().hex, + name=display, + vendor="Other", + device_type="generic", + ip_address=ip, + port=22, + protocol="ssh", + username="", + password_enc="", + enable_secret_enc="", + connect_status="unknown", + tags="", + remark="Created on topology canvas", + source=TOPOLOGY_NE_SOURCE, + source_ref="", + created_at=now, + updated_at=now, + ) + db.add(ne) + db.flush() + fabric = ensure_fabric_node_for_managed(db, ne) + + already = ( + db.query(TopoViewNode) + .filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fabric.id) + .one_or_none() + ) + if already is None: + db.add( + TopoViewNode( + id=uuid4().hex, + view_id=view.id, + fabric_node_id=fabric.id, + x=float(body.x or 0.0), + y=float(body.y or 0.0), + label="", + locked=False, + created_at=now, + updated_at=now, + ) + ) + else: + already.x = float(body.x or already.x or 0.0) + already.y = float(body.y or already.y or 0.0) + already.updated_at = now + view.updated_at = now + db.commit() + return get_view_graph(db, view.id) + + def _neighbor_ids( db: Session, *, seed_ids: set[str], layer: str, hops: int ) -> set[str]: diff --git a/tests/test_managed_ne.py b/tests/test_managed_ne.py index 12f8c2f..effce9c 100644 --- a/tests/test_managed_ne.py +++ b/tests/test_managed_ne.py @@ -573,6 +573,46 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase): self.assertEqual(hit["ne_source"], WEBCRT_NE_SOURCE) self.assertEqual(hit["ip_address"], "10.9.9.9") + def test_claim_topology_placeholder_promotes_to_webcrt(self): + from netx_api.device_types import TOPOLOGY_NE_SOURCE + from netx_api.ne_service_common import _now + + now = _now() + row = ManagedNE( + name="TOPO-PH-01", + vendor="Other", + device_type="generic", + ip_address="", + port=22, + protocol="ssh", + username="", + password_enc="", + enable_secret_enc="", + connect_status="unknown", + tags="", + remark="", + source=TOPOLOGY_NE_SOURCE, + source_ref="", + created_at=now, + updated_at=now, + ) + self.db.add(row) + self.db.commit() + self.db.refresh(row) + out, action = upsert_webcrt_session_host( + self.db, + ne_id=row.id, + name="TOPO-PH-01", + ip_address="10.9.9.10", + protocol="ssh", + username="admin", + password="secret", + save_password=True, + ) + self.assertEqual(action, "updated") + self.assertEqual(out.id, row.id) + self.assertEqual(out.source, WEBCRT_NE_SOURCE) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_topology.py b/tests/test_topology.py index 9a34c4e..03bab17 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -13,7 +13,7 @@ from sqlalchemy.exc import OperationalError from netx_api import topology_lldp as lldp from netx_api import topology_service as svc from netx_api.db import Base, SessionLocal, engine -from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE +from netx_api.device_types import LLDP_DISCOVERED_NE_SOURCE, TOPOLOGY_NE_SOURCE, WEBCRT_NE_SOURCE from netx_api.models import ( ManagedNE, TopoDiscoverJob, @@ -28,6 +28,7 @@ from netx_api.models import ( from netx_api.topology_schemas import ( FabricDiscoverRequest, TopologyFolderCreate, + TopologyPlaceholderCreate, TopologyViewCreate, ViewNodesAdd, ViewPopulateRequest, @@ -357,6 +358,30 @@ class FabricTopologyTests(unittest.TestCase): self.db.delete(ne) self.db.commit() + def test_create_topology_placeholder_on_view(self) -> None: + suffix = uuid4().hex[:8] + view = svc.create_view( + self.db, + TopologyViewCreate(name=f"Vph-{suffix}", folder_id=self._region(f"Rph-{suffix}")), + ) + graph = svc.create_topology_placeholder_on_view( + self.db, + view.id, + TopologyPlaceholderCreate(name=f"SW-{suffix}", ip_address="", x=42, y=77), + ) + self.assertEqual(len(graph.nodes), 1) + node = graph.nodes[0] + self.assertEqual(node.name, f"SW-{suffix}") + self.assertEqual(node.x, 42) + self.assertEqual(node.y, 77) + self.assertTrue(node.managed_ne_id) + ne = self.db.get(ManagedNE, node.managed_ne_id) + self.assertIsNotNone(ne) + assert ne is not None + self.assertEqual(ne.source, TOPOLOGY_NE_SOURCE) + self.assertEqual(ne.ip_address, "") + self.assertEqual(ne.device_type, "generic") + def test_topology_tree_root_region_and_leaf(self) -> None: tree = svc.get_topology_tree(self.db) self.assertIsNotNone(tree.root) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c3c2274..0fc5ffb 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -737,6 +737,7 @@ const en = { ume_sync: "UME sync", webcrt: "WebCRT", lldp: "LLDP placeholder", + topology: "Topology placeholder", }, connect: { run: "Connectivity test", @@ -1123,9 +1124,9 @@ const en = { }, newSession: { title: "New Session", - claimTitle: "Complete LLDP placeholder", + claimTitle: "Complete placeholder session", claimHint: - "Enter a reachable host. Session name defaults to the LLDP placeholder. On success, source becomes WebCRT.", + "Enter a reachable host. Session name defaults to the placeholder name. On success, source becomes WebCRT.", connect: "Connect", connecting: "Connecting…", protocol: "Protocol", @@ -1454,6 +1455,18 @@ const en = { removeNode: "Remove from canvas", removeEdge: "Delete edge", nodeMenu: "Node", + paneMenu: "Canvas", + createNe: "Create NE", + createNeTitle: "Create topology placeholder NE", + createNeHint: + "Source is topology placeholder. Complete login via WebCRT, or promote in NE management.", + createNeName: "Name", + createNeNamePh: "e.g. SW-CORE-01", + createNeNameRequired: "NE name is required", + createNeIp: "IP (optional)", + createNeIpPh: "Can fill later during setup", + createNeDone: "Created placeholder {{name}}", + createNeBusy: "Creating…", selectionMenu: "Selection", edgeStyle: "Edge style", edgeColor: "Color", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 74123cd..0500322 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -732,6 +732,7 @@ const zh = { ume_sync: "UME 同步", webcrt: "WebCRT", lldp: "LLDP 占位", + topology: "拓扑占位", }, connect: { run: "连通性测试", @@ -1117,8 +1118,8 @@ const zh = { }, newSession: { title: "新建会话", - claimTitle: "完善 LLDP 占位会话", - claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。", + claimTitle: "完善占位会话", + claimHint: "请填写可达地址;会话名默认使用占位名。连接成功后来源将更新为 WebCRT。", connect: "连接", connecting: "连接中…", protocol: "协议", @@ -1447,6 +1448,17 @@ const zh = { removeNode: "从画布移除", removeEdge: "删除链路", nodeMenu: "网元", + paneMenu: "画布", + createNe: "建立网元", + createNeTitle: "建立拓扑占位网元", + createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。", + createNeName: "名称", + createNeNamePh: "例如 SW-CORE-01", + createNeNameRequired: "请填写网元名称", + createNeIp: "IP(可选)", + createNeIpPh: "可稍后在完善时填写", + createNeDone: "已建立占位网元 {{name}}", + createNeBusy: "创建中…", selectionMenu: "多选", edgeStyle: "链路样式", edgeColor: "颜色", diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index c5cfb3c..3bc6d76 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -112,10 +112,12 @@ function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; } -function managedSourceKey(source: string | undefined): "manual" | "ume_sync" | "webcrt" | "lldp" | "" { +function managedSourceKey( + source: string | undefined, +): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" { const s = String(source || "").trim().toLowerCase(); if (!s) return "manual"; - if (s === "ume_sync" || s === "webcrt" || s === "lldp") return s; + if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s; return ""; } diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 9ff0ce0..6679c07 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -36,6 +36,7 @@ import { addTopologyViewNodes, createFabricManualEdge, createTopologyFolder, + createTopologyPlaceholder, createTopologyView, deleteTopologyFolder, deleteTopologyMap, @@ -298,7 +299,8 @@ type TopoDisplayOpts = { type CtxMenu = | { kind: "node"; id: string; x: number; y: number } | { kind: "edge"; id: string; x: number; y: number } - | { kind: "selection"; x: number; y: number }; + | { kind: "selection"; x: number; y: number } + | { kind: "pane"; x: number; y: number; flowX: number; flowY: number }; const TopoDisplayContext = createContext({ hideIp: true, @@ -853,6 +855,13 @@ export function TopologyPage() { const [paletteSource, setPaletteSource] = useState("managed"); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState([]); const [paletteAdding, setPaletteAdding] = useState(false); + const [createNeDialog, setCreateNeDialog] = useState<{ + flowX: number; + flowY: number; + name: string; + ip_address: string; + } | null>(null); + const [createNeBusy, setCreateNeBusy] = useState(false); const [discoverOpen, setDiscoverOpen] = useState(false); const [discovering, setDiscovering] = useState(false); const [discoverReport, setDiscoverReport] = useState(null); @@ -2084,8 +2093,8 @@ export function TopologyPage() { 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"; + // LLDP / topology placeholders / incomplete rows → full NE edit form. + const needsSetup = !hasIp || src === "lldp" || src === "topology"; if (!needsSetup) { openOrFocusModule({ moduleId: "webcrt", @@ -2180,6 +2189,11 @@ export function TopologyPage() { void runDiscover([id]); }; + const openCreateNeAt = (flowX: number, flowY: number) => { + closeCtxMenu(); + setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" }); + }; + const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => { const pad = 8; const w = size?.w ?? 200; @@ -2206,6 +2220,58 @@ export function TopologyPage() { [setNodes, setEdges], ); + const submitCreateNe = useCallback(async () => { + if (!mapId || !createNeDialog) return; + const name = createNeDialog.name.trim(); + if (!name) { + showError(t("topology.createNeNameRequired")); + return; + } + setCreateNeBusy(true); + try { + const graph = await createTopologyPlaceholder(mapId, { + name, + ip_address: createNeDialog.ip_address.trim(), + x: createNeDialog.flowX, + y: createNeDialog.flowY, + }); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + historyLockRef.current = true; + applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + const created = graph.nodes.find( + (n) => + String(n.name || "").trim() === name && + Math.abs(Number(n.x) - createNeDialog.flowX) < 0.5 && + Math.abs(Number(n.y) - createNeDialog.flowY) < 0.5, + ); + if (created?.fabric_node_id) { + focusNode(created.fabric_node_id, false); + } + setCreateNeDialog(null); + showOk(t("topology.createNeDone").replace("{{name}}", name)); + void queryClient.invalidateQueries({ queryKey: ["managedNe"] }); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } catch (err) { + showError(String(err)); + } finally { + setCreateNeBusy(false); + } + }, [ + mapId, + createNeDialog, + queryClient, + edgeDefaults, + setNodes, + setEdges, + clearDirty, + showError, + showOk, + t, + focusNode, + ]); + const locateNode = useCallback( (nodeId: string) => { focusNode(nodeId, false); @@ -2858,6 +2924,29 @@ export function TopologyPage() { > {t("topology.addNe")} + @@ -3736,6 +3825,21 @@ export function TopologyPage() { 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} onInit={(inst) => { rfRef.current = inst as ReactFlowInstance, Edge>; @@ -3779,6 +3883,94 @@ export function TopologyPage() { )} + {createNeDialog && canvasMode ? ( +
+
{ + if (createNeBusy) return; + setCreateNeDialog(null); + }} + /> +
+
+ {t("topology.createNeTitle")} + +
+

{t("topology.createNeHint")}

+
+ + +
+
+ + +
+
+
+ ) : null} + {addNeOpen && canvasMode ? (
+ ) : ctxMenu.kind === "pane" ? ( + <> +
  • + {t("topology.paneMenu")} +
  • +
  • + +
  • + ) : ctxMenu.kind === "node" ? ( <>
  • diff --git a/web/src/pages/WebcrtPage.tsx b/web/src/pages/WebcrtPage.tsx index 883b0be..92b8beb 100644 --- a/web/src/pages/WebcrtPage.tsx +++ b/web/src/pages/WebcrtPage.tsx @@ -355,7 +355,7 @@ function isInventorySsh(target: Pick): boo return String(target.protocol || "ssh").toLowerCase() !== "telnet"; } -/** LLDP placeholders / incomplete inventory rows need New Session (host/IP) before auth. */ +/** LLDP / topology placeholders / incomplete inventory rows need New Session (host/IP) before auth. */ function needsSessionSetup( target: Pick, ): boolean { @@ -363,7 +363,7 @@ function needsSessionSetup( if (listSrc === "ume") return false; if (listSrc !== "managed" && listSrc !== "webcrt") return false; const neSrc = String(target.ne_source || "").trim().toLowerCase(); - if (neSrc === "lldp") return true; + if (neSrc === "lldp" || neSrc === "topology") return true; return !String(target.ip_address || "").trim(); } diff --git a/web/src/pages/network/TopologyClassifyPage.tsx b/web/src/pages/network/TopologyClassifyPage.tsx index 15191d3..3e18677 100644 --- a/web/src/pages/network/TopologyClassifyPage.tsx +++ b/web/src/pages/network/TopologyClassifyPage.tsx @@ -34,7 +34,7 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean { } if (n.managed_alive === false) return true; const src = String(n.managed_source || "").toLowerCase(); - return src === "lldp" || src === "webcrt"; + return src === "lldp" || src === "topology" || src === "webcrt"; } export function TopologyClassifyPage() { diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 99be47e..e46f456 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1070,6 +1070,15 @@ export const addTopologyViewNodes = ( }, ) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); +export const createTopologyPlaceholder = ( + viewId: string, + body: { name: string; ip_address?: string; x?: number; y?: number }, +) => + apiPost( + `/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`, + body, + ); + export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => apiPost(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { fabric_node_ids: fabricNodeIds,