mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +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
|
||||
|
|
|
|||
|
|
@ -491,6 +491,26 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
self.assertEqual(len(rm.views), 2)
|
||||
self.assertTrue(all(not getattr(v, "children", None) for v in rm.views))
|
||||
|
||||
def test_manual_root_map_name_follows_locale(self) -> None:
|
||||
"""English UI creates Root map; zh (default) keeps 根图."""
|
||||
en_root = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="West-EN", kind="region", locale="en")
|
||||
)
|
||||
tree = svc.get_topology_tree(self.db)
|
||||
assert tree.root is not None
|
||||
west = next(c for c in tree.root.children if c.id == en_root.id)
|
||||
self.assertEqual(len(west.children), 1)
|
||||
self.assertEqual(west.children[0].name, "Root map")
|
||||
self.assertEqual(west.children[0].views[0].name, "Root map")
|
||||
|
||||
zh_root = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="East-ZH", kind="region", locale="zh")
|
||||
)
|
||||
tree2 = svc.get_topology_tree(self.db)
|
||||
assert tree2.root is not None
|
||||
east = next(c for c in tree2.root.children if c.id == zh_root.id)
|
||||
self.assertEqual(east.children[0].name, "根图")
|
||||
|
||||
def test_site_physical_and_custom_flat(self) -> None:
|
||||
top = svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name="Site-R", kind="region")
|
||||
|
|
@ -1774,6 +1794,76 @@ Management Addresses:
|
|||
self.db.delete(ne_b)
|
||||
self.db.commit()
|
||||
|
||||
def test_ume_edge_not_marked_missing_by_lldp_miss(self) -> None:
|
||||
"""Valid LLDP scan on endpoint must not red-line UME-authority edges."""
|
||||
suffix = uuid4().hex[:8]
|
||||
fa, fb, ne_a, ne_b = self._pair_nodes(suffix)
|
||||
edge, _ = svc.upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=fa.id,
|
||||
b_node_id=fb.id,
|
||||
a_port="xxvgei-1/1/0/1",
|
||||
b_port="xxvgei-1/1/0/2",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(edge.source, "ume")
|
||||
|
||||
newly, purged = svc._apply_missing_and_purge(
|
||||
self.db,
|
||||
scanned_ok={fa.id},
|
||||
touched_edge_ids=set(),
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(newly, 0)
|
||||
self.assertEqual(purged, 0)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
self.assertEqual(edge.source, "ume")
|
||||
|
||||
# Dual provenance still UME-protected; LLDP mark is cleared.
|
||||
edge.attrs = {"sources": ["lldp", "ume"]}
|
||||
edge.source = "ume"
|
||||
self.db.commit()
|
||||
newly, purged = svc._apply_missing_and_purge(
|
||||
self.db,
|
||||
scanned_ok={fa.id},
|
||||
touched_edge_ids=set(),
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(newly, 0)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
self.assertEqual(edge.source, "ume")
|
||||
self.assertNotIn("lldp", (edge.attrs or {}).get("sources", []))
|
||||
self.assertIn("ume", (edge.attrs or {}).get("sources", []))
|
||||
|
||||
# Pure LLDP on same node still miss-eligible.
|
||||
lldp_edge, _ = svc.upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=fa.id,
|
||||
b_node_id=fb.id,
|
||||
a_port="Gi0/0",
|
||||
b_port="Gi0/1",
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
newly, purged = svc._apply_missing_and_purge(
|
||||
self.db,
|
||||
scanned_ok={fa.id},
|
||||
touched_edge_ids=set(),
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(newly, 1)
|
||||
self.db.refresh(lldp_edge)
|
||||
self.assertEqual(lldp_edge.status, "missing")
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual(edge.status, "active")
|
||||
|
||||
self.db.delete(ne_a)
|
||||
self.db.delete(ne_b)
|
||||
self.db.commit()
|
||||
|
||||
def test_delete_managed_purges_orphan_fabric_and_edges(self) -> None:
|
||||
"""Managed-only delete → detach then purge fabric node + incident edges."""
|
||||
from netx_api import ne_service
|
||||
|
|
@ -1879,7 +1969,7 @@ Management Addresses:
|
|||
b_node_id=peer.id,
|
||||
a_port="Gi1/0",
|
||||
b_port="Gi1/1",
|
||||
source="lldp",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
edge_id = str(edge.id)
|
||||
|
|
@ -1927,7 +2017,7 @@ Management Addresses:
|
|||
b_node_id=peer.id,
|
||||
a_port="Eth1",
|
||||
b_port="Eth2",
|
||||
source="lldp",
|
||||
source="ume",
|
||||
)
|
||||
self.db.commit()
|
||||
edge_id = str(edge.id)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from netx_api.ume_port_normalize import (
|
|||
extract_ifnames_from_user_label,
|
||||
port_keys_compatible,
|
||||
port_suffix_from_tp_ref,
|
||||
prefer_richer_ifname,
|
||||
resolve_link_ifnames,
|
||||
)
|
||||
from netx_api.ume_topology_apply import (
|
||||
|
|
@ -69,10 +70,78 @@ class UmePortNormalizeTests(unittest.TestCase):
|
|||
self.assertEqual(a, "1/1/0/28")
|
||||
self.assertEqual(z, "1/1/0/28")
|
||||
|
||||
def test_prefer_richer_ifname(self):
|
||||
from netx_api.ume_port_normalize import prefer_richer_ifname
|
||||
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("1/1/0/32", "xxvgei-1/1/0/32"),
|
||||
"xxvgei-1/1/0/32",
|
||||
)
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("xxvgei-1/1/0/32", "1/1/0/32"),
|
||||
"xxvgei-1/1/0/32",
|
||||
)
|
||||
self.assertEqual(
|
||||
prefer_richer_ifname("1/1/0/32", "xxvgei-1/1/0/28"),
|
||||
"1/1/0/32",
|
||||
)
|
||||
|
||||
def test_port_compatible(self):
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "1/1/0/32"))
|
||||
self.assertTrue(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/32"))
|
||||
self.assertFalse(port_keys_compatible("xxvgei-1/1/0/32", "xxvgei-1/1/0/28"))
|
||||
# Must not false-match via endswith (11/… vs 1/…).
|
||||
self.assertFalse(port_keys_compatible("11/1/0/1", "1/1/0/1"))
|
||||
self.assertFalse(port_keys_compatible("xxvgei-11/1/0/1", "1/1/0/1"))
|
||||
|
||||
def test_no_tp_does_not_invent_port_pair(self):
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a}",
|
||||
z_end_tp_ref="ME{b}",
|
||||
user_label="NE-A_xxvgei-1/1/0/32_NE-B_xxvgei-1/1/0/28",
|
||||
)
|
||||
self.assertEqual(a, "")
|
||||
self.assertEqual(z, "")
|
||||
|
||||
def test_tp_first_ignores_swapped_neighbor_ports_in_label(self):
|
||||
"""Real UME label embeds the *peer* port beside each end — order swaps A/Z.
|
||||
|
||||
TP pins local ports (A=14, Z=16); media prefix is taken from whichever
|
||||
token matches that numeric tail (even if it appears under the other end).
|
||||
"""
|
||||
label = (
|
||||
"RSFRL22-RMP01-SMGD[0-1-1]-25GE:14(NNI | RSFRLR1-RMP01 xxvgei-1/1/0/16 "
|
||||
"| CLARO | FO | RSCSL15-RSNHO06-01)_RSFRLR1-RMP01-SMGD[0-1-1]-25GE:16"
|
||||
"(NNI | RSFRL22-RMP01 xxvgei-1/1/0/14 | CLARO | FO | RSCSL15-RSNHO06-01)"
|
||||
)
|
||||
a_tp = (
|
||||
"ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_14}"
|
||||
)
|
||||
z_tp = (
|
||||
"ME{b1215371-491a-4641-a1fd-9d59c8002e77},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}"
|
||||
)
|
||||
tokens = extract_ifnames_from_user_label(label)
|
||||
# Label order is neighbor-first (16 then 14) — must not drive A/Z.
|
||||
self.assertEqual(tokens[0], "xxvgei-1/1/0/16")
|
||||
self.assertEqual(tokens[1], "xxvgei-1/1/0/14")
|
||||
self.assertEqual(port_suffix_from_tp_ref(a_tp), "1/1/0/14")
|
||||
self.assertEqual(port_suffix_from_tp_ref(z_tp), "1/1/0/16")
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref=a_tp, z_end_tp_ref=z_tp, user_label=label
|
||||
)
|
||||
self.assertEqual(a, "xxvgei-1/1/0/14")
|
||||
self.assertEqual(z, "xxvgei-1/1/0/16")
|
||||
|
||||
def test_tp_suffix_without_matching_media_stays_bare(self):
|
||||
a, z = resolve_link_ifnames(
|
||||
a_end_tp_ref="ME{a},EQ={/r=0/sh=1/sl=1},PTP={/p=1_14}",
|
||||
z_end_tp_ref="ME{b},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}",
|
||||
user_label="no-cli-tokens-here",
|
||||
)
|
||||
self.assertEqual(a, "1/1/0/14")
|
||||
self.assertEqual(z, "1/1/0/16")
|
||||
self.assertTrue(port_keys_compatible(a, "xxvgei-1/1/0/14"))
|
||||
self.assertTrue(port_keys_compatible(z, "cgei-1/1/0/16"))
|
||||
|
||||
|
||||
class UmeFabricApplyTests(unittest.TestCase):
|
||||
|
|
@ -241,6 +310,99 @@ class UmeFabricApplyTests(unittest.TestCase):
|
|||
self.assertEqual(len(manuals), 1)
|
||||
self.assertEqual(manuals[0].status, "active")
|
||||
|
||||
def test_apply_reresolves_and_upgrades_fabric_ports(self):
|
||||
"""Apply always re-resolves dock ifnames and upgrades compatible Fabric ports."""
|
||||
from netx_api.ume_port_normalize import numeric_port_tail
|
||||
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
link = self.db.query(UmeTopoLink).one()
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
# Simulate stale bare dock + fabric ports after an older resolve.
|
||||
link.a_ifname = "1/1/0/32"
|
||||
link.z_ifname = "1/1/0/28"
|
||||
edge.a_port = numeric_port_tail(edge.a_port)
|
||||
edge.b_port = numeric_port_tail(edge.b_port)
|
||||
self.db.commit()
|
||||
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
self.db.refresh(link)
|
||||
self.db.refresh(edge)
|
||||
self.assertEqual({link.a_ifname, link.z_ifname}, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
self.assertEqual({edge.a_port, edge.b_port}, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
|
||||
def test_lldp_upgrades_bare_ume_ports_and_keeps_ume_primary(self):
|
||||
from netx_api.ume_port_normalize import numeric_port_tail
|
||||
|
||||
self._seed_ume()
|
||||
# Apply creates bare-capable UME edge with media from label.
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
edge = self.db.query(TopoFabricEdge).one()
|
||||
# Force bare ports as if UME had no media prefix (keep A/B assignment).
|
||||
rich_a, rich_b = edge.a_port, edge.b_port
|
||||
edge.a_port = numeric_port_tail(edge.a_port)
|
||||
edge.b_port = numeric_port_tail(edge.b_port)
|
||||
edge.source = "ume"
|
||||
edge.attrs = {"sources": ["ume"], "ume_link_id": "link-1"}
|
||||
self.db.commit()
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
edge2, action = upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=a.id,
|
||||
b_node_id=b.id,
|
||||
a_port=rich_a,
|
||||
b_port=rich_b,
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(action, "updated")
|
||||
self.assertIsNotNone(edge2)
|
||||
assert edge2 is not None
|
||||
self.assertEqual(edge2.id, edge.id)
|
||||
self.assertEqual(edge2.source, "ume")
|
||||
self.assertIn("lldp", (edge2.attrs or {}).get("sources", []))
|
||||
ports = {edge2.a_port, edge2.b_port}
|
||||
self.assertEqual(ports, {"xxvgei-1/1/0/32", "xxvgei-1/1/0/28"})
|
||||
|
||||
def test_lldp_can_add_edge_between_ume_nes_when_ume_has_no_link(self):
|
||||
"""UME dump may omit a physical link — LLDP may still create it (dashed)."""
|
||||
self._seed_ume()
|
||||
apply_ume_topology_to_fabric(self.db)
|
||||
a = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
.one()
|
||||
)
|
||||
b = (
|
||||
self.db.query(TopoFabricNode)
|
||||
.filter(TopoFabricNode.ume_ne_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
.one()
|
||||
)
|
||||
before = self.db.query(TopoFabricEdge).count()
|
||||
edge, action = upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=a.id,
|
||||
b_node_id=b.id,
|
||||
a_port="xxvgei-1/1/0/99",
|
||||
b_port="xxvgei-1/1/0/98",
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
self.assertEqual(action, "added")
|
||||
self.assertIsNotNone(edge)
|
||||
assert edge is not None
|
||||
self.assertEqual(edge.source, "lldp")
|
||||
self.assertEqual(self.db.query(TopoFabricEdge).count(), before + 1)
|
||||
|
||||
def test_gap_needs_apply_when_dock_only(self):
|
||||
self._seed_ume()
|
||||
self.db.commit()
|
||||
|
|
|
|||
|
|
@ -1443,6 +1443,7 @@ const en = {
|
|||
subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
||||
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
|
||||
worldMapName: "World map",
|
||||
rootMapName: "Root map",
|
||||
worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}",
|
||||
worldEmptyCoords: "World map has no coordinates yet",
|
||||
worldEmptyCoordsHint:
|
||||
|
|
@ -1596,7 +1597,8 @@ const en = {
|
|||
completeNeFirst:
|
||||
"Login details are incomplete (vendor, hop/proxy, etc. belong in NE inventory). Complete the NE first, then open the terminal.",
|
||||
edgeManual: "Manual",
|
||||
edgeDiscovered: "Discovered",
|
||||
edgeUme: "UME",
|
||||
edgeDiscovered: "LLDP",
|
||||
edgeStale: "Missing",
|
||||
fit: "Fit view",
|
||||
liveSync: "Live sync",
|
||||
|
|
|
|||
|
|
@ -1433,6 +1433,7 @@ const zh = {
|
|||
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||
worldMapName: "世界地图",
|
||||
rootMapName: "根图",
|
||||
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
|
||||
worldEmptyCoords: "世界地图还没有坐标",
|
||||
worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。",
|
||||
|
|
@ -1581,7 +1582,8 @@ const zh = {
|
|||
completeNeFirst:
|
||||
"该网元登录信息不完整(厂家、代理跳转等需在网元管理填写)。请先完善后再打开终端。",
|
||||
edgeManual: "人工",
|
||||
edgeDiscovered: "发现",
|
||||
edgeUme: "UME",
|
||||
edgeDiscovered: "LLDP 发现",
|
||||
edgeStale: "未发现",
|
||||
fit: "适应画布",
|
||||
liveSync: "实时同步",
|
||||
|
|
|
|||
|
|
@ -183,8 +183,14 @@ function isWorldFlatViewName(name: string | undefined | null): boolean {
|
|||
return n === "世界地图" || n === "完整世界地图" || n === "World map";
|
||||
}
|
||||
|
||||
function isManualRootMapName(name: string | undefined | null): boolean {
|
||||
const n = String(name || "").trim();
|
||||
return n === "根图" || n === "Root map";
|
||||
}
|
||||
|
||||
function displayViewName(name: string | undefined | null, t: (key: string) => string): string {
|
||||
if (isWorldFlatViewName(name)) return t("topology.worldMapName");
|
||||
if (isManualRootMapName(name)) return t("topology.rootMapName");
|
||||
return String(name || "").trim();
|
||||
}
|
||||
|
||||
|
|
@ -267,9 +273,14 @@ function ChevronIcon({ open }: { open: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
function regionDisplayName(region: TopologyTreeFolderItem | null | undefined): string {
|
||||
function regionDisplayName(
|
||||
region: TopologyTreeFolderItem | null | undefined,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (!region) return "";
|
||||
return region.name || "";
|
||||
const raw = String(region.name || "").trim();
|
||||
if (t && isManualRootMapName(raw)) return t("topology.rootMapName");
|
||||
return raw;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value?: string | null): string {
|
||||
|
|
@ -805,7 +816,7 @@ const edgeTypes = { topoParallel: ParallelEdge };
|
|||
type EdgeStyleData = LinkEdgeData;
|
||||
|
||||
type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||
type EdgeSourceKind = "manual" | "discovered" | "stale";
|
||||
type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale";
|
||||
|
||||
type EdgeDefaultStyle = {
|
||||
stroke_color: string;
|
||||
|
|
@ -973,14 +984,20 @@ function persistAutoLayoutAfterDiscover(value: boolean) {
|
|||
}
|
||||
|
||||
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
||||
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||
// UME is authority when present — solid blue.
|
||||
ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" },
|
||||
// LLDP/CDP only (no UME) — dashed sky blue.
|
||||
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
||||
// Gone / not seen — red dashed.
|
||||
stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" },
|
||||
// Hand-drawn — gray solid.
|
||||
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||
};
|
||||
|
||||
function sourceKind(source: string): EdgeSourceKind {
|
||||
const src = (source || "manual").toLowerCase();
|
||||
if (src === "stale" || src === "missing") return "stale";
|
||||
if (src === "ume") return "ume";
|
||||
if (src === "lldp" || src === "cdp") return "discovered";
|
||||
return "manual";
|
||||
}
|
||||
|
|
@ -988,7 +1005,14 @@ function sourceKind(source: string): EdgeSourceKind {
|
|||
function loadEdgeDefaults(): EdgeDefaults {
|
||||
try {
|
||||
const raw = localStorage.getItem(EDGE_DEFAULTS_KEY);
|
||||
if (!raw) return { ...BUILTIN_EDGE_DEFAULTS, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale } };
|
||||
if (!raw) {
|
||||
return {
|
||||
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<EdgeDefaults>;
|
||||
const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => {
|
||||
const base = BUILTIN_EDGE_DEFAULTS[kind];
|
||||
|
|
@ -1000,12 +1024,18 @@ function loadEdgeDefaults(): EdgeDefaults {
|
|||
line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style;
|
||||
return { stroke_color: color, stroke_width: width, line_style };
|
||||
};
|
||||
return { manual: pick("manual"), discovered: pick("discovered"), stale: pick("stale") };
|
||||
return {
|
||||
ume: pick("ume"),
|
||||
discovered: pick("discovered"),
|
||||
stale: pick("stale"),
|
||||
manual: pick("manual"),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1104,7 +1134,7 @@ function graphToFlow(
|
|||
const rfEdges: Edge[] = edges.map((e) => {
|
||||
const src =
|
||||
e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
|
||||
const label = formatPortPairLabel(e.a_port || "", e.b_port || "");
|
||||
const label = formatPortPairLabel(e.a_port || "", e.b_port || "", e.display_label || "");
|
||||
const data: EdgeStyleData = {
|
||||
source: src,
|
||||
source_port: e.a_port || "",
|
||||
|
|
@ -1113,6 +1143,7 @@ function graphToFlow(
|
|||
stroke_width: Number(e.stroke_width || 0),
|
||||
line_style: e.line_style || "",
|
||||
discovered_at: e.discovered_at ?? null,
|
||||
display_label: e.display_label || "",
|
||||
};
|
||||
return withEdgeVisual(
|
||||
{
|
||||
|
|
@ -1181,7 +1212,7 @@ function applyViewGraph(
|
|||
}
|
||||
|
||||
export function TopologyPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -1964,9 +1995,10 @@ export function TopologyPage() {
|
|||
|
||||
const resetEdgeDefaults = useCallback(() => {
|
||||
const next: EdgeDefaults = {
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||
};
|
||||
persistEdgeDefaults(next);
|
||||
setEdgeDefaults(next);
|
||||
|
|
@ -2096,10 +2128,11 @@ export function TopologyPage() {
|
|||
mutationFn: (input: { name: string; parent_id?: string }) => {
|
||||
const parentId = String(input.parent_id || "").trim();
|
||||
// Top-level root: omit parent_id so API bootstraps system root even if tree query failed.
|
||||
// locale drives auto「根图」/ "Root map" label under a new root.
|
||||
return createTopologyFolder(
|
||||
parentId
|
||||
? { name: input.name, kind: "region", parent_id: parentId }
|
||||
: { name: input.name, kind: "region" },
|
||||
? { name: input.name, kind: "region", parent_id: parentId, locale }
|
||||
: { name: input.name, kind: "region", locale },
|
||||
);
|
||||
},
|
||||
onSuccess: async (folder, input) => {
|
||||
|
|
@ -2399,7 +2432,7 @@ export function TopologyPage() {
|
|||
<span className="topo-region-list__glyph" aria-hidden="true">
|
||||
<RegionGlyph size={14} />
|
||||
</span>
|
||||
<span className="topo-map-list__title">{regionDisplayName(folder)}</span>
|
||||
<span className="topo-map-list__title">{regionDisplayName(folder, t)}</span>
|
||||
<span className="topo-map-list__count">
|
||||
{(() => {
|
||||
if (containerFolder) {
|
||||
|
|
@ -3032,7 +3065,11 @@ export function TopologyPage() {
|
|||
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
|
||||
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
|
||||
};
|
||||
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
|
||||
}),
|
||||
);
|
||||
|
|
@ -4028,7 +4065,7 @@ export function TopologyPage() {
|
|||
|
||||
const titleText = useMemo(() => {
|
||||
if (canvasMode) return activeLeafName || t("topology.selectMap");
|
||||
if (activeRegion) return regionDisplayName(activeRegion);
|
||||
if (activeRegion) return regionDisplayName(activeRegion, t);
|
||||
return t("topology.rootName");
|
||||
}, [canvasMode, activeLeafName, activeRegion, t]);
|
||||
const onCanvasManagedIds = useMemo(
|
||||
|
|
@ -4308,7 +4345,7 @@ export function TopologyPage() {
|
|||
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||
primaryViewOfFolder(activeRegion)?.id === activeView.id ? (
|
||||
<span className="topo-breadcrumb__current">
|
||||
{regionDisplayName(activeRegion)}
|
||||
{regionDisplayName(activeRegion, t)}
|
||||
{dirty ? " *" : ""}
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -4317,7 +4354,7 @@ export function TopologyPage() {
|
|||
className="topo-breadcrumb__link"
|
||||
onClick={() => goRegion(activeRegion.id)}
|
||||
>
|
||||
{regionDisplayName(activeRegion)}
|
||||
{regionDisplayName(activeRegion, t)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -4578,7 +4615,7 @@ export function TopologyPage() {
|
|||
{t("topology.rootName")}
|
||||
</button>
|
||||
<span className="topo-breadcrumb__sep">/</span>
|
||||
<span className="topo-breadcrumb__current">{regionDisplayName(hexBrowseRegion)}</span>
|
||||
<span className="topo-breadcrumb__current">{regionDisplayName(hexBrowseRegion, t)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<strong>{titleText}</strong>
|
||||
|
|
@ -4677,7 +4714,7 @@ export function TopologyPage() {
|
|||
<RegionGlyph size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
|
||||
<span className="topo-region-hex__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
|
|
@ -4730,7 +4767,7 @@ export function TopologyPage() {
|
|||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">
|
||||
{regionDisplayName(umeWorldHexModules.drill)}
|
||||
{regionDisplayName(umeWorldHexModules.drill, t)}
|
||||
</span>
|
||||
<span className="topo-region-hex__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
|
|
@ -4798,7 +4835,7 @@ export function TopologyPage() {
|
|||
<RegionGlyph size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
|
||||
<span className="topo-region-hex__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
|
|
@ -5164,9 +5201,10 @@ export function TopologyPage() {
|
|||
</div>
|
||||
{(
|
||||
[
|
||||
["manual", t("topology.edgeManual")],
|
||||
["ume", t("topology.edgeUme")],
|
||||
["discovered", t("topology.edgeDiscovered")],
|
||||
["stale", t("topology.edgeStale")],
|
||||
["manual", t("topology.edgeManual")],
|
||||
] as const
|
||||
).map(([kind, label]) => {
|
||||
const d = edgeDefaults[kind];
|
||||
|
|
@ -6158,7 +6196,8 @@ export function TopologyPage() {
|
|||
<ul className="topo-ctx__member-list">
|
||||
{(selectedEdgeData.members || []).map((m) => (
|
||||
<li key={m.id}>
|
||||
{formatPortPairLabel(m.a_port, m.b_port) || m.id.slice(0, 8)}
|
||||
{formatPortPairLabel(m.a_port, m.b_port, m.display_label) || m.id.slice(0, 8)}
|
||||
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type LinkMember = {
|
|||
a_port: string;
|
||||
b_port: string;
|
||||
source: string;
|
||||
display_label?: string;
|
||||
};
|
||||
|
||||
export type LinkEdgeData = {
|
||||
|
|
@ -15,6 +16,8 @@ export type LinkEdgeData = {
|
|||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
/** UME userLabel when ports are unavailable (no EQ+PTP). */
|
||||
display_label?: string;
|
||||
/** Logical bundle of parallel physical links between the same NE pair. */
|
||||
aggregated?: boolean;
|
||||
member_count?: number;
|
||||
|
|
@ -35,24 +38,40 @@ export function aggregateIdForPair(a: string, b: string): string {
|
|||
return `agg:${pairKey(a, b)}`;
|
||||
}
|
||||
|
||||
function isLabelPlaceholderPort(port: string): boolean {
|
||||
return String(port || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.startsWith("label:");
|
||||
}
|
||||
|
||||
/** Clear A↔B port label (not ambiguous "local/remote"). */
|
||||
export function formatPortPairLabel(aPort: string, bPort: string): string {
|
||||
export function formatPortPairLabel(aPort: string, bPort: string, displayLabel?: string): string {
|
||||
const shown = String(displayLabel || "").trim();
|
||||
if (shown) return shown;
|
||||
const a = String(aPort || "").trim();
|
||||
const b = String(bPort || "").trim();
|
||||
if (isLabelPlaceholderPort(a) || isLabelPlaceholderPort(b)) return "";
|
||||
if (a && b) return `${a} ↔ ${b}`;
|
||||
return a || b || "";
|
||||
}
|
||||
|
||||
export function formatBundleLabel(
|
||||
count: number,
|
||||
sample?: { a_port?: string; b_port?: string },
|
||||
sample?: { a_port?: string; b_port?: string; display_label?: string },
|
||||
opts?: { hidePorts?: boolean },
|
||||
): string {
|
||||
if (count <= 1) {
|
||||
return opts?.hidePorts ? "" : formatPortPairLabel(sample?.a_port || "", sample?.b_port || "");
|
||||
return opts?.hidePorts
|
||||
? ""
|
||||
: formatPortPairLabel(sample?.a_port || "", sample?.b_port || "", sample?.display_label);
|
||||
}
|
||||
if (opts?.hidePorts) return `×${count}`;
|
||||
const sampleLabel = formatPortPairLabel(sample?.a_port || "", sample?.b_port || "");
|
||||
const sampleLabel = formatPortPairLabel(
|
||||
sample?.a_port || "",
|
||||
sample?.b_port || "",
|
||||
sample?.display_label,
|
||||
);
|
||||
return sampleLabel ? `×${count} · ${sampleLabel}` : `×${count}`;
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +82,7 @@ function memberFrom(edge: Edge): LinkMember {
|
|||
a_port: String(d.source_port || "").trim(),
|
||||
b_port: String(d.target_port || "").trim(),
|
||||
source: String(d.source || "manual"),
|
||||
display_label: String(d.display_label || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +133,11 @@ export function buildLinkDisplayEdges(
|
|||
parallelIndex: i,
|
||||
parallelCount: count,
|
||||
};
|
||||
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
out.push({
|
||||
...e,
|
||||
type: count > 1 ? "topoParallel" : e.type || "straight",
|
||||
|
|
@ -134,7 +158,11 @@ export function buildLinkDisplayEdges(
|
|||
member_count: 1,
|
||||
members: [memberFrom(e)],
|
||||
};
|
||||
const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || "");
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
out.push({
|
||||
...e,
|
||||
type: "straight",
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,14 @@ export const fetchTopologyViews = () =>
|
|||
|
||||
export const fetchTopologyTree = () => apiGet<TopologyTree>("/v1/topology/tree");
|
||||
|
||||
export const createTopologyFolder = (body: { name: string; kind?: string; parent_id?: string; sort_order?: number }) =>
|
||||
export const createTopologyFolder = (body: {
|
||||
name: string;
|
||||
kind?: string;
|
||||
parent_id?: string;
|
||||
sort_order?: number;
|
||||
/** UI locale so auto-spawned root map is「根图」or "Root map". */
|
||||
locale?: string;
|
||||
}) =>
|
||||
apiPost<{
|
||||
id: string;
|
||||
parent_id: string;
|
||||
|
|
|
|||
|
|
@ -546,6 +546,8 @@ export type TopologyViewEdgeItem = {
|
|||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
discovered_at?: string | null;
|
||||
/** UME userLabel when no EQ+PTP ports. */
|
||||
display_label?: string;
|
||||
};
|
||||
|
||||
export type TopologyOutsidePeer = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue