mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
RR vpnv6 wrap lines with next-hop+metrics were collapsing ECMP legs; store raw_line_count/declared_total before DB truncate so collect stats stay accurate. Co-authored-by: Cursor <cursoragent@cursor.com>
1381 lines
50 KiB
Python
1381 lines
50 KiB
Python
"""biz_state service: tasks, profiles, batches, export."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import zipfile
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import String, cast, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..lldp_shared import resolve_vendor_key
|
|
from ..models import (
|
|
BizStateBatch,
|
|
BizStateBatchCommand,
|
|
BizStateCommandOverride,
|
|
BizStateEvent,
|
|
BizStateLldpNeighbor,
|
|
BizStateMetricRow,
|
|
BizStateTask,
|
|
BizStateTaskItem,
|
|
BizStateTaskItemBinding,
|
|
ManagedNE,
|
|
)
|
|
from ..timeutil import utcnow_naive
|
|
from .command_match import (
|
|
EXPAND_ALL_COMMAND,
|
|
expand_from_bindings,
|
|
match_command,
|
|
normalize_command,
|
|
preview_task_item,
|
|
)
|
|
from .collect_session import resolve_aux_command
|
|
from .profiles import (
|
|
all_profiles,
|
|
get_profile,
|
|
metric_field_map,
|
|
profile_to_public_dict,
|
|
profiles_for_vendor,
|
|
)
|
|
from .retention import (
|
|
batch_protect_info,
|
|
delete_batch_data,
|
|
protected_batch_map,
|
|
purge_task_batches,
|
|
)
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return utcnow_naive()
|
|
|
|
|
|
def _override_map(db: Session) -> dict[str, dict[str, Any]]:
|
|
rows = db.query(BizStateCommandOverride).all()
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for r in rows:
|
|
ov: dict[str, Any] = {}
|
|
if r.title:
|
|
ov["title"] = r.title
|
|
if r.command_template:
|
|
ov["command_template"] = r.command_template
|
|
if r.description:
|
|
ov["description"] = r.description
|
|
if r.sample_output:
|
|
ov["sample_output"] = r.sample_output
|
|
if r.enabled is not None:
|
|
ov["enabled"] = bool(r.enabled)
|
|
out[str(r.profile_id)] = ov
|
|
return out
|
|
|
|
|
|
def list_profiles_public(db: Session, *, vendor_key: str = "") -> list[dict[str, Any]]:
|
|
ov = _override_map(db)
|
|
key = str(vendor_key or "").strip().lower()
|
|
profiles = profiles_for_vendor(key) if key else [p for p in all_profiles() if p.enabled]
|
|
# Also include disabled-in-code but enabled via override? keep simple: filter enabled after merge
|
|
result = []
|
|
for p in sorted(profiles, key=lambda x: (x.sort_order, x.profile_id)):
|
|
d = profile_to_public_dict(p, overrides=ov.get(p.profile_id))
|
|
if d.get("enabled"):
|
|
result.append(d)
|
|
return result
|
|
|
|
|
|
def upsert_profile_override(db: Session, profile_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
p = get_profile(profile_id)
|
|
if not p:
|
|
raise HTTPException(status_code=404, detail="profile_not_found")
|
|
row = (
|
|
db.query(BizStateCommandOverride)
|
|
.filter(BizStateCommandOverride.profile_id == profile_id)
|
|
.one_or_none()
|
|
)
|
|
if row is None:
|
|
row = BizStateCommandOverride(id=uuid4().hex, profile_id=profile_id)
|
|
db.add(row)
|
|
if "title" in body:
|
|
row.title = str(body.get("title") or "")
|
|
if "command_template" in body:
|
|
row.command_template = str(body.get("command_template") or "")
|
|
if "description" in body:
|
|
row.description = str(body.get("description") or "")
|
|
if "sample_output" in body:
|
|
row.sample_output = str(body.get("sample_output") or "")
|
|
if "enabled" in body and body.get("enabled") is not None:
|
|
row.enabled = bool(body.get("enabled"))
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
return profile_to_public_dict(p, overrides=_override_map(db).get(profile_id))
|
|
|
|
|
|
def _ne_meta(db: Session, *, source: str, ne_id: str) -> dict[str, str]:
|
|
src = str(source or "managed").strip().lower()
|
|
nid = str(ne_id or "").strip()
|
|
if src == "managed":
|
|
row = db.get(ManagedNE, nid)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
|
return {
|
|
"ne_name": str(getattr(row, "name", "") or ""),
|
|
"ne_ip": str(getattr(row, "ip_address", "") or ""),
|
|
"vendor": str(getattr(row, "vendor", "") or ""),
|
|
"device_type": str(getattr(row, "device_type", "") or ""),
|
|
}
|
|
if src == "ume":
|
|
from ..models import UmeInventoryNE
|
|
|
|
row = db.get(UmeInventoryNE, nid)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="ume_ne_not_found")
|
|
return {
|
|
"ne_name": str(
|
|
getattr(row, "user_label", "")
|
|
or getattr(row, "ne_name", "")
|
|
or getattr(row, "host_name", "")
|
|
or getattr(row, "ip_address", "")
|
|
or nid
|
|
),
|
|
"ne_ip": str(getattr(row, "ip_address", "") or getattr(row, "ip", "") or ""),
|
|
"vendor": str(getattr(row, "vendor", "") or ""),
|
|
"device_type": str(getattr(row, "device_type", "") or getattr(row, "ne_type", "") or ""),
|
|
}
|
|
raise HTTPException(status_code=400, detail="invalid_source")
|
|
|
|
|
|
def create_task(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
|
source = str(body.get("source") or "managed").strip().lower() or "managed"
|
|
if source not in ("managed", "ume"):
|
|
raise HTTPException(status_code=400, detail="invalid_source")
|
|
ne_id = str(body.get("ne_id") or "").strip()
|
|
if not ne_id:
|
|
raise HTTPException(status_code=400, detail="ne_id_required")
|
|
|
|
meta = _ne_meta(db, source=source, ne_id=ne_id)
|
|
vendor = str(body.get("vendor") or meta["vendor"] or "")
|
|
device_type = str(body.get("device_type") or meta["device_type"] or "")
|
|
status = str(body.get("status") or "draft").strip() or "draft"
|
|
if status not in ("draft", "running", "paused", "stopped"):
|
|
status = "draft"
|
|
task = BizStateTask(
|
|
id=uuid4().hex,
|
|
source=source,
|
|
ne_id=ne_id,
|
|
ne_name=str(body.get("ne_name") or meta["ne_name"] or ""),
|
|
ne_ip=str(body.get("ne_ip") or meta["ne_ip"] or ""),
|
|
vendor=vendor,
|
|
device_type=device_type,
|
|
note=str(body.get("note") or "")[:256],
|
|
purpose=str(body.get("purpose") or "")[:32],
|
|
status=status,
|
|
interval_sec=max(60, int(body.get("interval_sec") or 3600)),
|
|
retention_days=max(1, min(3650, int(body.get("retention_days") or 30))),
|
|
daily_keep_enabled=bool(body.get("daily_keep_enabled") or False),
|
|
daily_keep_count=max(1, min(1000, int(body.get("daily_keep_count") or 10))),
|
|
# keep legacy column in sync for brownfield readers
|
|
retention_batches=max(1, int(body.get("retention_days") or body.get("retention_batches") or 30)),
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
db.add(task)
|
|
db.flush()
|
|
|
|
items_in = list(body.get("items") or [])
|
|
if not items_in:
|
|
# Default: enable LLDP profile for this vendor
|
|
vkey = resolve_vendor_key(vendor, device_type)
|
|
for p in profiles_for_vendor(vkey):
|
|
if p.metric_id == "lldp_neighbor" and p.kind == "collect":
|
|
items_in.append(
|
|
{
|
|
"source_profile_id": p.profile_id,
|
|
"kind": "catalog",
|
|
"enabled": True,
|
|
"title": p.title,
|
|
}
|
|
)
|
|
break
|
|
|
|
_replace_items(db, task.id, items_in)
|
|
db.commit()
|
|
return get_task(db, task.id)
|
|
|
|
|
|
def _replace_items(db: Session, task_id: str, items_in: list[dict[str, Any]]) -> None:
|
|
old_items = db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id).all()
|
|
for it in old_items:
|
|
db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == it.id).delete()
|
|
db.delete(it)
|
|
db.flush()
|
|
|
|
for idx, raw in enumerate(items_in):
|
|
kind = str(raw.get("kind") or "catalog").strip() or "catalog"
|
|
item = BizStateTaskItem(
|
|
id=uuid4().hex,
|
|
task_id=task_id,
|
|
source_profile_id=str(raw.get("source_profile_id") or "")[:128],
|
|
kind=kind,
|
|
enabled=bool(raw.get("enabled", True)),
|
|
title=str(raw.get("title") or "")[:256],
|
|
command_override=str(raw.get("command_override") or raw.get("command") or "")[:512],
|
|
sort_order=int(raw.get("sort_order") if raw.get("sort_order") is not None else idx),
|
|
created_at=_utcnow(),
|
|
)
|
|
db.add(item)
|
|
db.flush()
|
|
for b in list(raw.get("bindings") or []):
|
|
ph = str(b.get("placeholder") or b.get("name") or "").strip()
|
|
val = str(b.get("value") or "").strip()
|
|
if not ph or not val:
|
|
continue
|
|
db.add(
|
|
BizStateTaskItemBinding(
|
|
id=uuid4().hex,
|
|
item_id=item.id,
|
|
placeholder=ph[:64],
|
|
value=val[:256],
|
|
created_at=_utcnow(),
|
|
)
|
|
)
|
|
|
|
|
|
def set_task_status(db: Session, task_id: str, status: str) -> dict[str, Any]:
|
|
"""Set lifecycle status: draft | running | paused | stopped."""
|
|
return update_task(db, task_id, {"status": status})
|
|
|
|
|
|
def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task_not_found")
|
|
if "note" in body:
|
|
task.note = str(body.get("note") or "")[:256]
|
|
if "purpose" in body and body["purpose"] is not None:
|
|
task.purpose = str(body.get("purpose") or "")[:32]
|
|
if "interval_sec" in body:
|
|
task.interval_sec = max(60, int(body.get("interval_sec") or 3600))
|
|
if "retention_days" in body:
|
|
task.retention_days = max(1, min(3650, int(body.get("retention_days") or 30)))
|
|
task.retention_batches = task.retention_days # legacy mirror
|
|
elif "retention_batches" in body:
|
|
# backward compat: treat as days if old clients still send it
|
|
task.retention_days = max(1, min(3650, int(body.get("retention_batches") or 30)))
|
|
task.retention_batches = task.retention_days
|
|
if "daily_keep_enabled" in body:
|
|
task.daily_keep_enabled = bool(body.get("daily_keep_enabled"))
|
|
if "daily_keep_count" in body:
|
|
task.daily_keep_count = max(1, min(1000, int(body.get("daily_keep_count") or 10)))
|
|
if "items" in body:
|
|
_replace_items(db, task.id, list(body.get("items") or []))
|
|
if "status" in body:
|
|
st = str(body.get("status") or "").strip()
|
|
if st in ("draft", "running", "paused", "stopped"):
|
|
if st == "running":
|
|
# Cutover HF: NE-scoped catalogs; placeholder bindings optional / stamped later
|
|
purpose = str(getattr(task, "purpose", None) or "").strip()
|
|
note = str(getattr(task, "note", None) or "")
|
|
if purpose != "cutover_hf" and not note.startswith("割接高频"):
|
|
_assert_bindings_ready(db, task.id)
|
|
task.status = st
|
|
task.updated_at = _utcnow()
|
|
db.commit()
|
|
return get_task(db, task_id)
|
|
|
|
|
|
def _assert_bindings_ready(db: Session, task_id: str) -> None:
|
|
items = (
|
|
db.query(BizStateTaskItem)
|
|
.filter(BizStateTaskItem.task_id == task_id, BizStateTaskItem.enabled.is_(True))
|
|
.all()
|
|
)
|
|
for it in items:
|
|
if it.kind != "catalog":
|
|
continue
|
|
profile = get_profile(it.source_profile_id)
|
|
if not profile or not profile.placeholders:
|
|
continue
|
|
# Optional discover placeholders: empty bindings → expand all at collect.
|
|
if all(not ph.required for ph in profile.placeholders):
|
|
continue
|
|
binds = (
|
|
db.query(BizStateTaskItemBinding)
|
|
.filter(BizStateTaskItemBinding.item_id == it.id)
|
|
.all()
|
|
)
|
|
if not binds:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"bindings_required:{profile.profile_id}",
|
|
)
|
|
|
|
|
|
def set_item_bindings(
|
|
db: Session, task_id: str, item_id: str, bindings: list[dict[str, Any]]
|
|
) -> dict[str, Any]:
|
|
item = db.get(BizStateTaskItem, item_id)
|
|
if not item or item.task_id != task_id:
|
|
raise HTTPException(status_code=404, detail="item_not_found")
|
|
db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item_id).delete()
|
|
for b in bindings:
|
|
ph = str(b.get("placeholder") or b.get("name") or "").strip()
|
|
val = str(b.get("value") or "").strip()
|
|
if not ph or not val:
|
|
continue
|
|
db.add(
|
|
BizStateTaskItemBinding(
|
|
id=uuid4().hex,
|
|
item_id=item_id,
|
|
placeholder=ph[:64],
|
|
value=val[:256],
|
|
created_at=_utcnow(),
|
|
)
|
|
)
|
|
task = db.get(BizStateTask, task_id)
|
|
if task:
|
|
task.updated_at = _utcnow()
|
|
db.commit()
|
|
return get_task(db, task_id)
|
|
|
|
|
|
def get_task(db: Session, task_id: str) -> dict[str, Any]:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task_not_found")
|
|
items = (
|
|
db.query(BizStateTaskItem)
|
|
.filter(BizStateTaskItem.task_id == task_id)
|
|
.order_by(BizStateTaskItem.sort_order.asc())
|
|
.all()
|
|
)
|
|
item_out = []
|
|
for it in items:
|
|
binds = (
|
|
db.query(BizStateTaskItemBinding)
|
|
.filter(BizStateTaskItemBinding.item_id == it.id)
|
|
.all()
|
|
)
|
|
item_out.append(
|
|
{
|
|
"id": it.id,
|
|
"source_profile_id": it.source_profile_id,
|
|
"kind": it.kind,
|
|
"enabled": bool(it.enabled),
|
|
"title": it.title,
|
|
"command_override": it.command_override,
|
|
"sort_order": it.sort_order,
|
|
"bindings": [{"placeholder": b.placeholder, "value": b.value} for b in binds],
|
|
}
|
|
)
|
|
return {
|
|
"id": task.id,
|
|
"source": task.source,
|
|
"ne_id": task.ne_id,
|
|
"ne_name": task.ne_name,
|
|
"ne_ip": task.ne_ip,
|
|
"vendor": task.vendor,
|
|
"device_type": task.device_type,
|
|
"note": task.note,
|
|
"purpose": str(getattr(task, "purpose", None) or ""),
|
|
"status": task.status,
|
|
"interval_sec": task.interval_sec,
|
|
"retention_days": int(getattr(task, "retention_days", None) or 30),
|
|
"daily_keep_enabled": bool(getattr(task, "daily_keep_enabled", False)),
|
|
"daily_keep_count": int(getattr(task, "daily_keep_count", None) or 10),
|
|
"collect_running": bool(task.collect_running),
|
|
"last_collect_started_at": task.last_collect_started_at.isoformat() + "Z"
|
|
if task.last_collect_started_at
|
|
else None,
|
|
"last_collect_ended_at": task.last_collect_ended_at.isoformat() + "Z"
|
|
if task.last_collect_ended_at
|
|
else None,
|
|
"last_error": task.last_error,
|
|
"items": item_out,
|
|
}
|
|
|
|
|
|
def list_tasks(db: Session, *, purpose: str | None = None) -> list[dict[str, Any]]:
|
|
from sqlalchemy import or_
|
|
|
|
q = db.query(BizStateTask)
|
|
purpose_f = str(purpose or "").strip()
|
|
if purpose_f:
|
|
if purpose_f == "portrait":
|
|
# Portrait = empty purpose or explicit portrait (exclude cutover_hf)
|
|
q = q.filter(
|
|
or_(
|
|
BizStateTask.purpose == "",
|
|
BizStateTask.purpose == "portrait",
|
|
BizStateTask.purpose.is_(None),
|
|
)
|
|
)
|
|
else:
|
|
q = q.filter(BizStateTask.purpose == purpose_f)
|
|
rows = q.order_by(BizStateTask.updated_at.desc()).all()
|
|
return [
|
|
{
|
|
"id": t.id,
|
|
"source": t.source,
|
|
"ne_id": t.ne_id,
|
|
"ne_name": t.ne_name,
|
|
"ne_ip": t.ne_ip,
|
|
"vendor": t.vendor,
|
|
"note": t.note,
|
|
"purpose": str(getattr(t, "purpose", None) or ""),
|
|
"status": t.status,
|
|
"interval_sec": t.interval_sec,
|
|
"collect_running": bool(t.collect_running),
|
|
"last_error": t.last_error,
|
|
"last_collect_started_at": t.last_collect_started_at.isoformat() + "Z"
|
|
if t.last_collect_started_at
|
|
else None,
|
|
"last_collect_ended_at": t.last_collect_ended_at.isoformat() + "Z"
|
|
if t.last_collect_ended_at
|
|
else None,
|
|
}
|
|
for t in rows
|
|
]
|
|
|
|
|
|
def delete_task(db: Session, task_id: str) -> None:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task_not_found")
|
|
batches = db.query(BizStateBatch).filter(BizStateBatch.task_id == task_id).all()
|
|
# Refuse if any batch is still referenced by compare/migration (manual baseline is OK to drop with task)
|
|
pmap = protected_batch_map(db, task_id=task_id)
|
|
blocked: list[dict[str, Any]] = []
|
|
for b in batches:
|
|
reasons = [r for r in pmap.get(b.id, []) if r != "manual_baseline"]
|
|
if reasons:
|
|
blocked.append({"batch_id": b.id, "reasons": reasons})
|
|
if blocked:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"error": "batches_referenced", "items": blocked[:20]},
|
|
)
|
|
for b in batches:
|
|
delete_batch_data(db, b.id)
|
|
items = db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id).all()
|
|
for it in items:
|
|
db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == it.id).delete()
|
|
db.delete(it)
|
|
db.query(BizStateEvent).filter(BizStateEvent.task_id == task_id).delete()
|
|
db.delete(task)
|
|
db.commit()
|
|
|
|
|
|
def _batch_list_item(b: BizStateBatch, protect: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": b.id,
|
|
"status": b.status,
|
|
"command_count": b.command_count,
|
|
"row_count": b.row_count,
|
|
"message": b.message,
|
|
"ne_name": b.ne_name or "",
|
|
"ne_id": b.ne_id or "",
|
|
"alias": str(getattr(b, "alias", "") or ""),
|
|
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
|
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
|
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
|
"baseline_marked_at": b.baseline_marked_at.isoformat() + "Z"
|
|
if getattr(b, "baseline_marked_at", None)
|
|
else None,
|
|
"protected": bool(protect.get("protected")),
|
|
"protect_reasons": list(protect.get("reasons") or []),
|
|
}
|
|
|
|
|
|
def list_batches(db: Session, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
rows = (
|
|
db.query(BizStateBatch)
|
|
.filter(BizStateBatch.task_id == task_id)
|
|
.order_by(BizStateBatch.started_at.desc())
|
|
.limit(max(1, min(500, int(limit))))
|
|
.all()
|
|
)
|
|
pmap = protected_batch_map(db, task_id=task_id)
|
|
out: list[dict[str, Any]] = []
|
|
for b in rows:
|
|
reasons = list(pmap.get(b.id, []))
|
|
if bool(getattr(b, "is_baseline", False)) and "manual_baseline" not in reasons:
|
|
reasons = ["manual_baseline", *reasons]
|
|
out.append(
|
|
_batch_list_item(
|
|
b,
|
|
{"protected": bool(reasons), "reasons": reasons},
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def set_batch_baseline(db: Session, batch_id: str, *, marked: bool) -> dict[str, Any]:
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
b.is_baseline = bool(marked)
|
|
b.baseline_marked_at = _utcnow() if marked else None
|
|
db.commit()
|
|
return _batch_list_item(b, batch_protect_info(db, batch_id))
|
|
|
|
|
|
def set_batch_alias(db: Session, batch_id: str, *, alias: str) -> dict[str, Any]:
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
b.alias = str(alias or "").strip()[:128]
|
|
db.commit()
|
|
return _batch_list_item(b, batch_protect_info(db, batch_id))
|
|
|
|
|
|
def delete_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
info = batch_protect_info(db, batch_id)
|
|
if info.get("protected"):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"error": "batch_protected", "reasons": info.get("reasons") or []},
|
|
)
|
|
delete_batch_data(db, batch_id)
|
|
db.commit()
|
|
return {"ok": True, "batch_id": batch_id}
|
|
|
|
|
|
def delete_batches_bulk(db: Session, batch_ids: list[str]) -> dict[str, Any]:
|
|
deleted: list[str] = []
|
|
skipped: list[dict[str, Any]] = []
|
|
for raw in batch_ids:
|
|
bid = str(raw or "").strip()
|
|
if not bid:
|
|
continue
|
|
b = db.get(BizStateBatch, bid)
|
|
if not b:
|
|
skipped.append({"batch_id": bid, "reasons": ["not_found"]})
|
|
continue
|
|
info = batch_protect_info(db, bid)
|
|
if info.get("protected"):
|
|
skipped.append({"batch_id": bid, "reasons": info.get("reasons") or []})
|
|
continue
|
|
delete_batch_data(db, bid)
|
|
deleted.append(bid)
|
|
if deleted:
|
|
db.commit()
|
|
return {"ok": True, "deleted": deleted, "skipped": skipped, "deleted_count": len(deleted)}
|
|
|
|
|
|
def run_purge_for_task(db: Session, task_id: str) -> dict[str, Any]:
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task_not_found")
|
|
return purge_task_batches(db, task)
|
|
|
|
|
|
# Workbook sheets are keyed by metric_id. Multiple AF-specific collect profiles
|
|
# share one metric (e.g. all BGP summaries → bgp_peer); use a neutral title.
|
|
_METRIC_SHEET_TITLES: dict[str, str] = {
|
|
"bgp_peer": "BGP Status Summary",
|
|
"bgp_route": "BGP Neighbor Routes",
|
|
"vrrp": "VRRP",
|
|
"ip_route": "IPv4 Forwarding",
|
|
"ipv6_route": "IPv6 Forwarding",
|
|
}
|
|
|
|
|
|
def _metric_sheet_title(metric_id: str, fallback: str = "") -> str:
|
|
mid = str(metric_id or "").strip()
|
|
if mid in _METRIC_SHEET_TITLES:
|
|
return _METRIC_SHEET_TITLES[mid]
|
|
fb = str(fallback or "").strip()
|
|
return fb or mid
|
|
|
|
|
|
def _raw_line_count(raw: str | None) -> int:
|
|
"""CLI text lines collected (splitlines-compatible, no list materialization)."""
|
|
s = raw or ""
|
|
if not s:
|
|
return 0
|
|
return s.count("\n") + (0 if s.endswith("\n") else 1)
|
|
|
|
|
|
def _cmd_raw_line_count(cmd: Any) -> int:
|
|
"""Prefer full-file line count persisted before DB raw_text truncate."""
|
|
stored = int(getattr(cmd, "raw_line_count", 0) or 0)
|
|
if stored > 0:
|
|
return stored
|
|
return _raw_line_count(getattr(cmd, "raw_text", None))
|
|
|
|
|
|
def _cmd_declared_total(cmd: Any) -> int:
|
|
return int(getattr(cmd, "declared_total", 0) or 0)
|
|
|
|
|
|
def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
|
"""Batch workbook summary: meta + commands + sheet catalog (no metric row payload)."""
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
cmds = (
|
|
db.query(BizStateBatchCommand)
|
|
.filter(BizStateBatchCommand.batch_id == batch_id)
|
|
.order_by(BizStateBatchCommand.created_at.asc())
|
|
.all()
|
|
)
|
|
protect = batch_protect_info(db, batch_id)
|
|
|
|
# Per-metric row counts (generic table)
|
|
metric_counts: dict[str, int] = {}
|
|
for mid, cnt in (
|
|
db.query(BizStateMetricRow.metric_id, func.count(BizStateMetricRow.id))
|
|
.filter(BizStateMetricRow.batch_id == batch_id)
|
|
.group_by(BizStateMetricRow.metric_id)
|
|
.all()
|
|
):
|
|
key = str(mid or "").strip()
|
|
if key:
|
|
metric_counts[key] = int(cnt or 0)
|
|
|
|
lldp_count = (
|
|
db.query(func.count(BizStateLldpNeighbor.id))
|
|
.filter(BizStateLldpNeighbor.batch_id == batch_id)
|
|
.scalar()
|
|
)
|
|
lldp_n = int(lldp_count or 0)
|
|
if lldp_n:
|
|
metric_counts["lldp_neighbor"] = lldp_n
|
|
|
|
cmd_payload: list[dict[str, Any]] = []
|
|
sheets_order: list[str] = []
|
|
sheet_cmds: dict[str, list[dict[str, Any]]] = {}
|
|
sheet_titles: dict[str, str] = {}
|
|
|
|
def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None, title: str = "") -> None:
|
|
id_ = str(mid or "").strip()
|
|
if not id_ or id_ in ("vrf_list", "commands"):
|
|
return
|
|
if id_ not in sheets_order:
|
|
sheets_order.append(id_)
|
|
sheet_cmds.setdefault(id_, [])
|
|
sheet_titles[id_] = _metric_sheet_title(id_, title)
|
|
if cmd_info is not None:
|
|
# Prefer primary collect rows over aux / aux_cached for the same CLI
|
|
sheet_cmds[id_].append(cmd_info)
|
|
|
|
primary_cmds_by_cli: dict[str, dict[str, Any]] = {}
|
|
for c in cmds:
|
|
status = str(c.parse_status or "").strip().lower()
|
|
is_aux = status.startswith("aux")
|
|
raw = c.raw_text or ""
|
|
info = {
|
|
"id": c.id,
|
|
"profile_id": c.profile_id,
|
|
"parser_id": c.parser_id,
|
|
"metric_id": c.metric_id,
|
|
"raw_command": c.raw_command,
|
|
"params": c.params_json or {},
|
|
"parse_status": c.parse_status,
|
|
"row_count": c.row_count,
|
|
"raw_line_count": _cmd_raw_line_count(c),
|
|
"declared_total": _cmd_declared_total(c),
|
|
"message": c.message,
|
|
"has_raw": bool(str(raw).strip()),
|
|
"is_aux": is_aux,
|
|
}
|
|
cmd_n = normalize_command(str(c.raw_command or ""))
|
|
if not is_aux and cmd_n:
|
|
primary_cmds_by_cli.setdefault(cmd_n, info)
|
|
# Commands sheet: hide successful aux when the same CLI already has a primary row;
|
|
# keep failed/skipped aux visible so partial reasons are not hidden.
|
|
if is_aux and cmd_n and cmd_n in primary_cmds_by_cli:
|
|
st_l = status
|
|
if st_l in ("aux_failed",) or "fail" in st_l or st_l.startswith("skipped"):
|
|
cmd_payload.append(info)
|
|
continue
|
|
if is_aux and cmd_n:
|
|
# aux may appear before primary in list — defer; second pass below
|
|
continue
|
|
cmd_payload.append(info)
|
|
mid = str(c.metric_id or "").strip()
|
|
title = ""
|
|
pid = str(c.profile_id or "").strip()
|
|
if pid:
|
|
prof = get_profile(pid)
|
|
if prof:
|
|
title = str(prof.title or "")
|
|
if not mid:
|
|
mid = str(prof.metric_id or "").strip()
|
|
if mid and mid not in ("", "vrf_list") and not is_aux:
|
|
_push_sheet(
|
|
mid,
|
|
{
|
|
"id": c.id,
|
|
"raw_command": c.raw_command,
|
|
"parse_status": c.parse_status,
|
|
"row_count": c.row_count,
|
|
"raw_line_count": info["raw_line_count"],
|
|
"declared_total": info["declared_total"],
|
|
"message": c.message,
|
|
"has_raw": info["has_raw"],
|
|
"profile_id": c.profile_id,
|
|
},
|
|
title=title,
|
|
)
|
|
|
|
# Include aux-only CLIs that had no primary counterpart
|
|
for c in cmds:
|
|
status = str(c.parse_status or "").strip().lower()
|
|
if not status.startswith("aux"):
|
|
continue
|
|
cmd_n = normalize_command(str(c.raw_command or ""))
|
|
if cmd_n and cmd_n in primary_cmds_by_cli:
|
|
continue
|
|
cmd_payload.append(
|
|
{
|
|
"id": c.id,
|
|
"profile_id": c.profile_id,
|
|
"parser_id": c.parser_id,
|
|
"metric_id": c.metric_id,
|
|
"raw_command": c.raw_command,
|
|
"params": c.params_json or {},
|
|
"parse_status": c.parse_status,
|
|
"row_count": c.row_count,
|
|
"raw_line_count": _cmd_raw_line_count(c),
|
|
"declared_total": _cmd_declared_total(c),
|
|
"message": c.message,
|
|
"has_raw": bool(str(c.raw_text or "").strip()),
|
|
"is_aux": True,
|
|
}
|
|
)
|
|
|
|
for mid in metric_counts:
|
|
if mid not in sheets_order:
|
|
sheets_order.append(mid)
|
|
sheet_cmds.setdefault(mid, [])
|
|
if mid not in sheet_titles:
|
|
# Best-effort title from any profile with this metric_id
|
|
fallback = mid
|
|
for p in all_profiles():
|
|
if p.metric_id == mid and p.enabled:
|
|
fallback = str(p.title or mid)
|
|
break
|
|
sheet_titles[mid] = _metric_sheet_title(mid, fallback)
|
|
|
|
sheets = [
|
|
{
|
|
"metric_id": mid,
|
|
"title": sheet_titles.get(mid) or mid,
|
|
"row_count": int(metric_counts.get(mid) or 0),
|
|
"commands": list(sheet_cmds.get(mid) or []),
|
|
}
|
|
for mid in sheets_order
|
|
]
|
|
sheet_count = len(sheets)
|
|
sheets_with_data = sum(1 for s in sheets if int(s.get("row_count") or 0) > 0)
|
|
|
|
# Full-batch command stats (includes hidden successful aux).
|
|
stats = {"total": 0, "ok": 0, "failed": 0, "aux_failed": 0, "skipped": 0, "other": 0}
|
|
for c in cmds:
|
|
stats["total"] += 1
|
|
st = str(c.parse_status or "").strip().lower()
|
|
if st in ("ok", "success", "aux", "aux_ok", "aux_cached"):
|
|
stats["ok"] += 1
|
|
elif st == "aux_failed" or (st.startswith("aux") and "fail" in st):
|
|
stats["aux_failed"] += 1
|
|
stats["failed"] += 1
|
|
elif st in ("failed", "error", "fail") or st.endswith("_failed"):
|
|
stats["failed"] += 1
|
|
elif st.startswith("skipped"):
|
|
stats["skipped"] += 1
|
|
else:
|
|
stats["other"] += 1
|
|
|
|
return {
|
|
"id": b.id,
|
|
"task_id": b.task_id,
|
|
"status": b.status,
|
|
"command_count": b.command_count,
|
|
"row_count": b.row_count,
|
|
"message": b.message,
|
|
"alias": str(getattr(b, "alias", "") or ""),
|
|
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
|
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
|
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
|
"baseline_marked_at": b.baseline_marked_at.isoformat() + "Z"
|
|
if getattr(b, "baseline_marked_at", None)
|
|
else None,
|
|
"protected": bool(protect.get("protected")),
|
|
"protect_reasons": list(protect.get("reasons") or []),
|
|
# Workbook header: 对比项 / 有数据 / 表格
|
|
"compare_item_count": sheet_count,
|
|
"sheets_with_data": sheets_with_data,
|
|
"sheet_count": sheet_count,
|
|
"commands": cmd_payload,
|
|
"command_stats": stats,
|
|
"sheets": sheets,
|
|
}
|
|
|
|
|
|
def list_batch_metric_rows(
|
|
db: Session,
|
|
batch_id: str,
|
|
metric_id: str,
|
|
*,
|
|
page: int = 1,
|
|
page_size: int = 50,
|
|
kw: str = "",
|
|
column: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Paginated rows for one batch metric sheet (server-side filter)."""
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
mid = str(metric_id or "").strip()
|
|
if not mid or mid in ("commands", "vrf_list"):
|
|
raise HTTPException(status_code=400, detail="invalid_metric_id")
|
|
|
|
page_n = max(1, int(page or 1))
|
|
size_n = max(1, min(200, int(page_size or 50)))
|
|
kw_n = str(kw or "").strip()
|
|
col_n = str(column or "").strip()
|
|
|
|
fields = metric_field_map().get(mid) or []
|
|
columns = [
|
|
{
|
|
"key": f.name,
|
|
"header": f.name, # strict original field name (no display_name localization)
|
|
"role": f.role,
|
|
"is_key": bool(f.is_key),
|
|
}
|
|
for f in fields
|
|
]
|
|
|
|
if mid == "lldp_neighbor":
|
|
q = db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == batch_id)
|
|
if kw_n:
|
|
like = f"%{kw_n}%"
|
|
if col_n == "local_if":
|
|
q = q.filter(BizStateLldpNeighbor.local_if.ilike(like))
|
|
elif col_n == "remote_sys":
|
|
q = q.filter(BizStateLldpNeighbor.remote_sys.ilike(like))
|
|
elif col_n == "remote_if":
|
|
q = q.filter(BizStateLldpNeighbor.remote_if.ilike(like))
|
|
elif col_n == "remote_ip":
|
|
q = q.filter(BizStateLldpNeighbor.remote_ip.ilike(like))
|
|
elif col_n == "protocol":
|
|
q = q.filter(BizStateLldpNeighbor.protocol.ilike(like))
|
|
else:
|
|
q = q.filter(
|
|
or_(
|
|
BizStateLldpNeighbor.local_if.ilike(like),
|
|
BizStateLldpNeighbor.remote_sys.ilike(like),
|
|
BizStateLldpNeighbor.remote_if.ilike(like),
|
|
BizStateLldpNeighbor.remote_ip.ilike(like),
|
|
BizStateLldpNeighbor.protocol.ilike(like),
|
|
)
|
|
)
|
|
total = int(q.count() or 0)
|
|
rows_db = (
|
|
q.order_by(BizStateLldpNeighbor.local_if.asc())
|
|
.offset((page_n - 1) * size_n)
|
|
.limit(size_n)
|
|
.all()
|
|
)
|
|
items = [
|
|
{
|
|
"local_if": n.local_if,
|
|
"remote_sys": n.remote_sys,
|
|
"remote_if": n.remote_if,
|
|
"remote_ip": n.remote_ip,
|
|
"protocol": n.protocol,
|
|
}
|
|
for n in rows_db
|
|
]
|
|
if not columns:
|
|
columns = [
|
|
{"key": "local_if", "header": "local_if", "role": "identity", "is_key": True},
|
|
{"key": "remote_sys", "header": "remote_sys", "role": "identity", "is_key": True},
|
|
{"key": "remote_if", "header": "remote_if", "role": "identity", "is_key": True},
|
|
{"key": "remote_ip", "header": "remote_ip", "role": "meta", "is_key": False},
|
|
{"key": "protocol", "header": "protocol", "role": "meta", "is_key": False},
|
|
]
|
|
else:
|
|
q = db.query(BizStateMetricRow).filter(
|
|
BizStateMetricRow.batch_id == batch_id,
|
|
BizStateMetricRow.metric_id == mid,
|
|
)
|
|
if kw_n:
|
|
like = f"%{kw_n}%"
|
|
if col_n:
|
|
# JSON path as text — works on Postgres JSONB and SQLite JSON
|
|
q = q.filter(cast(BizStateMetricRow.data_json[col_n], String).ilike(like))
|
|
else:
|
|
q = q.filter(cast(BizStateMetricRow.data_json, String).ilike(like))
|
|
total = int(q.count() or 0)
|
|
rows_db = (
|
|
q.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
|
|
.offset((page_n - 1) * size_n)
|
|
.limit(size_n)
|
|
.all()
|
|
)
|
|
items = [dict(r.data_json or {}) for r in rows_db]
|
|
if not columns and items:
|
|
keys: list[str] = []
|
|
for rec in items:
|
|
for k in rec.keys():
|
|
if k not in keys:
|
|
keys.append(str(k))
|
|
columns = [
|
|
{"key": k, "header": k, "role": "identity", "is_key": False} for k in keys
|
|
]
|
|
|
|
pages = max(1, (total + size_n - 1) // size_n) if total else 1
|
|
return {
|
|
"batch_id": batch_id,
|
|
"metric_id": mid,
|
|
"total": total,
|
|
"page": page_n,
|
|
"page_size": size_n,
|
|
"pages": pages,
|
|
"columns": columns,
|
|
"items": items,
|
|
}
|
|
|
|
|
|
def get_batch_command(db: Session, batch_id: str, command_id: str) -> dict[str, Any]:
|
|
"""Full CLI raw text for one collect command (AI / deep dive)."""
|
|
from ..models import BizStateBatchCommand
|
|
|
|
b = db.get(BizStateBatch, batch_id)
|
|
if not b:
|
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
|
c = db.get(BizStateBatchCommand, command_id)
|
|
if not c or c.batch_id != batch_id:
|
|
raise HTTPException(status_code=404, detail="command_not_found")
|
|
task = db.get(BizStateTask, b.task_id) if b.task_id else None
|
|
raw = c.raw_text or ""
|
|
return {
|
|
"id": c.id,
|
|
"batch_id": batch_id,
|
|
"task_id": b.task_id,
|
|
"device": {
|
|
"ne_id": task.ne_id if task else "",
|
|
"ne_name": task.ne_name if task else "",
|
|
"ne_ip": task.ne_ip if task else "",
|
|
},
|
|
"profile_id": c.profile_id,
|
|
"parser_id": c.parser_id,
|
|
"metric_id": c.metric_id,
|
|
"raw_command": c.raw_command,
|
|
"params": c.params_json or {},
|
|
"parse_status": c.parse_status,
|
|
"row_count": c.row_count,
|
|
"raw_line_count": _cmd_raw_line_count(c),
|
|
"declared_total": _cmd_declared_total(c),
|
|
"message": c.message,
|
|
"raw_text": raw,
|
|
"collected_at": c.created_at.isoformat() + "Z" if c.created_at else None,
|
|
"batch_started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
|
"batch_ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
|
}
|
|
|
|
|
|
def export_batch_zip(db: Session, batch_id: str) -> bytes:
|
|
detail = get_batch(db, batch_id)
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
lines = [
|
|
f"batch_id={detail['id']}",
|
|
f"task_id={detail['task_id']}",
|
|
f"status={detail['status']}",
|
|
f"commands={detail['command_count']}",
|
|
f"rows={detail['row_count']}",
|
|
"",
|
|
"commands:",
|
|
]
|
|
for c in detail["commands"]:
|
|
lines.append(
|
|
f"- {c['raw_command']} | parse={c['parse_status']} | "
|
|
f"profile={c['profile_id']} | rows={c['row_count']}"
|
|
)
|
|
zf.writestr("manifest.txt", "\n".join(lines) + "\n")
|
|
|
|
for c in detail["commands"]:
|
|
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in c["raw_command"])[:80]
|
|
row = db.get(BizStateBatchCommand, c["id"])
|
|
raw = (row.raw_text if row else "") or ""
|
|
if raw:
|
|
zf.writestr(f"raw/{c['id']}_{safe}.full.txt", raw)
|
|
zf.writestr(f"raw/{c['id']}_{safe}.txt", raw[:2000])
|
|
|
|
# LLDP CSV
|
|
neighbors = (
|
|
db.query(BizStateLldpNeighbor)
|
|
.filter(BizStateLldpNeighbor.batch_id == batch_id)
|
|
.order_by(BizStateLldpNeighbor.local_if.asc())
|
|
.all()
|
|
)
|
|
csv_lines = ["local_if,remote_sys,remote_if,remote_ip,protocol"]
|
|
for n in neighbors:
|
|
csv_lines.append(
|
|
",".join(
|
|
[
|
|
_csv(n.local_if),
|
|
_csv(n.remote_sys),
|
|
_csv(n.remote_if),
|
|
_csv(n.remote_ip),
|
|
_csv(n.protocol),
|
|
]
|
|
)
|
|
)
|
|
zf.writestr("tables/lldp_neighbor.csv", "\n".join(csv_lines) + "\n")
|
|
|
|
# Generic metrics CSV (stream by metric_id)
|
|
for sheet in detail.get("sheets") or []:
|
|
mid = str(sheet.get("metric_id") or "").strip()
|
|
if not mid or mid == "lldp_neighbor":
|
|
continue
|
|
rows = (
|
|
db.query(BizStateMetricRow)
|
|
.filter(
|
|
BizStateMetricRow.batch_id == batch_id,
|
|
BizStateMetricRow.metric_id == mid,
|
|
)
|
|
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
|
|
.all()
|
|
)
|
|
if not rows:
|
|
continue
|
|
recs = [dict(r.data_json or {}) for r in rows]
|
|
cols: list[str] = []
|
|
for rec in recs:
|
|
for k in rec.keys():
|
|
if k not in cols:
|
|
cols.append(str(k))
|
|
out_lines = [",".join(_csv(c) for c in cols)]
|
|
for rec in recs:
|
|
out_lines.append(",".join(_csv(str(rec.get(c, "") or "")) for c in cols))
|
|
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "metric"
|
|
zf.writestr(f"tables/{safe}.csv", "\n".join(out_lines) + "\n")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _csv(v: str) -> str:
|
|
s = str(v or "")
|
|
if any(ch in s for ch in ",\"\n"):
|
|
return '"' + s.replace('"', '""') + '"'
|
|
return s
|
|
|
|
|
|
def preview_items(db: Session, *, vendor: str, device_type: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
vkey = resolve_vendor_key(vendor, device_type)
|
|
out = []
|
|
for raw in items:
|
|
out.append(
|
|
preview_task_item(
|
|
vendor_key=vkey,
|
|
profile_id=str(raw.get("source_profile_id") or ""),
|
|
command=str(raw.get("command_override") or raw.get("command") or ""),
|
|
bindings=list(raw.get("bindings") or []),
|
|
kind=str(raw.get("kind") or "catalog"),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
def _resolve_export_profile(profile_id: str):
|
|
"""Resolve collect profile; remap disabled if_intf → config_interface."""
|
|
|
|
pid = str(profile_id or "").strip()
|
|
if not pid:
|
|
return None
|
|
profile = get_profile(pid)
|
|
if profile is None:
|
|
return None
|
|
if profile.enabled:
|
|
return profile
|
|
if profile.metric_id == "if_intf" or pid.endswith(".if_intf"):
|
|
vk = str(profile.vendor_key or "zte").strip() or "zte"
|
|
remapped = get_profile(f"{vk}.config_interface") or get_profile("zte.config_interface")
|
|
if remapped and remapped.enabled:
|
|
return remapped
|
|
return None
|
|
|
|
|
|
def _append_aux_commands(
|
|
section: dict[str, Any],
|
|
profile: Any,
|
|
*,
|
|
params: dict[str, str],
|
|
) -> None:
|
|
"""Append resolved aux CLIs onto a plan section (section-local dedupe only)."""
|
|
existing = {
|
|
str(c.get("command") or "").strip()
|
|
for c in section.get("commands") or []
|
|
if str(c.get("role") or "") == "aux"
|
|
}
|
|
for aux in list(getattr(profile, "aux_commands", None) or []):
|
|
try:
|
|
ra = resolve_aux_command(aux, params=dict(params or {}))
|
|
except ValueError as exc:
|
|
section["notes"].append(f"aux {getattr(aux, 'key', '')}: {exc}")
|
|
continue
|
|
cmd = normalize_command(ra.command)
|
|
if not cmd or cmd in existing:
|
|
continue
|
|
existing.add(cmd)
|
|
section["commands"].append(
|
|
{
|
|
"command": cmd,
|
|
"role": "aux",
|
|
"aux_key": ra.key,
|
|
"params": dict(params or {}),
|
|
"profile_id": ra.profile_id,
|
|
}
|
|
)
|
|
|
|
|
|
def _flat_unique_commands(sections: list[dict[str, Any]]) -> list[str]:
|
|
"""Dedupe executable CLIs across items (templates excluded)."""
|
|
flat: list[str] = []
|
|
seen: set[str] = set()
|
|
for sec in sections:
|
|
for c in sec.get("commands") or []:
|
|
role = str(c.get("role") or "primary")
|
|
if role == "template":
|
|
continue
|
|
cmd = normalize_command(c.get("command"))
|
|
if not cmd or cmd in seen:
|
|
continue
|
|
seen.add(cmd)
|
|
flat.append(cmd)
|
|
return flat
|
|
|
|
|
|
def plan_task_collect_commands(
|
|
db: Session,
|
|
task_id: str,
|
|
*,
|
|
enabled_only: bool = True,
|
|
include_aux: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Plan concrete collect CLIs for a task (no device login).
|
|
|
|
Each monitoring item lists its own primary + aux CLIs (aux repeated per
|
|
item when shared). The top-level ``commands`` list is the deduped union
|
|
for scripting. Pass include_aux=False for primary-only lists.
|
|
"""
|
|
task = db.get(BizStateTask, task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="task_not_found")
|
|
vendor_key = resolve_vendor_key(task.vendor or "", task.device_type or "")
|
|
q = db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id)
|
|
if enabled_only:
|
|
q = q.filter(BizStateTaskItem.enabled.is_(True))
|
|
items = q.order_by(BizStateTaskItem.sort_order.asc()).all()
|
|
|
|
sections: list[dict[str, Any]] = []
|
|
|
|
for item in items:
|
|
title = str(item.title or "").strip()
|
|
kind = str(item.kind or "catalog")
|
|
section: dict[str, Any] = {
|
|
"item_id": item.id,
|
|
"kind": kind,
|
|
"source_profile_id": str(item.source_profile_id or ""),
|
|
"title": title,
|
|
"enabled": bool(item.enabled),
|
|
"commands": [],
|
|
"notes": [],
|
|
}
|
|
|
|
if kind == "custom_raw":
|
|
cmd = normalize_command(item.command_override)
|
|
if cmd:
|
|
section["commands"].append(
|
|
{"command": cmd, "role": "primary", "params": {}, "profile_id": ""}
|
|
)
|
|
else:
|
|
section["notes"].append("empty custom command")
|
|
sections.append(section)
|
|
continue
|
|
|
|
profile = _resolve_export_profile(item.source_profile_id)
|
|
if profile is None:
|
|
section["notes"].append(
|
|
f"skip profile {item.source_profile_id} (missing or disabled)"
|
|
)
|
|
sections.append(section)
|
|
continue
|
|
|
|
section["title"] = title or str(profile.title or profile.profile_id)
|
|
section["source_profile_id"] = profile.profile_id
|
|
binds = (
|
|
db.query(BizStateTaskItemBinding)
|
|
.filter(BizStateTaskItemBinding.item_id == item.id)
|
|
.all()
|
|
)
|
|
binding_dicts = [
|
|
{
|
|
"placeholder": str(b.placeholder or "").strip(),
|
|
"value": str(b.value or "").strip(),
|
|
}
|
|
for b in binds
|
|
if str(b.placeholder or "").strip() and str(b.value or "").strip()
|
|
]
|
|
|
|
try:
|
|
pairs = expand_from_bindings(
|
|
profile=profile,
|
|
bindings=binding_dicts,
|
|
command_override=item.command_override,
|
|
)
|
|
except ValueError as exc:
|
|
section["notes"].append(str(exc))
|
|
tmpl = normalize_command(
|
|
item.command_override or profile.command_template or ""
|
|
)
|
|
if tmpl:
|
|
section["commands"].append(
|
|
{
|
|
"command": tmpl,
|
|
"role": "template",
|
|
"params": {},
|
|
"profile_id": profile.profile_id,
|
|
}
|
|
)
|
|
if include_aux:
|
|
_append_aux_commands(section, profile, params={})
|
|
sections.append(section)
|
|
continue
|
|
|
|
if pairs and pairs[0][0] == EXPAND_ALL_COMMAND:
|
|
tmpl = normalize_command(profile.command_template)
|
|
section["notes"].append(
|
|
"expand_all: no bindings; collect will expand discover values"
|
|
)
|
|
if tmpl:
|
|
section["commands"].append(
|
|
{
|
|
"command": tmpl,
|
|
"role": "template",
|
|
"params": {"__expand_all__": "1"},
|
|
"profile_id": profile.profile_id,
|
|
}
|
|
)
|
|
if include_aux:
|
|
_append_aux_commands(section, profile, params={})
|
|
sections.append(section)
|
|
continue
|
|
|
|
for concrete, params in pairs:
|
|
cmd = normalize_command(concrete)
|
|
if not cmd:
|
|
continue
|
|
hit = match_command(vendor_key=vendor_key, command=cmd)
|
|
pid = str(
|
|
(hit.profile.profile_id if hit else profile.profile_id) or ""
|
|
).strip()
|
|
section["commands"].append(
|
|
{
|
|
"command": cmd,
|
|
"role": "primary",
|
|
"params": dict(params or {}),
|
|
"profile_id": pid,
|
|
}
|
|
)
|
|
if include_aux:
|
|
_append_aux_commands(
|
|
section,
|
|
hit.profile if hit else profile,
|
|
params=dict(params or {}),
|
|
)
|
|
|
|
# No concrete primary cmds → still show template as comment
|
|
if not any(c.get("role") == "primary" for c in section["commands"]):
|
|
tmpl = normalize_command(
|
|
item.command_override or profile.command_template or ""
|
|
)
|
|
if tmpl and not any(
|
|
c.get("role") == "template" and c.get("command") == tmpl
|
|
for c in section["commands"]
|
|
):
|
|
section["commands"].append(
|
|
{
|
|
"command": tmpl,
|
|
"role": "template",
|
|
"params": {},
|
|
"profile_id": profile.profile_id,
|
|
}
|
|
)
|
|
|
|
sections.append(section)
|
|
|
|
flat = _flat_unique_commands(sections)
|
|
return {
|
|
"task_id": task.id,
|
|
"ne_name": task.ne_name or "",
|
|
"ne_ip": task.ne_ip or "",
|
|
"vendor": task.vendor or "",
|
|
"device_type": task.device_type or "",
|
|
"command_count": len(flat),
|
|
"commands": flat,
|
|
"items": sections,
|
|
}
|
|
|
|
|
|
def export_task_commands_text(
|
|
db: Session,
|
|
task_id: str,
|
|
*,
|
|
enabled_only: bool = True,
|
|
include_aux: bool = True,
|
|
) -> str:
|
|
"""Plain-text export of planned collect commands (one CLI per line + section headers)."""
|
|
plan = plan_task_collect_commands(
|
|
db, task_id, enabled_only=enabled_only, include_aux=include_aux
|
|
)
|
|
lines = [
|
|
"# biz-state collect commands",
|
|
f"# task_id={plan['task_id']}",
|
|
f"# ne={plan['ne_name'] or '-'} ({plan['ne_ip'] or '-'})",
|
|
f"# vendor={plan['vendor'] or '-'} device_type={plan['device_type'] or '-'}",
|
|
f"# command_count={plan['command_count']}",
|
|
f"# include_aux={'1' if include_aux else '0'}",
|
|
f"# enabled_only={'1' if enabled_only else '0'}",
|
|
"",
|
|
]
|
|
for sec in plan["items"]:
|
|
title = str(sec.get("title") or sec.get("source_profile_id") or "item").strip()
|
|
pid = str(sec.get("source_profile_id") or "").strip()
|
|
header = f"## {title}"
|
|
if pid and pid not in title:
|
|
header = f"## {title} · {pid}"
|
|
lines.append(header)
|
|
for note in sec.get("notes") or []:
|
|
lines.append(f"# note: {note}")
|
|
cmds = list(sec.get("commands") or [])
|
|
if not cmds and not (sec.get("notes") or []):
|
|
lines.append("# (no commands)")
|
|
for c in cmds:
|
|
role = str(c.get("role") or "primary")
|
|
cmd = str(c.get("command") or "").strip()
|
|
if not cmd:
|
|
continue
|
|
if role == "aux":
|
|
lines.append(f"# aux:{c.get('aux_key') or ''}")
|
|
lines.append(cmd)
|
|
elif role == "template":
|
|
# No concrete CLI — keep template as a # comment line
|
|
lines.append(f"# {cmd}")
|
|
else:
|
|
lines.append(cmd)
|
|
lines.append("")
|
|
# Flat unique list at end for easy copy into scripts
|
|
lines.append("# ---- flat unique commands ----")
|
|
for cmd in plan["commands"]:
|
|
lines.append(str(cmd))
|
|
lines.append("")
|
|
return "\n".join(lines)
|