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:
oliver 2026-08-05 22:43:03 +08:00
parent 2aafb72676
commit 6e2c8517cf
16 changed files with 732 additions and 166 deletions

View file

@ -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

View file

@ -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).

View 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)),
}

View file

@ -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"]}')

View file

@ -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

View file

@ -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,
} }

View file

@ -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,

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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`

View file

@ -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: {

View file

@ -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: {

View file

@ -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"

View file

@ -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";