mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10: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).
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "颜色",
|
||||||
|
|
|
||||||
|
|
@ -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 "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue