diff --git a/netx_api/topology_discover_jobs.py b/netx_api/topology_discover_jobs.py index 9921fff..f38707f 100644 --- a/netx_api/topology_discover_jobs.py +++ b/netx_api/topology_discover_jobs.py @@ -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 []: diff --git a/netx_api/topology_discover_scan.py b/netx_api/topology_discover_scan.py index ac4cb1f..db93d71 100644 --- a/netx_api/topology_discover_scan.py +++ b/netx_api/topology_discover_scan.py @@ -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() diff --git a/netx_api/topology_fabric_links.py b/netx_api/topology_fabric_links.py index d535ada..c8cac1a 100644 --- a/netx_api/topology_fabric_links.py +++ b/netx_api/topology_fabric_links.py @@ -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): diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index 367ac46..5a22646 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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): diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index 97f325a..1f165d4 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -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) diff --git a/netx_api/topology_views_tree.py b/netx_api/topology_views_tree.py index 789b484..e2314e9 100644 --- a/netx_api/topology_views_tree.py +++ b/netx_api/topology_views_tree.py @@ -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={}, diff --git a/netx_api/ume_port_normalize.py b/netx_api/ume_port_normalize.py index 58b5cd2..0e82bc2 100644 --- a/netx_api/ume_port_normalize.py +++ b/netx_api/ume_port_normalize.py @@ -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)(? 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 diff --git a/netx_api/ume_topology_apply.py b/netx_api/ume_topology_apply.py index adf3dcc..be7773f 100644 --- a/netx_api/ume_topology_apply.py +++ b/netx_api/ume_topology_apply.py @@ -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 diff --git a/tests/test_topology.py b/tests/test_topology.py index 2442f27..333899b 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -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) diff --git a/tests/test_ume_topology_apply.py b/tests/test_ume_topology_apply.py index d582fd7..3c6e819 100644 --- a/tests/test_ume_topology_apply.py +++ b/tests/test_ume_topology_apply.py @@ -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() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index aec04a9..ea3e421 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6e917c9..50a2894 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "实时同步", diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 9864fb8..be2b671 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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; 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() { - {regionDisplayName(folder)} + {regionDisplayName(folder, t)} {(() => { 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 ? ( - {regionDisplayName(activeRegion)} + {regionDisplayName(activeRegion, t)} {dirty ? " *" : ""} ) : ( @@ -4317,7 +4354,7 @@ export function TopologyPage() { className="topo-breadcrumb__link" onClick={() => goRegion(activeRegion.id)} > - {regionDisplayName(activeRegion)} + {regionDisplayName(activeRegion, t)} )} @@ -4578,7 +4615,7 @@ export function TopologyPage() { {t("topology.rootName")} / - {regionDisplayName(hexBrowseRegion)} + {regionDisplayName(hexBrowseRegion, t)} ) : ( {titleText} @@ -4677,7 +4714,7 @@ export function TopologyPage() { - {regionDisplayName(region)} + {regionDisplayName(region, t)} {t("topology.regionNodeHint").replace( "{{count}}", @@ -4730,7 +4767,7 @@ export function TopologyPage() { - {regionDisplayName(umeWorldHexModules.drill)} + {regionDisplayName(umeWorldHexModules.drill, t)} {t("topology.regionNodeHint").replace( @@ -4798,7 +4835,7 @@ export function TopologyPage() { - {regionDisplayName(region)} + {regionDisplayName(region, t)} {t("topology.regionNodeHint").replace( "{{count}}", @@ -5164,9 +5201,10 @@ export function TopologyPage() { {( [ - ["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() {
    {(selectedEdgeData.members || []).map((m) => (
  • - {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)} +
  • ))}
diff --git a/web/src/pages/topology/linkDisplay.ts b/web/src/pages/topology/linkDisplay.ts index f154b78..7424a88 100644 --- a/web/src/pages/topology/linkDisplay.ts +++ b/web/src/pages/topology/linkDisplay.ts @@ -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", diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 50883de..46eb146 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1060,7 +1060,14 @@ export const fetchTopologyViews = () => export const fetchTopologyTree = () => apiGet("/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; diff --git a/web/src/types.ts b/web/src/types.ts index c6af4ec..5745ec2 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 = {