"""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, _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 get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut: view = _get_view_or_404(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] fabric_nodes = { n.id: n for n in db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(fids)).all() } if fids else {} 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] = [] for vn in vnodes: 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] 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_for_node(db, fn) if fn else "", managed_source=_managed_source_for_node(db, fn) if fn else "", ) ) 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 if 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) 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) 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. """ merge_duplicate_fabric_nodes(db) view = _get_view_or_404(db, view_id) mem = _membership_for_view(view) if bool(mem.get("frozen")): return get_view_graph(db, view.id) 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" req = body or ViewProjectNeighborsRequest() vnodes = db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).all() # Drop placements pointing at missing fabric rows only (keep LLDP placeholders). orphan_vns = [vn for vn in vnodes if db.get(TopoFabricNode, vn.fabric_node_id) is None] if orphan_vns: 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() 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() } for mid in req.managed_ne_ids or []: mid_s = str(mid or "").strip() if not mid_s: continue for fid in existing: fn = db.get(TopoFabricNode, fid) if fn is not None and str(fn.managed_ne_id or "").strip() == mid_s: 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) to_add: list[str] = [] for peer in sorted(peer_ids): if peer in existing: continue fn = db.get(TopoFabricNode, 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 to_add.append(peer) if len(existing) + len(to_add) >= max_nodes: break truncated = len(peer_ids) > len(to_add) 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)