Add LLDP collect page with conservative fabric edge lifecycle.

Move scheduled discovery under Network → LLDP links, mark absent edges missing after one successful scan, purge only after four miss cycles, and expose unmatched/raw job detail.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-02 03:39:15 +08:00
parent 9674de221d
commit d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions

View file

@ -1,106 +1,203 @@
"""Topology HTTP routes."""
"""Topology HTTP routes — fabric + views (final model)."""
from __future__ import annotations
import json
from typing import Any, Iterator
from typing import Any
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from .db import get_db
from .topology_schemas import (
TopologyDiscoverRequest,
TopologyGraphPut,
TopologyMapCreate,
TopologyMapUpdate,
FabricDiscoverRequest,
FabricManualEdgeIn,
TopologyViewCreate,
TopologyViewUpdate,
ViewEdgeStylePatch,
ViewNodesAdd,
ViewPositionsPatch,
)
from .topology_service import (
create_map,
delete_map,
discover_neighbors,
get_graph,
iter_discover_neighbors,
list_maps,
put_graph,
update_map,
add_nodes_to_view,
create_view,
delete_view,
get_discover_job,
get_fabric_neighborhood,
get_fabric_summary,
get_view_graph,
list_fabric_edges,
list_fabric_nodes,
list_views,
merge_duplicate_fabric_nodes,
patch_view_edge_style,
patch_view_positions,
project_fabric_neighbors_to_view,
refresh_fabric_stats,
remove_view_nodes,
start_discover_job,
update_view,
upsert_fabric_edge,
)
router = APIRouter(prefix="/v1/topology", tags=["topology"])
def _sse_pack(event: dict[str, Any]) -> str:
etype = str(event.get("type") or "message")
return f"event: {etype}\ndata: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
# --- Fabric -----------------------------------------------------------------
@router.get("/maps")
def api_list_maps(db: Session = Depends(get_db)) -> dict[str, Any]:
return list_maps(db)
@router.get("/fabric/summary")
def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]:
return get_fabric_summary(db).model_dump()
@router.post("/maps")
def api_create_map(body: TopologyMapCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
return create_map(db, body).model_dump()
@router.get("/maps/{map_id}")
def api_get_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return get_graph(db, map_id).model_dump()
@router.patch("/maps/{map_id}")
def api_patch_map(
map_id: str, body: TopologyMapUpdate, db: Session = Depends(get_db)
) -> dict[str, Any]:
return update_map(db, map_id, body).model_dump()
@router.delete("/maps/{map_id}")
def api_delete_map(map_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return delete_map(db, map_id)
@router.put("/maps/{map_id}/graph")
def api_put_graph(
map_id: str, body: TopologyGraphPut, db: Session = Depends(get_db)
) -> dict[str, Any]:
return put_graph(db, map_id, body).model_dump()
@router.post("/maps/{map_id}/discover")
def api_discover(
map_id: str,
body: TopologyDiscoverRequest | None = None,
@router.get("/fabric/nodes")
def api_fabric_nodes(
keyword: str = "",
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=2000),
db: Session = Depends(get_db),
) -> dict[str, Any]:
req = body or TopologyDiscoverRequest()
return discover_neighbors(db, map_id, req).model_dump()
return list_fabric_nodes(db, keyword=keyword, page=page, page_size=page_size)
@router.post("/maps/{map_id}/discover/stream")
def api_discover_stream(
map_id: str,
body: TopologyDiscoverRequest | None = None,
@router.get("/fabric/edges")
def api_fabric_edges(
node_id: str = "",
layer: str = "physical",
status: str = "",
source: str = "",
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=2000),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""SSE stream: start → ne_start/ne_result (per NE) → done."""
req = body or TopologyDiscoverRequest()
def generate() -> Iterator[str]:
try:
for event in iter_discover_neighbors(db, map_id, req):
yield _sse_pack(event)
except Exception as exc: # noqa: BLE001 — surface to client then end stream
yield _sse_pack({"type": "error", "detail": str(exc)[:800]})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
) -> dict[str, Any]:
return list_fabric_edges(
db,
node_id=node_id,
layer=layer,
status=status,
source=source,
page=page,
page_size=page_size,
)
@router.get("/fabric/neighborhood")
def api_fabric_neighborhood(
node_id: str,
depth: int = Query(default=1, ge=1, le=3),
layer: str = "physical",
db: Session = Depends(get_db),
) -> dict[str, Any]:
return get_fabric_neighborhood(db, node_id, depth=depth, layer=layer).model_dump()
@router.post("/fabric/edges")
def api_fabric_manual_edge(
body: FabricManualEdgeIn,
db: Session = Depends(get_db),
) -> dict[str, Any]:
edge, action = upsert_fabric_edge(
db,
a_node_id=body.a_node_id,
b_node_id=body.b_node_id,
a_port=body.a_port,
b_port=body.b_port,
source="manual",
)
db.commit()
refresh_fabric_stats(db)
return {"ok": True, "action": action, "edge": {
"id": edge.id,
"a_node_id": edge.a_node_id,
"b_node_id": edge.b_node_id,
"a_port": edge.a_port,
"b_port": edge.b_port,
"source": edge.source,
"status": edge.status,
}}
@router.post("/fabric/discover")
def api_fabric_discover(
body: FabricDiscoverRequest | None = None,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return start_discover_job(db, body or FabricDiscoverRequest()).model_dump()
@router.post("/fabric/cleanup-duplicates")
def api_fabric_cleanup_duplicates(db: Session = Depends(get_db)) -> dict[str, Any]:
"""Merge duplicate fabric nodes (same managed/ume/name/ip) and retarget edges."""
result = merge_duplicate_fabric_nodes(db)
return {"ok": True, **result}
@router.get("/fabric/discover/{job_id}")
def api_fabric_discover_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return get_discover_job(db, job_id).model_dump()
# --- Views ------------------------------------------------------------------
@router.get("/views")
def api_list_views(db: Session = Depends(get_db)) -> dict[str, Any]:
return list_views(db)
@router.post("/views")
def api_create_view(body: TopologyViewCreate, db: Session = Depends(get_db)) -> dict[str, Any]:
return create_view(db, body).model_dump()
@router.get("/views/{view_id}")
def api_get_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return get_view_graph(db, view_id).model_dump()
@router.patch("/views/{view_id}")
def api_patch_view(
view_id: str, body: TopologyViewUpdate, db: Session = Depends(get_db)
) -> dict[str, Any]:
return update_view(db, view_id, body).model_dump()
@router.delete("/views/{view_id}")
def api_delete_view(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return delete_view(db, view_id)
@router.patch("/views/{view_id}/positions")
def api_patch_positions(
view_id: str, body: ViewPositionsPatch, db: Session = Depends(get_db)
) -> dict[str, Any]:
return patch_view_positions(db, view_id, body).model_dump()
@router.post("/views/{view_id}/nodes")
def api_add_nodes(
view_id: str, body: ViewNodesAdd, db: Session = Depends(get_db)
) -> dict[str, Any]:
return add_nodes_to_view(db, view_id, body).model_dump()
@router.post("/views/{view_id}/project-neighbors")
def api_project_neighbors(view_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return project_fabric_neighbors_to_view(db, view_id).model_dump()
@router.post("/views/{view_id}/nodes/remove")
def api_remove_nodes(
view_id: str,
body: dict[str, Any],
db: Session = Depends(get_db),
) -> dict[str, Any]:
ids = body.get("fabric_node_ids") if isinstance(body, dict) else None
return remove_view_nodes(db, view_id, list(ids or [])).model_dump()
@router.patch("/views/{view_id}/edge-style")
def api_edge_style(
view_id: str, body: ViewEdgeStylePatch, db: Session = Depends(get_db)
) -> dict[str, Any]:
return patch_view_edge_style(db, view_id, body).model_dump()