mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 16:50:46 +08:00
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:
parent
9674de221d
commit
d05ba0f1f7
30 changed files with 4713 additions and 1870 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue