Ship gated eye polish, fabric levels, and collection UI refresh.

Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

138
netx_api/topology_level.py Normal file
View file

@ -0,0 +1,138 @@
"""Fabric topology level (layout rank): major.minor, smaller = closer to external/WAN."""
from __future__ import annotations
import math
from typing import Any
# Preset alias → default level (major.0). Sub-tiers use 1.1, 2.1, …
LEVEL_PRESETS: dict[str, float] = {
"external": 0.0,
"core": 1.0,
"aggregation": 2.0,
"aggregate": 2.0,
"agg": 2.0,
"access": 3.0,
"edge": 4.0,
"cpe": 4.0,
}
# floor(level) → synced role alias (filters / UI chips)
_MAJOR_TO_ROLE: dict[int, str] = {
0: "external",
1: "core",
2: "aggregation",
3: "access",
}
_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", ""}
def normalize_level(value: Any) -> float | None:
"""Parse level; empty/None → None. Snap to one decimal in [0, 99.9]."""
if value is None:
return None
if isinstance(value, str):
s = value.strip().lower()
if not s or s in {"unknown", "null", "none"}:
return None
if s in LEVEL_PRESETS:
return LEVEL_PRESETS[s]
try:
value = float(s)
except ValueError as exc:
raise ValueError("level_invalid") from exc
try:
lv = float(value)
except (TypeError, ValueError) as exc:
raise ValueError("level_invalid") from exc
if not math.isfinite(lv):
raise ValueError("level_invalid")
if lv < 0 or lv > 99.9:
raise ValueError("level_out_of_range")
return round(lv + 1e-9, 1)
def level_major(level: float | None) -> int | None:
if level is None:
return None
return int(math.floor(float(level)))
def level_to_role(level: float | None) -> str:
"""Denormalized role alias for filters; empty when unclassified."""
maj = level_major(level)
if maj is None:
return ""
if maj in _MAJOR_TO_ROLE:
return _MAJOR_TO_ROLE[maj]
if maj >= 4:
return "edge"
return ""
def role_to_level(role: str | None) -> float | None:
r = str(role or "").strip().lower()
if not r or r == "unknown":
return None
if r in LEVEL_PRESETS:
return LEVEL_PRESETS[r]
raise ValueError("role_invalid")
def coerce_level_input(
*,
level: Any = None,
role: Any = None,
level_provided: bool = False,
role_provided: bool = False,
) -> float | None | object:
"""Resolve patch/bulk input.
Returns:
- float | None: concrete level (None clears)
- Ellipsis: neither field provided
"""
if level_provided:
return normalize_level(level)
if role_provided:
return role_to_level(None if role is None else str(role))
return Ellipsis
def format_level(level: float | None) -> str:
if level is None:
return ""
lv = float(level)
if abs(lv - round(lv)) < 1e-9:
return str(int(round(lv)))
return f"{lv:.1f}".rstrip("0").rstrip(".") if "." in f"{lv:.1f}" else f"{lv:.1f}"
def infer_layer_from_level(
level: float | None,
*,
name: str = "",
role: str | None = None,
) -> str:
"""Map to layout layer key: external|core|agg|access|other."""
maj = level_major(level)
if maj is not None:
if maj <= 0:
return "external"
if maj == 1:
return "core"
if maj == 2:
return "agg"
if maj >= 3:
return "access"
# Fallbacks when unclassified
r = str(role or "").strip().lower()
if r in LEVEL_PRESETS:
return infer_layer_from_level(LEVEL_PRESETS[r])
import re
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()]