mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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:
parent
71c5193ecd
commit
241093343e
21 changed files with 460 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue