mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Harden UME/LLDP link ports and canvas styles around UME authority.
Resolve ports from EQ+PTP first, enrich from LLDP when compatible, miss only on valid LLDP evidence, and paint UME solid blue / LLDP dashed / missing red. Also localize new root maps as Root map in English. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
77832b271d
commit
137597cd25
16 changed files with 736 additions and 152 deletions
|
|
@ -251,7 +251,12 @@ def _run_discover_job(
|
|||
scanned_ok: set[str] = {
|
||||
str(it.fabric_node_id)
|
||||
for it in prior_items
|
||||
if it.ok and str(it.fabric_node_id or "").strip()
|
||||
# Resume: only prior items with trustworthy LLDP evidence can miss-judge.
|
||||
if it.ok
|
||||
and str(it.fabric_node_id or "").strip()
|
||||
and not bool(it.parser_stub)
|
||||
and str(it.error or "").strip()
|
||||
not in {"parser_stub", "empty_cli_output", "vendor_or_device_type_required"}
|
||||
}
|
||||
touched_edges: set[str] = set()
|
||||
# After worker death we lost in-memory touched edges — skip miss to avoid false marks.
|
||||
|
|
@ -371,8 +376,21 @@ def _run_discover_job(
|
|||
added, updated = _record_item(
|
||||
db, job, job_id, result, added=added, updated=updated
|
||||
)
|
||||
if result.get("ok") and result.get("scanned_node_id"):
|
||||
if (
|
||||
result.get("ok")
|
||||
and result.get("lldp_evidence_ok")
|
||||
and result.get("scanned_node_id")
|
||||
):
|
||||
scanned_ok.add(str(result["scanned_node_id"]))
|
||||
elif result.get("ok") and result.get("scanned_node_id"):
|
||||
# Backward-compatible: older workers only set scanned_node_id
|
||||
# when evidence was implied; still require no stub/empty errors.
|
||||
err = str(result.get("error") or "").strip()
|
||||
if not result.get("parser_stub") and err not in {
|
||||
"parser_stub",
|
||||
"empty_cli_output",
|
||||
}:
|
||||
scanned_ok.add(str(result["scanned_node_id"]))
|
||||
for eid in result.get("touched_edge_ids") or []:
|
||||
touched_edges.add(str(eid))
|
||||
for eid in result.get("replaced_edge_ids") or []:
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ def _discover_one_target(
|
|||
device_type=device_type,
|
||||
command=cmd,
|
||||
)
|
||||
stub_flag = bool(is_stub and raw.strip() and not hits)
|
||||
# Stub / empty CLI body = maybe logged in, but not trustworthy LLDP evidence.
|
||||
# Miss marking requires a real parser + non-empty command output.
|
||||
stub_flag = bool(is_stub)
|
||||
evidence_ok = (not stub_flag) and bool(raw.strip())
|
||||
|
||||
apply_out = _apply_discover_hits(
|
||||
db,
|
||||
|
|
@ -141,10 +144,17 @@ def _discover_one_target(
|
|||
"command": cmd,
|
||||
"parser_key": pkey,
|
||||
"parser_stub": stub_flag,
|
||||
"lldp_evidence_ok": False,
|
||||
"error": str(apply_out.get("error") or "apply_failed")[:500],
|
||||
"raw_preview": _raw_preview(raw),
|
||||
}
|
||||
|
||||
err = ""
|
||||
if stub_flag:
|
||||
err = "parser_stub"
|
||||
elif not raw.strip():
|
||||
err = "empty_cli_output"
|
||||
|
||||
return {
|
||||
**base,
|
||||
"ok": True,
|
||||
|
|
@ -156,11 +166,13 @@ def _discover_one_target(
|
|||
"unmatched": list(apply_out.get("unmatched") or []),
|
||||
"parser_key": pkey,
|
||||
"parser_stub": stub_flag,
|
||||
"error": "parser_stub" if stub_flag else "",
|
||||
"lldp_evidence_ok": evidence_ok,
|
||||
"error": err,
|
||||
"raw_preview": _raw_preview(raw),
|
||||
"touched_edge_ids": list(apply_out.get("touched_edge_ids") or []),
|
||||
"replaced_edge_ids": list(apply_out.get("replaced_edge_ids") or []),
|
||||
"scanned_node_id": fabric_node_id,
|
||||
# Only evidence-ok scans participate in miss/purge judgment.
|
||||
"scanned_node_id": fabric_node_id if evidence_ok else "",
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
|
|
|
|||
|
|
@ -73,15 +73,22 @@ def upsert_fabric_edge(
|
|||
layer: str = "physical",
|
||||
now: datetime | None = None,
|
||||
) -> tuple[TopoFabricEdge | None, str]:
|
||||
"""Return (edge, action) where action is added|updated|kept_manual|skipped_self_loop.
|
||||
"""Return (edge, action) where action is added|updated|kept_manual|skipped_*.
|
||||
|
||||
Self-loops are skipped (``(None, \"skipped_self_loop\")``) so LLDP discovery can
|
||||
ignore a device advertising itself without aborting the rest of the scan.
|
||||
Manual edge APIs should treat that action as a client error.
|
||||
|
||||
``source`` may be ``lldp`` | ``manual`` | ``ume``. Provenance is tracked in
|
||||
``attrs.sources`` (union). Primary ``source`` column prefers manual > lldp > ume.
|
||||
``attrs.sources`` (union). Primary ``source`` prefers manual > ume > lldp
|
||||
(UME is authority when present; LLDP is the no-UME discovery path).
|
||||
|
||||
LLDP against an existing compatible edge upgrades bare UME ports to media-
|
||||
prefixed names. When no compatible edge exists, LLDP may still create a new
|
||||
edge even if both ends are UME inventory NEs (UME dump may omit the link).
|
||||
"""
|
||||
from .ume_port_normalize import prefer_richer_ifname
|
||||
|
||||
now = now or _utcnow()
|
||||
a, b, ap, bp = _normalize_endpoints(a_node_id, b_node_id, a_port, b_port)
|
||||
if a == b:
|
||||
|
|
@ -92,6 +99,7 @@ def upsert_fabric_edge(
|
|||
src = "lldp"
|
||||
if src not in {"lldp", "manual", "ume"}:
|
||||
raise HTTPException(status_code=400, detail="invalid_edge_source")
|
||||
|
||||
row = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
|
|
@ -104,45 +112,64 @@ def upsert_fabric_edge(
|
|||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = TopoFabricEdge(
|
||||
id=uuid4().hex,
|
||||
layer=layer_v,
|
||||
a_node_id=a,
|
||||
b_node_id=b,
|
||||
a_port=ap,
|
||||
b_port=bp,
|
||||
source=src,
|
||||
status="active",
|
||||
attrs={"sources": [src]},
|
||||
discovered_at=now if src in {"lldp", "ume"} else None,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
# Compatible ports (bare UME ↔ LLDP media) — merge, don't duplicate.
|
||||
row = find_fabric_edge_compatible(
|
||||
db, a_node_id=a, b_node_id=b, a_port=ap, b_port=bp, layer=layer_v
|
||||
)
|
||||
if row is not None:
|
||||
new_ap = prefer_richer_ifname(row.a_port, ap)
|
||||
new_bp = prefer_richer_ifname(row.b_port, bp)
|
||||
if new_ap != normalize_ifname(row.a_port) or new_bp != normalize_ifname(row.b_port):
|
||||
row.a_port = new_ap[:128]
|
||||
row.b_port = new_bp[:128]
|
||||
else:
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = TopoFabricEdge(
|
||||
id=uuid4().hex,
|
||||
layer=layer_v,
|
||||
a_node_id=a,
|
||||
b_node_id=b,
|
||||
a_port=ap,
|
||||
b_port=bp,
|
||||
source=src,
|
||||
status="active",
|
||||
attrs={"sources": [src]},
|
||||
discovered_at=now if src in {"lldp", "ume"} else None,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row, "added"
|
||||
except IntegrityError:
|
||||
row = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.layer == layer_v,
|
||||
TopoFabricEdge.a_node_id == a,
|
||||
TopoFabricEdge.b_node_id == b,
|
||||
TopoFabricEdge.a_port == ap,
|
||||
TopoFabricEdge.b_port == bp,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row, "added"
|
||||
except IntegrityError:
|
||||
row = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.layer == layer_v,
|
||||
TopoFabricEdge.a_node_id == a,
|
||||
TopoFabricEdge.b_node_id == b,
|
||||
TopoFabricEdge.a_port == ap,
|
||||
TopoFabricEdge.b_port == bp,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise
|
||||
if row is None:
|
||||
raise
|
||||
|
||||
# Manual edges keep primary source=manual; still record other sources.
|
||||
attrs = _clear_miss_attrs(_edge_attrs(row))
|
||||
sources = _sources_from_attrs(attrs, fallback=row.source or src)
|
||||
sources.add(src)
|
||||
attrs["sources"] = sorted(sources)
|
||||
# Enrich ports when a richer compatible name arrives (typically LLDP).
|
||||
if src in {"lldp", "ume"}:
|
||||
new_ap = prefer_richer_ifname(row.a_port, ap)
|
||||
new_bp = prefer_richer_ifname(row.b_port, bp)
|
||||
if new_ap != normalize_ifname(row.a_port) or new_bp != normalize_ifname(row.b_port):
|
||||
row.a_port = new_ap[:128]
|
||||
row.b_port = new_bp[:128]
|
||||
if (row.source or "") == "manual" and src != "manual":
|
||||
row.attrs = attrs
|
||||
row.status = "active"
|
||||
|
|
@ -176,12 +203,13 @@ def _sources_from_attrs(attrs: dict[str, Any], *, fallback: str = "") -> set[str
|
|||
|
||||
|
||||
def _primary_source(sources: set[str]) -> str:
|
||||
"""Paint / authority order: manual > ume > lldp."""
|
||||
if "manual" in sources:
|
||||
return "manual"
|
||||
if "lldp" in sources:
|
||||
return "lldp"
|
||||
if "ume" in sources:
|
||||
return "ume"
|
||||
if "lldp" in sources:
|
||||
return "lldp"
|
||||
return "lldp"
|
||||
|
||||
|
||||
|
|
@ -302,12 +330,14 @@ def _mark_replaced_port_peers(
|
|||
.all()
|
||||
)
|
||||
handled: list[str] = []
|
||||
from .ume_port_normalize import port_keys_compatible
|
||||
|
||||
for e in candidates:
|
||||
if e.a_node_id == self_id:
|
||||
e_local, e_peer = e.a_port or "", e.b_node_id
|
||||
else:
|
||||
e_local, e_peer = e.b_port or "", e.a_node_id
|
||||
if normalize_ifname(e_local) != lp:
|
||||
if not port_keys_compatible(e_local, lp):
|
||||
continue
|
||||
if e_peer == peer_id:
|
||||
continue
|
||||
|
|
@ -343,7 +373,7 @@ def _mark_replaced_port_peers(
|
|||
)
|
||||
for se in survivor:
|
||||
se_local = se.a_port if se.a_node_id == self_id else se.b_port
|
||||
if normalize_ifname(se_local or "") != lp:
|
||||
if not port_keys_compatible(se_local or "", lp):
|
||||
continue
|
||||
se.status = "active"
|
||||
se.attrs = _clear_miss_attrs(_edge_attrs(se))
|
||||
|
|
@ -364,7 +394,14 @@ def _apply_missing_and_purge(
|
|||
touched_edge_ids: set[str],
|
||||
now: datetime | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Rule A: endpoint scanned OK but edge absent → missing; purge after N cycles.
|
||||
"""Rule A: endpoint scanned with valid LLDP evidence but edge absent → missing.
|
||||
|
||||
``scanned_ok`` must only contain nodes that actually logged in and produced
|
||||
trustworthy LLDP output (not login failures / stub parsers / empty CLI).
|
||||
|
||||
Dual-source (ume+lldp): drop ``lldp`` provenance but keep the edge active under
|
||||
UME. Pure UME edges are left untouched. Pure LLDP edges may be miss-marked.
|
||||
Manual edges are excluded by the query filter.
|
||||
|
||||
Returns (newly_marked_missing, purged).
|
||||
"""
|
||||
|
|
@ -390,6 +427,20 @@ def _apply_missing_and_purge(
|
|||
continue
|
||||
if e.a_node_id not in scanned_ok and e.b_node_id not in scanned_ok:
|
||||
continue
|
||||
attrs = _edge_attrs(e)
|
||||
sources = _sources_from_attrs(attrs, fallback=e.source or "")
|
||||
has_ume = "ume" in sources or str(e.source or "").strip().lower() == "ume"
|
||||
if has_ume:
|
||||
# UME still claims the link — strip stale LLDP mark only.
|
||||
if "lldp" in sources:
|
||||
sources.discard("lldp")
|
||||
attrs["sources"] = sorted(sources)
|
||||
e.attrs = attrs
|
||||
if str(e.source or "").strip().lower() != "manual":
|
||||
e.source = _primary_source(sources)
|
||||
e.status = "active"
|
||||
e.updated_at = now
|
||||
continue
|
||||
if _set_edge_missing(e, now):
|
||||
newly_marked += 1
|
||||
if _purge_edge_if_due(db, e):
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ class TopologyFolderCreate(BaseModel):
|
|||
kind: str = Field(default="region", description="region only from API")
|
||||
parent_id: str | None = None
|
||||
sort_order: int = 0
|
||||
# UI locale for auto-spawned「根图」/ Root map label (zh | en).
|
||||
locale: str = Field(default="", max_length=16)
|
||||
|
||||
|
||||
class TopologyFolderUpdate(BaseModel):
|
||||
|
|
@ -300,6 +302,8 @@ class ViewEdgeOut(BaseModel):
|
|||
stroke_width: int = 0
|
||||
line_style: str = ""
|
||||
discovered_at: datetime | None = None
|
||||
# UME links without EQ+PTP: show this instead of a_port↔b_port.
|
||||
display_label: str = ""
|
||||
|
||||
|
||||
class WorldScatterPointOut(BaseModel):
|
||||
|
|
|
|||
|
|
@ -261,6 +261,13 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
src = str(e.source or "lldp").strip().lower() or "lldp"
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
attrs = e.attrs if isinstance(e.attrs, dict) else {}
|
||||
display_label = str(attrs.get("display_label") or "").strip()
|
||||
if not display_label and (
|
||||
str(e.a_port or "").lower().startswith("label:")
|
||||
or str(e.b_port or "").lower().startswith("label:")
|
||||
):
|
||||
display_label = str(attrs.get("ume_link_id") or "")
|
||||
edges_out.append(
|
||||
ViewEdgeOut(
|
||||
id=e.id,
|
||||
|
|
@ -275,6 +282,7 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
|||
stroke_width=int(st.stroke_width if st else 0) or 0,
|
||||
line_style=(st.line_style if st else "") or "",
|
||||
discovered_at=e.discovered_at,
|
||||
display_label=display_label[:512],
|
||||
)
|
||||
)
|
||||
outside = _outside_peers_for_view(db, view, member_ids=set(fids), layer=layer)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,21 @@ from .topology_common import (
|
|||
|
||||
# Manual top-level「根」auto-spawns this unique L2 canvas (mirrors UME World / World).
|
||||
MANUAL_ROOT_MAP_NAME = "根图"
|
||||
MANUAL_ROOT_MAP_NAME_EN = "Root map"
|
||||
MANUAL_ROOT_MAP_NAMES = frozenset({MANUAL_ROOT_MAP_NAME, MANUAL_ROOT_MAP_NAME_EN})
|
||||
|
||||
|
||||
def manual_root_map_name(locale: str | None = None) -> str:
|
||||
loc = str(locale or "").strip().lower()
|
||||
if loc.startswith("en"):
|
||||
return MANUAL_ROOT_MAP_NAME_EN
|
||||
return MANUAL_ROOT_MAP_NAME
|
||||
|
||||
|
||||
def is_manual_root_map_name(name: str | None) -> bool:
|
||||
return str(name or "").strip() in MANUAL_ROOT_MAP_NAMES
|
||||
|
||||
|
||||
from .topology_fabric import (
|
||||
_edge_out,
|
||||
_fabric_match_score,
|
||||
|
|
@ -166,7 +181,11 @@ def ensure_region_physical_view(db: Session, folder_id: str, *, commit: bool = T
|
|||
|
||||
|
||||
def _ensure_manual_root_map(
|
||||
db: Session, top: TopoFolder, *, now: Any | None = None
|
||||
db: Session,
|
||||
top: TopoFolder,
|
||||
*,
|
||||
now: Any | None = None,
|
||||
locale: str | None = None,
|
||||
) -> tuple[TopoFolder, bool]:
|
||||
"""Ensure a top-level manual「根」has unique L2「根图」; migrate legacy L1 canvas onto it.
|
||||
|
||||
|
|
@ -175,19 +194,23 @@ def _ensure_manual_root_map(
|
|||
"""
|
||||
stamp = now or _utcnow()
|
||||
changed = False
|
||||
map_name = manual_root_map_name(locale)
|
||||
kids = (
|
||||
db.query(TopoFolder)
|
||||
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||
.order_by(TopoFolder.sort_order.asc(), TopoFolder.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
root_map = next((k for k in kids if str(k.name or "") == MANUAL_ROOT_MAP_NAME), None)
|
||||
root_map = next((k for k in kids if is_manual_root_map_name(k.name)), None)
|
||||
if root_map is None:
|
||||
# Prefer an existing system child (renamed root map) before creating another.
|
||||
root_map = next((k for k in kids if bool(k.is_system)), None)
|
||||
if root_map is None:
|
||||
root_map = TopoFolder(
|
||||
id=uuid4().hex,
|
||||
parent_id=top.id,
|
||||
kind="region",
|
||||
name=MANUAL_ROOT_MAP_NAME,
|
||||
name=map_name,
|
||||
sort_order=0,
|
||||
is_system=True,
|
||||
created_at=stamp,
|
||||
|
|
@ -226,7 +249,7 @@ def _ensure_manual_root_map(
|
|||
folder_id=root_map.id,
|
||||
kind=VIEW_KIND_PHYSICAL,
|
||||
role="core",
|
||||
name=MANUAL_ROOT_MAP_NAME,
|
||||
name=str(root_map.name or map_name)[:256],
|
||||
remark="",
|
||||
sort_order=0,
|
||||
filter={},
|
||||
|
|
@ -272,15 +295,18 @@ def _heal_manual_root_canvases(db: Session, root: TopoFolder) -> bool:
|
|||
.filter(TopoFolder.parent_id == top.id, TopoFolder.kind == "region")
|
||||
.all()
|
||||
)
|
||||
has_root_map = any(str(k.name or "") == MANUAL_ROOT_MAP_NAME for k in kids)
|
||||
has_root_map = any(is_manual_root_map_name(k.name) or bool(k.is_system) for k in kids)
|
||||
l1_view_cnt = (
|
||||
db.query(func.count(TopoView.id)).filter(TopoView.folder_id == top.id).scalar() or 0
|
||||
)
|
||||
# Already correct: unique 根图, no stray L1 views.
|
||||
if has_root_map and int(l1_view_cnt) == 0:
|
||||
# Still mark 根图 system if needed.
|
||||
rm = next(k for k in kids if str(k.name or "") == MANUAL_ROOT_MAP_NAME)
|
||||
if not bool(rm.is_system):
|
||||
rm = next(
|
||||
(k for k in kids if is_manual_root_map_name(k.name) or bool(k.is_system)),
|
||||
None,
|
||||
)
|
||||
if rm is not None and not bool(rm.is_system):
|
||||
rm.is_system = True
|
||||
rm.updated_at = now
|
||||
changed = True
|
||||
|
|
@ -417,10 +443,11 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
|||
and str(parent.parent_id or "") == str(root.id)
|
||||
and not is_ume_world_container(parent)
|
||||
):
|
||||
root_map, _ = _ensure_manual_root_map(db, parent)
|
||||
root_map, _ = _ensure_manual_root_map(db, parent, locale=body.locale)
|
||||
parent = root_map
|
||||
parent_kind = "region"
|
||||
now = _utcnow()
|
||||
map_name = manual_root_map_name(body.locale)
|
||||
row = TopoFolder(
|
||||
id=uuid4().hex,
|
||||
parent_id=parent.id,
|
||||
|
|
@ -440,7 +467,7 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
|||
id=uuid4().hex,
|
||||
parent_id=row.id,
|
||||
kind="region",
|
||||
name=MANUAL_ROOT_MAP_NAME,
|
||||
name=map_name,
|
||||
sort_order=0,
|
||||
is_system=True,
|
||||
created_at=now,
|
||||
|
|
@ -454,7 +481,7 @@ def create_folder(db: Session, body: TopologyFolderCreate) -> TopologyFolderOut:
|
|||
folder_id=root_map.id,
|
||||
kind=VIEW_KIND_PHYSICAL,
|
||||
role="core",
|
||||
name=MANUAL_ROOT_MAP_NAME,
|
||||
name=map_name,
|
||||
remark="",
|
||||
sort_order=0,
|
||||
filter={},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
"""Normalize UME TP DN / userLabel into CLI-like ifnames for Fabric merge."""
|
||||
"""Normalize UME TP DN / userLabel into CLI-like ifnames for Fabric merge.
|
||||
|
||||
Authority order for each link end:
|
||||
1. EQ+PTP → numeric ``shelf/slot/…/port`` (same as LLDP ``x/x/x/x``)
|
||||
2. userLabel media token whose numeric tail matches that suffix (``xxvgei-…``)
|
||||
3. ``[slot]_ETH:N`` / ``NGE:N`` forms that match the suffix
|
||||
4. bare TP suffix (still LLDP-compatible via ``port_keys_compatible``)
|
||||
|
||||
Never assign A/Z from label token order alone — labels often embed the
|
||||
*neighbor* port first and swap the two ends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -11,8 +21,12 @@ _IFNAME_TOKEN_RE = re.compile(
|
|||
r"(?i)(?<![A-Za-z0-9])((?:xxvgei|xgei|cgei|xlgei|lgei|gei|gi|eth)"
|
||||
r"(?:-[\d./]+|[\d./]+))"
|
||||
)
|
||||
_ETH_COLON_RE = re.compile(r"(?i)\bETH:(\d+)\b")
|
||||
_BRACKET_SLOT_RE = re.compile(r"\[(\d+/\d+/\d+)\]")
|
||||
# `_ETH:28` — `\b` fails after `_` (word char); use lookbehind instead.
|
||||
_ETH_COLON_RE = re.compile(r"(?i)(?<![A-Za-z0-9])ETH:(\d+)\b")
|
||||
# `25GE:14` / `10GE:32` style local port markers in some regions' labels.
|
||||
_RATE_GE_RE = re.compile(r"(?i)(?<![A-Za-z0-9])(?:\d+)?GE:(\d+)\b")
|
||||
# `[1/1/0]` or `[0-1-1]`
|
||||
_BRACKET_SLOT_RE = re.compile(r"\[(\d+[/\-]\d+[/\-]\d+)\]")
|
||||
_EQ_SH_RE = re.compile(r"(?i)/sh=(\d+)")
|
||||
_EQ_SL_RE = re.compile(r"(?i)/sl=(\d+)")
|
||||
_EQ_SSL_RE = re.compile(r"(?i)/ssl=(\d+)")
|
||||
|
|
@ -34,11 +48,25 @@ def extract_ifnames_from_user_label(label: str) -> list[str]:
|
|||
key = normalize_ifname(raw)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
# Drop truncated junk like ``cgei-0`` / ``xxvgei-0`` from cut labels.
|
||||
if numeric_port_tail(key).count("/") < 2:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def numeric_port_tail(ifname: str) -> str:
|
||||
"""Digits path after media prefix: ``xxvgei-1/1/0/14`` → ``1/1/0/14``."""
|
||||
s = normalize_ifname(ifname)
|
||||
if not s:
|
||||
return ""
|
||||
for i, ch in enumerate(s):
|
||||
if ch.isdigit():
|
||||
return s[i:]
|
||||
return ""
|
||||
|
||||
|
||||
def port_suffix_from_tp_ref(tp_ref: str) -> str:
|
||||
"""Build shelf/slot/…/port suffix from EQ+PTP, e.g. ``1/1/0/32`` or ``0/0/1/4``."""
|
||||
s = str(tp_ref or "")
|
||||
|
|
@ -58,15 +86,56 @@ def port_suffix_from_tp_ref(tp_ref: str) -> str:
|
|||
return f"{sh}/{sl}/0/{port}"
|
||||
|
||||
|
||||
def _norm_bracket_slot(raw: str) -> str:
|
||||
return str(raw or "").strip().replace("-", "/")
|
||||
|
||||
|
||||
def iter_eth_style_suffixes(label: str) -> list[str]:
|
||||
"""``[1/1/0]_ETH:28`` / ``[0-1-1]-25GE:14`` → ``1/1/0/28`` / ``0/1/1/14``."""
|
||||
s = str(label or "")
|
||||
slots = [
|
||||
(m.start(), _norm_bracket_slot(m.group(1)))
|
||||
for m in _BRACKET_SLOT_RE.finditer(s)
|
||||
]
|
||||
ports: list[tuple[int, str]] = []
|
||||
for m in _ETH_COLON_RE.finditer(s):
|
||||
ports.append((m.start(), m.group(1)))
|
||||
for m in _RATE_GE_RE.finditer(s):
|
||||
ports.append((m.start(), m.group(1)))
|
||||
ports.sort(key=lambda x: x[0])
|
||||
if not slots or not ports:
|
||||
return []
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for (_spos, slot), (_ppos, port) in zip(slots, ports):
|
||||
if slot.count("/") != 2:
|
||||
continue
|
||||
key = normalize_ifname(f"{slot}/{port}")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def port_suffix_from_eth_label(label: str, *, end_index: int = 0) -> str:
|
||||
"""From ``…[1/1/0]_ETH:28_…`` build ``1/1/0/28`` (nth ETH occurrence)."""
|
||||
s = str(label or "")
|
||||
slots = _BRACKET_SLOT_RE.findall(s)
|
||||
eths = _ETH_COLON_RE.findall(s)
|
||||
if not slots or not eths:
|
||||
all_suf = iter_eth_style_suffixes(label)
|
||||
if not all_suf:
|
||||
return ""
|
||||
i = max(0, min(int(end_index), len(slots) - 1, len(eths) - 1))
|
||||
return f"{slots[i]}/{eths[i]}"
|
||||
i = max(0, min(int(end_index), len(all_suf) - 1))
|
||||
return all_suf[i]
|
||||
|
||||
|
||||
def find_ifname_matching_suffix(suffix: str, user_label: str) -> str:
|
||||
"""Return a label CLI ifname whose numeric tail equals ``suffix``."""
|
||||
want = normalize_ifname(suffix)
|
||||
if not want:
|
||||
return ""
|
||||
for tok in extract_ifnames_from_user_label(user_label):
|
||||
tail = numeric_port_tail(tok)
|
||||
if tail == want or normalize_ifname(tok) == want:
|
||||
return tok
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_ume_ifname(
|
||||
|
|
@ -77,21 +146,21 @@ def resolve_ume_ifname(
|
|||
) -> str:
|
||||
"""Best-effort CLI ifname for one end of a UME link.
|
||||
|
||||
Preference: userLabel ifname token → ETH:+bracket → EQ+PTP suffix.
|
||||
Preference: EQ+PTP suffix → matching label media token → ETH/GE match → bare suffix.
|
||||
Without TP: return empty (caller should show ``userLabel``, not invent A/Z ports).
|
||||
"""
|
||||
tokens = extract_ifnames_from_user_label(user_label)
|
||||
if tokens:
|
||||
idx = max(0, min(int(end_index), len(tokens) - 1))
|
||||
return tokens[idx][:128]
|
||||
|
||||
eth = port_suffix_from_eth_label(user_label, end_index=end_index)
|
||||
if eth:
|
||||
return normalize_ifname(eth)[:128]
|
||||
|
||||
_ = end_index # kept for call-site compatibility; unused without TP
|
||||
suffix = port_suffix_from_tp_ref(tp_ref)
|
||||
if suffix:
|
||||
return normalize_ifname(suffix)[:128]
|
||||
return ""
|
||||
if not suffix:
|
||||
return ""
|
||||
hit = find_ifname_matching_suffix(suffix, user_label)
|
||||
if hit:
|
||||
return hit[:128]
|
||||
want = normalize_ifname(suffix)
|
||||
for eth in iter_eth_style_suffixes(user_label):
|
||||
if normalize_ifname(eth) == want:
|
||||
return want[:128]
|
||||
return want[:128]
|
||||
|
||||
|
||||
def resolve_link_ifnames(
|
||||
|
|
@ -100,35 +169,69 @@ def resolve_link_ifnames(
|
|||
z_end_tp_ref: str,
|
||||
user_label: str = "",
|
||||
) -> tuple[str, str]:
|
||||
tokens = extract_ifnames_from_user_label(user_label)
|
||||
if len(tokens) >= 2:
|
||||
return tokens[0][:128], tokens[1][:128]
|
||||
if len(tokens) == 1:
|
||||
a = tokens[0]
|
||||
z = resolve_ume_ifname(tp_ref=z_end_tp_ref, user_label=user_label, end_index=1)
|
||||
return a[:128], z[:128]
|
||||
"""Resolve A/Z independently — TP pins the port; label only supplies media prefix.
|
||||
|
||||
If neither end has EQ+PTP, returns empty ports (display ``userLabel`` instead).
|
||||
"""
|
||||
return (
|
||||
resolve_ume_ifname(tp_ref=a_end_tp_ref, user_label=user_label, end_index=0),
|
||||
resolve_ume_ifname(tp_ref=z_end_tp_ref, user_label=user_label, end_index=1),
|
||||
)
|
||||
|
||||
|
||||
def is_label_placeholder_port(ifname: str) -> bool:
|
||||
"""Synthetic fabric port for UME links that only have userLabel (no TP)."""
|
||||
return normalize_ifname(ifname).startswith("label:")
|
||||
|
||||
|
||||
def label_placeholder_ports(link_id: str) -> tuple[str, str]:
|
||||
lid = str(link_id or "").strip() or "unknown"
|
||||
return (f"label:{lid}:a"[:128], f"label:{lid}:z"[:128])
|
||||
|
||||
|
||||
def port_keys_compatible(a: str, b: str) -> bool:
|
||||
"""True if two ports are the same after normalize, or share a numeric suffix."""
|
||||
"""True if two ports are the same after normalize, or share the same numeric path.
|
||||
|
||||
Uses exact equality of ``numeric_port_tail`` (e.g. ``xxvgei-1/1/0/32`` ↔ ``1/1/0/32``).
|
||||
Does **not** use naive ``endswith`` (avoids ``11/1/0/1`` matching ``1/1/0/1``).
|
||||
"""
|
||||
na = normalize_ifname(a)
|
||||
nb = normalize_ifname(b)
|
||||
if not na or not nb:
|
||||
return False
|
||||
if na == nb:
|
||||
return True
|
||||
# xxvgei-1/1/0/32 vs 1/1/0/32
|
||||
if na.endswith(nb) or nb.endswith(na):
|
||||
return True
|
||||
# strip alpha prefix before first digit
|
||||
def _num_tail(s: str) -> str:
|
||||
for i, ch in enumerate(s):
|
||||
if ch.isdigit():
|
||||
return s[i:]
|
||||
return s
|
||||
if is_label_placeholder_port(na) or is_label_placeholder_port(nb):
|
||||
return False
|
||||
ta = numeric_port_tail(na)
|
||||
tb = numeric_port_tail(nb)
|
||||
# Require a real shelf/…/port path (at least one slash).
|
||||
return bool(ta) and ta == tb and "/" in ta
|
||||
|
||||
return _num_tail(na) == _num_tail(nb) and bool(_num_tail(na))
|
||||
|
||||
_MEDIA_PREFIX_RE = re.compile(
|
||||
r"(?i)^(xxvgei|xgei|cgei|xlgei|lgei|gei|gi|eth)-"
|
||||
)
|
||||
|
||||
|
||||
def has_media_prefix(ifname: str) -> bool:
|
||||
return bool(_MEDIA_PREFIX_RE.match(normalize_ifname(ifname)))
|
||||
|
||||
|
||||
def prefer_richer_ifname(current: str, candidate: str) -> str:
|
||||
"""When ports are compatible, prefer the media-prefixed (LLDP-style) name.
|
||||
|
||||
Bare ``1/1/0/32`` + ``xxvgei-1/1/0/32`` → ``xxvgei-1/1/0/32``.
|
||||
Incompatible candidates are ignored (keep ``current``).
|
||||
"""
|
||||
cur = normalize_ifname(current)
|
||||
can = normalize_ifname(candidate)
|
||||
if not can:
|
||||
return cur
|
||||
if not cur:
|
||||
return can
|
||||
if not port_keys_compatible(cur, can):
|
||||
return cur
|
||||
if has_media_prefix(can) and not has_media_prefix(cur):
|
||||
return can
|
||||
return cur
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ from .topology_fabric_links import (
|
|||
find_fabric_edge_compatible,
|
||||
upsert_fabric_edge,
|
||||
)
|
||||
from .topology_fabric_nodes import ensure_fabric_node_for_ume, refresh_fabric_stats
|
||||
from .ume_port_normalize import (
|
||||
is_label_placeholder_port,
|
||||
label_placeholder_ports,
|
||||
prefer_richer_ifname,
|
||||
resolve_link_ifnames,
|
||||
)
|
||||
from .topology_fabric_nodes import _normalize_endpoints, ensure_fabric_node_for_ume, refresh_fabric_stats
|
||||
from .topology_lldp import normalize_ifname
|
||||
from .ume_port_normalize import resolve_link_ifnames
|
||||
|
||||
_log = logging.getLogger("netx.ume.topo_apply")
|
||||
|
||||
|
|
@ -142,34 +147,54 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
|
|||
if a_fn is None or z_fn is None:
|
||||
stats["edges_skipped"] += 1
|
||||
continue
|
||||
a_if = str(link.a_ifname or "").strip()
|
||||
z_if = str(link.z_ifname or "").strip()
|
||||
if not a_if or not z_if:
|
||||
a_if, z_if = resolve_link_ifnames(
|
||||
a_end_tp_ref=link.a_end_tp_ref or "",
|
||||
z_end_tp_ref=link.z_end_tp_ref or "",
|
||||
user_label=link.user_label or "",
|
||||
)
|
||||
a_if = normalize_ifname(a_if)
|
||||
z_if = normalize_ifname(z_if)
|
||||
if not a_if or not z_if:
|
||||
stats["edges_skipped"] += 1
|
||||
continue
|
||||
|
||||
existing = find_fabric_edge_compatible(
|
||||
db,
|
||||
a_node_id=a_fn.id,
|
||||
b_node_id=z_fn.id,
|
||||
a_port=a_if,
|
||||
b_port=z_if,
|
||||
# Always re-resolve from TP+userLabel (new normalize rules); keep richer
|
||||
# of dock-stored vs fresh so Fabric ports stay aligned after ifname backfill.
|
||||
fresh_a, fresh_z = resolve_link_ifnames(
|
||||
a_end_tp_ref=link.a_end_tp_ref or "",
|
||||
z_end_tp_ref=link.z_end_tp_ref or "",
|
||||
user_label=link.user_label or "",
|
||||
)
|
||||
stored_a = normalize_ifname(str(link.a_ifname or "").strip())
|
||||
stored_z = normalize_ifname(str(link.z_ifname or "").strip())
|
||||
fresh_a = normalize_ifname(fresh_a)
|
||||
fresh_z = normalize_ifname(fresh_z)
|
||||
a_if = prefer_richer_ifname(stored_a, fresh_a) or fresh_a or stored_a
|
||||
z_if = prefer_richer_ifname(stored_z, fresh_z) or fresh_z or stored_z
|
||||
if a_if != stored_a or z_if != stored_z:
|
||||
link.a_ifname = (a_if or "")[:128]
|
||||
link.z_ifname = (z_if or "")[:128]
|
||||
link.last_seen_at = now
|
||||
display_label = str(link.user_label or "").strip()
|
||||
label_only = False
|
||||
if not a_if or not z_if:
|
||||
# No EQ+PTP → do not invent A/Z ports; show userLabel on canvas.
|
||||
if not display_label:
|
||||
stats["edges_skipped"] += 1
|
||||
continue
|
||||
a_if, z_if = label_placeholder_ports(str(link.link_id or ""))
|
||||
label_only = True
|
||||
|
||||
existing = None
|
||||
if not label_only:
|
||||
existing = find_fabric_edge_compatible(
|
||||
db,
|
||||
a_node_id=a_fn.id,
|
||||
b_node_id=z_fn.id,
|
||||
a_port=a_if,
|
||||
b_port=z_if,
|
||||
)
|
||||
if existing is not None:
|
||||
# Merge onto existing LLDP/manual edge (keep its port strings).
|
||||
# Merge onto existing LLDP/manual edge; keep/upgrade to richer port strings.
|
||||
_a, _b, nap, nbp = _normalize_endpoints(a_fn.id, z_fn.id, a_if, z_if)
|
||||
existing.a_port = prefer_richer_ifname(existing.a_port, nap)[:128]
|
||||
existing.b_port = prefer_richer_ifname(existing.b_port, nbp)[:128]
|
||||
attrs = _edge_attrs(existing)
|
||||
sources = _sources_from_attrs(attrs, fallback=existing.source or "")
|
||||
sources.add("ume")
|
||||
attrs["sources"] = sorted(sources)
|
||||
attrs["ume_link_id"] = str(link.link_id or "")[:128]
|
||||
if display_label:
|
||||
attrs["display_label"] = display_label[:512]
|
||||
existing.attrs = _clear_and_keep(attrs)
|
||||
if (existing.source or "") != "manual":
|
||||
existing.source = _primary_source(sources)
|
||||
|
|
@ -196,6 +221,10 @@ def apply_ume_topology_to_fabric(db: Session) -> dict[str, Any]:
|
|||
continue
|
||||
attrs = _edge_attrs(edge)
|
||||
attrs["ume_link_id"] = str(link.link_id or "")[:128]
|
||||
if display_label:
|
||||
attrs["display_label"] = display_label[:512]
|
||||
if label_only or is_label_placeholder_port(a_if):
|
||||
attrs["label_only"] = True
|
||||
edge.attrs = attrs
|
||||
seen_edge_ids.add(edge.id)
|
||||
stats["edges_upserted"] += 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue