mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Unify manual topology roots with UME World: auto root map, and fix empty UME canvas.
Top-level folders are nav-only with a unique system root map; UI drops custom/extra L2 creates. Batched dock-to-Fabric apply avoids PG lock OOM so World NEs can render, with subtree region counts restored. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
edfa3f8756
commit
48b10656d2
9 changed files with 867 additions and 366 deletions
|
|
@ -18,6 +18,7 @@ from .models import (
|
||||||
TopoViewEdgeStyle,
|
TopoViewEdgeStyle,
|
||||||
TopoViewNode,
|
TopoViewNode,
|
||||||
UmeInventoryNE,
|
UmeInventoryNE,
|
||||||
|
UmeTopoNode,
|
||||||
)
|
)
|
||||||
from .topology_common import (
|
from .topology_common import (
|
||||||
PHYSICAL_VIEW_NAME,
|
PHYSICAL_VIEW_NAME,
|
||||||
|
|
@ -28,6 +29,9 @@ from .topology_common import (
|
||||||
_normalize_edge_status,
|
_normalize_edge_status,
|
||||||
_utcnow,
|
_utcnow,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World).
|
||||||
|
MANUAL_ROOT_MAP_NAME = "根图"
|
||||||
from .topology_fabric import (
|
from .topology_fabric import (
|
||||||
_edge_out,
|
_edge_out,
|
||||||
_fabric_match_score,
|
_fabric_match_score,
|
||||||
|
|
@ -261,6 +265,50 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
parent = drill
|
parent = drill
|
||||||
parent_kind = "region"
|
parent_kind = "region"
|
||||||
|
|
||||||
|
# Manual 根 (top-level nav): unique L2「根图」only — further creates remount under it.
|
||||||
|
if parent_kind == "region" and str(parent.parent_id or "") == str(root.id):
|
||||||
|
existing_l2 = (
|
||||||
|
db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.parent_id == parent.id, TopoFolder.kind == "region")
|
||||||
|
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if existing_l2:
|
||||||
|
parent = existing_l2[0]
|
||||||
|
parent_kind = "region"
|
||||||
|
else:
|
||||||
|
# Legacy 根 without 根图 — heal then remount.
|
||||||
|
heal_now = _utcnow()
|
||||||
|
root_map = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=parent.id,
|
||||||
|
kind="region",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
sort_order=0,
|
||||||
|
is_system=True,
|
||||||
|
created_at=heal_now,
|
||||||
|
updated_at=heal_now,
|
||||||
|
)
|
||||||
|
db.add(root_map)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=root_map.id,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role="core",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter={},
|
||||||
|
viewport={},
|
||||||
|
created_at=heal_now,
|
||||||
|
updated_at=heal_now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
parent = root_map
|
||||||
|
parent_kind = "region"
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
row = TopoFolder(
|
row = TopoFolder(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
|
|
@ -274,26 +322,68 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.flush()
|
db.flush()
|
||||||
# Nested / any new region gets a blank physical canvas so the tree can open it.
|
|
||||||
phys = TopoView(
|
|
||||||
id=uuid4().hex,
|
|
||||||
folder_id=row.id,
|
|
||||||
kind=VIEW_KIND_PHYSICAL,
|
|
||||||
role="core",
|
|
||||||
name=name[:200] or "Topology",
|
|
||||||
remark="",
|
|
||||||
sort_order=0,
|
|
||||||
filter={},
|
|
||||||
viewport={},
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
db.add(phys)
|
|
||||||
# Nested region: place a building icon on the parent's canvas (region === canvas).
|
|
||||||
if parent_kind == "region":
|
|
||||||
from .topology_region_canvas import place_child_region_on_parent_canvas
|
|
||||||
|
|
||||||
place_child_region_on_parent_canvas(db, parent, row)
|
if parent_kind == "root":
|
||||||
|
# 「根」nav container + auto unique「根图」canvas (UME World / World pattern).
|
||||||
|
root_map = TopoFolder(
|
||||||
|
id=uuid4().hex,
|
||||||
|
parent_id=row.id,
|
||||||
|
kind="region",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
sort_order=0,
|
||||||
|
is_system=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(root_map)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=root_map.id,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role="core",
|
||||||
|
name=MANUAL_ROOT_MAP_NAME,
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter={},
|
||||||
|
viewport={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif parent_kind == "region":
|
||||||
|
# Nested under 根图 / deeper canvas: region === canvas + icon on parent.
|
||||||
|
phys = TopoView(
|
||||||
|
id=uuid4().hex,
|
||||||
|
folder_id=row.id,
|
||||||
|
kind=VIEW_KIND_PHYSICAL,
|
||||||
|
role="core",
|
||||||
|
name=name[:200] or "Topology",
|
||||||
|
remark="",
|
||||||
|
sort_order=0,
|
||||||
|
filter={},
|
||||||
|
viewport={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(phys)
|
||||||
|
db.flush()
|
||||||
|
parent_has_phys = (
|
||||||
|
db.query(TopoView.id)
|
||||||
|
.filter(TopoView.folder_id == parent.id, TopoView.kind == VIEW_KIND_PHYSICAL)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if parent_has_phys:
|
||||||
|
from .topology_region_canvas import place_child_region_on_parent_canvas
|
||||||
|
|
||||||
|
place_child_region_on_parent_canvas(db, parent, row)
|
||||||
|
else:
|
||||||
|
ensure_region_physical_view(db, parent.id, commit=False)
|
||||||
|
from .topology_region_canvas import place_child_region_on_parent_canvas
|
||||||
|
|
||||||
|
place_child_region_on_parent_canvas(db, parent, row)
|
||||||
db.flush()
|
db.flush()
|
||||||
reconcile_world_flat_view(db)
|
reconcile_world_flat_view(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -338,28 +428,41 @@ def update_folder(db: Session, folder_id: str, body: TopologyFolderUpdate) -> To
|
||||||
|
|
||||||
|
|
||||||
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
|
def delete_folder(db: Session, folder_id: str, *, force: bool = False) -> dict[str, Any]:
|
||||||
"""Delete a region and cascade-delete its maps.
|
"""Delete a region and cascade-delete nested regions + maps.
|
||||||
|
|
||||||
``force`` is accepted for API compatibility; cascade always runs.
|
``force`` is accepted for API compatibility; cascade always runs.
|
||||||
|
System L2「根图」may be removed only as part of cascading from its parent「根」.
|
||||||
"""
|
"""
|
||||||
row = _get_folder_or_404(db, folder_id)
|
row = _get_folder_or_404(db, folder_id)
|
||||||
if str(row.kind or "") == "root" or bool(row.is_system):
|
if str(row.kind or "") == "root":
|
||||||
|
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
|
||||||
|
if bool(row.is_system):
|
||||||
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
|
raise HTTPException(status_code=400, detail="cannot_delete_system_folder")
|
||||||
_ = force
|
_ = force
|
||||||
from .topology_region_canvas import remove_region_canvas_placements
|
from .topology_region_canvas import remove_region_canvas_placements
|
||||||
|
|
||||||
remove_region_canvas_placements(db, row.id)
|
def _purge_folder(folder: TopoFolder) -> None:
|
||||||
views = db.query(TopoView).filter(TopoView.folder_id == row.id).all()
|
kids = (
|
||||||
for v in views:
|
db.query(TopoFolder)
|
||||||
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
|
.filter(TopoFolder.parent_id == folder.id)
|
||||||
synchronize_session=False
|
.all()
|
||||||
)
|
)
|
||||||
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
|
for kid in kids:
|
||||||
synchronize_session=False
|
_purge_folder(kid)
|
||||||
)
|
remove_region_canvas_placements(db, folder.id)
|
||||||
db.delete(v)
|
views = db.query(TopoView).filter(TopoView.folder_id == folder.id).all()
|
||||||
db.flush()
|
for v in views:
|
||||||
db.delete(row)
|
db.query(TopoViewEdgeStyle).filter(TopoViewEdgeStyle.view_id == v.id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(TopoViewNode).filter(TopoViewNode.view_id == v.id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.delete(v)
|
||||||
|
db.flush()
|
||||||
|
db.delete(folder)
|
||||||
|
|
||||||
|
_purge_folder(row)
|
||||||
from .ume_topology_world import reconcile_world_flat_view
|
from .ume_topology_world import reconcile_world_flat_view
|
||||||
|
|
||||||
reconcile_world_flat_view(db)
|
reconcile_world_flat_view(db)
|
||||||
|
|
@ -371,38 +474,61 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
||||||
bootstrap_topology_tree(db)
|
bootstrap_topology_tree(db)
|
||||||
folders = db.query(TopoFolder).order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc()).all()
|
folders = db.query(TopoFolder).order_by(TopoFolder.sort_order.asc(), TopoFolder.name.asc()).all()
|
||||||
views = db.query(TopoView).order_by(TopoView.sort_order.asc(), TopoView.name.asc()).all()
|
views = db.query(TopoView).order_by(TopoView.sort_order.asc(), TopoView.name.asc()).all()
|
||||||
nc_map: dict[str, int] = {}
|
# Cheap directory counts — never hydrate 15k fabric rows (attrs JSON) on tree load.
|
||||||
for vid, cnt in (
|
# Full inventory scans belong only to the world flat map graph.
|
||||||
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
|
nc_map: dict[str, int] = {
|
||||||
.group_by(TopoViewNode.view_id)
|
str(vid): int(cnt or 0)
|
||||||
.all()
|
for vid, cnt in (
|
||||||
):
|
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
|
||||||
nc_map[str(vid)] = int(cnt or 0)
|
.group_by(TopoViewNode.view_id)
|
||||||
region_icon_map: dict[str, int] = {}
|
.all()
|
||||||
for vid, cnt in (
|
)
|
||||||
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
|
}
|
||||||
.filter(TopoViewNode.fabric_node_id.like("region:%"))
|
region_icon_map: dict[str, int] = {
|
||||||
.group_by(TopoViewNode.view_id)
|
str(vid): int(cnt or 0)
|
||||||
.all()
|
for vid, cnt in (
|
||||||
):
|
db.query(TopoViewNode.view_id, func.count(TopoViewNode.id))
|
||||||
region_icon_map[str(vid)] = int(cnt or 0)
|
.filter(TopoViewNode.fabric_node_id.like("region:%"))
|
||||||
|
.group_by(TopoViewNode.view_id)
|
||||||
# UME canvases are virtual (no TopoViewNode membership) — count fabric MEs instead.
|
.all()
|
||||||
ume_ne_by_sbn: dict[str, int] = {}
|
)
|
||||||
ume_ne_by_folder: dict[str, int] = {}
|
}
|
||||||
ume_ne_total = 0
|
ume_ne_by_folder: dict[str, int] = {
|
||||||
for fn in (
|
str(fid): int(cnt or 0)
|
||||||
db.query(TopoFabricNode)
|
for fid, cnt in (
|
||||||
|
db.query(TopoFabricNode.region_folder_id, func.count(TopoFabricNode.id))
|
||||||
|
.filter(
|
||||||
|
TopoFabricNode.ume_ne_id.isnot(None),
|
||||||
|
TopoFabricNode.ume_ne_id != "",
|
||||||
|
TopoFabricNode.region_folder_id.isnot(None),
|
||||||
|
TopoFabricNode.region_folder_id != "",
|
||||||
|
)
|
||||||
|
.group_by(TopoFabricNode.region_folder_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if str(fid or "").strip()
|
||||||
|
}
|
||||||
|
# Direct ME counts by UME parent SBN (level-by-level; not full subtree rollup).
|
||||||
|
ume_ne_by_sbn: dict[str, int] = {
|
||||||
|
str(parent): int(cnt or 0)
|
||||||
|
for parent, cnt in (
|
||||||
|
db.query(UmeTopoNode.parent_node, func.count(UmeTopoNode.node_id))
|
||||||
|
.filter(
|
||||||
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
UmeTopoNode.parent_node.isnot(None),
|
||||||
|
UmeTopoNode.parent_node != "",
|
||||||
|
)
|
||||||
|
.group_by(UmeTopoNode.parent_node)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if str(parent or "").strip()
|
||||||
|
}
|
||||||
|
ume_ne_total = int(
|
||||||
|
db.query(func.count(TopoFabricNode.id))
|
||||||
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
|
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
|
||||||
.all()
|
.scalar()
|
||||||
):
|
or 0
|
||||||
ume_ne_total += 1
|
)
|
||||||
fid = str(fn.region_folder_id or "").strip()
|
|
||||||
if fid:
|
|
||||||
ume_ne_by_folder[fid] = ume_ne_by_folder.get(fid, 0) + 1
|
|
||||||
sid = str((fn.attrs or {}).get("ume_sbn_id") or "").strip()
|
|
||||||
if sid:
|
|
||||||
ume_ne_by_sbn[sid] = ume_ne_by_sbn.get(sid, 0) + 1
|
|
||||||
|
|
||||||
def _view_node_count(v: TopoView) -> int:
|
def _view_node_count(v: TopoView) -> int:
|
||||||
raw = int(nc_map.get(v.id, 0) or 0)
|
raw = int(nc_map.get(v.id, 0) or 0)
|
||||||
|
|
@ -418,7 +544,7 @@ def get_topology_tree(db: Session) -> TopologyTreeOut:
|
||||||
sid = str(filt.get("sbn_id") or "").strip()
|
sid = str(filt.get("sbn_id") or "").strip()
|
||||||
if sid:
|
if sid:
|
||||||
return int(ume_ne_by_sbn.get(sid, 0))
|
return int(ume_ne_by_sbn.get(sid, 0))
|
||||||
# World drill root: all UME MEs.
|
# World drill root: directory hint only (open canvas for real graph).
|
||||||
if str(filt.get("parent") or "") == "md":
|
if str(filt.get("parent") or "") == "md":
|
||||||
return ume_ne_total
|
return ume_ne_total
|
||||||
if v.folder_id:
|
if v.folder_id:
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,10 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
|
||||||
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
|
.filter(UmeTopoNode.node_type == "TOPO_NODE_ME")
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for tn in me_nodes:
|
# Advisory xact locks + nested savepoints accumulate; commit in batches or PG
|
||||||
|
# hits OutOfMemory / max_locks_per_transaction on ~15k MEs.
|
||||||
|
_NODE_BATCH = 250
|
||||||
|
for i, tn in enumerate(me_nodes, start=1):
|
||||||
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
|
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
|
||||||
if not uid:
|
if not uid:
|
||||||
continue
|
continue
|
||||||
|
|
@ -118,8 +121,12 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
|
||||||
attrs["ume_sbn_id"] = str(tn.parent_node)[:128]
|
attrs["ume_sbn_id"] = str(tn.parent_node)[:128]
|
||||||
fn.attrs = attrs
|
fn.attrs = attrs
|
||||||
fabric_by_ume[uid] = fn
|
fabric_by_ume[uid] = fn
|
||||||
|
if i % _NODE_BATCH == 0:
|
||||||
|
db.commit()
|
||||||
|
if i % 2000 == 0:
|
||||||
|
_log.info("ume topology apply nodes progress %s/%s", i, len(me_nodes))
|
||||||
|
|
||||||
db.flush()
|
db.commit()
|
||||||
|
|
||||||
seen_edge_ids: set[str] = set()
|
seen_edge_ids: set[str] = set()
|
||||||
links = db.query(UmeTopoLink).all()
|
links = db.query(UmeTopoLink).all()
|
||||||
|
|
|
||||||
|
|
@ -123,16 +123,21 @@ def get_world_flat_view(db: Session) -> TopoView | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _folder_subtree_ids(db: Session, folder_id: str) -> set[str]:
|
def _folder_subtree_ids(
|
||||||
all_folders = db.query(TopoFolder).all()
|
db: Session,
|
||||||
children: dict[str | None, list[str]] = {}
|
folder_id: str,
|
||||||
for f in all_folders:
|
*,
|
||||||
children.setdefault(f.parent_id, []).append(f.id)
|
children_map: dict[str | None, list[str]] | None = None,
|
||||||
|
) -> set[str]:
|
||||||
|
if children_map is None:
|
||||||
|
children_map = {}
|
||||||
|
for fid, parent_id in db.query(TopoFolder.id, TopoFolder.parent_id).all():
|
||||||
|
children_map.setdefault(parent_id, []).append(fid)
|
||||||
ids = {folder_id}
|
ids = {folder_id}
|
||||||
stack = [folder_id]
|
stack = [folder_id]
|
||||||
while stack:
|
while stack:
|
||||||
cur = stack.pop()
|
cur = stack.pop()
|
||||||
for cid in children.get(cur, []):
|
for cid in children_map.get(cur, []):
|
||||||
if cid not in ids:
|
if cid not in ids:
|
||||||
ids.add(cid)
|
ids.add(cid)
|
||||||
stack.append(cid)
|
stack.append(cid)
|
||||||
|
|
@ -142,25 +147,28 @@ def _folder_subtree_ids(db: Session, folder_id: str) -> set[str]:
|
||||||
def _child_region_has_nes(db: Session, child: TopoFolder) -> bool:
|
def _child_region_has_nes(db: Session, child: TopoFolder) -> bool:
|
||||||
"""True when the child region subtree owns at least one fabric NE."""
|
"""True when the child region subtree owns at least one fabric NE."""
|
||||||
ids = _folder_subtree_ids(db, child.id)
|
ids = _folder_subtree_ids(db, child.id)
|
||||||
n = (
|
if ids and (
|
||||||
db.query(TopoFabricNode)
|
db.query(TopoFabricNode.id)
|
||||||
.filter(TopoFabricNode.region_folder_id.in_(list(ids)))
|
.filter(TopoFabricNode.region_folder_id.in_(list(ids)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.first()
|
.first()
|
||||||
)
|
is not None
|
||||||
if n is not None:
|
):
|
||||||
return True
|
return True
|
||||||
|
# Cheap fallback: direct UME ME under this SBN (never scan all fabric attrs).
|
||||||
ref = str(getattr(child, "external_ref", None) or "").strip()
|
ref = str(getattr(child, "external_ref", None) or "").strip()
|
||||||
if ref and not ref.startswith("ume:"):
|
if ref and not ref.startswith("ume:"):
|
||||||
for fn in (
|
if (
|
||||||
db.query(TopoFabricNode)
|
db.query(UmeTopoNode.node_id)
|
||||||
.filter(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != "")
|
.filter(
|
||||||
.all()
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
UmeTopoNode.parent_node == ref,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
):
|
):
|
||||||
if str((fn.attrs or {}).get("ume_sbn_id") or "") == ref:
|
return True
|
||||||
return True
|
|
||||||
if str(fn.region_folder_id or "") == child.id:
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,7 +188,38 @@ def world_map_should_exist(db: Session) -> bool:
|
||||||
)
|
)
|
||||||
if not children:
|
if not children:
|
||||||
return False
|
return False
|
||||||
return any(_child_region_has_nes(db, c) for c in children)
|
children_map: dict[str | None, list[str]] = {}
|
||||||
|
for fid, parent_id in db.query(TopoFolder.id, TopoFolder.parent_id).all():
|
||||||
|
children_map.setdefault(parent_id, []).append(fid)
|
||||||
|
all_ids: set[str] = set()
|
||||||
|
for c in children:
|
||||||
|
all_ids |= _folder_subtree_ids(db, c.id, children_map=children_map)
|
||||||
|
if all_ids and (
|
||||||
|
db.query(TopoFabricNode.id)
|
||||||
|
.filter(TopoFabricNode.region_folder_id.in_(list(all_ids)))
|
||||||
|
.limit(1)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
refs = [
|
||||||
|
str(getattr(c, "external_ref", None) or "").strip()
|
||||||
|
for c in children
|
||||||
|
if str(getattr(c, "external_ref", None) or "").strip()
|
||||||
|
and not str(getattr(c, "external_ref", None) or "").startswith("ume:")
|
||||||
|
]
|
||||||
|
if refs and (
|
||||||
|
db.query(UmeTopoNode.node_id)
|
||||||
|
.filter(
|
||||||
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
UmeTopoNode.parent_node.in_(refs),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def reconcile_world_flat_view(db: Session) -> TopoView | None:
|
def reconcile_world_flat_view(db: Session) -> TopoView | None:
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeTopoNode
|
from .models import TopoFabricEdge, TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeTopoNode
|
||||||
from .topology_common import (
|
from .topology_common import (
|
||||||
VIEW_GRAPH_EDGE_HARD_CAP,
|
VIEW_GRAPH_EDGE_HARD_CAP,
|
||||||
|
VIEW_GRAPH_NODE_HARD_CAP,
|
||||||
_EDGE_STATUS_MISSING,
|
_EDGE_STATUS_MISSING,
|
||||||
_EDGE_STATUS_MISSING_COMPAT,
|
_EDGE_STATUS_MISSING_COMPAT,
|
||||||
_normalize_edge_status,
|
_normalize_edge_status,
|
||||||
|
|
@ -29,7 +31,9 @@ from .ume_topology_world import (
|
||||||
is_world_view,
|
is_world_view,
|
||||||
)
|
)
|
||||||
|
|
||||||
WORLD_NODE_SOFT_CAP = 8000
|
# Keep flat-world responses at the same browser-safe hard cap as other views.
|
||||||
|
# (Previously 8000 — loading that into React Flow + API RAM starved the whole host.)
|
||||||
|
WORLD_NODE_SOFT_CAP = VIEW_GRAPH_NODE_HARD_CAP
|
||||||
|
|
||||||
|
|
||||||
def apply_persisted_view_positions(
|
def apply_persisted_view_positions(
|
||||||
|
|
@ -122,20 +126,33 @@ def get_level_view_graph(
|
||||||
if parent_key == "md" or is_world_view(view):
|
if parent_key == "md" or is_world_view(view):
|
||||||
# Root: SBN whose parent is not another SBN (+ rare direct MEs under MD).
|
# Root: SBN whose parent is not another SBN (+ rare direct MEs under MD).
|
||||||
sbn_ids = {
|
sbn_ids = {
|
||||||
str(n.node_id)
|
str(row[0])
|
||||||
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
|
for row in db.query(UmeTopoNode.node_id).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
|
||||||
if str(n.node_id or "").strip()
|
if str(row[0] or "").strip()
|
||||||
}
|
}
|
||||||
child_sbns = [
|
child_sbns = [
|
||||||
n
|
n
|
||||||
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_SBN").all()
|
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
|
if str(n.parent_node or "").strip() not in sbn_ids
|
||||||
]
|
]
|
||||||
child_mes = [
|
# Almost all MEs hang under an SBN; push the exclusion into SQL.
|
||||||
n
|
if sbn_ids:
|
||||||
for n in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
|
child_mes = (
|
||||||
if str(n.parent_node or "").strip() not in sbn_ids
|
db.query(UmeTopoNode)
|
||||||
]
|
.filter(
|
||||||
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
or_(
|
||||||
|
UmeTopoNode.parent_node.is_(None),
|
||||||
|
UmeTopoNode.parent_node == "",
|
||||||
|
~UmeTopoNode.parent_node.in_(list(sbn_ids)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
child_mes = (
|
||||||
|
db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all()
|
||||||
|
)
|
||||||
level_parent = "" # MD virtual
|
level_parent = "" # MD virtual
|
||||||
else:
|
else:
|
||||||
if not sbn_id and view.folder_id:
|
if not sbn_id and view.folder_id:
|
||||||
|
|
@ -163,77 +180,100 @@ def get_level_view_graph(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Folder / view lookup for region drill targets
|
# --- Level-by-level: only THIS level's direct child SBNs + direct child MEs.
|
||||||
folders_by_ref: dict[str, TopoFolder] = {
|
# Region badge counts roll up the whole SBN subtree (cheap: 114 SBNs + GROUP BY).
|
||||||
str(f.external_ref): f
|
# Deep edge lifts still belong to deeper drills or the flat world map.
|
||||||
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()]
|
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}
|
child_sbn_id_set = set(child_sbn_ids)
|
||||||
|
|
||||||
# ME → which direct child region (if under a child SBN subtree)
|
folders_by_ref: dict[str, TopoFolder] = {}
|
||||||
me_to_region: dict[str, str] = {}
|
if child_sbn_id_set:
|
||||||
me_uid_to_topo: dict[str, UmeTopoNode] = {}
|
for f in (
|
||||||
for tn in db.query(UmeTopoNode).filter(UmeTopoNode.node_type == "TOPO_NODE_ME").all():
|
db.query(TopoFolder)
|
||||||
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
|
.filter(TopoFolder.external_ref.in_(list(child_sbn_id_set)))
|
||||||
if not uid:
|
.all()
|
||||||
continue
|
):
|
||||||
me_uid_to_topo[uid] = tn
|
ref = str(f.external_ref or "").strip()
|
||||||
parent = str(tn.parent_node or "").strip()
|
if ref:
|
||||||
|
folders_by_ref[ref] = f
|
||||||
|
|
||||||
|
level_view_id_by_sbn: dict[str, str] = {}
|
||||||
|
if child_sbn_id_set:
|
||||||
|
for vid, filt in db.query(TopoView.id, TopoView.filter).all():
|
||||||
|
vf = dict(filt or {})
|
||||||
|
sid = str(vf.get("sbn_id") or "").strip()
|
||||||
|
if vf.get("ume_level") and sid in child_sbn_id_set:
|
||||||
|
level_view_id_by_sbn[sid] = str(vid)
|
||||||
|
|
||||||
|
# Subtree ME counts for each direct child region (badge), without hydrating all MEs.
|
||||||
|
region_me_counts: dict[str, int] = {sid: 0 for sid in child_sbn_ids}
|
||||||
|
if child_sbn_id_set:
|
||||||
|
sbn_parent: dict[str, str] = {
|
||||||
|
str(nid): str(parent or "").strip()
|
||||||
|
for nid, parent in db.query(UmeTopoNode.node_id, UmeTopoNode.parent_node)
|
||||||
|
.filter(UmeTopoNode.node_type == "TOPO_NODE_SBN")
|
||||||
|
.all()
|
||||||
|
if str(nid or "").strip()
|
||||||
|
}
|
||||||
|
sbn_children: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for sid, parent in sbn_parent.items():
|
||||||
|
if parent:
|
||||||
|
sbn_children[parent].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
|
||||||
|
|
||||||
|
region_desc: dict[str, set[str]] = {sid: _descendants(sid) for sid in child_sbn_ids}
|
||||||
|
me_by_parent: dict[str, int] = {
|
||||||
|
str(parent): int(cnt or 0)
|
||||||
|
for parent, cnt in (
|
||||||
|
db.query(UmeTopoNode.parent_node, func.count())
|
||||||
|
.filter(
|
||||||
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
UmeTopoNode.parent_node.isnot(None),
|
||||||
|
UmeTopoNode.parent_node != "",
|
||||||
|
)
|
||||||
|
.group_by(UmeTopoNode.parent_node)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if str(parent or "").strip()
|
||||||
|
}
|
||||||
for rid, desc in region_desc.items():
|
for rid, desc in region_desc.items():
|
||||||
if parent in desc:
|
region_me_counts[rid] = sum(me_by_parent.get(p, 0) for p in desc)
|
||||||
me_to_region[uid] = rid
|
|
||||||
break
|
|
||||||
|
|
||||||
fabric_by_ume: dict[str, TopoFabricNode] = {
|
direct_me_uids = [
|
||||||
str(n.ume_ne_id): n
|
uid
|
||||||
for n in db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.isnot(None)).all()
|
for uid in (str(tn.ume_ne_id or tn.node_id or "").strip() for tn in child_mes)
|
||||||
if str(n.ume_ne_id or "").strip()
|
if uid
|
||||||
}
|
]
|
||||||
|
fabric_by_ume: dict[str, TopoFabricNode] = {}
|
||||||
|
if direct_me_uids:
|
||||||
|
for n in (
|
||||||
|
db.query(TopoFabricNode)
|
||||||
|
.filter(TopoFabricNode.ume_ne_id.in_(direct_me_uids))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
uid = str(n.ume_ne_id or "").strip()
|
||||||
|
if uid:
|
||||||
|
fabric_by_ume[uid] = n
|
||||||
|
|
||||||
nodes_out: list[ViewNodeOut] = []
|
nodes_out: list[ViewNodeOut] = []
|
||||||
# Region nodes
|
region_node_ids: dict[str, str] = {}
|
||||||
region_node_ids: dict[str, str] = {} # sbn_id -> fabric_node_id used in graph
|
|
||||||
for sbn in child_sbns:
|
for sbn in child_sbns:
|
||||||
sid = str(sbn.node_id or "").strip()
|
sid = str(sbn.node_id or "").strip()
|
||||||
if not sid:
|
if not sid:
|
||||||
continue
|
continue
|
||||||
folder = folders_by_ref.get(sid)
|
folder = folders_by_ref.get(sid)
|
||||||
child_view = level_view_by_sbn.get(sid)
|
child_view_id = level_view_id_by_sbn.get(sid, "")
|
||||||
# Count MEs under this region tree
|
n_me = int(region_me_counts.get(sid, 0))
|
||||||
n_me = sum(1 for uid, rid in me_to_region.items() if rid == sid)
|
|
||||||
nid = f"region:{sid}"
|
nid = f"region:{sid}"
|
||||||
region_node_ids[sid] = nid
|
region_node_ids[sid] = nid
|
||||||
label = (sbn.user_label or (folder.name if folder else sid) or sid)[:256]
|
label = (sbn.user_label or (folder.name if folder else sid) or sid)[:256]
|
||||||
|
|
@ -254,14 +294,13 @@ def get_level_view_graph(
|
||||||
device_type="region",
|
device_type="region",
|
||||||
kind="region",
|
kind="region",
|
||||||
folder_id=folder.id if folder else "",
|
folder_id=folder.id if folder else "",
|
||||||
view_id=child_view.id if child_view else "",
|
view_id=child_view_id,
|
||||||
node_count=n_me,
|
node_count=n_me,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Direct ME nodes (local coords)
|
|
||||||
direct_me_fids: set[str] = set()
|
direct_me_fids: set[str] = set()
|
||||||
me_fid_by_uid: dict[str, str] = {}
|
me_uid_to_fid: dict[str, str] = {}
|
||||||
for tn in child_mes:
|
for tn in child_mes:
|
||||||
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
|
uid = str(tn.ume_ne_id or tn.node_id or "").strip()
|
||||||
if not uid:
|
if not uid:
|
||||||
|
|
@ -270,7 +309,7 @@ def get_level_view_graph(
|
||||||
if fn is None:
|
if fn is None:
|
||||||
continue
|
continue
|
||||||
direct_me_fids.add(fn.id)
|
direct_me_fids.add(fn.id)
|
||||||
me_fid_by_uid[uid] = fn.id
|
me_uid_to_fid[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
|
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
|
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(
|
nodes_out.append(
|
||||||
|
|
@ -290,42 +329,35 @@ def get_level_view_graph(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Edges: physical among direct MEs; logical lifts across regions
|
|
||||||
st = str(status or "active").strip().lower() or "active"
|
st = str(status or "active").strip().lower() or "active"
|
||||||
st_norm = _normalize_edge_status(st)
|
st_norm = _normalize_edge_status(st)
|
||||||
eq = db.query(TopoFabricEdge).filter(TopoFabricEdge.layer == "physical")
|
status_filter = (
|
||||||
if st_norm == _EDGE_STATUS_MISSING:
|
list(_EDGE_STATUS_MISSING_COMPAT)
|
||||||
eq = eq.filter(TopoFabricEdge.status.in_(list(_EDGE_STATUS_MISSING_COMPAT)))
|
if st_norm == _EDGE_STATUS_MISSING
|
||||||
else:
|
else [st_norm]
|
||||||
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] = []
|
physical: list[ViewEdgeOut] = []
|
||||||
logical_count: dict[tuple[str, str], int] = defaultdict(int)
|
if direct_me_fids:
|
||||||
for e in eq.limit(VIEW_GRAPH_EDGE_HARD_CAP * 20).all():
|
direct_list = list(direct_me_fids)
|
||||||
a = _endpoint_on_canvas(e.a_node_id)
|
for e in (
|
||||||
b = _endpoint_on_canvas(e.b_node_id)
|
db.query(TopoFabricEdge)
|
||||||
if not a or not b or a == b:
|
.filter(
|
||||||
continue
|
TopoFabricEdge.layer == "physical",
|
||||||
# Both direct MEs → physical
|
TopoFabricEdge.status.in_(status_filter),
|
||||||
if a in direct_me_fids and b in direct_me_fids:
|
TopoFabricEdge.a_node_id.in_(direct_list),
|
||||||
|
TopoFabricEdge.b_node_id.in_(direct_list),
|
||||||
|
)
|
||||||
|
.limit(VIEW_GRAPH_EDGE_HARD_CAP + 1)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
if e.a_node_id == e.b_node_id:
|
||||||
|
continue
|
||||||
physical.append(
|
physical.append(
|
||||||
ViewEdgeOut(
|
ViewEdgeOut(
|
||||||
id=e.id,
|
id=e.id,
|
||||||
a_node_id=a,
|
a_node_id=e.a_node_id,
|
||||||
b_node_id=b,
|
b_node_id=e.b_node_id,
|
||||||
a_port=e.a_port or "",
|
a_port=e.a_port or "",
|
||||||
b_port=e.b_port or "",
|
b_port=e.b_port or "",
|
||||||
source=e.source or "lldp",
|
source=e.source or "lldp",
|
||||||
|
|
@ -334,9 +366,58 @@ def get_level_view_graph(
|
||||||
discovered_at=e.discovered_at,
|
discovered_at=e.discovered_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
key = (a, b) if a < b else (b, a)
|
# Logical lifts only among MEs that hang *directly* under this level's child regions
|
||||||
logical_count[key] += 1
|
# (one hop). Deeper nesting is loaded when the user drills into that region.
|
||||||
|
logical_count: dict[tuple[str, str], int] = defaultdict(int)
|
||||||
|
if region_node_ids and child_sbn_id_set:
|
||||||
|
me_uid_to_region: dict[str, str] = {}
|
||||||
|
for ume_ne_id, node_id, parent_node in (
|
||||||
|
db.query(UmeTopoNode.ume_ne_id, UmeTopoNode.node_id, UmeTopoNode.parent_node)
|
||||||
|
.filter(
|
||||||
|
UmeTopoNode.node_type == "TOPO_NODE_ME",
|
||||||
|
UmeTopoNode.parent_node.in_(list(child_sbn_id_set)),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
uid = str(ume_ne_id or node_id or "").strip()
|
||||||
|
parent = str(parent_node or "").strip()
|
||||||
|
if uid and parent in region_node_ids:
|
||||||
|
me_uid_to_region[uid] = parent
|
||||||
|
|
||||||
|
if me_uid_to_region:
|
||||||
|
fid_to_canvas: dict[str, str] = {}
|
||||||
|
for fid, uid in (
|
||||||
|
db.query(TopoFabricNode.id, TopoFabricNode.ume_ne_id)
|
||||||
|
.filter(TopoFabricNode.ume_ne_id.in_(list(me_uid_to_region.keys())))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
rid = me_uid_to_region.get(str(uid or "").strip(), "")
|
||||||
|
if rid:
|
||||||
|
fid_to_canvas[str(fid)] = region_node_ids[rid]
|
||||||
|
for fid in direct_me_fids:
|
||||||
|
fid_to_canvas[fid] = fid
|
||||||
|
|
||||||
|
fids = list(fid_to_canvas.keys())
|
||||||
|
if fids:
|
||||||
|
for a_id, b_id in (
|
||||||
|
db.query(TopoFabricEdge.a_node_id, TopoFabricEdge.b_node_id)
|
||||||
|
.filter(
|
||||||
|
TopoFabricEdge.layer == "physical",
|
||||||
|
TopoFabricEdge.status.in_(status_filter),
|
||||||
|
TopoFabricEdge.a_node_id.in_(fids),
|
||||||
|
TopoFabricEdge.b_node_id.in_(fids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
a = fid_to_canvas.get(str(a_id))
|
||||||
|
b = fid_to_canvas.get(str(b_id))
|
||||||
|
if not a or not b or a == b:
|
||||||
|
continue
|
||||||
|
if a in direct_me_fids and b in direct_me_fids:
|
||||||
|
continue
|
||||||
|
key = (a, b) if a < b else (b, a)
|
||||||
|
logical_count[key] += 1
|
||||||
|
|
||||||
edges_out = physical[:VIEW_GRAPH_EDGE_HARD_CAP]
|
edges_out = physical[:VIEW_GRAPH_EDGE_HARD_CAP]
|
||||||
truncated = len(physical) > VIEW_GRAPH_EDGE_HARD_CAP
|
truncated = len(physical) > VIEW_GRAPH_EDGE_HARD_CAP
|
||||||
|
|
@ -354,7 +435,6 @@ def get_level_view_graph(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Manual nested regions (新建子区域) on this UME canvas.
|
|
||||||
from .topology_region_canvas import child_region_nodes_for_view
|
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"}
|
seen_folder = {str(n.folder_id or "") for n in nodes_out if n.kind == "region"}
|
||||||
|
|
@ -413,18 +493,24 @@ def get_flat_view_graph(
|
||||||
min_y = float(bbox["min_y"]) - pad_y
|
min_y = float(bbox["min_y"]) - pad_y
|
||||||
max_y = float(bbox["max_y"]) + pad_y
|
max_y = float(bbox["max_y"]) + pad_y
|
||||||
|
|
||||||
extent = (
|
extent_row = (
|
||||||
db.query(TopoFabricNode)
|
db.query(
|
||||||
|
func.min(TopoFabricNode.world_x),
|
||||||
|
func.max(TopoFabricNode.world_x),
|
||||||
|
func.min(TopoFabricNode.world_y),
|
||||||
|
func.max(TopoFabricNode.world_y),
|
||||||
|
func.count(TopoFabricNode.id),
|
||||||
|
)
|
||||||
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
|
.filter(TopoFabricNode.world_x.isnot(None), TopoFabricNode.world_y.isnot(None))
|
||||||
.all()
|
.one()
|
||||||
)
|
)
|
||||||
if not extent:
|
if not int(extent_row[4] or 0):
|
||||||
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
|
return TopologyViewGraphOut(view=_view_out(view), nodes=[], edges=[])
|
||||||
|
|
||||||
full_min_x = min(float(n.world_x) for n in extent)
|
full_min_x = float(extent_row[0])
|
||||||
full_max_x = max(float(n.world_x) for n in extent)
|
full_max_x = float(extent_row[1])
|
||||||
full_min_y = min(float(n.world_y) for n in extent)
|
full_min_y = float(extent_row[2])
|
||||||
full_max_y = max(float(n.world_y) for n in extent)
|
full_max_y = float(extent_row[3])
|
||||||
|
|
||||||
if min_x is None or max_x is None or min_y is None or max_y is None:
|
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
|
min_x, max_x, min_y, max_y = full_min_x, full_max_x, full_min_y, full_max_y
|
||||||
|
|
@ -441,14 +527,17 @@ def get_flat_view_graph(
|
||||||
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
|
q = q.filter(TopoFabricNode.region_folder_id.in_(list(region_folder_ids)))
|
||||||
if sbn_id.strip():
|
if sbn_id.strip():
|
||||||
sid = sbn_id.strip()
|
sid = sbn_id.strip()
|
||||||
|
# Attr JSON filter is rare; keep Python filter but only after a bounded fetch.
|
||||||
|
candidates = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP * 5).all()
|
||||||
nodes = [
|
nodes = [
|
||||||
n
|
n
|
||||||
for n in q.all()
|
for n in candidates
|
||||||
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
|
if str((n.attrs or {}).get("ume_sbn_id") or "") == sid
|
||||||
or str(n.region_folder_id or "") == sid
|
or str(n.region_folder_id or "") == sid
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
nodes = q.all()
|
# Cap in SQL so we never hydrate 15k ORM rows for a single browser canvas.
|
||||||
|
nodes = q.order_by(TopoFabricNode.id.asc()).limit(WORLD_NODE_SOFT_CAP + 1).all()
|
||||||
|
|
||||||
truncated = False
|
truncated = False
|
||||||
reason = ""
|
reason = ""
|
||||||
|
|
|
||||||
|
|
@ -319,9 +319,17 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.assertEqual(action, "skipped_self_loop")
|
self.assertEqual(action, "skipped_self_loop")
|
||||||
|
|
||||||
def _region(self, name: str = "Test-Region") -> str:
|
def _region(self, name: str = "Test-Region") -> str:
|
||||||
return svc.create_folder(
|
"""Return the unique L2「根图」canvas id under a new top-level「根」."""
|
||||||
|
top = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name=name, kind="region")
|
self.db, TopologyFolderCreate(name=name, kind="region")
|
||||||
).id
|
)
|
||||||
|
child = (
|
||||||
|
self.db.query(TopoFolder)
|
||||||
|
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||||
|
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return child.id if child is not None else top.id
|
||||||
|
|
||||||
def test_view_crud_and_positions(self) -> None:
|
def test_view_crud_and_positions(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
|
|
@ -394,93 +402,96 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
region = svc.create_folder(
|
region = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="East", kind="region")
|
self.db, TopologyFolderCreate(name="East", kind="region")
|
||||||
)
|
)
|
||||||
# Creating a region does not auto-create a map — user/MCP adds views.
|
# Top-level「根」is nav-only; unique L2「根图」holds the canvas.
|
||||||
tree1 = svc.get_topology_tree(self.db)
|
tree1 = svc.get_topology_tree(self.db)
|
||||||
assert tree1.root is not None
|
assert tree1.root is not None
|
||||||
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
east0 = next(c for c in tree1.root.children if c.id == region.id)
|
||||||
self.assertEqual(east0.views, [])
|
self.assertEqual(east0.views, [])
|
||||||
|
self.assertEqual(len(east0.children), 1)
|
||||||
|
root_map = east0.children[0]
|
||||||
|
self.assertEqual(root_map.name, "根图")
|
||||||
|
self.assertEqual(len(root_map.views), 1)
|
||||||
|
self.assertEqual(root_map.views[0].name, "根图")
|
||||||
|
|
||||||
view = svc.create_view(
|
view = svc.create_view(
|
||||||
self.db,
|
self.db,
|
||||||
TopologyViewCreate(
|
TopologyViewCreate(
|
||||||
name="East-Custom",
|
name="East-Custom",
|
||||||
folder_id=region.id,
|
folder_id=root_map.id,
|
||||||
kind="custom",
|
kind="custom",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.assertEqual(view.folder_id, region.id)
|
self.assertEqual(view.folder_id, root_map.id)
|
||||||
self.assertEqual(view.kind, "custom")
|
self.assertEqual(view.kind, "custom")
|
||||||
self.assertIn("membership", view.filter)
|
self.assertIn("membership", view.filter)
|
||||||
|
|
||||||
tree2 = svc.get_topology_tree(self.db)
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
assert tree2.root is not None
|
assert tree2.root is not None
|
||||||
east = next(c for c in tree2.root.children if c.id == region.id)
|
east = next(c for c in tree2.root.children if c.id == region.id)
|
||||||
self.assertTrue(any(v.id == view.id for v in east.views))
|
rm = east.children[0]
|
||||||
self.assertEqual(len(east.views), 1)
|
self.assertTrue(any(v.id == view.id for v in rm.views))
|
||||||
self.assertTrue(all(not getattr(v, "children", None) for v in east.views))
|
self.assertEqual(len(rm.views), 2)
|
||||||
|
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views))
|
||||||
|
|
||||||
def test_site_physical_and_custom_flat(self) -> None:
|
def test_site_physical_and_custom_flat(self) -> None:
|
||||||
region = svc.create_folder(
|
top = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||||||
)
|
)
|
||||||
phys = svc.create_view(
|
tree0 = svc.get_topology_tree(self.db)
|
||||||
self.db,
|
assert tree0.root is not None
|
||||||
TopologyViewCreate(
|
nav = next(c for c in tree0.root.children if c.id == top.id)
|
||||||
name="Physical",
|
root_map = nav.children[0]
|
||||||
folder_id=region.id,
|
self.assertEqual(root_map.name, "根图")
|
||||||
kind="physical",
|
auto_phys = root_map.views[0]
|
||||||
),
|
self.assertEqual(auto_phys.kind, "physical")
|
||||||
)
|
|
||||||
tree = svc.get_topology_tree(self.db)
|
|
||||||
assert tree.root is not None
|
|
||||||
reg = next(c for c in tree.root.children if c.id == region.id)
|
|
||||||
physicals = [v for v in reg.views if v.kind == "physical"]
|
|
||||||
self.assertEqual(len(physicals), 1)
|
|
||||||
self.assertEqual(physicals[0].id, phys.id)
|
|
||||||
|
|
||||||
custom = svc.create_view(
|
custom = svc.create_view(
|
||||||
self.db,
|
self.db,
|
||||||
TopologyViewCreate(
|
TopologyViewCreate(
|
||||||
name="Custom-A",
|
name="Custom-A",
|
||||||
folder_id=region.id,
|
folder_id=root_map.id,
|
||||||
kind="custom",
|
kind="custom",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
tree2 = svc.get_topology_tree(self.db)
|
tree2 = svc.get_topology_tree(self.db)
|
||||||
assert tree2.root is not None
|
assert tree2.root is not None
|
||||||
reg2 = next(c for c in tree2.root.children if c.id == region.id)
|
reg2 = next(c for c in tree2.root.children if c.id == top.id).children[0]
|
||||||
ids = {v.id for v in reg2.views}
|
ids = {v.id for v in reg2.views}
|
||||||
self.assertIn(phys.id, ids)
|
self.assertIn(auto_phys.id, ids)
|
||||||
self.assertIn(custom.id, ids)
|
self.assertIn(custom.id, ids)
|
||||||
# physical first
|
# physical first
|
||||||
self.assertEqual(reg2.views[0].kind, "physical")
|
self.assertEqual(reg2.views[0].kind, "physical")
|
||||||
|
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
svc.delete_view(self.db, phys.id)
|
svc.delete_view(self.db, auto_phys.id)
|
||||||
svc.delete_view(self.db, custom.id)
|
svc.delete_view(self.db, custom.id)
|
||||||
# force-delete physical does not recreate another map
|
# force-delete physical does not recreate another map
|
||||||
svc.delete_view(self.db, phys.id, force=True)
|
svc.delete_view(self.db, auto_phys.id, force=True)
|
||||||
tree3 = svc.get_topology_tree(self.db)
|
tree3 = svc.get_topology_tree(self.db)
|
||||||
assert tree3.root is not None
|
assert tree3.root is not None
|
||||||
reg3 = next(c for c in tree3.root.children if c.id == region.id)
|
reg3 = next(c for c in tree3.root.children if c.id == top.id).children[0]
|
||||||
self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0)
|
self.assertEqual(sum(1 for v in reg3.views if v.kind == "physical"), 0)
|
||||||
|
|
||||||
def test_delete_region_cascades_views(self) -> None:
|
def test_delete_region_cascades_views(self) -> None:
|
||||||
region = svc.create_folder(
|
region = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="Del-Region", kind="region")
|
self.db, TopologyFolderCreate(name="Del-Region", kind="region")
|
||||||
)
|
)
|
||||||
|
tree0 = svc.get_topology_tree(self.db)
|
||||||
|
assert tree0.root is not None
|
||||||
|
nav = next(c for c in tree0.root.children if c.id == region.id)
|
||||||
|
root_map = nav.children[0]
|
||||||
custom = svc.create_view(
|
custom = svc.create_view(
|
||||||
self.db,
|
self.db,
|
||||||
TopologyViewCreate(
|
TopologyViewCreate(
|
||||||
name="Custom-Del",
|
name="Custom-Del",
|
||||||
folder_id=region.id,
|
folder_id=root_map.id,
|
||||||
kind="custom",
|
kind="custom",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
tree = svc.get_topology_tree(self.db)
|
tree = svc.get_topology_tree(self.db)
|
||||||
assert tree.root is not None
|
assert tree.root is not None
|
||||||
reg = next(c for c in tree.root.children if c.id == region.id)
|
reg = next(c for c in tree.root.children if c.id == region.id)
|
||||||
self.assertTrue(any(v.id == custom.id for v in reg.views))
|
self.assertTrue(any(v.id == custom.id for v in reg.children[0].views))
|
||||||
|
|
||||||
out = svc.delete_folder(self.db, region.id)
|
out = svc.delete_folder(self.db, region.id)
|
||||||
self.assertTrue(out.get("deleted"))
|
self.assertTrue(out.get("deleted"))
|
||||||
|
|
@ -489,13 +500,20 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
self.assertFalse(any(c.id == region.id for c in tree2.root.children))
|
self.assertFalse(any(c.id == region.id for c in tree2.root.children))
|
||||||
self.assertIsNone(self.db.get(TopoView, custom.id))
|
self.assertIsNone(self.db.get(TopoView, custom.id))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.db.query(TopoView).filter(TopoView.folder_id == region.id).count(),
|
self.db.query(TopoView).filter(TopoView.folder_id == root_map.id).count(),
|
||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
|
def test_nested_region_places_icon_on_parent_canvas(self) -> None:
|
||||||
|
# Top-level「根」is nav-only; canvas work happens under unique「根图」.
|
||||||
|
container = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="Site-Group", kind="region")
|
||||||
|
)
|
||||||
parent = svc.create_folder(
|
parent = svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name="Parent-Canvas", kind="region")
|
self.db,
|
||||||
|
TopologyFolderCreate(
|
||||||
|
name="Parent-Canvas", kind="region", parent_id=container.id
|
||||||
|
),
|
||||||
)
|
)
|
||||||
child = svc.create_folder(
|
child = svc.create_folder(
|
||||||
self.db,
|
self.db,
|
||||||
|
|
@ -503,7 +521,13 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
tree = svc.get_topology_tree(self.db)
|
tree = svc.get_topology_tree(self.db)
|
||||||
assert tree.root is not None
|
assert tree.root is not None
|
||||||
p = next(c for c in tree.root.children if c.id == parent.id)
|
group = next(c for c in tree.root.children if c.id == container.id)
|
||||||
|
self.assertEqual(group.views, [])
|
||||||
|
self.assertEqual(len(group.children), 1)
|
||||||
|
root_map = group.children[0]
|
||||||
|
self.assertEqual(root_map.name, "根图")
|
||||||
|
# Creating under the nav「根」remounts onto「根图」.
|
||||||
|
p = next(c for c in root_map.children if c.id == parent.id)
|
||||||
self.assertTrue(p.views)
|
self.assertTrue(p.views)
|
||||||
graph = svc.get_view_graph(self.db, p.views[0].id)
|
graph = svc.get_view_graph(self.db, p.views[0].id)
|
||||||
regions = [n for n in graph.nodes if n.kind == "region"]
|
regions = [n for n in graph.nodes if n.kind == "region"]
|
||||||
|
|
@ -540,6 +564,50 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
graph2 = svc.get_view_graph(self.db, p.views[0].id)
|
graph2 = svc.get_view_graph(self.db, p.views[0].id)
|
||||||
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
|
self.assertFalse(any(n.kind == "region" for n in graph2.nodes))
|
||||||
|
|
||||||
|
def test_top_level_stays_nav_when_adding_child(self) -> None:
|
||||||
|
"""Creating under「根」remounts onto unique「根图」; top stays nav-only."""
|
||||||
|
top = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="East-Nav", kind="region")
|
||||||
|
)
|
||||||
|
child = svc.create_folder(
|
||||||
|
self.db,
|
||||||
|
TopologyFolderCreate(name="Site-A", kind="region", parent_id=top.id),
|
||||||
|
)
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
assert tree.root is not None
|
||||||
|
east = next(c for c in tree.root.children if c.id == top.id)
|
||||||
|
self.assertEqual(east.views, [])
|
||||||
|
self.assertEqual(len(east.children), 1)
|
||||||
|
root_map = east.children[0]
|
||||||
|
self.assertEqual(root_map.name, "根图")
|
||||||
|
self.assertEqual(len(root_map.views), 1)
|
||||||
|
site = next(c for c in root_map.children if c.id == child.id)
|
||||||
|
self.assertEqual(len(site.views), 1)
|
||||||
|
self.assertEqual(site.views[0].name, "Site-A")
|
||||||
|
self.assertNotEqual(site.views[0].name, "Physical topology")
|
||||||
|
|
||||||
|
def test_root_auto_spawns_unique_root_map(self) -> None:
|
||||||
|
top = svc.create_folder(
|
||||||
|
self.db, TopologyFolderCreate(name="West", kind="region")
|
||||||
|
)
|
||||||
|
# Second create under the same「根」must remount under existing「根图」.
|
||||||
|
a = svc.create_folder(
|
||||||
|
self.db,
|
||||||
|
TopologyFolderCreate(name="A", kind="region", parent_id=top.id),
|
||||||
|
)
|
||||||
|
b = svc.create_folder(
|
||||||
|
self.db,
|
||||||
|
TopologyFolderCreate(name="B", kind="region", parent_id=top.id),
|
||||||
|
)
|
||||||
|
tree = svc.get_topology_tree(self.db)
|
||||||
|
assert tree.root is not None
|
||||||
|
west = next(c for c in tree.root.children if c.id == top.id)
|
||||||
|
self.assertEqual(len(west.children), 1)
|
||||||
|
root_map = west.children[0]
|
||||||
|
self.assertEqual(root_map.name, "根图")
|
||||||
|
child_ids = {c.id for c in root_map.children}
|
||||||
|
self.assertEqual(child_ids, {a.id, b.id})
|
||||||
|
|
||||||
def test_classify_role_region_and_slices(self) -> None:
|
def test_classify_role_region_and_slices(self) -> None:
|
||||||
from netx_api import topology_classify as clf
|
from netx_api import topology_classify as clf
|
||||||
from netx_api.topology_schemas import (
|
from netx_api.topology_schemas import (
|
||||||
|
|
|
||||||
|
|
@ -1394,7 +1394,11 @@ const en = {
|
||||||
maps: "Maps",
|
maps: "Maps",
|
||||||
tree: "Topology tree",
|
tree: "Topology tree",
|
||||||
treeLoading: "Loading tree…",
|
treeLoading: "Loading tree…",
|
||||||
treeHint: "Regions hold maps you create; open a map to edit the canvas.",
|
graphLoading: "Loading topology…",
|
||||||
|
graphRefreshing: "Refreshing…",
|
||||||
|
canvasEmpty: "No NEs on this map yet",
|
||||||
|
canvasEmptyHint: "Use Add NE, or pick a region that already has devices.",
|
||||||
|
treeHint: "Top level is a Root (nav only); its unique Root Map is the canvas. Open the root map or a sub-region to edit devices.",
|
||||||
treeSearch: "Search NEs across maps",
|
treeSearch: "Search NEs across maps",
|
||||||
treeSearchPh: "Name / IP",
|
treeSearchPh: "Name / IP",
|
||||||
treeSearchEmpty: "No matching NEs",
|
treeSearchEmpty: "No matching NEs",
|
||||||
|
|
@ -1402,47 +1406,47 @@ const en = {
|
||||||
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
|
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
|
||||||
treeSearchViewCount: "{{count}} map(s)",
|
treeSearchViewCount: "{{count}} map(s)",
|
||||||
treeSearchTruncated: "Showing first 30 of {{total}}",
|
treeSearchTruncated: "Showing first 30 of {{total}}",
|
||||||
rootName: "Regions",
|
rootName: "Roots",
|
||||||
selectRegion: "Select a region",
|
selectRegion: "Select a root or region",
|
||||||
openMap: "Open map",
|
openMap: "Open map",
|
||||||
openRegion: "Open region",
|
openRegion: "Open",
|
||||||
kindPhysical: "Physical",
|
kindPhysical: "Physical",
|
||||||
kindCustom: "Custom",
|
kindCustom: "Custom",
|
||||||
regionNodeHint: "{{count}}P",
|
regionNodeHint: "{{count}}P",
|
||||||
layerNodeHint: "{{count}} NE(s)",
|
layerNodeHint: "{{count}} NE(s)",
|
||||||
layerNodeEmpty: "Click to create & open",
|
layerNodeEmpty: "Click to create & open",
|
||||||
rootCanvasHint: "Pick a region from the left tree.",
|
rootCanvasHint: "Pick a root from the left tree.",
|
||||||
regionCanvasHint: "Open a map to edit devices, or create a custom map.",
|
regionCanvasHint: "Open the root map to edit devices; create sub-regions on that canvas.",
|
||||||
regionBrowseHint: "Region folder: maps you add. Click to open.",
|
regionBrowseHint: "Root folder: unique root map below. Click to open.",
|
||||||
browserTitle: "Topology folder",
|
browserTitle: "Topology folder",
|
||||||
browserRegionSub: "{{count}} map(s)",
|
browserRegionSub: "{{count}} map(s)",
|
||||||
browserRegionsSub: "{{count}} region(s)",
|
browserRegionsSub: "{{count}} item(s)",
|
||||||
browserEmptyMaps: "No maps in this region yet.",
|
browserEmptyMaps: "No root map under this root yet.",
|
||||||
addNe: "Add NE",
|
addNe: "Add NE",
|
||||||
backUp: "Back to region",
|
backUp: "Back",
|
||||||
newRegion: "New region",
|
newRegion: "New root",
|
||||||
newRegionName: "New region",
|
newRegionName: "New root",
|
||||||
newRegionPrompt: "Region name",
|
newRegionPrompt: "Root name",
|
||||||
newSubRegion: "New sub-region",
|
newSubRegion: "New sub-region",
|
||||||
newSubRegionName: "New sub-region",
|
newSubRegionName: "New sub-region",
|
||||||
newSubRegionPrompt: "Sub-region name",
|
newSubRegionPrompt: "Sub-region name",
|
||||||
subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
||||||
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
|
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
|
||||||
subRegionNeedParent: "Open a region canvas first, then create a sub-region.",
|
subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.",
|
||||||
regionCreated: "Region created",
|
regionCreated: "Created",
|
||||||
regionDeleted: "Region deleted",
|
regionDeleted: "Deleted",
|
||||||
deleteRegion: "Delete region",
|
deleteRegion: "Delete",
|
||||||
deleteRegionConfirm: "Delete region \"{{name}}\"? Its maps will also be deleted.",
|
deleteRegionConfirm: "Delete \"{{name}}\"? Its maps will also be deleted.",
|
||||||
renameRegion: "Rename region",
|
renameRegion: "Rename",
|
||||||
renameRegionPrompt: "Region name",
|
renameRegionPrompt: "Name",
|
||||||
regionRenamed: "Region renamed",
|
regionRenamed: "Renamed",
|
||||||
collapseRegion: "Collapse region",
|
collapseRegion: "Collapse",
|
||||||
expandRegion: "Expand region",
|
expandRegion: "Expand",
|
||||||
newCustom: "Custom map",
|
newCustom: "Custom map",
|
||||||
newCustomName: "Custom topology",
|
newCustomName: "Custom topology",
|
||||||
newCustomPrompt: "Custom map name",
|
newCustomPrompt: "Custom map name",
|
||||||
folderHint: "Regions group maps. After creating a region, add a physical or custom map yourself.",
|
folderHint: "Creating a root auto-creates its unique root map. Add sub-regions on that canvas.",
|
||||||
folderHintShort: "Region folder",
|
folderHintShort: "Root folder",
|
||||||
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
outsidePeers: "{{count}} outside peer(s) linked but not on this map",
|
||||||
projectNeighbors: "Project neighbors",
|
projectNeighbors: "Project neighbors",
|
||||||
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
||||||
|
|
@ -1467,7 +1471,7 @@ const en = {
|
||||||
renamed: "Topology map renamed",
|
renamed: "Topology map renamed",
|
||||||
deleteMap: "Delete map",
|
deleteMap: "Delete map",
|
||||||
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.",
|
||||||
emptyMaps: "No regions yet. Create a region first.",
|
emptyMaps: "No roots yet. Create a root first.",
|
||||||
palette: "NE library",
|
palette: "NE library",
|
||||||
paletteHint: "Select multiple to add in batch, or drag one onto the canvas",
|
paletteHint: "Select multiple to add in batch, or drag one onto the canvas",
|
||||||
paletteDragHint: "Drag onto canvas to add",
|
paletteDragHint: "Drag onto canvas to add",
|
||||||
|
|
|
||||||
|
|
@ -1385,7 +1385,11 @@ const zh = {
|
||||||
maps: "拓扑图",
|
maps: "拓扑图",
|
||||||
tree: "拓扑树",
|
tree: "拓扑树",
|
||||||
treeLoading: "加载拓扑树…",
|
treeLoading: "加载拓扑树…",
|
||||||
treeHint: "区域下为画布列表;点图打开设备画布(区域本身不自动建图)。",
|
graphLoading: "正在加载拓扑图…",
|
||||||
|
graphRefreshing: "刷新中…",
|
||||||
|
canvasEmpty: "这张图上还没有网元",
|
||||||
|
canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。",
|
||||||
|
treeHint: "顶级为「根」(导航);其下唯一「根图」为画布。点根图或子区域打开设备画布。",
|
||||||
treeSearch: "全拓扑搜索网元",
|
treeSearch: "全拓扑搜索网元",
|
||||||
treeSearchPh: "名称 / IP",
|
treeSearchPh: "名称 / IP",
|
||||||
treeSearchEmpty: "没有匹配的网元",
|
treeSearchEmpty: "没有匹配的网元",
|
||||||
|
|
@ -1393,47 +1397,47 @@ const zh = {
|
||||||
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
||||||
treeSearchViewCount: "{{count}} 张图",
|
treeSearchViewCount: "{{count}} 张图",
|
||||||
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
||||||
rootName: "区域",
|
rootName: "根",
|
||||||
selectRegion: "请选择区域",
|
selectRegion: "请选择根或区域",
|
||||||
openMap: "打开本图",
|
openMap: "打开本图",
|
||||||
openRegion: "打开区域",
|
openRegion: "打开",
|
||||||
kindPhysical: "物理拓扑",
|
kindPhysical: "物理拓扑",
|
||||||
kindCustom: "自定义图",
|
kindCustom: "自定义图",
|
||||||
regionNodeHint: "{{count}}P",
|
regionNodeHint: "{{count}}P",
|
||||||
layerNodeHint: "{{count}} 个网元",
|
layerNodeHint: "{{count}} 个网元",
|
||||||
layerNodeEmpty: "点击创建并打开",
|
layerNodeEmpty: "点击创建并打开",
|
||||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
rootCanvasHint: "从左侧选择根进入目录。",
|
||||||
regionCanvasHint: "点击图打开设备画布,或新建自定义图。",
|
regionCanvasHint: "点击根图打开设备画布;可在根图上新建子区域。",
|
||||||
regionBrowseHint: "区域目录:自行添加的画布。点击打开。",
|
regionBrowseHint: "根目录:下方为唯一根图。点击打开。",
|
||||||
browserTitle: "拓扑目录",
|
browserTitle: "拓扑目录",
|
||||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||||
browserRegionsSub: "共 {{count}} 个区域",
|
browserRegionsSub: "共 {{count}} 个",
|
||||||
browserEmptyMaps: "此区域还没有拓扑图。",
|
browserEmptyMaps: "此根下还没有根图。",
|
||||||
addNe: "添加网元",
|
addNe: "添加网元",
|
||||||
backUp: "返回区域",
|
backUp: "返回",
|
||||||
newRegion: "新建区域",
|
newRegion: "新建根",
|
||||||
newRegionName: "新区域",
|
newRegionName: "新根",
|
||||||
newRegionPrompt: "区域名称",
|
newRegionPrompt: "根名称",
|
||||||
newSubRegion: "新建子区域",
|
newSubRegion: "新建子区域",
|
||||||
newSubRegionName: "新子区域",
|
newSubRegionName: "新子区域",
|
||||||
newSubRegionPrompt: "子区域名称",
|
newSubRegionPrompt: "子区域名称",
|
||||||
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||||
subRegionNeedParent: "请先打开一个区域画布,再新建子区域。",
|
subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。",
|
||||||
regionCreated: "区域已创建",
|
regionCreated: "已创建",
|
||||||
regionDeleted: "区域已删除",
|
regionDeleted: "已删除",
|
||||||
deleteRegion: "删除区域",
|
deleteRegion: "删除",
|
||||||
deleteRegionConfirm: "删除区域「{{name}}」?其下画布将一并删除。",
|
deleteRegionConfirm: "删除「{{name}}」?其下画布将一并删除。",
|
||||||
renameRegion: "重命名区域",
|
renameRegion: "重命名",
|
||||||
renameRegionPrompt: "区域名称",
|
renameRegionPrompt: "名称",
|
||||||
regionRenamed: "区域已重命名",
|
regionRenamed: "已重命名",
|
||||||
collapseRegion: "折叠区域",
|
collapseRegion: "折叠",
|
||||||
expandRegion: "展开区域",
|
expandRegion: "展开",
|
||||||
newCustom: "自定义图",
|
newCustom: "自定义图",
|
||||||
newCustomName: "自定义拓扑",
|
newCustomName: "自定义拓扑",
|
||||||
newCustomPrompt: "自定义拓扑图名称",
|
newCustomPrompt: "自定义拓扑图名称",
|
||||||
folderHint: "区域用于分组。新建区域后请自行添加物理拓扑或自定义图。",
|
folderHint: "新建根时自动创建唯一「根图」。子区域请在根图画布上创建。",
|
||||||
folderHintShort: "区域目录",
|
folderHintShort: "根目录",
|
||||||
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
outsidePeers: "图外对端 {{count}} 个(有链路未上图)",
|
||||||
projectNeighbors: "投影邻居上图",
|
projectNeighbors: "投影邻居上图",
|
||||||
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
||||||
|
|
@ -1457,7 +1461,7 @@ const zh = {
|
||||||
renamed: "拓扑图已重命名",
|
renamed: "拓扑图已重命名",
|
||||||
deleteMap: "删除图",
|
deleteMap: "删除图",
|
||||||
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。",
|
||||||
emptyMaps: "还没有区域,先点「新建区域」。",
|
emptyMaps: "还没有根,先点「新建根」。",
|
||||||
palette: "网元库",
|
palette: "网元库",
|
||||||
paletteHint: "勾选后批量添加;也可拖到画布添加单台",
|
paletteHint: "勾选后批量添加;也可拖到画布添加单台",
|
||||||
paletteDragHint: "拖到画布添加",
|
paletteDragHint: "拖到画布添加",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7003,12 +7003,121 @@ pre {
|
||||||
color: var(--nm-muted, #5b6b7c);
|
color: var(--nm-muted, #5b6b7c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-canvas__status {
|
||||||
|
gap: 12px;
|
||||||
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__status p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-top-color: #60a5fa;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: topo-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-loading-spinner--lg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes topo-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 15;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay--loading {
|
||||||
|
background: rgba(11, 18, 32, 0.55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay--empty {
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay--empty .topo-canvas__overlay-title {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__overlay-hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-canvas__refresh {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 21;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-browser__empty--loading {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-browser__empty--loading p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-sidebar .panel__hint {
|
.topo-sidebar .panel__hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--nm-muted);
|
color: var(--nm-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-tree-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-sidebar .input {
|
.topo-sidebar .input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import {
|
||||||
createFabricManualEdge,
|
createFabricManualEdge,
|
||||||
createTopologyFolder,
|
createTopologyFolder,
|
||||||
createTopologyPlaceholder,
|
createTopologyPlaceholder,
|
||||||
createTopologyView,
|
|
||||||
deleteFabricEdges,
|
deleteFabricEdges,
|
||||||
purgePlaceholderFabricNodes,
|
purgePlaceholderFabricNodes,
|
||||||
deleteTopologyFolder,
|
deleteTopologyFolder,
|
||||||
|
|
@ -81,7 +80,6 @@ import type {
|
||||||
TopologyViewEdgeItem,
|
TopologyViewEdgeItem,
|
||||||
TopologyViewGraph,
|
TopologyViewGraph,
|
||||||
TopologyViewNodeItem,
|
TopologyViewNodeItem,
|
||||||
TopologyViewKind,
|
|
||||||
TopologyViewRole,
|
TopologyViewRole,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
||||||
|
|
@ -182,14 +180,18 @@ function isWorldFlatViewName(name: string | undefined | null): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Region row IS the canvas (SBN / manual region / nested sub-region / World drill).
|
* Region row IS the canvas for nested / UME SBN / World drill.
|
||||||
* UME World container is nav-only (hex browse for L2 + world map).
|
* Top-level under Network root (and UME World container) are hex-nav only — same as UME World.
|
||||||
*/
|
*/
|
||||||
function isRegionCanvasFolder(
|
function isRegionCanvasFolder(
|
||||||
folder: TopologyTreeFolderItem | null | undefined,
|
folder: TopologyTreeFolderItem | null | undefined,
|
||||||
|
rootId?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!folder) return false;
|
if (!folder) return false;
|
||||||
if (isUmeWorldContainer(folder)) return false;
|
if (isUmeWorldContainer(folder)) return false;
|
||||||
|
const rid = String(rootId || "").trim();
|
||||||
|
const parent = String(folder.parent_id || "").trim();
|
||||||
|
if (rid && parent === rid) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1183,6 +1185,8 @@ export function TopologyPage() {
|
||||||
const treeQuery = useQuery({
|
const treeQuery = useQuery({
|
||||||
queryKey: queryKeys.topologyTree,
|
queryKey: queryKeys.topologyTree,
|
||||||
queryFn: fetchTopologyTree,
|
queryFn: fetchTopologyTree,
|
||||||
|
staleTime: liveSync ? 0 : 30_000,
|
||||||
|
refetchOnWindowFocus: liveSync,
|
||||||
refetchInterval: liveSync ? 5000 : false,
|
refetchInterval: liveSync ? 5000 : false,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
});
|
});
|
||||||
|
|
@ -1202,6 +1206,8 @@ export function TopologyPage() {
|
||||||
queryKey: queryKeys.topologyGraph(mapId),
|
queryKey: queryKeys.topologyGraph(mapId),
|
||||||
queryFn: () => fetchTopologyGraph(mapId),
|
queryFn: () => fetchTopologyGraph(mapId),
|
||||||
enabled: Boolean(mapId),
|
enabled: Boolean(mapId),
|
||||||
|
staleTime: liveSync ? 0 : 30_000,
|
||||||
|
refetchOnWindowFocus: liveSync,
|
||||||
refetchInterval: liveSync && mapId ? 3000 : false,
|
refetchInterval: liveSync && mapId ? 3000 : false,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
});
|
});
|
||||||
|
|
@ -1284,6 +1290,8 @@ export function TopologyPage() {
|
||||||
}
|
}
|
||||||
}, [mapId, selectedFolderId, treeRoot, regions]);
|
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||||
|
|
||||||
|
const rootFolderId = String(treeRoot?.id || "").trim();
|
||||||
|
|
||||||
const canvasMode = Boolean(mapId);
|
const canvasMode = Boolean(mapId);
|
||||||
|
|
||||||
const activeRegion = useMemo(() => {
|
const activeRegion = useMemo(() => {
|
||||||
|
|
@ -1299,22 +1307,26 @@ export function TopologyPage() {
|
||||||
const browseEntries = useMemo(
|
const browseEntries = useMemo(
|
||||||
(): TopologyTreeViewItem[] => {
|
(): TopologyTreeViewItem[] => {
|
||||||
// Region === canvas: no map subdirectory listing.
|
// Region === canvas: no map subdirectory listing.
|
||||||
// UME World container: hex shows L2 + flat modules (handled separately).
|
// UME World / top-level containers: hex shows modules (handled separately).
|
||||||
if (!activeRegion || isRegionCanvasFolder(activeRegion) || isUmeWorldContainer(activeRegion)) {
|
if (
|
||||||
|
!activeRegion ||
|
||||||
|
isRegionCanvasFolder(activeRegion, rootFolderId) ||
|
||||||
|
isUmeWorldContainer(activeRegion)
|
||||||
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return activeRegion.views || [];
|
return activeRegion.views || [];
|
||||||
},
|
},
|
||||||
[activeRegion],
|
[activeRegion, rootFolderId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hexBrowseRegion = useMemo(() => {
|
const hexBrowseRegion = useMemo(() => {
|
||||||
// Root hex, or UME World container hex (L2 + world map modules).
|
// Root hex, or nav container hex (UME World / top-level manual).
|
||||||
if (!activeRegion) return null;
|
if (!activeRegion) return null;
|
||||||
if (isUmeWorldContainer(activeRegion)) return activeRegion;
|
if (isUmeWorldContainer(activeRegion)) return activeRegion;
|
||||||
if (isRegionCanvasFolder(activeRegion)) return null;
|
if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null;
|
||||||
return activeRegion;
|
return activeRegion;
|
||||||
}, [activeRegion]);
|
}, [activeRegion, rootFolderId]);
|
||||||
|
|
||||||
const umeWorldHexModules = useMemo(() => {
|
const umeWorldHexModules = useMemo(() => {
|
||||||
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
||||||
|
|
@ -1425,7 +1437,7 @@ export function TopologyPage() {
|
||||||
expandFolderPath(folderId);
|
expandFolderPath(folderId);
|
||||||
const folder = findFolderInTree(regions, folderId);
|
const folder = findFolderInTree(regions, folderId);
|
||||||
// Region === canvas: open its primary map (no map subdirectory / hex list).
|
// Region === canvas: open its primary map (no map subdirectory / hex list).
|
||||||
if (isRegionCanvasFolder(folder)) {
|
if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) {
|
||||||
setWorldFocusFolderId("");
|
setWorldFocusFolderId("");
|
||||||
const view = primaryViewOfFolder(folder);
|
const view = primaryViewOfFolder(folder);
|
||||||
if (view) {
|
if (view) {
|
||||||
|
|
@ -1449,6 +1461,7 @@ export function TopologyPage() {
|
||||||
regions,
|
regions,
|
||||||
primaryViewOfFolder,
|
primaryViewOfFolder,
|
||||||
expandFolderPath,
|
expandFolderPath,
|
||||||
|
treeRoot?.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1494,16 +1507,22 @@ export function TopologyPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Leaving other region canvases → root hex.
|
// Leaving other region canvases → parent nav container, else root hex.
|
||||||
if (isRegionCanvasFolder(folder) || isUmeWorldNavFolder(folder)) {
|
if (isRegionCanvasFolder(folder, String(treeRoot?.id || "")) || isUmeWorldNavFolder(folder)) {
|
||||||
setSelectedFolderId("");
|
const parentId = String(folder?.parent_id || "").trim();
|
||||||
|
const parent = parentId ? findFolderInTree(regions, parentId) : null;
|
||||||
|
if (parent && !isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) {
|
||||||
|
setSelectedFolderId(parent.id);
|
||||||
|
} else {
|
||||||
|
setSelectedFolderId("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setWorldFocusFolderId("");
|
setWorldFocusFolderId("");
|
||||||
setMapId("");
|
setMapId("");
|
||||||
clearDirty();
|
clearDirty();
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId]);
|
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId, treeRoot?.id]);
|
||||||
|
|
||||||
// Deep link from classify search: /topology?view=&ne=
|
// Deep link from classify search: /topology?view=&ne=
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1756,10 +1775,9 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Top-level = nav container (UME World pattern): stay on hex browse, do not open canvas.
|
||||||
setSelectedFolderId(folder.id);
|
setSelectedFolderId(folder.id);
|
||||||
const view = hit?.views?.[0];
|
setMapId("");
|
||||||
if (view) setMapId(view.id);
|
|
||||||
else setMapId("");
|
|
||||||
}
|
}
|
||||||
showOk(t("topology.regionCreated"));
|
showOk(t("topology.regionCreated"));
|
||||||
},
|
},
|
||||||
|
|
@ -1787,21 +1805,6 @@ export function TopologyPage() {
|
||||||
[renameRegionMut, t],
|
[renameRegionMut, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createMapMut = useMutation({
|
|
||||||
mutationFn: (input: { folder_id: string; kind: TopologyViewKind; name: string }) =>
|
|
||||||
createTopologyView(input),
|
|
||||||
onSuccess: async (row) => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
|
||||||
clearDirty();
|
|
||||||
if (row.folder_id) setSelectedFolderId(row.folder_id);
|
|
||||||
setMapId(row.id);
|
|
||||||
showOk(t("topology.newMap"));
|
|
||||||
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
|
|
||||||
},
|
|
||||||
onError: (err) => showError(String(err)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMapMut = useMutation({
|
const deleteMapMut = useMutation({
|
||||||
mutationFn: (id: string) => deleteTopologyMap(id),
|
mutationFn: (id: string) => deleteTopologyMap(id),
|
||||||
onSuccess: async (_out, id) => {
|
onSuccess: async (_out, id) => {
|
||||||
|
|
@ -1859,19 +1862,6 @@ export function TopologyPage() {
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]);
|
}, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]);
|
||||||
|
|
||||||
const promptNewCustom = useCallback(
|
|
||||||
(folderId: string) => {
|
|
||||||
const name = window.prompt(t("topology.newCustomPrompt"), t("topology.newCustomName"));
|
|
||||||
if (!name?.trim()) return;
|
|
||||||
createMapMut.mutate({
|
|
||||||
folder_id: folderId,
|
|
||||||
kind: "custom",
|
|
||||||
name: name.trim(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[createMapMut, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderWorldNavFolder = useCallback(
|
const renderWorldNavFolder = useCallback(
|
||||||
(folder: TopologyTreeFolderItem, depth: number): ReactNode => {
|
(folder: TopologyTreeFolderItem, depth: number): ReactNode => {
|
||||||
const kids = folder.children || [];
|
const kids = folder.children || [];
|
||||||
|
|
@ -1881,7 +1871,7 @@ export function TopologyPage() {
|
||||||
const umeNav = isUmeWorldNavFolder(folder);
|
const umeNav = isUmeWorldNavFolder(folder);
|
||||||
// Region === canvas: hide physical/custom view rows; only child regions.
|
// Region === canvas: hide physical/custom view rows; only child regions.
|
||||||
// UME World container: only「完整世界地图」as a sibling view.
|
// UME World container: only「完整世界地图」as a sibling view.
|
||||||
const canvasRegion = isRegionCanvasFolder(folder);
|
const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || ""));
|
||||||
const visibleViews = canvasRegion
|
const visibleViews = canvasRegion
|
||||||
? []
|
? []
|
||||||
: containerFolder
|
: containerFolder
|
||||||
|
|
@ -2120,6 +2110,7 @@ export function TopologyPage() {
|
||||||
deleteFolderMut,
|
deleteFolderMut,
|
||||||
deleteMapMut,
|
deleteMapMut,
|
||||||
t,
|
t,
|
||||||
|
treeRoot?.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3492,6 +3483,15 @@ export function TopologyPage() {
|
||||||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
const outsidePeers = graphQuery.data?.outside_peers || [];
|
||||||
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
||||||
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
||||||
|
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||||
|
const canvasGraphEmpty =
|
||||||
|
Boolean(mapId) &&
|
||||||
|
graphQuery.isSuccess &&
|
||||||
|
!graphQuery.isFetching &&
|
||||||
|
(graphQuery.data?.nodes?.length ?? 0) === 0 &&
|
||||||
|
nodes.length === 0;
|
||||||
|
const canvasGraphRefreshing =
|
||||||
|
Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading;
|
||||||
const truncateBannerText = useMemo(() => {
|
const truncateBannerText = useMemo(() => {
|
||||||
if (!graphTruncated) return "";
|
if (!graphTruncated) return "";
|
||||||
// World map soft-caps viewport nodes; the banner is noise there.
|
// World map soft-caps viewport nodes; the banner is noise there.
|
||||||
|
|
@ -3716,7 +3716,16 @@ export function TopologyPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!treeRoot ? (
|
{!treeRoot ? (
|
||||||
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
|
<p className="panel__hint topo-tree-status" role="status">
|
||||||
|
{treeQuery.isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
{t("topology.treeLoading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("topology.emptyMaps")
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
) : regions.length === 0 ? (
|
) : regions.length === 0 ? (
|
||||||
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -3760,7 +3769,7 @@ export function TopologyPage() {
|
||||||
<>
|
<>
|
||||||
<span className="topo-breadcrumb__sep">/</span>
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
{activeView &&
|
{activeView &&
|
||||||
isRegionCanvasFolder(activeRegion) &&
|
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||||
primaryViewOfFolder(activeRegion)?.id === activeView.id ? (
|
primaryViewOfFolder(activeRegion)?.id === activeView.id ? (
|
||||||
<span className="topo-breadcrumb__current">
|
<span className="topo-breadcrumb__current">
|
||||||
{regionDisplayName(activeRegion)}
|
{regionDisplayName(activeRegion)}
|
||||||
|
|
@ -3780,7 +3789,7 @@ export function TopologyPage() {
|
||||||
{activeView &&
|
{activeView &&
|
||||||
!(
|
!(
|
||||||
activeRegion &&
|
activeRegion &&
|
||||||
isRegionCanvasFolder(activeRegion) &&
|
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||||
primaryViewOfFolder(activeRegion)?.id === activeView.id
|
primaryViewOfFolder(activeRegion)?.id === activeView.id
|
||||||
) ? (
|
) ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -4069,7 +4078,12 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!hexBrowseRegion ? (
|
{!hexBrowseRegion ? (
|
||||||
regions.length === 0 ? (
|
treeQuery.isLoading ? (
|
||||||
|
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
<p>{t("topology.treeLoading")}</p>
|
||||||
|
</div>
|
||||||
|
) : regions.length === 0 ? (
|
||||||
<div className="topo-browser__empty">
|
<div className="topo-browser__empty">
|
||||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||||
<RegionGlyph size={36} />
|
<RegionGlyph size={36} />
|
||||||
|
|
@ -4207,22 +4221,36 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : browseEntries.length === 0 ? (
|
|
||||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="topo-region-hex topo-region-hex--add"
|
|
||||||
onClick={() => promptNewCustom(hexBrowseRegion.id)}
|
|
||||||
title={t("topology.newCustom")}
|
|
||||||
>
|
|
||||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
|
||||||
+
|
|
||||||
</span>
|
|
||||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
|
{(hexBrowseRegion.children || []).map((region, idx) => (
|
||||||
|
<button
|
||||||
|
key={region.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||||
|
selectedFolderId === region.id ? " is-selected" : ""
|
||||||
|
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
|
||||||
|
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
|
||||||
|
}
|
||||||
|
onClick={() => goRegion(region.id)}
|
||||||
|
title={t("topology.openRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
|
||||||
|
<span className="topo-region-hex__meta">
|
||||||
|
{t("topology.regionNodeHint").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(region.views?.length || region.children?.length || 0),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{browseEntries.map((v, idx) => {
|
{browseEntries.map((v, idx) => {
|
||||||
const isPhysical = String(v.kind) === "physical";
|
const isPhysical = String(v.kind) === "physical";
|
||||||
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
||||||
|
|
@ -4237,7 +4265,7 @@ export function TopologyPage() {
|
||||||
onMouseLeave={() =>
|
onMouseLeave={() =>
|
||||||
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
|
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
|
||||||
}
|
}
|
||||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
onClick={() => goCanvas(v.id, hexBrowseRegion.id)}
|
||||||
title={t("topology.openMap")}
|
title={t("topology.openMap")}
|
||||||
>
|
>
|
||||||
<span className="topo-region-hex__icon" aria-hidden="true">
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
|
@ -4250,17 +4278,23 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
{/* Top-level「根」is nav-only with unique「根图」— no extra L2 / custom map. */}
|
||||||
type="button"
|
{String(hexBrowseRegion.parent_id || "") !== rootFolderId ? (
|
||||||
className="topo-region-hex topo-region-hex--add"
|
<button
|
||||||
onClick={() => promptNewCustom(hexBrowseRegion.id)}
|
type="button"
|
||||||
title={t("topology.newCustom")}
|
className="topo-region-hex topo-region-hex--add"
|
||||||
>
|
onClick={() => {
|
||||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
setSelectedFolderId(hexBrowseRegion.id);
|
||||||
+
|
promptNewSubRegion();
|
||||||
</span>
|
}}
|
||||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
title={t("topology.newSubRegion")}
|
||||||
</button>
|
>
|
||||||
|
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__name">{t("topology.newSubRegion")}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4905,8 +4939,29 @@ export function TopologyPage() {
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</TopoDisplayContext.Provider>
|
</TopoDisplayContext.Provider>
|
||||||
) : (
|
) : (
|
||||||
<div className="topo-canvas__empty">{t("topology.treeLoading")}</div>
|
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
<p>{t("topology.treeLoading")}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{treeRoot && canvasGraphLoading ? (
|
||||||
|
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||||
|
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
|
||||||
|
<p className="topo-canvas__overlay-title">{t("topology.graphLoading")}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{treeRoot && canvasGraphEmpty ? (
|
||||||
|
<div className="topo-canvas__overlay topo-canvas__overlay--empty" role="status">
|
||||||
|
<p className="topo-canvas__overlay-title">{t("topology.canvasEmpty")}</p>
|
||||||
|
<p className="topo-canvas__overlay-hint">{t("topology.canvasEmptyHint")}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{treeRoot && canvasGraphRefreshing ? (
|
||||||
|
<div className="topo-canvas__refresh" role="status" aria-live="polite">
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
{t("topology.graphRefreshing")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue