mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner. Co-authored-by: Cursor <cursoragent@cursor.com>
523 lines
18 KiB
Python
523 lines
18 KiB
Python
"""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,
|
|
)
|