netx/netx_api/managed_ne_router.py
oliver 312f6f2f96 Improve managed NE filters/validation and simplify the workbench UI.
Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 00:26:58 +08:00

390 lines
13 KiB
Python

from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user
from .auth_service import write_audit
from .db import get_db
from .device_types import SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured
from .ne_capability import build_ne_capability
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
from .ne_exec_jobs import get_ne_exec_job, start_ne_exec_job
from .ne_schemas import (
BatchAccountApplyRequest,
BatchHopApplyRequest,
ConnectTestRequest,
ManagedNeCreate,
ManagedNeExecBatchRequest,
ManagedNeExecJobCreate,
ManagedNeExecRequest,
ManagedNeUpdate,
)
from .ne_service import (
batch_apply_account,
batch_apply_hop_proxy,
build_managed_ne_import_template,
create_managed_ne,
batch_delete_managed_ne,
delete_ume_synced_managed_ne,
delete_managed_ne,
get_ids_by_tag,
get_managed_ne,
get_managed_ne_stats,
import_managed_ne,
list_managed_ne,
sync_ume_inventory_to_managed_ne,
update_managed_ne,
)
from .models import ManagedNE
from .table_filters import TableFilterParams, table_filter_params
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
def _actor(ctx: AuthContext | None = None, request: Request | None = None) -> tuple[str, str]:
if ctx is not None and ctx.user is not None:
return str(ctx.user.id or ""), str(ctx.user.username or "")
if request is not None:
user = getattr(request.state, "auth_user", None)
if user:
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
return "", ""
@router.get("")
def api_list_managed_ne(
keyword: str | None = Query(default=None),
vendor: str | None = Query(default=None),
connect_status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db),
):
return list_managed_ne(
db,
keyword=keyword,
vendor=vendor,
connect_status=connect_status,
page=page,
page_size=page_size,
table_filters=table_filters,
)
@router.get("/meta/device-types")
def api_device_types():
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
from .config import settings
from .device_types import WEBCRT_DEVICE_TYPES
return {
"device_types": list(WEBCRT_DEVICE_TYPES),
"vendors": list(SUPPORTED_VENDORS),
"exec_policy_enabled": bool(getattr(settings, "ne_exec_policy_enabled", False)),
}
@router.get("/meta/credentials-configured")
def api_credentials_configured():
return {"configured": credentials_configured()}
@router.get("/meta/stats")
def api_managed_ne_stats(db: Session = Depends(get_db)):
return get_managed_ne_stats(db)
@router.get("/meta/ids-by-tag")
def api_ids_by_tag(tag: str | None = Query(default=None), db: Session = Depends(get_db)):
"""Return all NE ids that carry the given tag (or every id when tag is omitted)."""
return {"ids": get_ids_by_tag(db, tag)}
@router.post("")
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
return create_managed_ne(db, body).model_dump()
@router.get("/import/template")
def api_managed_ne_import_template(format: str = Query(default="xlsx")):
filename, payload, media_type = build_managed_ne_import_template(format)
return Response(
content=payload,
media_type=media_type,
headers={"content-disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import")
async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="empty_file")
return import_managed_ne(db, content, file.filename or "import.xlsx").model_dump()
@router.post("/batch-hop")
def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db)):
return batch_apply_hop_proxy(db, body.ids, body.hop)
@router.post("/batch-account")
def api_batch_apply_account(body: BatchAccountApplyRequest, db: Session = Depends(get_db)):
return batch_apply_account(db, body.ids, body.account)
@router.post("/batch-delete")
def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)):
return batch_delete_managed_ne(db, body.ids)
@router.post("/ume-sync")
def api_sync_ume_inventory_to_managed_ne(db: Session = Depends(get_db)):
return sync_ume_inventory_to_managed_ne(db).model_dump()
@router.delete("/ume-sync")
def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
return delete_ume_synced_managed_ne(db).model_dump()
def _audit_exec(
db: Session,
*,
uid: str,
uname: str,
path: str,
out: dict,
body_ne_id: str = "",
body_ume_ne_id: str = "",
commands: list | None = None,
) -> None:
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=path,
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body_ne_id,
"ume_ne_id": body_ume_ne_id,
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
@router.post("/exec")
def api_exec_managed_ne(
body: ManagedNeExecRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Login to a managed NE or UME inventory NE and run CLI (policy from managed NE exec_policy)."""
uid, uname = _actor(ctx)
out = execute_managed_ne_commands(
db,
body.commands,
ne_id=body.ne_id,
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec,
)
_audit_exec(
db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec",
out=out,
body_ne_id=body.ne_id or "",
body_ume_ne_id=body.ume_ne_id or "",
commands=list(body.commands or []),
)
return out
@router.post("/exec-batch")
def api_exec_managed_ne_batch(
body: ManagedNeExecBatchRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
uid, uname = _actor(ctx)
targets = None
if body.targets:
targets = [t.model_dump() for t in body.targets]
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=body.ne_ids,
ume_ne_ids=body.ume_ne_ids,
commands=body.commands,
read_timeout_sec=body.read_timeout_sec,
concurrency=body.concurrency,
)
items = out.get("items") if isinstance(out, dict) else None
ok_n = 0
fail_n = 0
if isinstance(items, list):
for row in items:
if isinstance(row, dict) and row.get("ok"):
ok_n += 1
else:
fail_n += 1
write_audit(
db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-batch",
status_code=200,
detail={
"ne_ids": list(body.ne_ids or [])[:100],
"ume_ne_ids": list(body.ume_ne_ids or [])[:100],
"target_count": len(targets or []) + len(body.ne_ids or []) + len(body.ume_ne_ids or []),
"commands": list(body.commands or [])[:20],
"ok_count": ok_n,
"fail_count": fail_n,
},
)
return out
@router.post("/exec-jobs")
def api_create_exec_job(
body: ManagedNeExecJobCreate,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Start a background exec (single or batch). Poll GET /exec-jobs/{job_id}."""
from .db import SessionLocal
uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True)
is_batch = bool(
payload.get("targets")
or payload.get("ne_ids")
or payload.get("ume_ne_ids")
)
kind = "exec-batch" if is_batch else "exec"
def _runner() -> dict:
with SessionLocal() as job_db:
if is_batch:
targets = payload.get("targets")
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=payload.get("ne_ids"),
ume_ne_ids=payload.get("ume_ne_ids"),
commands=payload.get("commands"),
read_timeout_sec=payload.get("read_timeout_sec"),
concurrency=payload.get("concurrency"),
)
write_audit(
job_db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200,
detail={
"async": True,
"ne_ids": list(payload.get("ne_ids") or [])[:100],
"ume_ne_ids": list(payload.get("ume_ne_ids") or [])[:100],
"ok": bool(out.get("ok", True)),
},
)
return out
cmds = list(payload.get("commands") or [])
out = execute_managed_ne_commands(
job_db,
cmds,
ne_id=payload.get("ne_id"),
ume_ne_id=payload.get("ume_ne_id"),
read_timeout_sec=payload.get("read_timeout_sec"),
)
_audit_exec(
job_db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec-jobs",
out=out,
body_ne_id=str(payload.get("ne_id") or ""),
body_ume_ne_id=str(payload.get("ume_ne_id") or ""),
commands=cmds,
)
return out
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner)
write_audit(
db,
action="ne.exec_job_start",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200 if ack.get("ok") else 429,
detail={"job_id": ack.get("job_id") or "", "kind": kind, "ok": bool(ack.get("ok"))},
)
return ack
@router.get("/exec-jobs/{job_id}")
def api_get_exec_job(
job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)],
):
"""Poll a background exec job started via POST /exec-jobs."""
_ = ctx
return get_ne_exec_job(job_id)
@router.post("/connect-test")
def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
ids = [str(x).strip() for x in body.ids if str(x).strip()]
if not ids:
raise HTTPException(status_code=400, detail="ids_required")
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ids)).all()
found_ids = {str(r.id) for r in rows}
missing = [x for x in ids if x not in found_ids]
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
submitted = schedule_connect_tests(ids)
return {"ok": True, "submitted": submitted}
@router.get("/{ne_id}")
def api_get_managed_ne(ne_id: str, db: Session = Depends(get_db)):
out = get_managed_ne(db, ne_id).model_dump()
out["capability"] = build_ne_capability(
device_type=out.get("device_type"),
exec_policy=out.get("exec_policy"),
vendor=out.get("vendor"),
hop_enabled=bool(out.get("hop_enabled")),
)
return out
@router.patch("/{ne_id}")
def api_update_managed_ne(ne_id: str, body: ManagedNeUpdate, db: Session = Depends(get_db)):
return update_managed_ne(db, ne_id, body).model_dump()
@router.delete("/{ne_id}")
def api_delete_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return delete_managed_ne(db, ne_id)