mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 06:30:48 +08:00
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:
parent
3908b78dbb
commit
cb8e0d23c5
6 changed files with 1520 additions and 364 deletions
|
|
@ -2,15 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import and_, func, or_, text
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -170,6 +172,54 @@ def _empty_to_none(s: str | None) -> str | None:
|
||||||
return v or 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
|
# Fabric nodes / edges helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -254,6 +304,11 @@ def ensure_fabric_node_for_managed(db: Session, ne: ManagedNE) -> TopoFabricNode
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
return row
|
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()
|
row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
return _apply(row)
|
return _apply(row)
|
||||||
|
|
@ -300,6 +355,10 @@ def ensure_fabric_node_for_ume(
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
return row
|
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()
|
row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
return _apply(row)
|
return _apply(row)
|
||||||
|
|
@ -602,6 +661,8 @@ def upsert_fabric_edge(
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
row = TopoFabricEdge(
|
row = TopoFabricEdge(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
layer=layer_v,
|
layer=layer_v,
|
||||||
|
|
@ -618,7 +679,22 @@ def upsert_fabric_edge(
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
return row, "added"
|
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":
|
if (row.source or "") == "manual" and src == "lldp":
|
||||||
return row, "kept_manual"
|
return row, "kept_manual"
|
||||||
row.source = src
|
row.source = src
|
||||||
|
|
@ -631,6 +707,46 @@ def upsert_fabric_edge(
|
||||||
return row, "updated"
|
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(
|
def _mark_replaced_port_peers(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -643,6 +759,9 @@ def _mark_replaced_port_peers(
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Same local port now peers with a different NE → mark old edges missing (cutover).
|
"""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).
|
Returns ids of edges touched by this replacement (skip re-bump in same job).
|
||||||
"""
|
"""
|
||||||
now = now or _utcnow()
|
now = now or _utcnow()
|
||||||
|
|
@ -650,6 +769,8 @@ def _mark_replaced_port_peers(
|
||||||
if not self_id or not peer_id or not lp:
|
if not self_id or not peer_id or not lp:
|
||||||
return []
|
return []
|
||||||
layer_v = str(layer or "physical").strip() or "physical"
|
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 = (
|
candidates = (
|
||||||
db.query(TopoFabricEdge)
|
db.query(TopoFabricEdge)
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -670,6 +791,47 @@ def _mark_replaced_port_peers(
|
||||||
continue
|
continue
|
||||||
if e_peer == peer_id:
|
if e_peer == peer_id:
|
||||||
continue
|
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)
|
_set_edge_missing(e, now, replaced_by_edge_id=new_edge_id)
|
||||||
handled.append(e.id)
|
handled.append(e.id)
|
||||||
return handled
|
return handled
|
||||||
|
|
@ -1794,13 +1956,25 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int:
|
||||||
def _pick_managed_ne(
|
def _pick_managed_ne(
|
||||||
db: Session, *, ip: str = "", name_key: str = ""
|
db: Session, *, ip: str = "", name_key: str = ""
|
||||||
) -> ManagedNE | None:
|
) -> ManagedNE | None:
|
||||||
|
"""Pick inventory NE. Name matching uses hostname key (not LLDP mgmt IP)."""
|
||||||
rows: list[ManagedNE] = []
|
rows: list[ManagedNE] = []
|
||||||
if ip:
|
if name_key:
|
||||||
rows = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).all()
|
key = _norm_host(name_key) or str(name_key or "").strip().lower()
|
||||||
elif name_key:
|
if key:
|
||||||
rows = (
|
candidates = (
|
||||||
db.query(ManagedNE).filter(func.lower(ManagedNE.name) == name_key).all()
|
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()
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
rows.sort(key=_ne_inventory_score, reverse=True)
|
rows.sort(key=_ne_inventory_score, reverse=True)
|
||||||
|
|
@ -1877,18 +2051,18 @@ def ensure_lldp_discovered_managed_ne(
|
||||||
|
|
||||||
|
|
||||||
class _FabricPeerIndex:
|
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:
|
def __init__(self, db: Session, self_id: str) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
self.self_id = self_id
|
self.self_id = self_id
|
||||||
self.by_ip: dict[str, list[TopoFabricNode]] = {}
|
|
||||||
self.by_name: dict[str, list[TopoFabricNode]] = {}
|
self.by_name: dict[str, list[TopoFabricNode]] = {}
|
||||||
self.placeholder_by_name: dict[str, ManagedNE] = {}
|
self.placeholder_by_name: dict[str, ManagedNE] = {}
|
||||||
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id != self_id).all():
|
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 "")
|
nk = _norm_host(n.name or "")
|
||||||
if nk:
|
if nk:
|
||||||
self.by_name.setdefault(nk, []).append(n)
|
self.by_name.setdefault(nk, []).append(n)
|
||||||
|
|
@ -1900,38 +2074,25 @@ class _FabricPeerIndex:
|
||||||
self.placeholder_by_name[nk] = ne
|
self.placeholder_by_name[nk] = ne
|
||||||
|
|
||||||
def _best(self, matched: list[TopoFabricNode]) -> TopoFabricNode:
|
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]
|
return matched[0]
|
||||||
|
|
||||||
def match(self, hit: NeighborHit) -> TopoFabricNode | None:
|
def match(self, hit: NeighborHit) -> TopoFabricNode | None:
|
||||||
name_key = _norm_host(hit.remote_name)
|
name_key = _norm_host(hit.remote_name)
|
||||||
ip_key = str(hit.remote_ip or "").strip()
|
if not name_key:
|
||||||
matched: list[TopoFabricNode] = []
|
return None
|
||||||
if ip_key:
|
|
||||||
matched.extend(self.by_ip.get(ip_key) or [])
|
matched = list(self.by_name.get(name_key) or [])
|
||||||
if name_key:
|
|
||||||
for n in self.by_name.get(name_key) or []:
|
|
||||||
if n not in matched:
|
|
||||||
matched.append(n)
|
|
||||||
if matched:
|
if matched:
|
||||||
return self._best(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)
|
ne = _pick_managed_ne(self.db, name_key=name_key)
|
||||||
if ne is not None:
|
if ne is not None:
|
||||||
node = ensure_fabric_node_for_managed(self.db, ne)
|
node = ensure_fabric_node_for_managed(self.db, ne)
|
||||||
|
|
@ -1942,11 +2103,6 @@ class _FabricPeerIndex:
|
||||||
def _remember(self, node: TopoFabricNode) -> None:
|
def _remember(self, node: TopoFabricNode) -> None:
|
||||||
if not node or node.id == self.self_id:
|
if not node or node.id == self.self_id:
|
||||||
return
|
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 "")
|
nk = _norm_host(node.name or "")
|
||||||
if nk:
|
if nk:
|
||||||
bucket = self.by_name.setdefault(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:
|
for d in dupes:
|
||||||
if d.id == canon.id:
|
if d.id == canon.id:
|
||||||
continue
|
continue
|
||||||
_retarget_fabric_edges(db, from_id=d.id, to_id=canon.id)
|
if db.get(TopoFabricNode, d.id) is None:
|
||||||
# View placements: keep canon if present, else retarget; drop duplicate placements.
|
continue
|
||||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == d.id).all()
|
_absorb_fabric_node(db, canon, d)
|
||||||
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)
|
|
||||||
merged += 1
|
merged += 1
|
||||||
|
|
||||||
seen_absorb: set[str] = set()
|
seen_absorb: set[str] = set()
|
||||||
|
|
@ -2368,12 +2509,20 @@ def _discover_one_target(
|
||||||
*,
|
*,
|
||||||
auto_add_unmatched: bool,
|
auto_add_unmatched: bool,
|
||||||
) -> dict[str, Any]:
|
) -> 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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
now = _utcnow()
|
|
||||||
if target["ume_ne_id"] and not target["ne_id"]:
|
|
||||||
pass
|
|
||||||
fabric_node: TopoFabricNode | None = None
|
fabric_node: TopoFabricNode | None = None
|
||||||
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
|
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
|
||||||
if managed is not None:
|
if managed is not None:
|
||||||
|
|
@ -2392,15 +2541,12 @@ def _discover_one_target(
|
||||||
vendor=target.get("vendor") or "",
|
vendor=target.get("vendor") or "",
|
||||||
)
|
)
|
||||||
if fabric_node is None:
|
if fabric_node is None:
|
||||||
return {
|
return {**base, "ok": False, "error": "fabric_node_resolve_failed"}
|
||||||
"ne_id": target["ne_id"],
|
|
||||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
fabric_node_id = fabric_node.id
|
||||||
"fabric_node_id": "",
|
base["fabric_node_id"] = fabric_node_id
|
||||||
"ne_name": target.get("ne_name") or "",
|
# Release unique-index locks before slow SSH.
|
||||||
"ne_ip": target.get("ne_ip") or "",
|
db.commit()
|
||||||
"ok": False,
|
|
||||||
"error": "fabric_node_resolve_failed",
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, _proto = pick_neighbor_command(
|
cmd, _proto = pick_neighbor_command(
|
||||||
vendor=target.get("vendor") or "",
|
vendor=target.get("vendor") or "",
|
||||||
|
|
@ -2414,25 +2560,15 @@ def _discover_one_target(
|
||||||
try:
|
try:
|
||||||
exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs)
|
exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
db.commit()
|
|
||||||
return {
|
return {
|
||||||
"ne_id": target["ne_id"],
|
**base,
|
||||||
"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 "",
|
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"command": cmd,
|
"command": cmd,
|
||||||
"error": str(exc.detail or "exec_failed")[:500],
|
"error": str(exc.detail or "exec_failed")[:500],
|
||||||
}
|
}
|
||||||
if not exec_out.get("ok"):
|
if not exec_out.get("ok"):
|
||||||
db.commit()
|
|
||||||
return {
|
return {
|
||||||
"ne_id": target["ne_id"],
|
**base,
|
||||||
"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 "",
|
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"command": cmd,
|
"command": cmd,
|
||||||
"error": str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
|
"error": str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
|
||||||
|
|
@ -2448,6 +2584,65 @@ def _discover_one_target(
|
||||||
vendor=target.get("vendor") or "",
|
vendor=target.get("vendor") or "",
|
||||||
device_type=target.get("device_type") or "",
|
device_type=target.get("device_type") or "",
|
||||||
)
|
)
|
||||||
|
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 {
|
||||||
|
**base,
|
||||||
|
"ok": True,
|
||||||
|
"command": cmd,
|
||||||
|
"neighbors": len(hits),
|
||||||
|
"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": 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 {**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
|
added = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
unmatched: list[dict[str, str]] = []
|
unmatched: list[dict[str, str]] = []
|
||||||
|
|
@ -2458,7 +2653,6 @@ def _discover_one_target(
|
||||||
peer = peer_index.match(hit)
|
peer = peer_index.match(hit)
|
||||||
if peer is None:
|
if peer is None:
|
||||||
if auto_add_unmatched and (hit.remote_name or hit.remote_ip):
|
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(
|
peer = peer_index.ensure_placeholder(
|
||||||
remote_name=(hit.remote_name or "").strip(),
|
remote_name=(hit.remote_name or "").strip(),
|
||||||
remote_ip=(hit.remote_ip or "").strip(),
|
remote_ip=(hit.remote_ip or "").strip(),
|
||||||
|
|
@ -2487,7 +2681,6 @@ def _discover_one_target(
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
touched.append(edge.id)
|
touched.append(edge.id)
|
||||||
# Same local port, different peer → immediate missing (cutover).
|
|
||||||
replaced.extend(
|
replaced.extend(
|
||||||
_mark_replaced_port_peers(
|
_mark_replaced_port_peers(
|
||||||
db,
|
db,
|
||||||
|
|
@ -2505,41 +2698,47 @@ def _discover_one_target(
|
||||||
fabric_node.last_seen_at = now
|
fabric_node.last_seen_at = now
|
||||||
fabric_node.updated_at = now
|
fabric_node.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
stub_flag = bool(is_stub and raw.strip() and not hits)
|
|
||||||
return {
|
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 "",
|
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"command": cmd,
|
|
||||||
"neighbors": len(hits),
|
|
||||||
"edges_added": added,
|
"edges_added": added,
|
||||||
"edges_updated": updated,
|
"edges_updated": updated,
|
||||||
"unmatched_count": len(unmatched),
|
"unmatched_count": len(unmatched),
|
||||||
"unmatched": unmatched[:40],
|
"unmatched": unmatched[:40],
|
||||||
"parser_key": pkey,
|
|
||||||
"parser_stub": stub_flag,
|
|
||||||
"error": "parser_stub" if stub_flag else "",
|
|
||||||
"raw_preview": _raw_preview(raw),
|
|
||||||
"touched_edge_ids": touched,
|
"touched_edge_ids": touched,
|
||||||
"replaced_edge_ids": replaced,
|
"replaced_edge_ids": replaced,
|
||||||
"scanned_node_id": fabric_node.id,
|
|
||||||
}
|
}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {
|
last_err = str(exc)[:500]
|
||||||
"ne_id": target.get("ne_id") or "",
|
if _is_deadlock_error(exc) and attempt + 1 < _DISCOVER_DEADLOCK_RETRIES:
|
||||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
_sleep_deadlock_backoff(attempt)
|
||||||
"fabric_node_id": "",
|
continue
|
||||||
"ne_name": target.get("ne_name") or "",
|
return {"ok": False, "error": last_err}
|
||||||
"ne_ip": target.get("ne_ip") or "",
|
return {"ok": False, "error": last_err or "apply_failed"}
|
||||||
"ok": False,
|
|
||||||
"error": str(exc)[:500],
|
|
||||||
}
|
def _preensure_discover_targets(db: Session, targets: list[dict[str, str]]) -> None:
|
||||||
finally:
|
"""Create fabric rows for scan targets before parallel workers start."""
|
||||||
db.close()
|
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:
|
def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
|
||||||
|
|
@ -2563,6 +2762,12 @@ def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
|
||||||
job.total = len(targets)
|
job.total = len(targets)
|
||||||
db.commit()
|
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)))
|
concurrency = max(1, min(32, int(body.concurrency or 4)))
|
||||||
added = 0
|
added = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from uuid import uuid4
|
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_lldp as lldp
|
||||||
from netx_api import topology_service as svc
|
from netx_api import topology_service as svc
|
||||||
from netx_api.db import Base, SessionLocal, engine
|
from netx_api.db import Base, SessionLocal, engine
|
||||||
|
|
@ -97,6 +100,17 @@ class LldpParserTests(unittest.TestCase):
|
||||||
self.assertEqual(cmd, "show lldp neighbors detail")
|
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):
|
class FabricTopologyTests(unittest.TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
|
|
@ -125,6 +139,62 @@ class FabricTopologyTests(unittest.TestCase):
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.db.close()
|
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:
|
def _region(self, name: str = "Test-Region") -> str:
|
||||||
return svc.create_folder(
|
return svc.create_folder(
|
||||||
self.db, TopologyFolderCreate(name=name, kind="region")
|
self.db, TopologyFolderCreate(name=name, kind="region")
|
||||||
|
|
@ -742,7 +812,15 @@ Management Addresses:
|
||||||
inv = svc.ensure_fabric_node_for_managed(self.db, real)
|
inv = svc.ensure_fabric_node_for_managed(self.db, real)
|
||||||
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
|
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
|
||||||
self.db.commit()
|
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")
|
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||||||
self.assertEqual(peer.id, inv.id)
|
self.assertEqual(peer.id, inv.id)
|
||||||
self.assertNotEqual(peer.id, ghost.id)
|
self.assertNotEqual(peer.id, ghost.id)
|
||||||
|
|
@ -755,6 +833,127 @@ Management Addresses:
|
||||||
self.db.delete(webcrt)
|
self.db.delete(webcrt)
|
||||||
self.db.commit()
|
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:
|
def test_neighborhood(self) -> None:
|
||||||
suffix = uuid4().hex[:8]
|
suffix = uuid4().hex[:8]
|
||||||
ne_a = ManagedNE(
|
ne_a = ManagedNE(
|
||||||
|
|
|
||||||
|
|
@ -1225,12 +1225,20 @@ const en = {
|
||||||
tree: "Topology tree",
|
tree: "Topology tree",
|
||||||
treeLoading: "Loading tree…",
|
treeLoading: "Loading tree…",
|
||||||
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
|
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",
|
rootName: "Regions",
|
||||||
selectRegion: "Select a region",
|
selectRegion: "Select a region",
|
||||||
openMap: "Open map",
|
openMap: "Open map",
|
||||||
|
openRegion: "Open region",
|
||||||
kindPhysical: "Physical",
|
kindPhysical: "Physical",
|
||||||
kindCustom: "Custom",
|
kindCustom: "Custom",
|
||||||
regionNodeHint: "{{count}} map(s)",
|
regionNodeHint: "{{count}}P",
|
||||||
layerNodeHint: "{{count}} NE(s)",
|
layerNodeHint: "{{count}} NE(s)",
|
||||||
layerNodeEmpty: "Click to create & open",
|
layerNodeEmpty: "Click to create & open",
|
||||||
rootCanvasHint: "Pick a region from the left tree.",
|
rootCanvasHint: "Pick a region from the left tree.",
|
||||||
|
|
@ -1238,6 +1246,7 @@ const en = {
|
||||||
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
|
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
|
||||||
browserTitle: "Topology folder",
|
browserTitle: "Topology folder",
|
||||||
browserRegionSub: "{{count}} map(s)",
|
browserRegionSub: "{{count}} map(s)",
|
||||||
|
browserRegionsSub: "{{count}} region(s)",
|
||||||
browserEmptyMaps: "No maps in this region yet.",
|
browserEmptyMaps: "No maps in this region yet.",
|
||||||
addNe: "Add NE",
|
addNe: "Add NE",
|
||||||
backUp: "Back to region",
|
backUp: "Back to region",
|
||||||
|
|
|
||||||
|
|
@ -1219,12 +1219,20 @@ const zh = {
|
||||||
tree: "拓扑树",
|
tree: "拓扑树",
|
||||||
treeLoading: "加载拓扑树…",
|
treeLoading: "加载拓扑树…",
|
||||||
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
||||||
|
treeSearch: "全拓扑搜索网元",
|
||||||
|
treeSearchPh: "名称 / IP",
|
||||||
|
treeSearchEmpty: "没有匹配的网元",
|
||||||
|
treeSearchNoViews: "未上图",
|
||||||
|
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
||||||
|
treeSearchViewCount: "{{count}} 张图",
|
||||||
|
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
||||||
rootName: "区域",
|
rootName: "区域",
|
||||||
selectRegion: "请选择区域",
|
selectRegion: "请选择区域",
|
||||||
openMap: "打开本图",
|
openMap: "打开本图",
|
||||||
|
openRegion: "打开区域",
|
||||||
kindPhysical: "物理拓扑",
|
kindPhysical: "物理拓扑",
|
||||||
kindCustom: "自定义图",
|
kindCustom: "自定义图",
|
||||||
regionNodeHint: "{{count}} 张图",
|
regionNodeHint: "{{count}}P",
|
||||||
layerNodeHint: "{{count}} 个网元",
|
layerNodeHint: "{{count}} 个网元",
|
||||||
layerNodeEmpty: "点击创建并打开",
|
layerNodeEmpty: "点击创建并打开",
|
||||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||||
|
|
@ -1232,6 +1240,7 @@ const zh = {
|
||||||
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
||||||
browserTitle: "拓扑目录",
|
browserTitle: "拓扑目录",
|
||||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||||
|
browserRegionsSub: "共 {{count}} 个区域",
|
||||||
browserEmptyMaps: "此区域还没有拓扑图。",
|
browserEmptyMaps: "此区域还没有拓扑图。",
|
||||||
addNe: "添加网元",
|
addNe: "添加网元",
|
||||||
backUp: "返回区域",
|
backUp: "返回区域",
|
||||||
|
|
|
||||||
|
|
@ -3514,23 +3514,155 @@ pre {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-sidebar__head {
|
.app-main .topo-sidebar__icon-btn,
|
||||||
display: flex;
|
.topo-sidebar__icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 8px;
|
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 {
|
.app-main .topo-sidebar__icon-btn:hover:not(:disabled),
|
||||||
font-size: 13px;
|
.topo-sidebar__icon-btn:hover:not(:disabled) {
|
||||||
font-weight: 650;
|
color: var(--nm-brand, #0f2744);
|
||||||
color: var(--nm-brand);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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;
|
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 {
|
.topo-palette-source {
|
||||||
|
|
@ -3859,9 +3991,156 @@ pre {
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: 16px 18px 20px;
|
padding: 16px 18px 20px;
|
||||||
background-image:
|
background: #eef2f7;
|
||||||
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
|
}
|
||||||
background-size: 18px 18px;
|
.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 {
|
.topo-map-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -3988,22 +4267,51 @@ pre {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 2px 6px;
|
||||||
min-width: 0;
|
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 {
|
.topo-breadcrumb__link {
|
||||||
border: 0;
|
border: 0 !important;
|
||||||
background: transparent;
|
background: transparent !important;
|
||||||
color: var(--nm-accent, #2563eb);
|
box-shadow: none !important;
|
||||||
|
color: #5b6b7c !important;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0 !important;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0 !important;
|
||||||
font: inherit;
|
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 {
|
.topo-breadcrumb__sep {
|
||||||
opacity: 0.45;
|
color: #94a3b8;
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.topo-breadcrumb__current {
|
.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 {
|
.topo-toolbar__level {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
|
@ -4277,7 +4585,7 @@ pre {
|
||||||
.topo-region-list {
|
.topo-region-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4285,49 +4593,216 @@ pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 2px 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-region-list__row {
|
.topo-region-list__row {
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topo-region-list__block.is-active > .topo-region-list__row {
|
/* Flat list rows — no boxed cards */
|
||||||
border-color: var(--nm-accent);
|
.topo-region-list .topo-map-list__row {
|
||||||
background: var(--nm-nav-active);
|
border: 0;
|
||||||
box-shadow: inset 2px 0 0 var(--nm-accent);
|
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 {
|
.topo-region-list .topo-map-list__row:hover,
|
||||||
border-left-color: #9bb6d9;
|
.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 {
|
.topo-region-list__glyph {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 5px;
|
justify-content: center;
|
||||||
vertical-align: -2px;
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
color: #0f766e;
|
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 {
|
.topo-region-list__maps {
|
||||||
margin: 4px 0 0 18px;
|
position: relative;
|
||||||
padding: 0;
|
margin: 2px 0 0;
|
||||||
|
padding: 2px 0 2px 0;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
flex: none;
|
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 {
|
.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 {
|
.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;
|
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 {
|
.topo-region-list .topo-map-list__name {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
min-width: 0;
|
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,
|
.topo-map-list li,
|
||||||
|
|
@ -4361,6 +4836,29 @@ pre {
|
||||||
box-shadow: inset 2px 0 0 var(--nm-accent);
|
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 {
|
.topo-palette > li.is-on-canvas .topo-palette__row {
|
||||||
background: #eef2f7;
|
background: #eef2f7;
|
||||||
border-color: #d7dee8;
|
border-color: #d7dee8;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
patchTopologyPositions,
|
patchTopologyPositions,
|
||||||
projectTopologyNeighbors,
|
projectTopologyNeighbors,
|
||||||
removeTopologyViewNodes,
|
removeTopologyViewNodes,
|
||||||
|
searchFabricNodes,
|
||||||
startLldpDiscover,
|
startLldpDiscover,
|
||||||
updateTopologyFolder,
|
updateTopologyFolder,
|
||||||
updateTopologyMap,
|
updateTopologyMap,
|
||||||
|
|
@ -49,6 +50,7 @@ import { useI18n } from "../i18n";
|
||||||
import { useToast } from "../hooks/useToast";
|
import { useToast } from "../hooks/useToast";
|
||||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||||
import type {
|
import type {
|
||||||
|
FabricNodeSearchHit,
|
||||||
ManagedNeItem,
|
ManagedNeItem,
|
||||||
TopologyDiscoverJob,
|
TopologyDiscoverJob,
|
||||||
TopologyDiscoverNeResult,
|
TopologyDiscoverNeResult,
|
||||||
|
|
@ -70,10 +72,6 @@ import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/tool
|
||||||
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||||
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||||
|
|
||||||
function kindLabelKey(kind: string): string {
|
|
||||||
return String(kind) === "physical" ? "topology.kindPhysical" : "topology.kindCustom";
|
|
||||||
}
|
|
||||||
|
|
||||||
function findViewInRegion(
|
function findViewInRegion(
|
||||||
regions: TopologyTreeFolderItem[],
|
regions: TopologyTreeFolderItem[],
|
||||||
viewId: string,
|
viewId: string,
|
||||||
|
|
@ -127,36 +125,15 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
|
||||||
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
|
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
|
||||||
const SEP = " / ";
|
const SEP = " / ";
|
||||||
|
|
||||||
function ChevronIcon({ dir }: { dir: "left" | "right" | "down" }) {
|
/** Left-panel collapse / expand control (simple chevron). */
|
||||||
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). */
|
|
||||||
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
|
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
<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
|
<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"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.8"
|
strokeWidth="2.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
|
|
@ -632,6 +609,8 @@ export function TopologyPage() {
|
||||||
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
|
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
|
||||||
const [mapId, setMapId] = useState<string>("");
|
const [mapId, setMapId] = useState<string>("");
|
||||||
const [selectedFolderId, setSelectedFolderId] = 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 [pendingHighlightNe, setPendingHighlightNe] = useState("");
|
||||||
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
|
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -643,6 +622,10 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [debouncedKeyword, setDebouncedKeyword] = 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 [dirty, setDirty] = useState(false);
|
||||||
const [historyTick, setHistoryTick] = useState(0);
|
const [historyTick, setHistoryTick] = useState(0);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
|
|
@ -755,6 +738,17 @@ export function TopologyPage() {
|
||||||
queryFn: fetchTopologyTree,
|
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({
|
const graphQuery = useQuery({
|
||||||
queryKey: queryKeys.topologyGraph(mapId),
|
queryKey: queryKeys.topologyGraph(mapId),
|
||||||
queryFn: () => fetchTopologyGraph(mapId),
|
queryFn: () => fetchTopologyGraph(mapId),
|
||||||
|
|
@ -815,11 +809,9 @@ export function TopologyPage() {
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedFolderId || selectedFolderId === treeRoot.id) {
|
// Keep root (all regions) as the default landing view — do not auto-pick regions[0].
|
||||||
setSelectedFolderId(regions[0].id);
|
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
|
||||||
setExpandedIds((prev) =>
|
setSelectedFolderId("");
|
||||||
prev[regions[0].id] === undefined ? { ...prev, [regions[0].id]: true } : prev,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [mapId, selectedFolderId, treeRoot, regions]);
|
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||||
|
|
||||||
|
|
@ -852,6 +844,15 @@ export function TopologyPage() {
|
||||||
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
|
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const goRoot = useCallback(() => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
setMapId("");
|
||||||
|
setSelectedFolderId("");
|
||||||
|
clearDirty();
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
|
||||||
|
|
||||||
const goCanvas = useCallback(
|
const goCanvas = useCallback(
|
||||||
(viewId: string, folderId?: string) => {
|
(viewId: string, folderId?: string) => {
|
||||||
if (!confirmDiscardIfDirty()) return;
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
|
@ -1801,6 +1802,70 @@ export function TopologyPage() {
|
||||||
[focusNode],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
|
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
|
||||||
const nodeId = pendingHighlightNe;
|
const nodeId = pendingHighlightNe;
|
||||||
|
|
@ -1922,7 +1987,8 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const titleText = useMemo(() => {
|
const titleText = useMemo(() => {
|
||||||
if (canvasMode) return activeLeafName || t("topology.selectMap");
|
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]);
|
}, [canvasMode, activeLeafName, activeRegion, t]);
|
||||||
const onCanvasManagedIds = useMemo(
|
const onCanvasManagedIds = useMemo(
|
||||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
() => 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__section">
|
||||||
<div className="topo-sidebar__head">
|
<div className="topo-tree-search" ref={treeSearchRef}>
|
||||||
<strong>{t("topology.tree")}</strong>
|
<div className="topo-tree-search__bar">
|
||||||
<div className="topo-sidebar__head-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm btn--ghost topo-icon-btn"
|
className="topo-sidebar__icon-btn"
|
||||||
onClick={promptNewRegion}
|
onClick={promptNewRegion}
|
||||||
disabled={createRegionMut.isPending || !treeRoot}
|
disabled={createRegionMut.isPending || !treeRoot}
|
||||||
title={t("topology.newRegion")}
|
title={t("topology.newRegion")}
|
||||||
|
|
@ -2017,7 +2094,7 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm btn--ghost topo-icon-btn"
|
className="topo-sidebar__icon-btn"
|
||||||
title={t("topology.collapseSidebar")}
|
title={t("topology.collapseSidebar")}
|
||||||
aria-label={t("topology.collapseSidebar")}
|
aria-label={t("topology.collapseSidebar")}
|
||||||
onClick={() => setSidebarCollapsed(true)}
|
onClick={() => setSidebarCollapsed(true)}
|
||||||
|
|
@ -2025,6 +2102,72 @@ export function TopologyPage() {
|
||||||
<SidebarFoldIcon />
|
<SidebarFoldIcon />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{!treeRoot ? (
|
{!treeRoot ? (
|
||||||
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
|
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
|
||||||
|
|
@ -2033,31 +2176,57 @@ export function TopologyPage() {
|
||||||
) : (
|
) : (
|
||||||
<ul className="topo-map-list topo-region-list">
|
<ul className="topo-map-list topo-region-list">
|
||||||
{regions.map((region) => {
|
{regions.map((region) => {
|
||||||
const open = expandedIds[region.id] ?? true;
|
const open = expandedIds[region.id] ?? false;
|
||||||
const regionActive = selectedFolderId === region.id && !mapId;
|
const regionActive = selectedFolderId === region.id && !mapId;
|
||||||
|
const regionBranch = selectedFolderId === region.id && Boolean(mapId);
|
||||||
|
const regionHot = hotBrowseKey === `region:${region.id}`;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={region.id}
|
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"
|
||||||
|
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onMouseLeave={() =>
|
||||||
|
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="topo-map-list__row topo-region-list__row">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-map-list__item"
|
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-map-list__name">
|
||||||
<span className="topo-region-list__glyph" aria-hidden="true">
|
<span className="topo-region-list__glyph" aria-hidden="true">
|
||||||
<RegionGlyph size={14} />
|
<RegionGlyph size={14} />
|
||||||
</span>
|
</span>
|
||||||
|
<span className="topo-map-list__title">
|
||||||
{regionDisplayName(region)}
|
{regionDisplayName(region)}
|
||||||
</span>
|
</span>
|
||||||
<span className="topo-map-list__meta">
|
<span className="topo-map-list__count">
|
||||||
{t("topology.regionNodeHint").replace(
|
{t("topology.regionNodeHint").replace(
|
||||||
"{{count}}",
|
"{{count}}",
|
||||||
String(region.views.length),
|
String(region.views.length),
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="topo-map-list__actions">
|
<div className="topo-map-list__actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -2086,34 +2255,27 @@ export function TopologyPage() {
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{open ? (
|
{open ? (
|
||||||
<ul className="topo-map-list topo-region-list__maps">
|
<ul className="topo-map-list topo-region-list__maps">
|
||||||
{(region.views || []).map((v) => {
|
{(region.views || []).map((v) => {
|
||||||
const isPhysical = String(v.kind) === "physical";
|
const isPhysical = String(v.kind) === "physical";
|
||||||
|
const viewHot = hotBrowseKey === `view:${v.id}`;
|
||||||
return (
|
return (
|
||||||
<li key={v.id} className={mapId === v.id ? "is-active" : ""}>
|
<li
|
||||||
<div className="topo-map-list__row">
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-map-list__item"
|
className="topo-map-list__item"
|
||||||
|
|
@ -2133,14 +2295,14 @@ export function TopologyPage() {
|
||||||
size={13}
|
size={13}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="topo-map-list__title">
|
||||||
{v.name}
|
{v.name}
|
||||||
{mapId === v.id && dirty ? " *" : ""}
|
{mapId === v.id && dirty ? " *" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="topo-map-list__meta">
|
<span className="topo-map-list__count">
|
||||||
{t(kindLabelKey(String(v.kind)))}
|
|
||||||
{" · "}
|
|
||||||
{v.node_count || 0}N
|
{v.node_count || 0}N
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="topo-map-list__actions">
|
<div className="topo-map-list__actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -2197,7 +2359,16 @@ export function TopologyPage() {
|
||||||
<div className="topo-toolbar__row">
|
<div className="topo-toolbar__row">
|
||||||
<div className="topo-toolbar__title">
|
<div className="topo-toolbar__title">
|
||||||
<div className="topo-breadcrumb">
|
<div className="topo-breadcrumb">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-breadcrumb__link"
|
||||||
|
onClick={() => goRoot()}
|
||||||
|
>
|
||||||
|
{t("topology.rootName")}
|
||||||
|
</button>
|
||||||
{activeRegion ? (
|
{activeRegion ? (
|
||||||
|
<>
|
||||||
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-breadcrumb__link"
|
className="topo-breadcrumb__link"
|
||||||
|
|
@ -2205,9 +2376,8 @@ export function TopologyPage() {
|
||||||
>
|
>
|
||||||
{regionDisplayName(activeRegion)}
|
{regionDisplayName(activeRegion)}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
</>
|
||||||
<span className="topo-breadcrumb__current">{t("topology.selectRegion")}</span>
|
) : null}
|
||||||
)}
|
|
||||||
{activeView ? (
|
{activeView ? (
|
||||||
<>
|
<>
|
||||||
<span className="topo-breadcrumb__sep">/</span>
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
|
|
@ -2597,9 +2767,18 @@ export function TopologyPage() {
|
||||||
.replace("{{failed}}", String(discoverSummary.failed))}
|
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||||
</p>
|
</p>
|
||||||
<p className="panel__hint">
|
<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")}
|
{t("topology.discoverGoLldp")}
|
||||||
</Link>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -2610,90 +2789,147 @@ export function TopologyPage() {
|
||||||
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
|
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
|
||||||
<div className="topo-browser__head">
|
<div className="topo-browser__head">
|
||||||
<div>
|
<div>
|
||||||
|
{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>
|
<strong>{titleText}</strong>
|
||||||
|
)}
|
||||||
<p className="topo-browser__sub">
|
<p className="topo-browser__sub">
|
||||||
{activeRegion
|
{activeRegion
|
||||||
? t("topology.browserRegionSub").replace(
|
? t("topology.browserRegionSub").replace(
|
||||||
"{{count}}",
|
"{{count}}",
|
||||||
String(browseEntries.length),
|
String(browseEntries.length),
|
||||||
)
|
)
|
||||||
: t("topology.regionBrowseHint")}
|
: t("topology.browserRegionsSub").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(regions.length),
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!activeRegion ? (
|
{!activeRegion ? (
|
||||||
|
regions.length === 0 ? (
|
||||||
<div className="topo-browser__empty">
|
<div className="topo-browser__empty">
|
||||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||||
<RegionGlyph size={36} />
|
<RegionGlyph size={36} />
|
||||||
</span>
|
</span>
|
||||||
<p>{t("topology.selectRegion")}</p>
|
<p>{t("topology.emptyMaps")}</p>
|
||||||
</div>
|
|
||||||
) : browseEntries.length === 0 ? (
|
|
||||||
<div className="topo-browser__empty">
|
|
||||||
<p>{t("topology.browserEmptyMaps")}</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className="btn btn--sm"
|
||||||
onClick={() => promptNewCustom(activeRegion.id)}
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionMut.isPending || !treeRoot}
|
||||||
>
|
>
|
||||||
{t("topology.newCustom")}
|
{t("topology.newRegion")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="topo-browser__grid">
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
{browseEntries.map((v) => {
|
{regions.map((region, idx) => (
|
||||||
const isPhysical = String(v.kind) === "physical";
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={v.id}
|
|
||||||
className={`topo-map-card${isPhysical ? " is-physical" : ""}`}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
|
key={region.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-map-card__body"
|
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
selectedFolderId === region.id ? " is-selected" : ""
|
||||||
onDoubleClick={() => goCanvas(v.id, activeRegion.id)}
|
}${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-map-card__icon" aria-hidden="true">
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={28} />
|
<RegionGlyph size={22} />
|
||||||
</span>
|
</span>
|
||||||
<span className={`topo-map-card__kind${isPhysical ? " is-physical" : ""}`}>
|
<span className="topo-region-hex__title">
|
||||||
{t(kindLabelKey(String(v.kind)))}
|
<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>
|
||||||
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="topo-map-card__actions">
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--sm"
|
className="topo-region-hex topo-region-hex--add"
|
||||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionMut.isPending || !treeRoot}
|
||||||
|
title={t("topology.newRegion")}
|
||||||
>
|
>
|
||||||
{t("topology.openMap")}
|
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||||
</button>
|
+
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
|
||||||
className="btn btn--sm btn--ghost"
|
|
||||||
onClick={() => promptRenameMap(v.id, v.name)}
|
|
||||||
>
|
|
||||||
{t("topology.rename")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
)
|
||||||
|
) : browseEntries.length === 0 ? (
|
||||||
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-region-hex topo-region-hex--add"
|
||||||
|
onClick={() => promptNewCustom(activeRegion.id)}
|
||||||
|
title={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 topo-browser__grid--regions">
|
||||||
|
{browseEntries.map((v, idx) => {
|
||||||
|
const isPhysical = String(v.kind) === "physical";
|
||||||
|
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
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")}
|
||||||
|
>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-map-card topo-map-card--add"
|
className="topo-region-hex topo-region-hex--add"
|
||||||
onClick={() => promptNewCustom(activeRegion.id)}
|
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>
|
||||||
<span>{t("topology.newCustom")}</span>
|
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue