netx/netx_api/topology_views_graph.py
oliver 1ef43fdcd4 Unify UME World as hex nav with conditional flat world map.
Seed a unique L2 World canvas under the UME World container, show the flat map only when nested regions have NEs, pack blocks without overlap, allow drag overrides on the world map while forbidding direct NE adds, and hide the world-map truncation banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:51:08 +08:00

1341 lines
43 KiB
Python

"""Topology view graph, populate, positions, and edge styles."""
from __future__ import annotations
import re
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from .models import (
ManagedNE,
TopoFabricEdge,
TopoFabricNode,
TopoFolder,
TopoView,
TopoViewEdgeStyle,
TopoViewNode,
UmeInventoryNE,
)
from .topology_common import (
PHYSICAL_VIEW_NAME,
ROOT_FOLDER_NAME,
VIEW_GRAPH_EDGE_HARD_CAP,
VIEW_GRAPH_NODE_HARD_CAP,
_EDGE_STATUS_MISSING,
_EDGE_STATUS_MISSING_COMPAT,
_LEGACY_UNASSIGNED_NAME,
_normalize_edge_status,
_utcnow,
)
from .topology_fabric import (
_edge_out,
_fabric_match_score,
_is_inventory_node,
_node_out,
_nodes_by_ids,
ensure_fabric_node_for_managed,
ensure_fabric_node_for_ume,
merge_duplicate_fabric_nodes,
)
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .topology_membership import (
VIEW_KIND_CUSTOM,
VIEW_KIND_PHYSICAL,
has_hard_scope,
merge_filter_with_membership,
normalize_view_kind,
normalize_view_role,
parse_membership,
)
from .topology_schemas import (
TopologyFolderCreate,
TopologyFolderOut,
TopologyFolderUpdate,
TopologyPlaceholderCreate,
TopologyTreeFolderOut,
TopologyTreeOut,
TopologyTreeViewOut,
TopologyViewCreate,
TopologyViewGraphOut,
TopologyViewOut,
TopologyViewUpdate,
ViewEdgeOut,
ViewEdgeStylePatch,
ViewMutationOut,
ViewNodeIn,
ViewNodeOut,
ViewNodesAdd,
ViewNodesRemove,
ViewPopulateOut,
ViewPopulateRequest,
ViewPositionsPatch,
ViewProjectNeighborsRequest,
)
# ---------------------------------------------------------------------------
# Folders (tree) + Views (leaf canvases)
# ---------------------------------------------------------------------------
from .topology_views_tree import (
_get_folder_or_404,
_get_view_or_404,
_view_out,
ensure_region_physical_view,
)
def _connect_status_for_node(db: Session, n: TopoFabricNode) -> str:
if n.managed_ne_id:
ne = db.get(ManagedNE, n.managed_ne_id)
if ne is not None:
return ne.connect_status or ""
if n.ume_ne_id:
ume = (
db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == n.ume_ne_id).one_or_none()
)
if ume is not None:
return ume.connection_status or ""
return ""
def _managed_source_for_node(db: Session, n: TopoFabricNode) -> str:
if n.managed_ne_id:
ne = db.get(ManagedNE, n.managed_ne_id)
if ne is not None:
return str(ne.source or "").strip()
return ""
def _batch_ne_lookups(
db: Session, fabric_nodes: dict[str, TopoFabricNode]
) -> tuple[dict[str, ManagedNE], dict[str, UmeInventoryNE]]:
mids = sorted(
{
str(fn.managed_ne_id or "").strip()
for fn in fabric_nodes.values()
if str(fn.managed_ne_id or "").strip()
}
)
uids = sorted(
{
str(fn.ume_ne_id or "").strip()
for fn in fabric_nodes.values()
if str(fn.ume_ne_id or "").strip()
}
)
managed = (
{str(n.id): n for n in db.query(ManagedNE).filter(ManagedNE.id.in_(mids)).all()}
if mids
else {}
)
ume = (
{
str(u.ne_id): u
for u in db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id.in_(uids)).all()
}
if uids
else {}
)
return managed, ume
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 = ""
if len(vnodes) > VIEW_GRAPH_NODE_HARD_CAP:
vnodes = vnodes[:VIEW_GRAPH_NODE_HARD_CAP]
truncated = True
reason = "too_many_view_nodes"
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 {}
managed_by_id, ume_by_id = _batch_ne_lookups(db, fabric_nodes)
filt = dict(view.filter or {})
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:
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
connect_status = ""
managed_source = ""
if fn is not None:
mid = str(fn.managed_ne_id or "").strip()
uid = str(fn.ume_ne_id or "").strip()
if mid and mid in managed_by_id:
mne = managed_by_id[mid]
connect_status = mne.connect_status or ""
managed_source = str(mne.source or "").strip()
elif uid and uid in ume_by_id:
connect_status = ume_by_id[uid].connection_status or ""
nodes_out.append(
ViewNodeOut(
fabric_node_id=vn.fabric_node_id,
managed_ne_id=(fn.managed_ne_id if fn else "") or "",
ume_ne_id=(fn.ume_ne_id if fn else "") or "",
label=label,
x=float(vn.x or 0),
y=float(vn.y or 0),
locked=bool(vn.locked),
name=(fn.name if fn else "") or "",
ip=(fn.ip if fn else "") or "",
vendor=(fn.vendor if fn else "") or "",
device_type=(fn.device_type if fn else "") or "",
connect_status=connect_status,
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(
TopoFabricEdge.layer == layer,
TopoFabricEdge.a_node_id.in_(fids),
TopoFabricEdge.b_node_id.in_(fids),
)
if status:
st_norm = _normalize_edge_status(status)
if st_norm == _EDGE_STATUS_MISSING:
q = q.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
else:
q = q.filter(TopoFabricEdge.status == st_norm)
edges = q.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"
styles = {
s.fabric_edge_id: s
for s in db.query(TopoViewEdgeStyle)
.filter(
TopoViewEdgeStyle.view_id == view.id,
TopoViewEdgeStyle.fabric_edge_id.in_([e.id for e in edges]),
)
.all()
}
for e in edges:
st = styles.get(e.id)
src = str(e.source or "lldp").strip().lower() or "lldp"
if src == "stale":
src = "lldp"
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=src,
status=_normalize_edge_status(e.status or "active"),
layer=e.layer or "physical",
stroke_color=(st.stroke_color if st else "") or "",
stroke_width=int(st.stroke_width if st else 0) or 0,
line_style=(st.line_style if st else "") or "",
discovered_at=e.discovered_at,
)
)
outside = _outside_peers_for_view(db, view, member_ids=set(fids), layer=layer)
return TopologyViewGraphOut(
view=_view_out(view, node_count=len(nodes_out)),
nodes=nodes_out,
edges=edges_out,
truncated=truncated,
truncate_reason=reason,
outside_peers=outside,
)
def _membership_for_view(view: TopoView) -> dict[str, Any]:
return parse_membership(dict(view.filter or {}), role=normalize_view_role(view.role))
def _fabric_in_hard_scope(db: Session, fn: TopoFabricNode, mem: dict[str, Any]) -> bool:
"""If hard scope filters are set, node must match ALL set dimensions (AND)."""
if not has_hard_scope(mem):
return True
mid = str(fn.managed_ne_id or "").strip()
allowed_mids = set(mem.get("managed_ne_ids") or [])
if allowed_mids and mid not in allowed_mids:
return False
vendors = {str(x).lower() for x in (mem.get("vendors") or [])}
if vendors and str(fn.vendor or "").strip().lower() not in vendors:
return False
dtypes = {str(x).lower() for x in (mem.get("device_types") or [])}
if dtypes and str(fn.device_type or "").strip().lower() not in dtypes:
return False
keyword = str(mem.get("keyword") or "").strip().lower()
if keyword:
blob = f"{fn.name or ''} {fn.ip or ''}".lower()
if keyword not in blob:
return False
tags_any = {str(x).lower() for x in (mem.get("tags_any") or [])}
if tags_any:
ne = db.get(ManagedNE, mid) if mid else None
tag_blob = str(getattr(ne, "tags", "") or "").lower() if ne else ""
if not any(t in tag_blob for t in tags_any):
return False
return True
def _outside_peers_for_view(
db: Session,
view: TopoView,
*,
member_ids: set[str],
layer: str,
limit: int = 50,
) -> list[dict[str, str]]:
if not member_ids:
return []
edges = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer,
or_(
TopoFabricEdge.a_node_id.in_(list(member_ids)),
TopoFabricEdge.b_node_id.in_(list(member_ids)),
),
)
.limit(5000)
.all()
)
out: list[dict[str, str]] = []
seen: set[str] = set()
for e in edges:
for peer_id, local_id in ((e.b_node_id, e.a_node_id), (e.a_node_id, e.b_node_id)):
if local_id not in member_ids or peer_id in member_ids:
continue
if peer_id in seen:
continue
seen.add(peer_id)
fn = db.get(TopoFabricNode, peer_id)
out.append(
{
"fabric_node_id": peer_id,
"name": (fn.name if fn else "") or "",
"ip": (fn.ip if fn else "") or "",
"via_node_id": local_id,
}
)
if len(out) >= limit:
return out
return out
def _place_fabric_ids_on_view(
db: Session,
view: TopoView,
fabric_ids: list[str],
*,
existing: set[str],
near_fabric_ids: set[str] | None = None,
) -> int:
now = _utcnow()
added = 0
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
anchor = None
near = {str(x).strip() for x in (near_fabric_ids or set()) if str(x).strip()}
if near:
for vn in vnodes:
if vn.fabric_node_id in near:
anchor = vn
break
if anchor is not None:
base_x = float(anchor.x or 0.0) + 200.0
base_y = float(anchor.y or 0.0)
else:
max_x = max((float(vn.x or 0) for vn in vnodes), default=40.0)
base_x = max_x + 200.0
base_y = 40.0
cols = max(1, int(len(fabric_ids) ** 0.5) or 1)
for i, fid in enumerate(fabric_ids):
if fid in existing or db.get(TopoFabricNode, fid) is None:
continue
x = base_x + (i % cols) * 180.0
y = base_y + (i // cols) * 120.0
db.add(
TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fid,
x=x,
y=y,
label="",
locked=False,
created_at=now,
updated_at=now,
)
)
existing.add(fid)
added += 1
if added:
view.updated_at = now
return added
def _has_fabric_filter(
*,
keyword: str = "",
role: str = "",
vendor: str = "",
link_status: str = "",
) -> bool:
return bool(
str(keyword or "").strip()
or str(role or "").strip()
or str(vendor or "").strip()
or str(link_status or "").strip()
)
def _apply_fabric_filters(
q: Any,
*,
keyword: str = "",
role: str = "",
vendor: str = "",
link_status: str = "",
) -> Any:
kw = str(keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
or_(
TopoFabricNode.name.ilike(like),
TopoFabricNode.ip.ilike(like),
TopoFabricNode.managed_ne_id.ilike(like),
TopoFabricNode.ume_ne_id.ilike(like),
)
)
role_v = str(role or "").strip().lower()
if role_v:
q = q.filter(TopoFabricNode.role == role_v)
vendor_v = str(vendor or "").strip()
if vendor_v:
q = q.filter(TopoFabricNode.vendor.ilike(f"%{vendor_v}%"))
ls = str(link_status or "").strip().lower()
if ls == "orphaned":
q = q.filter(
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
)
elif ls == "linked":
q = q.filter(
or_(
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
)
)
elif ls == "managed":
q = q.filter(
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
or_(TopoFabricNode.ume_ne_id.is_(None), TopoFabricNode.ume_ne_id == ""),
)
elif ls == "ume":
q = q.filter(
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
or_(TopoFabricNode.managed_ne_id.is_(None), TopoFabricNode.managed_ne_id == ""),
)
elif ls == "both":
q = q.filter(
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
)
return q
def select_fabric_ids(
db: Session,
*,
keyword: str = "",
role: str = "",
vendor: str = "",
link_status: str = "",
offset: int = 0,
limit: int = 500,
) -> tuple[list[str], int]:
"""Server-side fabric id selection for bulk add (paged)."""
lim = max(1, min(VIEW_GRAPH_NODE_HARD_CAP, int(limit or 500)))
off = max(0, int(offset or 0))
q = _apply_fabric_filters(
db.query(TopoFabricNode),
keyword=keyword,
role=role,
vendor=vendor,
link_status=link_status,
)
total = int(q.count())
rows = q.order_by(TopoFabricNode.name.asc()).offset(off).limit(lim).all()
return [str(r.id) for r in rows], total
def select_view_fabric_ids(
db: Session,
view_id: str,
*,
fabric_node_ids: list[str] | None = None,
keyword: str = "",
role: str = "",
vendor: str = "",
link_status: str = "",
) -> list[str]:
"""Select fabric ids already placed on a view, optionally narrowed by filter/ids."""
explicit = [str(x).strip() for x in (fabric_node_ids or []) if str(x).strip()]
q = (
db.query(TopoFabricNode.id)
.join(TopoViewNode, TopoViewNode.fabric_node_id == TopoFabricNode.id)
.filter(TopoViewNode.view_id == view_id)
)
if explicit:
q = q.filter(TopoFabricNode.id.in_(explicit))
if _has_fabric_filter(keyword=keyword, role=role, vendor=vendor, link_status=link_status):
q = _apply_fabric_filters(
q, keyword=keyword, role=role, vendor=vendor, link_status=link_status
)
rows = q.order_by(TopoFabricNode.name.asc()).all()
return [str(r[0] if isinstance(r, tuple) else r.id if hasattr(r, "id") else r) for r in rows]
def _layout_coords(
count: int,
*,
layout: str,
origin_x: float,
origin_y: float,
gap_x: float,
gap_y: float,
cols: int,
) -> list[tuple[float, float]]:
kind = str(layout or "grid").strip().lower() or "grid"
if count <= 0:
return []
if kind == "stack":
return [(float(origin_x), float(origin_y) + i * float(gap_y)) for i in range(count)]
c = int(cols or 0)
if c <= 0:
c = max(1, int(count**0.5) or 1)
return [
(float(origin_x) + (i % c) * float(gap_x), float(origin_y) + (i // c) * float(gap_y))
for i in range(count)
]
def _view_node_count(db: Session, view_id: str) -> int:
return int(
db.query(func.count(TopoViewNode.id)).filter(TopoViewNode.view_id == view_id).scalar() or 0
)
def _mutation_result(
db: Session,
view_id: str,
*,
max_nodes: int,
return_graph: bool,
matched: int = 0,
added: int = 0,
updated: int = 0,
removed: int = 0,
skipped_existing: int = 0,
skipped_missing: int = 0,
skipped_locked: int = 0,
truncated: bool = False,
next_offset: int | None = None,
) -> ViewMutationOut | TopologyViewGraphOut:
if return_graph:
return get_view_graph(db, view_id)
return ViewMutationOut(
ok=True,
view_id=view_id,
matched=matched,
added=added,
updated=updated,
removed=removed,
skipped_existing=skipped_existing,
skipped_missing=skipped_missing,
skipped_locked=skipped_locked,
view_node_count=_view_node_count(db, view_id),
max_nodes=max_nodes,
truncated=truncated,
next_offset=next_offset,
graph=None,
)
def patch_view_positions(
db: Session, view_id: str, body: ViewPositionsPatch
) -> ViewMutationOut | TopologyViewGraphOut:
view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300)
now = _utcnow()
layout = str(body.layout or "").strip().lower()
updated = 0
skipped_locked = 0
matched = 0
existing = {
vn.fabric_node_id: vn
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
}
if layout in {"grid", "offset", "stack"}:
ids = select_view_fabric_ids(
db,
view.id,
fabric_node_ids=list(body.fabric_node_ids or []),
keyword=body.keyword,
role=body.role,
vendor=body.vendor,
link_status=body.link_status,
)
if not ids and not _has_fabric_filter(
keyword=body.keyword,
role=body.role,
vendor=body.vendor,
link_status=body.link_status,
) and not (body.fabric_node_ids or []):
# layout with no filter/ids → all nodes on view
ids = sorted(existing.keys())
matched = len(ids)
if layout == "offset":
for fid in ids:
row = existing.get(fid)
if row is None:
continue
if row.locked:
skipped_locked += 1
continue
row.x = float(row.x or 0) + float(body.dx or 0)
row.y = float(row.y or 0) + float(body.dy or 0)
row.updated_at = now
updated += 1
else:
coords = _layout_coords(
len(ids),
layout=layout,
origin_x=float(body.origin_x),
origin_y=float(body.origin_y),
gap_x=float(body.gap_x),
gap_y=float(body.gap_y),
cols=int(body.cols or 0),
)
for fid, (x, y) in zip(ids, coords):
row = existing.get(fid)
if row is None:
continue
if row.locked:
skipped_locked += 1
continue
row.x = x
row.y = y
row.updated_at = now
updated += 1
else:
positions = list(body.positions or [])
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
raise HTTPException(status_code=400, detail="too_many_positions")
matched = len(positions)
for p in positions:
fid = str(p.fabric_node_id or "").strip()
if not fid:
continue
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:
row = TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fid,
x=float(p.x or 0),
y=float(p.y or 0),
label=str(p.label or "")[:256],
locked=bool(p.locked),
created_at=now,
updated_at=now,
)
db.add(row)
existing[fid] = row
updated += 1
else:
if row.locked and bool(p.locked):
skipped_locked += 1
continue
row.x = float(p.x or 0)
row.y = float(p.y or 0)
if p.label is not None:
row.label = str(p.label or "")[:256]
row.locked = bool(p.locked)
row.updated_at = now
updated += 1
view.updated_at = now
db.commit()
return _mutation_result(
db,
view.id,
max_nodes=max_nodes,
return_graph=bool(body.return_graph),
matched=matched,
updated=updated,
skipped_locked=skipped_locked,
)
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()
existing = {
vn.fabric_node_id
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
}
original_count = len(existing)
if original_count >= max_nodes:
if body.return_graph:
raise HTTPException(status_code=400, detail="membership_max_nodes")
return ViewMutationOut(
ok=False,
view_id=view.id,
matched=0,
view_node_count=original_count,
max_nodes=max_nodes,
truncated=True,
)
candidate_ids: list[str] = []
matched_total = 0
next_offset: int | None = None
filter_mode = _has_fabric_filter(
keyword=body.keyword,
role=body.role,
vendor=body.vendor,
link_status=body.link_status,
)
if filter_mode:
page_ids, matched_total = select_fabric_ids(
db,
keyword=body.keyword,
role=body.role,
vendor=body.vendor,
link_status=body.link_status,
offset=int(body.offset or 0),
limit=int(body.limit or 500),
)
candidate_ids.extend(page_ids)
end = int(body.offset or 0) + len(page_ids)
if end < matched_total:
next_offset = end
else:
for fid in body.fabric_node_ids or []:
fid_s = str(fid or "").strip()
if fid_s:
candidate_ids.append(fid_s)
matched_total = len(candidate_ids)
# UI path: managed / ume may still create fabric nodes.
for mid in body.managed_ne_ids or []:
mid_s = str(mid or "").strip()
if not mid_s:
continue
ne = db.get(ManagedNE, mid_s)
if ne is None:
continue
fn = ensure_fabric_node_for_managed(db, ne)
candidate_ids.append(fn.id)
matched_total += 1
default_profile = get_default_profile(db)
for uid in body.ume_ne_ids or []:
uid_s = str(uid or "").strip()
if not uid_s:
continue
ume = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id == uid_s).one_or_none()
if ume is None:
continue
if default_profile is not None:
dtype, vendor = infer_device_type_vendor(str(ume.ne_type or ""), default_profile)
else:
dtype, vendor = "zte_zxros", (ume.vendor or "ZTE")
fn = ensure_fabric_node_for_ume(db, ume, device_type=dtype, vendor=vendor)
candidate_ids.append(fn.id)
matched_total += 1
# Dedupe preserve order
seen: set[str] = set()
ordered: list[str] = []
for fid in candidate_ids:
if fid in seen:
continue
seen.add(fid)
ordered.append(fid)
skipped_existing = 0
skipped_missing = 0
to_add: list[str] = []
for fid in ordered:
if fid in existing:
skipped_existing += 1
continue
if db.get(TopoFabricNode, fid) is None:
skipped_missing += 1
continue
to_add.append(fid)
room = max(0, max_nodes - original_count)
truncated = len(to_add) > room
if truncated:
to_add = to_add[:room]
next_offset = None # capped by membership; caller should open another view
keep_layout = str(body.layout or "grid").strip().lower() == "keep"
if keep_layout:
coords = [(40.0, 40.0)] * len(to_add)
else:
# Place new nodes to the right of existing content when possible.
max_x = max((float(vn.x or 0) for vn in db.query(TopoViewNode).filter(
TopoViewNode.view_id == view.id
).all()), default=40.0)
origin_x = (max_x + 200.0) if original_count else 40.0
coords = _layout_coords(
len(to_add),
layout="grid",
origin_x=origin_x,
origin_y=40.0,
gap_x=180.0,
gap_y=120.0,
cols=0,
)
for fid, (x, y) in zip(to_add, coords):
db.add(
TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fid,
x=x,
y=y,
label="",
locked=False,
created_at=now,
updated_at=now,
)
)
existing.add(fid)
if to_add:
view.updated_at = now
db.commit()
elif filter_mode or body.fabric_node_ids or body.managed_ne_ids or body.ume_ne_ids:
db.commit()
return _mutation_result(
db,
view.id,
max_nodes=max_nodes,
return_graph=bool(body.return_graph),
matched=matched_total if filter_mode else len(ordered),
added=len(to_add),
skipped_existing=skipped_existing,
skipped_missing=skipped_missing,
truncated=truncated or (next_offset is not None),
next_offset=next_offset,
)
def create_topology_placeholder_on_view(
db: Session,
view_id: str,
body: TopologyPlaceholderCreate,
) -> TopologyViewGraphOut:
"""Create a ManagedNE with source=topology, ensure fabric node, place on the view."""
from .device_types import TOPOLOGY_NE_SOURCE
from .ne_service_common import _normalize_ip
view = _get_view_or_404(db, view_id)
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 = (
db.query(func.count(TopoViewNode.id)).filter(TopoViewNode.view_id == view.id).scalar() or 0
)
if int(existing_count) >= max_nodes:
raise HTTPException(status_code=400, detail="membership_max_nodes")
display = str(body.name or "").strip()[:256]
if not display:
raise HTTPException(status_code=400, detail="name_required")
ip = _normalize_ip(body.ip_address)[:128]
now = _utcnow()
ne = ManagedNE(
id=uuid4().hex,
name=display,
vendor="Other",
device_type="generic",
ip_address=ip,
port=22,
protocol="ssh",
username="",
password_enc="",
enable_secret_enc="",
connect_status="unknown",
tags="",
remark="Created on topology canvas",
source=TOPOLOGY_NE_SOURCE,
source_ref="",
created_at=now,
updated_at=now,
)
db.add(ne)
db.flush()
fabric = ensure_fabric_node_for_managed(db, ne)
already = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fabric.id)
.one_or_none()
)
if already is None:
db.add(
TopoViewNode(
id=uuid4().hex,
view_id=view.id,
fabric_node_id=fabric.id,
x=float(body.x or 0.0),
y=float(body.y or 0.0),
label="",
locked=False,
created_at=now,
updated_at=now,
)
)
else:
already.x = float(body.x or already.x or 0.0)
already.y = float(body.y or already.y or 0.0)
already.updated_at = now
view.updated_at = now
db.commit()
return get_view_graph(db, view.id)
def _neighbor_ids(
db: Session, *, seed_ids: set[str], layer: str, hops: int
) -> set[str]:
frontier = set(seed_ids)
found: set[str] = set()
for _ in range(max(0, hops)):
if not frontier:
break
rows = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer,
or_(
TopoFabricEdge.a_node_id.in_(list(frontier)),
TopoFabricEdge.b_node_id.in_(list(frontier)),
),
)
.all()
)
nxt: set[str] = set()
for e in rows:
for a, b in ((e.a_node_id, e.b_node_id), (e.b_node_id, e.a_node_id)):
if a in frontier and b not in seed_ids and b not in found:
nxt.add(b)
found |= nxt
frontier = nxt
return found
def project_fabric_neighbors_to_view(
db: Session,
view_id: str,
body: ViewProjectNeighborsRequest | None = None,
) -> TopologyViewGraphOut:
"""Add in-scope fabric neighbors onto the leaf view (bounded by membership).
Optional seeds limit expansion to neighbors of those fabric nodes (must
already be on the view). Empty seeds → expand from every canvas node.
dry_run=True returns the projected graph without persisting placements.
"""
merge_duplicate_fabric_nodes(db)
view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
req = body or ViewProjectNeighborsRequest()
dry_run = bool(req.dry_run)
if bool(mem.get("frozen")):
g = get_view_graph(db, view.id)
g.truncated = True
g.truncate_reason = "membership_frozen"
return g
max_nodes = int(mem.get("max_nodes") or 300)
hops = int(mem.get("expand_hops") or 1)
filt = dict(view.filter or {})
layer = str(filt.get("layer") or "physical").strip() or "physical"
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
fids_on_view = [vn.fabric_node_id for vn in vnodes]
fabric_on_view = (
{
n.id: n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids_on_view)).all()
}
if fids_on_view
else {}
)
# Drop placements pointing at missing fabric rows only (keep LLDP placeholders).
orphan_vns = [vn for vn in vnodes if vn.fabric_node_id not in fabric_on_view]
if orphan_vns and not dry_run:
for vn in orphan_vns:
db.delete(vn)
view.updated_at = _utcnow()
db.commit()
vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
fids_on_view = [vn.fabric_node_id for vn in vnodes]
fabric_on_view = (
{
n.id: n
for n in db.query(TopoFabricNode)
.filter(TopoFabricNode.id.in_(fids_on_view))
.all()
}
if fids_on_view
else {}
)
existing = {vn.fabric_node_id for vn in vnodes}
if not existing:
return get_view_graph(db, view.id)
if len(existing) >= max_nodes:
g = get_view_graph(db, view.id)
g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap"
return g
seed_ids: set[str] = {
str(x).strip() for x in (req.seed_fabric_node_ids or []) if str(x).strip()
}
want_mids = {
str(mid or "").strip() for mid in (req.managed_ne_ids or []) if str(mid or "").strip()
}
if want_mids:
for fid, fn in fabric_on_view.items():
if str(fn.managed_ne_id or "").strip() in want_mids:
seed_ids.add(fid)
if seed_ids:
seed_ids &= existing
if not seed_ids:
return get_view_graph(db, view.id)
else:
seed_ids = set(existing)
peer_ids = _neighbor_ids(db, seed_ids=seed_ids, layer=layer, hops=hops)
peer_rows = (
{
n.id: n
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(list(peer_ids))).all()
}
if peer_ids
else {}
)
eligible: list[str] = []
for peer in sorted(peer_ids):
if peer in existing:
continue
fn = peer_rows.get(peer)
if fn is None or not _is_inventory_node(fn):
continue
if _fabric_match_score(db, fn) < 2:
continue
if not _fabric_in_hard_scope(db, fn, mem):
continue
eligible.append(peer)
room = max(0, max_nodes - len(existing))
to_add = eligible[:room]
truncated = len(eligible) > len(to_add)
if dry_run:
if to_add:
nested = db.begin_nested()
try:
_place_fabric_ids_on_view(
db, view, to_add, existing=set(existing), near_fabric_ids=seed_ids
)
db.flush()
g = get_view_graph(db, view.id)
finally:
nested.rollback()
else:
g = get_view_graph(db, view.id)
if truncated:
g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap"
return g
if to_add:
_place_fabric_ids_on_view(
db, view, to_add, existing=existing, near_fabric_ids=seed_ids
)
db.commit()
g = get_view_graph(db, view.id)
if truncated:
g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap"
return g
def populate_view(db: Session, view_id: str, body: ViewPopulateRequest) -> ViewPopulateOut:
"""Resolve membership candidates and optionally place them on the leaf view."""
view = _get_view_or_404(db, view_id)
role = normalize_view_role(view.role)
if body.membership is not None:
filt = merge_filter_with_membership(
dict(view.filter or {}), role=role, membership=parse_membership(
{"membership": body.membership}, role=role
)
)
if not body.dry_run:
view.filter = filt
mem = parse_membership(dict(view.filter or {}), role=role)
max_nodes = int(mem.get("max_nodes") or 300)
hops = int(mem.get("expand_hops") or 1)
layer = str((view.filter or {}).get("layer") or "physical").strip() or "physical"
existing = {
vn.fabric_node_id
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all()
}
seeds = set(mem.get("seed_fabric_node_ids") or []) | set(existing)
# Seed from managed_ne_ids
for mid in mem.get("managed_ne_ids") or []:
ne = db.get(ManagedNE, mid)
if ne is None:
continue
fn = ensure_fabric_node_for_managed(db, ne)
seeds.add(fn.id)
# Hard-scope scan when filters present
candidates: set[str] = set(seeds)
if has_hard_scope(mem):
for fn in db.query(TopoFabricNode).all():
if not _is_inventory_node(fn):
continue
if _fabric_in_hard_scope(db, fn, mem):
candidates.add(fn.id)
if hops > 0 and seeds:
for peer in _neighbor_ids(db, seed_ids=seeds, layer=layer, hops=hops):
fn = db.get(TopoFabricNode, peer)
if fn is None or not _is_inventory_node(fn):
continue
if has_hard_scope(mem) and not _fabric_in_hard_scope(db, fn, mem):
continue
if _fabric_match_score(db, fn) < 2:
continue
candidates.add(peer)
ordered = sorted(candidates)
truncated = len(ordered) > max_nodes
ordered = ordered[:max_nodes]
would_add = [fid for fid in ordered if fid not in existing]
outside = _outside_peers_for_view(db, view, member_ids=set(ordered), layer=layer)
if body.dry_run:
return ViewPopulateOut(
view_id=view.id,
dry_run=True,
candidate_count=len(candidates),
would_add=len(would_add),
added=0,
max_nodes=max_nodes,
truncated=truncated,
outside_peers=outside,
graph=None,
)
added = _place_fabric_ids_on_view(db, view, would_add, existing=existing)
if body.freeze_after:
mem["frozen"] = True
view.filter = merge_filter_with_membership(dict(view.filter or {}), role=role, membership=mem)
view.updated_at = _utcnow()
db.commit()
g = get_view_graph(db, view.id)
if truncated:
g.truncated = True
g.truncate_reason = g.truncate_reason or "membership_cap"
return ViewPopulateOut(
view_id=view.id,
dry_run=False,
candidate_count=len(candidates),
would_add=len(would_add),
added=added,
max_nodes=max_nodes,
truncated=truncated,
outside_peers=g.outside_peers,
graph=g,
)
def remove_view_nodes(
db: Session,
view_id: str,
fabric_node_ids: list[str] | ViewNodesRemove | None = None,
*,
body: ViewNodesRemove | None = None,
) -> ViewMutationOut | TopologyViewGraphOut:
"""Remove placements. Accept ViewNodesRemove, or a legacy list of fabric ids."""
view = _get_view_or_404(db, view_id)
mem = _membership_for_view(view)
max_nodes = int(mem.get("max_nodes") or 300)
if isinstance(fabric_node_ids, ViewNodesRemove):
req = fabric_node_ids
elif body is not None:
req = body
else:
req = ViewNodesRemove(fabric_node_ids=list(fabric_node_ids or []), return_graph=True)
filter_mode = _has_fabric_filter(
keyword=req.keyword, role=req.role, vendor=req.vendor, link_status=req.link_status
)
explicit = [str(x).strip() for x in (req.fabric_node_ids or []) if str(x).strip()]
if filter_mode or explicit:
ids = select_view_fabric_ids(
db,
view.id,
fabric_node_ids=explicit or None,
keyword=req.keyword,
role=req.role,
vendor=req.vendor,
link_status=req.link_status,
)
else:
ids = []
removed = 0
if ids:
removed = (
db.query(TopoViewNode)
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id.in_(ids))
.delete(synchronize_session=False)
)
view.updated_at = _utcnow()
db.commit()
return _mutation_result(
db,
view.id,
max_nodes=max_nodes,
return_graph=bool(req.return_graph),
matched=len(ids),
removed=int(removed or 0),
)
_HEX_COLOR_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
_LINE_STYLES = {"", "solid", "dashed", "dotted"}
def patch_view_edge_style(
db: Session, view_id: str, body: ViewEdgeStylePatch
) -> TopologyViewGraphOut:
view = _get_view_or_404(db, view_id)
eid = str(body.fabric_edge_id or "").strip()
if not eid or db.get(TopoFabricEdge, eid) is None:
raise HTTPException(status_code=404, detail="fabric_edge_not_found")
color = str(body.stroke_color or "").strip()
if color and not _HEX_COLOR_RE.match(color):
raise HTTPException(status_code=400, detail="invalid_stroke_color")
width = int(body.stroke_width or 0)
if width < 0 or width > 12:
raise HTTPException(status_code=400, detail="invalid_stroke_width")
style = str(body.line_style or "").strip().lower()
if style not in _LINE_STYLES:
raise HTTPException(status_code=400, detail="invalid_line_style")
now = _utcnow()
row = (
db.query(TopoViewEdgeStyle)
.filter(TopoViewEdgeStyle.view_id == view.id, TopoViewEdgeStyle.fabric_edge_id == eid)
.one_or_none()
)
if row is None:
row = TopoViewEdgeStyle(
id=uuid4().hex,
view_id=view.id,
fabric_edge_id=eid,
stroke_color=color,
stroke_width=width,
line_style=style,
created_at=now,
updated_at=now,
)
db.add(row)
else:
row.stroke_color = color
row.stroke_width = width
row.line_style = style
row.updated_at = now
view.updated_at = now
db.commit()
return get_view_graph(db, view.id)