netx/netx_api/biz_state_router.py
oliver 227959b9a2 Make biz compare async and cutover-friendly for large tables.
Run compares in the background with progress, persist fails first, and store success as sampled key+row_id hydrated from source so million-row sheets no longer block the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-08 10:47:39 +08:00

782 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""HTTP API for business state monitoring (/v1/biz-state)."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, Query, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .db import get_db
from .biz_state import service as svc
from .biz_state.collect_runner import execute_enqueued_batch
from .lldp_shared import resolve_vendor_key
from .models import BizStateTask
router = APIRouter(prefix="/v1/biz-state", tags=["biz-state"])
class ProfileOverrideIn(BaseModel):
title: str | None = None
command_template: str | None = None
description: str | None = None
sample_output: str | None = None
enabled: bool | None = None
class TaskItemIn(BaseModel):
source_profile_id: str = ""
kind: str = "catalog"
enabled: bool = True
title: str = ""
command_override: str = ""
sort_order: int | None = None
bindings: list[dict[str, str]] = Field(default_factory=list)
class TaskCreateIn(BaseModel):
source: str = "managed"
ne_id: str
ne_name: str = ""
ne_ip: str = ""
vendor: str = ""
device_type: str = ""
note: str = ""
purpose: str = ""
status: str = "draft"
interval_sec: int = 3600
retention_days: int = 30
daily_keep_enabled: bool = False
daily_keep_count: int = 10
items: list[TaskItemIn] = Field(default_factory=list)
class TaskPatchIn(BaseModel):
note: str | None = None
purpose: str | None = None
interval_sec: int | None = None
retention_days: int | None = None
daily_keep_enabled: bool | None = None
daily_keep_count: int | None = None
status: str | None = None
items: list[TaskItemIn] | None = None
class BatchBaselineIn(BaseModel):
marked: bool = True
class BatchAliasIn(BaseModel):
alias: str = ""
class BatchBulkDeleteIn(BaseModel):
batch_ids: list[str] = Field(default_factory=list)
class PreviewIn(BaseModel):
vendor: str = ""
device_type: str = ""
items: list[TaskItemIn] = Field(default_factory=list)
class DiscoverIn(BaseModel):
source: str = "managed"
ne_id: str = ""
task_id: str = ""
discover_profile_id: str = ""
collect_profile_id: str = ""
placeholder: str = ""
force_refresh: bool = False
class BindingsIn(BaseModel):
bindings: list[dict[str, str]] = Field(default_factory=list)
@router.get("/profiles")
def api_list_profiles(
vendor: str = "",
device_type: str = "",
kind: str = "",
db: Session = Depends(get_db),
) -> dict[str, Any]:
vkey = resolve_vendor_key(vendor, device_type) if (vendor or device_type) else ""
items = svc.list_profiles_public(db, vendor_key=vkey)
if kind:
items = [p for p in items if str(p.get("kind") or "") == kind]
return {"items": items}
@router.patch("/profiles/{profile_id}")
def api_patch_profile(
profile_id: str,
body: ProfileOverrideIn,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return svc.upsert_profile_override(db, profile_id, body.model_dump(exclude_unset=True))
@router.post("/tasks/preview-items")
def api_preview_items(
body: PreviewIn,
db: Session = Depends(get_db),
) -> dict[str, Any]:
items = [i.model_dump() for i in body.items]
return {
"items": svc.preview_items(
db, vendor=body.vendor, device_type=body.device_type, items=items
)
}
@router.post("/discover")
def api_discover(body: DiscoverIn, db: Session = Depends(get_db)) -> dict[str, Any]:
from .biz_state.discover import discover_params
return discover_params(
db,
source=body.source,
ne_id=body.ne_id,
task_id=body.task_id,
discover_profile_id=body.discover_profile_id,
collect_profile_id=body.collect_profile_id,
placeholder=body.placeholder,
force_refresh=bool(body.force_refresh),
)
@router.put("/tasks/{task_id}/items/{item_id}/bindings")
def api_set_bindings(
task_id: str,
item_id: str,
body: BindingsIn,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return svc.set_item_bindings(db, task_id, item_id, list(body.bindings or []))
@router.get("/tasks")
def api_list_tasks(purpose: str = "", db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": svc.list_tasks(db, purpose=purpose or None)}
@router.post("/tasks")
def api_create_task(body: TaskCreateIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.create_task(db, body.model_dump())
@router.get("/tasks/{task_id}")
def api_get_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.get_task(db, task_id)
@router.get("/tasks/{task_id}/commands")
def api_plan_task_commands(
task_id: str,
enabled_only: bool = Query(True),
include_aux: bool = Query(True),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""List planned collect CLIs for a task (bindings expanded; no device login)."""
return svc.plan_task_collect_commands(
db, task_id, enabled_only=enabled_only, include_aux=include_aux
)
@router.get("/tasks/{task_id}/commands/export")
def api_export_task_commands(
task_id: str,
enabled_only: bool = Query(True),
include_aux: bool = Query(True),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""Download planned collect commands as a .txt file."""
text = svc.export_task_commands_text(
db, task_id, enabled_only=enabled_only, include_aux=include_aux
)
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in task_id)[:40]
return StreamingResponse(
iter([text.encode("utf-8")]),
media_type="text/plain; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="biz_state_commands_{safe}.txt"'
},
)
@router.patch("/tasks/{task_id}")
def api_patch_task(
task_id: str, body: TaskPatchIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.update_task(db, task_id, body.model_dump(exclude_unset=True))
@router.post("/tasks/{task_id}/pause")
def api_pause_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Pause periodic schedule; manual collect remains allowed."""
return svc.set_task_status(db, task_id, "paused")
@router.post("/tasks/{task_id}/start")
def api_start_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Enable periodic schedule (requires bindings for non–cutover-HF tasks)."""
return svc.set_task_status(db, task_id, "running")
@router.delete("/tasks/{task_id}")
def api_delete_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
svc.delete_task(db, task_id)
return {"ok": True}
@router.post("/tasks/{task_id}/collect")
def api_collect_now(
task_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict[str, Any]:
task = db.get(BizStateTask, task_id)
if not task:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="task not found")
if bool(task.collect_running):
return {"ok": True, "started": False, "reason": "already_collecting", "task_id": task_id}
tid = task_id
# Enqueue synchronously so collect_running flips before the HTTP response
# (schedule may stay paused; manual collect is still allowed).
from .biz_state.claim import enqueue_collect
result = enqueue_collect(tid, manual=True)
if not result.get("queued"):
return {
"ok": bool(result.get("ok", False)),
"started": False,
"queued": False,
"reason": result.get("reason") or "enqueue_failed",
"task_id": tid,
}
batch_id = str(result.get("batch_id") or "")
if batch_id:
background_tasks.add_task(
lambda bid=batch_id, t=tid: execute_enqueued_batch(batch_id=bid, task_id=t)
)
return {
"ok": True,
"started": True,
"queued": True,
"batch_id": batch_id,
"task_id": tid,
"collect_running": True,
}
@router.post("/tasks/{task_id}/collect/stop")
def api_collect_stop(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Stop queued or running collect for this task (best-effort mid-command)."""
from .biz_state.collect_stop import request_stop_collect
task = db.get(BizStateTask, task_id)
if not task:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="task not found")
return request_stop_collect(task_id)
@router.post("/import")
async def api_import_log_standalone(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
vendor_key: str = Form("zte"),
ne_name: str = Form(""),
note: str = Form(""),
) -> dict[str, Any]:
"""Standalone offline import: create an import task (no NE) and parse the log."""
from fastapi import HTTPException
from .biz_state.import_runner import execute_import, start_standalone_import
from .biz_state.log_split import import_max_bytes, read_upload_stream
fname = str(file.filename or "import.log")
try:
data = read_upload_stream(file.file, max_bytes=import_max_bytes())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = start_standalone_import(
filename=fname,
data=data,
vendor_key=str(vendor_key or "").strip() or "zte",
ne_name=str(ne_name or "").strip(),
note=str(note or "").strip(),
)
if not result.get("queued"):
return {
"ok": bool(result.get("ok", False)),
"started": False,
"queued": False,
"reason": result.get("reason") or "enqueue_failed",
"task_id": result.get("task_id") or "",
"created_task": bool(result.get("created_task")),
"segments_preview": result.get("segments_preview")
or {"files": result.get("files"), "segments": result.get("segments")},
}
segments = result.pop("segments", [])
bid = str(result.get("batch_id") or "")
tid = str(result.get("task_id") or "")
vk = str(result.get("vendor_key") or "zte")
vendor = str(result.get("vendor") or "")
device_type = str(result.get("device_type") or "")
background_tasks.add_task(
lambda: execute_import(
batch_id=bid,
task_id=tid,
segments=list(segments),
vendor_key=vk,
vendor=vendor,
device_type=device_type,
filename=fname,
)
)
return {
"ok": True,
"started": True,
"queued": True,
"batch_id": bid,
"task_id": tid,
"vendor_key": vk,
"filename": result.get("filename") or fname,
"ne_name": result.get("ne_name") or "",
"created_task": True,
"segments_preview": result.get("segments_preview") or {},
"collect_running": True,
}
@router.post("/tasks/{task_id}/import")
async def api_import_log(
task_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
vendor_key: str = Form(""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Upload a CLI transcript into an existing task and parse offline."""
from fastapi import HTTPException
from .biz_state.import_runner import execute_import, start_import_from_upload
from .biz_state.log_split import import_max_bytes, read_upload_stream
task = db.get(BizStateTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="task not found")
if bool(task.collect_running):
return {
"ok": True,
"started": False,
"reason": "already_collecting",
"task_id": task_id,
}
fname = str(file.filename or "import.log")
try:
data = read_upload_stream(file.file, max_bytes=import_max_bytes())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = start_import_from_upload(
task_id=task_id,
filename=fname,
data=data,
vendor_key=str(vendor_key or "").strip(),
)
if not result.get("queued"):
return {
"ok": bool(result.get("ok", False)),
"started": False,
"queued": False,
"reason": result.get("reason") or "enqueue_failed",
"task_id": task_id,
"segments_preview": result.get("segments_preview") or result.get("files"),
}
segments = result.pop("segments", [])
bid = str(result.get("batch_id") or "")
vk = str(result.get("vendor_key") or "")
vendor = str(result.get("vendor") or "")
device_type = str(result.get("device_type") or "")
background_tasks.add_task(
lambda: execute_import(
batch_id=bid,
task_id=task_id,
segments=list(segments),
vendor_key=vk,
vendor=vendor,
device_type=device_type,
filename=fname,
)
)
return {
"ok": True,
"started": True,
"queued": True,
"batch_id": bid,
"task_id": task_id,
"vendor_key": vk,
"filename": result.get("filename") or fname,
"segments_preview": result.get("segments_preview") or {},
"collect_running": True,
}
@router.get("/tasks/{task_id}/batches")
def api_list_batches(
task_id: str, limit: int = 50, db: Session = Depends(get_db)
) -> dict[str, Any]:
return {"items": svc.list_batches(db, task_id, limit=limit)}
@router.post("/tasks/{task_id}/purge")
def api_purge_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Apply retention policy now (age + optional daily keep). Protected batches skipped."""
return svc.run_purge_for_task(db, task_id)
@router.post("/batches/bulk-delete")
def api_bulk_delete_batches(
body: BatchBulkDeleteIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.delete_batches_bulk(db, list(body.batch_ids or []))
@router.post("/batches/{batch_id}/baseline")
def api_set_batch_baseline(
batch_id: str, body: BatchBaselineIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.set_batch_baseline(db, batch_id, marked=bool(body.marked))
@router.patch("/batches/{batch_id}/alias")
def api_set_batch_alias(
batch_id: str, body: BatchAliasIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.set_batch_alias(db, batch_id, alias=str(body.alias or ""))
@router.delete("/batches/{batch_id}")
def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.delete_batch(db, batch_id)
@router.get("/batches/{batch_id}")
def api_get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.get_batch(db, batch_id)
@router.get("/batches/{batch_id}/metrics/{metric_id}")
def api_list_batch_metric_rows(
batch_id: str,
metric_id: str,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
kw: str = Query(""),
column: str = Query(""),
db: Session = Depends(get_db),
) -> dict[str, Any]:
return svc.list_batch_metric_rows(
db,
batch_id,
metric_id,
page=page,
page_size=page_size,
kw=kw,
column=column,
)
@router.get("/batches/{batch_id}/commands/{command_id}")
def api_get_batch_command(
batch_id: str, command_id: str, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.get_batch_command(db, batch_id, command_id)
@router.get("/batches/{batch_id}/commands/{command_id}/raw.txt")
def api_download_batch_command_raw(
batch_id: str, command_id: str, db: Session = Depends(get_db)
) -> StreamingResponse:
"""Download one command's raw CLI output as plain text."""
detail = svc.get_batch_command(db, batch_id, command_id)
raw = str(detail.get("raw_text") or "")
cmd = str(detail.get("raw_command") or "command")
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in cmd)[:80] or "command"
filename = f"{command_id}_{safe}.txt"
return StreamingResponse(
iter([raw.encode("utf-8")]),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/batches/{batch_id}/export")
def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse:
data = svc.export_batch_zip(db, batch_id)
return StreamingResponse(
iter([data]),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="biz_state_{batch_id}.zip"'},
)
# ---- Phase2: templates / port maps / compare jobs ----
from .biz_state import compare_service as cmp_svc # noqa: E402
class FieldRuleIn(BaseModel):
field: str
compare: str = ""
normalize: str = ""
ignore: bool = False
tolerance: float | None = None
class RowFilterIn(BaseModel):
"""Leaf or nested (any/all) row filter — kept open for nested dicts."""
model_config = {"extra": "allow"}
field: str | None = None
op: str | None = None
value: Any = None
any: list[dict[str, Any]] | None = None
all: list[dict[str, Any]] | None = None
class TemplateMetricIn(BaseModel):
metric_id: str
# Split sheets share metric_id; identity is sheet_id (export/import must keep both).
sheet_id: str = ""
title: str = ""
key_fields: list[str] = Field(default_factory=list)
iface_fields: list[str] = Field(default_factory=list)
compare_fields: list[str] = Field(default_factory=list)
display_fields: list[str] = Field(default_factory=list)
row_filters: list[dict[str, Any]] = Field(default_factory=list)
field_rules: list[FieldRuleIn] = Field(default_factory=list)
class TemplateIn(BaseModel):
name: str = ""
note: str = ""
# Preferred: multi-metric sheets
metrics: list[TemplateMetricIn] | None = None
# Template-owned interface type aliases (GE→gei, …)
iface_normalize_rules: list[dict[str, str]] | None = None
# Legacy single-metric fields (still accepted)
metric_id: str = "lldp_neighbor"
key_fields: list[str] = Field(default_factory=list)
iface_fields: list[str] = Field(default_factory=list)
compare_fields: list[str] = Field(default_factory=list)
ignore_fields: list[str] = Field(default_factory=list)
display_fields: list[str] = Field(default_factory=list)
row_filters: list[dict[str, Any]] = Field(default_factory=list)
field_rules: list[FieldRuleIn] = Field(default_factory=list)
class TemplatePatchIn(BaseModel):
name: str | None = None
note: str | None = None
metrics: list[TemplateMetricIn] | None = None
iface_normalize_rules: list[dict[str, str]] | None = None
metric_id: str | None = None
key_fields: list[str] | None = None
iface_fields: list[str] | None = None
compare_fields: list[str] | None = None
ignore_fields: list[str] | None = None
display_fields: list[str] | None = None
row_filters: list[dict[str, Any]] | None = None
field_rules: list[FieldRuleIn] | None = None
class MappingRowIn(BaseModel):
before_if: str
after_if: str
class MappingIn(BaseModel):
name: str = ""
note: str = ""
rows: list[MappingRowIn] = Field(default_factory=list)
class ValidateMappingIn(BaseModel):
mapping_id: str
before_batch_id: str
after_batch_id: str
template_id: str = ""
class CompareJobIn(BaseModel):
name: str = ""
template_id: str = ""
mapping_id: str = ""
before_task_id: str = ""
after_task_id: str = ""
before_batch_id: str = ""
after_batch_id: str = ""
mode: str = "manual"
# Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: list[str] = Field(default_factory=list)
# auto|always|never|sample|keys — how to persist matching (success) rows
store_unchanged: str = "auto"
note: str = ""
@router.get("/compare/metrics")
def api_list_compare_metrics() -> dict[str, Any]:
return {
"items": cmp_svc.list_metric_schemas(),
"row_filter_presets": cmp_svc.list_row_filter_presets(),
}
@router.get("/compare/templates")
def api_list_templates(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_templates(db)}
@router.post("/compare/templates")
def api_create_template(body: TemplateIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_template(db, body.model_dump())
@router.patch("/compare/templates/{template_id}")
def api_patch_template(
template_id: str, body: TemplatePatchIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return cmp_svc.update_template(db, template_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/templates/{template_id}")
def api_delete_template(template_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_template(db, template_id)
return {"ok": True}
@router.get("/compare/mappings")
def api_list_mappings(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_mappings(db)}
@router.post("/compare/mappings")
def api_create_mapping(body: MappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_mapping(db, body.model_dump())
@router.patch("/compare/mappings/{mapping_id}")
def api_patch_mapping(
mapping_id: str, body: MappingIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return cmp_svc.update_mapping(db, mapping_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/mappings/{mapping_id}")
def api_delete_mapping(mapping_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_mapping(db, mapping_id)
return {"ok": True}
@router.post("/compare/mappings/validate")
def api_validate_mapping(body: ValidateMappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.validate_mapping(
db,
mapping_id=body.mapping_id,
before_batch_id=body.before_batch_id,
after_batch_id=body.after_batch_id,
template_id=body.template_id,
)
@router.get("/compare/jobs")
def api_list_jobs(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_jobs(db)}
@router.post("/compare/jobs")
def api_create_job(body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_job(db, body.model_dump())
@router.patch("/compare/jobs/{job_id}")
def api_patch_job(job_id: str, body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.update_job(db, job_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/jobs/{job_id}")
def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_job(db, job_id)
return {"ok": True}
@router.post("/compare/jobs/{job_id}/run")
def api_run_job(
job_id: str,
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Start a compare run. Default is async (returns status=running immediately)."""
if sync:
return cmp_svc.run_compare(db, job_id)
return cmp_svc.enqueue_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs")
def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_runs(db, job_id, limit=limit)}
@router.get("/compare/runs/{run_id}")
def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id)
@router.delete("/compare/runs/{run_id}")
def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.delete_run(db, run_id)
@router.get("/compare/runs/{run_id}/diffs")
def api_list_run_diffs(
run_id: str,
metric_id: str = "",
kind: str = "diff",
kw: str = "",
page: int = 1,
page_size: int = 100,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return cmp_svc.list_run_diffs(
db,
run_id,
metric_id=metric_id,
kind=kind,
kw=kw,
page=page,
page_size=page_size,
)
@router.get("/compare/runs/{run_id}/export")
def api_export_run(run_id: str, db: Session = Depends(get_db)) -> StreamingResponse:
data = cmp_svc.export_run_zip(db, run_id)
return StreamingResponse(
iter([data]),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="biz_compare_{run_id}.zip"'},
)