netx/netx_api/biz_state_router.py
oliver 3d2489041a Improve biz-compare UX with guided create, sheet toggles, and per-run delete.
Also sticky template field headers and biz-state pause/start endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:29:50 +08:00

528 lines
16 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, Query
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 dispatch_collect
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 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 = ""
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,
)
@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.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}
# Allow one-shot even when schedule is enabled (idle only).
task.last_collect_ended_at = None
db.commit()
tid = task_id
background_tasks.add_task(lambda: dispatch_collect(tid, manual=True))
return {"ok": True, "started": True, "task_id": task_id}
@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.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}/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)
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, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.run_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"'},
)