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

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

284 lines
10 KiB
Python

"""Analyze imported UME topo + fabric for layout strategy."""
from __future__ import annotations
import json
from collections import Counter, defaultdict
from pathlib import Path
from sqlalchemy import text
from netx_api.db import engine
out: dict = {}
with engine.connect() as c:
def q(sql: str, **kw):
return c.execute(text(sql), kw).fetchall()
def scalar(sql: str, **kw):
return c.execute(text(sql), kw).scalar()
out["counts"] = {
"topo_nodes": scalar("select count(*) from ume_topo_node"),
"topo_me": scalar("select count(*) from ume_topo_node where node_type='TOPO_NODE_ME'"),
"topo_sbn": scalar("select count(*) from ume_topo_node where node_type='TOPO_NODE_SBN'"),
"topo_links": scalar("select count(*) from ume_topo_link"),
"fabric_nodes": scalar("select count(*) from topo_fabric_node"),
"fabric_edges": scalar("select count(*) from topo_fabric_edge"),
"inventory": scalar("select count(*) from ume_inventory_ne"),
}
# coordinate extent for ME only
row = q(
"""
select
min(x_pos), max(x_pos), min(y_pos), max(y_pos),
avg(x_pos::float), avg(y_pos::float),
count(*) filter (where x_pos is null or y_pos is null),
count(*) filter (where x_pos=0 and y_pos=0)
from ume_topo_node where node_type='TOPO_NODE_ME'
"""
)[0]
out["me_bbox"] = {
"min_x": row[0], "max_x": row[1], "min_y": row[2], "max_y": row[3],
"avg_x": round(float(row[4] or 0), 1), "avg_y": round(float(row[5] or 0), 1),
"null_xy": row[6], "zero_zero": row[7],
"width": (row[1] or 0) - (row[0] or 0),
"height": (row[3] or 0) - (row[2] or 0),
}
# SBN hierarchy depth + children
sbns = q(
"""
select node_id, user_label, parent_node, x_pos, y_pos
from ume_topo_node where node_type='TOPO_NODE_SBN'
"""
)
me_by_parent = q(
"""
select parent_node, count(*)
from ume_topo_node
where node_type='TOPO_NODE_ME'
group by parent_node
order by count(*) desc
"""
)
out["sbn_count"] = len(sbns)
out["me_per_parent_top20"] = [
{"parent": r[0], "me_count": r[1]} for r in me_by_parent[:20]
]
out["me_parent_buckets"] = {
"parents_gt_2000": sum(1 for r in me_by_parent if r[1] > 2000),
"parents_gt_500": sum(1 for r in me_by_parent if r[1] > 500),
"parents_gt_100": sum(1 for r in me_by_parent if r[1] > 100),
"parents_le_100": sum(1 for r in me_by_parent if r[1] <= 100),
"max_me_in_one_parent": me_by_parent[0][1] if me_by_parent else 0,
}
# resolve parent labels
sbn_label = {r[0]: r[1] for r in sbns}
labeled = []
for r in me_by_parent[:25]:
pid = r[0]
labeled.append({
"parent_id": pid,
"parent_label": sbn_label.get(pid, pid[:40] if pid else ""),
"me_count": r[1],
})
out["me_per_sbn_top25"] = labeled
# SBN tree: roots vs nested
sbn_ids = {r[0] for r in sbns}
roots = []
nested = []
for r in sbns:
nid, label, parent, x, y = r
if parent in sbn_ids:
nested.append({"id": nid, "label": label, "parent": parent, "parent_label": sbn_label.get(parent, "")})
else:
roots.append({"id": nid, "label": label, "parent": parent, "x": x, "y": y})
out["sbn_roots"] = roots
out["sbn_nested_count"] = len(nested)
out["sbn_nested_sample"] = nested[:30]
# ME count under each root SBN (walk parents)
parent_of = {r[0]: r[2] for r in sbns}
me_parent_map = {r[0]: r[1] for r in me_by_parent} # wrong - me_by_parent is list of (parent, count)
# build node->parent for all nodes
all_parents = q("select node_id, parent_node, node_type from ume_topo_node")
pmap = {r[0]: r[1] for r in all_parents}
ntype = {r[0]: r[2] for r in all_parents}
def root_sbn(nid: str) -> str:
seen = set()
cur = nid
last_sbn = nid if ntype.get(nid) == "TOPO_NODE_SBN" else ""
# climb from parent
cur = pmap.get(nid, "")
while cur and cur not in seen:
seen.add(cur)
if ntype.get(cur) == "TOPO_NODE_SBN":
last_sbn = cur
# stop at non-uuid / MD= root
if cur not in pmap and cur not in sbn_ids:
break
nxt = pmap.get(cur)
if not nxt or nxt == cur:
break
cur = nxt
return last_sbn or "unknown"
root_me_counts: Counter = Counter()
for r in q("select node_id from ume_topo_node where node_type='TOPO_NODE_ME'"):
# climb from ME's parent
parent = pmap.get(r[0], "")
rs = parent
# find topmost SBN
seen = set()
cur = parent
top = parent if parent in sbn_ids else ""
while cur and cur not in seen:
seen.add(cur)
if cur in sbn_ids:
top = cur
cur = pmap.get(cur, "")
if not cur:
break
# prefer root among SBN chain: keep climbing while parent is SBN
while top and parent_of.get(top) in sbn_ids:
top = parent_of[top]
root_me_counts[top or "no_sbn"] += 1
out["me_per_root_sbn"] = [
{"root_id": k, "label": sbn_label.get(k, k), "me_count": v}
for k, v in root_me_counts.most_common()
]
# per-root bbox for ME
root_bbox = {}
for r in q(
"select node_id, parent_node, x_pos, y_pos from ume_topo_node where node_type='TOPO_NODE_ME' and x_pos is not null"
):
nid, parent, x, y = r
top = parent if parent in sbn_ids else ""
seen = set()
cur = parent
while cur and cur not in seen:
seen.add(cur)
if cur in sbn_ids:
top = cur
cur = pmap.get(cur, "")
while top and parent_of.get(top) in sbn_ids:
top = parent_of[top]
key = top or "no_sbn"
b = root_bbox.setdefault(key, {"min_x": x, "max_x": x, "min_y": y, "max_y": y, "n": 0})
b["min_x"] = min(b["min_x"], x)
b["max_x"] = max(b["max_x"], x)
b["min_y"] = min(b["min_y"], y)
b["max_y"] = max(b["max_y"], y)
b["n"] += 1
out["root_sbn_bbox"] = [
{
"root_id": k,
"label": sbn_label.get(k, k),
"n": v["n"],
"width": v["max_x"] - v["min_x"],
"height": v["max_y"] - v["min_y"],
"min_x": v["min_x"],
"max_x": v["max_x"],
"min_y": v["min_y"],
"max_y": v["max_y"],
}
for k, v in sorted(root_bbox.items(), key=lambda kv: -kv[1]["n"])
]
# grid density: how many ME in 100x100 cells
cells = Counter()
for r in q(
"select x_pos, y_pos from ume_topo_node where node_type='TOPO_NODE_ME' and x_pos is not null"
):
cells[(r[0] // 100, r[1] // 100)] += 1
dens = sorted(cells.values(), reverse=True)
out["grid100_density"] = {
"cells": len(cells),
"max_per_cell": dens[0] if dens else 0,
"p95": dens[int(len(dens) * 0.05)] if dens else 0,
"median": dens[len(dens) // 2] if dens else 0,
"cells_gt_50": sum(1 for d in dens if d > 50),
"cells_gt_20": sum(1 for d in dens if d > 20),
}
# UME links vs fabric: endpoint overlap
out["link_stats"] = {
"ume_links": scalar("select count(*) from ume_topo_link"),
"ume_connected": scalar(
"select count(*) from ume_topo_link where connection_status='Connected'"
),
"unique_a_ptp": scalar("select count(distinct a_ptp) from ume_topo_link"),
"links_both_in_fabric": scalar(
"""
select count(*) from ume_topo_link l
join topo_fabric_node a on a.ume_ne_id=l.a_ume_ne_id
join topo_fabric_node z on z.ume_ne_id=l.z_ume_ne_id
"""
),
}
# approximate undirected edge key match UME vs LLDP fabric
# fabric ports are like xxvgei-1/1/0/32; ume ptp is /p=1_28
out["port_sample_ume"] = [
dict(a_ptp=r[0], z_ptp=r[1], a_ne=r[2], z_ne=r[3])
for r in q(
"select a_ptp, z_ptp, a_ume_ne_id, z_ume_ne_id from ume_topo_link limit 5"
)
]
out["port_sample_fabric"] = [
dict(a_port=r[0], b_port=r[1])
for r in q("select a_port, b_port from topo_fabric_edge limit 5")
]
# same NE-pair undirected overlap ignoring ports
ume_pairs = {
frozenset((r[0], r[1]))
for r in q(
"select a_ume_ne_id, z_ume_ne_id from ume_topo_link where a_ume_ne_id<>'' and z_ume_ne_id<>''"
)
if r[0] != r[1]
}
fab_pairs = set()
for r in q(
"""
select a.ume_ne_id, b.ume_ne_id
from topo_fabric_edge e
join topo_fabric_node a on a.id=e.a_node_id
join topo_fabric_node b on b.id=e.b_node_id
where a.ume_ne_id is not null and b.ume_ne_id is not null
"""
):
if r[0] and r[1] and r[0] != r[1]:
fab_pairs.add(frozenset((r[0], r[1])))
inter = ume_pairs & fab_pairs
out["ne_pair_overlap"] = {
"ume_undirected_pairs": len(ume_pairs),
"fabric_undirected_pairs": len(fab_pairs),
"intersection": len(inter),
"ume_only": len(ume_pairs - fab_pairs),
"fabric_only": len(fab_pairs - ume_pairs),
}
# inventory vendor / device_level under topo
out["inventory_levels"] = [
{"device_level": r[0], "n": r[1]}
for r in q(
"select coalesce(nullif(device_level,''),'?'), count(*) from ume_inventory_ne group by 1 order by 2 desc"
)
]
Path("ume/_topo_analysis.json").write_text(
json.dumps(out, ensure_ascii=False, indent=2, default=str), encoding="utf-8"
)
print("wrote ume/_topo_analysis.json")
for k in ("counts", "me_bbox", "me_parent_buckets", "ne_pair_overlap", "grid100_density"):
print(k, json.dumps(out[k], ensure_ascii=False, default=str))
print("roots", len(out["sbn_roots"]), "nested_sbn", out["sbn_nested_count"])
print("me_per_root", json.dumps(out["me_per_root_sbn"][:15], ensure_ascii=False))