netx/netx_api/mcp_remote/topology/layout_ops/level_util.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

209 lines
6.1 KiB
Python

"""Map fabric level / role / name → layout layer key; level y-bands."""
from __future__ import annotations
import math
import re
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_ROLE_TO_LAYER = {
"external": "external",
"core": "core",
"cn": "core",
"aggregation": "agg",
"aggregate": "agg",
"agg": "agg",
"an": "agg",
"access": "access",
"en": "access",
"edge": "access",
"cpe": "access",
}
# Top → bottom on canvas (smaller fabric level sits higher).
_BAND_ORDER = ("external", "core", "agg", "access", "other")
def _parse_level(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, str):
s = value.strip()
if not s:
return None
try:
value = float(s)
except ValueError:
return None
try:
lv = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(lv):
return None
return lv
def infer_layer(
name: str,
role: str | None = None,
level: float | None = None,
) -> str:
"""Map level / role / name token → external|core|agg|access|other."""
lv = _parse_level(level)
if lv is not None:
maj = int(math.floor(lv))
if maj <= 0:
return "external"
if maj == 1:
return "core"
if maj == 2:
return "agg"
return "access"
r = str(role or "").strip().lower()
if r in _ROLE_TO_LAYER:
return _ROLE_TO_LAYER[r]
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
if not m:
return "other"
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
def apply_level_bands(
state: LayoutState,
params: LayoutParams | None = None,
*,
y0: float = 120.0,
band_gap: float = 320.0,
pitch: float | None = None,
preserve_x: bool = True,
layers: tuple[str, ...] | None = None,
max_per_row: int = 24,
row_gap: float | None = None,
) -> OpResult:
"""Snap nodes into horizontal bands by layer (external→…→access).
Large bands wrap into multiple rows (``max_per_row``) so hundreds of
access nodes are not crushed onto one y-line. Within a row, x is either
kept (preserve_x) then gently de-overlapped to ``pitch``, or re-spaced
by name order.
"""
params = params or LayoutParams()
step = float(pitch if pitch is not None else max(params.pitch, 200.0))
rgap = float(row_gap if row_gap is not None else max(step * 0.85, 170.0))
per_row = max(4, int(max_per_row or 24))
order = tuple(layers) if layers else _BAND_ORDER
by: dict[str, list[str]] = {ly: [] for ly in order}
for nid in state.positions:
ly = state.layers.get(nid) or "other"
if ly not in by:
ly = "other"
by.setdefault(ly, [])
by[ly].append(nid)
pos = dict(state.positions)
moved: set[str] = set()
band_notes: list[dict[str, Any]] = []
y_cursor = float(y0)
def _place_row(ids: list[str], y: float) -> None:
nonlocal moved
if not ids:
return
if preserve_x:
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
xs = [float(pos[n][0]) for n in ids]
# Enforce min pitch left→right without reversing order.
fixed: list[float] = []
for i, x in enumerate(xs):
if i == 0:
fixed.append(x)
else:
fixed.append(max(x, fixed[-1] + step))
for n, x in zip(ids, fixed):
nxt = (x, y)
if nxt != pos[n]:
moved.add(n)
pos[n] = nxt
else:
ids = sorted(ids, key=lambda n: state.names.get(n, n))
for i, n in enumerate(ids):
nxt = (40.0 + i * step, y)
if nxt != pos.get(n):
moved.add(n)
pos[n] = nxt
for ly in order:
ids = by.get(ly) or []
if not ids:
continue
if preserve_x:
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
else:
ids = sorted(ids, key=lambda n: state.names.get(n, n))
rows = [ids[i : i + per_row] for i in range(0, len(ids), per_row)]
y_band0 = y_cursor
for ri, row in enumerate(rows):
_place_row(row, y_cursor + ri * rgap)
band_h = max(0, len(rows) - 1) * rgap
band_notes.append(
{
"layer": ly,
"count": len(ids),
"y": y_band0,
"rows": len(rows),
"y_max": y_band0 + band_h,
}
)
y_cursor = y_band0 + band_h + float(band_gap)
out = state.copy()
out.positions = pos
out.meta = dict(out.meta or {})
out.meta["level_bands"] = {
"bands": band_notes,
"preserve_x": preserve_x,
"max_per_row": per_row,
}
return OpResult(
state=out,
moved=moved,
op="level_bands",
params={
"bands": band_notes,
"preserve_x": preserve_x,
"y0": y0,
"band_gap": band_gap,
"pitch": step,
"max_per_row": per_row,
"moved_n": len(moved),
},
note=f"level_bands:{len(band_notes)} bands moved={len(moved)}",
)
def level_bands_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
for key, cast in (
("y0", float),
("band_gap", float),
("pitch", float),
("row_gap", float),
("max_per_row", int),
):
if overrides.get(key) is not None:
try:
out[key] = cast(overrides[key])
except (TypeError, ValueError):
pass
if "preserve_x" in overrides:
out["preserve_x"] = bool(overrides.get("preserve_x"))
raw = overrides.get("layers")
if isinstance(raw, (list, tuple)) and raw:
out["layers"] = tuple(str(x) for x in raw if str(x).strip())
return out