mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
407 lines
14 KiB
Python
407 lines
14 KiB
Python
"""Topology slice map preview/generation and fabric search."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode
|
|
from .topology_classify_common import _SLICE_TEMPLATES, _utcnow
|
|
from .topology_membership import (
|
|
VIEW_KIND_CUSTOM,
|
|
VIEW_ROLE_ACCESS,
|
|
VIEW_ROLE_AGGREGATION,
|
|
VIEW_ROLE_CORE,
|
|
merge_filter_with_membership,
|
|
normalize_view_role,
|
|
)
|
|
from .topology_schemas import (
|
|
FabricNodeOut,
|
|
SliceGenerateOut,
|
|
SliceGenerateRequest,
|
|
SliceMapPlan,
|
|
TopologyViewCreate,
|
|
)
|
|
|
|
def _active_neighbors(db: Session, seed_ids: set[str], *, hops: int = 1) -> set[str]:
|
|
if not seed_ids or hops <= 0:
|
|
return set()
|
|
frontier = set(seed_ids)
|
|
found: set[str] = set()
|
|
for _ in range(hops):
|
|
if not frontier:
|
|
break
|
|
rows = (
|
|
db.query(TopoFabricEdge)
|
|
.filter(
|
|
TopoFabricEdge.layer == "physical",
|
|
TopoFabricEdge.status == "active",
|
|
(TopoFabricEdge.a_node_id.in_(frontier) | TopoFabricEdge.b_node_id.in_(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(str(b))
|
|
found |= nxt
|
|
frontier = nxt
|
|
return found
|
|
|
|
|
|
def _connected_components(db: Session, node_ids: list[str]) -> list[list[str]]:
|
|
ids = [str(x) for x in node_ids if str(x)]
|
|
if not ids:
|
|
return []
|
|
id_set = set(ids)
|
|
adj: dict[str, set[str]] = {i: set() for i in ids}
|
|
rows = (
|
|
db.query(TopoFabricEdge)
|
|
.filter(
|
|
TopoFabricEdge.layer == "physical",
|
|
TopoFabricEdge.status == "active",
|
|
TopoFabricEdge.a_node_id.in_(ids),
|
|
TopoFabricEdge.b_node_id.in_(ids),
|
|
)
|
|
.all()
|
|
)
|
|
for e in rows:
|
|
a, b = str(e.a_node_id), str(e.b_node_id)
|
|
if a in id_set and b in id_set:
|
|
adj[a].add(b)
|
|
adj[b].add(a)
|
|
seen: set[str] = set()
|
|
comps: list[list[str]] = []
|
|
for nid in ids:
|
|
if nid in seen:
|
|
continue
|
|
stack = [nid]
|
|
seen.add(nid)
|
|
comp: list[str] = []
|
|
while stack:
|
|
cur = stack.pop()
|
|
comp.append(cur)
|
|
for nb in adj.get(cur, ()):
|
|
if nb not in seen:
|
|
seen.add(nb)
|
|
stack.append(nb)
|
|
comps.append(sorted(comp))
|
|
return comps
|
|
|
|
|
|
def _nodes_in_region(db: Session, folder_id: str, *, role: str = "") -> list[TopoFabricNode]:
|
|
q = db.query(TopoFabricNode).filter(TopoFabricNode.region_folder_id == folder_id)
|
|
if role:
|
|
q = q.filter(TopoFabricNode.role == role)
|
|
return q.order_by(TopoFabricNode.name.asc()).all()
|
|
|
|
|
|
def preview_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
|
|
folder = db.get(TopoFolder, body.folder_id)
|
|
if folder is None or str(folder.kind or "") != "region":
|
|
raise HTTPException(status_code=400, detail="folder_not_found")
|
|
template = str(body.template or "").strip().lower()
|
|
if template not in _SLICE_TEMPLATES:
|
|
raise HTTPException(status_code=400, detail="template_invalid")
|
|
max_nodes = max(1, min(2000, int(body.max_nodes or 300)))
|
|
plans: list[SliceMapPlan] = []
|
|
overlap_ids: set[str] = set()
|
|
seen_in_maps: dict[str, int] = {}
|
|
|
|
def _track(ids: list[str]) -> None:
|
|
for i in ids:
|
|
seen_in_maps[i] = seen_in_maps.get(i, 0) + 1
|
|
|
|
if template == "core_only":
|
|
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
|
|
comps = _connected_components(db, [n.id for n in cores]) or [
|
|
[n.id] for n in cores
|
|
]
|
|
for idx, comp in enumerate(comps, start=1):
|
|
if len(comp) > max_nodes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"slice_exceeds_max_nodes:{len(comp)}>{max_nodes}",
|
|
)
|
|
name = f"Core-{idx}" if len(comps) > 1 else "Core"
|
|
plans.append(
|
|
SliceMapPlan(
|
|
name=name,
|
|
role=VIEW_ROLE_CORE,
|
|
seed_fabric_node_ids=comp,
|
|
member_fabric_node_ids=comp,
|
|
node_count=len(comp),
|
|
)
|
|
)
|
|
_track(comp)
|
|
elif template == "core_agg":
|
|
cores = _nodes_in_region(db, folder.id, role=VIEW_ROLE_CORE)
|
|
comps = _connected_components(db, [n.id for n in cores]) or [
|
|
[n.id] for n in cores
|
|
]
|
|
for idx, comp in enumerate(comps, start=1):
|
|
peers = _active_neighbors(db, set(comp), hops=1)
|
|
agg_ids = [
|
|
p
|
|
for p in peers
|
|
if (fn := db.get(TopoFabricNode, p)) is not None
|
|
and str(fn.role or "") == VIEW_ROLE_AGGREGATION
|
|
and str(fn.region_folder_id or "") == folder.id
|
|
]
|
|
members = sorted(set(comp) | set(agg_ids))
|
|
if len(members) > max_nodes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
|
|
)
|
|
name = f"CoreAgg-{idx}" if len(comps) > 1 else "Core+Agg"
|
|
plans.append(
|
|
SliceMapPlan(
|
|
name=name,
|
|
role=VIEW_ROLE_CORE,
|
|
seed_fabric_node_ids=comp,
|
|
member_fabric_node_ids=members,
|
|
node_count=len(members),
|
|
)
|
|
)
|
|
_track(members)
|
|
else: # agg_access
|
|
aggs = _nodes_in_region(db, folder.id, role=VIEW_ROLE_AGGREGATION)
|
|
comps = _connected_components(db, [n.id for n in aggs]) or [
|
|
[n.id] for n in aggs
|
|
]
|
|
for idx, comp in enumerate(comps, start=1):
|
|
peers = _active_neighbors(db, set(comp), hops=1)
|
|
acc_ids = [
|
|
p
|
|
for p in peers
|
|
if (fn := db.get(TopoFabricNode, p)) is not None
|
|
and str(fn.role or "") == VIEW_ROLE_ACCESS
|
|
and str(fn.region_folder_id or "") == folder.id
|
|
]
|
|
members = sorted(set(comp) | set(acc_ids))
|
|
if len(members) > max_nodes:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"slice_exceeds_max_nodes:{len(members)}>{max_nodes}",
|
|
)
|
|
name = f"AggAccess-{idx}" if len(comps) > 1 else "Agg+Access"
|
|
plans.append(
|
|
SliceMapPlan(
|
|
name=name,
|
|
role=VIEW_ROLE_AGGREGATION,
|
|
seed_fabric_node_ids=comp,
|
|
member_fabric_node_ids=members,
|
|
node_count=len(members),
|
|
)
|
|
)
|
|
_track(members)
|
|
|
|
overlap_ids = {nid for nid, cnt in seen_in_maps.items() if cnt > 1}
|
|
return SliceGenerateOut(
|
|
folder_id=folder.id,
|
|
template=template,
|
|
dry_run=True,
|
|
maps=plans,
|
|
map_count=len(plans),
|
|
overlap_node_count=len(overlap_ids),
|
|
created_view_ids=[],
|
|
)
|
|
|
|
|
|
def generate_slices(db: Session, body: SliceGenerateRequest) -> SliceGenerateOut:
|
|
from .topology_service import create_view, _place_fabric_ids_on_view
|
|
|
|
preview = preview_slices(db, body)
|
|
if body.dry_run:
|
|
return preview
|
|
|
|
created: list[str] = []
|
|
for plan in preview.maps:
|
|
view = create_view(
|
|
db,
|
|
TopologyViewCreate(
|
|
name=plan.name,
|
|
folder_id=body.folder_id,
|
|
kind=VIEW_KIND_CUSTOM,
|
|
role=plan.role,
|
|
remark=f"slice:{body.template}",
|
|
),
|
|
)
|
|
mem = {
|
|
"mode": "hybrid",
|
|
"seed_fabric_node_ids": list(plan.member_fabric_node_ids),
|
|
"expand_hops": 0,
|
|
"max_nodes": int(body.max_nodes or 300),
|
|
"frozen": True,
|
|
"managed_ne_ids": [],
|
|
"tags_any": [],
|
|
"vendors": [],
|
|
"device_types": [],
|
|
"keyword": "",
|
|
}
|
|
row = db.get(TopoView, view.id)
|
|
assert row is not None
|
|
row.filter = merge_filter_with_membership(
|
|
dict(row.filter or {}), role=normalize_view_role(plan.role), membership=mem
|
|
)
|
|
_place_fabric_ids_on_view(db, row, list(plan.member_fabric_node_ids), existing=set())
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
created.append(view.id)
|
|
|
|
# Optionally seed physical overview with cores only
|
|
if body.seed_physical_cores:
|
|
from .topology_service import ensure_region_physical_view
|
|
|
|
phys = ensure_region_physical_view(db, body.folder_id, commit=True)
|
|
cores = [n.id for n in _nodes_in_region(db, body.folder_id, role=VIEW_ROLE_CORE)]
|
|
existing = {
|
|
vn.fabric_node_id
|
|
for vn in db.query(TopoViewNode).filter(TopoViewNode.view_id == phys.id).all()
|
|
}
|
|
to_add = [c for c in cores if c not in existing][: int(body.max_nodes or 300)]
|
|
if to_add:
|
|
_place_fabric_ids_on_view(db, phys, to_add, existing=existing)
|
|
mem = merge_filter_with_membership(
|
|
dict(phys.filter or {}),
|
|
role=VIEW_ROLE_CORE,
|
|
kind="physical",
|
|
membership={
|
|
**dict((phys.filter or {}).get("membership") or {}),
|
|
"frozen": True,
|
|
"max_nodes": int(body.max_nodes or 500),
|
|
},
|
|
)
|
|
phys.filter = mem
|
|
phys.updated_at = _utcnow()
|
|
db.commit()
|
|
|
|
return SliceGenerateOut(
|
|
folder_id=body.folder_id,
|
|
template=str(body.template),
|
|
dry_run=False,
|
|
maps=preview.maps,
|
|
map_count=len(preview.maps),
|
|
overlap_node_count=preview.overlap_node_count,
|
|
created_view_ids=created,
|
|
)
|
|
|
|
|
|
def search_fabric_nodes_with_views(
|
|
db: Session,
|
|
*,
|
|
keyword: str = "",
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
) -> dict[str, Any]:
|
|
"""Search fabric NEs and resolve which canvases they appear on.
|
|
|
|
Membership views (TopoViewNode) come first. UME synthetic canvases usually have
|
|
no membership rows — fall back to region_folder_id → primary canvas, and to the
|
|
world map when world_x/y are set.
|
|
"""
|
|
from sqlalchemy import or_
|
|
|
|
from .models import UmeInventoryNE
|
|
from .topology_region_canvas import primary_canvas_view
|
|
from .topology_service import _node_out
|
|
from .ume_topology_world import get_world_flat_view, is_world_flat_visible
|
|
|
|
q = db.query(TopoFabricNode)
|
|
kw = str(keyword or "").strip()
|
|
if kw:
|
|
like = f"%{kw}%"
|
|
inv_ids = [
|
|
str(r[0])
|
|
for r in db.query(UmeInventoryNE.ne_id)
|
|
.filter(
|
|
or_(
|
|
UmeInventoryNE.user_label.ilike(like),
|
|
UmeInventoryNE.ne_name.ilike(like),
|
|
UmeInventoryNE.host_name.ilike(like),
|
|
UmeInventoryNE.ip_address.ilike(like),
|
|
)
|
|
)
|
|
.limit(500)
|
|
.all()
|
|
if str(r[0] or "").strip()
|
|
]
|
|
clauses = [
|
|
TopoFabricNode.name.ilike(like),
|
|
TopoFabricNode.ip.ilike(like),
|
|
TopoFabricNode.vendor.ilike(like),
|
|
TopoFabricNode.ume_ne_id.ilike(like),
|
|
]
|
|
if inv_ids:
|
|
clauses.append(TopoFabricNode.ume_ne_id.in_(inv_ids))
|
|
q = q.filter(or_(*clauses))
|
|
total = q.count()
|
|
rows = (
|
|
q.order_by(TopoFabricNode.name.asc())
|
|
.offset(max(0, (page - 1) * page_size))
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
node_ids = [n.id for n in rows]
|
|
placements: dict[str, list[dict[str, Any]]] = {nid: [] for nid in node_ids}
|
|
seen: dict[str, set[str]] = {nid: set() for nid in node_ids}
|
|
|
|
def _add_view(nid: str, view: TopoView, folder: TopoFolder | None) -> None:
|
|
vid = str(view.id or "").strip()
|
|
if not vid or vid in seen.get(nid, set()):
|
|
return
|
|
seen.setdefault(nid, set()).add(vid)
|
|
placements.setdefault(nid, []).append(
|
|
{
|
|
"view_id": view.id,
|
|
"view_name": view.name,
|
|
"folder_id": view.folder_id or "",
|
|
"folder_name": (folder.name if folder else "") or "",
|
|
"kind": view.kind or "custom",
|
|
}
|
|
)
|
|
|
|
if node_ids:
|
|
vnodes = (
|
|
db.query(TopoViewNode, TopoView, TopoFolder)
|
|
.join(TopoView, TopoView.id == TopoViewNode.view_id)
|
|
.outerjoin(TopoFolder, TopoFolder.id == TopoView.folder_id)
|
|
.filter(TopoViewNode.fabric_node_id.in_(node_ids))
|
|
.all()
|
|
)
|
|
for vn, view, folder in vnodes:
|
|
_add_view(vn.fabric_node_id, view, folder)
|
|
|
|
folder_ids = {
|
|
str(n.region_folder_id or "").strip()
|
|
for n in rows
|
|
if str(n.region_folder_id or "").strip()
|
|
}
|
|
folders_by_id: dict[str, TopoFolder] = {}
|
|
if folder_ids:
|
|
for f in db.query(TopoFolder).filter(TopoFolder.id.in_(list(folder_ids))).all():
|
|
folders_by_id[str(f.id)] = f
|
|
|
|
flat = get_world_flat_view(db)
|
|
flat_ok = flat is not None and is_world_flat_visible(flat)
|
|
flat_folder = db.get(TopoFolder, flat.folder_id) if flat_ok and flat and flat.folder_id else None
|
|
|
|
for n in rows:
|
|
rid = str(n.region_folder_id or "").strip()
|
|
if rid:
|
|
view = primary_canvas_view(db, rid)
|
|
if view is not None:
|
|
_add_view(n.id, view, folders_by_id.get(rid))
|
|
if flat_ok and flat is not None and n.world_x is not None and n.world_y is not None:
|
|
_add_view(n.id, flat, flat_folder)
|
|
|
|
items = []
|
|
for n in rows:
|
|
d = _node_out(n).model_dump()
|
|
d["views"] = placements.get(n.id, [])
|
|
items.append(d)
|
|
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
|
|