mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +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
|
||||
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import and_, func, or_, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -170,6 +172,54 @@ def _empty_to_none(s: str | None) -> str | None:
|
|||
return v or None
|
||||
|
||||
|
||||
# Postgres advisory-lock namespaces for fabric ensure (avoid cross-feature collisions).
|
||||
_ADV_NS_FABRIC_MANAGED = 710001
|
||||
_ADV_NS_FABRIC_UME = 710002
|
||||
_DISCOVER_DEADLOCK_RETRIES = 4
|
||||
|
||||
|
||||
def _is_postgres(db: Session) -> bool:
|
||||
bind = db.get_bind()
|
||||
return bind is not None and str(bind.dialect.name).lower() == "postgresql"
|
||||
|
||||
|
||||
def _advisory_xact_lock(db: Session, namespace: int, key: str) -> None:
|
||||
"""Serialize concurrent creates for the same unique key (Postgres only)."""
|
||||
k = str(key or "").strip()
|
||||
if not k or not _is_postgres(db):
|
||||
return
|
||||
db.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:ns, hashtext(:key))"),
|
||||
{"ns": int(namespace), "key": k},
|
||||
)
|
||||
|
||||
|
||||
def _is_deadlock_error(exc: BaseException) -> bool:
|
||||
"""True for Postgres 40P01 / SQLite 'database is locked' style races."""
|
||||
cur: BaseException | None = exc
|
||||
seen: set[int] = set()
|
||||
while cur is not None and id(cur) not in seen:
|
||||
seen.add(id(cur))
|
||||
pgcode = getattr(cur, "pgcode", None) or getattr(cur, "sqlstate", None)
|
||||
if str(pgcode or "") == "40P01":
|
||||
return True
|
||||
msg = str(cur).lower()
|
||||
if "deadlock" in msg or "40p01" in msg:
|
||||
return True
|
||||
orig = getattr(cur, "orig", None)
|
||||
if isinstance(orig, BaseException) and id(orig) not in seen:
|
||||
cur = orig
|
||||
continue
|
||||
cur = cur.__cause__ or cur.__context__ # type: ignore[assignment]
|
||||
return False
|
||||
|
||||
|
||||
def _sleep_deadlock_backoff(attempt: int) -> None:
|
||||
# attempt is 0-based; jitter avoids thundering herd across workers.
|
||||
base = 0.05 * (2**attempt)
|
||||
time.sleep(base + random.uniform(0.0, 0.05))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fabric nodes / edges helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -254,6 +304,11 @@ def ensure_fabric_node_for_managed(db: Session, ne: ManagedNE) -> TopoFabricNode
|
|||
row.updated_at = now
|
||||
return row
|
||||
|
||||
row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none()
|
||||
if row is not None:
|
||||
return _apply(row)
|
||||
# Serialize same-key creates across workers (cross-key deadlocks still retried upstream).
|
||||
_advisory_xact_lock(db, _ADV_NS_FABRIC_MANAGED, mid)
|
||||
row = db.query(TopoFabricNode).filter(TopoFabricNode.managed_ne_id == mid).one_or_none()
|
||||
if row is not None:
|
||||
return _apply(row)
|
||||
|
|
@ -300,6 +355,10 @@ def ensure_fabric_node_for_ume(
|
|||
row.updated_at = now
|
||||
return row
|
||||
|
||||
row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none()
|
||||
if row is not None:
|
||||
return _apply(row)
|
||||
_advisory_xact_lock(db, _ADV_NS_FABRIC_UME, uid)
|
||||
row = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id == uid).one_or_none()
|
||||
if row is not None:
|
||||
return _apply(row)
|
||||
|
|
@ -602,23 +661,40 @@ def upsert_fabric_edge(
|
|||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
row = TopoFabricEdge(
|
||||
id=uuid4().hex,
|
||||
layer=layer_v,
|
||||
a_node_id=a,
|
||||
b_node_id=b,
|
||||
a_port=ap,
|
||||
b_port=bp,
|
||||
source=src,
|
||||
status="active",
|
||||
attrs={},
|
||||
discovered_at=now if src == "lldp" else None,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
return row, "added"
|
||||
try:
|
||||
with db.begin_nested():
|
||||
row = TopoFabricEdge(
|
||||
id=uuid4().hex,
|
||||
layer=layer_v,
|
||||
a_node_id=a,
|
||||
b_node_id=b,
|
||||
a_port=ap,
|
||||
b_port=bp,
|
||||
source=src,
|
||||
status="active",
|
||||
attrs={},
|
||||
discovered_at=now if src == "lldp" else None,
|
||||
last_seen_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row, "added"
|
||||
except IntegrityError:
|
||||
row = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.layer == layer_v,
|
||||
TopoFabricEdge.a_node_id == a,
|
||||
TopoFabricEdge.b_node_id == b,
|
||||
TopoFabricEdge.a_port == ap,
|
||||
TopoFabricEdge.b_port == bp,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
raise
|
||||
if (row.source or "") == "manual" and src == "lldp":
|
||||
return row, "kept_manual"
|
||||
row.source = src
|
||||
|
|
@ -631,6 +707,46 @@ def upsert_fabric_edge(
|
|||
return row, "updated"
|
||||
|
||||
|
||||
def _absorb_fabric_node(db: Session, canon: TopoFabricNode, dupe: TopoFabricNode) -> None:
|
||||
"""Retarget edges/view placements from dupe onto canon, then delete dupe."""
|
||||
if canon is None or dupe is None or canon.id == dupe.id:
|
||||
return
|
||||
if db.get(TopoFabricNode, dupe.id) is None:
|
||||
return
|
||||
_retarget_fabric_edges(db, from_id=dupe.id, to_id=canon.id)
|
||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == dupe.id).all()
|
||||
for vn in vnodes:
|
||||
exists = (
|
||||
db.query(TopoViewNode)
|
||||
.filter(
|
||||
TopoViewNode.view_id == vn.view_id,
|
||||
TopoViewNode.fabric_node_id == canon.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if exists is not None:
|
||||
db.delete(vn)
|
||||
else:
|
||||
vn.fabric_node_id = canon.id
|
||||
vn.updated_at = _utcnow()
|
||||
db.delete(dupe)
|
||||
|
||||
|
||||
def _prefer_fabric_canon(
|
||||
db: Session, a: TopoFabricNode, b: TopoFabricNode
|
||||
) -> tuple[TopoFabricNode, TopoFabricNode]:
|
||||
"""Return (canon, dupe) preferring higher inventory score, then older row."""
|
||||
sa = _fabric_match_score(db, a)
|
||||
sb = _fabric_match_score(db, b)
|
||||
if sa != sb:
|
||||
return (a, b) if sa > sb else (b, a)
|
||||
ta = a.created_at or a.updated_at
|
||||
tb = b.created_at or b.updated_at
|
||||
if ta and tb and ta != tb:
|
||||
return (a, b) if ta <= tb else (b, a)
|
||||
return (a, b) if a.id <= b.id else (b, a)
|
||||
|
||||
|
||||
def _mark_replaced_port_peers(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -643,6 +759,9 @@ def _mark_replaced_port_peers(
|
|||
) -> list[str]:
|
||||
"""Same local port now peers with a different NE → mark old edges missing (cutover).
|
||||
|
||||
If the previous peer is the same hostname (duplicate fabric rows for one device),
|
||||
absorb the weaker node instead of marking the link missing.
|
||||
|
||||
Returns ids of edges touched by this replacement (skip re-bump in same job).
|
||||
"""
|
||||
now = now or _utcnow()
|
||||
|
|
@ -650,6 +769,8 @@ def _mark_replaced_port_peers(
|
|||
if not self_id or not peer_id or not lp:
|
||||
return []
|
||||
layer_v = str(layer or "physical").strip() or "physical"
|
||||
new_peer = db.get(TopoFabricNode, peer_id)
|
||||
new_name = _norm_host(new_peer.name if new_peer is not None else "")
|
||||
candidates = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
|
|
@ -670,6 +791,47 @@ def _mark_replaced_port_peers(
|
|||
continue
|
||||
if e_peer == peer_id:
|
||||
continue
|
||||
old_peer = db.get(TopoFabricNode, e_peer)
|
||||
old_name = _norm_host(old_peer.name if old_peer is not None else "")
|
||||
# Same System Name under two fabric nodes → collapse, keep one link.
|
||||
if (
|
||||
new_peer is not None
|
||||
and old_peer is not None
|
||||
and new_name
|
||||
and old_name
|
||||
and new_name == old_name
|
||||
):
|
||||
canon, dupe = _prefer_fabric_canon(db, new_peer, old_peer)
|
||||
_absorb_fabric_node(db, canon, dupe)
|
||||
# Survivor edge on this port should stay active (retarget may have merged).
|
||||
survivor = (
|
||||
db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
TopoFabricEdge.layer == layer_v,
|
||||
or_(
|
||||
and_(
|
||||
TopoFabricEdge.a_node_id == self_id,
|
||||
TopoFabricEdge.b_node_id == canon.id,
|
||||
),
|
||||
and_(
|
||||
TopoFabricEdge.b_node_id == self_id,
|
||||
TopoFabricEdge.a_node_id == canon.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for se in survivor:
|
||||
se_local = se.a_port if se.a_node_id == self_id else se.b_port
|
||||
if normalize_ifname(se_local or "") != lp:
|
||||
continue
|
||||
se.status = "active"
|
||||
se.attrs = _clear_miss_attrs(_edge_attrs(se))
|
||||
se.last_seen_at = now
|
||||
se.updated_at = now
|
||||
handled.append(se.id)
|
||||
handled.append(e.id)
|
||||
continue
|
||||
_set_edge_missing(e, now, replaced_by_edge_id=new_edge_id)
|
||||
handled.append(e.id)
|
||||
return handled
|
||||
|
|
@ -1794,13 +1956,25 @@ def _fabric_match_score(db: Session, n: TopoFabricNode) -> int:
|
|||
def _pick_managed_ne(
|
||||
db: Session, *, ip: str = "", name_key: str = ""
|
||||
) -> ManagedNE | None:
|
||||
"""Pick inventory NE. Name matching uses hostname key (not LLDP mgmt IP)."""
|
||||
rows: list[ManagedNE] = []
|
||||
if ip:
|
||||
if name_key:
|
||||
key = _norm_host(name_key) or str(name_key or "").strip().lower()
|
||||
if key:
|
||||
candidates = (
|
||||
db.query(ManagedNE)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(ManagedNE.name) == key,
|
||||
func.lower(ManagedNE.name).like(f"{key}.%"),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
rows = [ne for ne in candidates if _norm_host(ne.name or "") == key]
|
||||
elif ip:
|
||||
# Kept for non-LLDP callers; LLDP peer match must not use this path.
|
||||
rows = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).all()
|
||||
elif name_key:
|
||||
rows = (
|
||||
db.query(ManagedNE).filter(func.lower(ManagedNE.name) == name_key).all()
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
rows.sort(key=_ne_inventory_score, reverse=True)
|
||||
|
|
@ -1877,18 +2051,18 @@ def ensure_lldp_discovered_managed_ne(
|
|||
|
||||
|
||||
class _FabricPeerIndex:
|
||||
"""In-memory name/IP index for one discover target (avoids O(nodes) per neighbor)."""
|
||||
"""In-memory name index for one discover target (avoids O(nodes) per neighbor).
|
||||
|
||||
Identity is System Name / Device ID only. LLDP Management Address is often a
|
||||
physical-interface IP and must not be used to pick the peer NE.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, self_id: str) -> None:
|
||||
self.db = db
|
||||
self.self_id = self_id
|
||||
self.by_ip: dict[str, list[TopoFabricNode]] = {}
|
||||
self.by_name: dict[str, list[TopoFabricNode]] = {}
|
||||
self.placeholder_by_name: dict[str, ManagedNE] = {}
|
||||
for n in db.query(TopoFabricNode).filter(TopoFabricNode.id != self_id).all():
|
||||
ip = str(n.ip or "").strip()
|
||||
if ip:
|
||||
self.by_ip.setdefault(ip, []).append(n)
|
||||
nk = _norm_host(n.name or "")
|
||||
if nk:
|
||||
self.by_name.setdefault(nk, []).append(n)
|
||||
|
|
@ -1900,53 +2074,35 @@ class _FabricPeerIndex:
|
|||
self.placeholder_by_name[nk] = ne
|
||||
|
||||
def _best(self, matched: list[TopoFabricNode]) -> TopoFabricNode:
|
||||
matched.sort(key=lambda n: _fabric_match_score(self.db, n), reverse=True)
|
||||
# Prefer real inventory; ties → older fabric row (stable across rediscovers).
|
||||
matched.sort(
|
||||
key=lambda n: (
|
||||
-_fabric_match_score(self.db, n),
|
||||
n.created_at.timestamp() if n.created_at else 0.0,
|
||||
n.id,
|
||||
)
|
||||
)
|
||||
return matched[0]
|
||||
|
||||
def match(self, hit: NeighborHit) -> TopoFabricNode | None:
|
||||
name_key = _norm_host(hit.remote_name)
|
||||
ip_key = str(hit.remote_ip or "").strip()
|
||||
matched: list[TopoFabricNode] = []
|
||||
if ip_key:
|
||||
matched.extend(self.by_ip.get(ip_key) or [])
|
||||
if name_key:
|
||||
for n in self.by_name.get(name_key) or []:
|
||||
if n not in matched:
|
||||
matched.append(n)
|
||||
if not name_key:
|
||||
return None
|
||||
|
||||
matched = list(self.by_name.get(name_key) or [])
|
||||
if matched:
|
||||
return self._best(matched)
|
||||
|
||||
if ip_key:
|
||||
ne = _pick_managed_ne(self.db, ip=ip_key)
|
||||
if ne is not None:
|
||||
node = ensure_fabric_node_for_managed(self.db, ne)
|
||||
self._remember(node)
|
||||
return node
|
||||
ume = (
|
||||
self.db.query(UmeInventoryNE)
|
||||
.filter(UmeInventoryNE.ip_address == ip_key)
|
||||
.first()
|
||||
)
|
||||
if ume is not None:
|
||||
node = ensure_fabric_node_for_ume(self.db, ume)
|
||||
self._remember(node)
|
||||
return node
|
||||
if name_key:
|
||||
ne = _pick_managed_ne(self.db, name_key=name_key)
|
||||
if ne is not None:
|
||||
node = ensure_fabric_node_for_managed(self.db, ne)
|
||||
self._remember(node)
|
||||
return node
|
||||
ne = _pick_managed_ne(self.db, name_key=name_key)
|
||||
if ne is not None:
|
||||
node = ensure_fabric_node_for_managed(self.db, ne)
|
||||
self._remember(node)
|
||||
return node
|
||||
return None
|
||||
|
||||
def _remember(self, node: TopoFabricNode) -> None:
|
||||
if not node or node.id == self.self_id:
|
||||
return
|
||||
ip = str(node.ip or "").strip()
|
||||
if ip:
|
||||
bucket = self.by_ip.setdefault(ip, [])
|
||||
if node not in bucket:
|
||||
bucket.append(node)
|
||||
nk = _norm_host(node.name or "")
|
||||
if nk:
|
||||
bucket = self.by_name.setdefault(nk, [])
|
||||
|
|
@ -2047,24 +2203,9 @@ def merge_duplicate_fabric_nodes(db: Session) -> dict[str, int]:
|
|||
for d in dupes:
|
||||
if d.id == canon.id:
|
||||
continue
|
||||
_retarget_fabric_edges(db, from_id=d.id, to_id=canon.id)
|
||||
# View placements: keep canon if present, else retarget; drop duplicate placements.
|
||||
vnodes = db.query(TopoViewNode).filter(TopoViewNode.fabric_node_id == d.id).all()
|
||||
for vn in vnodes:
|
||||
exists = (
|
||||
db.query(TopoViewNode)
|
||||
.filter(
|
||||
TopoViewNode.view_id == vn.view_id,
|
||||
TopoViewNode.fabric_node_id == canon.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if exists is not None:
|
||||
db.delete(vn)
|
||||
else:
|
||||
vn.fabric_node_id = canon.id
|
||||
vn.updated_at = _utcnow()
|
||||
db.delete(d)
|
||||
if db.get(TopoFabricNode, d.id) is None:
|
||||
continue
|
||||
_absorb_fabric_node(db, canon, d)
|
||||
merged += 1
|
||||
|
||||
seen_absorb: set[str] = set()
|
||||
|
|
@ -2368,12 +2509,20 @@ def _discover_one_target(
|
|||
*,
|
||||
auto_add_unmatched: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Run LLDP for one NE in a fresh DB session."""
|
||||
"""Run LLDP for one NE in a fresh DB session.
|
||||
|
||||
Keep the write txn short: resolve self fabric → commit → SSH → apply peers/edges
|
||||
(with deadlock retries). Holding inserts across SSH was a major deadlock source.
|
||||
"""
|
||||
base = {
|
||||
"ne_id": target.get("ne_id") or "",
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": "",
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = _utcnow()
|
||||
if target["ume_ne_id"] and not target["ne_id"]:
|
||||
pass
|
||||
fabric_node: TopoFabricNode | None = None
|
||||
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
|
||||
if managed is not None:
|
||||
|
|
@ -2392,15 +2541,12 @@ def _discover_one_target(
|
|||
vendor=target.get("vendor") or "",
|
||||
)
|
||||
if fabric_node is None:
|
||||
return {
|
||||
"ne_id": target["ne_id"],
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": "",
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
"ok": False,
|
||||
"error": "fabric_node_resolve_failed",
|
||||
}
|
||||
return {**base, "ok": False, "error": "fabric_node_resolve_failed"}
|
||||
|
||||
fabric_node_id = fabric_node.id
|
||||
base["fabric_node_id"] = fabric_node_id
|
||||
# Release unique-index locks before slow SSH.
|
||||
db.commit()
|
||||
|
||||
cmd, _proto = pick_neighbor_command(
|
||||
vendor=target.get("vendor") or "",
|
||||
|
|
@ -2414,25 +2560,15 @@ def _discover_one_target(
|
|||
try:
|
||||
exec_out = execute_managed_ne_commands(db, [cmd], **exec_kwargs)
|
||||
except HTTPException as exc:
|
||||
db.commit()
|
||||
return {
|
||||
"ne_id": target["ne_id"],
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": fabric_node.id,
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
**base,
|
||||
"ok": False,
|
||||
"command": cmd,
|
||||
"error": str(exc.detail or "exec_failed")[:500],
|
||||
}
|
||||
if not exec_out.get("ok"):
|
||||
db.commit()
|
||||
return {
|
||||
"ne_id": target["ne_id"],
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": fabric_node.id,
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
**base,
|
||||
"ok": False,
|
||||
"command": cmd,
|
||||
"error": str(exec_out.get("detail") or exec_out.get("error") or "exec_failed")[:500],
|
||||
|
|
@ -2448,100 +2584,163 @@ def _discover_one_target(
|
|||
vendor=target.get("vendor") or "",
|
||||
device_type=target.get("device_type") or "",
|
||||
)
|
||||
added = 0
|
||||
updated = 0
|
||||
unmatched: list[dict[str, str]] = []
|
||||
touched: list[str] = []
|
||||
replaced: list[str] = []
|
||||
peer_index = _FabricPeerIndex(db, fabric_node.id)
|
||||
for hit in hits:
|
||||
peer = peer_index.match(hit)
|
||||
if peer is None:
|
||||
if auto_add_unmatched and (hit.remote_name or hit.remote_ip):
|
||||
# Not in inventory → SSH placeholder ManagedNE (empty IP/creds).
|
||||
peer = peer_index.ensure_placeholder(
|
||||
remote_name=(hit.remote_name or "").strip(),
|
||||
remote_ip=(hit.remote_ip or "").strip(),
|
||||
)
|
||||
peer.attrs = dict(peer.attrs or {})
|
||||
peer.attrs["from_lldp_unmatched"] = True
|
||||
peer.last_seen_at = now
|
||||
peer.updated_at = now
|
||||
else:
|
||||
unmatched.append(
|
||||
{
|
||||
"remote_name": (hit.remote_name or "").strip()[:256],
|
||||
"remote_ip": (hit.remote_ip or "").strip()[:128],
|
||||
"local_port": (hit.local_port or "").strip()[:128],
|
||||
"remote_port": (hit.remote_port or "").strip()[:128],
|
||||
}
|
||||
)
|
||||
continue
|
||||
edge, action = upsert_fabric_edge(
|
||||
db,
|
||||
a_node_id=fabric_node.id,
|
||||
b_node_id=peer.id,
|
||||
a_port=(hit.local_port or ""),
|
||||
b_port=(hit.remote_port or ""),
|
||||
source="lldp",
|
||||
now=now,
|
||||
)
|
||||
touched.append(edge.id)
|
||||
# Same local port, different peer → immediate missing (cutover).
|
||||
replaced.extend(
|
||||
_mark_replaced_port_peers(
|
||||
db,
|
||||
self_id=fabric_node.id,
|
||||
local_port=(hit.local_port or ""),
|
||||
peer_id=peer.id,
|
||||
new_edge_id=edge.id,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
if action == "added":
|
||||
added += 1
|
||||
elif action == "updated":
|
||||
updated += 1
|
||||
fabric_node.last_seen_at = now
|
||||
fabric_node.updated_at = now
|
||||
db.commit()
|
||||
stub_flag = bool(is_stub and raw.strip() and not hits)
|
||||
|
||||
apply_out = _apply_discover_hits(
|
||||
db,
|
||||
fabric_node_id=fabric_node_id,
|
||||
hits=hits,
|
||||
auto_add_unmatched=auto_add_unmatched,
|
||||
)
|
||||
if not apply_out.get("ok"):
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"command": cmd,
|
||||
"parser_key": pkey,
|
||||
"parser_stub": stub_flag,
|
||||
"error": str(apply_out.get("error") or "apply_failed")[:500],
|
||||
"raw_preview": _raw_preview(raw),
|
||||
}
|
||||
|
||||
return {
|
||||
"ne_id": target["ne_id"],
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": fabric_node.id,
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
**base,
|
||||
"ok": True,
|
||||
"command": cmd,
|
||||
"neighbors": len(hits),
|
||||
"edges_added": added,
|
||||
"edges_updated": updated,
|
||||
"unmatched_count": len(unmatched),
|
||||
"unmatched": unmatched[:40],
|
||||
"edges_added": int(apply_out.get("edges_added") or 0),
|
||||
"edges_updated": int(apply_out.get("edges_updated") or 0),
|
||||
"unmatched_count": int(apply_out.get("unmatched_count") or 0),
|
||||
"unmatched": list(apply_out.get("unmatched") or []),
|
||||
"parser_key": pkey,
|
||||
"parser_stub": stub_flag,
|
||||
"error": "parser_stub" if stub_flag else "",
|
||||
"raw_preview": _raw_preview(raw),
|
||||
"touched_edge_ids": touched,
|
||||
"replaced_edge_ids": replaced,
|
||||
"scanned_node_id": fabric_node.id,
|
||||
"touched_edge_ids": list(apply_out.get("touched_edge_ids") or []),
|
||||
"replaced_edge_ids": list(apply_out.get("replaced_edge_ids") or []),
|
||||
"scanned_node_id": fabric_node_id,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
return {
|
||||
"ne_id": target.get("ne_id") or "",
|
||||
"ume_ne_id": target.get("ume_ne_id") or "",
|
||||
"fabric_node_id": "",
|
||||
"ne_name": target.get("ne_name") or "",
|
||||
"ne_ip": target.get("ne_ip") or "",
|
||||
"ok": False,
|
||||
"error": str(exc)[:500],
|
||||
}
|
||||
return {**base, "ok": False, "error": str(exc)[:500]}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _apply_discover_hits(
|
||||
db: Session,
|
||||
*,
|
||||
fabric_node_id: str,
|
||||
hits: list[NeighborHit],
|
||||
auto_add_unmatched: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Write peer fabric nodes + edges; retry on Postgres deadlocks."""
|
||||
last_err = ""
|
||||
for attempt in range(_DISCOVER_DEADLOCK_RETRIES):
|
||||
try:
|
||||
now = _utcnow()
|
||||
fabric_node = db.get(TopoFabricNode, fabric_node_id)
|
||||
if fabric_node is None:
|
||||
return {"ok": False, "error": "fabric_node_missing"}
|
||||
|
||||
added = 0
|
||||
updated = 0
|
||||
unmatched: list[dict[str, str]] = []
|
||||
touched: list[str] = []
|
||||
replaced: list[str] = []
|
||||
peer_index = _FabricPeerIndex(db, fabric_node.id)
|
||||
for hit in hits:
|
||||
peer = peer_index.match(hit)
|
||||
if peer is None:
|
||||
if auto_add_unmatched and (hit.remote_name or hit.remote_ip):
|
||||
peer = peer_index.ensure_placeholder(
|
||||
remote_name=(hit.remote_name or "").strip(),
|
||||
remote_ip=(hit.remote_ip or "").strip(),
|
||||
)
|
||||
peer.attrs = dict(peer.attrs or {})
|
||||
peer.attrs["from_lldp_unmatched"] = True
|
||||
peer.last_seen_at = now
|
||||
peer.updated_at = now
|
||||
else:
|
||||
unmatched.append(
|
||||
{
|
||||
"remote_name": (hit.remote_name or "").strip()[:256],
|
||||
"remote_ip": (hit.remote_ip or "").strip()[:128],
|
||||
"local_port": (hit.local_port or "").strip()[:128],
|
||||
"remote_port": (hit.remote_port or "").strip()[:128],
|
||||
}
|
||||
)
|
||||
continue
|
||||
edge, action = upsert_fabric_edge(
|
||||
db,
|
||||
a_node_id=fabric_node.id,
|
||||
b_node_id=peer.id,
|
||||
a_port=(hit.local_port or ""),
|
||||
b_port=(hit.remote_port or ""),
|
||||
source="lldp",
|
||||
now=now,
|
||||
)
|
||||
touched.append(edge.id)
|
||||
replaced.extend(
|
||||
_mark_replaced_port_peers(
|
||||
db,
|
||||
self_id=fabric_node.id,
|
||||
local_port=(hit.local_port or ""),
|
||||
peer_id=peer.id,
|
||||
new_edge_id=edge.id,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
if action == "added":
|
||||
added += 1
|
||||
elif action == "updated":
|
||||
updated += 1
|
||||
fabric_node.last_seen_at = now
|
||||
fabric_node.updated_at = now
|
||||
db.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"edges_added": added,
|
||||
"edges_updated": updated,
|
||||
"unmatched_count": len(unmatched),
|
||||
"unmatched": unmatched[:40],
|
||||
"touched_edge_ids": touched,
|
||||
"replaced_edge_ids": replaced,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
db.rollback()
|
||||
last_err = str(exc)[:500]
|
||||
if _is_deadlock_error(exc) and attempt + 1 < _DISCOVER_DEADLOCK_RETRIES:
|
||||
_sleep_deadlock_backoff(attempt)
|
||||
continue
|
||||
return {"ok": False, "error": last_err}
|
||||
return {"ok": False, "error": last_err or "apply_failed"}
|
||||
|
||||
|
||||
def _preensure_discover_targets(db: Session, targets: list[dict[str, str]]) -> None:
|
||||
"""Create fabric rows for scan targets before parallel workers start."""
|
||||
for target in targets:
|
||||
managed = db.get(ManagedNE, target["ne_id"]) if target.get("ne_id") else None
|
||||
if managed is not None:
|
||||
ensure_fabric_node_for_managed(db, managed)
|
||||
continue
|
||||
if not target.get("ume_ne_id"):
|
||||
continue
|
||||
ume = (
|
||||
db.query(UmeInventoryNE)
|
||||
.filter(UmeInventoryNE.ne_id == target["ume_ne_id"])
|
||||
.one_or_none()
|
||||
)
|
||||
if ume is not None:
|
||||
ensure_fabric_node_for_ume(
|
||||
db,
|
||||
ume,
|
||||
device_type=target.get("device_type") or "",
|
||||
vendor=target.get("vendor") or "",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
|
@ -2563,6 +2762,12 @@ def _run_discover_job(job_id: str, body: FabricDiscoverRequest) -> None:
|
|||
job.total = len(targets)
|
||||
db.commit()
|
||||
|
||||
# Reduce cross-worker races on self nodes before concurrent SSH/apply.
|
||||
try:
|
||||
_preensure_discover_targets(db, targets)
|
||||
except Exception: # noqa: BLE001
|
||||
db.rollback()
|
||||
|
||||
concurrency = max(1, min(32, int(body.concurrency or 4)))
|
||||
added = 0
|
||||
updated = 0
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import unittest
|
|||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from netx_api import topology_lldp as lldp
|
||||
from netx_api import topology_service as svc
|
||||
from netx_api.db import Base, SessionLocal, engine
|
||||
|
|
@ -97,6 +100,17 @@ class LldpParserTests(unittest.TestCase):
|
|||
self.assertEqual(cmd, "show lldp neighbors detail")
|
||||
|
||||
|
||||
class DeadlockHelperTests(unittest.TestCase):
|
||||
def test_is_deadlock_error_detects_pg_message(self) -> None:
|
||||
exc = Exception(
|
||||
'(psycopg.errors.DeadlockDetected) 检测到死锁 DETAIL: 进程28484等待'
|
||||
)
|
||||
self.assertTrue(svc._is_deadlock_error(exc))
|
||||
|
||||
def test_is_deadlock_error_ignores_other(self) -> None:
|
||||
self.assertFalse(svc._is_deadlock_error(ValueError("unique violation")))
|
||||
|
||||
|
||||
class FabricTopologyTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
|
|
@ -125,6 +139,62 @@ class FabricTopologyTests(unittest.TestCase):
|
|||
def tearDown(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def test_apply_discover_hits_retries_deadlock(self) -> None:
|
||||
suffix = uuid4().hex[:8]
|
||||
ne = ManagedNE(
|
||||
id=f"nea-{suffix}",
|
||||
name=f"R2-{suffix}",
|
||||
vendor="Cisco",
|
||||
device_type="cisco_ios",
|
||||
ip_address=f"203.0.113.{(int(suffix[:2], 16) % 80) + 20}",
|
||||
)
|
||||
peer_ne = ManagedNE(
|
||||
id=f"neb-{suffix}",
|
||||
name="r1",
|
||||
vendor="Cisco",
|
||||
device_type="cisco_ios",
|
||||
ip_address=f"203.0.113.{(int(suffix[2:4], 16) % 80) + 120}",
|
||||
)
|
||||
self.db.add(ne)
|
||||
self.db.add(peer_ne)
|
||||
self.db.commit()
|
||||
node = svc.ensure_fabric_node_for_managed(self.db, ne)
|
||||
svc.ensure_fabric_node_for_managed(self.db, peer_ne)
|
||||
self.db.commit()
|
||||
hits = [
|
||||
lldp.NeighborHit(
|
||||
remote_name="r1",
|
||||
local_port="Gi0/1",
|
||||
remote_port="Ethernet1/0/1",
|
||||
)
|
||||
]
|
||||
calls = {"n": 0}
|
||||
real_upsert = svc.upsert_fabric_edge
|
||||
|
||||
def flaky_upsert(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise OperationalError(
|
||||
"INSERT",
|
||||
{},
|
||||
Exception("DeadlockDetected: fake"),
|
||||
)
|
||||
return real_upsert(*args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(svc, "upsert_fabric_edge", side_effect=flaky_upsert),
|
||||
patch.object(svc, "_sleep_deadlock_backoff", return_value=None),
|
||||
):
|
||||
out = svc._apply_discover_hits(
|
||||
self.db,
|
||||
fabric_node_id=node.id,
|
||||
hits=hits,
|
||||
auto_add_unmatched=False,
|
||||
)
|
||||
self.assertTrue(out.get("ok"), out)
|
||||
self.assertEqual(calls["n"], 2)
|
||||
self.assertGreaterEqual(int(out.get("edges_added") or 0), 1)
|
||||
|
||||
def _region(self, name: str = "Test-Region") -> str:
|
||||
return svc.create_folder(
|
||||
self.db, TopologyFolderCreate(name=name, kind="region")
|
||||
|
|
@ -742,7 +812,15 @@ Management Addresses:
|
|||
inv = svc.ensure_fabric_node_for_managed(self.db, real)
|
||||
ghost = svc.ensure_fabric_node_for_managed(self.db, webcrt)
|
||||
self.db.commit()
|
||||
hit = lldp.NeighborHit(remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1")
|
||||
# LLDP management IP alone is not identity — need System Name.
|
||||
ip_only = lldp.NeighborHit(
|
||||
remote_name="", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
|
||||
)
|
||||
self.assertIsNone(svc._match_hit_to_fabric_node(self.db, ip_only, self_id="self"))
|
||||
|
||||
hit = lldp.NeighborHit(
|
||||
remote_name="R2", remote_ip=ip, local_port="Gi0/0", remote_port="Gi0/1"
|
||||
)
|
||||
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||||
self.assertEqual(peer.id, inv.id)
|
||||
self.assertNotEqual(peer.id, ghost.id)
|
||||
|
|
@ -755,6 +833,127 @@ Management Addresses:
|
|||
self.db.delete(webcrt)
|
||||
self.db.commit()
|
||||
|
||||
def test_match_ignores_lldp_mgmt_ip_prefers_hostname(self) -> None:
|
||||
"""Two inventory NEs both named r1 with different IPs — match by name, not LLDP IP."""
|
||||
suffix = uuid4().hex[:8]
|
||||
ne_real = ManagedNE(
|
||||
id=f"real-{suffix}",
|
||||
name="r1",
|
||||
vendor="Huawei",
|
||||
device_type="huawei",
|
||||
ip_address="192.168.0.127",
|
||||
source="manual",
|
||||
)
|
||||
ne_wrong = ManagedNE(
|
||||
id=f"wrong-{suffix}",
|
||||
name="r1-lab", # different hostname key
|
||||
vendor="Cisco",
|
||||
device_type="cisco_ios",
|
||||
ip_address="203.0.113.184",
|
||||
source="manual",
|
||||
)
|
||||
self.db.add(ne_real)
|
||||
self.db.add(ne_wrong)
|
||||
self.db.commit()
|
||||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_real)
|
||||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_wrong)
|
||||
self.db.commit()
|
||||
hit = lldp.NeighborHit(
|
||||
remote_name="r1",
|
||||
remote_ip="203.0.113.184", # misleading interface/mgmt IP
|
||||
local_port="Gi0/1",
|
||||
remote_port="Ethernet1/0/1",
|
||||
)
|
||||
peer = svc._match_hit_to_fabric_node(self.db, hit, self_id="self")
|
||||
self.assertEqual(peer.id, fa.id)
|
||||
self.assertNotEqual(peer.id, fb.id)
|
||||
self.db.delete(ne_real)
|
||||
self.db.delete(ne_wrong)
|
||||
self.db.commit()
|
||||
|
||||
def test_same_hostname_port_cutover_merges_not_missing(self) -> None:
|
||||
"""Duplicate fabric rows for same hostname must not leave a red replaced edge."""
|
||||
suffix = uuid4().hex[:8]
|
||||
ne_a = ManagedNE(
|
||||
id=f"nea-{suffix}",
|
||||
name=f"R2-{suffix}",
|
||||
vendor="Cisco",
|
||||
device_type="cisco_ios",
|
||||
ip_address=f"192.168.0.{(int(suffix[:2], 16) % 80) + 10}",
|
||||
)
|
||||
ne_b = ManagedNE(
|
||||
id=f"neb-{suffix}",
|
||||
name="r1",
|
||||
vendor="Cisco",
|
||||
device_type="cisco_ios",
|
||||
ip_address="192.168.0.127",
|
||||
source="manual",
|
||||
)
|
||||
ne_c = ManagedNE(
|
||||
id=f"nec-{suffix}",
|
||||
name="r1",
|
||||
vendor="Other",
|
||||
device_type="generic",
|
||||
ip_address="203.0.113.184",
|
||||
source=LLDP_DISCOVERED_NE_SOURCE,
|
||||
)
|
||||
self.db.add_all([ne_a, ne_b, ne_c])
|
||||
self.db.commit()
|
||||
fa = svc.ensure_fabric_node_for_managed(self.db, ne_a)
|
||||
fb = svc.ensure_fabric_node_for_managed(self.db, ne_b)
|
||||
fc = svc.ensure_fabric_node_for_managed(self.db, ne_c)
|
||||
self.db.commit()
|
||||
|
||||
old, _ = svc.upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=fa.id,
|
||||
b_node_id=fb.id,
|
||||
a_port="Gi0/1",
|
||||
b_port="Ethernet1/0/1",
|
||||
source="lldp",
|
||||
)
|
||||
new, _ = svc.upsert_fabric_edge(
|
||||
self.db,
|
||||
a_node_id=fa.id,
|
||||
b_node_id=fc.id,
|
||||
a_port="Gi0/1",
|
||||
b_port="Ethernet1/0/1",
|
||||
source="lldp",
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
handled = svc._mark_replaced_port_peers(
|
||||
self.db,
|
||||
self_id=fa.id,
|
||||
local_port="Gi0/1",
|
||||
peer_id=fc.id,
|
||||
new_edge_id=new.id,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.expire_all()
|
||||
|
||||
# Placeholder fabric absorbed into real inventory; no missing/replaced link.
|
||||
self.assertIsNone(self.db.get(TopoFabricNode, fc.id))
|
||||
edges = (
|
||||
self.db.query(TopoFabricEdge)
|
||||
.filter(
|
||||
or_(TopoFabricEdge.a_node_id == fa.id, TopoFabricEdge.b_node_id == fa.id)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
active = [e for e in edges if e.status == "active"]
|
||||
missing = [e for e in edges if e.status == "missing"]
|
||||
self.assertEqual(len(active), 1)
|
||||
self.assertEqual(len(missing), 0)
|
||||
peer_id = active[0].b_node_id if active[0].a_node_id == fa.id else active[0].a_node_id
|
||||
self.assertEqual(peer_id, fb.id)
|
||||
self.assertTrue(handled)
|
||||
|
||||
self.db.delete(ne_a)
|
||||
self.db.delete(ne_b)
|
||||
self.db.delete(ne_c)
|
||||
self.db.commit()
|
||||
|
||||
def test_neighborhood(self) -> None:
|
||||
suffix = uuid4().hex[:8]
|
||||
ne_a = ManagedNE(
|
||||
|
|
|
|||
|
|
@ -1225,12 +1225,20 @@ const en = {
|
|||
tree: "Topology tree",
|
||||
treeLoading: "Loading tree…",
|
||||
treeHint: "Regions hold a physical map plus optional custom maps; open a map to edit the canvas.",
|
||||
treeSearch: "Search NEs across maps",
|
||||
treeSearchPh: "Name / IP",
|
||||
treeSearchEmpty: "No matching NEs",
|
||||
treeSearchNoViews: "Not on any map",
|
||||
treeSearchNotOnMap: "This NE is not placed on any topology map yet",
|
||||
treeSearchViewCount: "{{count}} map(s)",
|
||||
treeSearchTruncated: "Showing first 30 of {{total}}",
|
||||
rootName: "Regions",
|
||||
selectRegion: "Select a region",
|
||||
openMap: "Open map",
|
||||
openRegion: "Open region",
|
||||
kindPhysical: "Physical",
|
||||
kindCustom: "Custom",
|
||||
regionNodeHint: "{{count}} map(s)",
|
||||
regionNodeHint: "{{count}}P",
|
||||
layerNodeHint: "{{count}} NE(s)",
|
||||
layerNodeEmpty: "Click to create & open",
|
||||
rootCanvasHint: "Pick a region from the left tree.",
|
||||
|
|
@ -1238,6 +1246,7 @@ const en = {
|
|||
regionBrowseHint: "Region folder: physical + custom maps. Click to open.",
|
||||
browserTitle: "Topology folder",
|
||||
browserRegionSub: "{{count}} map(s)",
|
||||
browserRegionsSub: "{{count}} region(s)",
|
||||
browserEmptyMaps: "No maps in this region yet.",
|
||||
addNe: "Add NE",
|
||||
backUp: "Back to region",
|
||||
|
|
|
|||
|
|
@ -1219,12 +1219,20 @@ const zh = {
|
|||
tree: "拓扑树",
|
||||
treeLoading: "加载拓扑树…",
|
||||
treeHint: "区域下为物理拓扑与自定义图;点图打开设备画布。",
|
||||
treeSearch: "全拓扑搜索网元",
|
||||
treeSearchPh: "名称 / IP",
|
||||
treeSearchEmpty: "没有匹配的网元",
|
||||
treeSearchNoViews: "未上图",
|
||||
treeSearchNotOnMap: "该网元尚未出现在任何拓扑图上",
|
||||
treeSearchViewCount: "{{count}} 张图",
|
||||
treeSearchTruncated: "仅显示前 30 条(共 {{total}})",
|
||||
rootName: "区域",
|
||||
selectRegion: "请选择区域",
|
||||
openMap: "打开本图",
|
||||
openRegion: "打开区域",
|
||||
kindPhysical: "物理拓扑",
|
||||
kindCustom: "自定义图",
|
||||
regionNodeHint: "{{count}} 张图",
|
||||
regionNodeHint: "{{count}}P",
|
||||
layerNodeHint: "{{count}} 个网元",
|
||||
layerNodeEmpty: "点击创建并打开",
|
||||
rootCanvasHint: "从左侧选择区域进入目录。",
|
||||
|
|
@ -1232,6 +1240,7 @@ const zh = {
|
|||
regionBrowseHint: "区域目录:物理拓扑 + 自定义图。点击打开。",
|
||||
browserTitle: "拓扑目录",
|
||||
browserRegionSub: "共 {{count}} 张拓扑图",
|
||||
browserRegionsSub: "共 {{count}} 个区域",
|
||||
browserEmptyMaps: "此区域还没有拓扑图。",
|
||||
addNe: "添加网元",
|
||||
backUp: "返回区域",
|
||||
|
|
|
|||
|
|
@ -3514,23 +3514,155 @@ pre {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.topo-sidebar__head {
|
||||
display: flex;
|
||||
.app-main .topo-sidebar__icon-btn,
|
||||
.topo-sidebar__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0 !important;
|
||||
margin: 0;
|
||||
border: 0 !important;
|
||||
border-radius: 4px !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topo-sidebar__head > strong {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand);
|
||||
.app-main .topo-sidebar__icon-btn:hover:not(:disabled),
|
||||
.topo-sidebar__icon-btn:hover:not(:disabled) {
|
||||
color: var(--nm-brand, #0f2744);
|
||||
background: rgba(15, 39, 68, 0.06) !important;
|
||||
}
|
||||
|
||||
.topo-sidebar__head-actions {
|
||||
.app-main .topo-sidebar__icon-btn:disabled,
|
||||
.topo-sidebar__icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.topo-tree-search {
|
||||
position: relative;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.topo-tree-search__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-tree-search__bar > .input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topo-tree-search__panel {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: min(360px, 50vh);
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid #b8c4d2;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.topo-tree-search__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topo-tree-search__item + .topo-tree-search__item {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid #eef2f6;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.topo-tree-search__ne {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.topo-tree-search__ne:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.topo-tree-search__name {
|
||||
grid-column: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nm-ink, #0f172a);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-tree-search__ip {
|
||||
grid-column: 1;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topo-tree-search__meta {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: center;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-tree-search__views {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 0 6px 4px;
|
||||
}
|
||||
|
||||
.topo-tree-search__view {
|
||||
max-width: 100%;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d0dae6;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
color: var(--nm-brand, #1d4ed8);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topo-tree-search__view:hover {
|
||||
background: #e8eef8;
|
||||
border-color: #9eb6d8;
|
||||
}
|
||||
|
||||
.topo-tree-search__hint {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.topo-palette-source {
|
||||
|
|
@ -3859,9 +3991,156 @@ pre {
|
|||
gap: 14px;
|
||||
align-content: start;
|
||||
padding: 16px 18px 20px;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.22) 1px, transparent 0);
|
||||
background-size: 18px 18px;
|
||||
background: #eef2f7;
|
||||
}
|
||||
.topo-browser__grid--regions {
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 160px));
|
||||
gap: 20px 22px;
|
||||
padding: 24px 22px 32px;
|
||||
background: #e8eef6;
|
||||
justify-content: start;
|
||||
}
|
||||
/* Beat .app-main button { background:#fff; color:#1f2937; border } */
|
||||
.app-main .topo-region-hex,
|
||||
.topo-region-hex {
|
||||
width: 140px;
|
||||
height: 156px;
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 34px 18px 30px;
|
||||
margin: 0;
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: #fff !important;
|
||||
transition: transform 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
.app-main .topo-region-hex:hover,
|
||||
.topo-region-hex:hover,
|
||||
.app-main .topo-region-hex.is-hot,
|
||||
.topo-region-hex.is-hot {
|
||||
transform: translateY(-2px) scale(1.03);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
.app-main .topo-region-hex.is-selected,
|
||||
.topo-region-hex.is-selected,
|
||||
.app-main .topo-region-hex.is-hot,
|
||||
.topo-region-hex.is-hot {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.92),
|
||||
0 0 0 6px rgba(26, 86, 168, 0.55);
|
||||
z-index: 1;
|
||||
}
|
||||
.app-main .topo-region-hex.is-selected,
|
||||
.topo-region-hex.is-selected {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
}
|
||||
.app-main .topo-region-hex--tone-0,
|
||||
.topo-region-hex--tone-0 {
|
||||
background: linear-gradient(160deg, #5b9cf0 0%, #1a56a8 52%, #0f766e 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--tone-1,
|
||||
.topo-region-hex--tone-1 {
|
||||
background: linear-gradient(160deg, #67e8f9 0%, #0284c7 48%, #1d4ed8 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--tone-2,
|
||||
.topo-region-hex--tone-2 {
|
||||
background: linear-gradient(160deg, #5eead4 0%, #0d9488 50%, #115e59 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--tone-3,
|
||||
.topo-region-hex--tone-3 {
|
||||
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 46%, #b45309 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--tone-4,
|
||||
.topo-region-hex--tone-4 {
|
||||
background: linear-gradient(160deg, #cbd5e1 0%, #64748b 48%, #1e3a5f 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--tone-physical,
|
||||
.topo-region-hex--tone-physical {
|
||||
background: linear-gradient(160deg, #fcd34d 0%, #f59e0b 42%, #b45309 100%) !important;
|
||||
}
|
||||
.app-main .topo-region-hex--add,
|
||||
.topo-region-hex--add {
|
||||
background: linear-gradient(160deg, #f8fafc 0%, #dbe7f5 100%) !important;
|
||||
color: #1a56a8 !important;
|
||||
}
|
||||
.app-main .topo-region-hex--add:hover,
|
||||
.topo-region-hex--add:hover {
|
||||
background: linear-gradient(160deg, #ffffff 0%, #cfe0f4 100%) !important;
|
||||
color: #0f2744 !important;
|
||||
}
|
||||
.app-main .topo-region-hex--add:disabled,
|
||||
.topo-region-hex--add:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.topo-region-hex__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-region-hex--add .topo-region-hex__icon {
|
||||
background: rgba(26, 86, 168, 0.12);
|
||||
color: #1a56a8;
|
||||
}
|
||||
.topo-region-hex__name {
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topo-region-hex__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.topo-region-hex__title .topo-region-hex__name {
|
||||
max-width: 70%;
|
||||
}
|
||||
.topo-region-hex__meta {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topo-region-hex--add .topo-region-hex__meta {
|
||||
color: #3d6f9c;
|
||||
}
|
||||
.topo-region-hex__plus {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
background: rgba(26, 86, 168, 0.12);
|
||||
color: #1a56a8;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.topo-map-card {
|
||||
display: flex;
|
||||
|
|
@ -3988,22 +4267,51 @@ pre {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 2px 6px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.3;
|
||||
color: var(--nm-brand, #0f2744);
|
||||
}
|
||||
.app-main .topo-breadcrumb__link,
|
||||
.topo-breadcrumb__link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--nm-accent, #2563eb);
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
color: #5b6b7c !important;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
padding: 0 !important;
|
||||
margin: 0;
|
||||
border-radius: 0 !important;
|
||||
font: inherit;
|
||||
font-weight: 550;
|
||||
line-height: inherit;
|
||||
width: auto;
|
||||
}
|
||||
.app-main .topo-breadcrumb__link:hover,
|
||||
.topo-breadcrumb__link:hover {
|
||||
color: var(--nm-accent, #1a56a8) !important;
|
||||
background: transparent !important;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.topo-breadcrumb__sep {
|
||||
opacity: 0.45;
|
||||
color: #94a3b8;
|
||||
opacity: 1;
|
||||
font-weight: 400;
|
||||
user-select: none;
|
||||
}
|
||||
.topo-breadcrumb__current {
|
||||
font-weight: 600;
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: var(--nm-accent, #1a56a8);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.topo-toolbar__level {
|
||||
margin-left: 8px;
|
||||
|
|
@ -4277,7 +4585,7 @@ pre {
|
|||
.topo-region-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
|
|
@ -4285,49 +4593,216 @@ pre {
|
|||
margin: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 2px 0 4px;
|
||||
}
|
||||
|
||||
.topo-region-list__row {
|
||||
align-items: stretch;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-region-list__row {
|
||||
border-color: var(--nm-accent);
|
||||
background: var(--nm-nav-active);
|
||||
box-shadow: inset 2px 0 0 var(--nm-accent);
|
||||
/* Flat list rows — no boxed cards */
|
||||
.topo-region-list .topo-map-list__row {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
gap: 2px;
|
||||
padding-right: 2px;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions {
|
||||
border-left-color: #9bb6d9;
|
||||
.topo-region-list .topo-map-list__row:hover,
|
||||
.topo-region-list__block.is-hot > .topo-region-list__row,
|
||||
.topo-region-list__maps > li.is-hot > .topo-map-list__row {
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
/* Selected = deep wash + left accent */
|
||||
.topo-region-list__block.is-active > .topo-region-list__row,
|
||||
.topo-region-list__maps > li.is-active > .topo-map-list__row {
|
||||
border: 0;
|
||||
background: #8eb6e3;
|
||||
box-shadow: inset 3px 0 0 #0f3f7a;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-branch-active > .topo-region-list__row {
|
||||
border: 0;
|
||||
background: rgba(142, 182, 227, 0.35);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__actions,
|
||||
.topo-region-list__maps > li.is-active > .topo-map-list__row .topo-map-list__actions {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.topo-region-list__glyph {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 5px;
|
||||
vertical-align: -2px;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-right: 2px;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topo-region-list__maps .topo-region-list__glyph {
|
||||
background: rgba(26, 86, 168, 0.1);
|
||||
color: #1a56a8;
|
||||
}
|
||||
|
||||
.topo-region-list__maps {
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
margin: 2px 0 0;
|
||||
padding: 2px 0 2px 0;
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.topo-region-list__maps::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 1px;
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.topo-region-list__maps > li {
|
||||
margin: 0 0 4px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topo-region-list__maps > li + li {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.topo-region-list__maps .topo-map-list__row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main .topo-region-list .topo-map-list__item,
|
||||
.topo-region-list .topo-map-list__item {
|
||||
padding: 8px 6px 8px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.app-main .topo-region-list__maps .topo-map-list__item,
|
||||
.topo-region-list__maps .topo-map-list__item {
|
||||
padding: 7px 6px 7px 36px;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__item:hover,
|
||||
.topo-region-list__block.is-active > .topo-region-list__row .topo-map-list__item,
|
||||
.topo-region-list__block.is-branch-active > .topo-region-list__row .topo-map-list__item,
|
||||
.topo-region-list__maps > li.is-active .topo-map-list__item,
|
||||
.topo-region-list__maps > li.is-active .topo-map-list__item:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__actions {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border-left: 0;
|
||||
gap: 4px;
|
||||
padding-right: 4px;
|
||||
align-items: center;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
/* Edit/delete only while the row itself is hovered */
|
||||
.topo-region-list .topo-map-list__row:hover .topo-map-list__actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-main .topo-region-list .topo-map-list__icon,
|
||||
.topo-region-list .topo-map-list__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
border-radius: 5px;
|
||||
color: #475569;
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.app-main .topo-region-list .topo-map-list__icon:hover:not(:disabled),
|
||||
.topo-region-list .topo-map-list__icon:hover:not(:disabled) {
|
||||
background: rgba(15, 39, 68, 0.12) !important;
|
||||
color: var(--nm-brand);
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__title {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--nm-brand, #0f2744);
|
||||
}
|
||||
|
||||
.topo-region-list__maps .topo-map-list__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.topo-region-list .topo-map-list__count {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.topo-region-list__maps .topo-map-list__count {
|
||||
color: #64748b;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active .topo-map-list__count,
|
||||
.topo-region-list__maps > li.is-active .topo-map-list__count {
|
||||
background: transparent;
|
||||
color: #0a274f;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-hot:not(.is-active) .topo-map-list__count,
|
||||
.topo-region-list__maps > li.is-hot:not(.is-active) .topo-map-list__count {
|
||||
background: transparent;
|
||||
color: #1a56a8;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active .topo-map-list__title,
|
||||
.topo-region-list__maps > li.is-active .topo-map-list__title {
|
||||
color: #0a274f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topo-region-list__maps > li.is-active .topo-region-list__glyph {
|
||||
background: #0f3f7a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topo-map-list li,
|
||||
|
|
@ -4361,6 +4836,29 @@ pre {
|
|||
box-shadow: inset 2px 0 0 var(--nm-accent);
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-map-list__row,
|
||||
.topo-region-list__maps > li.is-active > .topo-map-list__row {
|
||||
border: 0 !important;
|
||||
background: #8eb6e3 !important;
|
||||
box-shadow: inset 3px 0 0 #0f3f7a !important;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-active > .topo-map-list__row:hover,
|
||||
.topo-region-list__maps > li.is-active > .topo-map-list__row:hover {
|
||||
background: #7aa8db !important;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-branch-active > .topo-map-list__row {
|
||||
border: 0 !important;
|
||||
background: rgba(142, 182, 227, 0.35) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.topo-region-list__block.is-hot:not(.is-active):not(.is-branch-active) > .topo-map-list__row,
|
||||
.topo-region-list__maps > li.is-hot:not(.is-active) > .topo-map-list__row {
|
||||
background: #c5daf3 !important;
|
||||
}
|
||||
|
||||
.topo-palette > li.is-on-canvas .topo-palette__row {
|
||||
background: #eef2f7;
|
||||
border-color: #d7dee8;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ReactFlow,
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
patchTopologyPositions,
|
||||
projectTopologyNeighbors,
|
||||
removeTopologyViewNodes,
|
||||
searchFabricNodes,
|
||||
startLldpDiscover,
|
||||
updateTopologyFolder,
|
||||
updateTopologyMap,
|
||||
|
|
@ -49,6 +50,7 @@ import { useI18n } from "../i18n";
|
|||
import { useToast } from "../hooks/useToast";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import type {
|
||||
FabricNodeSearchHit,
|
||||
ManagedNeItem,
|
||||
TopologyDiscoverJob,
|
||||
TopologyDiscoverNeResult,
|
||||
|
|
@ -70,10 +72,6 @@ import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/tool
|
|||
const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||
const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||
|
||||
function kindLabelKey(kind: string): string {
|
||||
return String(kind) === "physical" ? "topology.kindPhysical" : "topology.kindCustom";
|
||||
}
|
||||
|
||||
function findViewInRegion(
|
||||
regions: TopologyTreeFolderItem[],
|
||||
viewId: string,
|
||||
|
|
@ -127,36 +125,15 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
|
|||
/** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */
|
||||
const SEP = " / ";
|
||||
|
||||
function ChevronIcon({ dir }: { dir: "left" | "right" | "down" }) {
|
||||
const d =
|
||||
dir === "left" ? "M15 6l-6 6 6 6" : dir === "down" ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6";
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||
<path fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Left-panel collapse / expand control (icon-only sidebar toggle). */
|
||||
/** Left-panel collapse / expand control (simple chevron). */
|
||||
function SidebarFoldIcon({ expand }: { expand?: boolean }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||
<rect
|
||||
x="3.5"
|
||||
y="4.5"
|
||||
width="17"
|
||||
height="15"
|
||||
rx="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
<path d="M9.5 4.5v15" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path
|
||||
d={expand ? "M13.5 9.5L17 12l-3.5 2.5" : "M16.5 9.5L13 12l3.5 2.5"}
|
||||
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
|
@ -632,6 +609,8 @@ export function TopologyPage() {
|
|||
// mapId set → fabric canvas; otherwise browse directory (region or view folder).
|
||||
const [mapId, setMapId] = useState<string>("");
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
|
||||
/** Hover sync key between left tree and right hex browser: `region:<id>` | `view:<id>`. */
|
||||
const [hotBrowseKey, setHotBrowseKey] = useState("");
|
||||
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
|
||||
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
|
||||
try {
|
||||
|
|
@ -643,6 +622,10 @@ export function TopologyPage() {
|
|||
});
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
||||
const [treeNeQuery, setTreeNeQuery] = useState("");
|
||||
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
|
||||
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
|
||||
const treeSearchRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [historyTick, setHistoryTick] = useState(0);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
|
|
@ -755,6 +738,17 @@ export function TopologyPage() {
|
|||
queryFn: fetchTopologyTree,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [treeNeQuery]);
|
||||
|
||||
const treeNeSearchQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
|
||||
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
|
||||
enabled: debouncedTreeNeQuery.length >= 1,
|
||||
});
|
||||
|
||||
const graphQuery = useQuery({
|
||||
queryKey: queryKeys.topologyGraph(mapId),
|
||||
queryFn: () => fetchTopologyGraph(mapId),
|
||||
|
|
@ -815,11 +809,9 @@ export function TopologyPage() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (!selectedFolderId || selectedFolderId === treeRoot.id) {
|
||||
setSelectedFolderId(regions[0].id);
|
||||
setExpandedIds((prev) =>
|
||||
prev[regions[0].id] === undefined ? { ...prev, [regions[0].id]: true } : prev,
|
||||
);
|
||||
// Keep root (all regions) as the default landing view — do not auto-pick regions[0].
|
||||
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
|
||||
setSelectedFolderId("");
|
||||
}
|
||||
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||
|
||||
|
|
@ -852,6 +844,15 @@ export function TopologyPage() {
|
|||
[confirmDiscardIfDirty, clearDirty, setNodes, setEdges],
|
||||
);
|
||||
|
||||
const goRoot = useCallback(() => {
|
||||
if (!confirmDiscardIfDirty()) return;
|
||||
setMapId("");
|
||||
setSelectedFolderId("");
|
||||
clearDirty();
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
|
||||
|
||||
const goCanvas = useCallback(
|
||||
(viewId: string, folderId?: string) => {
|
||||
if (!confirmDiscardIfDirty()) return;
|
||||
|
|
@ -1801,6 +1802,70 @@ export function TopologyPage() {
|
|||
[focusNode],
|
||||
);
|
||||
|
||||
const jumpToTreeSearchHit = useCallback(
|
||||
(
|
||||
hit: FabricNodeSearchHit,
|
||||
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
|
||||
) => {
|
||||
const views = hit.views || [];
|
||||
const onCurrent =
|
||||
Boolean(mapId) &&
|
||||
(views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id));
|
||||
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
|
||||
if (nodes.some((n) => n.id === hit.id)) {
|
||||
locateNode(hit.id);
|
||||
} else {
|
||||
setPendingHighlightNe(hit.id);
|
||||
}
|
||||
setTreeNeQuery("");
|
||||
setDebouncedTreeNeQuery("");
|
||||
setTreeSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
view ||
|
||||
views.find((v) => String(v.kind) === "physical") ||
|
||||
views[0] ||
|
||||
null;
|
||||
if (!target) {
|
||||
showError(t("topology.treeSearchNotOnMap"));
|
||||
return;
|
||||
}
|
||||
if (!confirmDiscardIfDirty()) return;
|
||||
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
|
||||
if (regionId) {
|
||||
setSelectedFolderId(regionId);
|
||||
setExpandedIds((p) => ({ ...p, [regionId]: true }));
|
||||
}
|
||||
setMapId(target.view_id);
|
||||
setPendingHighlightNe(hit.id);
|
||||
setTreeNeQuery("");
|
||||
setDebouncedTreeNeQuery("");
|
||||
setTreeSearchOpen(false);
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
locateNode,
|
||||
showError,
|
||||
t,
|
||||
confirmDiscardIfDirty,
|
||||
regions,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeSearchOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const el = treeSearchRef.current;
|
||||
if (el && e.target instanceof Element && !el.contains(e.target)) {
|
||||
setTreeSearchOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [treeSearchOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingHighlightNe || !canvasMode || !nodes.length) return;
|
||||
const nodeId = pendingHighlightNe;
|
||||
|
|
@ -1922,7 +1987,8 @@ export function TopologyPage() {
|
|||
|
||||
const titleText = useMemo(() => {
|
||||
if (canvasMode) return activeLeafName || t("topology.selectMap");
|
||||
return regionDisplayName(activeRegion) || t("topology.selectRegion");
|
||||
if (activeRegion) return regionDisplayName(activeRegion);
|
||||
return t("topology.rootName");
|
||||
}, [canvasMode, activeLeafName, activeRegion, t]);
|
||||
const onCanvasManagedIds = useMemo(
|
||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||
|
|
@ -2002,12 +2068,23 @@ export function TopologyPage() {
|
|||
) : (
|
||||
<>
|
||||
<div className="topo-sidebar__section">
|
||||
<div className="topo-sidebar__head">
|
||||
<strong>{t("topology.tree")}</strong>
|
||||
<div className="topo-sidebar__head-actions">
|
||||
<div className="topo-tree-search" ref={treeSearchRef}>
|
||||
<div className="topo-tree-search__bar">
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={treeNeQuery}
|
||||
placeholder={t("topology.treeSearchPh")}
|
||||
aria-label={t("topology.treeSearch")}
|
||||
onChange={(e) => {
|
||||
setTreeNeQuery(e.target.value);
|
||||
setTreeSearchOpen(true);
|
||||
}}
|
||||
onFocus={() => setTreeSearchOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost topo-icon-btn"
|
||||
className="topo-sidebar__icon-btn"
|
||||
onClick={promptNewRegion}
|
||||
disabled={createRegionMut.isPending || !treeRoot}
|
||||
title={t("topology.newRegion")}
|
||||
|
|
@ -2017,7 +2094,7 @@ export function TopologyPage() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost topo-icon-btn"
|
||||
className="topo-sidebar__icon-btn"
|
||||
title={t("topology.collapseSidebar")}
|
||||
aria-label={t("topology.collapseSidebar")}
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
|
|
@ -2025,6 +2102,72 @@ export function TopologyPage() {
|
|||
<SidebarFoldIcon />
|
||||
</button>
|
||||
</div>
|
||||
{treeSearchOpen && treeNeQuery.trim() ? (
|
||||
<div className="topo-tree-search__panel" role="listbox">
|
||||
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
|
||||
<p className="topo-tree-search__hint muted">…</p>
|
||||
) : !(treeNeSearchQuery.data?.items || []).length ? (
|
||||
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
|
||||
) : (
|
||||
(treeNeSearchQuery.data?.items || []).map((hit) => {
|
||||
const views = hit.views || [];
|
||||
const title = hit.name || hit.ip || hit.id.slice(0, 8);
|
||||
return (
|
||||
<div key={hit.id} className="topo-tree-search__item">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-tree-search__ne"
|
||||
onClick={() => jumpToTreeSearchHit(hit)}
|
||||
title={
|
||||
views.length
|
||||
? t("topology.locateOnCanvas")
|
||||
: t("topology.treeSearchNotOnMap")
|
||||
}
|
||||
>
|
||||
<span className="topo-tree-search__name">{title}</span>
|
||||
{hit.ip ? <span className="topo-tree-search__ip muted">{hit.ip}</span> : null}
|
||||
{!views.length ? (
|
||||
<span className="topo-tree-search__meta muted">
|
||||
{t("topology.treeSearchNoViews")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="topo-tree-search__meta muted">
|
||||
{t("topology.treeSearchViewCount").replace(
|
||||
"{{count}}",
|
||||
String(views.length),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{views.length > 1 ? (
|
||||
<div className="topo-tree-search__views">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={`${hit.id}-${v.view_id}`}
|
||||
type="button"
|
||||
className="topo-tree-search__view"
|
||||
onClick={() => jumpToTreeSearchHit(hit, v)}
|
||||
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
|
||||
>
|
||||
{v.view_name || v.view_id.slice(0, 8)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{(treeNeSearchQuery.data?.total || 0) > 30 ? (
|
||||
<p className="topo-tree-search__hint muted">
|
||||
{t("topology.treeSearchTruncated").replace(
|
||||
"{{total}}",
|
||||
String(treeNeSearchQuery.data?.total || 0),
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!treeRoot ? (
|
||||
<p className="panel__hint">{treeQuery.isLoading ? t("topology.treeLoading") : t("topology.emptyMaps")}</p>
|
||||
|
|
@ -2033,30 +2176,56 @@ export function TopologyPage() {
|
|||
) : (
|
||||
<ul className="topo-map-list topo-region-list">
|
||||
{regions.map((region) => {
|
||||
const open = expandedIds[region.id] ?? true;
|
||||
const open = expandedIds[region.id] ?? false;
|
||||
const regionActive = selectedFolderId === region.id && !mapId;
|
||||
const regionBranch = selectedFolderId === region.id && Boolean(mapId);
|
||||
const regionHot = hotBrowseKey === `region:${region.id}`;
|
||||
return (
|
||||
<li
|
||||
key={region.id}
|
||||
className={`topo-region-list__block${regionActive ? " is-active" : ""}`}
|
||||
className={`topo-region-list__block${
|
||||
regionActive ? " is-active" : regionBranch ? " is-branch-active" : ""
|
||||
}${regionHot ? " is-hot" : ""}`}
|
||||
>
|
||||
<div className="topo-map-list__row topo-region-list__row">
|
||||
<div
|
||||
className="topo-map-list__row topo-region-list__row"
|
||||
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
|
||||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-list__item"
|
||||
onClick={() => goRegion(region.id)}
|
||||
onClick={() => {
|
||||
if (!confirmDiscardIfDirty()) return;
|
||||
const nextOpen = !(expandedIds[region.id] ?? false);
|
||||
setMapId("");
|
||||
setSelectedFolderId(region.id);
|
||||
setExpandedIds((p) => ({ ...p, [region.id]: nextOpen }));
|
||||
clearDirty();
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
}}
|
||||
title={
|
||||
open
|
||||
? t("topology.collapseRegion")
|
||||
: t("topology.expandRegion")
|
||||
}
|
||||
>
|
||||
<span className="topo-map-list__name">
|
||||
<span className="topo-region-list__glyph" aria-hidden="true">
|
||||
<RegionGlyph size={14} />
|
||||
</span>
|
||||
{regionDisplayName(region)}
|
||||
</span>
|
||||
<span className="topo-map-list__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
String(region.views.length),
|
||||
)}
|
||||
<span className="topo-map-list__title">
|
||||
{regionDisplayName(region)}
|
||||
</span>
|
||||
<span className="topo-map-list__count">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
String(region.views.length),
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="topo-map-list__actions">
|
||||
|
|
@ -2086,34 +2255,27 @@ export function TopologyPage() {
|
|||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-list__icon"
|
||||
title={
|
||||
open
|
||||
? t("topology.collapseRegion")
|
||||
: t("topology.expandRegion")
|
||||
}
|
||||
aria-label={
|
||||
open
|
||||
? t("topology.collapseRegion")
|
||||
: t("topology.expandRegion")
|
||||
}
|
||||
onClick={() =>
|
||||
setExpandedIds((p) => ({ ...p, [region.id]: !open }))
|
||||
}
|
||||
>
|
||||
<ChevronIcon dir={open ? "down" : "right"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{open ? (
|
||||
<ul className="topo-map-list topo-region-list__maps">
|
||||
{(region.views || []).map((v) => {
|
||||
const isPhysical = String(v.kind) === "physical";
|
||||
const viewHot = hotBrowseKey === `view:${v.id}`;
|
||||
return (
|
||||
<li key={v.id} className={mapId === v.id ? "is-active" : ""}>
|
||||
<div className="topo-map-list__row">
|
||||
<li
|
||||
key={v.id}
|
||||
className={`${mapId === v.id ? "is-active" : ""}${
|
||||
viewHot ? " is-hot" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="topo-map-list__row"
|
||||
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
|
||||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-list__item"
|
||||
|
|
@ -2133,13 +2295,13 @@ export function TopologyPage() {
|
|||
size={13}
|
||||
/>
|
||||
</span>
|
||||
{v.name}
|
||||
{mapId === v.id && dirty ? " *" : ""}
|
||||
</span>
|
||||
<span className="topo-map-list__meta">
|
||||
{t(kindLabelKey(String(v.kind)))}
|
||||
{" · "}
|
||||
{v.node_count || 0}N
|
||||
<span className="topo-map-list__title">
|
||||
{v.name}
|
||||
{mapId === v.id && dirty ? " *" : ""}
|
||||
</span>
|
||||
<span className="topo-map-list__count">
|
||||
{v.node_count || 0}N
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="topo-map-list__actions">
|
||||
|
|
@ -2197,17 +2359,25 @@ export function TopologyPage() {
|
|||
<div className="topo-toolbar__row">
|
||||
<div className="topo-toolbar__title">
|
||||
<div className="topo-breadcrumb">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-breadcrumb__link"
|
||||
onClick={() => goRoot()}
|
||||
>
|
||||
{t("topology.rootName")}
|
||||
</button>
|
||||
{activeRegion ? (
|
||||
<button
|
||||
type="button"
|
||||
className="topo-breadcrumb__link"
|
||||
onClick={() => goRegion(activeRegion.id)}
|
||||
>
|
||||
{regionDisplayName(activeRegion)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="topo-breadcrumb__current">{t("topology.selectRegion")}</span>
|
||||
)}
|
||||
<>
|
||||
<span className="topo-breadcrumb__sep">/</span>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-breadcrumb__link"
|
||||
onClick={() => goRegion(activeRegion.id)}
|
||||
>
|
||||
{regionDisplayName(activeRegion)}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{activeView ? (
|
||||
<>
|
||||
<span className="topo-breadcrumb__sep">/</span>
|
||||
|
|
@ -2597,9 +2767,18 @@ export function TopologyPage() {
|
|||
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||
</p>
|
||||
<p className="panel__hint">
|
||||
<Link className="btn btn--sm btn--ghost" to="/network/topology/lldp">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() =>
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: "/network/topology/lldp",
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("topology.discoverGoLldp")}
|
||||
</Link>
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
|
@ -2610,90 +2789,147 @@ export function TopologyPage() {
|
|||
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
|
||||
<div className="topo-browser__head">
|
||||
<div>
|
||||
<strong>{titleText}</strong>
|
||||
{activeRegion ? (
|
||||
<div className="topo-breadcrumb">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-breadcrumb__link"
|
||||
onClick={() => goRoot()}
|
||||
>
|
||||
{t("topology.rootName")}
|
||||
</button>
|
||||
<span className="topo-breadcrumb__sep">/</span>
|
||||
<span className="topo-breadcrumb__current">{regionDisplayName(activeRegion)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<strong>{titleText}</strong>
|
||||
)}
|
||||
<p className="topo-browser__sub">
|
||||
{activeRegion
|
||||
? t("topology.browserRegionSub").replace(
|
||||
"{{count}}",
|
||||
String(browseEntries.length),
|
||||
)
|
||||
: t("topology.regionBrowseHint")}
|
||||
: t("topology.browserRegionsSub").replace(
|
||||
"{{count}}",
|
||||
String(regions.length),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!activeRegion ? (
|
||||
<div className="topo-browser__empty">
|
||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||
<RegionGlyph size={36} />
|
||||
</span>
|
||||
<p>{t("topology.selectRegion")}</p>
|
||||
</div>
|
||||
regions.length === 0 ? (
|
||||
<div className="topo-browser__empty">
|
||||
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||
<RegionGlyph size={36} />
|
||||
</span>
|
||||
<p>{t("topology.emptyMaps")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={promptNewRegion}
|
||||
disabled={createRegionMut.isPending || !treeRoot}
|
||||
>
|
||||
{t("topology.newRegion")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||
{regions.map((region, idx) => (
|
||||
<button
|
||||
key={region.id}
|
||||
type="button"
|
||||
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||
selectedFolderId === region.id ? " is-selected" : ""
|
||||
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
|
||||
onMouseEnter={() => setHotBrowseKey(`region:${region.id}`)}
|
||||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `region:${region.id}` ? "" : k))
|
||||
}
|
||||
onClick={() => goRegion(region.id)}
|
||||
title={t("topology.openRegion")}
|
||||
>
|
||||
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||
<RegionGlyph size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{regionDisplayName(region)}</span>
|
||||
<span className="topo-region-hex__meta">
|
||||
{t("topology.regionNodeHint").replace(
|
||||
"{{count}}",
|
||||
String(region.views?.length || 0),
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={promptNewRegion}
|
||||
disabled={createRegionMut.isPending || !treeRoot}
|
||||
title={t("topology.newRegion")}
|
||||
>
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : browseEntries.length === 0 ? (
|
||||
<div className="topo-browser__empty">
|
||||
<p>{t("topology.browserEmptyMaps")}</p>
|
||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={() => promptNewCustom(activeRegion.id)}
|
||||
title={t("topology.newCustom")}
|
||||
>
|
||||
{t("topology.newCustom")}
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-browser__grid">
|
||||
{browseEntries.map((v) => {
|
||||
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||
{browseEntries.map((v, idx) => {
|
||||
const isPhysical = String(v.kind) === "physical";
|
||||
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
||||
return (
|
||||
<article
|
||||
<button
|
||||
key={v.id}
|
||||
className={`topo-map-card${isPhysical ? " is-physical" : ""}`}
|
||||
type="button"
|
||||
className={`topo-region-hex topo-region-hex--tone-${tone}${
|
||||
mapId === v.id ? " is-selected" : ""
|
||||
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
|
||||
onMouseEnter={() => setHotBrowseKey(`view:${v.id}`)}
|
||||
onMouseLeave={() =>
|
||||
setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k))
|
||||
}
|
||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
||||
title={t("topology.openMap")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-card__body"
|
||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
||||
onDoubleClick={() => goCanvas(v.id, activeRegion.id)}
|
||||
>
|
||||
<span className="topo-map-card__icon" aria-hidden="true">
|
||||
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={28} />
|
||||
</span>
|
||||
<span className={`topo-map-card__kind${isPhysical ? " is-physical" : ""}`}>
|
||||
{t(kindLabelKey(String(v.kind)))}
|
||||
</span>
|
||||
<span className="topo-map-card__name">{v.name}</span>
|
||||
<span className="topo-map-card__meta">
|
||||
{t("topology.layerNodeHint").replace("{{count}}", String(v.node_count || 0))}
|
||||
{v.updated_at ? ` · ${formatUpdatedAt(v.updated_at)}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
<div className="topo-map-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={() => goCanvas(v.id, activeRegion.id)}
|
||||
>
|
||||
{t("topology.openMap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
onClick={() => promptRenameMap(v.id, v.name)}
|
||||
>
|
||||
{t("topology.rename")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
|
||||
</span>
|
||||
<span className="topo-region-hex__title">
|
||||
<span className="topo-region-hex__name">{v.name}</span>
|
||||
<span className="topo-region-hex__meta">{v.node_count || 0}N</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-map-card topo-map-card--add"
|
||||
className="topo-region-hex topo-region-hex--add"
|
||||
onClick={() => promptNewCustom(activeRegion.id)}
|
||||
title={t("topology.newCustom")}
|
||||
>
|
||||
<span className="topo-map-card__add-plus" aria-hidden="true">
|
||||
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
<span>{t("topology.newCustom")}</span>
|
||||
<span className="topo-region-hex__name">{t("topology.newCustom")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue