mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
138
netx_api/topology_level.py
Normal file
138
netx_api/topology_level.py
Normal 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()]
|
||||
Loading…
Add table
Add a link
Reference in a new issue