diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index ca75ff9..735249e 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -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 diff --git a/tests/test_topology.py b/tests/test_topology.py index 2d3736a..11eaebe 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -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( diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 292380c..f2f7ec0 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 06c5f27..3fa2743 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "返回区域", diff --git a/web/src/index.css b/web/src/index.css index cad84b4..cd6bbeb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 4ee3ac7..29239b7 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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 ( - - ); -} - -/** Left-panel collapse / expand control (icon-only sidebar toggle). */ +/** Left-panel collapse / expand control (simple chevron). */ function SidebarFoldIcon({ expand }: { expand?: boolean }) { return (
-
- {t("topology.tree")} -
+
+
+ { + setTreeNeQuery(e.target.value); + setTreeSearchOpen(true); + }} + onFocus={() => setTreeSearchOpen(true)} + />
+ {treeSearchOpen && treeNeQuery.trim() ? ( +
+ {debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? ( +

…

+ ) : !(treeNeSearchQuery.data?.items || []).length ? ( +

{t("topology.treeSearchEmpty")}

+ ) : ( + (treeNeSearchQuery.data?.items || []).map((hit) => { + const views = hit.views || []; + const title = hit.name || hit.ip || hit.id.slice(0, 8); + return ( +
+ + {views.length > 1 ? ( +
+ {views.map((v) => ( + + ))} +
+ ) : null} +
+ ); + }) + )} + {(treeNeSearchQuery.data?.total || 0) > 30 ? ( +

+ {t("topology.treeSearchTruncated").replace( + "{{total}}", + String(treeNeSearchQuery.data?.total || 0), + )} +

+ ) : null} +
+ ) : null}
{!treeRoot ? (

{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}

@@ -2033,30 +2176,56 @@ export function TopologyPage() { ) : (
    {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 (
  • -
    +
    setHotBrowseKey(`region:${region.id}`)} + onMouseLeave={() => + setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k)) + } + >
    @@ -2086,34 +2255,27 @@ export function TopologyPage() { > -
    {open ? (
      {(region.views || []).map((v) => { const isPhysical = String(v.kind) === "physical"; + const viewHot = hotBrowseKey === `view:${v.id}`; return ( -
    • -
      +
    • +
      setHotBrowseKey(`view:${v.id}`)} + onMouseLeave={() => + setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) + } + >
      @@ -2197,17 +2359,25 @@ export function TopologyPage() {
      + {activeRegion ? ( - - ) : ( - {t("topology.selectRegion")} - )} + <> + / + + + ) : null} {activeView ? ( <> / @@ -2597,9 +2767,18 @@ export function TopologyPage() { .replace("{{failed}}", String(discoverSummary.failed))}

      - +

      ) : null} @@ -2610,90 +2789,147 @@ export function TopologyPage() {
      - {titleText} + {activeRegion ? ( +
      + + / + {regionDisplayName(activeRegion)} +
      + ) : ( + {titleText} + )}

      {activeRegion ? t("topology.browserRegionSub").replace( "{{count}}", String(browseEntries.length), ) - : t("topology.regionBrowseHint")} + : t("topology.browserRegionsSub").replace( + "{{count}}", + String(regions.length), + )}

      {!activeRegion ? ( -
      - -

      {t("topology.selectRegion")}

      -
      + regions.length === 0 ? ( +
      + +

      {t("topology.emptyMaps")}

      + +
      + ) : ( +
      + {regions.map((region, idx) => ( + + ))} + +
      + ) ) : browseEntries.length === 0 ? ( -
      -

      {t("topology.browserEmptyMaps")}

      +
      ) : ( -
      - {browseEntries.map((v) => { +
      + {browseEntries.map((v, idx) => { const isPhysical = String(v.kind) === "physical"; + const tone = isPhysical ? "physical" : String((idx + 1) % 5); return ( -
      setHotBrowseKey(`view:${v.id}`)} + onMouseLeave={() => + setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) + } + onClick={() => goCanvas(v.id, activeRegion.id)} + title={t("topology.openMap")} > - -
      - - -
      -
      + + + {v.name} + {v.node_count || 0}N + + ); })}
      )}