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).
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.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).

View file

@ -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()

View file

@ -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]:

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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()

View file

@ -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."""

View file

@ -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",

View file

@ -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",

View file

@ -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]: