mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 17:20:44 +08:00
- 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>
209 lines
6.1 KiB
Python
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
|