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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 17:42:51 +08:00
parent 71c5193ecd
commit 241093343e
21 changed files with 460 additions and 31 deletions

View file

@ -53,3 +53,20 @@ WEBCRT_NE_SOURCE = "webcrt"
# ManagedNE.source for LLDP-discovered peers not yet in inventory (SSH shell, empty creds). # ManagedNE.source for LLDP-discovered peers not yet in inventory (SSH shell, empty creds).
LLDP_DISCOVERED_NE_SOURCE = "lldp" 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

View file

@ -8,7 +8,7 @@ from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import Session 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 .models import ManagedNE
from .ne_crypto import encrypt_secret from .ne_crypto import encrypt_secret
from .ne_hop_templates import default_bastion_username_template, default_hop_command_template 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): if any(k in data for k in hop_keys):
_apply_hop_update(row, data) _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 "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.source = ""
row.updated_at = _now() row.updated_at = _now()
# Keep linked fabric node identity in sync (name / IP / vendor). # Keep linked fabric node identity in sync (name / IP / vendor).

View file

@ -6,7 +6,7 @@ from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session 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 .models import ManagedNE
from .ne_crypto import encrypt_secret from .ne_crypto import encrypt_secret
from .ne_schemas import ManagedNeCreate, ManagedNeOut from .ne_schemas import ManagedNeCreate, ManagedNeOut
@ -26,14 +26,15 @@ from .ne_service_common import (
def _is_webcrt_claimable(row: ManagedNE) -> bool: 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() src = str(row.source or "").strip().lower()
if src in {LLDP_DISCOVERED_NE_SOURCE, "lldp"}: if is_placeholder_ne_source(src):
return True return True
if src in {WEBCRT_NE_SOURCE, "webcrt", "ume_sync"}: if src in {WEBCRT_NE_SOURCE, "webcrt", "ume_sync"}:
return False return False
return not str(row.ip_address or "").strip() return not str(row.ip_address or "").strip()
def _normalize_webcrt_device_type(device_type: str) -> str: def _normalize_webcrt_device_type(device_type: str) -> str:
dt = str(device_type or "").strip() dt = str(device_type or "").strip()
low = dt.lower() low = dt.lower()

View file

@ -7,7 +7,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor 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 .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE
from .topology_common import _RAW_PREVIEW_MAX from .topology_common import _RAW_PREVIEW_MAX
from .topology_schemas import ( 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: def _is_lldp_placeholder_ne(ne: ManagedNE) -> bool:
"""LLDP SSH placeholders have no credentials until the operator promotes them.""" """Incomplete placeholders (LLDP / topology) must not be used as discover targets."""
return str(ne.source or "").strip().lower() in {LLDP_DISCOVERED_NE_SOURCE, "lldp"} return is_placeholder_ne_source(ne.source)
def _managed_target_dict(ne: ManagedNE) -> dict[str, str]: def _managed_target_dict(ne: ManagedNE) -> dict[str, str]:

View file

@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor 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 ( from .models import (
ManagedNE, ManagedNE,
TopoFabricEdge, TopoFabricEdge,
@ -477,7 +477,7 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]:
_absorb(canon, [p]) _absorb(canon, [p])
db.flush() db.flush()
# Drop placeholder ManagedNE if nothing else references it. # 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 = ( still = (
db.query(TopoFabricNode) db.query(TopoFabricNode)
.filter(TopoFabricNode.managed_ne_id == ph_ne.id) .filter(TopoFabricNode.managed_ne_id == ph_ne.id)

View file

@ -12,7 +12,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor 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 ( from .models import (
ManagedNE, ManagedNE,
TopoFabricEdge, TopoFabricEdge,
@ -70,11 +70,11 @@ def _managed_source(db: Session, ne_id: str | None) -> str:
def _ne_inventory_score(ne: ManagedNE) -> int: 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() src = str(ne.source or "").strip().lower()
if src == WEBCRT_NE_SOURCE: if src == WEBCRT_NE_SOURCE:
return 0 return 0
if src == LLDP_DISCOVERED_NE_SOURCE: if is_placeholder_ne_source(src):
return 1 return 1
return 2 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 WebCRT quick-connect intentionally allows duplicate IPs as separate ManagedNE
rows; those must lose to real inventory NEs with the same address. 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(): if str(n.ume_ne_id or "").strip():
return 3 return 3
@ -94,7 +94,7 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int:
src = _managed_source(db, mid) src = _managed_source(db, mid)
if src == WEBCRT_NE_SOURCE: if src == WEBCRT_NE_SOURCE:
return 1 return 1
if src == LLDP_DISCOVERED_NE_SOURCE: if is_placeholder_ne_source(src):
return 2 return 2
return 4 return 4

View file

@ -9,7 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified 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 ( from .models import (
ManagedNE, ManagedNE,
TopoFabricEdge, TopoFabricEdge,
@ -28,6 +28,7 @@ _NON_INVENTORY_MANAGED_SOURCES = frozenset(
WEBCRT_NE_SOURCE, WEBCRT_NE_SOURCE,
"lldp", "lldp",
"webcrt", "webcrt",
"topology",
} }
) )
@ -64,7 +65,9 @@ def managed_source_label(source: str | None) -> str:
src = str(source or "").strip().lower() src = str(source or "").strip().lower()
if src == UME_SYNC_SOURCE: if src == UME_SYNC_SOURCE:
return "ume_sync" 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" return "lldp"
if src == WEBCRT_NE_SOURCE or src == "webcrt": if src == WEBCRT_NE_SOURCE or src == "webcrt":
return "webcrt" return "webcrt"

View file

@ -34,6 +34,7 @@ from .topology_schemas import (
SliceGenerateRequest, SliceGenerateRequest,
TopologyFolderCreate, TopologyFolderCreate,
TopologyFolderUpdate, TopologyFolderUpdate,
TopologyPlaceholderCreate,
TopologyViewCreate, TopologyViewCreate,
TopologyViewUpdate, TopologyViewUpdate,
ViewEdgeStylePatch, ViewEdgeStylePatch,
@ -56,6 +57,7 @@ from .topology_views import (
add_nodes_to_view, add_nodes_to_view,
bootstrap_topology_tree, bootstrap_topology_tree,
create_folder, create_folder,
create_topology_placeholder_on_view,
create_view, create_view,
delete_folder, delete_folder,
delete_view, delete_view,
@ -289,6 +291,15 @@ def api_add_nodes(
return add_nodes_to_view(db, view_id, body).model_dump() 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") @router.post("/views/{view_id}/project-neighbors")
def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: 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() return project_fabric_neighbors_to_view(db, view_id).model_dump()

View file

@ -356,6 +356,15 @@ class ViewNodesAdd(BaseModel):
return_graph: bool = True 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): class ViewNodesRemove(BaseModel):
"""Remove placements from a view (does not delete fabric). Filter and/or id list.""" """Remove placements from a view (does not delete fabric). Filter and/or id list."""

View file

@ -37,6 +37,7 @@ from .topology_views import (
add_nodes_to_view, add_nodes_to_view,
bootstrap_topology_tree, bootstrap_topology_tree,
create_folder, create_folder,
create_topology_placeholder_on_view,
create_view, create_view,
delete_folder, delete_folder,
delete_view, delete_view,
@ -65,6 +66,7 @@ __all__ = [
"add_nodes_to_view", "add_nodes_to_view",
"bootstrap_topology_tree", "bootstrap_topology_tree",
"create_folder", "create_folder",
"create_topology_placeholder_on_view",
"create_view", "create_view",
"delete_folder", "delete_folder",
"delete_view", "delete_view",

View file

@ -4,6 +4,7 @@ from __future__ import annotations
from .topology_views_graph import ( from .topology_views_graph import (
_place_fabric_ids_on_view, _place_fabric_ids_on_view,
add_nodes_to_view, add_nodes_to_view,
create_topology_placeholder_on_view,
get_view_graph, get_view_graph,
patch_view_edge_style, patch_view_edge_style,
patch_view_positions, patch_view_positions,
@ -29,6 +30,7 @@ __all__ = [
"add_nodes_to_view", "add_nodes_to_view",
"bootstrap_topology_tree", "bootstrap_topology_tree",
"create_folder", "create_folder",
"create_topology_placeholder_on_view",
"create_view", "create_view",
"delete_folder", "delete_folder",
"delete_view", "delete_view",

View file

@ -52,6 +52,7 @@ from .topology_schemas import (
TopologyFolderCreate, TopologyFolderCreate,
TopologyFolderOut, TopologyFolderOut,
TopologyFolderUpdate, TopologyFolderUpdate,
TopologyPlaceholderCreate,
TopologyTreeFolderOut, TopologyTreeFolderOut,
TopologyTreeOut, TopologyTreeOut,
TopologyTreeViewOut, 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( def _neighbor_ids(
db: Session, *, seed_ids: set[str], layer: str, hops: int db: Session, *, seed_ids: set[str], layer: str, hops: int
) -> set[str]: ) -> set[str]:

View file

@ -573,6 +573,46 @@ class WebcrtUpsertAndTargetsTests(unittest.TestCase):
self.assertEqual(hit["ne_source"], WEBCRT_NE_SOURCE) self.assertEqual(hit["ne_source"], WEBCRT_NE_SOURCE)
self.assertEqual(hit["ip_address"], "10.9.9.9") 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -13,7 +13,7 @@ from sqlalchemy.exc import OperationalError
from netx_api import topology_lldp as lldp from netx_api import topology_lldp as lldp
from netx_api import topology_service as svc from netx_api import topology_service as svc
from netx_api.db import Base, SessionLocal, engine 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 ( from netx_api.models import (
ManagedNE, ManagedNE,
TopoDiscoverJob, TopoDiscoverJob,
@ -28,6 +28,7 @@ from netx_api.models import (
from netx_api.topology_schemas import ( from netx_api.topology_schemas import (
FabricDiscoverRequest, FabricDiscoverRequest,
TopologyFolderCreate, TopologyFolderCreate,
TopologyPlaceholderCreate,
TopologyViewCreate, TopologyViewCreate,
ViewNodesAdd, ViewNodesAdd,
ViewPopulateRequest, ViewPopulateRequest,
@ -357,6 +358,30 @@ class FabricTopologyTests(unittest.TestCase):
self.db.delete(ne) self.db.delete(ne)
self.db.commit() 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: def test_topology_tree_root_region_and_leaf(self) -> None:
tree = svc.get_topology_tree(self.db) tree = svc.get_topology_tree(self.db)
self.assertIsNotNone(tree.root) self.assertIsNotNone(tree.root)

View file

@ -737,6 +737,7 @@ const en = {
ume_sync: "UME sync", ume_sync: "UME sync",
webcrt: "WebCRT", webcrt: "WebCRT",
lldp: "LLDP placeholder", lldp: "LLDP placeholder",
topology: "Topology placeholder",
}, },
connect: { connect: {
run: "Connectivity test", run: "Connectivity test",
@ -1123,9 +1124,9 @@ const en = {
}, },
newSession: { newSession: {
title: "New Session", title: "New Session",
claimTitle: "Complete LLDP placeholder", claimTitle: "Complete placeholder session",
claimHint: 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", connect: "Connect",
connecting: "Connecting…", connecting: "Connecting…",
protocol: "Protocol", protocol: "Protocol",
@ -1454,6 +1455,18 @@ const en = {
removeNode: "Remove from canvas", removeNode: "Remove from canvas",
removeEdge: "Delete edge", removeEdge: "Delete edge",
nodeMenu: "Node", 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", selectionMenu: "Selection",
edgeStyle: "Edge style", edgeStyle: "Edge style",
edgeColor: "Color", edgeColor: "Color",

View file

@ -732,6 +732,7 @@ const zh = {
ume_sync: "UME 同步", ume_sync: "UME 同步",
webcrt: "WebCRT", webcrt: "WebCRT",
lldp: "LLDP 占位", lldp: "LLDP 占位",
topology: "拓扑占位",
}, },
connect: { connect: {
run: "连通性测试", run: "连通性测试",
@ -1117,8 +1118,8 @@ const zh = {
}, },
newSession: { newSession: {
title: "新建会话", title: "新建会话",
claimTitle: "完善 LLDP 占位会话", claimTitle: "完善占位会话",
claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。", claimHint: "请填写可达地址;会话名默认使用占位名。连接成功后来源将更新为 WebCRT。",
connect: "连接", connect: "连接",
connecting: "连接中…", connecting: "连接中…",
protocol: "协议", protocol: "协议",
@ -1447,6 +1448,17 @@ const zh = {
removeNode: "从画布移除", removeNode: "从画布移除",
removeEdge: "删除链路", removeEdge: "删除链路",
nodeMenu: "网元", nodeMenu: "网元",
paneMenu: "画布",
createNe: "建立网元",
createNeTitle: "建立拓扑占位网元",
createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。",
createNeName: "名称",
createNeNamePh: "例如 SW-CORE-01",
createNeNameRequired: "请填写网元名称",
createNeIp: "IP(可选)",
createNeIpPh: "可稍后在完善时填写",
createNeDone: "已建立占位网元 {{name}}",
createNeBusy: "创建中…",
selectionMenu: "多选", selectionMenu: "多选",
edgeStyle: "链路样式", edgeStyle: "链路样式",
edgeColor: "颜色", edgeColor: "颜色",

View file

@ -112,10 +112,12 @@ function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; 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(); const s = String(source || "").trim().toLowerCase();
if (!s) return "manual"; 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 ""; return "";
} }

View file

@ -36,6 +36,7 @@ import {
addTopologyViewNodes, addTopologyViewNodes,
createFabricManualEdge, createFabricManualEdge,
createTopologyFolder, createTopologyFolder,
createTopologyPlaceholder,
createTopologyView, createTopologyView,
deleteTopologyFolder, deleteTopologyFolder,
deleteTopologyMap, deleteTopologyMap,
@ -298,7 +299,8 @@ type TopoDisplayOpts = {
type CtxMenu = type CtxMenu =
| { kind: "node"; id: string; x: number; y: number } | { kind: "node"; id: string; x: number; y: number }
| { kind: "edge"; 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<TopoDisplayOpts>({ const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true, hideIp: true,
@ -853,6 +855,13 @@ export function TopologyPage() {
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed"); const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
const [paletteAdding, setPaletteAdding] = useState(false); 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 [discoverOpen, setDiscoverOpen] = useState(false);
const [discovering, setDiscovering] = useState(false); const [discovering, setDiscovering] = useState(false);
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null); const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
@ -2084,8 +2093,8 @@ export function TopologyPage() {
const src = String(ne.source || "").trim().toLowerCase(); const src = String(ne.source || "").trim().toLowerCase();
const hasIp = Boolean(String(ne.ip_address || "").trim()); const hasIp = Boolean(String(ne.ip_address || "").trim());
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy). // Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
// LLDP placeholders / incomplete rows → full NE edit form (vendor, hop, etc.). // LLDP / topology placeholders / incomplete rows → full NE edit form.
const needsSetup = !hasIp || src === "lldp"; const needsSetup = !hasIp || src === "lldp" || src === "topology";
if (!needsSetup) { if (!needsSetup) {
openOrFocusModule({ openOrFocusModule({
moduleId: "webcrt", moduleId: "webcrt",
@ -2180,6 +2189,11 @@ export function TopologyPage() {
void runDiscover([id]); 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 placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => {
const pad = 8; const pad = 8;
const w = size?.w ?? 200; const w = size?.w ?? 200;
@ -2206,6 +2220,58 @@ export function TopologyPage() {
[setNodes, setEdges], [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( const locateNode = useCallback(
(nodeId: string) => { (nodeId: string) => {
focusNode(nodeId, false); focusNode(nodeId, false);
@ -2858,6 +2924,29 @@ export function TopologyPage() {
> >
{t("topology.addNe")} {t("topology.addNe")}
</button> </button>
<button
type="button"
className="btn btn--sm"
onClick={() => {
let flowX = 80 + nodes.length * 24;
let flowY = 80 + nodes.length * 24;
if (rfRef.current) {
const pane = document.querySelector(".react-flow__pane");
const rect = pane?.getBoundingClientRect();
if (rect && rect.width > 0 && rect.height > 0) {
const center = rfRef.current.screenToFlowPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
flowX = center.x;
flowY = center.y;
}
}
openCreateNeAt(flowX, flowY);
}}
>
{t("topology.createNe")}
</button>
<button type="button" className="btn btn--sm btn--ghost" onClick={() => goBackBrowse()}> <button type="button" className="btn btn--sm btn--ghost" onClick={() => goBackBrowse()}>
{t("topology.backUp")} {t("topology.backUp")}
</button> </button>
@ -3736,6 +3825,21 @@ export function TopologyPage() {
const pos = placeCtxMenu(e.clientX, e.clientY); const pos = placeCtxMenu(e.clientX, e.clientY);
setCtxMenu({ kind: "selection", ...pos }); 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} onMoveStart={closeCtxMenu}
onInit={(inst) => { onInit={(inst) => {
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>; rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
@ -3779,6 +3883,94 @@ export function TopologyPage() {
)} )}
</main> </main>
{createNeDialog && canvasMode ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (createNeBusy) return;
setCreateNeDialog(null);
}}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={createNeBusy}
onClick={() => setCreateNeDialog(null)}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={createNeDialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={createNeBusy}
onChange={(e) =>
setCreateNeDialog((prev) => (prev ? { ...prev, name: e.target.value } : prev))
}
onKeyDown={(e) => {
if (e.key === "Enter") void submitCreateNe();
}}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={createNeDialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={createNeBusy}
onChange={(e) =>
setCreateNeDialog((prev) =>
prev ? { ...prev, ip_address: e.target.value } : prev,
)
}
onKeyDown={(e) => {
if (e.key === "Enter") void submitCreateNe();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--ghost"
disabled={createNeBusy}
onClick={() => setCreateNeDialog(null)}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={createNeBusy}
onClick={() => void submitCreateNe()}
>
{createNeBusy ? t("topology.createNeBusy") : t("topology.createNe")}
</button>
</div>
</div>
</div>
) : null}
{addNeOpen && canvasMode ? ( {addNeOpen && canvasMode ? (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}> <div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div <div
@ -3974,6 +4166,22 @@ export function TopologyPage() {
</button> </button>
</li> </li>
</> </>
) : ctxMenu.kind === "pane" ? (
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.paneMenu")}
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
onClick={() => openCreateNeAt(ctxMenu.flowX, ctxMenu.flowY)}
>
{t("topology.createNe")}
</button>
</li>
</>
) : ctxMenu.kind === "node" ? ( ) : ctxMenu.kind === "node" ? (
<> <>
<li className="topo-ctx__head" role="presentation"> <li className="topo-ctx__head" role="presentation">

View file

@ -355,7 +355,7 @@ function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boo
return String(target.protocol || "ssh").toLowerCase() !== "telnet"; 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( function needsSessionSetup(
target: Pick<CliTargetItem, "source" | "ne_source" | "ip_address">, target: Pick<CliTargetItem, "source" | "ne_source" | "ip_address">,
): boolean { ): boolean {
@ -363,7 +363,7 @@ function needsSessionSetup(
if (listSrc === "ume") return false; if (listSrc === "ume") return false;
if (listSrc !== "managed" && listSrc !== "webcrt") return false; if (listSrc !== "managed" && listSrc !== "webcrt") return false;
const neSrc = String(target.ne_source || "").trim().toLowerCase(); 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(); return !String(target.ip_address || "").trim();
} }

View file

@ -34,7 +34,7 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
} }
if (n.managed_alive === false) return true; if (n.managed_alive === false) return true;
const src = String(n.managed_source || "").toLowerCase(); const src = String(n.managed_source || "").toLowerCase();
return src === "lldp" || src === "webcrt"; return src === "lldp" || src === "topology" || src === "webcrt";
} }
export function TopologyClassifyPage() { export function TopologyClassifyPage() {

View file

@ -1070,6 +1070,15 @@ export const addTopologyViewNodes = (
}, },
) => apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body); ) => apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body);
export const createTopologyPlaceholder = (
viewId: string,
body: { name: string; ip_address?: string; x?: number; y?: number },
) =>
apiPost<TopologyViewGraph>(
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`,
body,
);
export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) => export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) =>
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, { apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, {
fabric_node_ids: fabricNodeIds, fabric_node_ids: fabricNodeIds,