Unify UME World as hex nav with conditional flat world map.

Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-07 00:51:08 +08:00
parent 5d705fa7bb
commit 1ef43fdcd4
30 changed files with 4389 additions and 371 deletions

View file

@ -32,6 +32,9 @@ class TopoFabricNode(Base):
# rule | manual | "" # rule | manual | ""
role_source: Mapped[str] = mapped_column(String(16), default="") role_source: Mapped[str] = mapped_column(String(16), default="")
region_source: Mapped[str] = mapped_column(String(16), default="") region_source: Mapped[str] = mapped_column(String(16), default="")
# Composed flat-world coordinates (packed per-SBN local layouts). Not raw UME xPos/yPos.
world_x: Mapped[float | None] = mapped_column(Float, nullable=True, index=True)
world_y: Mapped[float | None] = mapped_column(Float, nullable=True, index=True)
attrs: Mapped[dict] = mapped_column(_JsonType, default=dict) attrs: Mapped[dict] = mapped_column(_JsonType, default=dict)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
@ -104,6 +107,8 @@ class TopoFolder(Base):
name: Mapped[str] = mapped_column(String(256), default="", index=True) name: Mapped[str] = mapped_column(String(256), default="", index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
is_system: Mapped[bool] = mapped_column(Boolean, default=False) is_system: Mapped[bool] = mapped_column(Boolean, default=False)
# UME SBN uuid when folder is synced from TopoNodes; empty for manual regions.
external_ref: Mapped[str] = mapped_column(String(128), default="", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)

View file

@ -210,6 +210,8 @@ class UmeTopoLink(Base):
z_ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True) z_ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
a_ptp: Mapped[str] = mapped_column(String(256), default="") a_ptp: Mapped[str] = mapped_column(String(256), default="")
z_ptp: Mapped[str] = mapped_column(String(256), default="") z_ptp: Mapped[str] = mapped_column(String(256), default="")
a_ifname: Mapped[str] = mapped_column(String(128), default="", comment="Normalized CLI-like ifname")
z_ifname: Mapped[str] = mapped_column(String(128), default="", comment="Normalized CLI-like ifname")
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
raw_json: Mapped[str] = mapped_column(Text, default="{}") raw_json: Mapped[str] = mapped_column(Text, default="{}")

View file

@ -248,6 +248,14 @@ def apply_domain_schema_patches(conn: Connection) -> None:
"ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''", "ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''",
"ALTER TABLE ume_alarms_current ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''", "ALTER TABLE ume_alarms_current ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''",
"ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''", "ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''",
"ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS a_ifname VARCHAR(128) DEFAULT ''",
"ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS z_ifname VARCHAR(128) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION",
"ALTER TABLE topo_folder ADD COLUMN IF NOT EXISTS external_ref VARCHAR(128) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_topo_folder_external_ref ON topo_folder (external_ref)",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)",
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_notification_id ON ume_alarms_current (notification_id)", "CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_notification_id ON ume_alarms_current (notification_id)",
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_notification_id ON ume_alarms_history (notification_id)", "CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_notification_id ON ume_alarms_history (notification_id)",
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_host_name ON ume_alarms_current (host_name)", "CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_host_name ON ume_alarms_current (host_name)",
@ -386,6 +394,12 @@ def apply_domain_schema_patches(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_a_ume_ne_id ON ume_topo_link (a_ume_ne_id)", "CREATE INDEX IF NOT EXISTS ix_ume_topo_link_a_ume_ne_id ON ume_topo_link (a_ume_ne_id)",
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_z_ume_ne_id ON ume_topo_link (z_ume_ne_id)", "CREATE INDEX IF NOT EXISTS ix_ume_topo_link_z_ume_ne_id ON ume_topo_link (z_ume_ne_id)",
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_last_seen_at ON ume_topo_link (last_seen_at)", "CREATE INDEX IF NOT EXISTS ix_ume_topo_link_last_seen_at ON ume_topo_link (last_seen_at)",
"ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS a_ifname VARCHAR(128) DEFAULT ''",
"ALTER TABLE ume_topo_link ADD COLUMN IF NOT EXISTS z_ifname VARCHAR(128) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)",
"COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'", "COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'",
"COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'", "COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'",
"COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'", "COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'",

View file

@ -78,6 +78,9 @@ def upsert_fabric_edge(
Self-loops are skipped (``(None, \"skipped_self_loop\")``) so LLDP discovery can Self-loops are skipped (``(None, \"skipped_self_loop\")``) so LLDP discovery can
ignore a device advertising itself without aborting the rest of the scan. ignore a device advertising itself without aborting the rest of the scan.
Manual edge APIs should treat that action as a client error. Manual edge APIs should treat that action as a client error.
``source`` may be ``lldp`` | ``manual`` | ``ume``. Provenance is tracked in
``attrs.sources`` (union). Primary ``source`` column prefers manual > lldp > ume.
""" """
now = now or _utcnow() now = now or _utcnow()
a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port) a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port)
@ -87,7 +90,7 @@ def upsert_fabric_edge(
src = str(source or "lldp").strip().lower() or "lldp" src = str(source or "lldp").strip().lower() or "lldp"
if src == "stale": if src == "stale":
src = "lldp" src = "lldp"
if src not in {"lldp", "manual"}: if src not in {"lldp", "manual", "ume"}:
raise HTTPException(status_code=400, detail="invalid_edge_source") raise HTTPException(status_code=400, detail="invalid_edge_source")
row = ( row = (
db.query(TopoFabricEdge) db.query(TopoFabricEdge)
@ -112,8 +115,8 @@ def upsert_fabric_edge(
b_port=bp, b_port=bp,
source=src, source=src,
status="active", status="active",
attrs={}, attrs={"sources": [src]},
discovered_at=now if src == "lldp" else None, discovered_at=now if src in {"lldp", "ume"} else None,
last_seen_at=now, last_seen_at=now,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@ -135,18 +138,95 @@ def upsert_fabric_edge(
) )
if row is None: if row is None:
raise raise
if (row.source or "") == "manual" and src == "lldp": # Manual edges keep primary source=manual; still record other sources.
attrs = _clear_miss_attrs(_edge_attrs(row))
sources = _sources_from_attrs(attrs, fallback=row.source or src)
sources.add(src)
attrs["sources"] = sorted(sources)
if (row.source or "") == "manual" and src != "manual":
row.attrs = attrs
row.status = "active"
row.last_seen_at = now
row.updated_at = now
return row, "kept_manual" return row, "kept_manual"
row.source = src row.source = _primary_source(sources)
row.status = "active" row.status = "active"
row.attrs = _clear_miss_attrs(_edge_attrs(row)) row.attrs = attrs
if src == "lldp": if src in {"lldp", "ume"}:
row.discovered_at = row.discovered_at or now row.discovered_at = row.discovered_at or now
row.last_seen_at = now row.last_seen_at = now
row.updated_at = now row.updated_at = now
return row, "updated" return row, "updated"
def _sources_from_attrs(attrs: dict[str, Any], *, fallback: str = "") -> set[str]:
raw = attrs.get("sources") if isinstance(attrs, dict) else None
out: set[str] = set()
if isinstance(raw, (list, tuple, set)):
for item in raw:
s = str(item or "").strip().lower()
if s in {"lldp", "manual", "ume"}:
out.add(s)
fb = str(fallback or "").strip().lower()
if fb in {"lldp", "manual", "ume"}:
out.add(fb)
elif fb == "stale":
out.add("lldp")
return out
def _primary_source(sources: set[str]) -> str:
if "manual" in sources:
return "manual"
if "lldp" in sources:
return "lldp"
if "ume" in sources:
return "ume"
return "lldp"
def find_fabric_edge_compatible(
db: Session,
*,
a_node_id: str,
b_node_id: str,
a_port: str,
b_port: str,
layer: str = "physical",
) -> TopoFabricEdge | None:
"""Exact match first, then same endpoints with compatible port names."""
from .ume_port_normalize import port_keys_compatible
a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port)
layer_v = str(layer or "physical").strip() or "physical"
exact = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer_v,
TopoFabricEdge.a_node_id == a,
TopoFabricEdge.b_node_id == b,
TopoFabricEdge.a_port == ap,
TopoFabricEdge.b_port == bp,
)
.one_or_none()
)
if exact is not None:
return exact
candidates = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer_v,
TopoFabricEdge.a_node_id == a,
TopoFabricEdge.b_node_id == b,
)
.all()
)
for e in candidates:
if port_keys_compatible(e.a_port, ap) and port_keys_compatible(e.b_port, bp):
return e
return None
def _absorb_fabric_node(db: Session, canon: TopoFabricNode, dupe: TopoFabricNode) -> None: def _absorb_fabric_node(db: Session, canon: TopoFabricNode, dupe: TopoFabricNode) -> None:
"""Retarget edges/view placements from dupe onto canon, then delete dupe.""" """Retarget edges/view placements from dupe onto canon, then delete dupe."""
if canon is None or dupe is None or canon.id == dupe.id: if canon is None or dupe is None or canon.id == dupe.id:

View file

@ -43,6 +43,10 @@ def ensure_topology_schema(conn: Connection) -> None:
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION",
"ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)",
"CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)",
""" """
CREATE TABLE IF NOT EXISTS topo_folder ( CREATE TABLE IF NOT EXISTS topo_folder (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,

View file

@ -0,0 +1,227 @@
"""Child-region icons on a parent canvas (folder === canvas drill-down).
Synthetic placements use fabric_node_id ``region:<folder_id>`` on the parent's
TopoViewNode rows — same shape as UME level region nodes, persisted so drag/save works.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from .models import TopoFolder, TopoView, TopoViewNode
from .topology_common import _utcnow
from .topology_membership import VIEW_KIND_PHYSICAL, normalize_view_kind
from .topology_schemas import ViewNodeOut
REGION_NODE_PREFIX = "region:"
def is_region_canvas_node_id(fabric_node_id: str | None) -> bool:
return str(fabric_node_id or "").startswith(REGION_NODE_PREFIX)
def region_folder_id_from_node(fabric_node_id: str) -> str:
return str(fabric_node_id or "")[len(REGION_NODE_PREFIX) :]
def region_canvas_node_id(folder_id: str) -> str:
return f"{REGION_NODE_PREFIX}{folder_id}"
def _is_world_flat(view: TopoView) -> bool:
filt = dict(view.filter or {})
if filt.get("world_flat"):
return True
return str(view.name or "").strip() == "完整世界地图"
def primary_canvas_view(db: Session, folder_id: str) -> TopoView | None:
"""Parent canvas used for child-region icons (skip flat world map)."""
fid = str(folder_id or "").strip()
if not fid:
return None
views = (
db.query(TopoView)
.filter(TopoView.folder_id == fid)
.order_by(TopoView.sort_order.asc(), TopoView.created_at.asc())
.all()
)
usable = [v for v in views if not _is_world_flat(v)]
if not usable:
return None
for v in usable:
filt = dict(v.filter or {})
if filt.get("ume_level") or filt.get("world"):
return v
for v in usable:
if normalize_view_kind(v.kind) == VIEW_KIND_PHYSICAL:
return v
return usable[0]
def _child_primary_view(db: Session, folder_id: str) -> TopoView | None:
return primary_canvas_view(db, folder_id)
def _default_region_xy(db: Session, view_id: str) -> tuple[float, float]:
rows = db.query(TopoViewNode).filter(TopoViewNode.view_id == view_id).all()
if not rows:
return 160.0, 160.0
max_x = max(float(r.x or 0) for r in rows)
ys = [float(r.y or 0) for r in rows]
avg_y = sum(ys) / len(ys) if ys else 160.0
return max_x + 220.0, avg_y
def place_child_region_on_parent_canvas(
db: Session,
parent: TopoFolder,
child: TopoFolder,
*,
x: float | None = None,
y: float | None = None,
) -> TopoViewNode | None:
"""Put a region building icon on the parent's canvas. No-op for root parent."""
if parent is None or child is None:
return None
if str(parent.kind or "") != "region":
return None
view = primary_canvas_view(db, parent.id)
if view is None:
return None
nid = region_canvas_node_id(child.id)
existing = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == nid)
.first()
)
if existing is not None:
if child.name and existing.label != child.name:
existing.label = str(child.name)[:256]
existing.updated_at = _utcnow()
return existing
px, py = _default_region_xy(db, view.id) if x is None or y is None else (float(x), float(y))
now = _utcnow()
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=nid,
x=px,
y=py,
label=str(child.name or "")[:256],
locked=False,
created_at=now,
updated_at=now,
)
db.add(row)
return row
def remove_region_canvas_placements(db: Session, folder_id: str) -> int:
"""Drop this folder's icon from any parent canvas."""
nid = region_canvas_node_id(folder_id)
return (
db.query(TopoViewNode)
.filter(TopoViewNode.fabric_node_id == nid)
.delete(synchronize_session=False)
)
def region_node_out(
db: Session,
*,
folder: TopoFolder,
x: float,
y: float,
locked: bool = False,
label: str = "",
) -> ViewNodeOut:
child_view = _child_primary_view(db, folder.id)
kids = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder.id, TopoFolder.kind == "region")
.count()
)
# node_count: prefer view membership count when available
node_count = 0
if child_view is not None:
node_count = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == child_view.id)
.count()
)
display = (label or folder.name or folder.id)[:256]
return ViewNodeOut(
fabric_node_id=region_canvas_node_id(folder.id),
managed_ne_id="",
ume_ne_id="",
label=display,
x=float(x),
y=float(y),
locked=bool(locked),
name=str(folder.name or "")[:256],
ip="",
vendor="",
device_type="region",
kind="region",
folder_id=folder.id,
view_id=child_view.id if child_view else "",
node_count=int(node_count or kids or 0),
)
def _is_ume_managed_folder(folder: TopoFolder) -> bool:
"""UME World / drill / SBN folders are drawn by ume_topology_world_graph, not here."""
ext = str(getattr(folder, "external_ref", None) or "").strip()
return bool(ext)
def child_region_nodes_for_view(db: Session, view: TopoView) -> list[ViewNodeOut]:
"""Manual child regions as canvas icons (skip UME-synced SBN folders)."""
folder_id = str(view.folder_id or "").strip()
if not folder_id or _is_world_flat(view):
return []
children = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == folder_id, TopoFolder.kind == "region")
.order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc())
.all()
)
children = [c for c in children if not _is_ume_managed_folder(c)]
if not children:
return []
placements = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
if is_region_canvas_node_id(vn.fabric_node_id)
}
out: list[ViewNodeOut] = []
auto_x, auto_y = _default_region_xy(db, view.id)
for i, child in enumerate(children):
nid = region_canvas_node_id(child.id)
vn = placements.get(nid)
if vn is not None:
out.append(
region_node_out(
db,
folder=child,
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
label=str(vn.label or child.name or ""),
)
)
else:
out.append(
region_node_out(
db,
folder=child,
x=auto_x + i * 40,
y=auto_y + i * 20,
locked=False,
label=str(child.name or ""),
)
)
return out

View file

@ -291,10 +291,71 @@ def api_create_view(body: TopologyViewCreate, db: Session = Depends(get_db)) ->
@router.get("/views/{view_id}") @router.get("/views/{view_id}")
def api_get_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_get_view(
view_id: str,
min_x: float | None = Query(default=None),
max_x: float | None = Query(default=None),
min_y: float | None = Query(default=None),
max_y: float | None = Query(default=None),
sbn_id: str = Query(default=""),
folder_id: str = Query(default=""),
lod: str = Query(default="auto", description="auto|sbn|me"),
status: str = Query(default="active"),
db: Session = Depends(get_db),
) -> dict[str, Any]:
from .topology_views_tree import _get_view_or_404
from .ume_topology_world import is_ume_canvas_view
from .ume_topology_world_graph import get_ume_canvas_graph
row = _get_view_or_404(db, view_id)
if is_ume_canvas_view(row):
return get_ume_canvas_graph(
db,
view_id,
min_x=min_x,
max_x=max_x,
min_y=min_y,
max_y=max_y,
sbn_id=sbn_id,
folder_id=folder_id,
lod=lod,
status=status,
).model_dump()
return get_view_graph(db, view_id).model_dump() return get_view_graph(db, view_id).model_dump()
@router.get("/folders/{folder_id}/bbox")
def api_folder_bbox(folder_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
from .ume_topology_world import folder_bbox
box = folder_bbox(db, folder_id)
if box is None:
raise HTTPException(status_code=404, detail="bbox_unavailable")
return {"folder_id": folder_id, **box}
@router.get("/world")
def api_get_world(db: Session = Depends(get_db)) -> dict[str, Any]:
from .ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
ensure_ume_world_and_sbn_folders(db)
view = get_world_view(db)
if view is None:
raise HTTPException(status_code=404, detail="world_view_missing")
return {"view_id": view.id, "folder_id": view.folder_id or "", "name": view.name}
@router.post("/world/apply-ume")
def api_apply_ume_world(db: Session = Depends(get_db)) -> dict[str, Any]:
"""Manual: dock tables → fabric coords/edges + SBN folders (does not pull UME)."""
from .ume_topology_apply import apply_ume_topology_to_fabric
from .ume_topology_world import ensure_ume_world_and_sbn_folders
apply_stats = apply_ume_topology_to_fabric(db)
world_stats = ensure_ume_world_and_sbn_folders(db)
return {"ok": True, "fabric_apply": apply_stats, "world": world_stats}
@router.patch("/views/{view_id}") @router.patch("/views/{view_id}")
def api_patch_view( def api_patch_view(
view_id: str, body: TopologyViewUpdate, db: Session = Depends(get_db) view_id: str, body: TopologyViewUpdate, db: Session = Depends(get_db)

View file

@ -162,6 +162,7 @@ class TopologyFolderOut(BaseModel):
name: str name: str
sort_order: int = 0 sort_order: int = 0
is_system: bool = False is_system: bool = False
external_ref: str = ""
created_at: datetime | None = None created_at: datetime | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
@ -222,6 +223,7 @@ class TopologyTreeFolderOut(BaseModel):
name: str name: str
sort_order: int = 0 sort_order: int = 0
is_system: bool = False is_system: bool = False
external_ref: str = ""
views: list[TopologyTreeViewOut] = Field(default_factory=list) views: list[TopologyTreeViewOut] = Field(default_factory=list)
children: list["TopologyTreeFolderOut"] = Field(default_factory=list) children: list["TopologyTreeFolderOut"] = Field(default_factory=list)
@ -276,6 +278,11 @@ class ViewNodeOut(BaseModel):
device_type: str = "" device_type: str = ""
connect_status: str = "" connect_status: str = ""
managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | … managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | …
# ne | region — region nodes drill into child UME canvases
kind: str = "ne"
folder_id: str = ""
view_id: str = ""
node_count: int = 0
class ViewEdgeOut(BaseModel): class ViewEdgeOut(BaseModel):

View file

@ -144,7 +144,19 @@ def _batch_ne_lookups(
def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
from .topology_region_canvas import (
child_region_nodes_for_view,
is_region_canvas_node_id,
region_folder_id_from_node,
region_node_out,
)
from .ume_topology_world import is_ume_canvas_view
from .ume_topology_world_graph import get_ume_canvas_graph
view = _get_view_or_404(db, view_id) view = _get_view_or_404(db, view_id)
# Save/mutate return_graph must use the same UME synthetic graph as GET.
if is_ume_canvas_view(view):
return get_ume_canvas_graph(db, view_id)
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
truncated = False truncated = False
reason = "" reason = ""
@ -152,7 +164,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
vnodes = vnodes[:VIEW_GRAPH_NODE_HARD_CAP] vnodes = vnodes[:VIEW_GRAPH_NODE_HARD_CAP]
truncated = True truncated = True
reason = "too_many_view_nodes" reason = "too_many_view_nodes"
fids = [vn.fabric_node_id for vn in vnodes] fids = [vn.fabric_node_id for vn in vnodes if not is_region_canvas_node_id(vn.fabric_node_id)]
fabric_nodes = { fabric_nodes = {
n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all() n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all()
} if fids else {} } if fids else {}
@ -161,7 +173,24 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
layer = str(filt.get("layer") or "physical").strip() or "physical" layer = str(filt.get("layer") or "physical").strip() or "physical"
status = str(filt.get("status") or "").strip().lower() status = str(filt.get("status") or "").strip().lower()
nodes_out: list[ViewNodeOut] = [] nodes_out: list[ViewNodeOut] = []
region_ids_seen: set[str] = set()
for vn in vnodes: for vn in vnodes:
if is_region_canvas_node_id(vn.fabric_node_id):
folder = db.get(TopoFolder, region_folder_id_from_node(vn.fabric_node_id))
if folder is None or str(folder.kind or "") != "region":
continue
region_ids_seen.add(folder.id)
nodes_out.append(
region_node_out(
db,
folder=folder,
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
label=str(vn.label or folder.name or ""),
)
)
continue
fn = fabric_nodes.get(vn.fabric_node_id) fn = fabric_nodes.get(vn.fabric_node_id)
label = (vn.label or "").strip() label = (vn.label or "").strip()
if not label and fn is not None: if not label and fn is not None:
@ -194,6 +223,12 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
managed_source=managed_source, managed_source=managed_source,
) )
) )
# Child regions without a placement row still appear (legacy / missed create).
for rn in child_region_nodes_for_view(db, view):
fid = str(rn.folder_id or "").strip()
if fid and fid not in region_ids_seen:
nodes_out.append(rn)
region_ids_seen.add(fid)
edges_out: list[ViewEdgeOut] = [] edges_out: list[ViewEdgeOut] = []
if fids: if fids:
q = db.query(TopoFabricEdge).filter( q = db.query(TopoFabricEdge).filter(
@ -648,7 +683,9 @@ def patch_view_positions(
fid = str(p.fabric_node_id or "").strip() fid = str(p.fabric_node_id or "").strip()
if not fid: if not fid:
continue continue
if db.get(TopoFabricNode, fid) is None: from .topology_region_canvas import is_region_canvas_node_id
if not is_region_canvas_node_id(fid) and db.get(TopoFabricNode, fid) is None:
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}") raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
row = existing.get(fid) row = existing.get(fid)
if row is None: if row is None:
@ -695,6 +732,10 @@ def add_nodes_to_view(
db: Session, view_id: str, body: ViewNodesAdd db: Session, view_id: str, body: ViewNodesAdd
) -> ViewMutationOut | TopologyViewGraphOut: ) -> ViewMutationOut | TopologyViewGraphOut:
view = _get_view_or_404(db, view_id) view = _get_view_or_404(db, view_id)
from .ume_topology_world import is_world_flat_view
if is_world_flat_view(view):
raise HTTPException(status_code=400, detail="world_map_no_direct_nes")
mem = _membership_for_view(view) mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300) max_nodes = int(mem.get("max_nodes") or 300)
now = _utcnow() now = _utcnow()
@ -865,6 +906,10 @@ def create_topology_placeholder_on_view(
from .ne_service_common import _normalize_ip from .ne_service_common import _normalize_ip
view = _get_view_or_404(db, view_id) view = _get_view_or_404(db, view_id)
from .ume_topology_world import is_world_flat_view
if is_world_flat_view(view):
raise HTTPException(status_code=400, detail="world_map_no_direct_nes")
mem = _membership_for_view(view) mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300) max_nodes = int(mem.get("max_nodes") or 300)
existing_count = ( existing_count = (

View file

@ -82,6 +82,7 @@ def _folder_out(f: TopoFolder) -> TopologyFolderOut:
name=f.name or "", name=f.name or "",
sort_order=int(f.sort_order or 0), sort_order=int(f.sort_order or 0),
is_system=bool(f.is_system), is_system=bool(f.is_system),
external_ref=str(getattr(f, "external_ref", None) or ""),
created_at=f.created_at, created_at=f.created_at,
updated_at=f.updated_at, updated_at=f.updated_at,
) )
@ -238,12 +239,32 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
raise HTTPException(status_code=500, detail="topology_root_missing") raise HTTPException(status_code=500, detail="topology_root_missing")
parent_id = str(body.parent_id or "").strip() or root.id parent_id = str(body.parent_id or "").strip() or root.id
parent = _get_folder_or_404(db, parent_id) parent = _get_folder_or_404(db, parent_id)
if str(parent.kind or "") != "root": parent_kind = str(parent.kind or "")
raise HTTPException(status_code=400, detail="region_must_hang_under_root") if parent_kind not in ("root", "region"):
raise HTTPException(status_code=400, detail="invalid_parent_folder")
# UME World container: only one L2 (system drill). New regions remount under drill.
from .ume_topology_world import (
ensure_ume_world_and_sbn_folders,
get_world_drill_folder,
is_ume_world_container,
reconcile_world_flat_view,
)
if is_ume_world_container(parent):
drill = get_world_drill_folder(db)
if drill is None:
ensure_ume_world_and_sbn_folders(db)
drill = get_world_drill_folder(db)
if drill is None:
raise HTTPException(status_code=500, detail="world_drill_missing")
parent = drill
parent_kind = "region"
now = _utcnow() now = _utcnow()
row = TopoFolder( row = TopoFolder(
id=uuid4().hex, id=uuid4().hex,
parent_id=root.id, parent_id=parent.id,
kind="region", kind="region",
name=name[:256], name=name[:256],
sort_order=int(body.sort_order or 0), sort_order=int(body.sort_order or 0),
@ -253,6 +274,28 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
) )
db.add(row) db.add(row)
db.flush() db.flush()
# Nested / any new region gets a blank physical canvas so the tree can open it.
phys = TopoView(
id=uuid4().hex,
folder_id=row.id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=name[:200] or "Topology",
remark="",
sort_order=0,
filter={},
viewport={},
created_at=now,
updated_at=now,
)
db.add(phys)
# Nested region: place a building icon on the parent's canvas (region === canvas).
if parent_kind == "region":
from .topology_region_canvas import place_child_region_on_parent_canvas
place_child_region_on_parent_canvas(db, parent, row)
db.flush()
reconcile_world_flat_view(db)
db.commit() db.commit()
db.refresh(row) db.refresh(row)
return _folder_out(row) return _folder_out(row)
@ -273,12 +316,20 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To
raise HTTPException(status_code=400, detail="cannot_rename_system_folder") raise HTTPException(status_code=400, detail="cannot_rename_system_folder")
else: else:
row.name = name[:256] row.name = name[:256]
from .topology_region_canvas import region_canvas_node_id
nid = region_canvas_node_id(row.id)
for vn in (
db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == nid).all()
):
vn.label = name[:256]
vn.updated_at = _utcnow()
if body.sort_order is not None: if body.sort_order is not None:
row.sort_order = int(body.sort_order) row.sort_order = int(body.sort_order)
if body.parent_id is not None and str(row.kind or "") == "region": if body.parent_id is not None and str(row.kind or "") == "region":
parent = _get_folder_or_404(db, body.parent_id) parent = _get_folder_or_404(db, body.parent_id)
if str(parent.kind or "") != "root": if str(parent.kind or "") not in ("root", "region"):
raise HTTPException(status_code=400, detail="region_must_hang_under_root") raise HTTPException(status_code=400, detail="invalid_parent_folder")
row.parent_id = parent.id row.parent_id = parent.id
row.updated_at = _utcnow() row.updated_at = _utcnow()
db.commit() db.commit()
@ -295,6 +346,9 @@ def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[s
if str(row.kind or "") == "root" or bool(row.is_system): if str(row.kind or "") == "root" or bool(row.is_system):
raise HTTPException(status_code=400, detail="cannot_delete_system_folder") raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
_ = force _ = force
from .topology_region_canvas import remove_region_canvas_placements
remove_region_canvas_placements(db, row.id)
views = db.query(TopoView).filter(TopoView.folder_id == row.id).all() views = db.query(TopoView).filter(TopoView.folder_id == row.id).all()
for v in views: for v in views:
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete( db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
@ -306,6 +360,9 @@ def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[s
db.delete(v) db.delete(v)
db.flush() db.flush()
db.delete(row) db.delete(row)
from .ume_topology_world import reconcile_world_flat_view
reconcile_world_flat_view(db)
db.commit() db.commit()
return {"ok": True, "folder_id": folder_id, "deleted": True} return {"ok": True, "folder_id": folder_id, "deleted": True}
@ -321,6 +378,54 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
.all() .all()
): ):
nc_map[str(vid)] = int(cnt or 0) nc_map[str(vid)] = int(cnt or 0)
region_icon_map: dict[str, int] = {}
for vid, cnt in (
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
.filter(TopoViewNode.fabric_node_id.like("region:%"))
.group_by(TopoViewNode.view_id)
.all()
):
region_icon_map[str(vid)] = int(cnt or 0)
# UME canvases are virtual (no TopoViewNode membership) — count fabric MEs instead.
ume_ne_by_sbn: dict[str, int] = {}
ume_ne_by_folder: dict[str, int] = {}
ume_ne_total = 0
for fn in (
db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
.all()
):
ume_ne_total += 1
fid = str(fn.region_folder_id or "").strip()
if fid:
ume_ne_by_folder[fid] = ume_ne_by_folder.get(fid, 0) + 1
sid = str((fn.attrs or {}).get("ume_sbn_id") or "").strip()
if sid:
ume_ne_by_sbn[sid] = ume_ne_by_sbn.get(sid, 0) + 1
def _view_node_count(v: TopoView) -> int:
raw = int(nc_map.get(v.id, 0) or 0)
ne_nc = max(0, raw - int(region_icon_map.get(v.id, 0) or 0))
if ne_nc:
return ne_nc
filt = dict(v.filter or {})
if filt.get("world_flat") or (
bool(filt.get("world")) and not filt.get("ume_level")
):
return ume_ne_total
if filt.get("ume_level"):
sid = str(filt.get("sbn_id") or "").strip()
if sid:
return int(ume_ne_by_sbn.get(sid, 0))
# World drill root: all UME MEs.
if str(filt.get("parent") or "") == "md":
return ume_ne_total
if v.folder_id:
return int(ume_ne_by_folder.get(str(v.folder_id), 0))
if v.folder_id:
return int(ume_ne_by_folder.get(str(v.folder_id), 0))
return 0
by_parent: dict[str, list[TopoFolder]] = {} by_parent: dict[str, list[TopoFolder]] = {}
root: TopoFolder | None = None root: TopoFolder | None = None
@ -335,7 +440,19 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
for v in views: for v in views:
views_by_folder.setdefault(str(v.folder_id or ""), []).append(v) views_by_folder.setdefault(str(v.folder_id or ""), []).append(v)
def _flat_views(folder_views: list[TopoView]) -> list[TopologyTreeViewOut]: def _flat_views(folder: TopoFolder, folder_views: list[TopoView]) -> list[TopologyTreeViewOut]:
from .ume_topology_world import (
is_ume_world_container,
is_world_flat_view,
is_world_flat_visible,
world_map_should_exist,
)
# Soft-hide world map on UME World when rule 2A is not met.
show_flat = True
if is_ume_world_container(folder):
show_flat = world_map_should_exist(db)
# physical first, then custom; stable by sort_order/name. # physical first, then custom; stable by sort_order/name.
ordered = sorted( ordered = sorted(
folder_views, folder_views,
@ -346,18 +463,23 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
x.id, x.id,
), ),
) )
return [ out: list[TopologyTreeViewOut] = []
TopologyTreeViewOut( for v in ordered:
id=v.id, if is_world_flat_view(v):
name=v.name or "", if not show_flat or not is_world_flat_visible(v):
kind=normalize_view_kind(getattr(v, "kind", None)), continue
role=normalize_view_role(v.role), out.append(
sort_order=int(v.sort_order or 0), TopologyTreeViewOut(
node_count=nc_map.get(v.id, 0), id=v.id,
updated_at=v.updated_at, name=v.name or "",
kind=normalize_view_kind(getattr(v, "kind", None)),
role=normalize_view_role(v.role),
sort_order=int(v.sort_order or 0),
node_count=_view_node_count(v),
updated_at=v.updated_at,
)
) )
for v in ordered return out
]
def _build(folder: TopoFolder) -> TopologyTreeFolderOut: def _build(folder: TopoFolder) -> TopologyTreeFolderOut:
kids = [_build(c) for c in by_parent.get(folder.id, [])] kids = [_build(c) for c in by_parent.get(folder.id, [])]
@ -368,7 +490,8 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
name=folder.name or "", name=folder.name or "",
sort_order=int(folder.sort_order or 0), sort_order=int(folder.sort_order or 0),
is_system=bool(folder.is_system), is_system=bool(folder.is_system),
views=_flat_views(views_by_folder.get(folder.id, [])), external_ref=str(getattr(folder, "external_ref", None) or ""),
views=_flat_views(folder, views_by_folder.get(folder.id, [])),
children=kids, children=kids,
) )

View file

@ -0,0 +1,134 @@
"""Normalize UME TP DN / userLabel into CLI-like ifnames for Fabric merge."""
from __future__ import annotations
import re
from .topology_lldp import normalize_ifname
# xxvgei-1/1/0/32, xgei-0/0/1/4, gei-1/1/0/1, … (may follow `_` in userLabel)
_IFNAME_TOKEN_RE = re.compile(
r"(?i)(?<![A-Za-z0-9])((?:xxvgei|xgei|cgei|xlgei|lgei|gei|gi|eth)"
r"(?:-[\d./]+|[\d./]+))"
)
_ETH_COLON_RE = re.compile(r"(?i)\bETH:(\d+)\b")
_BRACKET_SLOT_RE = re.compile(r"\[(\d+/\d+/\d+)\]")
_EQ_SH_RE = re.compile(r"(?i)/sh=(\d+)")
_EQ_SL_RE = re.compile(r"(?i)/sl=(\d+)")
_EQ_SSL_RE = re.compile(r"(?i)/ssl=(\d+)")
_PTP_PORT_RE = re.compile(
r"(?i)PTP=\{/p=\d+_(\d+)\}|/p=\d+_(\d+)\b"
)
def extract_ifnames_from_user_label(label: str) -> list[str]:
"""Ordered unique CLI ifnames embedded in UME link userLabel."""
s = str(label or "")
out: list[str] = []
seen: set[str] = set()
for m in _IFNAME_TOKEN_RE.finditer(s):
raw = m.group(1).strip().rstrip(".,);:")
# Skip bare ETH: handled separately; require a digit somewhere.
if not any(ch.isdigit() for ch in raw):
continue
key = normalize_ifname(raw)
if not key or key in seen:
continue
seen.add(key)
out.append(key)
return out
def port_suffix_from_tp_ref(tp_ref: str) -> str:
"""Build shelf/slot/…/port suffix from EQ+PTP, e.g. ``1/1/0/32`` or ``0/0/1/4``."""
s = str(tp_ref or "")
if not s:
return ""
sh_m = _EQ_SH_RE.search(s)
sl_m = _EQ_SL_RE.search(s)
ssl_m = _EQ_SSL_RE.search(s)
p_m = _PTP_PORT_RE.search(s)
if not (sh_m and sl_m and p_m):
return ""
sh = sh_m.group(1)
sl = sl_m.group(1)
port = p_m.group(1) or p_m.group(2)
if ssl_m:
return f"{sh}/{sl}/{ssl_m.group(1)}/{port}"
return f"{sh}/{sl}/0/{port}"
def port_suffix_from_eth_label(label: str, *, end_index: int = 0) -> str:
"""From ``…[1/1/0]_ETH:28_…`` build ``1/1/0/28`` (nth ETH occurrence)."""
s = str(label or "")
slots = _BRACKET_SLOT_RE.findall(s)
eths = _ETH_COLON_RE.findall(s)
if not slots or not eths:
return ""
i = max(0, min(int(end_index), len(slots) - 1, len(eths) - 1))
return f"{slots[i]}/{eths[i]}"
def resolve_ume_ifname(
*,
tp_ref: str,
user_label: str = "",
end_index: int = 0,
) -> str:
"""Best-effort CLI ifname for one end of a UME link.
Preference: userLabel ifname token → ETH:+bracket → EQ+PTP suffix.
"""
tokens = extract_ifnames_from_user_label(user_label)
if tokens:
idx = max(0, min(int(end_index), len(tokens) - 1))
return tokens[idx][:128]
eth = port_suffix_from_eth_label(user_label, end_index=end_index)
if eth:
return normalize_ifname(eth)[:128]
suffix = port_suffix_from_tp_ref(tp_ref)
if suffix:
return normalize_ifname(suffix)[:128]
return ""
def resolve_link_ifnames(
*,
a_end_tp_ref: str,
z_end_tp_ref: str,
user_label: str = "",
) -> tuple[str, str]:
tokens = extract_ifnames_from_user_label(user_label)
if len(tokens) >= 2:
return tokens[0][:128], tokens[1][:128]
if len(tokens) == 1:
a = tokens[0]
z = resolve_ume_ifname(tp_ref=z_end_tp_ref, user_label=user_label, end_index=1)
return a[:128], z[:128]
return (
resolve_ume_ifname(tp_ref=a_end_tp_ref, user_label=user_label, end_index=0),
resolve_ume_ifname(tp_ref=z_end_tp_ref, user_label=user_label, end_index=1),
)
def port_keys_compatible(a: str, b: str) -> bool:
"""True if two ports are the same after normalize, or share a numeric suffix."""
na = normalize_ifname(a)
nb = normalize_ifname(b)
if not na or not nb:
return False
if na == nb:
return True
# xxvgei-1/1/0/32 vs 1/1/0/32
if na.endswith(nb) or nb.endswith(na):
return True
# strip alpha prefix before first digit
def _num_tail(s: str) -> str:
for i, ch in enumerate(s):
if ch.isdigit():
return s[i:]
return s
return _num_tail(na) == _num_tail(nb) and bool(_num_tail(na))

View file

@ -1,4 +1,4 @@
"""UME TopoNodes + TopologicalLinks sync (phase-1: local tables only).""" """UME TopoNodes + TopologicalLinks sync, then apply into Fabric world map."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@ -17,6 +17,7 @@ from .db import SessionLocal
from .models import UmeSyncJob, UmeTopoLink, UmeTopoNode from .models import UmeSyncJob, UmeTopoLink, UmeTopoNode
from .ume_client import UMEClient from .ume_client import UMEClient
from .ume_raw import dumps_ume_raw from .ume_raw import dumps_ume_raw
from .ume_port_normalize import resolve_link_ifnames
from .ume_sync_common import _pick, _s, _utc_now_naive from .ume_sync_common import _pick, _s, _utc_now_naive
from .ume_sync_pull import _build_sync_job from .ume_sync_pull import _build_sync_job
@ -371,6 +372,12 @@ def _upsert_topo_link(work: Session, link_id: str, row: dict[str, Any], *, now)
existing.z_ume_ne_id = extract_me_uuid(z_ref)[:128] existing.z_ume_ne_id = extract_me_uuid(z_ref)[:128]
existing.a_ptp = extract_ptp(a_ref) existing.a_ptp = extract_ptp(a_ref)
existing.z_ptp = extract_ptp(z_ref) existing.z_ptp = extract_ptp(z_ref)
label = _s(_pick(row, "userLabel", "user-label", "user_label"))
a_if, z_if = resolve_link_ifnames(
a_end_tp_ref=a_ref, z_end_tp_ref=z_ref, user_label=label
)
existing.a_ifname = a_if[:128]
existing.z_ifname = z_if[:128]
existing.last_seen_at = now existing.last_seen_at = now
existing.raw_json = dumps_ume_raw(row) existing.raw_json = dumps_ume_raw(row)
@ -412,7 +419,7 @@ def _delete_missing_ids(work: Session, model, pk_col, seen_ids: set[str]) -> int
def sync_topology_full(db: Session, client: UMEClient, *, trigger_mode: str = "manual") -> UmeSyncJob: def sync_topology_full(db: Session, client: UMEClient, *, trigger_mode: str = "manual") -> UmeSyncJob:
"""Pull TopoNodes + TopologicalLinks into local tables (no Fabric apply).""" """Pull TopoNodes + TopologicalLinks into dock tables, then apply into Fabric."""
if not _TOPOLOGY_SYNC_LOCK.acquire(blocking=False): if not _TOPOLOGY_SYNC_LOCK.acquire(blocking=False):
raise RuntimeError("topology_sync_busy") raise RuntimeError("topology_sync_busy")
@ -522,6 +529,28 @@ def sync_topology_full(db: Session, client: UMEClient, *, trigger_mode: str = "m
links_del = _delete_missing_ids(work, UmeTopoLink, UmeTopoLink.link_id, seen_links) links_del = _delete_missing_ids(work, UmeTopoLink, UmeTopoLink.link_id, seen_links)
work.commit() work.commit()
apply_stats: dict[str, Any] = {}
try:
from .ume_topology_apply import apply_ume_topology_to_fabric
from .ume_topology_world import ensure_ume_world_and_sbn_folders
_sync_log.info("topology sync job %s: applying dock → fabric…", job_id)
apply_db = _session_factory_for(db)()
try:
apply_stats = apply_ume_topology_to_fabric(apply_db)
world_stats = ensure_ume_world_and_sbn_folders(apply_db)
apply_stats = {**apply_stats, "world": world_stats}
finally:
try:
apply_db.close()
except Exception:
pass
except Exception:
_sync_log.exception(
"topology sync job %s: fabric apply failed (dock sync kept)",
job_id,
)
pulled = nodes_pulled + links_pulled pulled = nodes_pulled + links_pulled
inserted = nodes_ins + links_ins inserted = nodes_ins + links_ins
updated = nodes_upd + links_upd updated = nodes_upd + links_upd
@ -547,6 +576,7 @@ def sync_topology_full(db: Session, client: UMEClient, *, trigger_mode: str = "m
}, },
"deleted_topo_nodes": nodes_del, "deleted_topo_nodes": nodes_del,
"deleted_topo_links": links_del, "deleted_topo_links": links_del,
"fabric_apply": apply_stats,
}, },
ensure_ascii=False, ensure_ascii=False,
) )

View file

@ -0,0 +1,247 @@
"""Apply docked UME topology into Fabric (local attrs + edges, union with LLDP)."""
from __future__ import annotations
import logging
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from .models import TopoFabricEdge, TopoFabricNode, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from .topology_common import (
_edge_attrs,
_purge_edge_if_due,
_set_edge_missing,
_utcnow,
)
from .topology_fabric_links import (
_primary_source,
_sources_from_attrs,
find_fabric_edge_compatible,
upsert_fabric_edge,
)
from .topology_fabric_nodes import ensure_fabric_node_for_ume, refresh_fabric_stats
from .topology_lldp import normalize_ifname
from .ume_port_normalize import resolve_link_ifnames
_log = logging.getLogger("netx.ume.topo_apply")
def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
"""Upsert UME ME local coords/links into Fabric; reconcile ume provenance only.
Local UME xPos/yPos are stored in attrs (ume_local_*), not as global world_*.
Flat-world packing runs via ``recompute_flat_world_coords`` after apply.
Edge presence = UME ∪ LLDP ∪ manual. Removing ume from dump only drops the
``ume`` source mark; if no sources remain, mark missing (未发现).
"""
now = _utcnow()
stats: dict[str, Any] = {
"nodes_seen": 0,
"nodes_ensured": 0,
"nodes_coords": 0,
"links_seen": 0,
"edges_upserted": 0,
"edges_merged": 0,
"edges_skipped": 0,
"ume_source_cleared": 0,
"edges_missing": 0,
"edges_purged": 0,
"flat_coords": {},
}
inv_by_id = {
str(r.ne_id): r
for r in db.query(UmeInventoryNE).all()
if str(r.ne_id or "").strip()
}
fabric_by_ume: dict[str, TopoFabricNode] = {
str(n.ume_ne_id): n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all()
if str(n.ume_ne_id or "").strip()
}
me_nodes = (
db.query(UmeTopoNode)
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
.all()
)
for tn in me_nodes:
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
stats["nodes_seen"] += 1
inv = inv_by_id.get(uid)
if inv is not None:
fn = ensure_fabric_node_for_ume(db, inv)
else:
fn = fabric_by_ume.get(uid)
if fn is None:
fn = TopoFabricNode(
id=uuid4().hex,
managed_ne_id=None,
ume_ne_id=uid,
name=(tn.user_label or uid)[:256],
ip="",
vendor="ZTE",
device_type="zte_zxros",
attrs={},
last_seen_at=now,
created_at=now,
updated_at=now,
)
db.add(fn)
db.flush()
else:
if tn.user_label:
fn.name = str(tn.user_label)[:256]
fn.last_seen_at = now
fn.updated_at = now
stats["nodes_ensured"] += 1
attrs = dict(fn.attrs or {})
sources = set(attrs.get("sources") or [])
if not isinstance(sources, set):
sources = {str(x) for x in (sources or []) if str(x).strip()}
sources.add("ume")
attrs["sources"] = sorted(sources)
if tn.x_pos is not None:
attrs["ume_local_x"] = float(tn.x_pos)
stats["nodes_coords"] += 1
if tn.y_pos is not None:
attrs["ume_local_y"] = float(tn.y_pos)
if tn.x_pos is None:
stats["nodes_coords"] += 1
if tn.parent_node:
attrs["ume_parent_node"] = str(tn.parent_node)[:512]
attrs["ume_sbn_id"] = str(tn.parent_node)[:128]
fn.attrs = attrs
fabric_by_ume[uid] = fn
db.flush()
seen_edge_ids: set[str] = set()
links = db.query(UmeTopoLink).all()
for link in links:
stats["links_seen"] += 1
a_uid = str(link.a_ume_ne_id or "").strip()
z_uid = str(link.z_ume_ne_id or "").strip()
if not a_uid or not z_uid or a_uid == z_uid:
stats["edges_skipped"] += 1
continue
a_fn = fabric_by_ume.get(a_uid)
z_fn = fabric_by_ume.get(z_uid)
if a_fn is None or z_fn is None:
stats["edges_skipped"] += 1
continue
a_if = str(link.a_ifname or "").strip()
z_if = str(link.z_ifname or "").strip()
if not a_if or not z_if:
a_if, z_if = resolve_link_ifnames(
a_end_tp_ref=link.a_end_tp_ref or "",
z_end_tp_ref=link.z_end_tp_ref or "",
user_label=link.user_label or "",
)
a_if = normalize_ifname(a_if)
z_if = normalize_ifname(z_if)
if not a_if or not z_if:
stats["edges_skipped"] += 1
continue
existing = find_fabric_edge_compatible(
db,
a_node_id=a_fn.id,
b_node_id=z_fn.id,
a_port=a_if,
b_port=z_if,
)
if existing is not None:
# Merge onto existing LLDP/manual edge (keep its port strings).
attrs = _edge_attrs(existing)
sources = _sources_from_attrs(attrs, fallback=existing.source or "")
sources.add("ume")
attrs["sources"] = sorted(sources)
attrs["ume_link_id"] = str(link.link_id or "")[:128]
existing.attrs = _clear_and_keep(attrs)
if (existing.source or "") != "manual":
existing.source = _primary_source(sources)
existing.status = "active"
existing.last_seen_at = now
existing.updated_at = now
if existing.discovered_at is None:
existing.discovered_at = now
seen_edge_ids.add(existing.id)
stats["edges_merged"] += 1
continue
edge, action = upsert_fabric_edge(
db,
a_node_id=a_fn.id,
b_node_id=z_fn.id,
a_port=a_if,
b_port=z_if,
source="ume",
now=now,
)
if edge is None:
stats["edges_skipped"] += 1
continue
attrs = _edge_attrs(edge)
attrs["ume_link_id"] = str(link.link_id or "")[:128]
edge.attrs = attrs
seen_edge_ids.add(edge.id)
stats["edges_upserted"] += 1
if action == "kept_manual":
stats["edges_merged"] += 1
# Drop ume provenance from edges not seen this round.
for edge in db.query(TopoFabricEdge).all():
attrs = _edge_attrs(edge)
sources = _sources_from_attrs(attrs, fallback=edge.source or "")
if "ume" not in sources and str(edge.source or "").lower() != "ume":
continue
if edge.id in seen_edge_ids:
continue
sources.discard("ume")
stats["ume_source_cleared"] += 1
if "manual" in sources:
attrs["sources"] = sorted(sources)
edge.attrs = attrs
edge.source = "manual"
edge.updated_at = now
continue
if "lldp" in sources:
attrs["sources"] = sorted(sources)
edge.attrs = attrs
edge.source = "lldp"
# Keep active — LLDP still claims the edge until discover marks missing.
edge.updated_at = now
continue
# No remaining discovery source → 未发现
attrs["sources"] = []
edge.attrs = attrs
if _set_edge_missing(edge, now):
stats["edges_missing"] += 1
if _purge_edge_if_due(db, edge):
stats["edges_purged"] += 1
db.commit()
try:
from .ume_topology_flat_coords import recompute_flat_world_coords
stats["flat_coords"] = recompute_flat_world_coords(db)
except Exception:
_log.exception("recompute_flat_world_coords after ume apply failed")
try:
refresh_fabric_stats(db)
except Exception:
_log.exception("refresh_fabric_stats after ume apply failed")
_log.info("ume topology apply done: %s", stats)
return stats
def _clear_and_keep(attrs: dict[str, Any]) -> dict[str, Any]:
from .topology_common import _clear_miss_attrs
return _clear_miss_attrs(attrs)

View file

@ -0,0 +1,186 @@
"""Compose non-overlapping flat-world coordinates by packing per-region ME blocks."""
from __future__ import annotations
import logging
import math
from collections import defaultdict
from typing import Any
from sqlalchemy.orm import Session
from .models import TopoFabricNode, TopoFolder, UmeTopoNode
from .topology_common import _utcnow
_log = logging.getLogger("netx.ume.topo_flat")
# Gap between packed region blocks (same units as UME local coords).
SLOT_PAD = 600.0
def recompute_flat_world_coords(db: Session) -> dict[str, Any]:
"""Pack each ME-owning region cluster into a non-overlapping block.
Block key = ME's owning region (ume_sbn_id / region_folder_id), typically the
leaf SBN that directly parents the ME — not collapsed to the L2 World child.
Preserves relative geometry inside a block; area-sorted strip packing prevents
city-on-city stacking.
Does not clear TopoViewNode drag overrides (display layer merges those).
"""
from .ume_topology_world import get_world_drill_folder, world_map_should_exist
now = _utcnow()
stats: dict[str, Any] = {"slots": 0, "nodes": 0, "cleared": 0, "skipped": False}
fabric_by_ume: dict[str, TopoFabricNode] = {
str(n.ume_ne_id): n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all()
if str(n.ume_ne_id or "").strip()
}
# Only clear when World tree exists but rule 2A says no world map.
# Before seed (no drill), still pack so apply() can set world_* coords.
drill = get_world_drill_folder(db)
if drill is not None and not world_map_should_exist(db):
for fn in fabric_by_ume.values():
attrs = dict(fn.attrs or {})
if "ume" not in set(attrs.get("sources") or []):
continue
if fn.world_x is not None or fn.world_y is not None:
fn.world_x = None
fn.world_y = None
stats["cleared"] += 1
db.commit()
stats["skipped"] = True
_log.info("flat world coords skipped (2A not met): %s", stats)
return stats
folders_by_ref: dict[str, TopoFolder] = {
str(f.external_ref): f
for f in db.query(TopoFolder)
.filter(TopoFolder.external_ref.isnot(None), TopoFolder.external_ref != "")
.all()
if str(f.external_ref or "").strip() and not str(f.external_ref).startswith("ume:")
}
# Block key → list of (local_x, local_y, fabric_node)
blocks: dict[str, list[tuple[float, float, TopoFabricNode]]] = defaultdict(list)
me_rows = (
db.query(UmeTopoNode)
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
.all()
)
for tn in me_rows:
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
fn = fabric_by_ume.get(uid)
if fn is None:
continue
lx = float(tn.x_pos) if tn.x_pos is not None else float((fn.attrs or {}).get("ume_local_x") or 0)
ly = float(tn.y_pos) if tn.y_pos is not None else float((fn.attrs or {}).get("ume_local_y") or 0)
parent = str(tn.parent_node or "").strip() or "_orphan"
blocks[parent].append((lx, ly, fn))
# Manual fabric NEs tagged to a region folder (non-UME).
for fn in db.query(TopoFabricNode).filter(TopoFabricNode.region_folder_id.isnot(None)).all():
if fn.ume_ne_id and str(fn.ume_ne_id) in fabric_by_ume:
# Already handled via UME topo rows when present.
already = False
for members in blocks.values():
if any(x[2].id == fn.id for x in members):
already = True
break
if already:
continue
attrs = dict(fn.attrs or {})
sid = str(attrs.get("ume_sbn_id") or "").strip()
key = sid or f"folder:{fn.region_folder_id}"
lx = float(attrs.get("ume_local_x") or 0)
ly = float(attrs.get("ume_local_y") or 0)
if any(x[2].id == fn.id for x in blocks[key]):
continue
blocks[key].append((lx, ly, fn))
if not blocks:
db.commit()
_log.info("flat world coords: no blocks")
return stats
slot_meta: dict[str, dict[str, float]] = {}
for key, members in blocks.items():
xs = [m[0] for m in members]
ys = [m[1] for m in members]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
w = max(max_x - min_x, 1.0) + SLOT_PAD
h = max(max_y - min_y, 1.0) + SLOT_PAD
slot_meta[key] = {
"min_x": min_x,
"min_y": min_y,
"w": w,
"h": h,
"area": w * h,
}
# Area-descending strip pack into ~sqrt(n) columns (largest first).
keys = sorted(blocks.keys(), key=lambda k: (-slot_meta[k]["area"], k))
n = max(1, len(keys))
cols = max(1, int(math.ceil(math.sqrt(n))))
col_widths: list[float] = [0.0] * cols
row_heights: dict[int, float] = defaultdict(float)
for i, key in enumerate(keys):
c, r = i % cols, i // cols
meta = slot_meta[key]
col_widths[c] = max(col_widths[c], meta["w"])
row_heights[r] = max(row_heights[r], meta["h"])
col_origin = [0.0]
for c in range(cols - 1):
col_origin.append(col_origin[-1] + col_widths[c])
row_origin: dict[int, float] = {0: 0.0}
for r in range(1, (n // cols) + 2):
row_origin[r] = row_origin.get(r - 1, 0.0) + row_heights.get(r - 1, 0.0)
origins: dict[str, tuple[float, float]] = {}
for i, key in enumerate(keys):
c, r = i % cols, i // cols
origins[key] = (col_origin[c], row_origin[r])
stats["slots"] += 1
touched: set[str] = set()
for key, members in blocks.items():
ox, oy = origins[key]
meta = slot_meta[key]
folder = folders_by_ref.get(key)
for lx, ly, fn in members:
attrs = dict(fn.attrs or {})
attrs["ume_local_x"] = lx
attrs["ume_local_y"] = ly
if key and not key.startswith("folder:") and not key.startswith("_"):
attrs["ume_sbn_id"] = key[:128]
if folder is not None:
fn.region_folder_id = folder.id
fn.region_source = fn.region_source or "ume"
fn.attrs = attrs
fn.world_x = ox + (lx - meta["min_x"])
fn.world_y = oy + (ly - meta["min_y"])
fn.updated_at = now
touched.add(fn.id)
stats["nodes"] += 1
for fn in fabric_by_ume.values():
if fn.id in touched:
continue
attrs = dict(fn.attrs or {})
if "ume" not in set(attrs.get("sources") or []):
continue
fn.world_x = None
fn.world_y = None
stats["cleared"] += 1
db.commit()
_log.info("flat world coords recomputed: %s", stats)
return stats

View file

@ -0,0 +1,564 @@
"""Seed UME hierarchical canvases: World (level) + World Flat + per-SBN views."""
from __future__ import annotations
import logging
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from .models import TopoFabricNode, TopoFolder, TopoView, UmeTopoNode
from .topology_common import _utcnow
from .topology_membership import VIEW_KIND_PHYSICAL
from .topology_views_tree import bootstrap_topology_tree
_log = logging.getLogger("netx.ume.topo_world")
WORLD_FOLDER_NAME = "UME World"
WORLD_DRILL_FOLDER_NAME = "World"
WORLD_FOLDER_REF = "ume:world"
WORLD_DRILL_REF = "ume:world:drill"
WORLD_VIEW_NAME = "World"
WORLD_FLAT_VIEW_NAME = "完整世界地图"
# Drill-down root: children of MD (root SBNs + rare direct MEs).
WORLD_LEVEL_FILTER = {"ume_level": True, "parent": "md"}
WORLD_FLAT_FILTER = {"world_flat": True}
def _filt(view: TopoView | None) -> dict[str, Any]:
if view is None:
return {}
return dict(view.filter or {})
def is_ume_level_view(view: TopoView | None) -> bool:
return bool(_filt(view).get("ume_level"))
def is_world_flat_view(view: TopoView | None) -> bool:
filt = _filt(view)
return bool(filt.get("world_flat")) or str(getattr(view, "name", "") or "") == WORLD_FLAT_VIEW_NAME
def is_ume_world_container(folder: TopoFolder | None) -> bool:
if folder is None:
return False
ext = str(getattr(folder, "external_ref", None) or "").strip()
if ext == WORLD_FOLDER_REF:
return True
return str(folder.name or "").strip() == WORLD_FOLDER_NAME and bool(folder.is_system)
def is_world_drill_folder(folder: TopoFolder | None) -> bool:
if folder is None:
return False
ext = str(getattr(folder, "external_ref", None) or "").strip()
return ext == WORLD_DRILL_REF or (
str(folder.name or "") == WORLD_DRILL_FOLDER_NAME and bool(folder.is_system)
)
def is_world_view(view: TopoView | None) -> bool:
"""True for UME World drill-root (legacy `world` key or ume_level parent=md)."""
if view is None:
return False
filt = _filt(view)
if filt.get("world_flat"):
return False
if filt.get("ume_level") and str(filt.get("parent") or "") == "md":
return True
# Legacy flat-world filter — treat as flat, not drill root.
if bool(filt.get("world")) and not filt.get("ume_level"):
return False
return str(view.name or "") == WORLD_VIEW_NAME and not is_world_flat_view(view)
def is_ume_canvas_view(view: TopoView | None) -> bool:
"""Any UME-driven virtual canvas (level or flat) — bypass membership graph."""
if view is None:
return False
filt = _filt(view)
if filt.get("ume_level") or filt.get("world_flat"):
return True
# Legacy world viewport
return bool(filt.get("world"))
def get_world_container_folder(db: Session) -> TopoFolder | None:
for f in db.query(TopoFolder).filter(TopoFolder.kind == "region").all():
if is_ume_world_container(f):
return f
return None
def get_world_drill_folder(db: Session) -> TopoFolder | None:
row = (
db.query(TopoFolder)
.filter(TopoFolder.external_ref == WORLD_DRILL_REF)
.first()
)
if row is not None:
return row
for f in db.query(TopoFolder).filter(TopoFolder.kind == "region").all():
if is_world_drill_folder(f):
return f
return None
def get_world_view(db: Session) -> TopoView | None:
"""Drill-down World root canvas (not the flat all-NE map)."""
for v in db.query(TopoView).all():
if is_world_view(v) or (
is_ume_level_view(v) and str(_filt(v).get("parent") or "") == "md"
):
return v
return None
def get_world_flat_view(db: Session) -> TopoView | None:
for v in db.query(TopoView).all():
if is_world_flat_view(v):
return v
return None
def _folder_subtree_ids(db: Session, folder_id: str) -> set[str]:
all_folders = db.query(TopoFolder).all()
children: dict[str | None, list[str]] = {}
for f in all_folders:
children.setdefault(f.parent_id, []).append(f.id)
ids = {folder_id}
stack = [folder_id]
while stack:
cur = stack.pop()
for cid in children.get(cur, []):
if cid not in ids:
ids.add(cid)
stack.append(cid)
return ids
def _child_region_has_nes(db: Session, child: TopoFolder) -> bool:
"""True when the child region subtree owns at least one fabric NE."""
ids = _folder_subtree_ids(db, child.id)
n = (
db.query(TopoFabricNode)
.filter(TopoFabricNode.region_folder_id.in_(list(ids)))
.limit(1)
.first()
)
if n is not None:
return True
ref = str(getattr(child, "external_ref", None) or "").strip()
if ref and not ref.startswith("ume:"):
for fn in (
db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
.all()
):
if str((fn.attrs or {}).get("ume_sbn_id") or "") == ref:
return True
if str(fn.region_folder_id or "") == child.id:
return True
return False
def world_map_should_exist(db: Session) -> bool:
"""Rule 2A: L2 has nested child regions and at least one of those has NEs.
If the unique L2 canvas only has direct NEs (no child regions), world map is
redundant with the L2 canvas and must not appear.
"""
drill = get_world_drill_folder(db)
if drill is None:
return False
children = (
db.query(TopoFolder)
.filter(TopoFolder.parent_id == drill.id, TopoFolder.kind == "region")
.all()
)
if not children:
return False
return any(_child_region_has_nes(db, c) for c in children)
def reconcile_world_flat_view(db: Session) -> TopoView | None:
"""Create flat view when 2A holds; keep row but mark suppressed when not.
Soft-hide via filter.suppressed so drag overrides on TopoViewNode survive.
"""
container = get_world_container_folder(db)
if container is None:
return None
should = world_map_should_exist(db)
existing = get_world_flat_view(db)
now = _utcnow()
if should:
if existing is None:
existing = _ensure_view(
db,
folder_id=container.id,
name=WORLD_FLAT_VIEW_NAME,
filt=dict(WORLD_FLAT_FILTER),
remark="All NEs with composed flat-world coordinates (no regions)",
sort_order=1,
match=is_world_flat_view,
)
else:
existing.folder_id = container.id
filt = dict(existing.filter or {})
filt.update(WORLD_FLAT_FILTER)
filt.pop("suppressed", None)
existing.filter = filt
existing.name = WORLD_FLAT_VIEW_NAME
existing.updated_at = now
return existing
if existing is not None:
filt = dict(existing.filter or {})
filt.update(WORLD_FLAT_FILTER)
filt["suppressed"] = True
existing.filter = filt
existing.updated_at = now
return existing
def is_world_flat_visible(view: TopoView | None) -> bool:
"""Tree/hex should show flat only when not suppressed and 2A would hold."""
if view is None or not is_world_flat_view(view):
return False
if bool(_filt(view).get("suppressed")):
return False
return True
def _ensure_view(
db: Session,
*,
folder_id: str,
name: str,
filt: dict[str, Any],
remark: str,
sort_order: int,
match,
) -> TopoView:
"""Find view under folder matching predicate, or create."""
now = _utcnow()
found: TopoView | None = None
for v in db.query(TopoView).filter(TopoView.folder_id == folder_id).all():
if match(v):
found = v
break
if found is None:
found = TopoView(
id=uuid4().hex,
folder_id=folder_id,
kind=VIEW_KIND_PHYSICAL,
role="core",
name=name,
remark=remark,
sort_order=sort_order,
filter=dict(filt),
viewport={},
created_at=now,
updated_at=now,
)
db.add(found)
db.flush()
return found
found.kind = VIEW_KIND_PHYSICAL
found.name = name
found.remark = remark
found.sort_order = sort_order
cur = dict(found.filter or {})
cur.update(filt)
found.filter = cur
found.updated_at = now
return found
def ensure_ume_world_and_sbn_folders(db: Session) -> dict[str, Any]:
"""Create/update UME World container + unique L2 World drill + conditional Flat.
Tree shape::
UME World (nav only — hex browse)
├─ World (unique L2 folder ume:world:drill — SBNs hang here)
│ └─ <root SBNs…>
└─ 完整世界地图 (flat view — only when rule 2A holds)
"""
now = _utcnow()
stats: dict[str, Any] = {
"world_folder_id": "",
"world_drill_folder_id": "",
"world_view_id": "",
"world_flat_view_id": "",
"world_map_visible": False,
"sbn_folders": 0,
"sbn_updated": 0,
"sbn_views": 0,
}
bootstrap_topology_tree(db)
root = db.query(TopoFolder).filter(TopoFolder.kind == "root").first()
if root is None:
raise RuntimeError("topology_root_missing")
world_folder = (
db.query(TopoFolder)
.filter(TopoFolder.kind == "region", TopoFolder.name == WORLD_FOLDER_NAME)
.first()
)
if world_folder is None:
world_folder = TopoFolder(
id=uuid4().hex,
parent_id=root.id,
kind="region",
name=WORLD_FOLDER_NAME,
sort_order=0,
is_system=True,
external_ref=WORLD_FOLDER_REF,
created_at=now,
updated_at=now,
)
db.add(world_folder)
db.flush()
else:
world_folder.is_system = True
world_folder.external_ref = world_folder.external_ref or WORLD_FOLDER_REF
world_folder.updated_at = now
# Drill folder "World" under UME World — owns the level canvas + SBN children.
drill_folder = (
db.query(TopoFolder)
.filter(TopoFolder.external_ref == WORLD_DRILL_REF)
.first()
)
if drill_folder is None:
drill_folder = (
db.query(TopoFolder)
.filter(
TopoFolder.parent_id == world_folder.id,
TopoFolder.name == WORLD_DRILL_FOLDER_NAME,
TopoFolder.is_system.is_(True),
)
.first()
)
if drill_folder is None:
drill_folder = TopoFolder(
id=uuid4().hex,
parent_id=world_folder.id,
kind="region",
name=WORLD_DRILL_FOLDER_NAME,
sort_order=0,
is_system=True,
external_ref=WORLD_DRILL_REF,
created_at=now,
updated_at=now,
)
db.add(drill_folder)
db.flush()
else:
drill_folder.parent_id = world_folder.id
drill_folder.name = WORLD_DRILL_FOLDER_NAME
drill_folder.is_system = True
drill_folder.external_ref = WORLD_DRILL_REF
drill_folder.sort_order = 0
drill_folder.updated_at = now
world_view = _ensure_view(
db,
folder_id=drill_folder.id,
name=WORLD_VIEW_NAME,
filt=dict(WORLD_LEVEL_FILTER),
remark="UME drill-down root (child regions + direct MEs)",
sort_order=0,
match=lambda v: is_world_view(v)
or (is_ume_level_view(v) and str(_filt(v).get("parent") or "") == "md")
or (bool(_filt(v).get("world")) and not _filt(v).get("world_flat")),
)
# Migrate legacy World view that still hangs on UME World container.
for v in db.query(TopoView).filter(TopoView.folder_id == world_folder.id).all():
if is_world_flat_view(v):
continue
if is_world_view(v) or (
is_ume_level_view(v) and str(_filt(v).get("parent") or "") == "md"
):
if v.id != world_view.id:
v.folder_id = drill_folder.id
vf = dict(v.filter or {})
vf.pop("world", None)
vf.pop("mode", None)
vf.update(WORLD_LEVEL_FILTER)
v.filter = vf
v.updated_at = now
wf = dict(world_view.filter or {})
wf.pop("world", None)
wf.pop("mode", None)
wf.update(WORLD_LEVEL_FILTER)
world_view.filter = wf
world_view.folder_id = drill_folder.id
stats["world_folder_id"] = world_folder.id
stats["world_drill_folder_id"] = drill_folder.id
stats["world_view_id"] = world_view.id
sbns = (
db.query(UmeTopoNode)
.filter(UmeTopoNode.node_type == "TOPO_NODE_SBN")
.all()
)
sbn_ids = {str(s.node_id) for s in sbns if str(s.node_id or "").strip()}
by_ref: dict[str, TopoFolder] = {
str(f.external_ref): f
for f in db.query(TopoFolder)
.filter(TopoFolder.external_ref.isnot(None), TopoFolder.external_ref != "")
.all()
if str(f.external_ref or "").strip() and not str(f.external_ref).startswith("ume:")
}
for sbn in sbns:
sid = str(sbn.node_id or "").strip()
if not sid:
continue
label = (sbn.user_label or sid)[:256]
folder = by_ref.get(sid)
if folder is None:
folder = TopoFolder(
id=uuid4().hex,
parent_id=drill_folder.id,
kind="region",
name=label,
sort_order=100,
is_system=True,
external_ref=sid,
created_at=now,
updated_at=now,
)
db.add(folder)
by_ref[sid] = folder
stats["sbn_folders"] += 1
else:
folder.name = label
folder.is_system = True
folder.updated_at = now
stats["sbn_updated"] += 1
db.flush()
for sbn in sbns:
sid = str(sbn.node_id or "").strip()
folder = by_ref.get(sid)
if folder is None:
continue
parent = str(sbn.parent_node or "").strip()
if parent in sbn_ids and parent in by_ref:
folder.parent_id = by_ref[parent].id
else:
# Root SBNs hang under World (drill), not UME World container.
folder.parent_id = drill_folder.id
label = (sbn.user_label or sid)[:256]
level_filt = {
"ume_level": True,
"sbn_id": sid,
"ume_x": float(sbn.x_pos) if sbn.x_pos is not None else None,
"ume_y": float(sbn.y_pos) if sbn.y_pos is not None else None,
}
_ensure_view(
db,
folder_id=folder.id,
name=label[:200] or "Topology",
filt=level_filt,
remark=f"UME SBN level canvas ({sid})",
sort_order=0,
match=lambda v, _sid=sid: is_ume_level_view(v)
and str(_filt(v).get("sbn_id") or "") == _sid,
)
stats["sbn_views"] += 1
# Tag fabric ME nodes with direct SBN for region filter / flat packing.
me_parent = {
str(n.ume_ne_id or n.node_id): str(n.parent_node or "")
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
if str(n.ume_ne_id or n.node_id or "").strip()
}
for fn in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all():
uid = str(fn.ume_ne_id or "").strip()
parent = me_parent.get(uid, "")
if not parent:
continue
attrs = dict(fn.attrs or {})
attrs["ume_sbn_id"] = parent[:128]
folder = by_ref.get(parent)
if folder is not None:
fn.region_folder_id = folder.id
fn.region_source = "ume"
fn.attrs = attrs
db.flush()
flat_view = reconcile_world_flat_view(db)
stats["world_map_visible"] = world_map_should_exist(db)
if flat_view is not None and stats["world_map_visible"]:
stats["world_flat_view_id"] = flat_view.id
elif flat_view is not None:
stats["world_flat_view_id"] = flat_view.id
db.commit()
try:
from .ume_topology_flat_coords import recompute_flat_world_coords
stats["flat_coords"] = recompute_flat_world_coords(db)
except Exception:
_log.exception("recompute_flat_world_coords after world ensure failed")
_log.info("ume world/sbn folders ready: %s", stats)
return stats
def folder_bbox(db: Session, folder_id: str) -> dict[str, float | int] | None:
"""Bounding box of fabric nodes tagged with this SBN folder (or descendants)."""
folder = db.get(TopoFolder, folder_id)
if folder is None:
return None
all_folders = db.query(TopoFolder).all()
children: dict[str | None, list[str]] = {}
for f in all_folders:
children.setdefault(f.parent_id, []).append(f.id)
ids = {folder_id}
stack = [folder_id]
while stack:
cur = stack.pop()
for cid in children.get(cur, []):
if cid not in ids:
ids.add(cid)
stack.append(cid)
q = (
db.query(TopoFabricNode)
.filter(
TopoFabricNode.region_folder_id.in_(list(ids)),
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
)
.all()
)
if not q:
ref = str(folder.external_ref or "").strip()
if ref:
q = [
n
for n in db.query(TopoFabricNode)
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
.all()
if str((n.attrs or {}).get("ume_sbn_id") or "") == ref
]
if not q:
return None
xs = [float(n.world_x) for n in q]
ys = [float(n.world_y) for n in q]
return {
"min_x": min(xs),
"max_x": max(xs),
"min_y": min(ys),
"max_y": max(ys),
"n": len(q),
}

View file

@ -0,0 +1,523 @@
"""UME hierarchical level graph + flat world viewport graph."""
from __future__ import annotations
from collections import defaultdict
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeTopoNode
from .topology_common import (
VIEW_GRAPH_EDGE_HARD_CAP,
_EDGE_STATUS_MISSING,
_EDGE_STATUS_MISSING_COMPAT,
_normalize_edge_status,
)
from .topology_schemas import (
TopologyViewGraphOut,
ViewEdgeOut,
ViewNodeOut,
)
from .topology_views_tree import _get_view_or_404, _view_out
from .ume_topology_world import (
folder_bbox,
is_ume_canvas_view,
is_ume_level_view,
is_world_flat_view,
is_world_view,
)
WORLD_NODE_SOFT_CAP = 8000
def apply_persisted_view_positions(
db: Session, view_id: str, nodes: list[ViewNodeOut]
) -> list[ViewNodeOut]:
"""Overlay drag/save positions from TopoViewNode onto a synthetic UME graph.
UME level canvases are built from dock coords; user moves persist on the view
membership table and must win on reload (and on save return_graph).
"""
vid = str(view_id or "").strip()
if not vid or not nodes:
return nodes
rows = db.query(TopoViewNode).filter(TopoViewNode.view_id == vid).all()
if not rows:
return nodes
overrides = {str(vn.fabric_node_id): vn for vn in rows}
out: list[ViewNodeOut] = []
for n in nodes:
ov = overrides.get(str(n.fabric_node_id))
if ov is None:
out.append(n)
continue
# Keep UME-built labels (e.g. region NE counts); only persist geometry.
out.append(
n.model_copy(
update={
"x": float(ov.x or 0),
"y": float(ov.y or 0),
"locked": bool(ov.locked),
}
)
)
return out
def get_ume_canvas_graph(
db: Session,
view_id: str,
*,
min_x: float | None = None,
max_x: float | None = None,
min_y: float | None = None,
max_y: float | None = None,
sbn_id: str = "",
folder_id: str = "",
lod: str = "auto",
status: str = "active",
) -> TopologyViewGraphOut:
view = _get_view_or_404(db, view_id)
if not is_ume_canvas_view(view):
raise HTTPException(status_code=400, detail="not_a_ume_canvas_view")
if is_world_flat_view(view) or (
bool((view.filter or {}).get("world")) and not is_ume_level_view(view)
):
return get_flat_view_graph(
db,
view,
min_x=min_x,
max_x=max_x,
min_y=min_y,
max_y=max_y,
sbn_id=sbn_id,
folder_id=folder_id,
status=status,
)
return get_level_view_graph(db, view, status=status)
# Back-compat name used by router
def get_world_view_graph(
db: Session,
view_id: str,
**kwargs: Any,
) -> TopologyViewGraphOut:
return get_ume_canvas_graph(db, view_id, **kwargs)
def get_level_view_graph(
db: Session,
view: TopoView,
*,
status: str = "active",
) -> TopologyViewGraphOut:
"""One UME canvas level: direct child SBNs (regions) + direct child MEs."""
filt = dict(view.filter or {})
parent_key = str(filt.get("parent") or "").strip()
sbn_id = str(filt.get("sbn_id") or "").strip()
if parent_key == "md" or is_world_view(view):
# Root: SBN whose parent is not another SBN (+ rare direct MEs under MD).
sbn_ids = {
str(n.node_id)
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.node_id or "").strip()
}
child_sbns = [
n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.parent_node or "").strip() not in sbn_ids
]
child_mes = [
n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
if str(n.parent_node or "").strip() not in sbn_ids
]
level_parent = "" # MD virtual
else:
if not sbn_id and view.folder_id:
folder = db.get(TopoFolder, view.folder_id)
sbn_id = str(getattr(folder, "external_ref", None) or "").strip()
if sbn_id == "ume:world":
sbn_id = ""
if not sbn_id:
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
level_parent = sbn_id
child_sbns = (
db.query(UmeTopoNode)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_SBN",
UmeTopoNode.parent_node == level_parent,
)
.all()
)
child_mes = (
db.query(UmeTopoNode)
.filter(
UmeTopoNode.node_type == "TOPO_NODE_ME",
UmeTopoNode.parent_node == level_parent,
)
.all()
)
# Folder / view lookup for region drill targets
folders_by_ref: dict[str, TopoFolder] = {
str(f.external_ref): f
for f in db.query(TopoFolder)
.filter(TopoFolder.external_ref.isnot(None), TopoFolder.external_ref != "")
.all()
if str(f.external_ref or "").strip() and not str(f.external_ref).startswith("ume:")
}
level_view_by_sbn: dict[str, TopoView] = {}
for v in db.query(TopoView).all():
vf = dict(v.filter or {})
if vf.get("ume_level") and str(vf.get("sbn_id") or "").strip():
level_view_by_sbn[str(vf["sbn_id"])] = v
# Descendants of each direct child SBN (for logical edge lift + counts)
all_sbns = {
str(n.node_id): n
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
if str(n.node_id or "").strip()
}
sbn_children: dict[str, list[str]] = defaultdict(list)
for sid, node in all_sbns.items():
p = str(node.parent_node or "").strip()
if p:
sbn_children[p].append(sid)
def _descendants(root_sid: str) -> set[str]:
out = {root_sid}
stack = [root_sid]
while stack:
cur = stack.pop()
for cid in sbn_children.get(cur, []):
if cid not in out:
out.add(cid)
stack.append(cid)
return out
child_sbn_ids = [str(s.node_id) for s in child_sbns if str(s.node_id or "").strip()]
region_desc: dict[str, set[str]] = {sid: _descendants(sid) for sid in child_sbn_ids}
# ME → which direct child region (if under a child SBN subtree)
me_to_region: dict[str, str] = {}
me_uid_to_topo: dict[str, UmeTopoNode] = {}
for tn in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all():
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
me_uid_to_topo[uid] = tn
parent = str(tn.parent_node or "").strip()
for rid, desc in region_desc.items():
if parent in desc:
me_to_region[uid] = rid
break
fabric_by_ume: dict[str, TopoFabricNode] = {
str(n.ume_ne_id): n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all()
if str(n.ume_ne_id or "").strip()
}
nodes_out: list[ViewNodeOut] = []
# Region nodes
region_node_ids: dict[str, str] = {} # sbn_id -> fabric_node_id used in graph
for sbn in child_sbns:
sid = str(sbn.node_id or "").strip()
if not sid:
continue
folder = folders_by_ref.get(sid)
child_view = level_view_by_sbn.get(sid)
# Count MEs under this region tree
n_me = sum(1 for uid, rid in me_to_region.items() if rid == sid)
nid = f"region:{sid}"
region_node_ids[sid] = nid
label = (sbn.user_label or (folder.name if folder else sid) or sid)[:256]
if n_me:
label = f"{label} ({n_me})"
nodes_out.append(
ViewNodeOut(
fabric_node_id=nid,
managed_ne_id="",
ume_ne_id=sid,
label=label,
x=float(sbn.x_pos or 0),
y=float(sbn.y_pos or 0),
locked=False,
name=(sbn.user_label or "")[:256],
ip="",
vendor="SBN",
device_type="region",
kind="region",
folder_id=folder.id if folder else "",
view_id=child_view.id if child_view else "",
node_count=n_me,
)
)
# Direct ME nodes (local coords)
direct_me_fids: set[str] = set()
me_fid_by_uid: dict[str, str] = {}
for tn in child_mes:
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
if not uid:
continue
fn = fabric_by_ume.get(uid)
if fn is None:
continue
direct_me_fids.add(fn.id)
me_fid_by_uid[uid] = fn.id
lx = float(tn.x_pos) if tn.x_pos is not None else float(fn.attrs or {}).get("ume_local_x") or 0.0
ly = float(tn.y_pos) if tn.y_pos is not None else float(fn.attrs or {}).get("ume_local_y") or 0.0
nodes_out.append(
ViewNodeOut(
fabric_node_id=fn.id,
managed_ne_id=str(fn.managed_ne_id or ""),
ume_ne_id=uid,
label=(fn.name or fn.ip or uid)[:256],
x=lx,
y=ly,
locked=False,
name=fn.name or "",
ip=fn.ip or "",
vendor=fn.vendor or "",
device_type=fn.device_type or "",
kind="ne",
)
)
# Edges: physical among direct MEs; logical lifts across regions
st = str(status or "active").strip().lower() or "active"
st_norm = _normalize_edge_status(st)
eq = db.query(TopoFabricEdge).filter(TopoFabricEdge.layer == "physical")
if st_norm == _EDGE_STATUS_MISSING:
eq = eq.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
else:
eq = eq.filter(TopoFabricEdge.status == st_norm)
# Map fabric id → ume ne id
fid_to_uid = {fn.id: uid for uid, fn in fabric_by_ume.items()}
def _endpoint_on_canvas(fid: str) -> str | None:
"""Return canvas node id for a fabric endpoint, or None if outside this level."""
if fid in direct_me_fids:
return fid
uid = fid_to_uid.get(fid, "")
rid = me_to_region.get(uid, "")
if rid and rid in region_node_ids:
return region_node_ids[rid]
return None
physical: list[ViewEdgeOut] = []
logical_count: dict[tuple[str, str], int] = defaultdict(int)
for e in eq.limit(VIEW_GRAPH_EDGE_HARD_CAP * 20).all():
a = _endpoint_on_canvas(e.a_node_id)
b = _endpoint_on_canvas(e.b_node_id)
if not a or not b or a == b:
continue
# Both direct MEs → physical
if a in direct_me_fids and b in direct_me_fids:
physical.append(
ViewEdgeOut(
id=e.id,
a_node_id=a,
b_node_id=b,
a_port=e.a_port or "",
b_port=e.b_port or "",
source=e.source or "lldp",
status=e.status or "active",
layer=e.layer or "physical",
discovered_at=e.discovered_at,
)
)
else:
key = (a, b) if a < b else (b, a)
logical_count[key] += 1
edges_out = physical[:VIEW_GRAPH_EDGE_HARD_CAP]
truncated = len(physical) > VIEW_GRAPH_EDGE_HARD_CAP
for (a, b), cnt in logical_count.items():
edges_out.append(
ViewEdgeOut(
id=f"logical:{a}:{b}",
a_node_id=a,
b_node_id=b,
a_port=str(cnt),
b_port="",
source="ume",
status="active",
layer="logical",
)
)
# Manual nested regions (新建子区域) on this UME canvas.
from .topology_region_canvas import child_region_nodes_for_view
seen_folder = {str(n.folder_id or "") for n in nodes_out if n.kind == "region"}
for rn in child_region_nodes_for_view(db, view):
fid = str(rn.folder_id or "").strip()
if fid and fid not in seen_folder:
nodes_out.append(rn)
seen_folder.add(fid)
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
return TopologyViewGraphOut(
view=_view_out(view),
nodes=nodes_out,
edges=edges_out,
truncated=truncated,
truncate_reason="too_many_edges" if truncated else "",
)
def get_flat_view_graph(
db: Session,
view: TopoView,
*,
min_x: float | None = None,
max_x: float | None = None,
min_y: float | None = None,
max_y: float | None = None,
sbn_id: str = "",
folder_id: str = "",
status: str = "active",
) -> TopologyViewGraphOut:
"""All NEs using composed world_x/y — no region nodes."""
region_folder_ids: set[str] | None = None
if folder_id.strip():
bbox = folder_bbox(db, folder_id.strip())
folder = db.get(TopoFolder, folder_id.strip())
if folder is not None:
all_folders = db.query(TopoFolder).all()
children: dict[str | None, list[str]] = {}
for f in all_folders:
children.setdefault(f.parent_id, []).append(f.id)
region_folder_ids = {folder.id}
stack = [folder.id]
while stack:
cur = stack.pop()
for cid in children.get(cur, []):
if cid not in region_folder_ids:
region_folder_ids.add(cid)
stack.append(cid)
if bbox and min_x is None:
pad_x = max(50.0, (bbox["max_x"] - bbox["min_x"]) * 0.05)
pad_y = max(50.0, (bbox["max_y"] - bbox["min_y"]) * 0.05)
min_x = float(bbox["min_x"]) - pad_x
max_x = float(bbox["max_x"]) + pad_x
min_y = float(bbox["min_y"]) - pad_y
max_y = float(bbox["max_y"]) + pad_y
extent = (
db.query(TopoFabricNode)
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
.all()
)
if not extent:
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
full_min_x = min(float(n.world_x) for n in extent)
full_max_x = max(float(n.world_x) for n in extent)
full_min_y = min(float(n.world_y) for n in extent)
full_max_y = max(float(n.world_y) for n in extent)
if min_x is None or max_x is None or min_y is None or max_y is None:
min_x, max_x, min_y, max_y = full_min_x, full_max_x, full_min_y, full_max_y
q = db.query(TopoFabricNode).filter(
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
TopoFabricNode.world_x >= min_x,
TopoFabricNode.world_x <= max_x,
TopoFabricNode.world_y >= min_y,
TopoFabricNode.world_y <= max_y,
)
if region_folder_ids is not None:
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
if sbn_id.strip():
sid = sbn_id.strip()
nodes = [
n
for n in q.all()
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
or str(n.region_folder_id or "") == sid
]
else:
nodes = q.all()
truncated = False
reason = ""
if len(nodes) > WORLD_NODE_SOFT_CAP:
nodes = nodes[:WORLD_NODE_SOFT_CAP]
truncated = True
reason = "too_many_viewport_nodes"
fids = [n.id for n in nodes]
fid_set = set(fids)
nodes_out = [
ViewNodeOut(
fabric_node_id=n.id,
managed_ne_id=str(n.managed_ne_id or ""),
ume_ne_id=str(n.ume_ne_id or ""),
label=(n.name or n.ip or n.id)[:256],
x=float(n.world_x or 0),
y=float(n.world_y or 0),
locked=False,
name=n.name or "",
ip=n.ip or "",
vendor=n.vendor or "",
device_type=n.device_type or "",
kind="ne",
)
for n in nodes
]
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
edges_out: list[ViewEdgeOut] = []
if fids:
eq = db.query(TopoFabricEdge).filter(
TopoFabricEdge.layer == "physical",
TopoFabricEdge.a_node_id.in_(fids),
TopoFabricEdge.b_node_id.in_(fids),
)
st = str(status or "").strip().lower()
if st:
st_norm = _normalize_edge_status(st)
if st_norm == _EDGE_STATUS_MISSING:
eq = eq.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
else:
eq = eq.filter(TopoFabricEdge.status == st_norm)
edges = eq.limit(VIEW_GRAPH_EDGE_HARD_CAP + 1).all()
if len(edges) > VIEW_GRAPH_EDGE_HARD_CAP:
edges = edges[:VIEW_GRAPH_EDGE_HARD_CAP]
truncated = True
reason = reason or "too_many_edges"
for e in edges:
if e.a_node_id not in fid_set or e.b_node_id not in fid_set:
continue
edges_out.append(
ViewEdgeOut(
id=e.id,
a_node_id=e.a_node_id,
b_node_id=e.b_node_id,
a_port=e.a_port or "",
b_port=e.b_port or "",
source=e.source or "lldp",
status=e.status or "active",
layer=e.layer or "physical",
discovered_at=e.discovered_at,
)
)
return TopologyViewGraphOut(
view=_view_out(view),
nodes=nodes_out,
edges=edges_out,
truncated=truncated,
truncate_reason=reason,
)

View file

@ -0,0 +1,284 @@
"""Analyze imported UME topo + fabric for layout strategy."""
from __future__ import annotations
import json
from collections import Counter, defaultdict
from pathlib import Path
from sqlalchemy import text
from netx_api.db import engine
out: dict = {}
with engine.connect() as c:
def q(sql: str, **kw):
return c.execute(text(sql), kw).fetchall()
def scalar(sql: str, **kw):
return c.execute(text(sql), kw).scalar()
out["counts"] = {
"topo_nodes": scalar("select count(*) from ume_topo_node"),
"topo_me": scalar("select count(*) from ume_topo_node where node_type='TOPO_NODE_ME'"),
"topo_sbn": scalar("select count(*) from ume_topo_node where node_type='TOPO_NODE_SBN'"),
"topo_links": scalar("select count(*) from ume_topo_link"),
"fabric_nodes": scalar("select count(*) from topo_fabric_node"),
"fabric_edges": scalar("select count(*) from topo_fabric_edge"),
"inventory": scalar("select count(*) from ume_inventory_ne"),
}
# coordinate extent for ME only
row = q(
"""
select
min(x_pos), max(x_pos), min(y_pos), max(y_pos),
avg(x_pos::float), avg(y_pos::float),
count(*) filter (where x_pos is null or y_pos is null),
count(*) filter (where x_pos=0 and y_pos=0)
from ume_topo_node where node_type='TOPO_NODE_ME'
"""
)[0]
out["me_bbox"] = {
"min_x": row[0], "max_x": row[1], "min_y": row[2], "max_y": row[3],
"avg_x": round(float(row[4] or 0), 1), "avg_y": round(float(row[5] or 0), 1),
"null_xy": row[6], "zero_zero": row[7],
"width": (row[1] or 0) - (row[0] or 0),
"height": (row[3] or 0) - (row[2] or 0),
}
# SBN hierarchy depth + children
sbns = q(
"""
select node_id, user_label, parent_node, x_pos, y_pos
from ume_topo_node where node_type='TOPO_NODE_SBN'
"""
)
me_by_parent = q(
"""
select parent_node, count(*)
from ume_topo_node
where node_type='TOPO_NODE_ME'
group by parent_node
order by count(*) desc
"""
)
out["sbn_count"] = len(sbns)
out["me_per_parent_top20"] = [
{"parent": r[0], "me_count": r[1]} for r in me_by_parent[:20]
]
out["me_parent_buckets"] = {
"parents_gt_2000": sum(1 for r in me_by_parent if r[1] > 2000),
"parents_gt_500": sum(1 for r in me_by_parent if r[1] > 500),
"parents_gt_100": sum(1 for r in me_by_parent if r[1] > 100),
"parents_le_100": sum(1 for r in me_by_parent if r[1] <= 100),
"max_me_in_one_parent": me_by_parent[0][1] if me_by_parent else 0,
}
# resolve parent labels
sbn_label = {r[0]: r[1] for r in sbns}
labeled = []
for r in me_by_parent[:25]:
pid = r[0]
labeled.append({
"parent_id": pid,
"parent_label": sbn_label.get(pid, pid[:40] if pid else ""),
"me_count": r[1],
})
out["me_per_sbn_top25"] = labeled
# SBN tree: roots vs nested
sbn_ids = {r[0] for r in sbns}
roots = []
nested = []
for r in sbns:
nid, label, parent, x, y = r
if parent in sbn_ids:
nested.append({"id": nid, "label": label, "parent": parent, "parent_label": sbn_label.get(parent, "")})
else:
roots.append({"id": nid, "label": label, "parent": parent, "x": x, "y": y})
out["sbn_roots"] = roots
out["sbn_nested_count"] = len(nested)
out["sbn_nested_sample"] = nested[:30]
# ME count under each root SBN (walk parents)
parent_of = {r[0]: r[2] for r in sbns}
me_parent_map = {r[0]: r[1] for r in me_by_parent} # wrong - me_by_parent is list of (parent, count)
# build node->parent for all nodes
all_parents = q("select node_id, parent_node, node_type from ume_topo_node")
pmap = {r[0]: r[1] for r in all_parents}
ntype = {r[0]: r[2] for r in all_parents}
def root_sbn(nid: str) -> str:
seen = set()
cur = nid
last_sbn = nid if ntype.get(nid) == "TOPO_NODE_SBN" else ""
# climb from parent
cur = pmap.get(nid, "")
while cur and cur not in seen:
seen.add(cur)
if ntype.get(cur) == "TOPO_NODE_SBN":
last_sbn = cur
# stop at non-uuid / MD= root
if cur not in pmap and cur not in sbn_ids:
break
nxt = pmap.get(cur)
if not nxt or nxt == cur:
break
cur = nxt
return last_sbn or "unknown"
root_me_counts: Counter = Counter()
for r in q("select node_id from ume_topo_node where node_type='TOPO_NODE_ME'"):
# climb from ME's parent
parent = pmap.get(r[0], "")
rs = parent
# find topmost SBN
seen = set()
cur = parent
top = parent if parent in sbn_ids else ""
while cur and cur not in seen:
seen.add(cur)
if cur in sbn_ids:
top = cur
cur = pmap.get(cur, "")
if not cur:
break
# prefer root among SBN chain: keep climbing while parent is SBN
while top and parent_of.get(top) in sbn_ids:
top = parent_of[top]
root_me_counts[top or "no_sbn"] += 1
out["me_per_root_sbn"] = [
{"root_id": k, "label": sbn_label.get(k, k), "me_count": v}
for k, v in root_me_counts.most_common()
]
# per-root bbox for ME
root_bbox = {}
for r in q(
"select node_id, parent_node, x_pos, y_pos from ume_topo_node where node_type='TOPO_NODE_ME' and x_pos is not null"
):
nid, parent, x, y = r
top = parent if parent in sbn_ids else ""
seen = set()
cur = parent
while cur and cur not in seen:
seen.add(cur)
if cur in sbn_ids:
top = cur
cur = pmap.get(cur, "")
while top and parent_of.get(top) in sbn_ids:
top = parent_of[top]
key = top or "no_sbn"
b = root_bbox.setdefault(key, {"min_x": x, "max_x": x, "min_y": y, "max_y": y, "n": 0})
b["min_x"] = min(b["min_x"], x)
b["max_x"] = max(b["max_x"], x)
b["min_y"] = min(b["min_y"], y)
b["max_y"] = max(b["max_y"], y)
b["n"] += 1
out["root_sbn_bbox"] = [
{
"root_id": k,
"label": sbn_label.get(k, k),
"n": v["n"],
"width": v["max_x"] - v["min_x"],
"height": v["max_y"] - v["min_y"],
"min_x": v["min_x"],
"max_x": v["max_x"],
"min_y": v["min_y"],
"max_y": v["max_y"],
}
for k, v in sorted(root_bbox.items(), key=lambda kv: -kv[1]["n"])
]
# grid density: how many ME in 100x100 cells
cells = Counter()
for r in q(
"select x_pos, y_pos from ume_topo_node where node_type='TOPO_NODE_ME' and x_pos is not null"
):
cells[(r[0] // 100, r[1] // 100)] += 1
dens = sorted(cells.values(), reverse=True)
out["grid100_density"] = {
"cells": len(cells),
"max_per_cell": dens[0] if dens else 0,
"p95": dens[int(len(dens) * 0.05)] if dens else 0,
"median": dens[len(dens) // 2] if dens else 0,
"cells_gt_50": sum(1 for d in dens if d > 50),
"cells_gt_20": sum(1 for d in dens if d > 20),
}
# UME links vs fabric: endpoint overlap
out["link_stats"] = {
"ume_links": scalar("select count(*) from ume_topo_link"),
"ume_connected": scalar(
"select count(*) from ume_topo_link where connection_status='Connected'"
),
"unique_a_ptp": scalar("select count(distinct a_ptp) from ume_topo_link"),
"links_both_in_fabric": scalar(
"""
select count(*) from ume_topo_link l
join topo_fabric_node a on a.ume_ne_id=l.a_ume_ne_id
join topo_fabric_node z on z.ume_ne_id=l.z_ume_ne_id
"""
),
}
# approximate undirected edge key match UME vs LLDP fabric
# fabric ports are like xxvgei-1/1/0/32; ume ptp is /p=1_28
out["port_sample_ume"] = [
dict(a_ptp=r[0], z_ptp=r[1], a_ne=r[2], z_ne=r[3])
for r in q(
"select a_ptp, z_ptp, a_ume_ne_id, z_ume_ne_id from ume_topo_link limit 5"
)
]
out["port_sample_fabric"] = [
dict(a_port=r[0], b_port=r[1])
for r in q("select a_port, b_port from topo_fabric_edge limit 5")
]
# same NE-pair undirected overlap ignoring ports
ume_pairs = {
frozenset((r[0], r[1]))
for r in q(
"select a_ume_ne_id, z_ume_ne_id from ume_topo_link where a_ume_ne_id<>'' and z_ume_ne_id<>''"
)
if r[0] != r[1]
}
fab_pairs = set()
for r in q(
"""
select a.ume_ne_id, b.ume_ne_id
from topo_fabric_edge e
join topo_fabric_node a on a.id=e.a_node_id
join topo_fabric_node b on b.id=e.b_node_id
where a.ume_ne_id is not null and b.ume_ne_id is not null
"""
):
if r[0] and r[1] and r[0] != r[1]:
fab_pairs.add(frozenset((r[0], r[1])))
inter = ume_pairs & fab_pairs
out["ne_pair_overlap"] = {
"ume_undirected_pairs": len(ume_pairs),
"fabric_undirected_pairs": len(fab_pairs),
"intersection": len(inter),
"ume_only": len(ume_pairs - fab_pairs),
"fabric_only": len(fab_pairs - ume_pairs),
}
# inventory vendor / device_level under topo
out["inventory_levels"] = [
{"device_level": r[0], "n": r[1]}
for r in q(
"select coalesce(nullif(device_level,''),'?'), count(*) from ume_inventory_ne group by 1 order by 2 desc"
)
]
Path("ume/_topo_analysis.json").write_text(
json.dumps(out, ensure_ascii=False, indent=2, default=str), encoding="utf-8"
)
print("wrote ume/_topo_analysis.json")
for k in ("counts", "me_bbox", "me_parent_buckets", "ne_pair_overlap", "grid100_density"):
print(k, json.dumps(out[k], ensure_ascii=False, default=str))
print("roots", len(out["sbn_roots"]), "nested_sbn", out["sbn_nested_count"])
print("me_per_root", json.dumps(out["me_per_root_sbn"][:15], ensure_ascii=False))

View file

@ -0,0 +1,151 @@
"""One-shot: ensure schema, then load field CSVs from ume/ into local DB."""
from __future__ import annotations
import csv
import json
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import netx_api.models # noqa: F401
from netx_api.db import Base, SessionLocal, engine
from netx_api.schema_patches import apply_all_legacy_startup_ddl
from sqlalchemy import text
UME_DIR = ROOT / "ume"
FILES = [
("ume_inventory_ne", "ume_inventory_ne_202608061843.csv"),
("ume_topo_node", "ume_topo_node_202608061841.csv"),
("ume_topo_link", "ume_topo_link_202608061841.csv"),
("topo_fabric_node", "topo_fabric_node_202608061847.csv"),
("topo_fabric_edge", "topo_fabric_edge_202608061847.csv"),
]
INT_COLS = {"x_pos", "y_pos", "layer_rate"}
JSON_COLS = {"attrs"}
DT_COLS = {
"first_seen_at",
"last_seen_at",
"discovered_at",
"created_at",
"updated_at",
}
NULLABLE_EMPTY = {
"managed_ne_id",
"region_folder_id",
"discovered_at",
"x_pos",
"y_pos",
"layer_rate",
}
# Unique nullable on fabric nodes: empty string would violate UNIQUE; use NULL.
FABRIC_NULL_EMPTY = {"ume_ne_id", "managed_ne_id", "region_folder_id"}
def _parse_dt(v: str):
s = (v or "").strip()
if not s:
return None
for fmt in (
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return s
def _coerce(col: str, raw: str, *, table: str):
if raw is None:
return None
v = raw
if isinstance(v, str):
v = v.strip()
null_empty = set(NULLABLE_EMPTY)
if table == "topo_fabric_node":
null_empty |= FABRIC_NULL_EMPTY
if v == "" and col in null_empty:
return None
if col in INT_COLS:
if v == "":
return None
return int(v)
if col in JSON_COLS:
if v == "":
return {}
if isinstance(v, (dict, list)):
return v
try:
return json.loads(v)
except Exception:
return {}
if col in DT_COLS:
return _parse_dt(v if isinstance(v, str) else str(v))
return v if v is not None else ""
def _load_csv(path: Path, *, table: str) -> tuple[list[str], list[dict]]:
with path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
cols = list(reader.fieldnames or [])
rows = []
for row in reader:
rows.append({c: _coerce(c, row.get(c, ""), table=table) for c in cols})
return cols, rows
def main() -> None:
print("ensure schema…")
Base.metadata.create_all(bind=engine)
apply_all_legacy_startup_ddl(engine)
# FK-safe truncate order
with engine.begin() as conn:
conn.execute(
text(
"TRUNCATE TABLE topo_fabric_edge, topo_fabric_node, "
"ume_topo_link, ume_topo_node, ume_inventory_ne RESTART IDENTITY CASCADE"
)
)
print("truncated ok")
db = SessionLocal()
try:
for table, fname in FILES:
path = UME_DIR / fname
if not path.exists():
raise FileNotFoundError(path)
cols, rows = _load_csv(path, table=table)
print(f"load {table}: {len(rows)} rows from {fname}")
if not rows:
continue
# chunked insert via core
chunk = 1000
meta = Base.metadata.tables[table]
for i in range(0, len(rows), chunk):
part = rows[i : i + chunk]
# drop unknown cols just in case
clean = [{k: r[k] for k in cols if k in meta.c} for r in part]
db.execute(meta.insert(), clean)
db.commit()
print(f" … {min(i + chunk, len(rows))}/{len(rows)}")
print("counts:")
for table, _ in FILES:
n = db.execute(text(f"SELECT count(*) FROM {table}")).scalar()
print(f" {table}: {n}")
finally:
db.close()
if __name__ == "__main__":
main()

23
scripts/_run_ume_apply.py Normal file
View file

@ -0,0 +1,23 @@
from sqlalchemy import text
from netx_api.db import SessionLocal
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
db = SessionLocal()
try:
print("apply…")
s = apply_ume_topology_to_fabric(db)
print("apply", s)
w = ensure_ume_world_and_sbn_folders(db)
print("world", w)
v = get_world_view(db)
print("view", v.id if v else None)
n_xy = db.execute(text("select count(*) from topo_fabric_node where world_x is not null")).scalar()
n_e = db.execute(text("select count(*) from topo_fabric_edge where status='active'")).scalar()
n_f = db.execute(
text("select count(*) from topo_folder where external_ref<>'' and external_ref<>'ume:world'")
).scalar()
print("fabric_xy", n_xy, "active_edges", n_e, "sbn_folders", n_f)
finally:
db.close()

View file

@ -480,7 +480,7 @@ class FabricTopologyTests(unittest.TestCase):
tree = svc.get_topology_tree(self.db) tree = svc.get_topology_tree(self.db)
assert tree.root is not None assert tree.root is not None
reg = next(c for c in tree.root.children if c.id == region.id) reg = next(c for c in tree.root.children if c.id == region.id)
self.assertEqual(len(reg.views), 1) self.assertTrue(any(v.id == custom.id for v in reg.views))
out = svc.delete_folder(self.db, region.id) out = svc.delete_folder(self.db, region.id)
self.assertTrue(out.get("deleted")) self.assertTrue(out.get("deleted"))
@ -493,6 +493,53 @@ class FabricTopologyTests(unittest.TestCase):
0, 0,
) )
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
parent = svc.create_folder(
self.db, TopologyFolderCreate(name="Parent-Canvas", kind="region")
)
child = svc.create_folder(
self.db,
TopologyFolderCreate(name="Child-Icon", kind="region", parent_id=parent.id),
)
tree = svc.get_topology_tree(self.db)
assert tree.root is not None
p = next(c for c in tree.root.children if c.id == parent.id)
self.assertTrue(p.views)
graph = svc.get_view_graph(self.db, p.views[0].id)
regions = [n for n in graph.nodes if n.kind == "region"]
self.assertEqual(len(regions), 1)
self.assertEqual(regions[0].folder_id, child.id)
self.assertEqual(regions[0].label, "Child-Icon")
self.assertTrue(str(regions[0].fabric_node_id).startswith("region:"))
# Drag/save positions for synthetic region nodes.
moved = svc.patch_view_positions(
self.db,
p.views[0].id,
ViewPositionsPatch(
positions=[
ViewNodeIn(
fabric_node_id=regions[0].fabric_node_id, x=321, y=210
)
],
return_graph=True,
),
)
if hasattr(moved, "nodes"):
hit = next(n for n in moved.nodes if n.kind == "region")
else:
hit = next(
n
for n in svc.get_view_graph(self.db, p.views[0].id).nodes
if n.kind == "region"
)
self.assertEqual(hit.x, 321)
self.assertEqual(hit.y, 210)
svc.delete_folder(self.db, child.id)
graph2 = svc.get_view_graph(self.db, p.views[0].id)
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
def test_classify_role_region_and_slices(self) -> None: def test_classify_role_region_and_slices(self) -> None:
from netx_api import topology_classify as clf from netx_api import topology_classify as clf
from netx_api.topology_schemas import ( from netx_api.topology_schemas import (

369
tests/test_ume_hierarchy.py Normal file
View file

@ -0,0 +1,369 @@
"""Hierarchical UME level canvas + flat packing tests."""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import netx_api.models # noqa: F401
from netx_api.db import Base
from netx_api.models import TopoFabricNode, TopoFolder, TopoView, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
from netx_api.ume_topology_flat_coords import recompute_flat_world_coords
from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
from netx_api.ume_topology_world_graph import get_level_view_graph, get_flat_view_graph
class UmeHierarchyTests(unittest.TestCase):
def setUp(self):
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSessionLocal = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSessionLocal()
self.now = datetime(2026, 8, 6, 12, 0, 0)
def tearDown(self):
self.db.close()
def _seed_tree(self):
# MD
# └─ root-sbn (x=10,y=20)
# ├─ city-a (x=100,y=100) → ME-A1, ME-A2
# └─ city-b (x=200,y=100) → ME-B1
# └─ direct ME under MD (rare)
for sid, label, parent, x, y in [
("root-sbn", "Root SBN", "md-virtual", 10, 20),
("city-a", "City A", "root-sbn", 100, 100),
("city-b", "City B", "root-sbn", 200, 100),
]:
self.db.add(
UmeTopoNode(
node_id=sid,
name=f"SBN={sid}",
node_type="TOPO_NODE_SBN",
user_label=label,
parent_node=parent,
x_pos=x,
y_pos=y,
first_seen_at=self.now,
last_seen_at=self.now,
)
)
mes = [
("me-a1", "A1", "city-a", 10, 10),
("me-a2", "A2", "city-a", 50, 10),
("me-b1", "B1", "city-b", 10, 10), # same local as A1 — would collide if global
]
for uid, label, parent, x, y in mes:
self.db.add(
UmeInventoryNE(
ne_id=uid,
ne_name=f"ME{{{uid}}}",
user_label=label,
host_name=label,
ip_address=f"10.0.0.{label[-1]}",
)
)
self.db.add(
UmeTopoNode(
node_id=uid,
name=f"ME={uid}",
node_type="TOPO_NODE_ME",
user_label=label,
parent_node=parent,
x_pos=x,
y_pos=y,
ume_ne_id=uid,
first_seen_at=self.now,
last_seen_at=self.now,
)
)
# Cross-city link A1—B1
self.db.add(
UmeTopoLink(
link_id="link-ab",
name="TL{ab}",
user_label="A1_xxvgei-1/1/0/1_B1_xxvgei-1/1/0/1",
a_end_tp_ref="ME{me-a1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_1}",
z_end_tp_ref="ME{me-b1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_1}",
a_ume_ne_id="me-a1",
z_ume_ne_id="me-b1",
a_ifname="xxvgei-1/1/0/1",
z_ifname="xxvgei-1/1/0/1",
first_seen_at=self.now,
last_seen_at=self.now,
)
)
# Intra-city A1—A2
self.db.add(
UmeTopoLink(
link_id="link-aa",
name="TL{aa}",
user_label="A1_xxvgei-1/1/0/2_A2_xxvgei-1/1/0/2",
a_end_tp_ref="ME{me-a1},EQ={/r=0/sh=1/sl=1},PTP={/p=1_2}",
z_end_tp_ref="ME{me-a2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_2}",
a_ume_ne_id="me-a1",
z_ume_ne_id="me-a2",
a_ifname="xxvgei-1/1/0/2",
z_ifname="xxvgei-1/1/0/2",
first_seen_at=self.now,
last_seen_at=self.now,
)
)
self.db.commit()
def test_seed_views_and_level_graph(self):
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
stats = ensure_ume_world_and_sbn_folders(self.db)
self.assertTrue(stats["world_view_id"])
self.assertTrue(stats["world_flat_view_id"])
self.assertGreaterEqual(stats["sbn_views"], 3)
world = get_world_view(self.db)
self.assertIsNotNone(world)
# World view hangs under World drill folder, Flat under UME World container.
from netx_api.ume_topology_world import WORLD_DRILL_REF, WORLD_FOLDER_NAME
drill = (
self.db.query(TopoFolder)
.filter(TopoFolder.external_ref == WORLD_DRILL_REF)
.one()
)
container = (
self.db.query(TopoFolder)
.filter(TopoFolder.name == WORLD_FOLDER_NAME)
.one()
)
self.assertEqual(drill.parent_id, container.id)
self.assertEqual(world.folder_id, drill.id)
root_sbn = (
self.db.query(TopoFolder)
.filter(TopoFolder.external_ref == "root-sbn")
.one()
)
self.assertEqual(root_sbn.parent_id, drill.id)
g = get_level_view_graph(self.db, world)
kinds = {n.kind for n in g.nodes}
self.assertIn("region", kinds)
# Root level shows root-sbn as region, not city MEs
region_ids = {n.ume_ne_id for n in g.nodes if n.kind == "region"}
self.assertIn("root-sbn", region_ids)
self.assertNotIn("city-a", region_ids)
# Open root-sbn level: cities as regions + logical link between them
root_view = (
self.db.query(TopoView)
.filter(TopoView.filter["sbn_id"].as_string() == "root-sbn")
.first()
)
# SQLite JSON path may differ — find by filter scan
if root_view is None:
for v in self.db.query(TopoView).all():
if str((v.filter or {}).get("sbn_id") or "") == "root-sbn":
root_view = v
break
self.assertIsNotNone(root_view)
g2 = get_level_view_graph(self.db, root_view)
regions = [n for n in g2.nodes if n.kind == "region"]
self.assertEqual({n.ume_ne_id for n in regions}, {"city-a", "city-b"})
logical = [e for e in g2.edges if e.layer == "logical"]
self.assertGreaterEqual(len(logical), 1)
# Leaf city-a: only MEs + physical edge
leaf = None
for v in self.db.query(TopoView).all():
if str((v.filter or {}).get("sbn_id") or "") == "city-a":
leaf = v
break
self.assertIsNotNone(leaf)
g3 = get_level_view_graph(self.db, leaf)
self.assertTrue(all(n.kind == "ne" for n in g3.nodes))
self.assertEqual(len(g3.nodes), 2)
self.assertTrue(any(e.layer == "physical" for e in g3.edges))
def test_region_drag_position_persists_on_ume_level(self):
"""Drag/save writes TopoViewNode; reload must not snap back to UME x_pos."""
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import get_view_graph, patch_view_positions
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
world = get_world_view(self.db)
self.assertIsNotNone(world)
g0 = get_level_view_graph(self.db, world)
region = next(n for n in g0.nodes if n.kind == "region" and n.ume_ne_id == "root-sbn")
self.assertEqual(region.x, 10.0)
self.assertEqual(region.y, 20.0)
saved = patch_view_positions(
self.db,
world.id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=region.fabric_node_id, x=777.0, y=888.0)],
return_graph=True,
),
)
# Save return_graph must stay on the UME canvas (not empty membership graph).
self.assertTrue(any(n.kind == "region" for n in saved.nodes))
moved = next(n for n in saved.nodes if n.fabric_node_id == region.fabric_node_id)
self.assertEqual(moved.x, 777.0)
self.assertEqual(moved.y, 888.0)
g1 = get_level_view_graph(self.db, world)
again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id)
self.assertEqual(again.x, 777.0)
self.assertEqual(again.y, 888.0)
via_get = get_view_graph(self.db, world.id)
via = next(n for n in via_get.nodes if n.fabric_node_id == region.fabric_node_id)
self.assertEqual(via.x, 777.0)
self.assertEqual(via.y, 888.0)
def test_world_map_2a_and_tree_visibility(self):
from netx_api.topology_views_tree import get_topology_tree
from netx_api.ume_topology_world import (
WORLD_FLAT_VIEW_NAME,
get_world_container_folder,
get_world_flat_view,
world_map_should_exist,
)
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
stats = ensure_ume_world_and_sbn_folders(self.db)
self.assertTrue(world_map_should_exist(self.db))
self.assertTrue(stats.get("world_map_visible"))
flat = get_world_flat_view(self.db)
self.assertIsNotNone(flat)
self.assertFalse(bool((flat.filter or {}).get("suppressed")))
tree = get_topology_tree(self.db)
container = get_world_container_folder(self.db)
self.assertIsNotNone(container)
# Find container in tree
def find(folder, fid):
if folder.id == fid:
return folder
for c in folder.children or []:
hit = find(c, fid)
if hit:
return hit
return None
node = find(tree.root, container.id)
self.assertIsNotNone(node)
view_names = {v.name for v in node.views}
self.assertIn(WORLD_FLAT_VIEW_NAME, view_names)
# Only one L2 region child (World drill)
region_kids = [c for c in node.children if c.kind == "region"]
drill = [c for c in region_kids if c.external_ref == "ume:world:drill" or c.name == "World"]
self.assertEqual(len(drill), 1)
def test_world_map_forbids_add_nes(self):
from fastapi import HTTPException
from netx_api.topology_schemas import ViewNodesAdd
from netx_api.topology_views_graph import add_nodes_to_view
from netx_api.ume_topology_world import get_world_flat_view
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
flat = get_world_flat_view(self.db)
self.assertIsNotNone(flat)
with self.assertRaises(HTTPException) as ctx:
add_nodes_to_view(
self.db,
flat.id,
ViewNodesAdd(fabric_node_ids=["nope"], return_graph=False),
)
self.assertEqual(ctx.exception.detail, "world_map_no_direct_nes")
def test_flat_drag_override_survives_recompute(self):
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import patch_view_positions
from netx_api.ume_topology_world import get_world_flat_view
from netx_api.ume_topology_world_graph import get_flat_view_graph
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
flat = get_world_flat_view(self.db)
self.assertIsNotNone(flat)
g0 = get_flat_view_graph(self.db, flat)
self.assertGreater(len(g0.nodes), 0)
target = g0.nodes[0]
patch_view_positions(
self.db,
flat.id,
ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=1111.0, y=2222.0)],
return_graph=False,
),
)
recompute_flat_world_coords(self.db)
g1 = get_flat_view_graph(self.db, flat)
moved = next(n for n in g1.nodes if n.fabric_node_id == target.fabric_node_id)
self.assertEqual(moved.x, 1111.0)
self.assertEqual(moved.y, 2222.0)
def test_create_folder_under_ume_world_remounts_to_drill(self):
from netx_api.topology_schemas import TopologyFolderCreate
from netx_api.topology_views_tree import create_folder
from netx_api.ume_topology_world import get_world_container_folder, get_world_drill_folder
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
container = get_world_container_folder(self.db)
drill = get_world_drill_folder(self.db)
self.assertIsNotNone(container)
self.assertIsNotNone(drill)
out = create_folder(
self.db,
TopologyFolderCreate(name="Manual City", parent_id=container.id, kind="region"),
)
row = self.db.get(TopoFolder, out.id)
self.assertEqual(row.parent_id, drill.id)
def test_flat_slots_do_not_overlap(self):
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
recompute_flat_world_coords(self.db)
by_sbn: dict[str, list[TopoFabricNode]] = {}
for n in self.db.query(TopoFabricNode).all():
sid = str((n.attrs or {}).get("ume_sbn_id") or "")
by_sbn.setdefault(sid, []).append(n)
a_nodes = by_sbn.get("city-a") or []
b_nodes = by_sbn.get("city-b") or []
self.assertEqual(len(a_nodes), 2)
self.assertEqual(len(b_nodes), 1)
# Bounding boxes of the two cities must not intersect
def bbox(nodes):
xs = [float(n.world_x) for n in nodes]
ys = [float(n.world_y) for n in nodes]
return min(xs), max(xs), min(ys), max(ys)
ax0, ax1, ay0, ay1 = bbox(a_nodes)
bx0, bx1, by0, by1 = bbox(b_nodes)
overlap_x = not (ax1 < bx0 or bx1 < ax0)
overlap_y = not (ay1 < by0 or by1 < ay0)
self.assertFalse(overlap_x and overlap_y)
# Intra-city relative delta preserved (A2 - A1 local was 40 in x)
a1 = next(n for n in a_nodes if n.ume_ne_id == "me-a1")
a2 = next(n for n in a_nodes if n.ume_ne_id == "me-a2")
self.assertAlmostEqual(float(a2.world_x) - float(a1.world_x), 40.0)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,235 @@
"""Unit tests for UME port normalize + fabric apply provenance."""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import netx_api.models # noqa: F401
from netx_api.db import Base
from netx_api.models import TopoFabricEdge, TopoFabricNode, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from netx_api.ume_port_normalize import (
extract_ifnames_from_user_label,
port_keys_compatible,
port_suffix_from_tp_ref,
resolve_link_ifnames,
)
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
from netx_api.topology_fabric_links import upsert_fabric_edge
class UmePortNormalizeTests(unittest.TestCase):
def test_label_xxvgei_pair(self):
label = (
"MDN-UJSB-EN1-Z20HS-SMGD[1/1/0]_xxvgei-1/1/0/32(10G:to_MDN-UJSR-EN1-Z680H_"
"xgei-1/2/0/1:via_OTB)_MDN-UJSR"
)
tokens = extract_ifnames_from_user_label(label)
self.assertEqual(tokens[0], "xxvgei-1/1/0/32")
self.assertEqual(tokens[1], "xgei-1/2/0/1")
a, z = resolve_link_ifnames(
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_32}",
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=2},PTP={/p=1_1}",
user_label=label,
)
self.assertEqual(a, "xxvgei-1/1/0/32")
self.assertEqual(z, "xgei-1/2/0/1")
def test_eq_ptp_suffix(self):
self.assertEqual(
port_suffix_from_tp_ref("ME{x},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}"),
"1/1/0/28",
)
self.assertEqual(
port_suffix_from_tp_ref("ME{x},EQ={/r=0/sh=0/sl=0/ssl=1},PTP={/p=1_4}"),
"0/0/1/4",
)
def test_eth_colon_fallback(self):
label = "JBI-SIMK-EN1-Z20HS-[1/1/0]_ETH:28_JBI-OTOB-EN1-Z20HS-[1/1/0]_ETH:28"
a, z = resolve_link_ifnames(
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
user_label=label,
)
self.assertEqual(a, "1/1/0/28")
self.assertEqual(z, "1/1/0/28")
def test_port_compatible(self):
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "1/1/0/32"))
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/32"))
self.assertFalse(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/28"))
class UmeFabricApplyTests(unittest.TestCase):
def setUp(self):
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSessionLocal = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSessionLocal()
self.now = datetime(2026, 8, 6, 12, 0, 0)
def tearDown(self):
self.db.close()
def _seed_ume(self):
self.db.add(
UmeInventoryNE(
ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
ne_name="ME{a}",
user_label="NE-A",
host_name="NE-A",
ip_address="10.0.0.1",
)
)
self.db.add(
UmeInventoryNE(
ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
ne_name="ME{b}",
user_label="NE-B",
host_name="NE-B",
ip_address="10.0.0.2",
)
)
self.db.add(
UmeTopoNode(
node_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
name="MD=ZTE;ME=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
node_type="TOPO_NODE_ME",
user_label="NE-A",
parent_node="sbn-1",
x_pos=100,
y_pos=200,
ume_ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
first_seen_at=self.now,
last_seen_at=self.now,
)
)
self.db.add(
UmeTopoNode(
node_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
name="MD=ZTE;ME=bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
node_type="TOPO_NODE_ME",
user_label="NE-B",
parent_node="sbn-1",
x_pos=300,
y_pos=400,
ume_ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
first_seen_at=self.now,
last_seen_at=self.now,
)
)
self.db.add(
UmeTopoLink(
link_id="link-1",
name="TL{…}",
user_label="NE-A_xxvgei-1/1/0/32_NE-B_xxvgei-1/1/0/28",
a_end_tp_ref="ME{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa},EQ={/r=0/sh=1/sl=1},PTP={/p=1_32}",
z_end_tp_ref="ME{bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb},EQ={/r=0/sh=1/sl=1},PTP={/p=1_28}",
a_ume_ne_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
z_ume_ne_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
a_ptp="/p=1_32",
z_ptp="/p=1_28",
a_ifname="xxvgei-1/1/0/32",
z_ifname="xxvgei-1/1/0/28",
first_seen_at=self.now,
last_seen_at=self.now,
)
)
self.db.commit()
def test_apply_sets_coords_and_ume_edge(self):
self._seed_ume()
stats = apply_ume_topology_to_fabric(self.db)
self.assertEqual(stats["nodes_coords"], 2)
self.assertGreaterEqual(stats["edges_upserted"] + stats["edges_merged"], 1)
a = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.one()
)
self.assertEqual((a.attrs or {}).get("ume_local_x"), 100.0)
self.assertEqual((a.attrs or {}).get("ume_local_y"), 200.0)
# Flat packing: single SBN slot → local relative to cluster min.
self.assertEqual(a.world_x, 0.0)
self.assertEqual(a.world_y, 0.0)
edges = self.db.query(TopoFabricEdge).all()
self.assertEqual(len(edges), 1)
self.assertIn("ume", (edges[0].attrs or {}).get("sources", []))
def test_lldp_edge_survives_ume_clear(self):
self._seed_ume()
apply_ume_topology_to_fabric(self.db)
a = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.one()
)
b = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
.one()
)
edge = self.db.query(TopoFabricEdge).one()
# Simulate LLDP also seeing it
edge.source = "lldp"
edge.attrs = {"sources": ["ume", "lldp"]}
self.db.commit()
# Remove UME link → apply should keep edge (lldp remains)
self.db.query(UmeTopoLink).delete()
self.db.commit()
apply_ume_topology_to_fabric(self.db)
edge2 = self.db.query(TopoFabricEdge).one()
self.assertEqual(edge2.status, "active")
self.assertIn("lldp", (edge2.attrs or {}).get("sources", []))
self.assertNotIn("ume", (edge2.attrs or {}).get("sources", []))
def test_pure_ume_becomes_missing_when_gone(self):
self._seed_ume()
apply_ume_topology_to_fabric(self.db)
self.db.query(UmeTopoLink).delete()
self.db.commit()
apply_ume_topology_to_fabric(self.db)
edge = self.db.query(TopoFabricEdge).one()
self.assertEqual(edge.status, "missing")
def test_manual_edge_never_cleared(self):
self._seed_ume()
apply_ume_topology_to_fabric(self.db)
a = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
.one()
)
b = (
self.db.query(TopoFabricNode)
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
.one()
)
upsert_fabric_edge(
self.db,
a_node_id=a.id,
b_node_id=b.id,
a_port="gi0/1",
b_port="gi0/2",
source="manual",
)
self.db.commit()
self.db.query(UmeTopoLink).delete()
self.db.commit()
apply_ume_topology_to_fabric(self.db)
manuals = (
self.db.query(TopoFabricEdge).filter(TopoFabricEdge.source == "manual").all()
)
self.assertEqual(len(manuals), 1)
self.assertEqual(manuals[0].status, "active")
if __name__ == "__main__":
unittest.main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View file

@ -62,7 +62,8 @@ export const queryKeys = {
fabricSummary: ["fabricSummary"] as const, fabricSummary: ["fabricSummary"] as const,
/** @deprecated */ /** @deprecated */
topologyMaps: ["topologyViews"] as const, topologyMaps: ["topologyViews"] as const,
topologyGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, topologyGraph: (viewId: string, focusFolderId = "") =>
["topologyViewGraph", viewId, focusFolderId] as const,
lldpCollectDashboard: ["lldpCollectDashboard"] as const, lldpCollectDashboard: ["lldpCollectDashboard"] as const,
lldpCollectJobsAll: ["lldpCollectJobs"] as const, lldpCollectJobsAll: ["lldpCollectJobs"] as const,
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const, lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,

View file

@ -1423,6 +1423,12 @@ const en = {
newRegion: "New region", newRegion: "New region",
newRegionName: "New region", newRegionName: "New region",
newRegionPrompt: "Region name", newRegionPrompt: "Region name",
newSubRegion: "New sub-region",
newSubRegionName: "New sub-region",
newSubRegionPrompt: "Sub-region name",
subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.",
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
subRegionNeedParent: "Open a region canvas first, then create a sub-region.",
regionCreated: "Region created", regionCreated: "Region created",
regionDeleted: "Region deleted", regionDeleted: "Region deleted",
deleteRegion: "Delete region", deleteRegion: "Delete region",

View file

@ -1414,6 +1414,12 @@ const zh = {
newRegion: "新建区域", newRegion: "新建区域",
newRegionName: "新区域", newRegionName: "新区域",
newRegionPrompt: "区域名称", newRegionPrompt: "区域名称",
newSubRegion: "新建子区域",
newSubRegionName: "新子区域",
newSubRegionPrompt: "子区域名称",
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
subRegionNeedParent: "请先打开一个区域画布,再新建子区域。",
regionCreated: "区域已创建", regionCreated: "区域已创建",
regionDeleted: "区域已删除", regionDeleted: "区域已删除",
deleteRegion: "删除区域", deleteRegion: "删除区域",

View file

@ -1,4 +1,4 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -5558,9 +5558,11 @@ pre {
.topo-region-list__block { .topo-region-list__block {
margin: 0; margin: 0;
width: 100%; width: auto;
max-width: 100%;
min-width: 0; min-width: 0;
padding: 2px 0 4px; padding: 2px 0 4px;
box-sizing: border-box;
} }
.topo-region-list__row { .topo-region-list__row {
@ -5581,13 +5583,15 @@ pre {
.topo-region-list .topo-map-list__row:hover, .topo-region-list .topo-map-list__row:hover,
.topo-region-list__block.is-hot > .topo-region-list__row, .topo-region-list__block.is-hot > .topo-region-list__row,
.topo-region-list__maps > li.is-hot > .topo-map-list__row { .topo-region-list__maps > li.is-hot > .topo-map-list__row,
.topo-region-list__view.is-hot > .topo-map-list__row {
background: var(--nm-nav-hover, #152033); background: var(--nm-nav-hover, #152033);
} }
/* Selected = deep wash + left accent */ /* Selected = deep wash + left accent */
.topo-region-list__block.is-active > .topo-region-list__row, .topo-region-list__block.is-active > .topo-region-list__row,
.topo-region-list__maps > li.is-active > .topo-map-list__row { .topo-region-list__maps > li.is-active > .topo-map-list__row,
.topo-region-list__view.is-active > .topo-map-list__row {
border: 0; border: 0;
background: var(--nm-nav-active, #1a2740); background: var(--nm-nav-active, #1a2740);
box-shadow: inset 3px 0 0 var(--nm-accent, #3b82f6); box-shadow: inset 3px 0 0 var(--nm-accent, #3b82f6);
@ -5625,7 +5629,8 @@ pre {
.topo-region-list__maps { .topo-region-list__maps {
position: relative; position: relative;
margin: 2px 0 0; margin: 2px 0 0;
padding: 2px 0 2px 0; /* One nest level = one indent; do not also set marginLeft on child blocks. */
padding: 2px 0 2px 14px;
overflow: visible; overflow: visible;
flex: none; flex: none;
} }
@ -5633,7 +5638,7 @@ pre {
.topo-region-list__maps::before { .topo-region-list__maps::before {
content: ""; content: "";
position: absolute; position: absolute;
left: 18px; left: 6px;
top: 0; top: 0;
bottom: 6px; bottom: 6px;
width: 1px; width: 1px;
@ -5642,8 +5647,10 @@ pre {
.topo-region-list__maps > li { .topo-region-list__maps > li {
margin: 0; margin: 0;
width: 100%; width: auto;
max-width: 100%;
position: relative; position: relative;
box-sizing: border-box;
} }
.topo-region-list__maps > li + li { .topo-region-list__maps > li + li {
@ -5660,9 +5667,10 @@ pre {
border-radius: 8px; border-radius: 8px;
} }
/* View rows use a chevron spacer — same padding as folder rows (no extra 36px). */
.app-main .topo-region-list__maps .topo-map-list__item, .app-main .topo-region-list__maps .topo-map-list__item,
.topo-region-list__maps .topo-map-list__item { .topo-region-list__maps .topo-map-list__item {
padding: 7px 6px 7px 36px; padding: 8px 6px 8px 8px;
} }
.topo-region-list .topo-map-list__item:hover, .topo-region-list .topo-map-list__item:hover,
@ -7068,6 +7076,31 @@ pre {
.topo-node--other { .topo-node--other {
--topo-icon-color: var(--topo-vendor-gray, #94a3b8); --topo-icon-color: var(--topo-vendor-gray, #94a3b8);
} }
.topo-node--region {
--topo-icon-color: #38bdf8;
}
.topo-region-list__chevron {
flex: 0 0 auto;
width: 24px;
height: 24px;
min-width: 24px;
opacity: 0.75;
}
.topo-region-list__chevron:disabled {
opacity: 0.25;
cursor: default;
}
.topo-region-list__chevron-spacer {
flex: 0 0 auto;
width: 24px;
min-width: 24px;
height: 24px;
}
.topo-region-list .topo-map-list__row {
display: flex;
align-items: center;
gap: 2px;
}
.topo-node__glyph { .topo-node__glyph {
position: relative; position: relative;
@ -7076,6 +7109,20 @@ pre {
margin: 0 auto; margin: 0 auto;
} }
.topo-node.is-region .topo-node__glyph {
width: 56px;
height: 38px;
}
.topo-node.is-region .topo-node__caption {
margin-top: 8px;
}
.topo-node__icon--region {
width: 56px;
height: 38px;
}
.react-flow__node.is-search-hit .topo-node__glyph { .react-flow__node.is-search-hit .topo-node__glyph {
box-shadow: none; box-shadow: none;
outline: 2px solid rgba(245, 158, 11, 0.95); outline: 2px solid rgba(245, 158, 11, 0.95);

File diff suppressed because it is too large Load diff

View file

@ -1106,8 +1106,41 @@ export const populateTopologyView = (
graph: TopologyViewGraph | null; graph: TopologyViewGraph | null;
}>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {}); }>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {});
export const fetchTopologyViewGraph = (viewId: string) => export const fetchTopologyViewGraph = (
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`); viewId: string,
opts?: {
min_x?: number;
max_x?: number;
min_y?: number;
max_y?: number;
sbn_id?: string;
folder_id?: string;
lod?: "auto" | "sbn" | "me";
status?: string;
},
) => {
const q = new URLSearchParams();
if (opts?.min_x != null) q.set("min_x", String(opts.min_x));
if (opts?.max_x != null) q.set("max_x", String(opts.max_x));
if (opts?.min_y != null) q.set("min_y", String(opts.min_y));
if (opts?.max_y != null) q.set("max_y", String(opts.max_y));
if (opts?.sbn_id) q.set("sbn_id", opts.sbn_id);
if (opts?.folder_id) q.set("folder_id", opts.folder_id);
if (opts?.lod) q.set("lod", opts.lod);
if (opts?.status) q.set("status", opts.status);
const qs = q.toString();
return apiGet<TopologyViewGraph>(
`/v1/topology/views/${encodeURIComponent(viewId)}${qs ? `?${qs}` : ""}`,
);
};
export const fetchTopologyWorld = () =>
apiGet<{ view_id: string; folder_id: string; name: string }>("/v1/topology/world");
export const fetchTopologyFolderBBox = (folderId: string) =>
apiGet<{ folder_id: string; min_x: number; max_x: number; min_y: number; max_y: number; n: number }>(
`/v1/topology/folders/${encodeURIComponent(folderId)}/bbox`,
);
export const updateTopologyView = ( export const updateTopologyView = (
viewId: string, viewId: string,

View file

@ -413,6 +413,7 @@ export type TopologyTreeFolderItem = {
name: string; name: string;
sort_order: number; sort_order: number;
is_system: boolean; is_system: boolean;
external_ref?: string;
views: TopologyTreeViewItem[]; views: TopologyTreeViewItem[];
children: TopologyTreeFolderItem[]; children: TopologyTreeFolderItem[];
}; };
@ -510,6 +511,11 @@ export type TopologyViewNodeItem = {
device_type: string; device_type: string;
connect_status: string; connect_status: string;
managed_source?: string; managed_source?: string;
/** ne | region — region drills into a child canvas */
kind?: string;
folder_id?: string;
view_id?: string;
node_count?: number;
}; };
export type TopologyViewEdgeItem = { export type TopologyViewEdgeItem = {