mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
5d705fa7bb
commit
1ef43fdcd4
30 changed files with 4389 additions and 371 deletions
|
|
@ -32,6 +32,9 @@ class TopoFabricNode(Base):
|
|||
# rule | manual | ""
|
||||
role_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)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
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)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@ class UmeTopoLink(Base):
|
|||
z_ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
a_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)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
|
|
|||
|
|
@ -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_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_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_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)",
|
||||
|
|
@ -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_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)",
|
||||
"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 COLUMN ume_inventory_ne.ne_id IS '网元uuid'",
|
||||
"COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ def upsert_fabric_edge(
|
|||
Self-loops are skipped (``(None, \"skipped_self_loop\")``) so LLDP discovery can
|
||||
ignore a device advertising itself without aborting the rest of the scan.
|
||||
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()
|
||||
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"
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
if src not in {"lldp", "manual"}:
|
||||
if src not in {"lldp", "manual", "ume"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_edge_source")
|
||||
row = (
|
||||
db.query(TopoFabricEdge)
|
||||
|
|
@ -112,8 +115,8 @@ def upsert_fabric_edge(
|
|||
b_port=bp,
|
||||
source=src,
|
||||
status="active",
|
||||
attrs={},
|
||||
discovered_at=now if src == "lldp" else None,
|
||||
attrs={"sources": [src]},
|
||||
discovered_at=now if src in {"lldp", "ume"} else None,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
|
|
@ -135,18 +138,95 @@ def upsert_fabric_edge(
|
|||
)
|
||||
if row is None:
|
||||
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"
|
||||
row.source = src
|
||||
row.source = _primary_source(sources)
|
||||
row.status = "active"
|
||||
row.attrs = _clear_miss_attrs(_edge_attrs(row))
|
||||
if src == "lldp":
|
||||
row.attrs = attrs
|
||||
if src in {"lldp", "ume"}:
|
||||
row.discovered_at = row.discovered_at or now
|
||||
row.last_seen_at = now
|
||||
row.updated_at = now
|
||||
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:
|
||||
"""Retarget edges/view placements from dupe onto canon, then delete dupe."""
|
||||
if canon is None or dupe is None or canon.id == dupe.id:
|
||||
|
|
|
|||
|
|
@ -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 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 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 (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
|
|
|
|||
227
netx_api/topology_region_canvas.py
Normal file
227
netx_api/topology_region_canvas.py
Normal 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
|
||||
|
|
@ -291,10 +291,71 @@ def api_create_view(body: TopologyViewCreate, db: Session = Depends(get_db)) ->
|
|||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
@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}")
|
||||
def api_patch_view(
|
||||
view_id: str, body: TopologyViewUpdate, db: Session = Depends(get_db)
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ class TopologyFolderOut(BaseModel):
|
|||
name: str
|
||||
sort_order: int = 0
|
||||
is_system: bool = False
|
||||
external_ref: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
|
@ -222,6 +223,7 @@ class TopologyTreeFolderOut(BaseModel):
|
|||
name: str
|
||||
sort_order: int = 0
|
||||
is_system: bool = False
|
||||
external_ref: str = ""
|
||||
views: list[TopologyTreeViewOut] = Field(default_factory=list)
|
||||
children: list["TopologyTreeFolderOut"] = Field(default_factory=list)
|
||||
|
||||
|
|
@ -276,6 +278,11 @@ class ViewNodeOut(BaseModel):
|
|||
device_type: str = ""
|
||||
connect_status: str = ""
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -144,7 +144,19 @@ def _batch_ne_lookups(
|
|||
|
||||
|
||||
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)
|
||||
# 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()
|
||||
truncated = False
|
||||
reason = ""
|
||||
|
|
@ -152,7 +164,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
vnodes = vnodes[:VIEW_GRAPH_NODE_HARD_CAP]
|
||||
truncated = True
|
||||
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 = {
|
||||
n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all()
|
||||
} 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"
|
||||
status = str(filt.get("status") or "").strip().lower()
|
||||
nodes_out: list[ViewNodeOut] = []
|
||||
region_ids_seen: set[str] = set()
|
||||
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)
|
||||
label = (vn.label or "").strip()
|
||||
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,
|
||||
)
|
||||
)
|
||||
# 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] = []
|
||||
if fids:
|
||||
q = db.query(TopoFabricEdge).filter(
|
||||
|
|
@ -648,7 +683,9 @@ def patch_view_positions(
|
|||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
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}")
|
||||
row = existing.get(fid)
|
||||
if row is None:
|
||||
|
|
@ -695,6 +732,10 @@ def add_nodes_to_view(
|
|||
db: Session, view_id: str, body: ViewNodesAdd
|
||||
) -> ViewMutationOut | TopologyViewGraphOut:
|
||||
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)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
now = _utcnow()
|
||||
|
|
@ -865,6 +906,10 @@ def create_topology_placeholder_on_view(
|
|||
from .ne_service_common import _normalize_ip
|
||||
|
||||
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)
|
||||
max_nodes = int(mem.get("max_nodes") or 300)
|
||||
existing_count = (
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ def _folder_out(f: TopoFolder) -> TopologyFolderOut:
|
|||
name=f.name or "",
|
||||
sort_order=int(f.sort_order or 0),
|
||||
is_system=bool(f.is_system),
|
||||
external_ref=str(getattr(f, "external_ref", None) or ""),
|
||||
created_at=f.created_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")
|
||||
parent_id = str(body.parent_id or "").strip() or root.id
|
||||
parent = _get_folder_or_404(db, parent_id)
|
||||
if str(parent.kind or "") != "root":
|
||||
raise HTTPException(status_code=400, detail="region_must_hang_under_root")
|
||||
parent_kind = str(parent.kind or "")
|
||||
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()
|
||||
row = TopoFolder(
|
||||
id=uuid4().hex,
|
||||
parent_id=root.id,
|
||||
parent_id=parent.id,
|
||||
kind="region",
|
||||
name=name[:256],
|
||||
sort_order=int(body.sort_order or 0),
|
||||
|
|
@ -253,6 +274,28 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
|||
)
|
||||
db.add(row)
|
||||
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.refresh(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")
|
||||
else:
|
||||
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:
|
||||
row.sort_order = int(body.sort_order)
|
||||
if body.parent_id is not None and str(row.kind or "") == "region":
|
||||
parent = _get_folder_or_404(db, body.parent_id)
|
||||
if str(parent.kind or "") != "root":
|
||||
raise HTTPException(status_code=400, detail="region_must_hang_under_root")
|
||||
if str(parent.kind or "") not in ("root", "region"):
|
||||
raise HTTPException(status_code=400, detail="invalid_parent_folder")
|
||||
row.parent_id = parent.id
|
||||
row.updated_at = _utcnow()
|
||||
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):
|
||||
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
|
||||
_ = 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()
|
||||
for v in views:
|
||||
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.flush()
|
||||
db.delete(row)
|
||||
from .ume_topology_world import reconcile_world_flat_view
|
||||
|
||||
reconcile_world_flat_view(db)
|
||||
db.commit()
|
||||
return {"ok": True, "folder_id": folder_id, "deleted": True}
|
||||
|
||||
|
|
@ -321,6 +378,54 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
|||
.all()
|
||||
):
|
||||
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]] = {}
|
||||
root: TopoFolder | None = None
|
||||
|
|
@ -335,7 +440,19 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
|||
for v in views:
|
||||
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.
|
||||
ordered = sorted(
|
||||
folder_views,
|
||||
|
|
@ -346,18 +463,23 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
|||
x.id,
|
||||
),
|
||||
)
|
||||
return [
|
||||
TopologyTreeViewOut(
|
||||
id=v.id,
|
||||
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=nc_map.get(v.id, 0),
|
||||
updated_at=v.updated_at,
|
||||
out: list[TopologyTreeViewOut] = []
|
||||
for v in ordered:
|
||||
if is_world_flat_view(v):
|
||||
if not show_flat or not is_world_flat_visible(v):
|
||||
continue
|
||||
out.append(
|
||||
TopologyTreeViewOut(
|
||||
id=v.id,
|
||||
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:
|
||||
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 "",
|
||||
sort_order=int(folder.sort_order or 0),
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
134
netx_api/ume_port_normalize.py
Normal file
134
netx_api/ume_port_normalize.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
||||
import hashlib
|
||||
|
|
@ -17,6 +17,7 @@ from .db import SessionLocal
|
|||
from .models import UmeSyncJob, UmeTopoLink, UmeTopoNode
|
||||
from .ume_client import UMEClient
|
||||
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_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.a_ptp = extract_ptp(a_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.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:
|
||||
"""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):
|
||||
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)
|
||||
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
|
||||
inserted = nodes_ins + links_ins
|
||||
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_links": links_del,
|
||||
"fabric_apply": apply_stats,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
|
|
|||
247
netx_api/ume_topology_apply.py
Normal file
247
netx_api/ume_topology_apply.py
Normal 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)
|
||||
186
netx_api/ume_topology_flat_coords.py
Normal file
186
netx_api/ume_topology_flat_coords.py
Normal 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
|
||||
564
netx_api/ume_topology_world.py
Normal file
564
netx_api/ume_topology_world.py
Normal 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),
|
||||
}
|
||||
523
netx_api/ume_topology_world_graph.py
Normal file
523
netx_api/ume_topology_world_graph.py
Normal 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,
|
||||
)
|
||||
284
scripts/_analyze_topo_layout.py
Normal file
284
scripts/_analyze_topo_layout.py
Normal 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))
|
||||
151
scripts/_import_ume_csv_snapshot.py
Normal file
151
scripts/_import_ume_csv_snapshot.py
Normal 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
23
scripts/_run_ume_apply.py
Normal 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()
|
||||
|
|
@ -480,7 +480,7 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
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)
|
||||
self.assertTrue(out.get("deleted"))
|
||||
|
|
@ -493,6 +493,53 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
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:
|
||||
from netx_api import topology_classify as clf
|
||||
from netx_api.topology_schemas import (
|
||||
|
|
|
|||
369
tests/test_ume_hierarchy.py
Normal file
369
tests/test_ume_hierarchy.py
Normal 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()
|
||||
235
tests/test_ume_topology_apply.py
Normal file
235
tests/test_ume_topology_apply.py
Normal 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()
|
||||
BIN
web/public/topo/region-building.png
Normal file
BIN
web/public/topo/region-building.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
|
|
@ -62,7 +62,8 @@ export const queryKeys = {
|
|||
fabricSummary: ["fabricSummary"] as const,
|
||||
/** @deprecated */
|
||||
topologyMaps: ["topologyViews"] as const,
|
||||
topologyGraph: (viewId: string) => ["topologyViewGraph", viewId] as const,
|
||||
topologyGraph: (viewId: string, focusFolderId = "") =>
|
||||
["topologyViewGraph", viewId, focusFolderId] as const,
|
||||
lldpCollectDashboard: ["lldpCollectDashboard"] as const,
|
||||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||
|
|
|
|||
|
|
@ -1423,6 +1423,12 @@ const en = {
|
|||
newRegion: "New region",
|
||||
newRegionName: "New region",
|
||||
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",
|
||||
regionDeleted: "Region deleted",
|
||||
deleteRegion: "Delete region",
|
||||
|
|
|
|||
|
|
@ -1414,6 +1414,12 @@ const zh = {
|
|||
newRegion: "新建区域",
|
||||
newRegionName: "新区域",
|
||||
newRegionPrompt: "区域名称",
|
||||
newSubRegion: "新建子区域",
|
||||
newSubRegionName: "新子区域",
|
||||
newSubRegionPrompt: "子区域名称",
|
||||
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||
subRegionNeedParent: "请先打开一个区域画布,再新建子区域。",
|
||||
regionCreated: "区域已创建",
|
||||
regionDeleted: "区域已删除",
|
||||
deleteRegion: "删除区域",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
* {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
|
@ -5558,9 +5558,11 @@ pre {
|
|||
|
||||
.topo-region-list__block {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 2px 0 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topo-region-list__row {
|
||||
|
|
@ -5581,13 +5583,15 @@ pre {
|
|||
|
||||
.topo-region-list .topo-map-list__row:hover,
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Selected = deep wash + left accent */
|
||||
.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;
|
||||
background: var(--nm-nav-active, #1a2740);
|
||||
box-shadow: inset 3px 0 0 var(--nm-accent, #3b82f6);
|
||||
|
|
@ -5625,7 +5629,8 @@ pre {
|
|||
.topo-region-list__maps {
|
||||
position: relative;
|
||||
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;
|
||||
flex: none;
|
||||
}
|
||||
|
|
@ -5633,7 +5638,7 @@ pre {
|
|||
.topo-region-list__maps::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
|
|
@ -5642,8 +5647,10 @@ pre {
|
|||
|
||||
.topo-region-list__maps > li {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topo-region-list__maps > li + li {
|
||||
|
|
@ -5660,9 +5667,10 @@ pre {
|
|||
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,
|
||||
.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,
|
||||
|
|
@ -7068,6 +7076,31 @@ pre {
|
|||
.topo-node--other {
|
||||
--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 {
|
||||
position: relative;
|
||||
|
|
@ -7076,6 +7109,20 @@ pre {
|
|||
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 {
|
||||
box-shadow: none;
|
||||
outline: 2px solid rgba(245, 158, 11, 0.95);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1106,8 +1106,41 @@ export const populateTopologyView = (
|
|||
graph: TopologyViewGraph | null;
|
||||
}>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {});
|
||||
|
||||
export const fetchTopologyViewGraph = (viewId: string) =>
|
||||
apiGet<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}`);
|
||||
export const fetchTopologyViewGraph = (
|
||||
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 = (
|
||||
viewId: string,
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ export type TopologyTreeFolderItem = {
|
|||
name: string;
|
||||
sort_order: number;
|
||||
is_system: boolean;
|
||||
external_ref?: string;
|
||||
views: TopologyTreeViewItem[];
|
||||
children: TopologyTreeFolderItem[];
|
||||
};
|
||||
|
|
@ -510,6 +511,11 @@ export type TopologyViewNodeItem = {
|
|||
device_type: string;
|
||||
connect_status: 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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue