mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 15:43:26 +08:00
Defer canvas edge deletes to Save and cascade orphan Fabric on NE detach.
Queue fabric edge deletes until Save with undo-safe pending state; hard-delete topology/LLDP placeholders from the canvas; purge fully orphaned fabric nodes after inventory detach, with a periodic reconcile GC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2aafb72676
commit
6e2c8517cf
16 changed files with 732 additions and 166 deletions
|
|
@ -38,6 +38,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None:
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("stop_port_traffic_scheduler failed")
|
_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:
|
try:
|
||||||
import netx_api.ume_support as ume_support
|
import netx_api.ume_support as ume_support
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,10 @@ class Settings(BaseSettings):
|
||||||
# Reclaim hung discover jobs (updated_at / created_at older than these).
|
# Reclaim hung discover jobs (updated_at / created_at older than these).
|
||||||
lldp_collect_stale_run_sec: int = 7200
|
lldp_collect_stale_run_sec: int = 7200
|
||||||
lldp_collect_pending_stale_sec: int = 300
|
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 monitoring (CLI rate bit/s samples)
|
||||||
port_traffic_scheduler_enabled: bool = True
|
port_traffic_scheduler_enabled: bool = True
|
||||||
port_traffic_scheduler_tick_sec: int = 15
|
port_traffic_scheduler_tick_sec: int = 15
|
||||||
|
|
@ -141,7 +145,7 @@ class Settings(BaseSettings):
|
||||||
alembic_upgrade_on_start: bool = True
|
alembic_upgrade_on_start: bool = True
|
||||||
# Optional dedicated SQLAlchemy URL for /v1/sql/* (read-only DB role recommended).
|
# Optional dedicated SQLAlchemy URL for /v1/sql/* (read-only DB role recommended).
|
||||||
sql_readonly_database_url: str = ""
|
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.
|
# Production split: set false and run `python -m netx_api.worker` beside the API.
|
||||||
run_inline_schedulers: bool = True
|
run_inline_schedulers: bool = True
|
||||||
# Worker→API heartbeat file (used when run_inline_schedulers=false).
|
# Worker→API heartbeat file (used when run_inline_schedulers=false).
|
||||||
|
|
|
||||||
99
netx_api/fabric_reconcile_scheduler.py
Normal file
99
netx_api/fabric_reconcile_scheduler.py
Normal file
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
|
@ -110,12 +110,19 @@ def _prom_lines(metrics: dict[str, Any]) -> str:
|
||||||
("config_sync", "netx_config_sync_scheduler_running"),
|
("config_sync", "netx_config_sync_scheduler_running"),
|
||||||
("lldp_collect", "netx_lldp_collect_scheduler_running"),
|
("lldp_collect", "netx_lldp_collect_scheduler_running"),
|
||||||
("port_traffic", "netx_port_traffic_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 {}
|
block = sched.get(name) or metrics.get(name) or {}
|
||||||
if "running" in block:
|
if "running" in block:
|
||||||
lines.append(f'{key} {1 if block.get("running") else 0}')
|
lines.append(f'{key} {1 if block.get("running") else 0}')
|
||||||
if block.get("last_tick_age_sec") is not None:
|
age_key = "last_run_age_sec" if name == "fabric_reconcile" else "last_tick_age_sec"
|
||||||
lines.append(f"netx_{name}_tick_age_seconds {block['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 {}
|
pt = metrics.get("port_traffic") or {}
|
||||||
if pt.get("last_purge_age_sec") is not None:
|
if pt.get("last_purge_age_sec") is not None:
|
||||||
lines.append(f'netx_port_traffic_purge_age_seconds {pt["last_purge_age_sec"]}')
|
lines.append(f'netx_port_traffic_purge_age_seconds {pt["last_purge_age_sec"]}')
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,12 @@ def local_device_scheduler_status(*, role: str = "unknown") -> dict[str, Any]:
|
||||||
out["port_traffic"] = port_traffic_scheduler_status()
|
out["port_traffic"] = port_traffic_scheduler_status()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
out["port_traffic"] = {"running": False, "error": "unavailable"}
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,39 +115,105 @@ def _strip_membership_ids(
|
||||||
|
|
||||||
|
|
||||||
def detach_fabric_from_managed(db: Session, managed_ne_ids: list[str]) -> dict[str, int]:
|
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)
|
ids = _norm_ids(managed_ne_ids)
|
||||||
if not 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()
|
now = _utcnow()
|
||||||
rows = (
|
rows = (
|
||||||
db.query(TopoFabricNode)
|
db.query(TopoFabricNode)
|
||||||
.filter(TopoFabricNode.managed_ne_id.in_(ids))
|
.filter(TopoFabricNode.managed_ne_id.in_(ids))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
fabric_ids = [str(r.id) for r in rows]
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row.managed_ne_id = None
|
row.managed_ne_id = None
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
views = _strip_membership_ids(db, managed_ne_ids=set(ids))
|
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]:
|
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)
|
ids = _norm_ids(ume_ne_ids)
|
||||||
if not 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()
|
now = _utcnow()
|
||||||
rows = db.query(TopoFabricNode).filter(TopoFabricNode.ume_ne_id.in_(ids)).all()
|
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:
|
for row in rows:
|
||||||
row.ume_ne_id = None
|
row.ume_ne_id = None
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
views = _strip_membership_ids(db, ume_ne_ids=set(ids))
|
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]:
|
def purge_fully_orphaned_fabric_nodes(
|
||||||
"""Unbind fabric ids that no longer exist in managed_ne / ume_inventory_ne."""
|
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()}
|
managed_alive = {str(x[0]) for x in db.query(ManagedNE.id).all() if str(x[0] or "").strip()}
|
||||||
ume_alive = {
|
ume_alive = {
|
||||||
str(x[0]) for x in db.query(UmeInventoryNE.ne_id).all() if str(x[0] or "").strip()
|
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)
|
dangling_u.append(uid)
|
||||||
m_stats = detach_fabric_from_managed(db, dangling_m)
|
m_stats = detach_fabric_from_managed(db, dangling_m)
|
||||||
u_stats = detach_fabric_from_ume(db, dangling_u)
|
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 {
|
return {
|
||||||
"dangling_managed_refs": len(set(dangling_m)),
|
"dangling_managed_refs": len(set(dangling_m)),
|
||||||
"dangling_ume_refs": len(set(dangling_u)),
|
"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),
|
"detached_ume_nodes": int(u_stats.get("detached_nodes") or 0),
|
||||||
"membership_views": int(m_stats.get("membership_views") or 0)
|
"membership_views": int(m_stats.get("membership_views") or 0)
|
||||||
+ int(u_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
|
return touched
|
||||||
|
|
||||||
|
|
||||||
def delete_fabric_nodes(db: Session, fabric_node_ids: list[str]) -> dict[str, int]:
|
def _delete_fabric_node_rows(db: Session, rows: list[TopoFabricNode]) -> dict[str, int]:
|
||||||
"""Hard-delete fabric nodes (placements + edges). Does not touch managed/UME inventory."""
|
"""Delete fabric rows + incident edges + placements. Caller commits."""
|
||||||
ids = _norm_ids(fabric_node_ids)
|
ids = [str(r.id) for r in rows]
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="fabric_node_ids_required")
|
return {"deleted": 0, "edges_deleted": 0, "placements_deleted": 0}
|
||||||
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]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
edge_ids = [
|
edge_ids = [
|
||||||
str(e.id)
|
str(e.id)
|
||||||
for e in db.query(TopoFabricEdge)
|
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))
|
_strip_fabric_ids_from_membership(db, set(ids))
|
||||||
for r in rows:
|
for r in rows:
|
||||||
db.delete(r)
|
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()
|
db.commit()
|
||||||
try:
|
try:
|
||||||
from .topology_service import refresh_fabric_stats
|
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
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
return {
|
return {
|
||||||
"deleted": len(rows),
|
**out,
|
||||||
"edges_deleted": len(edge_ids),
|
"managed_deleted": len(managed_rows),
|
||||||
"placements_deleted": int(placements or 0),
|
"membership_views": membership_views,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,20 +205,30 @@ def api_fabric_discover(
|
||||||
@router.post("/fabric/cleanup-duplicates")
|
@router.post("/fabric/cleanup-duplicates")
|
||||||
def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]:
|
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."""
|
"""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)
|
result = merge_duplicate_fabric_nodes(db)
|
||||||
|
gc = purge_fully_orphaned_fabric_nodes(db, None)
|
||||||
db.commit()
|
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")
|
@router.post("/fabric/reconcile-links")
|
||||||
def api_fabric_reconcile_links(db: Session = Depends(get_db)) -> dict[str, Any]:
|
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
|
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()
|
db.commit()
|
||||||
return {"ok": True, **stats}
|
return {"ok": True, **stats}
|
||||||
|
|
||||||
|
|
@ -467,6 +477,17 @@ def api_delete_fabric_nodes(
|
||||||
return delete_fabric_nodes(db, body.fabric_node_ids)
|
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")
|
@router.post("/slices/generate")
|
||||||
def api_generate_slices(
|
def api_generate_slices(
|
||||||
body: SliceGenerateRequest,
|
body: SliceGenerateRequest,
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,14 @@ class FabricNodesDeleteOut(BaseModel):
|
||||||
placements_deleted: int = 0
|
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):
|
class SliceGenerateRequest(BaseModel):
|
||||||
folder_id: str
|
folder_id: str
|
||||||
template: str = Field(description="core_only | core_agg | agg_access")
|
template: str = Field(description="core_only | core_agg | agg_access")
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ _schedule_log = logging.getLogger("netx.ume.schedule")
|
||||||
def start_device_schedulers() -> None:
|
def start_device_schedulers() -> None:
|
||||||
"""Start device-facing periodic collectors (safe to call once per process)."""
|
"""Start device-facing periodic collectors (safe to call once per process)."""
|
||||||
from .config_sync_scheduler import start_config_sync_scheduler
|
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 .lldp_collect_scheduler import start_lldp_collect_scheduler
|
||||||
from .port_traffic_scheduler import start_port_traffic_scheduler
|
from .port_traffic_scheduler import start_port_traffic_scheduler
|
||||||
from .scheduler_heartbeat import start_scheduler_heartbeat_publisher
|
from .scheduler_heartbeat import start_scheduler_heartbeat_publisher
|
||||||
|
|
@ -56,6 +57,7 @@ def start_device_schedulers() -> None:
|
||||||
start_config_sync_scheduler()
|
start_config_sync_scheduler()
|
||||||
start_lldp_collect_scheduler()
|
start_lldp_collect_scheduler()
|
||||||
start_port_traffic_scheduler()
|
start_port_traffic_scheduler()
|
||||||
|
start_fabric_reconcile_scheduler()
|
||||||
# Publish status so API /metrics can see collectors when run in a split worker.
|
# 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"
|
role = "api_inline" if bool(getattr(settings, "run_inline_schedulers", True)) else "worker"
|
||||||
start_scheduler_heartbeat_publisher(role=role)
|
start_scheduler_heartbeat_publisher(role=role)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Optional when ``NETX_RUN_INLINE_SCHEDULERS=false`` (API does not start collector
|
||||||
|
|
||||||
python -m netx_api.worker
|
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).
|
UME WS / keepalive remain in the API process (token + alarm coordination).
|
||||||
By default the API runs collectors inline — no separate worker needed.
|
By default the API runs collectors inline — no separate worker needed.
|
||||||
"""
|
"""
|
||||||
|
|
@ -45,7 +45,9 @@ def main() -> None:
|
||||||
log_runtime_budget(role="worker")
|
log_runtime_budget(role="worker")
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("worker runtime budget log failed")
|
_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():
|
while not stop.is_set():
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
|
||||||
|
|
@ -1524,7 +1524,8 @@ Management Addresses:
|
||||||
self.db.delete(ne_b)
|
self.db.delete(ne_b)
|
||||||
self.db.commit()
|
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 import ne_service
|
||||||
from netx_api.topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
from netx_api.topology_inventory_lifecycle import reconcile_dangling_fabric_links
|
||||||
|
|
||||||
|
|
@ -1551,6 +1552,7 @@ Management Addresses:
|
||||||
id=f"peer-{suffix}",
|
id=f"peer-{suffix}",
|
||||||
name=f"PEER-{suffix}",
|
name=f"PEER-{suffix}",
|
||||||
ip=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.2",
|
ip=f"10.66.{(int(suffix[:2], 16) % 200) + 1}.2",
|
||||||
|
ume_ne_id=f"ume-peer-{suffix}",
|
||||||
)
|
)
|
||||||
self.db.add(peer)
|
self.db.add(peer)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
@ -1563,28 +1565,23 @@ Management Addresses:
|
||||||
source="lldp",
|
source="lldp",
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
edge_id = str(edge.id)
|
||||||
|
|
||||||
ne_service.delete_managed_ne(self.db, ne.id)
|
ne_service.delete_managed_ne(self.db, ne.id)
|
||||||
|
self.db.expire_all()
|
||||||
|
|
||||||
fab = self.db.get(TopoFabricNode, fid)
|
self.assertIsNone(self.db.get(TopoFabricNode, fid))
|
||||||
self.assertIsNotNone(fab)
|
self.assertIsNone(self.db.get(TopoFabricEdge, edge_id))
|
||||||
assert fab is not None
|
|
||||||
self.assertFalse(str(fab.managed_ne_id or "").strip())
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.db.query(TopoViewNode)
|
self.db.query(TopoViewNode)
|
||||||
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid)
|
.filter(TopoViewNode.view_id == view.id, TopoViewNode.fabric_node_id == fid)
|
||||||
.count(),
|
.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)
|
# Historical dangling ref → detach then purge (fully orphaned).
|
||||||
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.
|
|
||||||
ghost_id = f"ghost-{suffix}"
|
ghost_id = f"ghost-{suffix}"
|
||||||
dangling = TopoFabricNode(
|
dangling = TopoFabricNode(
|
||||||
id=f"dang-{suffix}",
|
id=f"dang-{suffix}",
|
||||||
|
|
@ -1596,9 +1593,122 @@ Management Addresses:
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
stats = reconcile_dangling_fabric_links(self.db)
|
stats = reconcile_dangling_fabric_links(self.db)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
self.db.expire_all()
|
||||||
self.assertGreaterEqual(int(stats["detached_managed_nodes"]), 1)
|
self.assertGreaterEqual(int(stats["detached_managed_nodes"]), 1)
|
||||||
self.db.refresh(dangling)
|
self.assertGreaterEqual(int(stats["purged_orphans"]), 1)
|
||||||
self.assertFalse(str(dangling.managed_ne_id or "").strip())
|
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:
|
def test_delete_fabric_node_only_orphans_and_placeholders(self) -> None:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -1659,6 +1769,59 @@ Management Addresses:
|
||||||
self.assertIsNotNone(self.db.get(ManagedNE, real.id))
|
self.assertIsNotNone(self.db.get(ManagedNE, real.id))
|
||||||
self.assertIsNotNone(self.db.get(ManagedNE, ph.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:
|
def test_filter_bulk_add_layout_and_remove(self) -> None:
|
||||||
from netx_api.topology_schemas import ViewMutationOut, ViewNodesRemove
|
from netx_api.topology_schemas import ViewMutationOut, ViewNodesRemove
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,8 @@ src/
|
||||||
## 拓扑管理(Fabric + 站点目录,对齐厂商)
|
## 拓扑管理(Fabric + 站点目录,对齐厂商)
|
||||||
|
|
||||||
- 三层库存:**运维** `managed_ne`(凭据/采集/WebCRT)· **EMS** `ume_inventory_ne` · **拓扑投影** `topo_fabric_node`(上图/LLDP/分类)。Fabric 由 ensure 按需创建,与运维表不是同一张表。
|
- 三层库存:**运维** `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_fabric_node` / `topo_fabric_edge`(按 5 万网元 / 100 万链路设计;物理层仅 LLDP)
|
||||||
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
- 站点树:`topo_folder`(系统隐藏 `root`;用户新建站点/区域,无默认「未分区」)
|
||||||
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`
|
- 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建区域后需手工/MCP 建画布)+ `topo_view_node`
|
||||||
|
|
|
||||||
|
|
@ -718,10 +718,12 @@ const en = {
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
openTerminal: "Terminal",
|
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",
|
batchDelete: "Batch delete",
|
||||||
batchDeleting: "Deleting…",
|
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)",
|
batchDeleteDone: "Deleted {{n}} network element(s)",
|
||||||
batchDeleteSelectRequired: "Select network elements to delete",
|
batchDeleteSelectRequired: "Select network elements to delete",
|
||||||
keywordPh: "name / IP / user / tags",
|
keywordPh: "name / IP / user / tags",
|
||||||
|
|
@ -784,7 +786,8 @@ const en = {
|
||||||
syncing: "Syncing…",
|
syncing: "Syncing…",
|
||||||
delete: "Delete UME NEs",
|
delete: "Delete UME NEs",
|
||||||
deleting: "Deleting…",
|
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",
|
done: "UME sync done: {{inserted}} inserted, {{updated}} updated, {{deleted}} deleted, {{total}} inventory total",
|
||||||
deletedDone: "Deleted {{n}} UME-synced NEs",
|
deletedDone: "Deleted {{n}} UME-synced NEs",
|
||||||
},
|
},
|
||||||
|
|
@ -1409,8 +1412,8 @@ const en = {
|
||||||
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
projectedNeighbors: "Projected {{count}} neighbor(s) onto the map",
|
||||||
localBrowserOnly: "this browser only",
|
localBrowserOnly: "this browser only",
|
||||||
deleteEdgeConfirm:
|
deleteEdgeConfirm:
|
||||||
"Delete selected link(s)? This removes them from Fabric globally; LLDP may recreate them on the next collect.",
|
"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: "Deleted {{count}} link(s)",
|
edgeDeleted: "Queued {{count}} link delete(s) — Save to apply",
|
||||||
discoverCancelled: "Discovery cancelled",
|
discoverCancelled: "Discovery cancelled",
|
||||||
truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.",
|
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.",
|
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.",
|
truncatedEdges: "Too many edges; display truncated. Filter status or create another map.",
|
||||||
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
|
truncatedGeneric: "Graph data truncated. Narrow scope or create another map.",
|
||||||
deleteSelectionConfirm:
|
deleteSelectionConfirm:
|
||||||
"Remove {{nodes}} NE(s) from this map and delete {{edges}} Fabric 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: "Removed {{nodes}} NE(s) and deleted {{edges}} link(s)",
|
selectionDeleted: "Queued: remove {{nodes}} NE(s), delete {{edges}} link(s) — Save to apply",
|
||||||
newMap: "New",
|
newMap: "New",
|
||||||
newMapName: "New topology",
|
newMapName: "New topology",
|
||||||
rename: "Rename",
|
rename: "Rename",
|
||||||
|
|
@ -1498,6 +1501,10 @@ const en = {
|
||||||
openWebcrt: "Open terminal",
|
openWebcrt: "Open terminal",
|
||||||
openNe: "NE details",
|
openNe: "NE details",
|
||||||
removeNode: "Remove from canvas",
|
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",
|
removeEdge: "Delete edge",
|
||||||
nodeMenu: "Node",
|
nodeMenu: "Node",
|
||||||
paneMenu: "Canvas",
|
paneMenu: "Canvas",
|
||||||
|
|
@ -1610,8 +1617,9 @@ const en = {
|
||||||
locateOnCanvas: "Click to locate on canvas",
|
locateOnCanvas: "Click to locate on canvas",
|
||||||
openPortTraffic: "Open port traffic",
|
openPortTraffic: "Open port traffic",
|
||||||
removeStale: "Clear missing ({{count}})",
|
removeStale: "Clear missing ({{count}})",
|
||||||
removeStaleHint: "Manually delete red missing edges; scheduled collect also purges after 4 consecutive misses",
|
removeStaleHint:
|
||||||
staleRemoved: "Removed {{count}} missing edge(s)",
|
"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)",
|
connectHint: "Connect mode: click source node, then target (Esc or Select to exit)",
|
||||||
},
|
},
|
||||||
topoClassify: {
|
topoClassify: {
|
||||||
|
|
|
||||||
|
|
@ -712,10 +712,12 @@ const zh = {
|
||||||
edit: "编辑",
|
edit: "编辑",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
openTerminal: "终端",
|
openTerminal: "终端",
|
||||||
confirmDelete: "确定删除该网元?",
|
confirmDelete:
|
||||||
|
"确定删除该网元?若拓扑中仅绑定此运维网元(无 UME),将同时删除对应 Fabric 节点及其直连链路。",
|
||||||
batchDelete: "批量删除",
|
batchDelete: "批量删除",
|
||||||
batchDeleting: "删除中…",
|
batchDeleting: "删除中…",
|
||||||
batchDeleteConfirm: "确定删除选中的 {{n}} 台网元?此操作不可恢复。",
|
batchDeleteConfirm:
|
||||||
|
"确定删除选中的 {{n}} 台网元?不可恢复。仅绑定运维侧的节点会级联清除 Fabric 节点与直连链路;仍绑 UME 的会保留拓扑。",
|
||||||
batchDeleteDone: "已删除 {{n}} 台网元",
|
batchDeleteDone: "已删除 {{n}} 台网元",
|
||||||
batchDeleteSelectRequired: "请先勾选要删除的网元",
|
batchDeleteSelectRequired: "请先勾选要删除的网元",
|
||||||
keywordPh: "名称 / IP / 用户名 / 标签",
|
keywordPh: "名称 / IP / 用户名 / 标签",
|
||||||
|
|
@ -777,7 +779,8 @@ const zh = {
|
||||||
syncing: "同步中…",
|
syncing: "同步中…",
|
||||||
delete: "删除 UME 网元",
|
delete: "删除 UME 网元",
|
||||||
deleting: "删除中…",
|
deleting: "删除中…",
|
||||||
deleteConfirm: "确定删除所有通过 UME 同步到网元管理中的网元?此操作不可恢复。",
|
deleteConfirm:
|
||||||
|
"确定删除所有通过 UME 同步到网元管理中的网元?不可恢复。仅绑定这些 ManagedNE、且无 UME 投影的 Fabric 节点会连同直连链路一并清除。",
|
||||||
done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台",
|
done: "UME 同步完成:新增 {{inserted}},更新 {{updated}},删除 {{deleted}},UME 清单共 {{total}} 台",
|
||||||
deletedDone: "已删除 {{n}} 台 UME 同步网元",
|
deletedDone: "已删除 {{n}} 台 UME 同步网元",
|
||||||
},
|
},
|
||||||
|
|
@ -1400,16 +1403,17 @@ const zh = {
|
||||||
projectNeighbors: "投影邻居上图",
|
projectNeighbors: "投影邻居上图",
|
||||||
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
projectedNeighbors: "已投影 {{count}} 个邻居上图",
|
||||||
localBrowserOnly: "仅本浏览器",
|
localBrowserOnly: "仅本浏览器",
|
||||||
deleteEdgeConfirm: "删除选中链路?将从 Fabric 全局移除;LLDP 下次采集若仍存在可能再次出现。",
|
deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。",
|
||||||
edgeDeleted: "已删除 {{count}} 条链路",
|
edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)",
|
||||||
discoverCancelled: "发现已取消",
|
discoverCancelled: "发现已取消",
|
||||||
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
|
truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。",
|
||||||
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
|
truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。",
|
||||||
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
|
truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。",
|
||||||
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
|
truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。",
|
||||||
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
|
truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。",
|
||||||
deleteSelectionConfirm: "删除选中的 {{nodes}} 个网元(移出画布)和 {{edges}} 条链路(从 Fabric 删除)?",
|
deleteSelectionConfirm:
|
||||||
selectionDeleted: "已移除 {{nodes}} 个网元、删除 {{edges}} 条链路",
|
"从本图移除 {{nodes}} 个网元、删除 {{edges}} 条链路?移出与链路删除均在点击「保存」后生效。",
|
||||||
|
selectionDeleted: "已标记:移除 {{nodes}} 个网元、删除 {{edges}} 条链路(保存后生效)",
|
||||||
newMap: "新建",
|
newMap: "新建",
|
||||||
newMapName: "新拓扑图",
|
newMapName: "新拓扑图",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
|
|
@ -1488,6 +1492,9 @@ const zh = {
|
||||||
openWebcrt: "打开终端",
|
openWebcrt: "打开终端",
|
||||||
openNe: "网元详情",
|
openNe: "网元详情",
|
||||||
removeNode: "从画布移除",
|
removeNode: "从画布移除",
|
||||||
|
deletePlaceholder: "删除占位网元",
|
||||||
|
deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。",
|
||||||
|
deletePlaceholderDone: "占位网元已删除",
|
||||||
removeEdge: "删除链路",
|
removeEdge: "删除链路",
|
||||||
nodeMenu: "网元",
|
nodeMenu: "网元",
|
||||||
paneMenu: "画布",
|
paneMenu: "画布",
|
||||||
|
|
@ -1598,8 +1605,8 @@ const zh = {
|
||||||
locateOnCanvas: "点击定位到画布",
|
locateOnCanvas: "点击定位到画布",
|
||||||
openPortTraffic: "打开端口流量",
|
openPortTraffic: "打开端口流量",
|
||||||
removeStale: "清除未发现 ({{count}})",
|
removeStale: "清除未发现 ({{count}})",
|
||||||
removeStaleHint: "手动删除红色未发现链路;周期采集连续 4 次未发现也会自动清理",
|
removeStaleHint: "标记删除红色未发现链路(保存后生效);周期采集连续 4 次未发现也会自动清理",
|
||||||
staleRemoved: "已删除 {{count}} 条未发现链路",
|
staleRemoved: "已标记删除 {{count}} 条未发现链路(保存后生效)",
|
||||||
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
connectHint: "连线模式:依次点击源节点 → 目标节点(Esc 或切回选择退出)",
|
||||||
},
|
},
|
||||||
topoClassify: {
|
topoClassify: {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
createTopologyPlaceholder,
|
createTopologyPlaceholder,
|
||||||
createTopologyView,
|
createTopologyView,
|
||||||
deleteFabricEdges,
|
deleteFabricEdges,
|
||||||
|
purgePlaceholderFabricNodes,
|
||||||
deleteTopologyFolder,
|
deleteTopologyFolder,
|
||||||
deleteTopologyMap,
|
deleteTopologyMap,
|
||||||
fetchLldpCollectDashboard,
|
fetchLldpCollectDashboard,
|
||||||
|
|
@ -278,7 +279,11 @@ type NeNodeData = {
|
||||||
node_count?: number;
|
node_count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HistorySnap = { nodes: Node<NeNodeData>[]; edges: Edge[] };
|
type HistorySnap = {
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
edges: Edge[];
|
||||||
|
pendingEdgeDeletes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type PaletteSource = "managed" | "ume";
|
type PaletteSource = "managed" | "ume";
|
||||||
|
|
||||||
|
|
@ -921,6 +926,8 @@ export function TopologyPage() {
|
||||||
const historyRef = useRef<HistorySnap[]>([]);
|
const historyRef = useRef<HistorySnap[]>([]);
|
||||||
const redoRef = useRef<HistorySnap[]>([]);
|
const redoRef = useRef<HistorySnap[]>([]);
|
||||||
const historyLockRef = useRef(false);
|
const historyLockRef = useRef(false);
|
||||||
|
/** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */
|
||||||
|
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
||||||
const connectClickRef = useRef<string | null>(null);
|
const connectClickRef = useRef<string | null>(null);
|
||||||
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||||
|
|
@ -1259,6 +1266,7 @@ export function TopologyPage() {
|
||||||
appliedMapIdRef.current = mapId;
|
appliedMapIdRef.current = mapId;
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
clearDirty();
|
clearDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
needsInitialFitRef.current = true;
|
needsInitialFitRef.current = true;
|
||||||
|
|
@ -1299,6 +1307,7 @@ export function TopologyPage() {
|
||||||
{
|
{
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
redoRef.current = [];
|
redoRef.current = [];
|
||||||
|
|
@ -1311,10 +1320,12 @@ export function TopologyPage() {
|
||||||
redoRef.current.push({
|
redoRef.current.push({
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
});
|
});
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(prev.nodes);
|
setNodes(prev.nodes);
|
||||||
setEdges(prev.edges);
|
setEdges(prev.edges);
|
||||||
|
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -1326,10 +1337,12 @@ export function TopologyPage() {
|
||||||
historyRef.current.push({
|
historyRef.current.push({
|
||||||
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
edges: edges.map((e) => ({ ...e })),
|
edges: edges.map((e) => ({ ...e })),
|
||||||
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
});
|
});
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(next.nodes);
|
setNodes(next.nodes);
|
||||||
setEdges(next.edges);
|
setEdges(next.edges);
|
||||||
|
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
||||||
markDirty();
|
markDirty();
|
||||||
bumpHistory();
|
bumpHistory();
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
|
@ -1353,7 +1366,9 @@ export function TopologyPage() {
|
||||||
if (opts?.persist && mapId) {
|
if (opts?.persist && mapId) {
|
||||||
try {
|
try {
|
||||||
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
const graph = await patchTopologyPositions(mapId, flowToPositions(next));
|
||||||
|
if (pendingEdgeDeletesRef.current.size === 0) {
|
||||||
clearDirty();
|
clearDirty();
|
||||||
|
}
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
|
|
@ -1501,6 +1516,11 @@ export function TopologyPage() {
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!mapId) throw new Error(t("topology.selectMap"));
|
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.
|
// Positions patch only updates listed nodes; sync canvas removals first.
|
||||||
const serverIds = (graphQuery.data?.nodes || [])
|
const serverIds = (graphQuery.data?.nodes || [])
|
||||||
.map((n) => n.fabric_node_id)
|
.map((n) => n.fabric_node_id)
|
||||||
|
|
@ -1553,6 +1573,19 @@ export function TopologyPage() {
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (dirtyRef.current) {
|
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));
|
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||||
clearDirty();
|
clearDirty();
|
||||||
}
|
}
|
||||||
|
|
@ -1697,7 +1730,7 @@ export function TopologyPage() {
|
||||||
setDiscovering(false);
|
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 () => {
|
const cancelDiscover = useCallback(async () => {
|
||||||
|
|
@ -1777,6 +1810,7 @@ export function TopologyPage() {
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
clearDirty();
|
clearDirty();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
|
|
@ -2029,41 +2063,31 @@ export function TopologyPage() {
|
||||||
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||||
}, [setNodes, setEdges]);
|
}, [setNodes, setEdges]);
|
||||||
|
|
||||||
const persistDeleteEdges = useCallback(
|
const queueDeleteEdges = useCallback(
|
||||||
async (
|
(
|
||||||
edgeIds: string[],
|
edgeIds: string[],
|
||||||
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
|
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
|
||||||
) => {
|
) => {
|
||||||
if (!mapId || !edgeIds.length) return false;
|
if (!edgeIds.length) return false;
|
||||||
if (!opts?.skipConfirm) {
|
if (!opts?.skipConfirm) {
|
||||||
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
|
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
|
||||||
if (!window.confirm(confirmMsg)) return false;
|
if (!window.confirm(confirmMsg)) return false;
|
||||||
}
|
}
|
||||||
pushHistory();
|
pushHistory();
|
||||||
try {
|
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
|
||||||
await deleteFabricEdges(edgeIds);
|
const idSet = new Set(edgeIds);
|
||||||
const graph = await fetchTopologyGraph(mapId);
|
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
|
||||||
appliedMapIdRef.current = mapId;
|
markDirty();
|
||||||
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(
|
showOk(
|
||||||
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
|
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
|
||||||
showError(String(err));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[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;
|
if (!mapId) return;
|
||||||
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
||||||
const selectedDisplay = displayEdges.filter((e) => e.selected);
|
const selectedDisplay = displayEdges.filter((e) => e.selected);
|
||||||
|
|
@ -2082,55 +2106,37 @@ export function TopologyPage() {
|
||||||
.replace("{{edges}}", String(edgeIds.size));
|
.replace("{{edges}}", String(edgeIds.size));
|
||||||
if (!window.confirm(msg)) return;
|
if (!window.confirm(msg)) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
try {
|
const nodeSet = new Set(nodeIds);
|
||||||
if (dirtyRef.current) {
|
for (const id of edgeIds) pendingEdgeDeletesRef.current.add(id);
|
||||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||||
clearDirty();
|
setEdges((es) =>
|
||||||
}
|
es.filter(
|
||||||
await deleteFabricEdges([...edgeIds]);
|
(e) =>
|
||||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
!edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target),
|
||||||
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);
|
setSelectedEdgeId(null);
|
||||||
|
markDirty();
|
||||||
showOk(
|
showOk(
|
||||||
t("topology.selectionDeleted")
|
t("topology.selectionDeleted")
|
||||||
.replace("{{nodes}}", String(nodeIds.length))
|
.replace("{{nodes}}", String(nodeIds.length))
|
||||||
.replace("{{edges}}", String(edgeIds.size)),
|
.replace("{{edges}}", String(edgeIds.size)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
showError(String(err));
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nodeIds.length && edgeIds.size) {
|
if (!nodeIds.length && edgeIds.size) {
|
||||||
await persistDeleteEdges([...edgeIds]);
|
queueDeleteEdges([...edgeIds]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushHistory();
|
pushHistory();
|
||||||
try {
|
const nodeSet = new Set(nodeIds);
|
||||||
if (dirtyRef.current) {
|
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
// Drop incident edges from the canvas only — do not queue Fabric deletes.
|
||||||
clearDirty();
|
setEdges((es) => es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target)));
|
||||||
}
|
|
||||||
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);
|
setSelectedEdgeId(null);
|
||||||
} catch (err) {
|
markDirty();
|
||||||
showError(String(err));
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
mapId,
|
mapId,
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -2139,12 +2145,9 @@ export function TopologyPage() {
|
||||||
setNodes,
|
setNodes,
|
||||||
setEdges,
|
setEdges,
|
||||||
pushHistory,
|
pushHistory,
|
||||||
queryClient,
|
markDirty,
|
||||||
edgeDefaults,
|
|
||||||
clearDirty,
|
|
||||||
showError,
|
|
||||||
showOk,
|
showOk,
|
||||||
persistDeleteEdges,
|
queueDeleteEdges,
|
||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -2152,7 +2155,7 @@ export function TopologyPage() {
|
||||||
const display = displayEdges.find((e) => e.id === edgeId);
|
const display = displayEdges.find((e) => e.id === edgeId);
|
||||||
const ids = physicalIdsForDisplayEdge(display, edges);
|
const ids = physicalIdsForDisplayEdge(display, edges);
|
||||||
const list = ids.length ? ids : [edgeId];
|
const list = ids.length ? ids : [edgeId];
|
||||||
void persistDeleteEdges(list);
|
queueDeleteEdges(list);
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2165,13 +2168,13 @@ export function TopologyPage() {
|
||||||
.map((e) => e.id);
|
.map((e) => e.id);
|
||||||
}, [edges]);
|
}, [edges]);
|
||||||
|
|
||||||
const removeStaleEdges = useCallback(async () => {
|
const removeStaleEdges = useCallback(() => {
|
||||||
if (!staleEdgeIds.length) return;
|
if (!staleEdgeIds.length) return;
|
||||||
await persistDeleteEdges(staleEdgeIds, {
|
queueDeleteEdges(staleEdgeIds, {
|
||||||
confirmKey: "topology.removeStaleHint",
|
confirmKey: "topology.removeStaleHint",
|
||||||
okKey: "topology.staleRemoved",
|
okKey: "topology.staleRemoved",
|
||||||
});
|
});
|
||||||
}, [staleEdgeIds, persistDeleteEdges]);
|
}, [staleEdgeIds, queueDeleteEdges]);
|
||||||
|
|
||||||
const projectOutsidePeers = useCallback(async () => {
|
const projectOutsidePeers = useCallback(async () => {
|
||||||
if (!mapId) return;
|
if (!mapId) return;
|
||||||
|
|
@ -2281,27 +2284,80 @@ export function TopologyPage() {
|
||||||
};
|
};
|
||||||
}, [ctxMenu, closeCtxMenu]);
|
}, [ctxMenu, closeCtxMenu]);
|
||||||
|
|
||||||
const removeNodeById = async (nodeId: string) => {
|
const removeNodeById = (nodeId: string) => {
|
||||||
if (!mapId) return;
|
if (!mapId) return;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
|
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 {
|
try {
|
||||||
if (dirtyRef.current) {
|
if (dirtyRef.current) {
|
||||||
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
const pendingEdges = [...pendingEdgeDeletesRef.current];
|
||||||
clearDirty();
|
if (pendingEdges.length) {
|
||||||
|
await deleteFabricEdges(pendingEdges);
|
||||||
|
pendingEdgeDeletesRef.current.clear();
|
||||||
}
|
}
|
||||||
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
const serverIds = (graphQuery.data?.nodes || [])
|
||||||
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
|
.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);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
appliedMapIdRef.current = mapId;
|
appliedMapIdRef.current = mapId;
|
||||||
|
const localPos = new Map(
|
||||||
|
nodes.filter((n) => n.id !== nodeId).map((n) => [n.id, n.position]),
|
||||||
|
);
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
|
historyRef.current = [];
|
||||||
|
redoRef.current = [];
|
||||||
|
bumpHistory();
|
||||||
clearDirty();
|
clearDirty();
|
||||||
|
showOk(t("topology.deletePlaceholderDone"));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[
|
||||||
|
mapId,
|
||||||
|
nodes,
|
||||||
|
graphQuery.data,
|
||||||
|
closeCtxMenu,
|
||||||
|
queryClient,
|
||||||
|
edgeDefaults,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
clearDirty,
|
||||||
|
bumpHistory,
|
||||||
|
showOk,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const openNeInventory = (opts: {
|
const openNeInventory = (opts: {
|
||||||
neId?: string;
|
neId?: string;
|
||||||
|
|
@ -4521,6 +4577,19 @@ export function TopologyPage() {
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="topo-ctx__sep" aria-hidden />
|
<li className="topo-ctx__sep" aria-hidden />
|
||||||
|
{selectedNode &&
|
||||||
|
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item topo-ctx__item--danger"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void purgePlaceholderById(ctxMenu.id)}
|
||||||
|
>
|
||||||
|
{t("topology.deletePlaceholder")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1176,6 +1176,18 @@ export const deleteFabricNodes = (fabricNodeIds: string[]) =>
|
||||||
{ fabric_node_ids: fabricNodeIds },
|
{ fabric_node_ids: fabricNodeIds },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Hard-delete topology/LLDP placeholders (ManagedNE + fabric + edges). */
|
||||||
|
export const purgePlaceholderFabricNodes = (fabricNodeIds: string[]) =>
|
||||||
|
apiPost<{
|
||||||
|
deleted: number;
|
||||||
|
edges_deleted: number;
|
||||||
|
placements_deleted: number;
|
||||||
|
managed_deleted: number;
|
||||||
|
membership_views: number;
|
||||||
|
}>("/v1/topology/fabric/nodes/purge-placeholders", {
|
||||||
|
fabric_node_ids: fabricNodeIds,
|
||||||
|
});
|
||||||
|
|
||||||
export const generateTopologySlices = (body: {
|
export const generateTopologySlices = (body: {
|
||||||
folder_id: string;
|
folder_id: string;
|
||||||
template: "core_only" | "core_agg" | "agg_access";
|
template: "core_only" | "core_agg" | "agg_access";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue