diff --git a/netx_api/models/topology.py b/netx_api/models/topology.py index b5e6f75..a6a70be 100644 --- a/netx_api/models/topology.py +++ b/netx_api/models/topology.py @@ -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) diff --git a/netx_api/models/ume.py b/netx_api/models/ume.py index 7a920a6..92a3444 100644 --- a/netx_api/models/ume.py +++ b/netx_api/models/ume.py @@ -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="{}") diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py index 7e758de..192e23e 100644 --- a/netx_api/schema_patches.py +++ b/netx_api/schema_patches.py @@ -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 '资源名称'", diff --git a/netx_api/topology_fabric_links.py b/netx_api/topology_fabric_links.py index 2a5721d..d535ada 100644 --- a/netx_api/topology_fabric_links.py +++ b/netx_api/topology_fabric_links.py @@ -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: diff --git a/netx_api/topology_migrate.py b/netx_api/topology_migrate.py index a9db859..36f40b3 100644 --- a/netx_api/topology_migrate.py +++ b/netx_api/topology_migrate.py @@ -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, diff --git a/netx_api/topology_region_canvas.py b/netx_api/topology_region_canvas.py new file mode 100644 index 0000000..bfa6f4c --- /dev/null +++ b/netx_api/topology_region_canvas.py @@ -0,0 +1,227 @@ +"""Child-region icons on a parent canvas (folder === canvas drill-down). + +Synthetic placements use fabric_node_id ``region:`` 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 diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 1aacaf4..93b9296 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -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) diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index e820b79..d6baf25 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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): diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index e67b2fc..97f325a 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -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 = ( diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 9944903..2892573 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -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, ) diff --git a/netx_api/ume_port_normalize.py b/netx_api/ume_port_normalize.py new file mode 100644 index 0000000..58b5cd2 --- /dev/null +++ b/netx_api/ume_port_normalize.py @@ -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)(? 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)) diff --git a/netx_api/ume_sync_topology.py b/netx_api/ume_sync_topology.py index 8c266c0..06f5865 100644 --- a/netx_api/ume_sync_topology.py +++ b/netx_api/ume_sync_topology.py @@ -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, ) diff --git a/netx_api/ume_topology_apply.py b/netx_api/ume_topology_apply.py new file mode 100644 index 0000000..c7b8dc0 --- /dev/null +++ b/netx_api/ume_topology_apply.py @@ -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) diff --git a/netx_api/ume_topology_flat_coords.py b/netx_api/ume_topology_flat_coords.py new file mode 100644 index 0000000..b7b3c1b --- /dev/null +++ b/netx_api/ume_topology_flat_coords.py @@ -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 diff --git a/netx_api/ume_topology_world.py b/netx_api/ume_topology_world.py new file mode 100644 index 0000000..26a51d4 --- /dev/null +++ b/netx_api/ume_topology_world.py @@ -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) + │ └─ + └─ 完整世界地图 (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), + } diff --git a/netx_api/ume_topology_world_graph.py b/netx_api/ume_topology_world_graph.py new file mode 100644 index 0000000..2954e7a --- /dev/null +++ b/netx_api/ume_topology_world_graph.py @@ -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, + ) diff --git a/scripts/_analyze_topo_layout.py b/scripts/_analyze_topo_layout.py new file mode 100644 index 0000000..77eedf7 --- /dev/null +++ b/scripts/_analyze_topo_layout.py @@ -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)) diff --git a/scripts/_import_ume_csv_snapshot.py b/scripts/_import_ume_csv_snapshot.py new file mode 100644 index 0000000..8f1cbeb --- /dev/null +++ b/scripts/_import_ume_csv_snapshot.py @@ -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() diff --git a/scripts/_run_ume_apply.py b/scripts/_run_ume_apply.py new file mode 100644 index 0000000..b24099e --- /dev/null +++ b/scripts/_run_ume_apply.py @@ -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() diff --git a/tests/test_topology.py b/tests/test_topology.py index 39f77cb..d57d151 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -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 ( diff --git a/tests/test_ume_hierarchy.py b/tests/test_ume_hierarchy.py new file mode 100644 index 0000000..d6223ca --- /dev/null +++ b/tests/test_ume_hierarchy.py @@ -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() diff --git a/tests/test_ume_topology_apply.py b/tests/test_ume_topology_apply.py new file mode 100644 index 0000000..a04f3db --- /dev/null +++ b/tests/test_ume_topology_apply.py @@ -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() diff --git a/web/public/topo/region-building.png b/web/public/topo/region-building.png new file mode 100644 index 0000000..181547c Binary files /dev/null and b/web/public/topo/region-building.png differ diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 4136930..7bb869e 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -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, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 51f4379..9cc613c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c748304..aaa341a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1414,6 +1414,12 @@ const zh = { newRegion: "新建区域", newRegionName: "新区域", newRegionPrompt: "区域名称", + newSubRegion: "新建子区域", + newSubRegionName: "新子区域", + newSubRegionPrompt: "子区域名称", + subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。", + worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。", + subRegionNeedParent: "请先打开一个区域画布,再新建子区域。", regionCreated: "区域已创建", regionDeleted: "区域已删除", deleteRegion: "删除区域", diff --git a/web/src/index.css b/web/src/index.css index 75821d4..a097afa 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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); diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 581301a..380ea58 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -8,6 +8,7 @@ import { useRef, useState, type CSSProperties, + type ReactNode, } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -49,6 +50,7 @@ import { fetchManagedNeById, fetchTopologyGraph, fetchTopologyTree, + fetchTopologyWorld, fetchUmeNe, patchTopologyEdgeStyle, patchTopologyPositions, @@ -103,10 +105,115 @@ function findViewInRegion( for (const region of regions) { const view = (region.views || []).find((v) => v.id === viewId); if (view) return { region, view }; + const nested = findViewInRegion(region.children || [], viewId); + if (nested) return nested; } return null; } +function findFolderInTree( + folders: TopologyTreeFolderItem[], + folderId: string, +): TopologyTreeFolderItem | null { + for (const folder of folders) { + if (folder.id === folderId) return folder; + const nested = findFolderInTree(folder.children || [], folderId); + if (nested) return nested; + } + return null; +} + +function indexFoldersById( + folders: TopologyTreeFolderItem[], + acc: Map = new Map(), +): Map { + for (const folder of folders) { + acc.set(folder.id, folder); + indexFoldersById(folder.children || [], acc); + } + return acc; +} + +/** Self + ancestors (rootward), for expanding the left tree path. */ +function folderPathIds( + folders: TopologyTreeFolderItem[], + folderId: string, +): string[] { + const byId = indexFoldersById(folders); + const path: string[] = []; + let cur: string | undefined = folderId; + const seen = new Set(); + while (cur && !seen.has(cur)) { + seen.add(cur); + path.push(cur); + const folder = byId.get(cur); + const parent = String(folder?.parent_id || "").trim(); + // Stop at topology root (kind=root) — regions list only contains region folders. + if (!folder || !parent || !byId.has(parent)) break; + cur = parent; + } + return path; +} + +/** UME World container only — hex nav, not a canvas. */ +function isUmeWorldContainer(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder) return false; + const ext = String(folder.external_ref || "").trim(); + return ext === "ume:world" || folder.name === "UME World"; +} + +/** UME World / SBN nav folders share drill canvases; manual regions keep their own maps. */ +function isUmeWorldNavFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder) return false; + const ext = String(folder.external_ref || "").trim(); + if (isUmeWorldContainer(folder) || ext === "ume:world:drill") return true; + return Boolean(ext); +} + +function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder) return false; + const ext = String(folder.external_ref || "").trim(); + return ext === "ume:world:drill" || (folder.name === "World" && Boolean(folder.is_system)); +} + +function isWorldFlatViewName(name: string | undefined | null): boolean { + return String(name || "").trim() === "完整世界地图"; +} + +/** + * Region row IS the canvas (SBN / manual region / nested sub-region / World drill). + * UME World container is nav-only (hex browse for L2 + world map). + */ +function isRegionCanvasFolder( + folder: TopologyTreeFolderItem | null | undefined, +): boolean { + if (!folder) return false; + if (isUmeWorldContainer(folder)) return false; + return true; +} + +function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} + function regionDisplayName(region: TopologyTreeFolderItem | null | undefined): string { if (!region) return ""; return region.name || ""; @@ -128,17 +235,9 @@ const SNAP_GRID: [number, number] = [16, 16]; * skips those. includeHiddenNodes uses declared width/height so fit still works. */ const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const; -const FIT_VIEW_OPTS_FS = { padding: 0.15, includeHiddenNodes: true } as const; -type FitViewOpts = typeof FIT_VIEW_OPTS | typeof FIT_VIEW_OPTS_FS; const UNDO_MAX = 40; const PALETTE_DND = "application/x-netx-topo-palette"; -/** Pane must be laid out before fitView; zero-size panes produce a corner-zoom viewport. */ -function canvasPaneReady(el: HTMLElement | null | undefined): boolean { - if (!el) return false; - return el.clientWidth >= 40 && el.clientHeight >= 40; -} - function FullscreenIcon({ exit }: { exit?: boolean }) { if (exit) { return ( @@ -417,13 +516,40 @@ function RouterIcon() { ); } +/** Canvas sub-region building art (UME-style); no vendor tint. */ +function RegionCanvasIcon() { + return ( + + ); +} + /** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */ const TOPO_NODE_W = 160; const TOPO_NODE_H = 88; /** Must match `.topo-node__glyph` (56×56, top-centered) + `--center` handles. */ const TOPO_ICON = 56; +/** region-building.png is 195×133 — keep aspect so icon center == handle. */ +const TOPO_REGION_ICON_H = Math.round((56 * 133) / 195); // 38 const TOPO_HANDLE_X = TOPO_NODE_W / 2; const TOPO_HANDLE_Y = TOPO_ICON / 2; +const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2; +const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2; + +/** Region API x/y is icon center; RF position is top-left of the node box. */ +function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } { + return { x: apiX - TOPO_REGION_HANDLE_X, y: apiY - TOPO_REGION_HANDLE_Y }; +} + +function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } { + return { x: flowX + TOPO_REGION_HANDLE_X, y: flowY + TOPO_REGION_HANDLE_Y }; +} function isPlaceholderSource(source: string | undefined, neIp: string): boolean { const src = String(source || "").trim().toLowerCase(); @@ -433,36 +559,45 @@ function isPlaceholderSource(source: string | undefined, neIp: string): boolean const NeNode = memo(function NeNode({ data, selected }: NodeProps>) { const { hideIp, hideVendor, connectMode, showPlaceholderBadge } = useContext(TopoDisplayContext); - const tone = nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id); - const placeholder = isPlaceholderSource(data.managed_source, data.ne_ip); + const isRegion = data.kind === "region"; + const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id); + const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip); const showBadge = placeholder && showPlaceholderBadge; - const name = data.label || (!hideIp ? data.ne_ip : "") || "NE"; - const secondary = [ - hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip, - hideVendor || !data.vendor ? "" : data.vendor, - ].filter(Boolean); + const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE"); + // Region count stays in the title "(N)"; do not repeat under the icon. + const secondary = isRegion + ? [] + : [ + hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip, + hideVendor || !data.vendor ? "" : data.vendor, + ].filter(Boolean); return (
- {/* Single center anchors on the router glyph — same attachment as discovered edges. */} - + {!isRegion ? : } {showBadge ? ( {String(data.managed_source || "ph").slice(0, 4)} @@ -744,17 +879,26 @@ function graphToFlow( edges: TopologyViewEdgeItem[], defaults: EdgeDefaults, ) { - const rfNodes: Node[] = nodes.map((n) => ({ + const rfNodes: Node[] = nodes.map((n) => { + const isRegion = + n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:"); + const apiX = Number(n.x) || 0; + const apiY = Number(n.y) || 0; + // Region coords are icon-center; NE coords stay top-left of the node box (legacy). + const position = isRegion ? regionFlowPosition(apiX, apiY) : { x: apiX, y: apiY }; + const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X; + const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y; + return { id: n.fabric_node_id, type: "neNode", - position: { x: n.x || 0, y: n.y || 0 }, + position, width: TOPO_NODE_W, height: TOPO_NODE_H, // Predetermined handles must match DOM anchors (icon center), not the 160px box edges — // otherwise edges float in the gap and never touch the router glyph. handles: [ - { type: "target", position: Position.Left, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y }, - { type: "source", position: Position.Right, x: TOPO_HANDLE_X, y: TOPO_HANDLE_Y }, + { type: "target", position: Position.Left, x: hx, y: hy }, + { type: "source", position: Position.Right, x: hx, y: hy }, ], data: { label: n.label || n.name || n.ip || n.fabric_node_id, @@ -764,8 +908,13 @@ function graphToFlow( vendor: n.vendor || "", connect_status: n.connect_status || "", managed_source: n.managed_source || "", + kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"), + folder_id: n.folder_id || "", + view_id: n.view_id || "", + node_count: n.node_count || 0, }, - })); + }; + }); const rfEdges: Edge[] = edges.map((e) => { const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual"; @@ -796,12 +945,18 @@ function graphToFlow( } function flowToPositions(nodes: Node[]) { - return nodes.map((n) => ({ - fabric_node_id: n.id, - x: n.position.x, - y: n.position.y, - label: n.data.label || "", - })); + return nodes.map((n) => { + const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:"); + const pos = isRegion + ? regionApiPosition(n.position.x, n.position.y) + : { x: n.position.x, y: n.position.y }; + return { + fabric_node_id: n.id, + x: pos.x, + y: pos.y, + label: n.data.label || "", + }; + }); } /** Stable signature so live poll only re-applies when the server graph actually changed. */ @@ -846,18 +1001,14 @@ export function TopologyPage() { const [searchParams, setSearchParams] = useSearchParams(); // mapId set → fabric canvas; otherwise browse directory (region or view folder). const [mapId, setMapId] = useState(""); + /** When mapId is the World canvas, optional SBN region folder to zoom/filter. */ + const [worldFocusFolderId, setWorldFocusFolderId] = useState(""); + const [worldViewId, setWorldViewId] = useState(""); const [selectedFolderId, setSelectedFolderId] = useState(""); /** Hover sync key between left tree and right hex browser: `region:` | `view:`. */ const [hotBrowseKey, setHotBrowseKey] = useState(""); const [pendingHighlightNe, setPendingHighlightNe] = useState(""); - const [expandedIds, setExpandedIds] = useState>(() => { - try { - const raw = localStorage.getItem(TREE_EXPAND_KEY); - return raw ? (JSON.parse(raw) as Record) : {}; - } catch { - return {}; - } - }); + const [expandedIds, setExpandedIds] = useState>({}); const [keyword, setKeyword] = useState(""); const [debouncedKeyword, setDebouncedKeyword] = useState(""); const [treeNeQuery, setTreeNeQuery] = useState(""); @@ -941,9 +1092,6 @@ export function TopologyPage() { const appliedMapIdRef = useRef(""); const appliedGraphFpRef = useRef(""); const nodesRef = useRef[]>([]); - /** True until the first successful fitView after opening / switching a canvas. */ - const needsInitialFitRef = useRef(false); - const fitGenRef = useRef(0); const historyRef = useRef([]); const redoRef = useRef([]); const historyLockRef = useRef(false); @@ -957,39 +1105,6 @@ export function TopologyPage() { nodesRef.current = nodes; - const scheduleFitView = useCallback((opts: FitViewOpts = FIT_VIEW_OPTS) => { - const gen = ++fitGenRef.current; - const attempt = (n: number) => { - if (gen !== fitGenRef.current) return; - const inst = rfRef.current; - const paneOk = canvasPaneReady(canvasRef.current); - if (!inst || !paneOk || nodesRef.current.length === 0) { - if (n < 80) window.setTimeout(() => attempt(n + 1), 40); - return; - } - // Double rAF: wait for flex layout + React Flow pane measurement. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (gen !== fitGenRef.current) return; - if (!canvasPaneReady(canvasRef.current) || !rfRef.current) { - if (n < 80) window.setTimeout(() => attempt(n + 1), 40); - return; - } - rfRef.current.fitView({ ...opts }); - // Late layout (fonts / sidebar) on slower hosts — one deferred refit. - window.setTimeout(() => { - if (gen !== fitGenRef.current) return; - if (canvasPaneReady(canvasRef.current) && rfRef.current && nodesRef.current.length > 0) { - rfRef.current.fitView({ ...opts }); - needsInitialFitRef.current = false; - } - }, 160); - }); - }); - }; - attempt(0); - }, []); - const markDirty = useCallback(() => { dirtyRef.current = true; setDirty(true); @@ -1169,30 +1284,9 @@ export function TopologyPage() { const canvasMode = Boolean(mapId); - // If the pane was 0×0 when nodes arrived (common on remote Linux), fit once it grows. - useEffect(() => { - if (!canvasMode) return; - const el = canvasRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - let prevW = el.clientWidth; - let prevH = el.clientHeight; - const ro = new ResizeObserver(() => { - const w = el.clientWidth; - const h = el.clientHeight; - const becameReady = (prevW < 40 || prevH < 40) && w >= 40 && h >= 40; - prevW = w; - prevH = h; - if (becameReady && needsInitialFitRef.current && nodesRef.current.length > 0) { - scheduleFitView(FIT_VIEW_OPTS); - } - }); - ro.observe(el); - return () => ro.disconnect(); - }, [canvasMode, mapId, scheduleFitView]); - const activeRegion = useMemo(() => { if (!selectedFolderId) return null; - return regions.find((c) => c.id === selectedFolderId) || null; + return findFolderInTree(regions, selectedFolderId); }, [regions, selectedFolderId]); const activeView = useMemo(() => { @@ -1201,27 +1295,166 @@ export function TopologyPage() { }, [mapId, regions]); const browseEntries = useMemo( - (): TopologyTreeViewItem[] => activeRegion?.views || [], + (): TopologyTreeViewItem[] => { + // Region === canvas: no map subdirectory listing. + // UME World container: hex shows L2 + flat modules (handled separately). + if (!activeRegion || isRegionCanvasFolder(activeRegion) || isUmeWorldContainer(activeRegion)) { + return []; + } + return activeRegion.views || []; + }, [activeRegion], ); + const hexBrowseRegion = useMemo(() => { + // Root hex, or UME World container hex (L2 + world map modules). + if (!activeRegion) return null; + if (isUmeWorldContainer(activeRegion)) return activeRegion; + if (isRegionCanvasFolder(activeRegion)) return null; + return activeRegion; + }, [activeRegion]); + + const umeWorldHexModules = useMemo(() => { + if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null; + const drill = + (activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null; + const flatView = + (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null; + return { drill, flatView }; + }, [activeRegion]); + + const isWorldFlatCanvas = isWorldFlatViewName(activeView?.name); + + // Resolve World drill view id for shortcuts; do not auto-open canvas. + useEffect(() => { + let cancelled = false; + fetchTopologyWorld() + .then((w) => { + if (cancelled) return; + setWorldViewId(w.view_id); + }) + .catch(() => { + /* world not seeded yet — keep classic browse */ + }); + return () => { + cancelled = true; + }; + }, []); + + const expandFolderPath = useCallback( + (folderId: string) => { + const path = folderPathIds(regions, folderId); + if (!path.length) return; + setExpandedIds((p) => { + const next = { ...p }; + for (const id of path) next[id] = true; + return next; + }); + }, + [regions], + ); + + const goUmeWorldNav = useCallback(() => { + if (!confirmDiscardIfDirty()) return; + const container = regions.find( + (r) => r.external_ref === "ume:world" || r.name === "UME World", + ); + if (!container) return; + setWorldFocusFolderId(""); + setSelectedFolderId(container.id); + setExpandedIds((p) => ({ ...p, [container.id]: true })); + setMapId(""); + clearDirty(); + }, [confirmDiscardIfDirty, clearDirty, regions]); + + const goWorld = useCallback(() => { + if (!confirmDiscardIfDirty()) return; + if (!worldViewId) return; + setWorldFocusFolderId(""); + const container = regions.find( + (r) => r.external_ref === "ume:world" || r.name === "UME World", + ); + let drillFolder = + (container?.children || []).find((c) => isWorldDrillFolder(c)) || null; + if (!drillFolder) { + for (const r of regions) { + if (isWorldDrillFolder(r)) { + drillFolder = r; + break; + } + const nested = (r.children || []).find((c) => isWorldDrillFolder(c)); + if (nested) { + drillFolder = nested; + break; + } + } + } + if (drillFolder) { + setSelectedFolderId(drillFolder.id); + } else if (container) { + setSelectedFolderId(container.id); + } + setMapId(worldViewId); + clearDirty(); + }, [confirmDiscardIfDirty, clearDirty, worldViewId, regions]); + + const primaryViewOfFolder = useCallback((folder: TopologyTreeFolderItem | null | undefined) => { + if (!folder) return null; + const views = folder.views || []; + if (isWorldDrillFolder(folder)) { + return views.find((v) => v.name === "World") || views[0] || null; + } + // UME World container: World canvas lives on the drill child, not here. + if (folder.external_ref === "ume:world" || folder.name === "UME World") { + const drill = (folder.children || []).find((c) => isWorldDrillFolder(c)); + if (drill) { + const dv = drill.views || []; + return dv.find((v) => v.name === "World") || dv[0] || null; + } + return views.find((v) => !isWorldFlatViewName(v.name)) || null; + } + return views[0] || null; + }, []); + const goRegion = useCallback( (folderId: string) => { if (!confirmDiscardIfDirty()) return; - setMapId(""); setSelectedFolderId(folderId); - setExpandedIds((p) => ({ ...p, [folderId]: true })); + expandFolderPath(folderId); + const folder = findFolderInTree(regions, folderId); + // Region === canvas: open its primary map (no map subdirectory / hex list). + if (isRegionCanvasFolder(folder)) { + setWorldFocusFolderId(""); + const view = primaryViewOfFolder(folder); + if (view) { + setMapId(view.id); + clearDirty(); + return; + } + } + // UME World container: fall through to empty selection (use goWorld from tree). + setMapId(""); + setWorldFocusFolderId(""); clearDirty(); setNodes([]); setEdges([]); }, - [confirmDiscardIfDirty, clearDirty, setNodes, setEdges], + [ + confirmDiscardIfDirty, + clearDirty, + setNodes, + setEdges, + regions, + primaryViewOfFolder, + expandFolderPath, + ], ); const goRoot = useCallback(() => { if (!confirmDiscardIfDirty()) return; - setMapId(""); setSelectedFolderId(""); + setWorldFocusFolderId(""); + setMapId(""); clearDirty(); setNodes([]); setEdges([]); @@ -1234,20 +1467,41 @@ export function TopologyPage() { const regionId = folderId || hit?.region.id || selectedFolderId; if (regionId) { setSelectedFolderId(regionId); - setExpandedIds((p) => ({ ...p, [regionId]: true })); + expandFolderPath(regionId); } + setWorldFocusFolderId(""); setMapId(viewId); }, - [confirmDiscardIfDirty, regions, selectedFolderId], + [confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath], ); const goBackBrowse = useCallback(() => { if (!confirmDiscardIfDirty()) return; + const folder = findFolderInTree(regions, selectedFolderId); + // Leaving a region canvas under UME World → container hex nav. + if (folder) { + const path = folderPathIds(regions, folder.id); + const container = regions.find((r) => isUmeWorldContainer(r)); + if (container && (folder.id === container.id || path.includes(container.id))) { + setSelectedFolderId(container.id); + setWorldFocusFolderId(""); + setMapId(""); + clearDirty(); + setNodes([]); + setEdges([]); + return; + } + } + // Leaving other region canvases → root hex. + if (isRegionCanvasFolder(folder) || isUmeWorldNavFolder(folder)) { + setSelectedFolderId(""); + } + setWorldFocusFolderId(""); setMapId(""); clearDirty(); setNodes([]); setEdges([]); - }, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]); + }, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId]); // Deep link from classify search: /topology?view=&ne= useEffect(() => { @@ -1268,6 +1522,19 @@ export function TopologyPage() { appliedGraphFpRef.current = ""; }, [mapId]); + // Keep the left tree scrolled to the active view/folder after drill-down. + useEffect(() => { + if (!mapId && !selectedFolderId) return; + const root = document.querySelector(".topo-region-list"); + if (!root) return; + const hit = + root.querySelector("li.is-active") || + root.querySelector(".topo-region-list__block.is-branch-active"); + if (hit && "scrollIntoView" in hit) { + (hit as HTMLElement).scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + }, [mapId, selectedFolderId, expandedIds]); + useEffect(() => { if (!canvasMode) return; if (!mapId || !graphQuery.data) return; @@ -1299,11 +1566,10 @@ export function TopologyPage() { pendingEdgeCreatesRef.current = new Set(); clearDirty(); bumpHistory(); - needsInitialFitRef.current = true; - scheduleFitView(FIT_VIEW_OPTS); + // Do not auto-fit on canvas switch — user clicks 适应画布. } historyLockRef.current = false; - }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory, scheduleFitView]); + }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]); useEffect(() => { setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); @@ -1397,7 +1663,6 @@ export function TopologyPage() { const next = layoutGraph(nodes, edges, kind, { onlyIds }); setNodes(next); markDirty(); - window.setTimeout(() => scheduleFitView(FIT_VIEW_OPTS), 40); if (opts?.persist && mapId) { try { const graph = await patchTopologyPositions(mapId, flowToPositions(next)); @@ -1413,7 +1678,7 @@ export function TopologyPage() { } } }, - [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty, scheduleFitView], + [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty], ); const applyAlign = useCallback( @@ -1452,17 +1717,48 @@ export function TopologyPage() { ); const createRegionMut = useMutation({ - mutationFn: (name: string) => + mutationFn: (input: { name: string; parent_id?: string }) => createTopologyFolder({ - name, + name: input.name, kind: "region", - parent_id: treeQuery.data?.root?.id, + parent_id: input.parent_id || treeQuery.data?.root?.id, }), - onSuccess: async (folder) => { + onSuccess: async (folder, input) => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); - setSelectedFolderId(folder.id); - setMapId(""); - setExpandedIds((prev) => ({ ...prev, [folder.id]: true })); + const tree = await fetchTopologyTree(); + const regionsList = tree.root?.children || []; + const hit = findFolderInTree(regionsList, folder.id); + const parent = String(input.parent_id || folder.parent_id || hit?.parent_id || "").trim(); + const rootId = String(tree.root?.id || "").trim(); + const isNested = Boolean(parent && parent !== rootId); + setWorldFocusFolderId(""); + setExpandedIds((prev) => { + const next = { ...prev, [folder.id]: true }; + if (parent) next[parent] = true; + return next; + }); + if (isNested) { + // Stay on parent canvas so the new region building icon is visible. + setSelectedFolderId(parent); + expandFolderPath(parent); + const parentHit = findFolderInTree(regionsList, parent); + const parentView = + primaryViewOfFolder(parentHit) || + (parentHit?.views || []).find((v) => String(v.kind) === "physical") || + (parentHit?.views || [])[0]; + const stayViewId = mapId || parentView?.id || ""; + if (stayViewId) { + setMapId(stayViewId); + await queryClient.invalidateQueries({ + queryKey: queryKeys.topologyGraph(stayViewId), + }); + } + } else { + setSelectedFolderId(folder.id); + const view = hit?.views?.[0]; + if (view) setMapId(view.id); + else setMapId(""); + } showOk(t("topology.regionCreated")); }, onError: (err) => showError(String(err)), @@ -1535,9 +1831,32 @@ export function TopologyPage() { const promptNewRegion = useCallback(() => { const name = window.prompt(t("topology.newRegionPrompt"), t("topology.newRegionName")); if (!name?.trim()) return; - createRegionMut.mutate(name.trim()); + createRegionMut.mutate({ name: name.trim() }); }, [createRegionMut, t]); + const promptNewSubRegion = useCallback(() => { + if (isWorldFlatViewName(activeView?.name)) { + showError(t("topology.subRegionNotOnFlat")); + return; + } + let parentId = selectedFolderId; + if (!parentId && worldViewId && mapId === worldViewId) { + const container = regions.find( + (r) => r.external_ref === "ume:world" || r.name === "UME World", + ); + const drill = (container?.children || []).find((c) => isWorldDrillFolder(c)); + parentId = drill?.id || container?.id || ""; + } + if (!parentId) { + showError(t("topology.subRegionNeedParent")); + return; + } + const name = window.prompt(t("topology.newSubRegionPrompt"), t("topology.newSubRegionName")); + if (!name?.trim()) return; + createRegionMut.mutate({ name: name.trim(), parent_id: parentId }); + setCtxMenu(null); + }, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]); + const promptNewCustom = useCallback( (folderId: string) => { const name = window.prompt(t("topology.newCustomPrompt"), t("topology.newCustomName")); @@ -1551,6 +1870,257 @@ export function TopologyPage() { [createMapMut, t], ); + const renderWorldNavFolder = useCallback( + (folder: TopologyTreeFolderItem, depth: number): ReactNode => { + const kids = folder.children || []; + const containerFolder = + folder.external_ref === "ume:world" || folder.name === "UME World"; + const drillFolder = isWorldDrillFolder(folder); + const umeNav = isUmeWorldNavFolder(folder); + // Region === canvas: hide physical/custom view rows; only child regions. + // UME World container: only「完整世界地图」as a sibling view. + const canvasRegion = isRegionCanvasFolder(folder); + const visibleViews = canvasRegion + ? [] + : containerFolder + ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) + : folder.views || []; + const hasKids = kids.length > 0 || visibleViews.length > 0; + const open = Boolean(expandedIds[folder.id]); + const viewActiveUnder = + (folder.views || []).some((v) => v.id === mapId) || + (drillFolder && Boolean(worldViewId) && mapId === worldViewId); + const isSelected = selectedFolderId === folder.id; + const focused = viewActiveUnder || (isSelected && Boolean(mapId)); + const regionActive = isSelected && !mapId; + const hot = hotBrowseKey === `region:${folder.id}`; + const childCount = kids.length; + + const renderViewRow = (v: TopologyTreeViewItem) => { + const isPhysical = String(v.kind) === "physical"; + const viewHot = hotBrowseKey === `view:${v.id}`; + const viewActive = mapId === v.id; + return ( +
  • +
    setHotBrowseKey(`view:${v.id}`)} + onMouseLeave={() => + setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) + } + > + {/* Same width as folder chevron so leaf canvas aligns under parent title. */} +
    +
  • + ); + }; + + return ( +
  • +
    setHotBrowseKey(`region:${folder.id}`)} + onMouseLeave={() => + setHotBrowseKey((k) => (k === `region:${folder.id}` ? "" : k)) + } + > + + + {!umeNav ? ( +
    + + +
    + ) : null} +
    + {open ? ( +
      + {containerFolder ? ( + <> + {kids.map((child) => renderWorldNavFolder(child, depth + 1))} + {visibleViews.map(renderViewRow)} + + ) : ( + <> + {visibleViews.map(renderViewRow)} + {kids.map((child) => renderWorldNavFolder(child, depth + 1))} + + )} +
    + ) : null} +
  • + ); + }, + [ + expandedIds, + mapId, + worldViewId, + selectedFolderId, + hotBrowseKey, + dirty, + goUmeWorldNav, + goWorld, + goRegion, + goCanvas, + promptRenameRegion, + promptRenameMap, + renameRegionMut.isPending, + renameMapMut.isPending, + deleteFolderMut, + deleteMapMut, + t, + ], + ); + const saveMut = useMutation({ mutationFn: async () => { if (!mapId) throw new Error(t("topology.selectMap")); @@ -1578,8 +2148,10 @@ export function TopologyPage() { const localIds = nodes.map((n) => n.id); const localSet = new Set(localIds); const serverSet = new Set(serverIds); - const toAdd = localIds.filter((id) => !serverSet.has(id)); - const toRemove = serverIds.filter((id) => !localSet.has(id)); + const isRegionNode = (id: string) => id.startsWith("region:"); + const toAdd = localIds.filter((id) => !serverSet.has(id) && !isRegionNode(id)); + const toRemove = serverIds.filter((id) => !localSet.has(id) && !isRegionNode(id)); + // Region icons are synthetic placements — persist via positions only. // Add first so a save that both adds and removes never flashes an empty view. if (toAdd.length) { await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd }); @@ -1753,7 +2325,6 @@ export function TopologyPage() { let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults); // Keep existing node positions when we did not auto-layout. const localPos = new Map(nodes.map((n) => [n.id, n.position])); - const beforeIds = new Set(nodes.map((n) => n.id)); const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id)); rfNodes = rfNodes.map((n) => { const p = localPos.get(n.id); @@ -1768,17 +2339,12 @@ export function TopologyPage() { setNodes(rfNodes); setEdges(rfEdges); historyLockRef.current = false; - const addedNodes = rfNodes.filter((n) => !beforeIds.has(n.id)).length; const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id)); if (localOnly || didAutoLayout) { markDirty(); } else { clearDirty(); } - if (didAutoLayout || addedNodes > 0) { - needsInitialFitRef.current = true; - scheduleFitView(FIT_VIEW_OPTS); - } const out: TopologyDiscoverOut = { map_id: mapId, protocol: "lldp", @@ -1810,7 +2376,7 @@ export function TopologyPage() { setDiscovering(false); } }, - [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView, graphQuery.data], + [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, graphQuery.data], ); const cancelDiscover = useCallback(async () => { @@ -1917,6 +2483,10 @@ export function TopologyPage() { showError(t("topology.selectMap")); return false; } + if (isWorldFlatViewName(activeView?.name)) { + showError(t("topology.worldMapNoDirectNes")); + return false; + } if (items.length === 0 || paletteAdding) return false; const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)); @@ -1994,6 +2564,7 @@ export function TopologyPage() { queryClient, edgeDefaults, clearDirty, + activeView?.name, ], ); @@ -2118,14 +2689,10 @@ export function TopologyPage() { const el = canvasRef.current; const active = Boolean(el && document.fullscreenElement === el); setFullscreen(active); - if (active) { - needsInitialFitRef.current = true; - scheduleFitView(FIT_VIEW_OPTS_FS); - } }; document.addEventListener("fullscreenchange", syncFs); return () => document.removeEventListener("fullscreenchange", syncFs); - }, [scheduleFitView]); + }, []); const toggleFullscreen = useCallback(async () => { const el = canvasRef.current; @@ -2302,8 +2869,6 @@ export function TopologyPage() { const added = Math.max(0, projected.nodes.length - before); if (added > 0) { markDirty(); - needsInitialFitRef.current = true; - scheduleFitView(FIT_VIEW_OPTS); } showOk(t("topology.projectedNeighbors").replace("{{count}}", String(added))); } catch (err) { @@ -2319,7 +2884,6 @@ export function TopologyPage() { showOk, showError, t, - scheduleFitView, ]); useEffect(() => { @@ -2623,6 +3187,10 @@ export function TopologyPage() { }; const openCreateNeAt = (flowX: number, flowY: number) => { + if (isWorldFlatViewName(activeView?.name)) { + showError(t("topology.worldMapNoDirectNes")); + return; + } closeCtxMenu(); setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" }); }; @@ -2885,14 +3453,38 @@ export function TopologyPage() { const onNodeClick = useCallback( (e: React.MouseEvent, node: Node) => { setCtxMenu(null); - // Connect mode: drag from center handle (onConnect). Clicks only select the NE. + if (node.data.kind === "region") { + const viewId = String(node.data.view_id || "").trim(); + const folderId = String(node.data.folder_id || "").trim(); + if (viewId) { + goCanvas(viewId, folderId || undefined); + return; + } + if (folderId) { + goRegion(folderId); + return; + } + } if (toolMode === "connect") { focusNode(node.id, false); return; } focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey); }, - [toolMode, focusNode], + [toolMode, focusNode, goCanvas, goRegion], + ); + + const onNodeDoubleClick = useCallback( + (_e: React.MouseEvent, node: Node) => { + // Regions already drill on single click; keep dblclick as no-op alias. + if (node.data.kind === "region") { + const viewId = String(node.data.view_id || "").trim(); + const folderId = String(node.data.folder_id || "").trim(); + if (viewId) goCanvas(viewId, folderId || undefined); + else if (folderId) goRegion(folderId); + } + }, + [goCanvas, goRegion], ); const outsidePeers = graphQuery.data?.outside_peers || []; @@ -2900,12 +3492,14 @@ export function TopologyPage() { const truncateReason = String(graphQuery.data?.truncate_reason || "").trim(); const truncateBannerText = useMemo(() => { if (!graphTruncated) return ""; + // World map soft-caps viewport nodes; the banner is noise there. + if (isWorldFlatViewName(activeView?.name)) return ""; if (truncateReason === "membership_cap") return t("topology.truncatedMembership"); if (truncateReason === "membership_frozen") return t("topology.truncatedFrozen"); if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes"); if (truncateReason === "too_many_edges") return t("topology.truncatedEdges"); return t("topology.truncatedGeneric"); - }, [graphTruncated, truncateReason, t]); + }, [graphTruncated, truncateReason, activeView?.name, t]); const activeLeafName = useMemo(() => { if (!mapId) return ""; if (activeView?.name) return activeView.name; @@ -3125,172 +3719,7 @@ export function TopologyPage() {

    {t("topology.emptyMaps")}

    ) : (
      - {regions.map((region) => { - const open = expandedIds[region.id] ?? false; - const regionActive = selectedFolderId === region.id && !mapId; - const regionBranch = selectedFolderId === region.id && Boolean(mapId); - const regionHot = hotBrowseKey === `region:${region.id}`; - return ( -
    • -
      setHotBrowseKey(`region:${region.id}`)} - onMouseLeave={() => - setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k)) - } - > - -
      - - -
      -
      - {open ? ( -
        - {(region.views || []).map((v) => { - const isPhysical = String(v.kind) === "physical"; - const viewHot = hotBrowseKey === `view:${v.id}`; - return ( -
      • -
        setHotBrowseKey(`view:${v.id}`)} - onMouseLeave={() => - setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) - } - > - -
        - - -
        -
        -
      • - ); - })} -
      - ) : null} -
    • - ); - })} + {regions.map((region) => renderWorldNavFolder(region, 0))}
    )} {mapId && outsidePeers.length > 0 && ( @@ -3328,16 +3757,30 @@ export function TopologyPage() { {activeRegion ? ( <> / - + {activeView && + isRegionCanvasFolder(activeRegion) && + primaryViewOfFolder(activeRegion)?.id === activeView.id ? ( + + {regionDisplayName(activeRegion)} + {dirty ? " *" : ""} + + ) : ( + + )} ) : null} - {activeView ? ( + {activeView && + !( + activeRegion && + isRegionCanvasFolder(activeRegion) && + primaryViewOfFolder(activeRegion)?.id === activeView.id + ) ? ( <> / @@ -3360,7 +3803,15 @@ export function TopologyPage() {
    ) : ( {titleText} )}

    - {activeRegion - ? t("topology.browserRegionSub").replace( - "{{count}}", - String(browseEntries.length), - ) + {hexBrowseRegion + ? isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules + ? t("topology.browserRegionSub").replace( + "{{count}}", + String( + (umeWorldHexModules.drill ? 1 : 0) + + (umeWorldHexModules.flatView ? 1 : 0), + ), + ) + : t("topology.browserRegionSub").replace( + "{{count}}", + String(browseEntries.length), + ) : t("topology.browserRegionsSub").replace( "{{count}}", String(regions.length), @@ -4037,7 +4504,7 @@ export function TopologyPage() { {liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}

    - {!activeRegion ? ( + {!hexBrowseRegion ? ( regions.length === 0 ? (
    ) + ) : isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules ? ( +
    + {umeWorldHexModules.drill ? ( + + ) : null} + {umeWorldHexModules.flatView ? ( + + ) : null} +
    ) : browseEntries.length === 0 ? (
    + {!isWorldFlatViewName(activeView?.name) ? ( +
  • + +
  • + ) : null} ) : ctxMenu.kind === "node" ? ( <> diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 11ff75e..34e15a0 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1106,8 +1106,41 @@ export const populateTopologyView = ( graph: TopologyViewGraph | null; }>(`/v1/topology/views/${encodeURIComponent(viewId)}/populate`, body || {}); -export const fetchTopologyViewGraph = (viewId: string) => - apiGet(`/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( + `/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, diff --git a/web/src/types.ts b/web/src/types.ts index 4fab1c4..8ba9cda 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 = {