diff --git a/netx_api/app_shutdown.py b/netx_api/app_shutdown.py index 733c1cc..70fd598 100644 --- a/netx_api/app_shutdown.py +++ b/netx_api/app_shutdown.py @@ -38,6 +38,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None: except Exception: # noqa: BLE001 _log.exception("stop_port_traffic_scheduler failed") + try: + from .fabric_reconcile_scheduler import stop_fabric_reconcile_scheduler + + stop_fabric_reconcile_scheduler() + except Exception: # noqa: BLE001 + _log.exception("stop_fabric_reconcile_scheduler failed") + try: import netx_api.ume_support as ume_support diff --git a/netx_api/config.py b/netx_api/config.py index 83151e1..dbac0e0 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -86,6 +86,10 @@ class Settings(BaseSettings): # Reclaim hung discover jobs (updated_at / created_at older than these). lldp_collect_stale_run_sec: int = 7200 lldp_collect_pending_stale_sec: int = 300 + # Fabric inventory lifecycle: detach dangling refs + purge fully orphaned nodes. + fabric_reconcile_scheduler_enabled: bool = True + # Default 6h — light full-table GC; deletes still purge orphans immediately on detach. + fabric_reconcile_interval_sec: int = 21600 # Port traffic monitoring (CLI rate bit/s samples) port_traffic_scheduler_enabled: bool = True port_traffic_scheduler_tick_sec: int = 15 @@ -141,7 +145,7 @@ class Settings(BaseSettings): alembic_upgrade_on_start: bool = True # Optional dedicated SQLAlchemy URL for /v1/sql/* (read-only DB role recommended). sql_readonly_database_url: str = "" - # When true (default), API also runs config_sync / lldp / port_traffic schedulers. + # When true (default), API also runs config_sync / lldp / port_traffic / fabric_reconcile schedulers. # Production split: set false and run `python -m netx_api.worker` beside the API. run_inline_schedulers: bool = True # Worker→API heartbeat file (used when run_inline_schedulers=false). diff --git a/netx_api/fabric_reconcile_scheduler.py b/netx_api/fabric_reconcile_scheduler.py new file mode 100644 index 0000000..d4b1900 --- /dev/null +++ b/netx_api/fabric_reconcile_scheduler.py @@ -0,0 +1,99 @@ +"""Periodic Fabric inventory reconcile: dangling detach + fully-orphaned GC.""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +from .config import settings +from .db import SessionLocal + +_log = logging.getLogger("netx.fabric_reconcile.scheduler") +_stop = threading.Event() +_thread: threading.Thread | None = None +_last_run_mono: float = 0.0 +_last_stats: dict[str, Any] | None = None + + +def run_fabric_reconcile_once() -> dict[str, Any]: + """Detach dangling managed/UME refs and purge fully orphaned fabric nodes.""" + global _last_run_mono, _last_stats + from .topology_inventory_lifecycle import reconcile_dangling_fabric_links + + db = SessionLocal() + try: + stats = reconcile_dangling_fabric_links(db, sweep_orphans=True) + db.commit() + _last_run_mono = time.monotonic() + _last_stats = dict(stats) + purged = int(stats.get("purged_orphans") or 0) + dangling = int(stats.get("dangling_managed_refs") or 0) + int( + stats.get("dangling_ume_refs") or 0 + ) + if purged or dangling: + _log.info( + "fabric reconcile done dangling_m=%s dangling_u=%s purged_orphans=%s edges_deleted=%s", + stats.get("dangling_managed_refs"), + stats.get("dangling_ume_refs"), + purged, + stats.get("edges_deleted"), + ) + else: + _log.debug("fabric reconcile idle") + return stats + except Exception: + db.rollback() + raise + finally: + db.close() + + +def _loop() -> None: + interval = max(300, int(getattr(settings, "fabric_reconcile_interval_sec", 21600) or 21600)) + # Short first delay so API/worker boot is not blocked by a full-table scan. + startup_delay = min(120, interval) + _log.info( + "fabric reconcile scheduler started interval=%ss startup_delay=%ss", + interval, + startup_delay, + ) + _stop.wait(startup_delay) + while not _stop.is_set(): + try: + if bool(getattr(settings, "fabric_reconcile_scheduler_enabled", True)): + run_fabric_reconcile_once() + except Exception: + _log.exception("fabric reconcile tick failed") + _stop.wait(interval) + _log.info("fabric reconcile scheduler stopped") + + +def start_fabric_reconcile_scheduler() -> None: + global _thread + if not bool(getattr(settings, "fabric_reconcile_scheduler_enabled", True)): + _log.info("fabric reconcile scheduler disabled by settings") + return + if _thread and _thread.is_alive(): + return + _stop.clear() + _thread = threading.Thread(target=_loop, name="fabric-reconcile-scheduler", daemon=True) + _thread.start() + + +def stop_fabric_reconcile_scheduler() -> None: + _stop.set() + + +def fabric_reconcile_scheduler_status() -> dict[str, Any]: + now = time.monotonic() + return { + "running": bool(_thread is not None and _thread.is_alive()), + "last_run_age_sec": (now - _last_run_mono) if _last_run_mono else None, + "last_stats": dict(_last_stats) if _last_stats else None, + "interval_sec": max( + 300, int(getattr(settings, "fabric_reconcile_interval_sec", 21600) or 21600) + ), + "enabled": bool(getattr(settings, "fabric_reconcile_scheduler_enabled", True)), + } diff --git a/netx_api/metrics_router.py b/netx_api/metrics_router.py index 5d18a8f..b8989fa 100644 --- a/netx_api/metrics_router.py +++ b/netx_api/metrics_router.py @@ -110,12 +110,19 @@ def _prom_lines(metrics: dict[str, Any]) -> str: ("config_sync", "netx_config_sync_scheduler_running"), ("lldp_collect", "netx_lldp_collect_scheduler_running"), ("port_traffic", "netx_port_traffic_scheduler_running"), + ("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"), ): block = sched.get(name) or metrics.get(name) or {} if "running" in block: lines.append(f'{key} {1 if block.get("running") else 0}') - if block.get("last_tick_age_sec") is not None: - lines.append(f"netx_{name}_tick_age_seconds {block['last_tick_age_sec']}") + age_key = "last_run_age_sec" if name == "fabric_reconcile" else "last_tick_age_sec" + if block.get(age_key) is not None: + metric_age = ( + "netx_fabric_reconcile_run_age_seconds" + if name == "fabric_reconcile" + else f"netx_{name}_tick_age_seconds" + ) + lines.append(f"{metric_age} {block[age_key]}") pt = metrics.get("port_traffic") or {} if pt.get("last_purge_age_sec") is not None: lines.append(f'netx_port_traffic_purge_age_seconds {pt["last_purge_age_sec"]}') diff --git a/netx_api/scheduler_heartbeat.py b/netx_api/scheduler_heartbeat.py index 6a9907f..2acb8fb 100644 --- a/netx_api/scheduler_heartbeat.py +++ b/netx_api/scheduler_heartbeat.py @@ -61,6 +61,12 @@ def local_device_scheduler_status(*, role: str = "unknown") -> dict[str, Any]: out["port_traffic"] = port_traffic_scheduler_status() except Exception: # noqa: BLE001 out["port_traffic"] = {"running": False, "error": "unavailable"} + try: + from .fabric_reconcile_scheduler import fabric_reconcile_scheduler_status + + out["fabric_reconcile"] = fabric_reconcile_scheduler_status() + except Exception: # noqa: BLE001 + out["fabric_reconcile"] = {"running": False, "error": "unavailable"} return out diff --git a/netx_api/topology_inventory_lifecycle.py b/netx_api/topology_inventory_lifecycle.py index a565fc6..c559388 100644 --- a/netx_api/topology_inventory_lifecycle.py +++ b/netx_api/topology_inventory_lifecycle.py @@ -115,39 +115,105 @@ def _strip_membership_ids( def detach_fabric_from_managed(db: Session, managed_ne_ids: list[str]) -> dict[str, int]: - """Clear fabric.managed_ne_id for deleted ManagedNEs; keep nodes/placements/edges.""" + """Clear fabric.managed_ne_id for deleted ManagedNEs; purge nodes that become fully orphaned.""" ids = _norm_ids(managed_ne_ids) if not ids: - return {"detached_nodes": 0, "membership_views": 0} + return { + "detached_nodes": 0, + "membership_views": 0, + "purged_orphans": 0, + "edges_deleted": 0, + "placements_deleted": 0, + } now = _utcnow() rows = ( db.query(TopoFabricNode) .filter(TopoFabricNode.managed_ne_id.in_(ids)) .all() ) + fabric_ids = [str(r.id) for r in rows] for row in rows: row.managed_ne_id = None row.updated_at = now views = _strip_membership_ids(db, managed_ne_ids=set(ids)) - return {"detached_nodes": len(rows), "membership_views": views} + db.flush() + purged = purge_fully_orphaned_fabric_nodes(db, fabric_ids) + return { + "detached_nodes": len(rows), + "membership_views": views, + "purged_orphans": int(purged.get("deleted") or 0), + "edges_deleted": int(purged.get("edges_deleted") or 0), + "placements_deleted": int(purged.get("placements_deleted") or 0), + } def detach_fabric_from_ume(db: Session, ume_ne_ids: list[str]) -> dict[str, int]: - """Clear fabric.ume_ne_id for deleted UME inventory rows; keep topology traces.""" + """Clear fabric.ume_ne_id for deleted UME inventory rows; purge nodes that become fully orphaned.""" ids = _norm_ids(ume_ne_ids) if not ids: - return {"detached_nodes": 0, "membership_views": 0} + return { + "detached_nodes": 0, + "membership_views": 0, + "purged_orphans": 0, + "edges_deleted": 0, + "placements_deleted": 0, + } now = _utcnow() rows = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.in_(ids)).all() + fabric_ids = [str(r.id) for r in rows] for row in rows: row.ume_ne_id = None row.updated_at = now views = _strip_membership_ids(db, ume_ne_ids=set(ids)) - return {"detached_nodes": len(rows), "membership_views": views} + db.flush() + purged = purge_fully_orphaned_fabric_nodes(db, fabric_ids) + return { + "detached_nodes": len(rows), + "membership_views": views, + "purged_orphans": int(purged.get("deleted") or 0), + "edges_deleted": int(purged.get("edges_deleted") or 0), + "placements_deleted": int(purged.get("placements_deleted") or 0), + } -def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]: - """Unbind fabric ids that no longer exist in managed_ne / ume_inventory_ne.""" +def purge_fully_orphaned_fabric_nodes( + db: Session, + fabric_node_ids: list[str] | None = None, +) -> dict[str, int]: + """Hard-delete fabric nodes with no managed_ne_id and no ume_ne_id (cascades edges/placements). + + When ``fabric_node_ids`` is None, sweeps the whole fabric table (GC). + Caller commits. + """ + if fabric_node_ids is None: + rows = [ + n + for n in db.query(TopoFabricNode).all() + if not str(n.managed_ne_id or "").strip() and not str(n.ume_ne_id or "").strip() + ] + else: + ids = _norm_ids(fabric_node_ids) + if not ids: + return {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0} + candidates = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all() + rows = [ + n + for n in candidates + if not str(n.managed_ne_id or "").strip() and not str(n.ume_ne_id or "").strip() + ] + return _delete_fabric_node_rows(db, rows) + + +def reconcile_dangling_fabric_links( + db: Session, + *, + sweep_orphans: bool = True, +) -> dict[str, int]: + """Unbind fabric ids that no longer exist in managed_ne / ume_inventory_ne. + + Detach paths purge nodes that become fully orphaned. When ``sweep_orphans`` is True + (default), also GC any remaining fully orphaned fabric nodes. + """ managed_alive = {str(x[0]) for x in db.query(ManagedNE.id).all() if str(x[0] or "").strip()} ume_alive = { str(x[0]) for x in db.query(UmeInventoryNE.ne_id).all() if str(x[0] or "").strip() @@ -163,6 +229,21 @@ def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]: dangling_u.append(uid) m_stats = detach_fabric_from_managed(db, dangling_m) u_stats = detach_fabric_from_ume(db, dangling_u) + gc = ( + purge_fully_orphaned_fabric_nodes(db, None) + if sweep_orphans + else {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0} + ) + purged = ( + int(m_stats.get("purged_orphans") or 0) + + int(u_stats.get("purged_orphans") or 0) + + int(gc.get("deleted") or 0) + ) + edges_deleted = ( + int(m_stats.get("edges_deleted") or 0) + + int(u_stats.get("edges_deleted") or 0) + + int(gc.get("edges_deleted") or 0) + ) return { "dangling_managed_refs": len(set(dangling_m)), "dangling_ume_refs": len(set(dangling_u)), @@ -170,6 +251,9 @@ def reconcile_dangling_fabric_links(db: Session) -> dict[str, int]: "detached_ume_nodes": int(u_stats.get("detached_nodes") or 0), "membership_views": int(m_stats.get("membership_views") or 0) + int(u_stats.get("membership_views") or 0), + "purged_orphans": purged, + "edges_deleted": edges_deleted, + "gc_orphans": int(gc.get("deleted") or 0), } @@ -218,23 +302,11 @@ def _strip_fabric_ids_from_membership(db: Session, fabric_ids: set[str]) -> int: return touched -def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]: - """Hard-delete fabric nodes (placements + edges). Does not touch managed/UME inventory.""" - ids = _norm_ids(fabric_node_ids) +def _delete_fabric_node_rows(db: Session, rows: list[TopoFabricNode]) -> dict[str, int]: + """Delete fabric rows + incident edges + placements. Caller commits.""" + ids = [str(r.id) for r in rows] if not ids: - raise HTTPException(status_code=400, detail="fabric_node_ids_required") - rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all() - found = {str(r.id): r for r in rows} - missing = [i for i in ids if i not in found] - if missing: - raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}") - blocked = [i for i, r in found.items() if not fabric_node_is_deletable(db, r)] - if blocked: - raise HTTPException( - status_code=400, - detail=f"fabric_node_not_deletable:{blocked[0]}", - ) - + return {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0} edge_ids = [ str(e.id) for e in db.query(TopoFabricEdge) @@ -258,6 +330,84 @@ def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, in _strip_fabric_ids_from_membership(db, set(ids)) for r in rows: db.delete(r) + return { + "deleted": len(rows), + "edges_deleted": len(edge_ids), + "placements_deleted": int(placements or 0), + } + + +def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]: + """Hard-delete fabric nodes (placements + edges). Does not touch managed/UME inventory.""" + ids = _norm_ids(fabric_node_ids) + if not ids: + raise HTTPException(status_code=400, detail="fabric_node_ids_required") + rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all() + found = {str(r.id): r for r in rows} + missing = [i for i in ids if i not in found] + if missing: + raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}") + blocked = [i for i, r in found.items() if not fabric_node_is_deletable(db, r)] + if blocked: + raise HTTPException( + status_code=400, + detail=f"fabric_node_not_deletable:{blocked[0]}", + ) + + out = _delete_fabric_node_rows(db, rows) + db.commit() + try: + from .topology_service import refresh_fabric_stats + + refresh_fabric_stats(db) + except Exception: # noqa: BLE001 + pass + return out + + +def purge_placeholder_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]: + """Hard-delete LLDP/topology placeholders: ManagedNE + fabric node + edges + placements. + + Rejects real inventory (manual / ume_sync), WebCRT sessions, and UME-only fabric rows. + """ + ids = _norm_ids(fabric_node_ids) + if not ids: + raise HTTPException(status_code=400, detail="fabric_node_ids_required") + rows = db.query(TopoFabricNode).filter(TopoFabricNode.id.in_(ids)).all() + found = {str(r.id): r for r in rows} + missing = [i for i in ids if i not in found] + if missing: + raise HTTPException(status_code=404, detail=f"fabric_node_not_found:{missing[0]}") + + managed_rows: list[ManagedNE] = [] + managed_ids: set[str] = set() + for fid, fab in found.items(): + mid = str(fab.managed_ne_id or "").strip() + uid = str(fab.ume_ne_id or "").strip() + if not mid: + if uid: + raise HTTPException( + status_code=400, + detail=f"fabric_node_not_placeholder:{fid}", + ) + # Orphan fabric — allow purge (same as fabric delete). + continue + mrow = db.get(ManagedNE, mid) + if mrow is None: + continue + if not is_placeholder_ne_source(mrow.source): + raise HTTPException( + status_code=400, + detail=f"fabric_node_not_placeholder:{fid}", + ) + if mid not in managed_ids: + managed_ids.add(mid) + managed_rows.append(mrow) + + out = _delete_fabric_node_rows(db, rows) + for mrow in managed_rows: + db.delete(mrow) + membership_views = _strip_membership_ids(db, managed_ne_ids=managed_ids) db.commit() try: from .topology_service import refresh_fabric_stats @@ -266,9 +416,9 @@ def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, in except Exception: # noqa: BLE001 pass return { - "deleted": len(rows), - "edges_deleted": len(edge_ids), - "placements_deleted": int(placements or 0), + **out, + "managed_deleted": len(managed_rows), + "membership_views": membership_views, } diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 704c72c..1aacaf4 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -205,20 +205,30 @@ def api_fabric_discover( @router.post("/fabric/cleanup-duplicates") def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]: """Merge duplicate fabric nodes (same managed/ume/name/ip) and retarget edges.""" - from .topology_inventory_lifecycle import reconcile_dangling_fabric_links + from .topology_inventory_lifecycle import ( + purge_fully_orphaned_fabric_nodes, + reconcile_dangling_fabric_links, + ) - link_stats = reconcile_dangling_fabric_links(db) + # Detach dangling first but defer full orphan GC until after absorb/merge. + link_stats = reconcile_dangling_fabric_links(db, sweep_orphans=False) result = merge_duplicate_fabric_nodes(db) + gc = purge_fully_orphaned_fabric_nodes(db, None) db.commit() - return {"ok": True, **result, "reconcile_links": link_stats} + return { + "ok": True, + **result, + "reconcile_links": link_stats, + "purged_orphans": gc, + } @router.post("/fabric/reconcile-links") def api_fabric_reconcile_links(db: Session = Depends(get_db)) -> dict[str, Any]: - """Detach fabric refs whose managed/UME inventory rows no longer exist.""" + """Detach fabric refs whose managed/UME inventory rows no longer exist; GC full orphans.""" from .topology_inventory_lifecycle import reconcile_dangling_fabric_links - stats = reconcile_dangling_fabric_links(db) + stats = reconcile_dangling_fabric_links(db, sweep_orphans=True) db.commit() return {"ok": True, **stats} @@ -467,6 +477,17 @@ def api_delete_fabric_nodes( return delete_fabric_nodes(db, body.fabric_node_ids) +@router.post("/fabric/nodes/purge-placeholders") +def api_purge_placeholder_fabric_nodes( + body: FabricNodesDeleteRequest, + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Hard-delete topology/LLDP placeholder ManagedNE + fabric node + incident edges.""" + from .topology_inventory_lifecycle import purge_placeholder_fabric_nodes + + return purge_placeholder_fabric_nodes(db, body.fabric_node_ids) + + @router.post("/slices/generate") def api_generate_slices( body: SliceGenerateRequest, diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index c5c16de..0e5acea 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -522,6 +522,14 @@ class FabricNodesDeleteOut(BaseModel): placements_deleted: int = 0 +class FabricPlaceholderPurgeOut(BaseModel): + deleted: int = 0 + edges_deleted: int = 0 + placements_deleted: int = 0 + managed_deleted: int = 0 + membership_views: int = 0 + + class SliceGenerateRequest(BaseModel): folder_id: str template: str = Field(description="core_only | core_agg | agg_access") diff --git a/netx_api/ume_runtime.py b/netx_api/ume_runtime.py index 3ab0a47..6eb46bd 100644 --- a/netx_api/ume_runtime.py +++ b/netx_api/ume_runtime.py @@ -49,6 +49,7 @@ _schedule_log = logging.getLogger("netx.ume.schedule") def start_device_schedulers() -> None: """Start device-facing periodic collectors (safe to call once per process).""" from .config_sync_scheduler import start_config_sync_scheduler + from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler from .lldp_collect_scheduler import start_lldp_collect_scheduler from .port_traffic_scheduler import start_port_traffic_scheduler from .scheduler_heartbeat import start_scheduler_heartbeat_publisher @@ -56,6 +57,7 @@ def start_device_schedulers() -> None: start_config_sync_scheduler() start_lldp_collect_scheduler() start_port_traffic_scheduler() + start_fabric_reconcile_scheduler() # Publish status so API /metrics can see collectors when run in a split worker. role = "api_inline" if bool(getattr(settings, "run_inline_schedulers", True)) else "worker" start_scheduler_heartbeat_publisher(role=role) diff --git a/netx_api/worker.py b/netx_api/worker.py index fc6c66a..9455c2a 100644 --- a/netx_api/worker.py +++ b/netx_api/worker.py @@ -4,7 +4,7 @@ Optional when ``NETX_RUN_INLINE_SCHEDULERS=false`` (API does not start collector python -m netx_api.worker -Starts: config_sync, lldp_collect, port_traffic tick loops. +Starts: config_sync, lldp_collect, port_traffic, fabric_reconcile tick loops. UME WS / keepalive remain in the API process (token + alarm coordination). By default the API runs collectors inline — no separate worker needed. """ @@ -45,7 +45,9 @@ def main() -> None: log_runtime_budget(role="worker") except Exception: _log.exception("worker runtime budget log failed") - _log.info("netx worker schedulers started (config_sync, lldp_collect, port_traffic)") + _log.info( + "netx worker schedulers started (config_sync, lldp_collect, port_traffic, fabric_reconcile)" + ) while not stop.is_set(): time.sleep(1.0) diff --git a/tests/test_topology.py b/tests/test_topology.py index 38e7bee..3b1db7d 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -1524,7 +1524,8 @@ Management Addresses: self.db.delete(ne_b) self.db.commit() - def test_delete_managed_detaches_fabric_keeps_topology(self) -> None: + def test_delete_managed_purges_orphan_fabric_and_edges(self) -> None: + """Managed-only delete → detach then purge fabric node + incident edges.""" from netx_api import ne_service from netx_api.topology_inventory_lifecycle import reconcile_dangling_fabric_links @@ -1551,6 +1552,7 @@ Management Addresses: id=f"peer-{suffix}", name=f"PEER-{suffix}", ip=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.2", + ume_ne_id=f"ume-peer-{suffix}", ) self.db.add(peer) self.db.commit() @@ -1563,28 +1565,23 @@ Management Addresses: source="lldp", ) self.db.commit() + edge_id = str(edge.id) ne_service.delete_managed_ne(self.db, ne.id) + self.db.expire_all() - fab = self.db.get(TopoFabricNode, fid) - self.assertIsNotNone(fab) - assert fab is not None - self.assertFalse(str(fab.managed_ne_id or "").strip()) + self.assertIsNone(self.db.get(TopoFabricNode, fid)) + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) self.assertEqual( self.db.query(TopoViewNode) .filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid) .count(), - 1, + 0, ) - self.assertIsNotNone(self.db.get(TopoFabricEdge, edge.id)) + # Peer still UME-linked — not purged. + self.assertIsNotNone(self.db.get(TopoFabricNode, peer.id)) - orphaned = svc.list_fabric_nodes(self.db, link_status="orphaned", page_size=500) - self.assertTrue(any(x["id"] == fid for x in orphaned["items"])) - hit = next(x for x in orphaned["items"] if x["id"] == fid) - self.assertEqual(hit["link_status"], "orphaned") - self.assertFalse(hit["managed_alive"]) - - # Historical dangling ref (pre-lifecycle) is cleared by reconcile. + # Historical dangling ref → detach then purge (fully orphaned). ghost_id = f"ghost-{suffix}" dangling = TopoFabricNode( id=f"dang-{suffix}", @@ -1596,9 +1593,122 @@ Management Addresses: self.db.commit() stats = reconcile_dangling_fabric_links(self.db) self.db.commit() + self.db.expire_all() self.assertGreaterEqual(int(stats["detached_managed_nodes"]), 1) - self.db.refresh(dangling) - self.assertFalse(str(dangling.managed_ne_id or "").strip()) + self.assertGreaterEqual(int(stats["purged_orphans"]), 1) + self.assertIsNone(self.db.get(TopoFabricNode, dangling.id)) + + def test_both_bound_keeps_fabric_until_fully_orphaned(self) -> None: + from netx_api import ne_service + from netx_api.topology_inventory_lifecycle import detach_fabric_from_ume + + suffix = uuid4().hex[:8] + ne = ManagedNE( + id=f"both-m-{suffix}", + name=f"BOTH-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.67.{(int(suffix[:2], 16) % 200) + 1}.1", + ) + self.db.add(ne) + self.db.commit() + fab = svc.ensure_fabric_node_for_managed(self.db, ne) + fab.ume_ne_id = f"ume-both-{suffix}" + self.db.commit() + peer = TopoFabricNode( + id=f"both-peer-{suffix}", + name=f"BP-{suffix}", + ip=f"10.67.{(int(suffix[:2], 16) % 200) + 1}.2", + ume_ne_id=f"ume-bp-{suffix}", + ) + self.db.add(peer) + self.db.commit() + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fab.id, + b_node_id=peer.id, + a_port="Gi1/0", + b_port="Gi1/1", + source="lldp", + ) + self.db.commit() + edge_id = str(edge.id) + fab_id = str(fab.id) + ume_id = str(fab.ume_ne_id) + + ne_service.delete_managed_ne(self.db, ne.id) + self.db.expire_all() + kept = self.db.get(TopoFabricNode, fab_id) + self.assertIsNotNone(kept) + assert kept is not None + self.assertFalse(str(kept.managed_ne_id or "").strip()) + self.assertEqual(str(kept.ume_ne_id or "").strip(), ume_id) + self.assertIsNotNone(self.db.get(TopoFabricEdge, edge_id)) + + detach_fabric_from_ume(self.db, [ume_id]) + self.db.commit() + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricNode, fab_id)) + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) + + def test_ume_only_detach_purges_orphan(self) -> None: + from netx_api.topology_inventory_lifecycle import detach_fabric_from_ume + + suffix = uuid4().hex[:8] + ume_id = f"ume-only-{suffix}" + fab = TopoFabricNode( + id=f"uo-{suffix}", + name=f"UO-{suffix}", + ip="10.68.0.1", + ume_ne_id=ume_id, + ) + peer = TopoFabricNode( + id=f"uo-peer-{suffix}", + name=f"UOP-{suffix}", + ip="10.68.0.2", + ume_ne_id=f"ume-uop-{suffix}", + ) + self.db.add(fab) + self.db.add(peer) + self.db.commit() + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fab.id, + b_node_id=peer.id, + a_port="Eth1", + b_port="Eth2", + source="lldp", + ) + self.db.commit() + edge_id = str(edge.id) + fab_id = str(fab.id) + + out = detach_fabric_from_ume(self.db, [ume_id]) + self.db.commit() + self.db.expire_all() + self.assertEqual(out["purged_orphans"], 1) + self.assertGreaterEqual(out["edges_deleted"], 1) + self.assertIsNone(self.db.get(TopoFabricNode, fab_id)) + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) + self.assertIsNotNone(self.db.get(TopoFabricNode, peer.id)) + + def test_fabric_reconcile_scheduler_once_sweeps_orphans(self) -> None: + from netx_api.fabric_reconcile_scheduler import run_fabric_reconcile_once + + suffix = uuid4().hex[:8] + orphan_id = f"gc-{suffix}" + orphan = TopoFabricNode( + id=orphan_id, + name=f"GC-{suffix}", + ip="10.69.0.1", + ) + self.db.add(orphan) + self.db.commit() + + stats = run_fabric_reconcile_once() + self.assertGreaterEqual(int(stats.get("purged_orphans") or 0), 1) + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricNode, orphan_id)) def test_delete_fabric_node_only_orphans_and_placeholders(self) -> None: from fastapi import HTTPException @@ -1659,6 +1769,59 @@ Management Addresses: self.assertIsNotNone(self.db.get(ManagedNE, real.id)) self.assertIsNotNone(self.db.get(ManagedNE, ph.id)) + def test_purge_placeholder_deletes_managed_and_edges(self) -> None: + from fastapi import HTTPException + + from netx_api.topology_inventory_lifecycle import purge_placeholder_fabric_nodes + + suffix = uuid4().hex[:8] + topo_ph = ManagedNE( + id=f"topo-{suffix}", + name=f"TOPO-{suffix}", + vendor="Other", + device_type="generic", + ip_address="", + source=TOPOLOGY_NE_SOURCE, + ) + real = ManagedNE( + id=f"real2-{suffix}", + name=f"REAL2-{suffix}", + vendor="Cisco", + device_type="cisco_ios", + ip_address=f"10.78.{(int(suffix[:2], 16) % 200) + 1}.1", + source="", + ) + self.db.add(topo_ph) + self.db.add(real) + self.db.commit() + fab_ph = svc.ensure_fabric_node_for_managed(self.db, topo_ph) + fab_real = svc.ensure_fabric_node_for_managed(self.db, real) + edge, _ = svc.upsert_fabric_edge( + self.db, + a_node_id=fab_ph.id, + b_node_id=fab_real.id, + a_port="Gi0/1", + b_port="Gi0/2", + source="manual", + ) + self.db.commit() + edge_id = str(edge.id) + + with self.assertRaises(HTTPException) as ctx: + purge_placeholder_fabric_nodes(self.db, [fab_real.id]) + self.assertEqual(ctx.exception.status_code, 400) + + out = purge_placeholder_fabric_nodes(self.db, [fab_ph.id]) + self.assertEqual(out["deleted"], 1) + self.assertEqual(out["managed_deleted"], 1) + self.assertGreaterEqual(out["edges_deleted"], 1) + self.db.expire_all() + self.assertIsNone(self.db.get(TopoFabricNode, fab_ph.id)) + self.assertIsNone(self.db.get(ManagedNE, topo_ph.id)) + self.assertIsNone(self.db.get(TopoFabricEdge, edge_id)) + self.assertIsNotNone(self.db.get(TopoFabricNode, fab_real.id)) + self.assertIsNotNone(self.db.get(ManagedNE, real.id)) + def test_filter_bulk_add_layout_and_remove(self) -> None: from netx_api.topology_schemas import ViewMutationOut, ViewNodesRemove diff --git a/web/WEB.md b/web/WEB.md index f44811b..25dc17e 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -126,7 +126,8 @@ src/ ## 拓扑管理(Fabric + 站点目录,对齐厂商) - 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。 -- 删除运维网元:解绑 `fabric.managed_ne_id`(及 view membership),**不删**上图点/边;UME 库存 reconcile 对称解绑 `ume_ne_id`。历史悬空引用:`POST /fabric/reconcile-links`(亦并入 `cleanup-duplicates`)。 +- 删除运维网元(手工 / WebCRT / `ume_sync` ManagedNE 同一路径):先解绑 `fabric.managed_ne_id`(及 view membership)。若该 Fabric 节点同时仍绑 UME → 变为 UME-only,**保留**节点与直连边;若解绑后已无 managed 且无 UME(完全孤儿)→ **硬删** Fabric 节点并级联直连边 / placements。UME 库存 reconcile 对称解绑 `ume_ne_id`,同样在完全孤儿时 purge。历史悬空引用与孤儿 GC:`POST /fabric/reconcile-links`(`cleanup-duplicates` 先 merge/absorb 再扫孤儿);后台默认每 6h 跑一次(`fabric_reconcile_scheduler_*`,可关)。 +- 画布:移出本图仅影响 placement(保存落库);删边本地排队、**保存**才删 Fabric(Undo 仅保存前有效);topology/lldp 占位可右键硬删(ManagedNE+Fabric+边)。未匹配且未建占位的对端不上 Fabric 边。 - 事实库:`topo_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP) - 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」) - 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node` diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4e8a4b3..060ea6c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -718,10 +718,12 @@ const en = { edit: "Edit", delete: "Delete", openTerminal: "Terminal", - confirmDelete: "Delete this network element?", + confirmDelete: + "Delete this network element? If its Fabric node is managed-only (no UME binding), that node and its direct links will also be removed.", batchDelete: "Batch delete", batchDeleting: "Deleting…", - batchDeleteConfirm: "Delete {{n}} selected network element(s)? This cannot be undone.", + batchDeleteConfirm: + "Delete {{n}} selected network element(s)? Cannot be undone. Managed-only Fabric nodes cascade (node + direct links); nodes still bound to UME keep topology.", batchDeleteDone: "Deleted {{n}} network element(s)", batchDeleteSelectRequired: "Select network elements to delete", keywordPh: "name / IP / user / tags", @@ -784,7 +786,8 @@ const en = { syncing: "Syncing…", delete: "Delete UME NEs", deleting: "Deleting…", - deleteConfirm: "Delete all managed NEs created from UME sync? This cannot be undone.", + deleteConfirm: + "Delete all managed NEs created from UME sync? Cannot be undone. Fabric nodes that become fully orphaned (no UME left) are removed with their direct links.", done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total", deletedDone: "Deleted {{n}} UME-synced NEs", }, @@ -1409,8 +1412,8 @@ const en = { projectedNeighbors: "Projected {{count}} neighbor(s) onto the map", localBrowserOnly: "this browser only", deleteEdgeConfirm: - "Delete selected link(s)? This removes them from Fabric globally; LLDP may recreate them on the next collect.", - edgeDeleted: "Deleted {{count}} link(s)", + "Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.", + edgeDeleted: "Queued {{count}} link delete(s) — Save to apply", discoverCancelled: "Discovery cancelled", truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.", truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.", @@ -1418,8 +1421,8 @@ const en = { truncatedEdges: "Too many edges; display truncated. Filter status or create another map.", truncatedGeneric: "Graph data truncated. Narrow scope or create another map.", deleteSelectionConfirm: - "Remove {{nodes}} NE(s) from this map and delete {{edges}} Fabric link(s)?", - selectionDeleted: "Removed {{nodes}} NE(s) and deleted {{edges}} link(s)", + "Remove {{nodes}} NE(s) from this map and {{edges}} link(s)? Link deletes apply on Save; NE remove from map applies on Save.", + selectionDeleted: "Queued: remove {{nodes}} NE(s), delete {{edges}} link(s) — Save to apply", newMap: "New", newMapName: "New topology", rename: "Rename", @@ -1498,6 +1501,10 @@ const en = { openWebcrt: "Open terminal", openNe: "NE details", removeNode: "Remove from canvas", + deletePlaceholder: "Delete placeholder", + deletePlaceholderConfirm: + "Permanently delete this placeholder NE (inventory + Fabric links)? This cannot be undone with canvas Undo.", + deletePlaceholderDone: "Placeholder deleted", removeEdge: "Delete edge", nodeMenu: "Node", paneMenu: "Canvas", @@ -1610,8 +1617,9 @@ const en = { locateOnCanvas: "Click to locate on canvas", openPortTraffic: "Open port traffic", removeStale: "Clear missing ({{count}})", - removeStaleHint: "Manually delete red missing edges; scheduled collect also purges after 4 consecutive misses", - staleRemoved: "Removed {{count}} missing edge(s)", + removeStaleHint: + "Queue delete of red missing edges (applies on Save); scheduled collect also purges after 4 consecutive misses", + staleRemoved: "Queued {{count}} missing edge delete(s) — Save to apply", connectHint: "Connect mode: click source node, then target (Esc or Select to exit)", }, topoClassify: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1b7cef3..9576341 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -712,10 +712,12 @@ const zh = { edit: "编辑", delete: "删除", openTerminal: "终端", - confirmDelete: "确定删除该网元?", + confirmDelete: + "确定删除该网元?若拓扑中仅绑定此运维网元(无 UME),将同时删除对应 Fabric 节点及其直连链路。", batchDelete: "批量删除", batchDeleting: "删除中…", - batchDeleteConfirm: "确定删除选中的 {{n}} 台网元?此操作不可恢复。", + batchDeleteConfirm: + "确定删除选中的 {{n}} 台网元?不可恢复。仅绑定运维侧的节点会级联清除 Fabric 节点与直连链路;仍绑 UME 的会保留拓扑。", batchDeleteDone: "已删除 {{n}} 台网元", batchDeleteSelectRequired: "请先勾选要删除的网元", keywordPh: "名称 / IP / 用户名 / 标签", @@ -777,7 +779,8 @@ const zh = { syncing: "同步中…", delete: "删除 UME 网元", deleting: "删除中…", - deleteConfirm: "确定删除所有通过 UME 同步到网元管理中的网元?此操作不可恢复。", + deleteConfirm: + "确定删除所有通过 UME 同步到网元管理中的网元?不可恢复。仅绑定这些 ManagedNE、且无 UME 投影的 Fabric 节点会连同直连链路一并清除。", done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台", deletedDone: "已删除 {{n}} 台 UME 同步网元", }, @@ -1400,16 +1403,17 @@ const zh = { projectNeighbors: "投影邻居上图", projectedNeighbors: "已投影 {{count}} 个邻居上图", localBrowserOnly: "仅本浏览器", - deleteEdgeConfirm: "删除选中链路?将从 Fabric 全局移除;LLDP 下次采集若仍存在可能再次出现。", - edgeDeleted: "已删除 {{count}} 条链路", + deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。", + edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)", discoverCancelled: "发现已取消", truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。", truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。", truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。", truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。", truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。", - deleteSelectionConfirm: "删除选中的 {{nodes}} 个网元(移出画布)和 {{edges}} 条链路(从 Fabric 删除)?", - selectionDeleted: "已移除 {{nodes}} 个网元、删除 {{edges}} 条链路", + deleteSelectionConfirm: + "从本图移除 {{nodes}} 个网元、删除 {{edges}} 条链路?移出与链路删除均在点击「保存」后生效。", + selectionDeleted: "已标记:移除 {{nodes}} 个网元、删除 {{edges}} 条链路(保存后生效)", newMap: "新建", newMapName: "新拓扑图", rename: "重命名", @@ -1488,6 +1492,9 @@ const zh = { openWebcrt: "打开终端", openNe: "网元详情", removeNode: "从画布移除", + deletePlaceholder: "删除占位网元", + deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。", + deletePlaceholderDone: "占位网元已删除", removeEdge: "删除链路", nodeMenu: "网元", paneMenu: "画布", @@ -1598,8 +1605,8 @@ const zh = { locateOnCanvas: "点击定位到画布", openPortTraffic: "打开端口流量", removeStale: "清除未发现 ({{count}})", - removeStaleHint: "手动删除红色未发现链路;周期采集连续 4 次未发现也会自动清理", - staleRemoved: "已删除 {{count}} 条未发现链路", + removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理", + staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)", connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)", }, topoClassify: { diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 018d78c..ac87dd1 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -39,6 +39,7 @@ import { createTopologyPlaceholder, createTopologyView, deleteFabricEdges, + purgePlaceholderFabricNodes, deleteTopologyFolder, deleteTopologyMap, fetchLldpCollectDashboard, @@ -278,7 +279,11 @@ type NeNodeData = { node_count?: number; }; -type HistorySnap = { nodes: Node[]; edges: Edge[] }; +type HistorySnap = { + nodes: Node[]; + edges: Edge[]; + pendingEdgeDeletes: string[]; +}; type PaletteSource = "managed" | "ume"; @@ -921,6 +926,8 @@ export function TopologyPage() { const historyRef = useRef([]); const redoRef = useRef([]); const historyLockRef = useRef(false); + /** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */ + const pendingEdgeDeletesRef = useRef>(new Set()); const connectClickRef = useRef(null); const canUndo = historyTick >= 0 && historyRef.current.length > 0; const canRedo = historyTick >= 0 && redoRef.current.length > 0; @@ -1259,6 +1266,7 @@ export function TopologyPage() { appliedMapIdRef.current = mapId; historyRef.current = []; redoRef.current = []; + pendingEdgeDeletesRef.current = new Set(); clearDirty(); bumpHistory(); needsInitialFitRef.current = true; @@ -1299,6 +1307,7 @@ export function TopologyPage() { { nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), + pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], }, ]; redoRef.current = []; @@ -1311,10 +1320,12 @@ export function TopologyPage() { redoRef.current.push({ nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), + pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], }); historyLockRef.current = true; setNodes(prev.nodes); setEdges(prev.edges); + pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes); markDirty(); bumpHistory(); historyLockRef.current = false; @@ -1326,10 +1337,12 @@ export function TopologyPage() { historyRef.current.push({ nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), + pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], }); historyLockRef.current = true; setNodes(next.nodes); setEdges(next.edges); + pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes); markDirty(); bumpHistory(); historyLockRef.current = false; @@ -1353,7 +1366,9 @@ export function TopologyPage() { if (opts?.persist && mapId) { try { const graph = await patchTopologyPositions(mapId, flowToPositions(next)); - clearDirty(); + if (pendingEdgeDeletesRef.current.size === 0) { + clearDirty(); + } queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); } catch (err) { showError(String(err)); @@ -1501,6 +1516,11 @@ export function TopologyPage() { const saveMut = useMutation({ mutationFn: async () => { if (!mapId) throw new Error(t("topology.selectMap")); + const pendingEdges = [...pendingEdgeDeletesRef.current]; + if (pendingEdges.length) { + await deleteFabricEdges(pendingEdges); + pendingEdgeDeletesRef.current.clear(); + } // Positions patch only updates listed nodes; sync canvas removals first. const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) @@ -1553,6 +1573,19 @@ export function TopologyPage() { }); try { if (dirtyRef.current) { + const pendingEdges = [...pendingEdgeDeletesRef.current]; + if (pendingEdges.length) { + await deleteFabricEdges(pendingEdges); + pendingEdgeDeletesRef.current.clear(); + } + const serverIds = (graphQuery.data?.nodes || []) + .map((n) => n.fabric_node_id) + .filter(Boolean); + const localIds = new Set(nodes.map((n) => n.id)); + const toRemove = serverIds.filter((id) => !localIds.has(id)); + if (toRemove.length) { + await removeTopologyViewNodes(mapId, toRemove); + } await patchTopologyPositions(mapId, flowToPositions(nodes)); clearDirty(); } @@ -1697,7 +1730,7 @@ export function TopologyPage() { setDiscovering(false); } }, - [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView], + [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, scheduleFitView, graphQuery.data], ); const cancelDiscover = useCallback(async () => { @@ -1777,6 +1810,7 @@ export function TopologyPage() { historyLockRef.current = true; applyViewGraph(graph, edgeDefaults, setNodes, setEdges); historyLockRef.current = false; + pendingEdgeDeletesRef.current = new Set(); clearDirty(); } catch (err) { showError(String(err)); @@ -2029,41 +2063,31 @@ export function TopologyPage() { setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges]); - const persistDeleteEdges = useCallback( - async ( + const queueDeleteEdges = useCallback( + ( edgeIds: string[], opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean }, ) => { - if (!mapId || !edgeIds.length) return false; + if (!edgeIds.length) return false; if (!opts?.skipConfirm) { const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm"); if (!window.confirm(confirmMsg)) return false; } pushHistory(); - try { - await deleteFabricEdges(edgeIds); - const graph = await fetchTopologyGraph(mapId); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - appliedMapIdRef.current = mapId; - const localPos = new Map(nodes.map((n) => [n.id, n.position])); - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); - historyLockRef.current = false; - clearDirty(); - setSelectedEdgeId((cur) => (cur && edgeIds.includes(cur) ? null : cur)); - showOk( - t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)), - ); - return true; - } catch (err) { - showError(String(err)); - return false; - } + for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id); + const idSet = new Set(edgeIds); + setEdges((es) => es.filter((e) => !idSet.has(e.id))); + setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur)); + markDirty(); + showOk( + t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)), + ); + return true; }, - [mapId, nodes, setNodes, setEdges, pushHistory, queryClient, edgeDefaults, clearDirty, showOk, showError, t], + [pushHistory, setEdges, markDirty, showOk, t], ); - const removeSelected = useCallback(async () => { + const removeSelected = useCallback(() => { if (!mapId) return; const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id); const selectedDisplay = displayEdges.filter((e) => e.selected); @@ -2082,55 +2106,37 @@ export function TopologyPage() { .replace("{{edges}}", String(edgeIds.size)); if (!window.confirm(msg)) return; pushHistory(); - try { - if (dirtyRef.current) { - await patchTopologyPositions(mapId, flowToPositions(nodes)); - clearDirty(); - } - await deleteFabricEdges([...edgeIds]); - const localPos = new Map(nodes.map((n) => [n.id, n.position])); - const graph = await removeTopologyViewNodes(mapId, nodeIds); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - appliedMapIdRef.current = mapId; - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); - historyLockRef.current = false; - clearDirty(); - setSelectedEdgeId(null); - showOk( - t("topology.selectionDeleted") - .replace("{{nodes}}", String(nodeIds.length)) - .replace("{{edges}}", String(edgeIds.size)), - ); - } catch (err) { - showError(String(err)); - } + const nodeSet = new Set(nodeIds); + for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id); + setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); + setEdges((es) => + es.filter( + (e) => + !edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target), + ), + ); + setSelectedEdgeId(null); + markDirty(); + showOk( + t("topology.selectionDeleted") + .replace("{{nodes}}", String(nodeIds.length)) + .replace("{{edges}}", String(edgeIds.size)), + ); return; } if (!nodeIds.length && edgeIds.size) { - await persistDeleteEdges([...edgeIds]); + queueDeleteEdges([...edgeIds]); return; } pushHistory(); - try { - if (dirtyRef.current) { - await patchTopologyPositions(mapId, flowToPositions(nodes)); - clearDirty(); - } - const localPos = new Map(nodes.map((n) => [n.id, n.position])); - const graph = await removeTopologyViewNodes(mapId, nodeIds); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - appliedMapIdRef.current = mapId; - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); - historyLockRef.current = false; - clearDirty(); - setSelectedEdgeId(null); - } catch (err) { - showError(String(err)); - } + const nodeSet = new Set(nodeIds); + setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); + // Drop incident edges from the canvas only — do not queue Fabric deletes. + setEdges((es) => es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target))); + setSelectedEdgeId(null); + markDirty(); }, [ mapId, nodes, @@ -2139,12 +2145,9 @@ export function TopologyPage() { setNodes, setEdges, pushHistory, - queryClient, - edgeDefaults, - clearDirty, - showError, + markDirty, showOk, - persistDeleteEdges, + queueDeleteEdges, t, ]); @@ -2152,7 +2155,7 @@ export function TopologyPage() { const display = displayEdges.find((e) => e.id === edgeId); const ids = physicalIdsForDisplayEdge(display, edges); const list = ids.length ? ids : [edgeId]; - void persistDeleteEdges(list); + queueDeleteEdges(list); closeCtxMenu(); }; @@ -2165,13 +2168,13 @@ export function TopologyPage() { .map((e) => e.id); }, [edges]); - const removeStaleEdges = useCallback(async () => { + const removeStaleEdges = useCallback(() => { if (!staleEdgeIds.length) return; - await persistDeleteEdges(staleEdgeIds, { + queueDeleteEdges(staleEdgeIds, { confirmKey: "topology.removeStaleHint", okKey: "topology.staleRemoved", }); - }, [staleEdgeIds, persistDeleteEdges]); + }, [staleEdgeIds, queueDeleteEdges]); const projectOutsidePeers = useCallback(async () => { if (!mapId) return; @@ -2281,28 +2284,81 @@ export function TopologyPage() { }; }, [ctxMenu, closeCtxMenu]); - const removeNodeById = async (nodeId: string) => { + const removeNodeById = (nodeId: string) => { if (!mapId) return; pushHistory(); closeCtxMenu(); - try { - if (dirtyRef.current) { - await patchTopologyPositions(mapId, flowToPositions(nodes)); - clearDirty(); - } - const localPos = new Map(nodes.map((n) => [n.id, n.position])); - const graph = await removeTopologyViewNodes(mapId, [nodeId]); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - appliedMapIdRef.current = mapId; - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); - historyLockRef.current = false; - clearDirty(); - } catch (err) { - showError(String(err)); - } + setNodes((ns) => ns.filter((n) => n.id !== nodeId)); + setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId)); + markDirty(); }; + const purgePlaceholderById = useCallback( + async (nodeId: string) => { + if (!mapId) return; + const node = nodes.find((n) => n.id === nodeId); + if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) { + return; + } + if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return; + closeCtxMenu(); + try { + if (dirtyRef.current) { + const pendingEdges = [...pendingEdgeDeletesRef.current]; + if (pendingEdges.length) { + await deleteFabricEdges(pendingEdges); + pendingEdgeDeletesRef.current.clear(); + } + const serverIds = (graphQuery.data?.nodes || []) + .map((n) => n.fabric_node_id) + .filter(Boolean); + const localIds = new Set(nodes.map((n) => n.id)); + const toRemove = serverIds.filter((id) => !localIds.has(id) && id !== nodeId); + if (toRemove.length) { + await removeTopologyViewNodes(mapId, toRemove); + } + await patchTopologyPositions( + mapId, + flowToPositions(nodes.filter((n) => n.id !== nodeId)), + ); + } + await purgePlaceholderFabricNodes([nodeId]); + const graph = await fetchTopologyGraph(mapId); + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + appliedMapIdRef.current = mapId; + const localPos = new Map( + nodes.filter((n) => n.id !== nodeId).map((n) => [n.id, n.position]), + ); + historyLockRef.current = true; + applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); + historyLockRef.current = false; + pendingEdgeDeletesRef.current = new Set(); + historyRef.current = []; + redoRef.current = []; + bumpHistory(); + clearDirty(); + showOk(t("topology.deletePlaceholderDone")); + } catch (err) { + showError(String(err)); + } + }, + [ + mapId, + nodes, + graphQuery.data, + closeCtxMenu, + queryClient, + edgeDefaults, + setNodes, + setEdges, + clearDirty, + bumpHistory, + showOk, + showError, + t, + ], + ); + const openNeInventory = (opts: { neId?: string; create?: boolean; @@ -4521,6 +4577,19 @@ export function TopologyPage() {
  • + {selectedNode && + isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? ( +
  • + +
  • + ) : null}