Polish topology tree UI and harden LLDP peer matching.

Make left-tree selection, hex browser, and sidebar chrome clearer while matching peers by hostname/ports instead of mgmt IP and reducing fabric-node insert deadlocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 15:42:15 +08:00
parent 3908b78dbb
commit cb8e0d23c5
6 changed files with 1520 additions and 364 deletions

View file

@ -2,15 +2,17 @@
from __future__ import annotations
import random
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from sqlalchemy import and_, func, or_, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@ -170,6 +172,54 @@ def _empty_to_none(s: str | None) -> str | None:
return v or None
# Postgres advisory-lock namespaces for fabric ensure (avoid cross-feature collisions).
_ADV_NS_FABRIC_MANAGED = 710001
_ADV_NS_FABRIC_UME = 710002
_DISCOVER_DEADLOCK_RETRIES = 4
def _is_postgres(db: Session) -> bool:
bind = db.get_bind()
return bind is not None and str(bind.dialect.name).lower() == "postgresql"
def _advisory_xact_lock(db: Session, namespace: int, key: str) -> None:
"""Serialize concurrent creates for the same unique key (Postgres only)."""
k = str(key or "").strip()
if not k or not _is_postgres(db):
return
db.execute(
text("SELECT pg_advisory_xact_lock(:ns, hashtext(:key))"),
{"ns": int(namespace), "key": k},
)
def _is_deadlock_error(exc: BaseException) -> bool:
"""True for Postgres 40P01 / SQLite 'database is locked' style races."""
cur: BaseException | None = exc
seen: set[int] = set()
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
pgcode = getattr(cur, "pgcode", None) or getattr(cur, "sqlstate", None)
if str(pgcode or "") == "40P01":
return True
msg = str(cur).lower()
if "deadlock" in msg or "40p01" in msg:
return True
orig = getattr(cur, "orig", None)
if isinstance(orig, BaseException) and id(orig) not in seen:
cur = orig
continue
cur = cur.__cause__ or cur.__context__ # type: ignore[assignment]
return False
def _sleep_deadlock_backoff(attempt: int) -> None:
# attempt is 0-based; jitter avoids thundering herd across workers.
base = 0.05 * (2**attempt)
time.sleep(base + random.uniform(0.0, 0.05))
# ---------------------------------------------------------------------------
# Fabric nodes / edges helpers
# ---------------------------------------------------------------------------
@ -254,6 +304,11 @@ def ensure_fabric_node_for_managed(db: Session, ne: ManagedNE) -> TopoFabricNode
row.updated_at = now
return row
row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none()
if row is not None:
return _apply(row)
# Serialize same-key creates across workers (cross-key deadlocks still retried upstream).
_advisory_xact_lock(db, _ADV_NS_FABRIC_MANAGED, mid)
row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none()
if row is not None:
return _apply(row)
@ -300,6 +355,10 @@ def ensure_fabric_node_for_ume(
row.updated_at = now
return row
row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none()
if row is not None:
return _apply(row)
_advisory_xact_lock(db, _ADV_NS_FABRIC_UME, uid)
row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none()
if row is not None:
return _apply(row)
@ -602,23 +661,40 @@ def upsert_fabric_edge(
.one_or_none()
)
if row is None:
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={},
discovered_at=now if src == "lldp" else None,
last_seen_at=now,
created_at=now,
updated_at=now,
)
db.add(row)
return row, "added"
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={},
discovered_at=now if src == "lldp" 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()
)
if row is None:
raise
if (row.source or "") == "manual" and src == "lldp":
return row, "kept_manual"
row.source = src
@ -631,6 +707,46 @@ def upsert_fabric_edge(
return row, "updated"
def _absorb_fabric_node(db: Session, canon: TopoFabricNode, dupe: TopoFabricNode) -> None:
"""Retarget edges/view placements from dupe onto canon, then delete dupe."""
if canon is None or dupe is None or canon.id == dupe.id:
return
if db.get(TopoFabricNode, dupe.id) is None:
return
_retarget_fabric_edges(db, from_id=dupe.id, to_id=canon.id)
vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == dupe.id).all()
for vn in vnodes:
exists = (
db.query(TopoViewNode)
.filter(
TopoViewNode.view_id == vn.view_id,
TopoViewNode.fabric_node_id == canon.id,
)
.one_or_none()
)
if exists is not None:
db.delete(vn)
else:
vn.fabric_node_id = canon.id
vn.updated_at = _utcnow()
db.delete(dupe)
def _prefer_fabric_canon(
db: Session, a: TopoFabricNode, b: TopoFabricNode
) -> tuple[TopoFabricNode, TopoFabricNode]:
"""Return (canon, dupe) preferring higher inventory score, then older row."""
sa = _fabric_match_score(db, a)
sb = _fabric_match_score(db, b)
if sa != sb:
return (a, b) if sa > sb else (b, a)
ta = a.created_at or a.updated_at
tb = b.created_at or b.updated_at
if ta and tb and ta != tb:
return (a, b) if ta <= tb else (b, a)
return (a, b) if a.id <= b.id else (b, a)
def _mark_replaced_port_peers(
db: Session,
*,
@ -643,6 +759,9 @@ def _mark_replaced_port_peers(
) -> list[str]:
"""Same local port now peers with a different NE → mark old edges missing (cutover).
If the previous peer is the same hostname (duplicate fabric rows for one device),
absorb the weaker node instead of marking the link missing.
Returns ids of edges touched by this replacement (skip re-bump in same job).
"""
now = now or _utcnow()
@ -650,6 +769,8 @@ def _mark_replaced_port_peers(
if not self_id or not peer_id or not lp:
return []
layer_v = str(layer or "physical").strip() or "physical"
new_peer = db.get(TopoFabricNode, peer_id)
new_name = _norm_host(new_peer.name if new_peer is not None else "")
candidates = (
db.query(TopoFabricEdge)
.filter(
@ -670,6 +791,47 @@ def _mark_replaced_port_peers(
continue
if e_peer == peer_id:
continue
old_peer = db.get(TopoFabricNode, e_peer)
old_name = _norm_host(old_peer.name if old_peer is not None else "")
# Same System Name under two fabric nodes → collapse, keep one link.
if (
new_peer is not None
and old_peer is not None
and new_name
and old_name
and new_name == old_name
):
canon, dupe = _prefer_fabric_canon(db, new_peer, old_peer)
_absorb_fabric_node(db, canon, dupe)
# Survivor edge on this port should stay active (retarget may have merged).
survivor = (
db.query(TopoFabricEdge)
.filter(
TopoFabricEdge.layer == layer_v,
or_(
and_(
TopoFabricEdge.a_node_id == self_id,
TopoFabricEdge.b_node_id == canon.id,
),
and_(
TopoFabricEdge.b_node_id == self_id,
TopoFabricEdge.a_node_id == canon.id,
),
),
)
.all()
)
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:
continue
se.status = "active"
se.attrs = _clear_miss_attrs(_edge_attrs(se))
se.last_seen_at = now
se.updated_at = now
handled.append(se.id)
handled.append(e.id)
continue
_set_edge_missing(e, now, replaced_by_edge_id=new_edge_id)
handled.append(e.id)
return handled
@ -1794,13 +1956,25 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int:
def _pick_managed_ne(
db: Session, *, ip: str = "", name_key: str = ""
) -> ManagedNE | None:
"""Pick inventory NE. Name matching uses hostname key (not LLDP mgmt IP)."""
rows: list[ManagedNE] = []
if ip:
if name_key:
key = _norm_host(name_key) or str(name_key or "").strip().lower()
if key:
candidates = (
db.query(ManagedNE)
.filter(
or_(
func.lower(ManagedNE.name) == key,
func.lower(ManagedNE.name).like(f"{key}.%"),
)
)
.all()
)
rows = [ne for ne in candidates if _norm_host(ne.name or "") == key]
elif ip:
# Kept for non-LLDP callers; LLDP peer match must not use this path.
rows = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).all()
elif name_key:
rows = (
db.query(ManagedNE).filter(func.lower(ManagedNE.name) == name_key).all()
)
if not rows:
return None
rows.sort(key=_ne_inventory_score, reverse=True)
@ -1877,18 +2051,18 @@ def ensure_lldp_discovered_managed_ne(
class _FabricPeerIndex:
"""In-memory name/IP index for one discover target (avoids O(nodes) per neighbor)."""
"""In-memory name index for one discover target (avoids O(nodes) per neighbor).
Identity is System Name / Device ID only. LLDP Management Address is often a
physical-interface IP and must not be used to pick the peer NE.
"""
def __init__(self, db: Session, self_id: str) -> None:
self.db = db
self.self_id = self_id
self.by_ip: dict[str, list[TopoFabricNode]] = {}
self.by_name: dict[str, list[TopoFabricNode]] = {}
self.placeholder_by_name: dict[str, ManagedNE] = {}
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id != self_id).all():
ip = str(n.ip or "").strip()
if ip:
self.by_ip.setdefault(ip, []).append(n)
nk = _norm_host(n.name or "")
if nk:
self.by_name.setdefault(nk, []).append(n)
@ -1900,53 +2074,35 @@ class _FabricPeerIndex:
self.placeholder_by_name[nk] = ne
def _best(self, matched: list[TopoFabricNode]) -> TopoFabricNode:
matched.sort(key=lambda n: _fabric_match_score(self.db, n), reverse=True)
# Prefer real inventory; ties → older fabric row (stable across rediscovers).
matched.sort(
key=lambda n: (
-_fabric_match_score(self.db, n),
n.created_at.timestamp() if n.created_at else 0.0,
n.id,
)
)
return matched[0]
def match(self, hit: NeighborHit) -> TopoFabricNode | None:
name_key = _norm_host(hit.remote_name)
ip_key = str(hit.remote_ip or "").strip()
matched: list[TopoFabricNode] = []
if ip_key:
matched.extend(self.by_ip.get(ip_key) or [])
if name_key:
for n in self.by_name.get(name_key) or []:
if n not in matched:
matched.append(n)
if not name_key:
return None
matched = list(self.by_name.get(name_key) or [])
if matched:
return self._best(matched)
if ip_key:
ne = _pick_managed_ne(self.db, ip=ip_key)
if ne is not None:
node = ensure_fabric_node_for_managed(self.db, ne)
self._remember(node)
return node
ume = (
self.db.query(UmeInventoryNE)
.filter(UmeInventoryNE.ip_address == ip_key)
.first()
)
if ume is not None:
node = ensure_fabric_node_for_ume(self.db, ume)
self._remember(node)
return node
if name_key:
ne = _pick_managed_ne(self.db, name_key=name_key)
if ne is not None:
node = ensure_fabric_node_for_managed(self.db, ne)
self._remember(node)
return node
ne = _pick_managed_ne(self.db, name_key=name_key)
if ne is not None:
node = ensure_fabric_node_for_managed(self.db, ne)
self._remember(node)
return node
return None
def _remember(self, node: TopoFabricNode) -> None:
if not node or node.id == self.self_id:
return
ip = str(node.ip or "").strip()
if ip:
bucket = self.by_ip.setdefault(ip, [])
if node not in bucket:
bucket.append(node)
nk = _norm_host(node.name or "")
if nk:
bucket = self.by_name.setdefault(nk, [])
@ -2047,24 +2203,9 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]:
for d in dupes:
if d.id == canon.id:
continue
_retarget_fabric_edges(db, from_id=d.id, to_id=canon.id)
# View placements: keep canon if present, else retarget; drop duplicate placements.
vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == d.id).all()
for vn in vnodes:
exists = (
db.query(TopoViewNode)
.filter(
TopoViewNode.view_id == vn.view_id,
TopoViewNode.fabric_node_id == canon.id,
)
.one_or_none()
)
if exists is not None:
db.delete(vn)
else:
vn.fabric_node_id = canon.id
vn.updated_at = _utcnow()
db.delete(d)
if db.get(TopoFabricNode, d.id) is None:
continue
_absorb_fabric_node(db, canon, d)
merged += 1
seen_absorb: set[str] = set()
@ -2368,12 +2509,20 @@ def _discover_one_target(
*,
auto_add_unmatched: bool,
) -> dict[str, Any]:
"""Run LLDP for one NE in a fresh DB session."""
"""Run LLDP for one NE in a fresh DB session.
Keep the write txn short: resolve self fabric → commit → SSH → apply peers/edges
(with deadlock retries). Holding inserts across SSH was a major deadlock source.
"""
base = {
"ne_id": target.get("ne_id") or "",
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": "",
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
}
db = SessionLocal()
try:
now = _utcnow()
if target["ume_ne_id"] and not target["ne_id"]:
pass
fabric_node: TopoFabricNode | None = None
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
if managed is not None:
@ -2392,15 +2541,12 @@ def _discover_one_target(
vendor=target.get("vendor") or "",
)
if fabric_node is None:
return {
"ne_id": target["ne_id"],
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": "",
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
"ok": False,
"error": "fabric_node_resolve_failed",
}
return {**base, "ok": False, "error": "fabric_node_resolve_failed"}
fabric_node_id = fabric_node.id
base["fabric_node_id"] = fabric_node_id
# Release unique-index locks before slow SSH.
db.commit()
cmd, _proto = pick_neighbor_command(
vendor=target.get("vendor") or "",
@ -2414,25 +2560,15 @@ def _discover_one_target(
try:
exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs)
except HTTPException as exc:
db.commit()
return {
"ne_id": target["ne_id"],
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": fabric_node.id,
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
**base,
"ok": False,
"command": cmd,
"error": str(exc.detail or "exec_failed")[:500],
}
if not exec_out.get("ok"):
db.commit()
return {
"ne_id": target["ne_id"],
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": fabric_node.id,
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
**base,
"ok": False,
"command": cmd,
"error": str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
@ -2448,100 +2584,163 @@ def _discover_one_target(
vendor=target.get("vendor") or "",
device_type=target.get("device_type") or "",
)
added = 0
updated = 0
unmatched: list[dict[str, str]] = []
touched: list[str] = []
replaced: list[str] = []
peer_index = _FabricPeerIndex(db, fabric_node.id)
for hit in hits:
peer = peer_index.match(hit)
if peer is None:
if auto_add_unmatched and (hit.remote_name or hit.remote_ip):
# Not in inventory → SSH placeholder ManagedNE (empty IP/creds).
peer = peer_index.ensure_placeholder(
remote_name=(hit.remote_name or "").strip(),
remote_ip=(hit.remote_ip or "").strip(),
)
peer.attrs = dict(peer.attrs or {})
peer.attrs["from_lldp_unmatched"] = True
peer.last_seen_at = now
peer.updated_at = now
else:
unmatched.append(
{
"remote_name": (hit.remote_name or "").strip()[:256],
"remote_ip": (hit.remote_ip or "").strip()[:128],
"local_port": (hit.local_port or "").strip()[:128],
"remote_port": (hit.remote_port or "").strip()[:128],
}
)
continue
edge, action = upsert_fabric_edge(
db,
a_node_id=fabric_node.id,
b_node_id=peer.id,
a_port=(hit.local_port or ""),
b_port=(hit.remote_port or ""),
source="lldp",
now=now,
)
touched.append(edge.id)
# Same local port, different peer → immediate missing (cutover).
replaced.extend(
_mark_replaced_port_peers(
db,
self_id=fabric_node.id,
local_port=(hit.local_port or ""),
peer_id=peer.id,
new_edge_id=edge.id,
now=now,
)
)
if action == "added":
added += 1
elif action == "updated":
updated += 1
fabric_node.last_seen_at = now
fabric_node.updated_at = now
db.commit()
stub_flag = bool(is_stub and raw.strip() and not hits)
apply_out = _apply_discover_hits(
db,
fabric_node_id=fabric_node_id,
hits=hits,
auto_add_unmatched=auto_add_unmatched,
)
if not apply_out.get("ok"):
return {
**base,
"ok": False,
"command": cmd,
"parser_key": pkey,
"parser_stub": stub_flag,
"error": str(apply_out.get("error") or "apply_failed")[:500],
"raw_preview": _raw_preview(raw),
}
return {
"ne_id": target["ne_id"],
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": fabric_node.id,
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
**base,
"ok": True,
"command": cmd,
"neighbors": len(hits),
"edges_added": added,
"edges_updated": updated,
"unmatched_count": len(unmatched),
"unmatched": unmatched[:40],
"edges_added": int(apply_out.get("edges_added") or 0),
"edges_updated": int(apply_out.get("edges_updated") or 0),
"unmatched_count": int(apply_out.get("unmatched_count") or 0),
"unmatched": list(apply_out.get("unmatched") or []),
"parser_key": pkey,
"parser_stub": stub_flag,
"error": "parser_stub" if stub_flag else "",
"raw_preview": _raw_preview(raw),
"touched_edge_ids": touched,
"replaced_edge_ids": replaced,
"scanned_node_id": fabric_node.id,
"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,
}
except Exception as exc: # noqa: BLE001
db.rollback()
return {
"ne_id": target.get("ne_id") or "",
"ume_ne_id": target.get("ume_ne_id") or "",
"fabric_node_id": "",
"ne_name": target.get("ne_name") or "",
"ne_ip": target.get("ne_ip") or "",
"ok": False,
"error": str(exc)[:500],
}
return {**base, "ok": False, "error": str(exc)[:500]}
finally:
db.close()
def _apply_discover_hits(
db: Session,
*,
fabric_node_id: str,
hits: list[NeighborHit],
auto_add_unmatched: bool,
) -> dict[str, Any]:
"""Write peer fabric nodes + edges; retry on Postgres deadlocks."""
last_err = ""
for attempt in range(_DISCOVER_DEADLOCK_RETRIES):
try:
now = _utcnow()
fabric_node = db.get(TopoFabricNode, fabric_node_id)
if fabric_node is None:
return {"ok": False, "error": "fabric_node_missing"}
added = 0
updated = 0
unmatched: list[dict[str, str]] = []
touched: list[str] = []
replaced: list[str] = []
peer_index = _FabricPeerIndex(db, fabric_node.id)
for hit in hits:
peer = peer_index.match(hit)
if peer is None:
if auto_add_unmatched and (hit.remote_name or hit.remote_ip):
peer = peer_index.ensure_placeholder(
remote_name=(hit.remote_name or "").strip(),
remote_ip=(hit.remote_ip or "").strip(),
)
peer.attrs = dict(peer.attrs or {})
peer.attrs["from_lldp_unmatched"] = True
peer.last_seen_at = now
peer.updated_at = now
else:
unmatched.append(
{
"remote_name": (hit.remote_name or "").strip()[:256],
"remote_ip": (hit.remote_ip or "").strip()[:128],
"local_port": (hit.local_port or "").strip()[:128],
"remote_port": (hit.remote_port or "").strip()[:128],
}
)
continue
edge, action = upsert_fabric_edge(
db,
a_node_id=fabric_node.id,
b_node_id=peer.id,
a_port=(hit.local_port or ""),
b_port=(hit.remote_port or ""),
source="lldp",
now=now,
)
touched.append(edge.id)
replaced.extend(
_mark_replaced_port_peers(
db,
self_id=fabric_node.id,
local_port=(hit.local_port or ""),
peer_id=peer.id,
new_edge_id=edge.id,
now=now,
)
)
if action == "added":
added += 1
elif action == "updated":
updated += 1
fabric_node.last_seen_at = now
fabric_node.updated_at = now
db.commit()
return {
"ok": True,
"edges_added": added,
"edges_updated": updated,
"unmatched_count": len(unmatched),
"unmatched": unmatched[:40],
"touched_edge_ids": touched,
"replaced_edge_ids": replaced,
}
except Exception as exc: # noqa: BLE001
db.rollback()
last_err = str(exc)[:500]
if _is_deadlock_error(exc) and attempt + 1 < _DISCOVER_DEADLOCK_RETRIES:
_sleep_deadlock_backoff(attempt)
continue
return {"ok": False, "error": last_err}
return {"ok": False, "error": last_err or "apply_failed"}
def _preensure_discover_targets(db: Session, targets: list[dict[str, str]]) -> None:
"""Create fabric rows for scan targets before parallel workers start."""
for target in targets:
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
if managed is not None:
ensure_fabric_node_for_managed(db, managed)
continue
if not target.get("ume_ne_id"):
continue
ume = (
db.query(UmeInventoryNE)
.filter(UmeInventoryNE.ne_id == target["ume_ne_id"])
.one_or_none()
)
if ume is not None:
ensure_fabric_node_for_ume(
db,
ume,
device_type=target.get("device_type") or "",
vendor=target.get("vendor") or "",
)
db.commit()
def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
db = SessionLocal()
try:
@ -2563,6 +2762,12 @@ def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
job.total = len(targets)
db.commit()
# Reduce cross-worker races on self nodes before concurrent SSH/apply.
try:
_preensure_discover_targets(db, targets)
except Exception: # noqa: BLE001
db.rollback()
concurrency = max(1, min(32, int(body.concurrency or 4)))
added = 0
updated = 0

View file

@ -7,6 +7,9 @@ import unittest
from unittest.mock import patch
from uuid import uuid4
from sqlalchemy import or_
from sqlalchemy.exc import OperationalError
from netx_api import topology_lldp as lldp
from netx_api import topology_service as svc
from netx_api.db import Base, SessionLocal, engine
@ -97,6 +100,17 @@ class LldpParserTests(unittest.TestCase):
self.assertEqual(cmd, "show lldp neighbors detail")
class DeadlockHelperTests(unittest.TestCase):
def test_is_deadlock_error_detects_pg_message(self) -> None:
exc = Exception(
'(psycopg.errors.DeadlockDetected) 检测到死锁 DETAIL: 进程28484等待'
)
self.assertTrue(svc._is_deadlock_error(exc))
def test_is_deadlock_error_ignores_other(self) -> None:
self.assertFalse(svc._is_deadlock_error(ValueError("unique violation")))
class FabricTopologyTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
@ -125,6 +139,62 @@ class FabricTopologyTests(unittest.TestCase):
def tearDown(self) -> None:
self.db.close()
def test_apply_discover_hits_retries_deadlock(self) -> None:
suffix = uuid4().hex[:8]
ne = ManagedNE(
id=f"nea-{suffix}",
name=f"R2-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 20}",
)
peer_ne = ManagedNE(
id=f"neb-{suffix}",
name="r1",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 120}",
)
self.db.add(ne)
self.db.add(peer_ne)
self.db.commit()
node = svc.ensure_fabric_node_for_managed(self.db, ne)
svc.ensure_fabric_node_for_managed(self.db, peer_ne)
self.db.commit()
hits = [
lldp.NeighborHit(
remote_name="r1",
local_port="Gi0/1",
remote_port="Ethernet1/0/1",
)
]
calls = {"n": 0}
real_upsert = svc.upsert_fabric_edge
def flaky_upsert(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise OperationalError(
"INSERT",
{},
Exception("DeadlockDetected: fake"),
)
return real_upsert(*args, **kwargs)
with (
patch.object(svc, "upsert_fabric_edge", side_effect=flaky_upsert),
patch.object(svc, "_sleep_deadlock_backoff", return_value=None),
):
out = svc._apply_discover_hits(
self.db,
fabric_node_id=node.id,
hits=hits,
auto_add_unmatched=False,
)
self.assertTrue(out.get("ok"), out)
self.assertEqual(calls["n"], 2)
self.assertGreaterEqual(int(out.get("edges_added") or 0), 1)
def _region(self, name: str = "Test-Region") -> str:
return svc.create_folder(
self.db, TopologyFolderCreate(name=name, kind="region")
@ -742,7 +812,15 @@ Management Addresses:
inv = svc.ensure_fabric_node_for_managed(self.db, real)
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
self.db.commit()
hit = lldp.NeighborHit(remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1")
# LLDP management IP alone is not identity — need System Name.
ip_only = lldp.NeighborHit(
remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
)
self.assertIsNone(svc._match_hit_to_fabric_node(self.db, ip_only, self_id="self"))
hit = lldp.NeighborHit(
remote_name="R2", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
)
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
self.assertEqual(peer.id, inv.id)
self.assertNotEqual(peer.id, ghost.id)
@ -755,6 +833,127 @@ Management Addresses:
self.db.delete(webcrt)
self.db.commit()
def test_match_ignores_lldp_mgmt_ip_prefers_hostname(self) -> None:
"""Two inventory NEs both named r1 with different IPs — match by name, not LLDP IP."""
suffix = uuid4().hex[:8]
ne_real = ManagedNE(
id=f"real-{suffix}",
name="r1",
vendor="Huawei",
device_type="huawei",
ip_address="192.168.0.127",
source="manual",
)
ne_wrong = ManagedNE(
id=f"wrong-{suffix}",
name="r1-lab", # different hostname key
vendor="Cisco",
device_type="cisco_ios",
ip_address="203.0.113.184",
source="manual",
)
self.db.add(ne_real)
self.db.add(ne_wrong)
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_real)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_wrong)
self.db.commit()
hit = lldp.NeighborHit(
remote_name="r1",
remote_ip="203.0.113.184", # misleading interface/mgmt IP
local_port="Gi0/1",
remote_port="Ethernet1/0/1",
)
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
self.assertEqual(peer.id, fa.id)
self.assertNotEqual(peer.id, fb.id)
self.db.delete(ne_real)
self.db.delete(ne_wrong)
self.db.commit()
def test_same_hostname_port_cutover_merges_not_missing(self) -> None:
"""Duplicate fabric rows for same hostname must not leave a red replaced edge."""
suffix = uuid4().hex[:8]
ne_a = ManagedNE(
id=f"nea-{suffix}",
name=f"R2-{suffix}",
vendor="Cisco",
device_type="cisco_ios",
ip_address=f"192.168.0.{(int(suffix[:2], 16) % 80) + 10}",
)
ne_b = ManagedNE(
id=f"neb-{suffix}",
name="r1",
vendor="Cisco",
device_type="cisco_ios",
ip_address="192.168.0.127",
source="manual",
)
ne_c = ManagedNE(
id=f"nec-{suffix}",
name="r1",
vendor="Other",
device_type="generic",
ip_address="203.0.113.184",
source=LLDP_DISCOVERED_NE_SOURCE,
)
self.db.add_all([ne_a, ne_b, ne_c])
self.db.commit()
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
self.db.commit()
old, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fb.id,
a_port="Gi0/1",
b_port="Ethernet1/0/1",
source="lldp",
)
new, _ = svc.upsert_fabric_edge(
self.db,
a_node_id=fa.id,
b_node_id=fc.id,
a_port="Gi0/1",
b_port="Ethernet1/0/1",
source="lldp",
)
self.db.commit()
handled = svc._mark_replaced_port_peers(
self.db,
self_id=fa.id,
local_port="Gi0/1",
peer_id=fc.id,
new_edge_id=new.id,
)
self.db.commit()
self.db.expire_all()
# Placeholder fabric absorbed into real inventory; no missing/replaced link.
self.assertIsNone(self.db.get(TopoFabricNode, fc.id))
edges = (
self.db.query(TopoFabricEdge)
.filter(
or_(TopoFabricEdge.a_node_id == fa.id, TopoFabricEdge.b_node_id == fa.id)
)
.all()
)
active = [e for e in edges if e.status == "active"]
missing = [e for e in edges if e.status == "missing"]
self.assertEqual(len(active), 1)
self.assertEqual(len(missing), 0)
peer_id = active[0].b_node_id if active[0].a_node_id == fa.id else active[0].a_node_id
self.assertEqual(peer_id, fb.id)
self.assertTrue(handled)
self.db.delete(ne_a)
self.db.delete(ne_b)
self.db.delete(ne_c)
self.db.commit()
def test_neighborhood(self) -> None:
suffix = uuid4().hex[:8]
ne_a = ManagedNE(

View file

@ -1225,12 +1225,20 @@ const en = {
tree: "Topology tree",
treeLoading: "Loading tree…",
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
treeSearch: "Search NEs across maps",
treeSearchPh: "Name / IP",
treeSearchEmpty: "No matching NEs",
treeSearchNoViews: "Not on any map",
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
treeSearchViewCount: "{{count}} map(s)",
treeSearchTruncated: "Showing first 30 of {{total}}",
rootName: "Regions",
selectRegion: "Select a region",
openMap: "Open map",
openRegion: "Open region",
kindPhysical: "Physical",
kindCustom: "Custom",
regionNodeHint: "{{count}} map(s)",
regionNodeHint: "{{count}}P",
layerNodeHint: "{{count}} NE(s)",
layerNodeEmpty: "Click to create & open",
rootCanvasHint: "Pick a region from the left tree.",
@ -1238,6 +1246,7 @@ const en = {
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
browserTitle: "Topology folder",
browserRegionSub: "{{count}} map(s)",
browserRegionsSub: "{{count}} region(s)",
browserEmptyMaps: "No maps in this region yet.",
addNe: "Add NE",
backUp: "Back to region",

View file

@ -1219,12 +1219,20 @@ const zh = {
tree: "拓扑树",
treeLoading: "加载拓扑树…",
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
treeSearch: "全拓扑搜索网元",
treeSearchPh: "名称 / IP",
treeSearchEmpty: "没有匹配的网元",
treeSearchNoViews: "未上图",
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
treeSearchViewCount: "{{count}} 张图",
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
rootName: "区域",
selectRegion: "请选择区域",
openMap: "打开本图",
openRegion: "打开区域",
kindPhysical: "物理拓扑",
kindCustom: "自定义图",
regionNodeHint: "{{count}} 张图",
regionNodeHint: "{{count}}P",
layerNodeHint: "{{count}} 个网元",
layerNodeEmpty: "点击创建并打开",
rootCanvasHint: "从左侧选择区域进入目录。",
@ -1232,6 +1240,7 @@ const zh = {
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
browserTitle: "拓扑目录",
browserRegionSub: "共 {{count}} 张拓扑图",
browserRegionsSub: "共 {{count}} 个区域",
browserEmptyMaps: "此区域还没有拓扑图。",
addNe: "添加网元",
backUp: "返回区域",

View file

@ -3514,23 +3514,155 @@ pre {
flex: 1;
}
.topo-sidebar__head {
display: flex;
.app-main .topo-sidebar__icon-btn,
.topo-sidebar__icon-btn {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
justify-content: center;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0 !important;
margin: 0;
border: 0 !important;
border-radius: 4px !important;
background: transparent !important;
box-shadow: none !important;
color: #64748b;
cursor: pointer;
flex: 0 0 auto;
}
.topo-sidebar__head > strong {
font-size: 13px;
font-weight: 650;
color: var(--nm-brand);
.app-main .topo-sidebar__icon-btn:hover:not(:disabled),
.topo-sidebar__icon-btn:hover:not(:disabled) {
color: var(--nm-brand, #0f2744);
background: rgba(15, 39, 68, 0.06) !important;
}
.topo-sidebar__head-actions {
.app-main .topo-sidebar__icon-btn:disabled,
.topo-sidebar__icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.topo-tree-search {
position: relative;
margin: 0 0 4px;
}
.topo-tree-search__bar {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.topo-tree-search__bar > .input {
flex: 1 1 auto;
min-width: 0;
}
.topo-tree-search__panel {
position: absolute;
z-index: 40;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: min(360px, 50vh);
overflow: auto;
padding: 4px;
border: 1px solid #b8c4d2;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.topo-tree-search__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px;
border-radius: 6px;
}
.topo-tree-search__item + .topo-tree-search__item {
margin-top: 2px;
border-top: 1px solid #eef2f6;
padding-top: 4px;
}
.topo-tree-search__ne {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
gap: 0 8px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
cursor: pointer;
color: inherit;
}
.topo-tree-search__ne:hover {
background: #f1f5f9;
}
.topo-tree-search__name {
grid-column: 1;
font-size: 12px;
font-weight: 600;
color: var(--nm-ink, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-tree-search__ip {
grid-column: 1;
font-size: 11px;
}
.topo-tree-search__meta {
grid-column: 2;
grid-row: 1 / span 2;
align-self: center;
font-size: 11px;
white-space: nowrap;
}
.topo-tree-search__views {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 0 6px 4px;
}
.topo-tree-search__view {
max-width: 100%;
padding: 2px 8px;
border: 1px solid #d0dae6;
border-radius: 999px;
background: #f8fafc;
font-size: 11px;
color: var(--nm-brand, #1d4ed8);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-tree-search__view:hover {
background: #e8eef8;
border-color: #9eb6d8;
}
.topo-tree-search__hint {
margin: 0;
padding: 8px;
font-size: 12px;
}
.topo-palette-source {
@ -3859,9 +3991,156 @@ pre {
gap: 14px;
align-content: start;
padding: 16px 18px 20px;
background-image:
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
background-size: 18px 18px;
background: #eef2f7;
}
.topo-browser__grid--regions {
grid-template-columns: repeat(auto-fill, minmax(148px, 160px));
gap: 20px 22px;
padding: 24px 22px 32px;
background: #e8eef6;
justify-content: start;
}
/* Beat .app-main button { background:#fff; color:#1f2937; border } */
.app-main .topo-region-hex,
.topo-region-hex {
width: 140px;
height: 156px;
box-sizing: border-box;
border: 0 !important;
border-radius: 0;
box-shadow: none;
padding: 34px 18px 30px;
margin: 0;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
text-align: center;
cursor: pointer;
color: #fff !important;
transition: transform 0.15s ease, filter 0.15s ease;
}
.app-main .topo-region-hex:hover,
.topo-region-hex:hover,
.app-main .topo-region-hex.is-hot,
.topo-region-hex.is-hot {
transform: translateY(-2px) scale(1.03);
filter: brightness(1.06);
}
.app-main .topo-region-hex.is-selected,
.topo-region-hex.is-selected,
.app-main .topo-region-hex.is-hot,
.topo-region-hex.is-hot {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.92),
0 0 0 6px rgba(26, 86, 168, 0.55);
z-index: 1;
}
.app-main .topo-region-hex.is-selected,
.topo-region-hex.is-selected {
filter: brightness(1.08);
transform: translateY(-2px) scale(1.04);
}
.app-main .topo-region-hex--tone-0,
.topo-region-hex--tone-0 {
background: linear-gradient(160deg, #5b9cf0 0%, #1a56a8 52%, #0f766e 100%) !important;
}
.app-main .topo-region-hex--tone-1,
.topo-region-hex--tone-1 {
background: linear-gradient(160deg, #67e8f9 0%, #0284c7 48%, #1d4ed8 100%) !important;
}
.app-main .topo-region-hex--tone-2,
.topo-region-hex--tone-2 {
background: linear-gradient(160deg, #5eead4 0%, #0d9488 50%, #115e59 100%) !important;
}
.app-main .topo-region-hex--tone-3,
.topo-region-hex--tone-3 {
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 46%, #b45309 100%) !important;
}
.app-main .topo-region-hex--tone-4,
.topo-region-hex--tone-4 {
background: linear-gradient(160deg, #cbd5e1 0%, #64748b 48%, #1e3a5f 100%) !important;
}
.app-main .topo-region-hex--tone-physical,
.topo-region-hex--tone-physical {
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 42%, #b45309 100%) !important;
}
.app-main .topo-region-hex--add,
.topo-region-hex--add {
background: linear-gradient(160deg, #f8fafc 0%, #dbe7f5 100%) !important;
color: #1a56a8 !important;
}
.app-main .topo-region-hex--add:hover,
.topo-region-hex--add:hover {
background: linear-gradient(160deg, #ffffff 0%, #cfe0f4 100%) !important;
color: #0f2744 !important;
}
.app-main .topo-region-hex--add:disabled,
.topo-region-hex--add:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.topo-region-hex__icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.22);
color: #fff;
flex: 0 0 auto;
}
.topo-region-hex--add .topo-region-hex__icon {
background: rgba(26, 86, 168, 0.12);
color: #1a56a8;
}
.topo-region-hex__name {
max-width: 100%;
font-size: 14px;
font-weight: 700;
line-height: 1.25;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topo-region-hex__title {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 4px 6px;
max-width: 100%;
min-width: 0;
}
.topo-region-hex__title .topo-region-hex__name {
max-width: 70%;
}
.topo-region-hex__meta {
display: inline;
flex: 0 0 auto;
font-size: 11px;
font-weight: 600;
line-height: 1.25;
color: rgba(255, 255, 255, 0.95);
white-space: nowrap;
}
.topo-region-hex--add .topo-region-hex__meta {
color: #3d6f9c;
}
.topo-region-hex__plus {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 22px;
line-height: 1;
background: rgba(26, 86, 168, 0.12);
color: #1a56a8;
flex: 0 0 auto;
}
.topo-map-card {
display: flex;
@ -3988,22 +4267,51 @@ pre {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
gap: 2px 6px;
min-width: 0;
font-size: 13px;
font-weight: 550;
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--nm-brand, #0f2744);
}
.app-main .topo-breadcrumb__link,
.topo-breadcrumb__link {
border: 0;
background: transparent;
color: var(--nm-accent, #2563eb);
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: #5b6b7c !important;
cursor: pointer;
padding: 0;
padding: 0 !important;
margin: 0;
border-radius: 0 !important;
font: inherit;
font-weight: 550;
line-height: inherit;
width: auto;
}
.app-main .topo-breadcrumb__link:hover,
.topo-breadcrumb__link:hover {
color: var(--nm-accent, #1a56a8) !important;
background: transparent !important;
text-decoration: underline;
text-underline-offset: 2px;
}
.topo-breadcrumb__sep {
opacity: 0.45;
color: #94a3b8;
opacity: 1;
font-weight: 400;
user-select: none;
}
.topo-breadcrumb__current {
font-weight: 600;
display: inline;
padding: 0;
border-radius: 0;
font: inherit;
font-weight: 700;
color: var(--nm-accent, #1a56a8);
background: transparent;
box-shadow: none;
}
.topo-toolbar__level {
margin-left: 8px;
@ -4277,7 +4585,7 @@ pre {
.topo-region-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 2px;
padding: 2px 0;
}
@ -4285,49 +4593,216 @@ pre {
margin: 0;
width: 100%;
min-width: 0;
padding: 2px 0 4px;
}
.topo-region-list__row {
align-items: stretch;
align-items: center;
}
.topo-region-list__block.is-active > .topo-region-list__row {
border-color: var(--nm-accent);
background: var(--nm-nav-active);
box-shadow: inset 2px 0 0 var(--nm-accent);
/* Flat list rows — no boxed cards */
.topo-region-list .topo-map-list__row {
border: 0;
border-radius: 8px;
background: transparent;
box-shadow: none;
overflow: visible;
gap: 2px;
padding-right: 2px;
transition: background 0.12s ease;
}
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions {
border-left-color: #9bb6d9;
.topo-region-list .topo-map-list__row:hover,
.topo-region-list__block.is-hot > .topo-region-list__row,
.topo-region-list__maps > li.is-hot > .topo-map-list__row {
background: rgba(255, 255, 255, 0.78);
}
/* Selected = deep wash + left accent */
.topo-region-list__block.is-active > .topo-region-list__row,
.topo-region-list__maps > li.is-active > .topo-map-list__row {
border: 0;
background: #8eb6e3;
box-shadow: inset 3px 0 0 #0f3f7a;
}
.topo-region-list__block.is-branch-active > .topo-region-list__row {
border: 0;
background: rgba(142, 182, 227, 0.35);
box-shadow: none;
}
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions,
.topo-region-list__maps > li.is-active > .topo-map-list__row .topo-map-list__actions {
border-left: 0;
}
.topo-region-list__glyph {
display: inline-flex;
align-items: center;
margin-right: 5px;
vertical-align: -2px;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 2px;
border-radius: 6px;
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
flex: 0 0 auto;
}
.topo-region-list__maps .topo-region-list__glyph {
background: rgba(26, 86, 168, 0.1);
color: #1a56a8;
}
.topo-region-list__maps {
margin: 4px 0 0 18px;
padding: 0;
position: relative;
margin: 2px 0 0;
padding: 2px 0 2px 0;
overflow: visible;
flex: none;
}
.topo-region-list__maps::before {
content: "";
position: absolute;
left: 18px;
top: 0;
bottom: 6px;
width: 1px;
background: rgba(148, 163, 184, 0.55);
}
.topo-region-list__maps > li {
margin: 0 0 4px;
margin: 0;
width: 100%;
position: relative;
}
.topo-region-list__maps > li + li {
margin-top: 1px;
}
.topo-region-list__maps .topo-map-list__row {
width: 100%;
}
.app-main .topo-region-list .topo-map-list__item,
.topo-region-list .topo-map-list__item {
padding: 8px 6px 8px 8px;
border-radius: 8px;
}
.app-main .topo-region-list__maps .topo-map-list__item,
.topo-region-list__maps .topo-map-list__item {
padding: 7px 6px 7px 36px;
}
.topo-region-list .topo-map-list__item:hover,
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__item,
.topo-region-list__block.is-branch-active > .topo-region-list__row .topo-map-list__item,
.topo-region-list__maps > li.is-active .topo-map-list__item,
.topo-region-list__maps > li.is-active .topo-map-list__item:hover {
background: transparent !important;
}
.topo-region-list .topo-map-list__actions {
opacity: 0;
pointer-events: none;
border-left: 0;
gap: 4px;
padding-right: 4px;
align-items: center;
transition: opacity 0.12s ease;
}
/* Edit/delete only while the row itself is hovered */
.topo-region-list .topo-map-list__row:hover .topo-map-list__actions {
opacity: 1;
pointer-events: auto;
}
.app-main .topo-region-list .topo-map-list__icon,
.topo-region-list .topo-map-list__icon {
width: 24px;
height: 24px;
min-height: 24px;
border-radius: 5px;
color: #475569;
background: transparent !important;
border: 0 !important;
}
.app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled),
.topo-region-list .topo-map-list__icon:hover:not(:disabled) {
background: rgba(15, 39, 68, 0.12) !important;
color: var(--nm-brand);
}
.topo-region-list .topo-map-list__name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.topo-region-list .topo-map-list__title {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
color: var(--nm-brand, #0f2744);
}
.topo-region-list__maps .topo-map-list__title {
font-size: 12px;
font-weight: 600;
}
.topo-region-list .topo-map-list__count {
flex: 0 0 auto;
margin-left: 4px;
padding: 0;
border-radius: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: #64748b;
background: transparent;
box-shadow: none;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
.topo-region-list__maps .topo-map-list__count {
color: #64748b;
background: transparent;
}
.topo-region-list__block.is-active .topo-map-list__count,
.topo-region-list__maps > li.is-active .topo-map-list__count {
background: transparent;
color: #0a274f;
}
.topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count,
.topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count {
background: transparent;
color: #1a56a8;
}
.topo-region-list__block.is-active .topo-map-list__title,
.topo-region-list__maps > li.is-active .topo-map-list__title {
color: #0a274f;
font-weight: 700;
}
.topo-region-list__maps > li.is-active .topo-region-list__glyph {
background: #0f3f7a;
color: #fff;
}
.topo-map-list li,
@ -4361,6 +4836,29 @@ pre {
box-shadow: inset 2px 0 0 var(--nm-accent);
}
.topo-region-list__block.is-active > .topo-map-list__row,
.topo-region-list__maps > li.is-active > .topo-map-list__row {
border: 0 !important;
background: #8eb6e3 !important;
box-shadow: inset 3px 0 0 #0f3f7a !important;
}
.topo-region-list__block.is-active > .topo-map-list__row:hover,
.topo-region-list__maps > li.is-active > .topo-map-list__row:hover {
background: #7aa8db !important;
}
.topo-region-list__block.is-branch-active > .topo-map-list__row {
border: 0 !important;
background: rgba(142, 182, 227, 0.35) !important;
box-shadow: none !important;
}
.topo-region-list__block.is-hot:not(.is-active):not(.is-branch-active) > .topo-map-list__row,
.topo-region-list__maps > li.is-hot:not(.is-active) > .topo-map-list__row {
background: #c5daf3 !important;
}
.topo-palette > li.is-on-canvas .topo-palette__row {
background: #eef2f7;
border-color: #d7dee8;

View file

@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ReactFlow,
@ -39,6 +39,7 @@ import {
patchTopologyPositions,
projectTopologyNeighbors,
removeTopologyViewNodes,
searchFabricNodes,
startLldpDiscover,
updateTopologyFolder,
updateTopologyMap,
@ -49,6 +50,7 @@ import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
ManagedNeItem,
TopologyDiscoverJob,
TopologyDiscoverNeResult,
@ -70,10 +72,6 @@ import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/tool
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
function kindLabelKey(kind: string): string {
return String(kind) === "physical" ? "topology.kindPhysical" : "topology.kindCustom";
}
function findViewInRegion(
regions: TopologyTreeFolderItem[],
viewId: string,
@ -127,36 +125,15 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
const SEP = " / ";
function ChevronIcon({ dir }: { dir: "left" | "right" | "down" }) {
const d =
dir === "left" ? "M15 6l-6 6 6 6" : dir === "down" ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6";
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<path fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/** Left-panel collapse / expand control (icon-only sidebar toggle). */
/** Left-panel collapse / expand control (simple chevron). */
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
return (
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
<rect
x="3.5"
y="4.5"
width="17"
height="15"
rx="2"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
/>
<path d="M9.5 4.5v15" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path
d={expand ? "M13.5 9.5L17 12l-3.5 2.5" : "M16.5 9.5L13 12l3.5 2.5"}
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
@ -632,6 +609,8 @@ export function TopologyPage() {
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
const [mapId, setMapId] = useState<string>("");
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
/** Hover sync key between left tree and right hex browser: `region:<id>` | `view:<id>`. */
const [hotBrowseKey, setHotBrowseKey] = useState("");
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
try {
@ -643,6 +622,10 @@ export function TopologyPage() {
});
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [treeNeQuery, setTreeNeQuery] = useState("");
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
const treeSearchRef = useRef<HTMLDivElement | null>(null);
const [dirty, setDirty] = useState(false);
const [historyTick, setHistoryTick] = useState(0);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
@ -755,6 +738,17 @@ export function TopologyPage() {
queryFn: fetchTopologyTree,
});
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
return () => window.clearTimeout(timer);
}, [treeNeQuery]);
const treeNeSearchQuery = useQuery({
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
enabled: debouncedTreeNeQuery.length >= 1,
});
const graphQuery = useQuery({
queryKey: queryKeys.topologyGraph(mapId),
queryFn: () => fetchTopologyGraph(mapId),
@ -815,11 +809,9 @@ export function TopologyPage() {
}
return;
}
if (!selectedFolderId || selectedFolderId === treeRoot.id) {
setSelectedFolderId(regions[0].id);
setExpandedIds((prev) =>
prev[regions[0].id] === undefined ? { ...prev, [regions[0].id]: true } : prev,
);
// Keep root (all regions) as the default landing view — do not auto-pick regions[0].
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
setSelectedFolderId("");
}
}, [mapId, selectedFolderId, treeRoot, regions]);
@ -852,6 +844,15 @@ export function TopologyPage() {
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
);
const goRoot = useCallback(() => {
if (!confirmDiscardIfDirty()) return;
setMapId("");
setSelectedFolderId("");
clearDirty();
setNodes([]);
setEdges([]);
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
const goCanvas = useCallback(
(viewId: string, folderId?: string) => {
if (!confirmDiscardIfDirty()) return;
@ -1801,6 +1802,70 @@ export function TopologyPage() {
[focusNode],
);
const jumpToTreeSearchHit = useCallback(
(
hit: FabricNodeSearchHit,
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
) => {
const views = hit.views || [];
const onCurrent =
Boolean(mapId) &&
(views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id));
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
if (nodes.some((n) => n.id === hit.id)) {
locateNode(hit.id);
} else {
setPendingHighlightNe(hit.id);
}
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
return;
}
const target =
view ||
views.find((v) => String(v.kind) === "physical") ||
views[0] ||
null;
if (!target) {
showError(t("topology.treeSearchNotOnMap"));
return;
}
if (!confirmDiscardIfDirty()) return;
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
if (regionId) {
setSelectedFolderId(regionId);
setExpandedIds((p) => ({ ...p, [regionId]: true }));
}
setMapId(target.view_id);
setPendingHighlightNe(hit.id);
setTreeNeQuery("");
setDebouncedTreeNeQuery("");
setTreeSearchOpen(false);
},
[
mapId,
nodes,
locateNode,
showError,
t,
confirmDiscardIfDirty,
regions,
],
);
useEffect(() => {
if (!treeSearchOpen) return;
const onDoc = (e: MouseEvent) => {
const el = treeSearchRef.current;
if (el && e.target instanceof Element && !el.contains(e.target)) {
setTreeSearchOpen(false);
}
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [treeSearchOpen]);
useEffect(() => {
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
const nodeId = pendingHighlightNe;
@ -1922,7 +1987,8 @@ export function TopologyPage() {
const titleText = useMemo(() => {
if (canvasMode) return activeLeafName || t("topology.selectMap");
return regionDisplayName(activeRegion) || t("topology.selectRegion");
if (activeRegion) return regionDisplayName(activeRegion);
return t("topology.rootName");
}, [canvasMode, activeLeafName, activeRegion, t]);
const onCanvasManagedIds = useMemo(
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
@ -2002,12 +2068,23 @@ export function TopologyPage() {
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-sidebar__head">
<strong>{t("topology.tree")}</strong>
<div className="topo-sidebar__head-actions">
<div className="topo-tree-search" ref={treeSearchRef}>
<div className="topo-tree-search__bar">
<input
className="input"
type="search"
value={treeNeQuery}
placeholder={t("topology.treeSearchPh")}
aria-label={t("topology.treeSearch")}
onChange={(e) => {
setTreeNeQuery(e.target.value);
setTreeSearchOpen(true);
}}
onFocus={() => setTreeSearchOpen(true)}
/>
<button
type="button"
className="btn btn--sm btn--ghost topo-icon-btn"
className="topo-sidebar__icon-btn"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
@ -2017,7 +2094,7 @@ export function TopologyPage() {
</button>
<button
type="button"
className="btn btn--sm btn--ghost topo-icon-btn"
className="topo-sidebar__icon-btn"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={() => setSidebarCollapsed(true)}
@ -2025,6 +2102,72 @@ export function TopologyPage() {
<SidebarFoldIcon />
</button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox">
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
<p className="topo-tree-search__hint muted">…</p>
) : !(treeNeSearchQuery.data?.items || []).length ? (
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
) : (
(treeNeSearchQuery.data?.items || []).map((hit) => {
const views = hit.views || [];
const title = hit.name || hit.ip || hit.id.slice(0, 8);
return (
<div key={hit.id} className="topo-tree-search__item">
<button
type="button"
className="topo-tree-search__ne"
onClick={() => jumpToTreeSearchHit(hit)}
title={
views.length
? t("topology.locateOnCanvas")
: t("topology.treeSearchNotOnMap")
}
>
<span className="topo-tree-search__name">{title}</span>
{hit.ip ? <span className="topo-tree-search__ip muted">{hit.ip}</span> : null}
{!views.length ? (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchNoViews")}
</span>
) : (
<span className="topo-tree-search__meta muted">
{t("topology.treeSearchViewCount").replace(
"{{count}}",
String(views.length),
)}
</span>
)}
</button>
{views.length > 1 ? (
<div className="topo-tree-search__views">
{views.map((v) => (
<button
key={`${hit.id}-${v.view_id}`}
type="button"
className="topo-tree-search__view"
onClick={() => jumpToTreeSearchHit(hit, v)}
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
>
{v.view_name || v.view_id.slice(0, 8)}
</button>
))}
</div>
) : null}
</div>
);
})
)}
{(treeNeSearchQuery.data?.total || 0) > 30 ? (
<p className="topo-tree-search__hint muted">
{t("topology.treeSearchTruncated").replace(
"{{total}}",
String(treeNeSearchQuery.data?.total || 0),
)}
</p>
) : null}
</div>
) : null}
</div>
{!treeRoot ? (
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
@ -2033,30 +2176,56 @@ export function TopologyPage() {
) : (
<ul className="topo-map-list topo-region-list">
{regions.map((region) => {
const open = expandedIds[region.id] ?? true;
const open = expandedIds[region.id] ?? false;
const regionActive = selectedFolderId === region.id && !mapId;
const regionBranch = selectedFolderId === region.id && Boolean(mapId);
const regionHot = hotBrowseKey === `region:${region.id}`;
return (
<li
key={region.id}
className={`topo-region-list__block${regionActive ? " is-active" : ""}`}
className={`topo-region-list__block${
regionActive ? " is-active" : regionBranch ? " is-branch-active" : ""
}${regionHot ? " is-hot" : ""}`}
>
<div className="topo-map-list__row topo-region-list__row">
<div
className="topo-map-list__row topo-region-list__row"
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
onClick={() => goRegion(region.id)}
onClick={() => {
if (!confirmDiscardIfDirty()) return;
const nextOpen = !(expandedIds[region.id] ?? false);
setMapId("");
setSelectedFolderId(region.id);
setExpandedIds((p) => ({ ...p, [region.id]: nextOpen }));
clearDirty();
setNodes([]);
setEdges([]);
}}
title={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
>
<span className="topo-map-list__name">
<span className="topo-region-list__glyph" aria-hidden="true">
<RegionGlyph size={14} />
</span>
{regionDisplayName(region)}
</span>
<span className="topo-map-list__meta">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views.length),
)}
<span className="topo-map-list__title">
{regionDisplayName(region)}
</span>
<span className="topo-map-list__count">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views.length),
)}
</span>
</span>
</button>
<div className="topo-map-list__actions">
@ -2086,34 +2255,27 @@ export function TopologyPage() {
>
<CloseIcon />
</button>
<button
type="button"
className="topo-map-list__icon"
title={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
aria-label={
open
? t("topology.collapseRegion")
: t("topology.expandRegion")
}
onClick={() =>
setExpandedIds((p) => ({ ...p, [region.id]: !open }))
}
>
<ChevronIcon dir={open ? "down" : "right"} />
</button>
</div>
</div>
{open ? (
<ul className="topo-map-list topo-region-list__maps">
{(region.views || []).map((v) => {
const isPhysical = String(v.kind) === "physical";
const viewHot = hotBrowseKey === `view:${v.id}`;
return (
<li key={v.id} className={mapId === v.id ? "is-active" : ""}>
<div className="topo-map-list__row">
<li
key={v.id}
className={`${mapId === v.id ? "is-active" : ""}${
viewHot ? " is-hot" : ""
}`}
>
<div
className="topo-map-list__row"
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
>
<button
type="button"
className="topo-map-list__item"
@ -2133,13 +2295,13 @@ export function TopologyPage() {
size={13}
/>
</span>
{v.name}
{mapId === v.id && dirty ? " *" : ""}
</span>
<span className="topo-map-list__meta">
{t(kindLabelKey(String(v.kind)))}
{" · "}
{v.node_count || 0}N
<span className="topo-map-list__title">
{v.name}
{mapId === v.id && dirty ? " *" : ""}
</span>
<span className="topo-map-list__count">
{v.node_count || 0}N
</span>
</span>
</button>
<div className="topo-map-list__actions">
@ -2197,17 +2359,25 @@ export function TopologyPage() {
<div className="topo-toolbar__row">
<div className="topo-toolbar__title">
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
{activeRegion ? (
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(activeRegion.id)}
>
{regionDisplayName(activeRegion)}
</button>
) : (
<span className="topo-breadcrumb__current">{t("topology.selectRegion")}</span>
)}
<>
<span className="topo-breadcrumb__sep">/</span>
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRegion(activeRegion.id)}
>
{regionDisplayName(activeRegion)}
</button>
</>
) : null}
{activeView ? (
<>
<span className="topo-breadcrumb__sep">/</span>
@ -2597,9 +2767,18 @@ export function TopologyPage() {
.replace("{{failed}}", String(discoverSummary.failed))}
</p>
<p className="panel__hint">
<Link className="btn btn--sm btn--ghost" to="/network/topology/lldp">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() =>
openOrFocusModule({
moduleId: "network",
path: "/network/topology/lldp",
})
}
>
{t("topology.discoverGoLldp")}
</Link>
</button>
</p>
</>
) : null}
@ -2610,90 +2789,147 @@ export function TopologyPage() {
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
<div className="topo-browser__head">
<div>
<strong>{titleText}</strong>
{activeRegion ? (
<div className="topo-breadcrumb">
<button
type="button"
className="topo-breadcrumb__link"
onClick={() => goRoot()}
>
{t("topology.rootName")}
</button>
<span className="topo-breadcrumb__sep">/</span>
<span className="topo-breadcrumb__current">{regionDisplayName(activeRegion)}</span>
</div>
) : (
<strong>{titleText}</strong>
)}
<p className="topo-browser__sub">
{activeRegion
? t("topology.browserRegionSub").replace(
"{{count}}",
String(browseEntries.length),
)
: t("topology.regionBrowseHint")}
: t("topology.browserRegionsSub").replace(
"{{count}}",
String(regions.length),
)}
</p>
</div>
</div>
{!activeRegion ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.selectRegion")}</p>
</div>
regions.length === 0 ? (
<div className="topo-browser__empty">
<span className="topo-browser__empty-icon" aria-hidden="true">
<RegionGlyph size={36} />
</span>
<p>{t("topology.emptyMaps")}</p>
<button
type="button"
className="btn btn--sm"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
>
{t("topology.newRegion")}
</button>
</div>
) : (
<div className="topo-browser__grid topo-browser__grid--regions">
{regions.map((region, idx) => (
<button
key={region.id}
type="button"
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
selectedFolderId === region.id ? " is-selected" : ""
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
}
onClick={() => goRegion(region.id)}
title={t("topology.openRegion")}
>
<span className="topo-region-hex__icon" aria-hidden="true">
<RegionGlyph size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
<span className="topo-region-hex__meta">
{t("topology.regionNodeHint").replace(
"{{count}}",
String(region.views?.length || 0),
)}
</span>
</span>
</button>
))}
<button
type="button"
className="topo-region-hex topo-region-hex--add"
onClick={promptNewRegion}
disabled={createRegionMut.isPending || !treeRoot}
title={t("topology.newRegion")}
>
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
</button>
</div>
)
) : browseEntries.length === 0 ? (
<div className="topo-browser__empty">
<p>{t("topology.browserEmptyMaps")}</p>
<div className="topo-browser__grid topo-browser__grid--regions">
<button
type="button"
className="btn btn--sm"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
{t("topology.newCustom")}
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
) : (
<div className="topo-browser__grid">
{browseEntries.map((v) => {
<div className="topo-browser__grid topo-browser__grid--regions">
{browseEntries.map((v, idx) => {
const isPhysical = String(v.kind) === "physical";
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
return (
<article
<button
key={v.id}
className={`topo-map-card${isPhysical ? " is-physical" : ""}`}
type="button"
className={`topo-region-hex topo-region-hex--tone-${tone}${
mapId === v.id ? " is-selected" : ""
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
onMouseLeave={() =>
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
}
onClick={() => goCanvas(v.id, activeRegion.id)}
title={t("topology.openMap")}
>
<button
type="button"
className="topo-map-card__body"
onClick={() => goCanvas(v.id, activeRegion.id)}
onDoubleClick={() => goCanvas(v.id, activeRegion.id)}
>
<span className="topo-map-card__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={28} />
</span>
<span className={`topo-map-card__kind${isPhysical ? " is-physical" : ""}`}>
{t(kindLabelKey(String(v.kind)))}
</span>
<span className="topo-map-card__name">{v.name}</span>
<span className="topo-map-card__meta">
{t("topology.layerNodeHint").replace("{{count}}", String(v.node_count || 0))}
{v.updated_at ? ` · ${formatUpdatedAt(v.updated_at)}` : ""}
</span>
</button>
<div className="topo-map-card__actions">
<button
type="button"
className="btn btn--sm"
onClick={() => goCanvas(v.id, activeRegion.id)}
>
{t("topology.openMap")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => promptRenameMap(v.id, v.name)}
>
{t("topology.rename")}
</button>
</div>
</article>
<span className="topo-region-hex__icon" aria-hidden="true">
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
</span>
<span className="topo-region-hex__title">
<span className="topo-region-hex__name">{v.name}</span>
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
</span>
</button>
);
})}
<button
type="button"
className="topo-map-card topo-map-card--add"
className="topo-region-hex topo-region-hex--add"
onClick={() => promptNewCustom(activeRegion.id)}
title={t("topology.newCustom")}
>
<span className="topo-map-card__add-plus" aria-hidden="true">
<span className="topo-region-hex__plus" aria-hidden="true">
+
</span>
<span>{t("topology.newCustom")}</span>
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
</button>
</div>
)}