mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 06:30:44 +08:00
2690 lines
111 KiB
Python
2690 lines
111 KiB
Python
"""CRUD + evaluate for cutover migration monitor."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from collections import Counter
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
from uuid import uuid4
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import and_, insert, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..biz_state.compare_rules import apply_row_filters, scalar_text
|
||
from ..biz_state.compare_service import (
|
||
_load_metric_rows,
|
||
_port_map_dict,
|
||
sheet_key,
|
||
sheet_title,
|
||
template_metrics,
|
||
)
|
||
from ..models import (
|
||
BizCompareTemplate,
|
||
BizMigrationBatch,
|
||
BizMigrationDiff,
|
||
BizMigrationProject,
|
||
BizMigrationRedTicket,
|
||
BizMigrationRun,
|
||
BizMonitorTemplate,
|
||
BizPortMapping,
|
||
BizStateBatch,
|
||
BizStateBatchCommand,
|
||
BizStateTask,
|
||
)
|
||
from ..timeutil import utcnow_naive
|
||
from . import monitor_templates as mon_tpl
|
||
from .evaluate import (
|
||
PORT_METRIC_ID,
|
||
evaluate_metric_dual,
|
||
override_for_sheet,
|
||
parse_expect_set,
|
||
port_sheet_def,
|
||
)
|
||
|
||
PURPOSE_CUTOVER_HF = "cutover_hf"
|
||
MANUAL_ROUTE_METRICS = frozenset({"ip_route", "ipv6_route", "bgp_route"})
|
||
|
||
|
||
def _parse_dt(raw: Any) -> datetime | None:
|
||
"""Parse ISO datetime to naive UTC for DB storage.
|
||
|
||
Accepts ``Z`` / offset-aware ISO (preferred from clients) or naive ISO
|
||
(treated as already-UTC). Comparisons use :func:`utcnow_naive`.
|
||
"""
|
||
if raw is None or raw == "":
|
||
return None
|
||
if isinstance(raw, datetime):
|
||
if raw.tzinfo is not None:
|
||
return raw.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return raw
|
||
s = str(raw).strip()
|
||
if not s:
|
||
return None
|
||
try:
|
||
if s.endswith("Z"):
|
||
s = s[:-1] + "+00:00"
|
||
dt = datetime.fromisoformat(s)
|
||
if dt.tzinfo is not None:
|
||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return dt
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _dt_iso(dt: datetime | None) -> str | None:
|
||
"""Serialize naive-UTC datetime with explicit ``Z`` for global clients."""
|
||
if not dt:
|
||
return None
|
||
return dt.replace(tzinfo=None).isoformat() + "Z"
|
||
|
||
|
||
def _task_looks_like_hf(task: BizStateTask | None) -> bool:
|
||
"""Legacy recovery heuristic. Prefer purpose; note match only when purpose empty."""
|
||
if not task:
|
||
return False
|
||
purpose = str(getattr(task, "purpose", None) or "").strip()
|
||
if purpose == PURPOSE_CUTOVER_HF:
|
||
return True
|
||
if purpose:
|
||
return False
|
||
note = str(task.note or "")
|
||
return note.startswith("割接高频")
|
||
|
||
|
||
def _find_portrait_sibling(db: Session, hf_task: BizStateTask) -> BizStateTask | None:
|
||
"""Prefer a non-HF sibling task on the same NE (legacy recovery)."""
|
||
rows = (
|
||
db.query(BizStateTask)
|
||
.filter(
|
||
BizStateTask.source == hf_task.source,
|
||
BizStateTask.ne_id == hf_task.ne_id,
|
||
BizStateTask.id != hf_task.id,
|
||
)
|
||
.order_by(BizStateTask.created_at.asc())
|
||
.all()
|
||
)
|
||
for r in rows:
|
||
if not _task_looks_like_hf(r):
|
||
return r
|
||
return None
|
||
|
||
|
||
def _migrate_legacy_hf_slot(db: Session, proj: BizMigrationProject, side: str) -> None:
|
||
"""If portrait slot holds an HF task and hf slot empty, move HF id; restore portrait sibling if found."""
|
||
portrait_attr = f"{side}_task_id"
|
||
hf_attr = f"{side}_hf_task_id"
|
||
portrait_id = str(getattr(proj, portrait_attr, None) or "").strip()
|
||
hf_id = str(getattr(proj, hf_attr, None) or "").strip()
|
||
if hf_id or not portrait_id:
|
||
return
|
||
task = db.get(BizStateTask, portrait_id)
|
||
if not task or not _task_looks_like_hf(task):
|
||
return
|
||
setattr(proj, hf_attr, portrait_id)
|
||
sibling = _find_portrait_sibling(db, task)
|
||
if sibling:
|
||
setattr(proj, portrait_attr, sibling.id)
|
||
# else keep portrait_id pointing at HF until user rebinds — hf slot is correct
|
||
|
||
|
||
def migrate_project_hf_slots(db: Session, proj: BizMigrationProject, *, commit: bool = False) -> BizMigrationProject:
|
||
"""Idempotent legacy repair: split HF out of portrait slots when needed."""
|
||
before = (proj.old_task_id, proj.new_task_id, getattr(proj, "old_hf_task_id", None), getattr(proj, "new_hf_task_id", None))
|
||
_migrate_legacy_hf_slot(db, proj, "old")
|
||
_migrate_legacy_hf_slot(db, proj, "new")
|
||
after = (proj.old_task_id, proj.new_task_id, getattr(proj, "old_hf_task_id", None), getattr(proj, "new_hf_task_id", None))
|
||
if before != after:
|
||
proj.updated_at = utcnow_naive()
|
||
if commit:
|
||
db.commit()
|
||
db.refresh(proj)
|
||
return proj
|
||
|
||
|
||
def portrait_task_id(proj: BizMigrationProject, side: str) -> str:
|
||
return str(getattr(proj, f"{side}_task_id", None) or "").strip()
|
||
|
||
|
||
def hf_task_id(proj: BizMigrationProject, side: str) -> str:
|
||
return str(getattr(proj, f"{side}_hf_task_id", None) or "").strip()
|
||
|
||
|
||
def current_task_id(proj: BizMigrationProject, side: str) -> str:
|
||
"""Evaluate current batches: prefer HF, fall back to portrait."""
|
||
return hf_task_id(proj, side)
|
||
|
||
|
||
def _metric_interval_map(proj: BizMigrationProject) -> dict[str, int]:
|
||
raw = getattr(proj, "metric_interval_sec_json", None)
|
||
out: dict[str, int] = {}
|
||
if isinstance(raw, dict):
|
||
for k, v in raw.items():
|
||
mid = str(k).strip()
|
||
if not mid:
|
||
continue
|
||
try:
|
||
out[mid] = max(60, int(v))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def _normalize_metric_intervals(raw: Any) -> dict[str, int]:
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
out: dict[str, int] = {}
|
||
for k, v in raw.items():
|
||
mid = str(k).strip()
|
||
if not mid:
|
||
continue
|
||
try:
|
||
out[mid] = max(60, int(v))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
|
||
def _group_metrics_by_interval(
|
||
metric_ids: list[str],
|
||
*,
|
||
default_interval: int,
|
||
by_metric: dict[str, int],
|
||
) -> list[tuple[int, list[str]]]:
|
||
"""Group metrics by effective interval (ascending)."""
|
||
groups: dict[int, list[str]] = {}
|
||
default_iv = max(60, int(default_interval or 60))
|
||
for mid in metric_ids:
|
||
m = str(mid).strip()
|
||
if not m:
|
||
continue
|
||
iv = max(60, int(by_metric.get(m) or default_iv))
|
||
groups.setdefault(iv, []).append(m)
|
||
return sorted(((iv, mids) for iv, mids in groups.items()), key=lambda x: x[0])
|
||
|
||
|
||
def _hf_bindings(proj: BizMigrationProject, side: str) -> list[dict[str, Any]]:
|
||
attr = f"{side}_hf_bindings_json"
|
||
raw = getattr(proj, attr, None)
|
||
if isinstance(raw, list) and raw:
|
||
out: list[dict[str, Any]] = []
|
||
for row in raw:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
tid = str(row.get("task_id") or "").strip()
|
||
if not tid:
|
||
continue
|
||
mids = [str(x).strip() for x in (row.get("metric_ids") or []) if str(x).strip()]
|
||
try:
|
||
iv = max(60, int(row.get("interval_sec") or 60))
|
||
except (TypeError, ValueError):
|
||
iv = 60
|
||
out.append({"task_id": tid, "metric_ids": mids, "interval_sec": iv,
|
||
"collect_group_id": str(row.get("collect_group_id") or ""),
|
||
"manual_only": bool(row.get("manual_only"))})
|
||
if out:
|
||
return out
|
||
# Legacy single slot
|
||
tid = hf_task_id(proj, side)
|
||
if tid:
|
||
return [
|
||
{
|
||
"task_id": tid,
|
||
"metric_ids": _project_collect_override(proj),
|
||
"interval_sec": int(getattr(proj, "hf_interval_sec", None) or 60),
|
||
}
|
||
]
|
||
return []
|
||
|
||
|
||
def _all_hf_task_ids(proj: BizMigrationProject, side: str) -> list[str]:
|
||
seen: list[str] = []
|
||
for b in _hf_bindings(proj, side):
|
||
tid = str(b.get("task_id") or "").strip()
|
||
if tid and tid not in seen:
|
||
seen.append(tid)
|
||
return seen
|
||
|
||
|
||
def _hf_task_id_for_metric(proj: BizMigrationProject, side: str, metric_id: str) -> str:
|
||
mid = str(metric_id or "").strip()
|
||
for b in _hf_bindings(proj, side):
|
||
mids = b.get("metric_ids") or []
|
||
if mid and mid in mids:
|
||
return str(b.get("task_id") or "").strip()
|
||
if not mids:
|
||
# empty metric_ids = covers all for that binding
|
||
return str(b.get("task_id") or "").strip()
|
||
return hf_task_id(proj, side) or portrait_task_id(proj, side)
|
||
|
||
|
||
def _current_batch_for_metric(
|
||
db: Session,
|
||
proj: BizMigrationProject,
|
||
side: str,
|
||
metric_id: str,
|
||
*,
|
||
pinned_batch_id: str = "",
|
||
) -> BizStateBatch | None:
|
||
tid = _hf_task_id_for_metric(proj, side, metric_id)
|
||
pin = pinned_batch_id.strip()
|
||
if pin:
|
||
pinned = db.get(BizStateBatch, pin)
|
||
# Only honor pin when it belongs to this metric's HF task (multi-interval safe)
|
||
if pinned and (not tid or str(pinned.task_id or "") == tid):
|
||
# Pinned partial / failed metric → treat as missing (avoid false red)
|
||
if not _metric_sample_ok(db, pinned, metric_id):
|
||
return None
|
||
return pinned
|
||
batch = (
|
||
db.query(BizStateBatch)
|
||
.filter(BizStateBatch.task_id == tid, BizStateBatch.status.notin_(("running", "queued")))
|
||
.order_by(BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
|
||
) if tid else None
|
||
if batch and not _metric_sample_ok(db, batch, metric_id):
|
||
return None
|
||
return batch
|
||
|
||
|
||
def _metric_sample_ok(db: Session, batch: BizStateBatch, metric_id: str) -> bool:
|
||
"""An empty result requires a successful command receipt, just like a full one.
|
||
|
||
Batch success alone cannot prove this metric was collected. Unmatched and
|
||
skipped parsers are uncertainty, never evidence that a business disappeared.
|
||
"""
|
||
if batch.status not in ("success", "partial"):
|
||
return False
|
||
statuses = [str(s or "").lower() for (s,) in db.query(BizStateBatchCommand.parse_status)
|
||
.filter(BizStateBatchCommand.batch_id == batch.id,
|
||
BizStateBatchCommand.metric_id == metric_id).all()]
|
||
return bool(statuses) and all(s in ("ok", "ok_aux") for s in statuses)
|
||
|
||
|
||
def _current_pair_for_metric(db: Session, proj: BizMigrationProject, mid: str,
|
||
pin_old: str = "", pin_new: str = "") -> tuple[BizStateBatch | None, BizStateBatch | None, dict[str, Any]]:
|
||
old_tid, new_tid = (_hf_task_id_for_metric(proj, side, mid) for side in ("old", "new"))
|
||
old_task = db.get(BizStateTask, old_tid) if old_tid else None
|
||
new_task = db.get(BizStateTask, new_tid) if new_tid else None
|
||
gid = old_task.collect_group_id if old_task else ""
|
||
meta: dict[str, Any] = {"collect_group_id": gid, "manual_only": bool(old_task and old_task.collect_manual_only)}
|
||
if gid and not (pin_old or pin_new):
|
||
if not new_task or new_task.collect_group_id != gid:
|
||
return None, None, {**meta, "sample_issues": ["collect_pair_invalid"]}
|
||
latest = db.query(BizStateBatch).filter(BizStateBatch.collect_group_id == gid,
|
||
BizStateBatch.task_id.in_((old_tid, new_tid))).order_by(
|
||
BizStateBatch.queued_at.desc(), BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
|
||
peers = db.query(BizStateBatch).filter(BizStateBatch.collect_round_id == latest.collect_round_id,
|
||
BizStateBatch.task_id.in_((old_tid, new_tid))).all() if latest and latest.collect_round_id else []
|
||
meta.update({"collect_round_id": latest.collect_round_id if latest else "",
|
||
"old_collect_status": next((b.status for b in peers if b.task_id == old_tid), "missing"),
|
||
"new_collect_status": next((b.status for b in peers if b.task_id == new_tid), "missing")})
|
||
if len(peers) != 2:
|
||
return None, None, {**meta, "sample_issues": ["collect_pair_missing"]}
|
||
if any(b.status in ("running", "queued") for b in peers):
|
||
return None, None, {**meta, "sample_issues": ["collect_pair_pending"]}
|
||
by_task = {b.task_id: b for b in peers}
|
||
if not all(_metric_sample_ok(db, b, mid) for b in peers):
|
||
return None, None, {**meta, "sample_issues": ["collect_pair_failed"]}
|
||
return by_task[old_tid], by_task[new_tid], meta
|
||
old = _current_batch_for_metric(db, proj, "old", mid, pinned_batch_id=pin_old)
|
||
new = _current_batch_for_metric(db, proj, "new", mid, pinned_batch_id=pin_new)
|
||
if old and new and (old.collect_round_id or new.collect_round_id) and old.collect_round_id != new.collect_round_id:
|
||
return None, None, {**meta, "sample_issues": ["collect_round_mismatch"]}
|
||
return old, new, meta
|
||
|
||
|
||
def _sample_timing(proj: BizMigrationProject, mid: str, old: BizStateBatch,
|
||
new: BizStateBatch, *, check_age: bool) -> dict[str, Any]:
|
||
interval = max(60, _metric_interval_map(proj).get(mid) or proj.hf_interval_sec or 60)
|
||
old_at, new_at = old.ended_at or old.started_at, new.ended_at or new.started_at
|
||
durations = [max(0, ((b.ended_at or b.started_at) - b.started_at).total_seconds()) for b in (old, new)]
|
||
age_limit, skew_limit = interval * 3 + max(durations), interval * 2
|
||
reasons: list[str] = []
|
||
if not old_at or not new_at:
|
||
reasons.append("sample_time_missing")
|
||
skew = None
|
||
else:
|
||
skew = abs((old_at - new_at).total_seconds())
|
||
if skew > skew_limit:
|
||
reasons.append("sample_skew")
|
||
if check_age and max((utcnow_naive() - old_at).total_seconds(),
|
||
(utcnow_naive() - new_at).total_seconds()) > age_limit:
|
||
reasons.append("sample_stale")
|
||
start_skew = abs((old.started_at - new.started_at).total_seconds())
|
||
if old.collect_round_id and start_skew > min(30, interval / 2):
|
||
reasons.append("sample_start_skew")
|
||
return {"old_collected_at": _dt_iso(old_at), "new_collected_at": _dt_iso(new_at),
|
||
"age_limit_sec": age_limit, "skew_limit_sec": skew_limit,
|
||
"skew_sec": skew, "start_skew_sec": start_skew, "sample_issues": reasons,
|
||
"old_duration_sec": durations[0], "new_duration_sec": durations[1],
|
||
"old_queue_sec": max(0, (old.started_at - (old.queued_at or old.started_at)).total_seconds()),
|
||
"new_queue_sec": max(0, (new.started_at - (new.queued_at or new.started_at)).total_seconds())}
|
||
|
||
|
||
def find_portrait_task_for_ne(db: Session, *, source: str, ne_id: str) -> BizStateTask | None:
|
||
"""Best portrait (non-HF) biz_state task for an NE — for baseline binding."""
|
||
src = str(source or "managed").strip().lower() or "managed"
|
||
nid = str(ne_id or "").strip()
|
||
if not nid:
|
||
return None
|
||
rows = (
|
||
db.query(BizStateTask)
|
||
.filter(BizStateTask.source == src, BizStateTask.ne_id == nid)
|
||
.order_by(BizStateTask.created_at.asc())
|
||
.all()
|
||
)
|
||
for r in rows:
|
||
if not _task_looks_like_hf(r):
|
||
return r
|
||
return None
|
||
|
||
|
||
def list_ne_portrait_options(db: Session, *, source: str, ne_id: str, limit: int = 30) -> dict[str, Any]:
|
||
"""Portrait task + recent batches for baseline picker when NE is selected."""
|
||
task = find_portrait_task_for_ne(db, source=source, ne_id=ne_id)
|
||
if not task:
|
||
return {
|
||
"source": source,
|
||
"ne_id": ne_id,
|
||
"task": None,
|
||
"batches": [],
|
||
"hint": "no_portrait_task",
|
||
}
|
||
batches = (
|
||
db.query(BizStateBatch)
|
||
.filter(
|
||
BizStateBatch.task_id == task.id,
|
||
BizStateBatch.status.in_(("success", "partial")),
|
||
)
|
||
.order_by(BizStateBatch.started_at.desc())
|
||
.limit(max(1, min(100, int(limit or 30))))
|
||
.all()
|
||
)
|
||
return {
|
||
"source": source,
|
||
"ne_id": ne_id,
|
||
"task": _task_brief(db, task.id),
|
||
"batches": [_batch_brief(db, b.id) for b in batches],
|
||
"hint": "",
|
||
}
|
||
|
||
|
||
def _project_collect_override(proj: BizMigrationProject) -> list[str]:
|
||
raw = getattr(proj, "collect_metric_ids_json", None)
|
||
if isinstance(raw, list):
|
||
return [str(x).strip() for x in raw if str(x).strip()]
|
||
return []
|
||
|
||
|
||
def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
|
||
t = db.get(BizStateTask, task_id) if task_id else None
|
||
if not t:
|
||
return {
|
||
"id": task_id or "",
|
||
"ne_id": "",
|
||
"ne_name": "",
|
||
"ne_ip": "",
|
||
"vendor": "",
|
||
"purpose": "",
|
||
}
|
||
return {
|
||
"id": t.id,
|
||
"ne_id": t.ne_id or "",
|
||
"ne_name": t.ne_name,
|
||
"ne_ip": t.ne_ip,
|
||
"vendor": t.vendor,
|
||
"status": t.status,
|
||
"note": t.note,
|
||
"purpose": str(getattr(t, "purpose", None) or ""),
|
||
"interval_sec": t.interval_sec,
|
||
"collect_running": bool(t.collect_running),
|
||
}
|
||
|
||
|
||
def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
|
||
b = db.get(BizStateBatch, batch_id) if batch_id else None
|
||
if not b:
|
||
return {"id": batch_id or "", "status": "", "started_at": None, "alias": ""}
|
||
return {
|
||
"id": b.id,
|
||
"status": b.status,
|
||
"started_at": b.started_at.isoformat() if b.started_at else None,
|
||
"row_count": b.row_count,
|
||
"alias": str(getattr(b, "alias", "") or ""),
|
||
}
|
||
|
||
|
||
def _monitor_template_brief(db: Session, template_id: str) -> dict[str, Any]:
|
||
tid = str(template_id or "").strip()
|
||
if not tid:
|
||
return {
|
||
"id": "",
|
||
"name": "",
|
||
"compare_template_id": "",
|
||
"compare_template_name": "",
|
||
"collect_metric_ids": [],
|
||
"collect_metric_ids_effective": [],
|
||
}
|
||
row = db.get(BizMonitorTemplate, tid)
|
||
if not row:
|
||
return {
|
||
"id": tid,
|
||
"name": "",
|
||
"compare_template_id": "",
|
||
"compare_template_name": "",
|
||
"collect_metric_ids": [],
|
||
"collect_metric_ids_effective": [],
|
||
}
|
||
cmp_name = ""
|
||
if row.compare_template_id:
|
||
ct = db.get(BizCompareTemplate, row.compare_template_id)
|
||
cmp_name = (ct.name if ct else "") or ""
|
||
return mon_tpl._out(row, cmp_name, db=db) # noqa: SLF001
|
||
|
||
|
||
def resolve_project_monitor_template(db: Session, proj: BizMigrationProject) -> BizMonitorTemplate:
|
||
"""Return monitor template for project; seed default port template if unbound."""
|
||
mon_tpl.ensure_default_monitor_templates(db)
|
||
tid = str(getattr(proj, "monitor_template_id", None) or "").strip()
|
||
row = db.get(BizMonitorTemplate, tid) if tid else None
|
||
if row:
|
||
return row
|
||
default_id = mon_tpl.default_port_monitor_template_id(db)
|
||
row = db.get(BizMonitorTemplate, default_id) if default_id else None
|
||
if not row:
|
||
raise HTTPException(status_code=400, detail="monitor_template_required")
|
||
# Persist default on first use so UI shows binding
|
||
if not tid:
|
||
proj.monitor_template_id = row.id
|
||
proj.updated_at = utcnow_naive()
|
||
db.commit()
|
||
db.refresh(proj)
|
||
return row
|
||
|
||
|
||
def resolve_evaluate_sheets(
|
||
db: Session, mt: BizMonitorTemplate
|
||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any], list[dict[str, str]]]:
|
||
"""Return (sheets, sheet_overrides, defaults, iface_normalize_rules)."""
|
||
from ..biz_state.compare_service import template_iface_normalize
|
||
|
||
overrides = list(mt.sheet_overrides_json or []) if isinstance(mt.sheet_overrides_json, list) else []
|
||
defaults = dict(mt.defaults_json or {}) if isinstance(mt.defaults_json, dict) else {}
|
||
cid = str(mt.compare_template_id or "").strip()
|
||
if cid:
|
||
ct = db.get(BizCompareTemplate, cid)
|
||
if ct:
|
||
sheets = template_metrics(ct)
|
||
if sheets:
|
||
return sheets, overrides, defaults, template_iface_normalize(ct)
|
||
# Fallback: built-in port sheet (legacy)
|
||
return [port_sheet_def()], overrides, defaults, []
|
||
|
||
|
||
def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject) -> list[str]:
|
||
"""Project-level override wins; else monitor template; else sheet metric ids."""
|
||
override = _project_collect_override(proj)
|
||
if override:
|
||
return override
|
||
mt = resolve_project_monitor_template(db, proj)
|
||
collect = [str(x).strip() for x in (mt.collect_metric_ids_json or []) if str(x).strip()]
|
||
if collect:
|
||
return collect
|
||
sheets, _, _, _ = resolve_evaluate_sheets(db, mt)
|
||
seen: list[str] = []
|
||
for s in sheets:
|
||
mid = str(s.get("metric_id") or "").strip()
|
||
if mid and mid not in seen:
|
||
seen.append(mid)
|
||
return seen
|
||
|
||
|
||
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
|
||
"""Serialize project. Does not migrate slots (GET stays read-only)."""
|
||
collect_override = _project_collect_override(p)
|
||
mt_brief = _monitor_template_brief(db, getattr(p, "monitor_template_id", None) or "")
|
||
# Same resolution as ensure/evaluate (empty → compare sheets)
|
||
effective_collect = resolve_collect_metric_ids(db, p)
|
||
return {
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"old_task_id": p.old_task_id,
|
||
"new_task_id": p.new_task_id,
|
||
"old_hf_task_id": getattr(p, "old_hf_task_id", None) or "",
|
||
"new_hf_task_id": getattr(p, "new_hf_task_id", None) or "",
|
||
"old_hf_bindings": _hf_bindings(p, "old"),
|
||
"new_hf_bindings": _hf_bindings(p, "new"),
|
||
"old_baseline_batch_id": p.old_baseline_batch_id,
|
||
"new_baseline_batch_id": p.new_baseline_batch_id,
|
||
"mapping_id": p.mapping_id,
|
||
"monitor_template_id": getattr(p, "monitor_template_id", None) or "",
|
||
"monitor_template": mt_brief,
|
||
"collect_metric_ids": collect_override,
|
||
"collect_metric_ids_effective": effective_collect,
|
||
"metric_interval_sec": _metric_interval_map(p),
|
||
"hf_interval_sec": int(getattr(p, "hf_interval_sec", None) or 60),
|
||
"hf_start_at": _dt_iso(getattr(p, "hf_start_at", None)),
|
||
"hf_end_at": _dt_iso(getattr(p, "hf_end_at", None)),
|
||
"status": p.status,
|
||
"note": p.note,
|
||
"old_task": _task_brief(db, p.old_task_id),
|
||
"new_task": _task_brief(db, p.new_task_id),
|
||
"old_hf_task": _task_brief(db, getattr(p, "old_hf_task_id", None) or ""),
|
||
"new_hf_task": _task_brief(db, getattr(p, "new_hf_task_id", None) or ""),
|
||
"old_baseline": _batch_brief(db, p.old_baseline_batch_id),
|
||
"new_baseline": _batch_brief(db, p.new_baseline_batch_id),
|
||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||
}
|
||
|
||
|
||
def batch_to_dict(b: BizMigrationBatch) -> dict[str, Any]:
|
||
return {
|
||
"id": b.id,
|
||
"project_id": b.project_id,
|
||
"batch_label": b.batch_label,
|
||
"status": b.status,
|
||
"expect_set": dict(b.expect_set_json or {}),
|
||
"started_at": b.started_at.isoformat() if b.started_at else None,
|
||
"ended_at": b.ended_at.isoformat() if b.ended_at else None,
|
||
"accept_status": getattr(b, "accept_status", None) or "none",
|
||
"accept_run_id": getattr(b, "accept_run_id", None) or "",
|
||
"accept_summary": dict(getattr(b, "accept_summary_json", None) or {}),
|
||
"note": b.note,
|
||
"created_at": b.created_at.isoformat() if b.created_at else None,
|
||
"updated_at": b.updated_at.isoformat() if b.updated_at else None,
|
||
}
|
||
|
||
|
||
def list_projects(db: Session) -> list[dict[str, Any]]:
|
||
rows = db.query(BizMigrationProject).order_by(BizMigrationProject.created_at.desc()).all()
|
||
return [project_to_dict(db, p) for p in rows]
|
||
|
||
|
||
def create_project(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||
"""One-shot cutover project: NE + monitor template + metrics + schedule + baseline + mapping.
|
||
|
||
Auto-binds portrait tasks when found for the NEs (baselines come from them).
|
||
Always creates HF biz_state tasks from the monitor template collect set.
|
||
"""
|
||
name = str(body.get("name") or "").strip()
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="name_required")
|
||
|
||
old_ne = body.get("old_ne") if isinstance(body.get("old_ne"), dict) else None
|
||
new_ne = body.get("new_ne") if isinstance(body.get("new_ne"), dict) else None
|
||
old_task_id = str(body.get("old_task_id") or body.get("old_portrait_task_id") or "").strip()
|
||
new_task_id = str(body.get("new_task_id") or body.get("new_portrait_task_id") or "").strip()
|
||
|
||
# Auto-bind portrait by NE when not explicitly provided
|
||
if not old_task_id and old_ne and old_ne.get("ne_id"):
|
||
pt = find_portrait_task_for_ne(
|
||
db,
|
||
source=str(old_ne.get("source") or "managed"),
|
||
ne_id=str(old_ne.get("ne_id") or ""),
|
||
)
|
||
if pt:
|
||
old_task_id = pt.id
|
||
if not new_task_id and new_ne and new_ne.get("ne_id"):
|
||
pt = find_portrait_task_for_ne(
|
||
db,
|
||
source=str(new_ne.get("source") or "managed"),
|
||
ne_id=str(new_ne.get("ne_id") or ""),
|
||
)
|
||
if pt:
|
||
new_task_id = pt.id
|
||
|
||
if old_task_id and not db.get(BizStateTask, old_task_id):
|
||
raise HTTPException(status_code=404, detail="old_task_not_found")
|
||
if new_task_id and not db.get(BizStateTask, new_task_id):
|
||
raise HTTPException(status_code=404, detail="new_task_not_found")
|
||
|
||
has_old = bool(old_task_id or (old_ne and old_ne.get("ne_id")))
|
||
has_new = bool(new_task_id or (new_ne and new_ne.get("ne_id")))
|
||
if not has_old or not has_new:
|
||
raise HTTPException(status_code=400, detail="old_new_ne_required")
|
||
|
||
mapping_id = str(body.get("mapping_id") or "").strip()
|
||
if mapping_id and not db.get(BizPortMapping, mapping_id):
|
||
raise HTTPException(status_code=404, detail="mapping_not_found")
|
||
|
||
# Inline mapping rows → create BizPortMapping
|
||
mapping_rows = body.get("mapping_rows")
|
||
mapping_name = str(body.get("mapping_name") or "").strip()
|
||
if not mapping_id and isinstance(mapping_rows, list) and mapping_rows:
|
||
from ..biz_state import compare_service as cmp_svc
|
||
|
||
created_map = cmp_svc.create_mapping(
|
||
db,
|
||
{
|
||
"name": mapping_name or f"{name}-ports",
|
||
"rows": mapping_rows,
|
||
},
|
||
)
|
||
mapping_id = str(created_map.get("id") or "")
|
||
|
||
monitor_template_id = str(body.get("monitor_template_id") or "").strip()
|
||
if monitor_template_id:
|
||
if not db.get(BizMonitorTemplate, monitor_template_id):
|
||
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
||
else:
|
||
monitor_template_id = mon_tpl.default_port_monitor_template_id(db)
|
||
if not monitor_template_id:
|
||
raise HTTPException(status_code=400, detail="monitor_template_required")
|
||
|
||
collect_ids = body.get("collect_metric_ids")
|
||
if not isinstance(collect_ids, list):
|
||
collect_ids = []
|
||
collect_ids = [str(x).strip() for x in collect_ids if str(x).strip()]
|
||
|
||
metric_intervals = _normalize_metric_intervals(body.get("metric_interval_sec"))
|
||
hf_interval = max(60, int(body.get("hf_interval_sec") or body.get("interval_sec") or 60))
|
||
hf_start = _parse_dt(body.get("hf_start_at"))
|
||
hf_end = _parse_dt(body.get("hf_end_at"))
|
||
|
||
old_baseline = str(body.get("old_baseline_batch_id") or "").strip()
|
||
new_baseline = str(body.get("new_baseline_batch_id") or "").strip()
|
||
if old_baseline and not db.get(BizStateBatch, old_baseline):
|
||
raise HTTPException(status_code=404, detail="old_baseline_not_found")
|
||
if new_baseline and not db.get(BizStateBatch, new_baseline):
|
||
raise HTTPException(status_code=404, detail="new_baseline_not_found")
|
||
|
||
p = BizMigrationProject(
|
||
id=uuid4().hex,
|
||
name=name,
|
||
old_task_id=old_task_id,
|
||
new_task_id=new_task_id,
|
||
old_hf_task_id="",
|
||
new_hf_task_id="",
|
||
old_baseline_batch_id=old_baseline,
|
||
new_baseline_batch_id=new_baseline,
|
||
mapping_id=mapping_id,
|
||
monitor_template_id=monitor_template_id,
|
||
collect_metric_ids_json=collect_ids,
|
||
metric_interval_sec_json=metric_intervals,
|
||
old_hf_bindings_json=[],
|
||
new_hf_bindings_json=[],
|
||
hf_interval_sec=hf_interval,
|
||
hf_start_at=hf_start,
|
||
hf_end_at=hf_end,
|
||
status=str(body.get("status") or "active").strip() or "active",
|
||
note=str(body.get("note") or "")[:500],
|
||
)
|
||
db.add(p)
|
||
db.commit()
|
||
db.refresh(p)
|
||
|
||
# Always create HF from NE / portrait — this is the cutover collect path
|
||
ensure_highfreq(
|
||
db,
|
||
p.id,
|
||
interval_sec=hf_interval,
|
||
retention_days=int(body.get("retention_days") or 7),
|
||
collect_now=bool(body.get("collect_now", False)),
|
||
old_ne=old_ne,
|
||
new_ne=new_ne,
|
||
)
|
||
p = db.get(BizMigrationProject, p.id) or p
|
||
return project_to_dict(db, p)
|
||
|
||
|
||
def get_project(db: Session, project_id: str) -> dict[str, Any]:
|
||
p = db.get(BizMigrationProject, project_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
return project_to_dict(db, p)
|
||
|
||
|
||
def patch_project(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||
p = db.get(BizMigrationProject, project_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
# Validate incoming baseline ownership before mutating any project field.
|
||
for side in ("old", "new"):
|
||
bid = str(body.get(f"{side}_baseline_batch_id", getattr(p, f"{side}_baseline_batch_id")) or "").strip()
|
||
tid = str(body.get(f"{side}_task_id", getattr(p, f"{side}_task_id")) or "").strip()
|
||
if bid:
|
||
sample = db.get(BizStateBatch, bid)
|
||
if not sample or sample.task_id != tid or sample.status not in ("success", "partial"):
|
||
raise HTTPException(status_code=400, detail=f"{side}_baseline_invalid")
|
||
if "name" in body and body["name"] is not None:
|
||
p.name = str(body["name"]).strip() or p.name
|
||
if "note" in body and body["note"] is not None:
|
||
p.note = str(body["note"])[:500]
|
||
if "status" in body and body["status"] is not None:
|
||
p.status = str(body["status"]).strip() or p.status
|
||
if "mapping_id" in body and body["mapping_id"] is not None:
|
||
mid = str(body["mapping_id"] or "").strip()
|
||
if mid and not db.get(BizPortMapping, mid):
|
||
raise HTTPException(status_code=404, detail="mapping_not_found")
|
||
p.mapping_id = mid
|
||
if "old_baseline_batch_id" in body and body["old_baseline_batch_id"] is not None:
|
||
bid = str(body["old_baseline_batch_id"] or "").strip()
|
||
if bid and not db.get(BizStateBatch, bid):
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
p.old_baseline_batch_id = bid
|
||
if "new_baseline_batch_id" in body and body["new_baseline_batch_id"] is not None:
|
||
bid = str(body["new_baseline_batch_id"] or "").strip()
|
||
if bid and not db.get(BizStateBatch, bid):
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
p.new_baseline_batch_id = bid
|
||
if "monitor_template_id" in body and body["monitor_template_id"] is not None:
|
||
mid = str(body["monitor_template_id"] or "").strip()
|
||
if mid and not db.get(BizMonitorTemplate, mid):
|
||
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
||
p.monitor_template_id = mid
|
||
if "old_task_id" in body and body["old_task_id"] is not None:
|
||
tid = str(body["old_task_id"] or "").strip()
|
||
if tid and not db.get(BizStateTask, tid):
|
||
raise HTTPException(status_code=404, detail="task_not_found")
|
||
p.old_task_id = tid
|
||
if "new_task_id" in body and body["new_task_id"] is not None:
|
||
tid = str(body["new_task_id"] or "").strip()
|
||
if tid and not db.get(BizStateTask, tid):
|
||
raise HTTPException(status_code=404, detail="task_not_found")
|
||
p.new_task_id = tid
|
||
if "old_hf_task_id" in body and body["old_hf_task_id"] is not None:
|
||
tid = str(body["old_hf_task_id"] or "").strip()
|
||
if tid and not db.get(BizStateTask, tid):
|
||
raise HTTPException(status_code=404, detail="task_not_found")
|
||
p.old_hf_task_id = tid
|
||
if "new_hf_task_id" in body and body["new_hf_task_id"] is not None:
|
||
tid = str(body["new_hf_task_id"] or "").strip()
|
||
if tid and not db.get(BizStateTask, tid):
|
||
raise HTTPException(status_code=404, detail="task_not_found")
|
||
p.new_hf_task_id = tid
|
||
if "collect_metric_ids" in body and body["collect_metric_ids"] is not None:
|
||
raw = body["collect_metric_ids"]
|
||
if not isinstance(raw, list):
|
||
raise HTTPException(status_code=400, detail="collect_metric_ids_must_be_list")
|
||
p.collect_metric_ids_json = [str(x).strip() for x in raw if str(x).strip()]
|
||
if "metric_interval_sec" in body and body["metric_interval_sec"] is not None:
|
||
p.metric_interval_sec_json = _normalize_metric_intervals(body["metric_interval_sec"])
|
||
if "hf_interval_sec" in body and body["hf_interval_sec"] is not None:
|
||
p.hf_interval_sec = max(60, int(body["hf_interval_sec"] or 60))
|
||
if "hf_start_at" in body:
|
||
p.hf_start_at = _parse_dt(body.get("hf_start_at"))
|
||
if "hf_end_at" in body:
|
||
p.hf_end_at = _parse_dt(body.get("hf_end_at"))
|
||
sampling_changed = any(k in body for k in ("collect_metric_ids", "metric_interval_sec", "hf_interval_sec", "monitor_template_id"))
|
||
has_hf = bool(_all_hf_task_ids(p, "old") and _all_hf_task_ids(p, "new"))
|
||
if sampling_changed and has_hf:
|
||
# Validate newly selected commands before saving the project selection.
|
||
for side in ("old", "new"):
|
||
portrait = db.get(BizStateTask, portrait_task_id(p, side))
|
||
existing = db.get(BizStateTask, hf_task_id(p, side))
|
||
fields = _template_from_task_or_ne(db, portrait=portrait, existing_hf=existing, ne=None)
|
||
for mid in resolve_collect_metric_ids(db, p):
|
||
task = db.get(BizStateTask, _hf_task_id_for_metric(p, side, mid))
|
||
if (task and mid in _enabled_metric_ids(db, task.id)) or _portrait_metric_items(db, portrait, mid):
|
||
continue
|
||
_catalog_item_for_metric(vendor=fields["vendor"], device_type=fields["device_type"], metric_id=mid)
|
||
p.updated_at = utcnow_naive()
|
||
db.commit()
|
||
db.refresh(p)
|
||
# Status / schedule changes should stop or resume HF without waiting for scheduler tick
|
||
if any(k in body for k in ("status", "hf_start_at", "hf_end_at")):
|
||
_apply_hf_window_to_tasks(db, p)
|
||
db.refresh(p)
|
||
if sampling_changed and has_hf:
|
||
ensure_highfreq(db, p.id, collect_now=False)
|
||
db.refresh(p)
|
||
return project_to_dict(db, p)
|
||
|
||
|
||
def delete_project(db: Session, project_id: str) -> dict[str, Any]:
|
||
from ..biz_state import service as biz_svc
|
||
|
||
p = db.get(BizMigrationProject, project_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
# Stop cutover HF collects before dropping the project
|
||
for tid in _all_hf_task_ids(p, "old") + _all_hf_task_ids(p, "new"):
|
||
t = db.get(BizStateTask, tid)
|
||
if t and t.status == "running":
|
||
try:
|
||
biz_svc.update_task(db, tid, {"status": "paused"})
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
db.query(BizMigrationRedTicket).filter(BizMigrationRedTicket.project_id == project_id).delete()
|
||
batches = db.query(BizMigrationBatch).filter(BizMigrationBatch.project_id == project_id).all()
|
||
for b in batches:
|
||
runs = db.query(BizMigrationRun).filter(BizMigrationRun.batch_id == b.id).all()
|
||
for r in runs:
|
||
db.query(BizMigrationDiff).filter(BizMigrationDiff.run_id == r.id).delete()
|
||
db.delete(r)
|
||
db.delete(b)
|
||
db.delete(p)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
def list_batches(db: Session, project_id: str) -> list[dict[str, Any]]:
|
||
if not db.get(BizMigrationProject, project_id):
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
rows = (
|
||
db.query(BizMigrationBatch)
|
||
.filter(BizMigrationBatch.project_id == project_id)
|
||
.order_by(BizMigrationBatch.created_at.desc())
|
||
.all()
|
||
)
|
||
return [batch_to_dict(b) for b in rows]
|
||
|
||
|
||
def create_batch(db: Session, project_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||
if not db.get(BizMigrationProject, project_id):
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
label = str(body.get("batch_label") or "").strip() or "batch"
|
||
expect = body.get("expect_set") if isinstance(body.get("expect_set"), dict) else {}
|
||
status = str(body.get("status") or "pending").strip()
|
||
_validate_batch_status(db, project_id, "", status)
|
||
b = BizMigrationBatch(
|
||
id=uuid4().hex,
|
||
project_id=project_id,
|
||
batch_label=label,
|
||
status=status,
|
||
expect_set_json=dict(expect or {}),
|
||
note=str(body.get("note") or "")[:500],
|
||
)
|
||
db.add(b)
|
||
db.commit()
|
||
db.refresh(b)
|
||
out = batch_to_dict(b)
|
||
out["open_red_count"] = count_open_red_tickets(db, project_id)
|
||
return out
|
||
|
||
|
||
def _latest_success_batch(db: Session, task_id: str) -> BizStateBatch | None:
|
||
"""Latest fully successful batch only — never use ``partial`` as current.
|
||
|
||
Partial batches omit failed-command metrics and cause mass false ``removed``.
|
||
"""
|
||
return (
|
||
db.query(BizStateBatch)
|
||
.filter(
|
||
BizStateBatch.task_id == task_id,
|
||
BizStateBatch.status == "success",
|
||
)
|
||
.order_by(BizStateBatch.started_at.desc())
|
||
.first()
|
||
)
|
||
|
||
|
||
def pinned_baseline_batch_ids(db: Session) -> set[str]:
|
||
"""Batch IDs that must survive retention purge."""
|
||
ids: set[str] = set()
|
||
for p in db.query(BizMigrationProject).all():
|
||
if p.old_baseline_batch_id:
|
||
ids.add(p.old_baseline_batch_id)
|
||
if p.new_baseline_batch_id:
|
||
ids.add(p.new_baseline_batch_id)
|
||
return ids
|
||
|
||
|
||
def run_evaluate(
|
||
db: Session,
|
||
*,
|
||
batch_id: str,
|
||
old_batch_id: str = "",
|
||
new_batch_id: str = "",
|
||
acceptance: bool = False,
|
||
purpose: str = "manual",
|
||
_commit: bool = True,
|
||
_metric_ids: set[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
mb = db.get(BizMigrationBatch, batch_id)
|
||
if not mb:
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
# PostgreSQL serializes evaluate/finish for this project across API workers.
|
||
proj = db.query(BizMigrationProject).filter(BizMigrationProject.id == mb.project_id).with_for_update().populate_existing().first()
|
||
if not proj:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
db.refresh(mb)
|
||
if acceptance and mb.status not in ("active", "review"):
|
||
raise HTTPException(status_code=409, detail="monitor_window_closed")
|
||
if purpose == "auto" and (mb.status != "active" or proj.status == "done"):
|
||
raise HTTPException(status_code=409, detail="monitor_window_closed")
|
||
if not proj.old_baseline_batch_id:
|
||
raise HTTPException(status_code=400, detail="old_baseline_required")
|
||
if not proj.new_baseline_batch_id:
|
||
raise HTTPException(status_code=400, detail="new_baseline_required")
|
||
for side in ("old", "new"):
|
||
baseline = db.get(BizStateBatch, getattr(proj, f"{side}_baseline_batch_id"))
|
||
if not baseline or baseline.task_id != portrait_task_id(proj, side):
|
||
raise HTTPException(status_code=400, detail=f"{side}_baseline_task_mismatch")
|
||
if baseline.status not in ("success", "partial"):
|
||
raise HTTPException(status_code=400, detail=f"{side}_baseline_not_complete")
|
||
pin = (old_batch_id if side == "old" else new_batch_id).strip()
|
||
if pin:
|
||
pinned = db.get(BizStateBatch, pin)
|
||
if not pinned or pinned.task_id not in _all_hf_task_ids(proj, side):
|
||
raise HTTPException(status_code=400, detail=f"{side}_current_batch_task_mismatch")
|
||
|
||
old_batch = db.get(BizStateBatch, old_batch_id.strip()) if old_batch_id.strip() else None
|
||
new_batch = db.get(BizStateBatch, new_batch_id.strip()) if new_batch_id.strip() else None
|
||
# Primary current batch ids (for run record / pin UX); per-metric may differ when multi-HF
|
||
if not old_batch:
|
||
for tid in _all_hf_task_ids(proj, "old") or [current_task_id(proj, "old")]:
|
||
old_batch = db.query(BizStateBatch).filter(BizStateBatch.task_id == tid).order_by(
|
||
BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
|
||
if old_batch:
|
||
break
|
||
if not new_batch:
|
||
for tid in _all_hf_task_ids(proj, "new") or [current_task_id(proj, "new")]:
|
||
new_batch = db.query(BizStateBatch).filter(BizStateBatch.task_id == tid).order_by(
|
||
BizStateBatch.started_at.desc(), BizStateBatch.id.desc()).first()
|
||
if new_batch:
|
||
break
|
||
if not old_batch:
|
||
raise HTTPException(status_code=400, detail="old_current_batch_required")
|
||
if not new_batch:
|
||
raise HTTPException(status_code=400, detail="new_current_batch_required")
|
||
old_cur = old_batch.id
|
||
new_cur = new_batch.id
|
||
pin_old = old_batch_id.strip()
|
||
pin_new = new_batch_id.strip()
|
||
|
||
port_map = _port_map_dict(db, proj.mapping_id)
|
||
if proj.mapping_id and not db.get(BizPortMapping, proj.mapping_id):
|
||
raise HTTPException(status_code=400, detail="mapping_missing")
|
||
if len(set(port_map.values())) != len(port_map):
|
||
raise HTTPException(status_code=400, detail="mapping_target_ambiguous")
|
||
expect = parse_expect_set(mb.expect_set_json if isinstance(mb.expect_set_json, dict) else {})
|
||
previous_expect: dict[str, set[str]] = {}
|
||
for prior in db.query(BizMigrationBatch).filter(
|
||
BizMigrationBatch.project_id == proj.id, BizMigrationBatch.id != mb.id,
|
||
BizMigrationBatch.status.in_(("review", "done")),
|
||
BizMigrationBatch.created_at <= mb.created_at,
|
||
).all():
|
||
for mid, keys in parse_expect_set(prior.expect_set_json).items():
|
||
previous_expect.setdefault(mid, set()).update(keys)
|
||
# Final acceptance: window closed → unfinished expect = red
|
||
window_active = (mb.status == "active") and (not acceptance)
|
||
# Evaluations never seed or commit default configuration inside the lock.
|
||
mt = db.get(BizMonitorTemplate, proj.monitor_template_id) if proj.monitor_template_id else None
|
||
if not mt:
|
||
raise HTTPException(status_code=400, detail="monitor_template_missing")
|
||
if mt.compare_template_id and not db.get(BizCompareTemplate, mt.compare_template_id):
|
||
raise HTTPException(status_code=400, detail="compare_template_missing")
|
||
sheets, sheet_overrides, defaults, iface_norm = resolve_evaluate_sheets(db, mt)
|
||
from .validation import validate_monitor_rules
|
||
|
||
validate_monitor_rules(defaults, sheet_overrides)
|
||
from ..biz_state.compare_validation import validate_template_body
|
||
|
||
validate_template_body({"metrics": sheets, "iface_normalize_rules": iface_norm})
|
||
from ..biz_state.iface_normalize import normalize_iface_name
|
||
|
||
normalized_map = {normalize_iface_name(k, iface_norm): normalize_iface_name(v, iface_norm)
|
||
for k, v in port_map.items()}
|
||
if len(normalized_map) != len(port_map) or len(set(normalized_map.values())) != len(normalized_map):
|
||
raise HTTPException(status_code=400, detail="mapping_target_ambiguous")
|
||
port_map = normalized_map
|
||
known_sheets = {sheet_key(s) for s in sheets}
|
||
known_metrics = {str(s.get("metric_id") or "") for s in sheets}
|
||
for override in sheet_overrides:
|
||
if (override.get("sheet_id") and override["sheet_id"] not in known_sheets) or (
|
||
not override.get("sheet_id") and override.get("metric_id") not in known_metrics
|
||
):
|
||
raise HTTPException(status_code=400, detail="monitor_override_sheet_missing")
|
||
out_of_expect = str((defaults or {}).get("out_of_expect") or "strict").strip().lower()
|
||
collect_ids = set(resolve_collect_metric_ids(db, proj))
|
||
|
||
sheet_cards: list[dict[str, Any]] = []
|
||
all_rows: list[dict[str, Any]] = []
|
||
metric_batches: dict[str, dict[str, str]] = {}
|
||
seq = 0
|
||
rows_cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||
command_cache: dict[str, dict[str, Any]] = {}
|
||
enabled_sheets = [s for s in sheets if str(s.get("metric_id") or "") in collect_ids and not
|
||
override_for_sheet(sheet_overrides, sheet_id=sheet_key(s), metric_id=s.get("metric_id", "")).get("skip_dual")]
|
||
scope_ids = {str(s.get("metric_id") or "") for s in enabled_sheets} | {sheet_key(s) for s in enabled_sheets}
|
||
if any(s.get("iface_fields") for s in enabled_sheets):
|
||
scope_ids.add("_ports")
|
||
uncovered_scope = sorted({k for scope in (expect, previous_expect) for k, values in scope.items()
|
||
if values and k not in scope_ids})
|
||
config_snapshot = {"sheets": sheets, "sheet_overrides": sheet_overrides,
|
||
"defaults": defaults, "iface_normalize": iface_norm, "port_map": port_map,
|
||
"expect_set": mb.expect_set_json, "previous_expect": {k: sorted(v) for k, v in previous_expect.items()},
|
||
"baseline_ids": [proj.old_baseline_batch_id, proj.new_baseline_batch_id],
|
||
"collect_metric_ids": sorted(collect_ids), "window_active": window_active,
|
||
"bindings": [_hf_bindings(proj, "old"), _hf_bindings(proj, "new")]}
|
||
prior_run = db.query(BizMigrationRun).filter(BizMigrationRun.batch_id == batch_id).order_by(
|
||
BizMigrationRun.created_at.desc()).first() if _metric_ids is not None else None
|
||
reusable = (prior_run.summary_json or {}).get("config_snapshot") == config_snapshot if prior_run else False
|
||
prior_cards = {sheet_key(c): c for c in (prior_run.summary_json or {}).get("sheet_cards", [])} if reusable else {}
|
||
pair_cache: dict[str, tuple[BizStateBatch | None, BizStateBatch | None, dict[str, Any]]] = {}
|
||
|
||
def metric_rows(bid: str, mid: str) -> list[dict[str, Any]]:
|
||
key = (bid, mid)
|
||
if key not in rows_cache:
|
||
rows_cache[key] = _load_metric_rows(db, batch_id=bid, metric_id=mid)
|
||
return rows_cache[key]
|
||
|
||
for sheet in sheets:
|
||
mid = str(sheet.get("metric_id") or "").strip()
|
||
sid = sheet_key(sheet)
|
||
key_fields = list(sheet.get("key_fields") or [])
|
||
if not mid or not key_fields:
|
||
continue
|
||
iface_fields = list(sheet.get("iface_fields") or [])
|
||
compare_fields = list(sheet.get("compare_fields") or [])
|
||
row_filters = list(sheet.get("row_filters") or [])
|
||
field_rules = list(sheet.get("field_rules") or [])
|
||
sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
|
||
if sheet_ov.get("skip_dual"):
|
||
sheet_cards.append({"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
|
||
"progress_ok": 0, "progress_total": 0, "anomaly": 0,
|
||
"collect_skipped": True})
|
||
continue
|
||
|
||
# Metric not in HF collect set → skip dual eval (avoid false red)
|
||
if collect_ids and mid not in collect_ids:
|
||
sheet_cards.append(
|
||
{
|
||
"metric_id": mid,
|
||
"sheet_id": sid,
|
||
"title": sheet_title(sheet),
|
||
"progress_ok": 0,
|
||
"progress_total": 0,
|
||
"anomaly": 0,
|
||
"anomaly_in_expect": 0,
|
||
"old_summary": {},
|
||
"new_summary": {},
|
||
"new_baseline_mode": "skipped",
|
||
"new_baseline_missing": False,
|
||
"collect_skipped": True,
|
||
}
|
||
)
|
||
continue
|
||
|
||
if _metric_ids is not None and mid not in _metric_ids and sid in prior_cards:
|
||
cached = {**prior_cards[sid], "source_run_id": prior_cards[sid].get("source_run_id") or prior_run.id}
|
||
times = [_parse_dt(cached.get(k)) for k in ("old_collected_at", "new_collected_at")]
|
||
if any(at and (utcnow_naive() - at).total_seconds() > cached.get("age_limit_sec", 180) for at in times):
|
||
cached.update({"current_missing": True, "sample_issues": ["sample_stale"]})
|
||
sheet_cards.append(cached)
|
||
metric_batches[mid] = (prior_run.summary_json or {}).get("metric_batches", {}).get(mid, {})
|
||
continue
|
||
if mid not in pair_cache:
|
||
pair_cache[mid] = _current_pair_for_metric(db, proj, mid, pin_old, pin_new)
|
||
old_cur_batch, new_cur_batch, pair_meta = pair_cache[mid]
|
||
# Do NOT fall back to another HF task's batch — empty/wrong metric → false red
|
||
if not old_cur_batch or not new_cur_batch:
|
||
sheet_cards.append(
|
||
{
|
||
"metric_id": mid,
|
||
"sheet_id": sid,
|
||
"title": sheet_title(sheet),
|
||
"progress_ok": 0,
|
||
"progress_total": 0,
|
||
"anomaly": 0,
|
||
"anomaly_in_expect": 0,
|
||
"old_summary": {},
|
||
"new_summary": {},
|
||
"new_baseline_mode": "missing_current",
|
||
"new_baseline_missing": False,
|
||
"current_missing": True,
|
||
"collect_incomplete": True,
|
||
"collect_skipped": False,
|
||
**pair_meta,
|
||
}
|
||
)
|
||
continue
|
||
old_cur_mid = old_cur_batch.id
|
||
new_cur_mid = new_cur_batch.id
|
||
timing = {**pair_meta, **_sample_timing(proj, mid, old_cur_batch, new_cur_batch,
|
||
check_age=acceptance or not (pin_old and pin_new))}
|
||
baseline_missing = [side for side in ("old", "new") if not _metric_sample_ok(
|
||
db, db.get(BizStateBatch, getattr(proj, f"{side}_baseline_batch_id")), mid)]
|
||
if timing["sample_issues"] or baseline_missing:
|
||
sheet_cards.append({"metric_id": mid, "sheet_id": sid, "title": sheet_title(sheet),
|
||
"progress_ok": 0, "progress_total": 0, "anomaly": 0,
|
||
"current_missing": True, "collect_incomplete": True,
|
||
"baseline_missing_sides": baseline_missing, **timing})
|
||
continue
|
||
|
||
old_base = apply_row_filters(
|
||
metric_rows(proj.old_baseline_batch_id, mid),
|
||
row_filters,
|
||
)
|
||
old_now = apply_row_filters(
|
||
metric_rows(old_cur_mid, mid),
|
||
row_filters,
|
||
)
|
||
new_base_rows = apply_row_filters(
|
||
metric_rows(proj.new_baseline_batch_id, mid),
|
||
row_filters,
|
||
)
|
||
new_now = apply_row_filters(
|
||
metric_rows(new_cur_mid, mid),
|
||
row_filters,
|
||
)
|
||
|
||
one = evaluate_metric_dual(
|
||
metric_id=mid,
|
||
sheet_id=sid,
|
||
key_fields=key_fields,
|
||
iface_fields=iface_fields,
|
||
compare_fields=compare_fields,
|
||
old_baseline_rows=old_base,
|
||
old_current_rows=old_now,
|
||
new_baseline_rows=new_base_rows,
|
||
new_current_rows=new_now,
|
||
port_map=port_map if iface_fields else {},
|
||
expect=expect,
|
||
window_active=window_active,
|
||
acceptance=acceptance,
|
||
field_rules=field_rules,
|
||
sheet_override=sheet_ov,
|
||
out_of_expect=out_of_expect,
|
||
iface_normalize_rules=iface_norm,
|
||
previous_expect=previous_expect,
|
||
allow_mapping_scope=False,
|
||
)
|
||
# A target snapshot taken before the source's disappearance cannot prove
|
||
# loss. Wait for the next target receipt; final acceptance stays blocked.
|
||
awaiting = 0
|
||
if (new_cur_batch.ended_at or new_cur_batch.started_at) < old_cur_batch.started_at:
|
||
for row in one["rows"]:
|
||
if row["in_expect"] and row["rule_hit"] == "service_not_received":
|
||
row.update(verdict="awaiting_peer", color="gray", rule_hit="target_sample_predates_source")
|
||
one["anomaly"] -= 1
|
||
one["anomaly_in_expect"] -= 1
|
||
awaiting += 1
|
||
old_tid = _hf_task_id_for_metric(proj, "old", mid) or current_task_id(proj, "old")
|
||
new_tid = _hf_task_id_for_metric(proj, "new", mid) or current_task_id(proj, "new")
|
||
old_task_brief = _task_brief(db, old_tid)
|
||
new_task_brief = _task_brief(db, new_tid)
|
||
metric_batches[mid] = {
|
||
"old_batch_id": old_cur_mid,
|
||
"new_batch_id": new_cur_mid,
|
||
"old_task_id": old_tid,
|
||
"new_task_id": new_tid,
|
||
"old_status": old_cur_batch.status,
|
||
"new_status": new_cur_batch.status,
|
||
**timing,
|
||
}
|
||
sheet_cards.append(
|
||
{
|
||
"metric_id": mid,
|
||
"sheet_id": sid,
|
||
"title": sheet_title(sheet),
|
||
"progress_ok": one["progress_ok"],
|
||
"progress_total": one["progress_total"],
|
||
"anomaly": one["anomaly"],
|
||
"anomaly_in_expect": int(one.get("anomaly_in_expect") or 0),
|
||
"steady_outside": int(one.get("steady_outside") or 0),
|
||
"old_summary": one["old_summary"],
|
||
"new_summary": one["new_summary"],
|
||
"new_baseline_mode": one.get("new_baseline_mode") or "provided",
|
||
"new_baseline_missing": bool(one.get("new_baseline_missing")),
|
||
"collect_skipped": False,
|
||
"old_batch_id": old_cur_mid,
|
||
"new_batch_id": new_cur_mid,
|
||
"duplicate_key_list": list(one.get("duplicate_key_list") or []),
|
||
"duplicate_keys_before": int(one.get("duplicate_keys_before") or 0),
|
||
"duplicate_keys_after": int(one.get("duplicate_keys_after") or 0),
|
||
**timing,
|
||
"awaiting_peer": awaiting,
|
||
"verdict_counts": dict(Counter(r["verdict"] for r in one["rows"] if r.get("verdict"))),
|
||
"expected_count": sum(bool(r.get("in_expect")) for r in one["rows"]),
|
||
}
|
||
)
|
||
for r in one["rows"]:
|
||
_enrich_row_evidence(
|
||
db,
|
||
r,
|
||
old_batch_id=old_cur_mid,
|
||
new_batch_id=new_cur_mid,
|
||
old_task=old_task_brief,
|
||
new_task=new_task_brief,
|
||
command_cache=command_cache,
|
||
)
|
||
r["seq"] = seq
|
||
r["sheet_id"] = sid
|
||
seq += 1
|
||
all_rows.append(r)
|
||
|
||
verdict_counts = {}
|
||
for card in sheet_cards:
|
||
if not card.get("current_missing"):
|
||
for verdict, count in card.get("verdict_counts", {}).items():
|
||
verdict_counts[verdict] = verdict_counts.get(verdict, 0) + count
|
||
active_cards = [
|
||
c for c in sheet_cards if not c.get("collect_skipped") and not c.get("current_missing")
|
||
]
|
||
run = BizMigrationRun(
|
||
id=uuid4().hex,
|
||
project_id=proj.id,
|
||
batch_id=mb.id,
|
||
old_batch_id=old_cur,
|
||
new_batch_id=new_cur,
|
||
purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32],
|
||
status="success",
|
||
summary_json={
|
||
"metric_focus": sheets[0].get("metric_id") if sheets else PORT_METRIC_ID,
|
||
"monitor_template_id": mt.id,
|
||
"compare_template_id": mt.compare_template_id or "",
|
||
"acceptance": acceptance,
|
||
"metric_batches": metric_batches,
|
||
"sheet_cards": sheet_cards,
|
||
"progress": {
|
||
"ok": sum(c["progress_ok"] for c in active_cards),
|
||
"total": sum(c["progress_total"] for c in active_cards),
|
||
},
|
||
"anomaly": sum(c["anomaly"] for c in active_cards),
|
||
"new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in active_cards),
|
||
"missing_metrics": [
|
||
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
|
||
for c in active_cards
|
||
if c.get("new_baseline_missing")
|
||
],
|
||
"current_missing_metrics": [
|
||
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
|
||
for c in sheet_cards
|
||
if c.get("current_missing")
|
||
],
|
||
"collect_skipped_metrics": [
|
||
str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
|
||
for c in sheet_cards
|
||
if c.get("collect_skipped")
|
||
],
|
||
"verdict_counts": verdict_counts,
|
||
"anomaly_outside_expect": sum(int(c.get("anomaly") or 0) - int(c.get("anomaly_in_expect") or 0) for c in active_cards),
|
||
"steady_outside": sum(int(c.get("steady_outside") or 0) for c in active_cards),
|
||
"duplicate_keys": sum(int(c.get("duplicate_keys_before") or 0) + int(c.get("duplicate_keys_after") or 0) for c in active_cards),
|
||
"uncovered_scope": uncovered_scope,
|
||
"coverage_complete": bool(enabled_sheets) and not uncovered_scope and not any(c.get("current_missing") or c.get("awaiting_peer") or c.get("duplicate_keys_before") or c.get("duplicate_keys_after") for c in sheet_cards if not c.get("collect_skipped")),
|
||
"config_snapshot": config_snapshot,
|
||
"window_active": window_active,
|
||
"expect_ports": sorted(expect.get("_ports") or ()),
|
||
"old_current_task_id": current_task_id(proj, "old"),
|
||
"new_current_task_id": current_task_id(proj, "new"),
|
||
},
|
||
message="",
|
||
)
|
||
db.add(run)
|
||
db.flush()
|
||
for card in sheet_cards:
|
||
card.setdefault("source_run_id", run.id)
|
||
run.summary_json = {**run.summary_json, "sheet_cards": sheet_cards}
|
||
pending_diffs: list[dict[str, Any]] = []
|
||
for r in all_rows:
|
||
key_list = r.get("key") or []
|
||
ev = r.get("evidence") if isinstance(r.get("evidence"), dict) else {}
|
||
pm = ev.get("port_map") if isinstance(ev.get("port_map"), dict) else {}
|
||
search = " ".join(
|
||
[
|
||
str(r.get("old_key") or r.get("key_str") or ""),
|
||
str(r.get("new_key") or r.get("new_key_str") or ""),
|
||
str(r.get("match_old_key") or ""),
|
||
str(r.get("match_new_key") or ""),
|
||
str(pm.get("match_before") or ""),
|
||
str(pm.get("match_after") or ""),
|
||
str(r.get("verdict") or ""),
|
||
str(r.get("old_status") or ""),
|
||
str(r.get("new_status") or ""),
|
||
str(r.get("metric_id") or ""),
|
||
]
|
||
)
|
||
pending_diffs.append(
|
||
dict(
|
||
id=uuid4().hex,
|
||
run_id=run.id,
|
||
metric_id=str(r.get("metric_id") or ""),
|
||
seq=int(r.get("seq") or 0),
|
||
verdict=str(r.get("verdict") or ""),
|
||
color=str(r.get("color") or ""),
|
||
key_json={
|
||
"key": key_list,
|
||
"old_key": r.get("old_key") or r.get("key_str"),
|
||
"new_key": r.get("new_key") or r.get("new_key_str"),
|
||
"key_str": r.get("old_key") or r.get("key_str"),
|
||
"new_key_str": r.get("new_key") or r.get("new_key_str"),
|
||
"match_old_key": r.get("match_old_key") or "",
|
||
"match_new_key": r.get("match_new_key") or "",
|
||
"old_status": r.get("old_status"),
|
||
"new_status": r.get("new_status"),
|
||
"rule_hit": r.get("rule_hit") or "",
|
||
"integrity_changes": r.get("integrity_changes") or [],
|
||
"previous_batch": bool(r.get("previous_batch")),
|
||
"sheet_id": r.get("sheet_id") or "",
|
||
"evidence": r.get("evidence") or {},
|
||
},
|
||
old_kind=str(r.get("old_kind") or ""),
|
||
new_kind=str(r.get("new_kind") or ""),
|
||
old_json=dict(r.get("old") or {}),
|
||
new_json=dict(r.get("new") or {}),
|
||
in_expect=bool(r.get("in_expect")),
|
||
search_text=search[:2000],
|
||
)
|
||
)
|
||
if len(pending_diffs) >= 500:
|
||
db.execute(insert(BizMigrationDiff), pending_diffs)
|
||
pending_diffs.clear()
|
||
if pending_diffs:
|
||
db.execute(insert(BizMigrationDiff), pending_diffs)
|
||
db.flush()
|
||
if _commit:
|
||
db.commit()
|
||
db.refresh(run)
|
||
return run_to_dict(db, run)
|
||
|
||
|
||
def run_to_dict(db: Session, run: BizMigrationRun, *, include_diffs: bool = False) -> dict[str, Any]:
|
||
out: dict[str, Any] = {
|
||
"id": run.id,
|
||
"project_id": run.project_id,
|
||
"batch_id": run.batch_id,
|
||
"old_batch_id": run.old_batch_id,
|
||
"new_batch_id": run.new_batch_id,
|
||
"purpose": getattr(run, "purpose", None) or "manual",
|
||
"status": run.status,
|
||
"summary": dict(run.summary_json or {}),
|
||
"message": run.message,
|
||
"created_at": run.created_at.isoformat() if run.created_at else None,
|
||
"old_batch": _batch_brief(db, run.old_batch_id),
|
||
"new_batch": _batch_brief(db, run.new_batch_id),
|
||
}
|
||
if include_diffs:
|
||
diffs = (
|
||
db.query(BizMigrationDiff)
|
||
.filter(BizMigrationDiff.run_id == run.id)
|
||
.order_by(BizMigrationDiff.seq.asc())
|
||
.limit(5000)
|
||
.all()
|
||
)
|
||
out["diffs"] = [diff_to_dict(d) for d in diffs]
|
||
return out
|
||
|
||
|
||
def _command_brief(db: Session, command_id: str) -> dict[str, Any]:
|
||
from ..models import BizStateBatchCommand
|
||
|
||
cid = str(command_id or "").strip()
|
||
if not cid:
|
||
return {}
|
||
# Select metadata only: raw CLI output can be several MB per command.
|
||
c = db.query(BizStateBatchCommand.id, BizStateBatchCommand.raw_command,
|
||
BizStateBatchCommand.parse_status, BizStateBatchCommand.row_count,
|
||
BizStateBatchCommand.profile_id, BizStateBatchCommand.parser_id,
|
||
BizStateBatchCommand.metric_id, BizStateBatchCommand.message).filter(
|
||
BizStateBatchCommand.id == cid).first()
|
||
if not c:
|
||
return {"command_id": cid}
|
||
return {
|
||
"command_id": c.id,
|
||
"raw_command": c.raw_command or "",
|
||
"parse_status": c.parse_status or "",
|
||
"row_count": int(c.row_count or 0),
|
||
"profile_id": c.profile_id or "",
|
||
"parser_id": c.parser_id or "",
|
||
"metric_id": c.metric_id or "",
|
||
"message": (c.message or "")[:300],
|
||
}
|
||
|
||
|
||
def _enrich_row_evidence(
|
||
db: Session,
|
||
row: dict[str, Any],
|
||
*,
|
||
old_batch_id: str,
|
||
new_batch_id: str,
|
||
old_task: dict[str, Any] | None,
|
||
new_task: dict[str, Any] | None,
|
||
command_cache: dict[str, dict[str, Any]] | None = None,
|
||
) -> None:
|
||
"""Attach device / collect / show-command onto evaluate evidence (in-place)."""
|
||
ev = dict(row.get("evidence") or {})
|
||
for side, brief, batch_id in (
|
||
("old", old_task or {}, old_batch_id),
|
||
("new", new_task or {}, new_batch_id),
|
||
):
|
||
side_ev = dict(ev.get(side) or {})
|
||
netx = dict(side_ev.get("netx") or {})
|
||
cid = str(netx.get("batch_command_id") or "")
|
||
cache = command_cache if command_cache is not None else {}
|
||
if cid not in cache:
|
||
cache[cid] = _command_brief(db, cid)
|
||
cmd = cache[cid]
|
||
side_ev["device"] = {
|
||
"ne_id": str(brief.get("ne_id") or netx.get("ne_id") or ""),
|
||
"ne_name": str(brief.get("ne_name") or ""),
|
||
"ne_ip": str(brief.get("ne_ip") or ""),
|
||
"side": side,
|
||
}
|
||
# Prefer the metric row's own batch (baseline vs current); fall back to
|
||
# the side's evaluate batch id used for this sheet.
|
||
side_ev["collect"] = {
|
||
"task_id": str(netx.get("task_id") or brief.get("id") or ""),
|
||
"batch_id": str(netx.get("batch_id") or batch_id or ""),
|
||
"side_batch_id": str(batch_id or ""),
|
||
"collected_at": netx.get("collected_at"),
|
||
"parse_status": str(cmd.get("parse_status") or ""),
|
||
}
|
||
side_ev["command"] = cmd
|
||
ev[side] = side_ev
|
||
row["evidence"] = ev
|
||
|
||
|
||
def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
|
||
kj = d.key_json if isinstance(d.key_json, dict) else {}
|
||
old_key = str(kj.get("old_key") or kj.get("key_str") or "")
|
||
new_key = str(kj.get("new_key") or kj.get("new_key_str") or "")
|
||
return {
|
||
"id": d.id,
|
||
"metric_id": d.metric_id,
|
||
"sheet_id": kj.get("sheet_id") or d.metric_id or "",
|
||
"seq": d.seq,
|
||
"verdict": d.verdict,
|
||
"color": d.color,
|
||
"key": kj,
|
||
"old_key": old_key,
|
||
"new_key": new_key,
|
||
"key_str": old_key,
|
||
"new_key_str": new_key,
|
||
"match_old_key": str(kj.get("match_old_key") or ""),
|
||
"match_new_key": str(kj.get("match_new_key") or ""),
|
||
"old_status": kj.get("old_status") or "",
|
||
"new_status": kj.get("new_status") or "",
|
||
"rule_hit": kj.get("rule_hit") or "",
|
||
"integrity_changes": kj.get("integrity_changes") or [],
|
||
"previous_batch": bool(kj.get("previous_batch")),
|
||
"evidence": dict(kj.get("evidence") or {}),
|
||
"old_kind": d.old_kind,
|
||
"new_kind": d.new_kind,
|
||
"old": d.old_json,
|
||
"new": d.new_json,
|
||
"in_expect": d.in_expect,
|
||
}
|
||
|
||
|
||
def list_baseline_ports(db: Session, project_id: str) -> dict[str, Any]:
|
||
"""List interface names from project old baseline for expect-set picking."""
|
||
p = db.get(BizMigrationProject, project_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
if not p.old_baseline_batch_id:
|
||
return {"batch_id": "", "ports": [], "mapped": {}}
|
||
rows = _load_metric_rows(
|
||
db, batch_id=p.old_baseline_batch_id, metric_id=PORT_METRIC_ID
|
||
)
|
||
port_map = _port_map_dict(db, p.mapping_id)
|
||
ports: list[dict[str, Any]] = []
|
||
for r in rows:
|
||
name = str(r.get("interface") or "").strip()
|
||
if not name:
|
||
continue
|
||
ports.append(
|
||
{
|
||
"interface": name,
|
||
"admin": r.get("admin") or "",
|
||
"phy": r.get("phy") or "",
|
||
"prot": r.get("prot") or "",
|
||
"description": r.get("description") or "",
|
||
"mapped_to": port_map.get(name) or "",
|
||
}
|
||
)
|
||
ports.sort(key=lambda x: str(x["interface"]))
|
||
return {
|
||
"batch_id": p.old_baseline_batch_id,
|
||
"metric_id": PORT_METRIC_ID,
|
||
"ports": ports,
|
||
"mapped": port_map,
|
||
}
|
||
|
||
|
||
def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]:
|
||
"""Per-sheet baseline keys for multi-metric expect picking."""
|
||
p = db.get(BizMigrationProject, project_id)
|
||
if not p:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
if not p.old_baseline_batch_id:
|
||
return {"batch_id": "", "sheets": [], "mapped": {}}
|
||
mt = resolve_project_monitor_template(db, p)
|
||
sheets, sheet_overrides, _defaults, _norm = resolve_evaluate_sheets(db, mt)
|
||
collect_ids = set(resolve_collect_metric_ids(db, p))
|
||
port_map = _port_map_dict(db, p.mapping_id)
|
||
out_sheets: list[dict[str, Any]] = []
|
||
from ..biz_state.iface_normalize import apply_iface_normalize, resolve_mapped_iface, normalize_iface_name
|
||
from .evaluate import KEY_SEP
|
||
|
||
rows_cache: dict[str, list[dict[str, Any]]] = {}
|
||
port_map = {normalize_iface_name(k, _norm): normalize_iface_name(v, _norm) for k, v in port_map.items()}
|
||
for sheet in sheets:
|
||
mid = str(sheet.get("metric_id") or "").strip()
|
||
sid = sheet_key(sheet)
|
||
key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()]
|
||
if not mid or not key_fields:
|
||
continue
|
||
# Only offer expect keys for metrics that are actually HF-collected
|
||
if collect_ids and mid not in collect_ids:
|
||
continue
|
||
if mid in MANUAL_ROUTE_METRICS:
|
||
# Never enumerate million-row route tables into batch scope pickers.
|
||
continue
|
||
sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
|
||
if sheet_ov.get("skip_dual"):
|
||
continue
|
||
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
|
||
if mid not in rows_cache:
|
||
rows_cache[mid] = _load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid)
|
||
rows_raw = apply_row_filters(
|
||
rows_cache[mid],
|
||
list(sheet.get("row_filters") or []),
|
||
)
|
||
items: list[dict[str, Any]] = []
|
||
seen_keys: set[str] = set()
|
||
for r in rows_raw:
|
||
normalized = apply_iface_normalize(dict(r), iface_fields=iface_fields, rules=_norm)
|
||
keys = [scalar_text(normalized.get(f)).strip() for f in key_fields]
|
||
if not any(keys):
|
||
continue
|
||
key_str = KEY_SEP.join(keys)
|
||
if key_str in seen_keys:
|
||
continue
|
||
seen_keys.add(key_str)
|
||
mapped = ""
|
||
if iface_fields and key_fields and key_fields[0] in iface_fields:
|
||
mapped = resolve_mapped_iface(keys[0], port_map) if port_map else ""
|
||
items.append(
|
||
{
|
||
"key": key_str,
|
||
"keys": keys,
|
||
"mapped_to": mapped,
|
||
"label": "|".join(scalar_text(r.get(f)).strip() for f in key_fields),
|
||
"row": {f: r.get(f) for f in list(dict.fromkeys([*key_fields, *iface_fields, "description", "admin", "phy", "prot"])) if f in r},
|
||
}
|
||
)
|
||
items.sort(key=lambda x: str(x["key"]))
|
||
out_sheets.append(
|
||
{
|
||
"metric_id": mid,
|
||
"sheet_id": sid,
|
||
"title": sheet_title(sheet),
|
||
"key_fields": key_fields,
|
||
"iface_fields": iface_fields,
|
||
"items": items,
|
||
}
|
||
)
|
||
return {
|
||
"batch_id": p.old_baseline_batch_id,
|
||
"monitor_template_id": mt.id,
|
||
"sheets": out_sheets,
|
||
"mapped": port_map,
|
||
}
|
||
|
||
|
||
def _catalog_item_for_metric(*, vendor: str, device_type: str, metric_id: str) -> dict[str, Any]:
|
||
from ..biz_state.profiles import profiles_for_vendor
|
||
from ..lldp_shared import resolve_vendor_key
|
||
|
||
mid = str(metric_id or "").strip()
|
||
vkey = resolve_vendor_key(vendor, device_type)
|
||
candidates = [p for p in profiles_for_vendor(vkey) if p.metric_id == mid and p.kind == "collect"]
|
||
for p in sorted(candidates, key=lambda p: bool(p.placeholders)):
|
||
if not any(getattr(ph, "required", True) for ph in (p.placeholders or [])):
|
||
return {
|
||
"source_profile_id": p.profile_id,
|
||
"kind": "catalog",
|
||
"enabled": True,
|
||
"title": p.title or mid,
|
||
}
|
||
if candidates:
|
||
ph = ",".join(x.name for x in candidates[0].placeholders if getattr(x, "required", True))
|
||
raise HTTPException(status_code=400, detail=f"metric_needs_bindings:{mid}:{ph}")
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"no_profile_for_metric:{mid}:{vkey or vendor or 'unknown'}",
|
||
)
|
||
|
||
|
||
def _iface_brief_item(*, vendor: str, device_type: str) -> dict[str, Any]:
|
||
return _catalog_item_for_metric(vendor=vendor, device_type=device_type, metric_id=PORT_METRIC_ID)
|
||
|
||
|
||
def _enabled_metric_ids(db: Session, task_id: str) -> set[str]:
|
||
from ..biz_state.profiles import get_profile
|
||
from ..models import BizStateTaskItem
|
||
|
||
out: set[str] = set()
|
||
items = (
|
||
db.query(BizStateTaskItem)
|
||
.filter(BizStateTaskItem.task_id == task_id, BizStateTaskItem.enabled.is_(True))
|
||
.all()
|
||
)
|
||
for it in items:
|
||
if it.kind == "custom_raw":
|
||
out.add("__custom__")
|
||
continue
|
||
p = get_profile(str(it.source_profile_id or ""))
|
||
if p and p.metric_id:
|
||
out.add(p.metric_id)
|
||
return out
|
||
|
||
|
||
def _is_highfreq_task(db: Session, task: BizStateTask, want_metrics: set[str]) -> bool:
|
||
"""True when task is cutover HF with matching metrics (or legacy short-interval match)."""
|
||
if str(getattr(task, "purpose", None) or "").strip() == PURPOSE_CUTOVER_HF:
|
||
metrics = _enabled_metric_ids(db, task.id)
|
||
return metrics == set(want_metrics)
|
||
metrics = _enabled_metric_ids(db, task.id)
|
||
return metrics == set(want_metrics) and int(task.interval_sec or 0) <= 300
|
||
|
||
|
||
def _is_port_highfreq_task(db: Session, task: BizStateTask) -> bool:
|
||
"""Back-compat: interface_brief-only HF."""
|
||
return _is_highfreq_task(db, task, {PORT_METRIC_ID})
|
||
|
||
|
||
def _ne_spec_to_template_fields(db: Session, ne: dict[str, Any]) -> dict[str, Any]:
|
||
"""Resolve NE dict into fields suitable for create_task."""
|
||
from ..biz_state import service as biz_svc
|
||
|
||
source = str(ne.get("source") or "managed").strip().lower() or "managed"
|
||
ne_id = str(ne.get("ne_id") or "").strip()
|
||
if not ne_id:
|
||
raise HTTPException(status_code=400, detail="ne_id_required")
|
||
meta = biz_svc._ne_meta(db, source=source, ne_id=ne_id) # noqa: SLF001
|
||
return {
|
||
"source": source,
|
||
"ne_id": ne_id,
|
||
"ne_name": str(ne.get("ne_name") or meta.get("ne_name") or ""),
|
||
"ne_ip": str(ne.get("ne_ip") or meta.get("ne_ip") or ""),
|
||
"vendor": str(ne.get("vendor") or meta.get("vendor") or ""),
|
||
"device_type": str(ne.get("device_type") or meta.get("device_type") or ""),
|
||
}
|
||
|
||
|
||
def _template_from_task_or_ne(
|
||
db: Session,
|
||
*,
|
||
portrait: BizStateTask | None,
|
||
existing_hf: BizStateTask | None,
|
||
ne: dict[str, Any] | None,
|
||
) -> dict[str, Any]:
|
||
"""Build NE identity for HF create: prefer portrait, else existing HF, else NE spec."""
|
||
if portrait and not _task_looks_like_hf(portrait):
|
||
return {
|
||
"source": portrait.source,
|
||
"ne_id": portrait.ne_id,
|
||
"ne_name": portrait.ne_name,
|
||
"ne_ip": portrait.ne_ip,
|
||
"vendor": portrait.vendor,
|
||
"device_type": portrait.device_type,
|
||
}
|
||
if existing_hf:
|
||
return {
|
||
"source": existing_hf.source,
|
||
"ne_id": existing_hf.ne_id,
|
||
"ne_name": existing_hf.ne_name,
|
||
"ne_ip": existing_hf.ne_ip,
|
||
"vendor": existing_hf.vendor,
|
||
"device_type": existing_hf.device_type,
|
||
}
|
||
if portrait:
|
||
return {
|
||
"source": portrait.source,
|
||
"ne_id": portrait.ne_id,
|
||
"ne_name": portrait.ne_name,
|
||
"ne_ip": portrait.ne_ip,
|
||
"vendor": portrait.vendor,
|
||
"device_type": portrait.device_type,
|
||
}
|
||
if ne:
|
||
return _ne_spec_to_template_fields(db, ne)
|
||
raise HTTPException(status_code=400, detail="old_new_ne_or_task_required")
|
||
|
||
|
||
def _portrait_metric_items(db: Session, portrait: BizStateTask | None, mid: str) -> list[dict[str, Any]]:
|
||
"""Preserve CLI variants and bindings already used by the device baseline."""
|
||
from ..biz_state.profiles import get_profile
|
||
from ..models import BizStateTaskItem, BizStateTaskItemBinding
|
||
|
||
if not portrait:
|
||
return []
|
||
out = []
|
||
for item in db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == portrait.id,
|
||
BizStateTaskItem.enabled.is_(True)).order_by(BizStateTaskItem.sort_order).all():
|
||
profile = get_profile(item.source_profile_id) if item.kind == "catalog" else None
|
||
if not profile or profile.metric_id != mid:
|
||
continue
|
||
bindings = db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item.id).all()
|
||
out.append({"source_profile_id": item.source_profile_id, "kind": item.kind,
|
||
"enabled": True, "title": item.title, "command_override": item.command_override,
|
||
"bindings": [{"placeholder": b.placeholder, "value": b.value} for b in bindings]})
|
||
return out
|
||
|
||
|
||
def _ensure_side_highfreq(
|
||
db: Session,
|
||
*,
|
||
existing_hf: BizStateTask | None,
|
||
template_fields: dict[str, Any],
|
||
project_name: str,
|
||
interval_sec: int,
|
||
retention_days: int,
|
||
metric_ids: list[str],
|
||
status: str = "running",
|
||
configured_items: list[dict[str, Any]] | None = None,
|
||
) -> tuple[BizStateTask, bool]:
|
||
"""Return (hf_task, created). Reuse existing HF if metrics match; never mutate portrait."""
|
||
from ..biz_state import service as biz_svc
|
||
|
||
want = {str(m).strip() for m in metric_ids if str(m).strip()}
|
||
if not want:
|
||
want = {PORT_METRIC_ID}
|
||
if existing_hf and existing_hf.collect_running and _enabled_metric_ids(db, existing_hf.id) != want:
|
||
# A running collection owns its item configuration until completion.
|
||
existing_hf = None
|
||
|
||
if existing_hf and _is_highfreq_task(db, existing_hf, want):
|
||
patch: dict[str, Any] = {
|
||
"interval_sec": interval_sec,
|
||
"retention_days": retention_days,
|
||
"purpose": PURPOSE_CUTOVER_HF,
|
||
}
|
||
if status:
|
||
patch["status"] = status
|
||
biz_svc.update_task(db, existing_hf.id, patch)
|
||
refreshed = db.get(BizStateTask, existing_hf.id)
|
||
return refreshed or existing_hf, False
|
||
|
||
# Existing HF (purpose already cutover_hf) with wrong metrics → update items in place.
|
||
# Never rewrite a task that only "looks like" HF by note — create a sibling instead.
|
||
if existing_hf and str(getattr(existing_hf, "purpose", None) or "").strip() == PURPOSE_CUTOVER_HF:
|
||
items = configured_items or [
|
||
_catalog_item_for_metric(
|
||
vendor=str(template_fields.get("vendor") or existing_hf.vendor),
|
||
device_type=str(template_fields.get("device_type") or existing_hf.device_type),
|
||
metric_id=mid,
|
||
)
|
||
for mid in sorted(want)
|
||
]
|
||
note = f"割接高频/{'+'.join(sorted(want)[:3])}/{project_name}"[:256]
|
||
biz_svc.update_task(
|
||
db,
|
||
existing_hf.id,
|
||
{
|
||
"note": note,
|
||
"purpose": PURPOSE_CUTOVER_HF,
|
||
"status": status or "running",
|
||
"interval_sec": interval_sec,
|
||
"retention_days": retention_days,
|
||
"items": items,
|
||
},
|
||
)
|
||
refreshed = db.get(BizStateTask, existing_hf.id)
|
||
return refreshed or existing_hf, False
|
||
|
||
items = configured_items or [
|
||
_catalog_item_for_metric(
|
||
vendor=str(template_fields.get("vendor") or ""),
|
||
device_type=str(template_fields.get("device_type") or ""),
|
||
metric_id=mid,
|
||
)
|
||
for mid in sorted(want)
|
||
]
|
||
note = f"割接高频/{'+'.join(sorted(want)[:3])}/{project_name}"[:256]
|
||
created = biz_svc.create_task(
|
||
db,
|
||
{
|
||
"source": template_fields["source"],
|
||
"ne_id": template_fields["ne_id"],
|
||
"ne_name": template_fields.get("ne_name") or "",
|
||
"ne_ip": template_fields.get("ne_ip") or "",
|
||
"vendor": template_fields.get("vendor") or "",
|
||
"device_type": template_fields.get("device_type") or "",
|
||
"note": note,
|
||
"purpose": PURPOSE_CUTOVER_HF,
|
||
"status": status or "running",
|
||
"interval_sec": interval_sec,
|
||
"retention_days": retention_days,
|
||
"items": items,
|
||
},
|
||
)
|
||
task = db.get(BizStateTask, str(created.get("id") or ""))
|
||
if not task:
|
||
raise HTTPException(status_code=500, detail="highfreq_task_create_failed")
|
||
return task, True
|
||
|
||
|
||
def _hf_window_status(proj: BizMigrationProject) -> str:
|
||
"""Return desired HF task status based on project window: running|paused."""
|
||
status = str(getattr(proj, "status", None) or "").strip().lower()
|
||
if status in ("done", "completed", "archived", "cancelled"):
|
||
return "paused"
|
||
now = utcnow_naive()
|
||
start = getattr(proj, "hf_start_at", None)
|
||
end = getattr(proj, "hf_end_at", None)
|
||
if end and now > end:
|
||
return "paused"
|
||
if start and now < start:
|
||
return "paused"
|
||
return "running"
|
||
|
||
|
||
def _apply_hf_window_to_tasks(db: Session, proj: BizMigrationProject) -> None:
|
||
"""Immediately pause/resume bound cutover HF tasks to match project window/status."""
|
||
import logging
|
||
|
||
from ..biz_state import service as biz_svc
|
||
|
||
log = logging.getLogger("netx.biz_migration.hf_window")
|
||
window_status = _hf_window_status(proj)
|
||
for tid in _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new"):
|
||
task = db.get(BizStateTask, tid)
|
||
if not task:
|
||
continue
|
||
want = "paused" if task.collect_manual_only else window_status
|
||
purpose = str(getattr(task, "purpose", None) or "").strip()
|
||
if purpose and purpose != PURPOSE_CUTOVER_HF:
|
||
continue
|
||
patch: dict[str, Any] = {}
|
||
# Legacy empty-purpose HF: stamp purpose so resume skips bindings assert
|
||
if purpose != PURPOSE_CUTOVER_HF:
|
||
patch["purpose"] = PURPOSE_CUTOVER_HF
|
||
if want == "paused" and task.status == "running":
|
||
patch["status"] = "paused"
|
||
elif want == "running" and task.status == "paused":
|
||
patch["status"] = "running"
|
||
if not patch:
|
||
continue
|
||
try:
|
||
biz_svc.update_task(db, tid, patch)
|
||
except Exception: # noqa: BLE001
|
||
log.exception("hf window sync failed project=%s task=%s want=%s", proj.id, tid, want)
|
||
|
||
|
||
def ensure_port_highfreq(
|
||
db: Session,
|
||
project_id: str,
|
||
*,
|
||
interval_sec: int = 60,
|
||
retention_days: int = 7,
|
||
collect_now: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""Create/bind high-freq biz_state tasks for old/new NEs using monitor collect_metric_ids."""
|
||
return ensure_highfreq(
|
||
db,
|
||
project_id,
|
||
interval_sec=interval_sec,
|
||
retention_days=retention_days,
|
||
collect_now=collect_now,
|
||
)
|
||
|
||
|
||
def ensure_highfreq(
|
||
db: Session,
|
||
project_id: str,
|
||
*,
|
||
interval_sec: int | None = None,
|
||
retention_days: int = 7,
|
||
collect_now: bool = True,
|
||
old_ne: dict[str, Any] | None = None,
|
||
new_ne: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""One task per metric/device, paired by project; route tables are manual-only."""
|
||
proj = db.get(BizMigrationProject, project_id)
|
||
if not proj:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
migrate_project_hf_slots(db, proj, commit=False)
|
||
|
||
if interval_sec is not None:
|
||
proj.hf_interval_sec = max(60, int(interval_sec))
|
||
iv_default = max(60, int(getattr(proj, "hf_interval_sec", None) or interval_sec or 60))
|
||
ret = max(1, int(retention_days or 7))
|
||
metric_ids = resolve_collect_metric_ids(db, proj)
|
||
if not metric_ids:
|
||
metric_ids = [PORT_METRIC_ID]
|
||
by_metric = _metric_interval_map(proj)
|
||
groups = [(max(60, by_metric.get(mid) or iv_default), [mid]) for mid in metric_ids]
|
||
want_status = _hf_window_status(proj)
|
||
|
||
old_portrait = db.get(BizStateTask, proj.old_task_id) if proj.old_task_id else None
|
||
new_portrait = db.get(BizStateTask, proj.new_task_id) if proj.new_task_id else None
|
||
|
||
# Reuse a single-metric binding; a legacy combined task can be assigned
|
||
# once, while the remaining metrics receive independent sibling tasks.
|
||
def _existing_by_metric(side: str) -> dict[str, BizStateTask]:
|
||
out: dict[str, BizStateTask] = {}
|
||
for binding in _hf_bindings(proj, side):
|
||
task = db.get(BizStateTask, binding["task_id"])
|
||
if task:
|
||
mids = binding.get("metric_ids") or metric_ids
|
||
for mid in mids:
|
||
if mid in metric_ids:
|
||
out.setdefault(mid, task)
|
||
break
|
||
return out
|
||
|
||
old_existing = _existing_by_metric("old")
|
||
new_existing = _existing_by_metric("new")
|
||
old_prev_ids = set(_all_hf_task_ids(proj, "old"))
|
||
new_prev_ids = set(_all_hf_task_ids(proj, "new"))
|
||
|
||
old_fields = _template_from_task_or_ne(
|
||
db,
|
||
portrait=old_portrait,
|
||
existing_hf=next(iter(old_existing.values()), None),
|
||
ne=old_ne,
|
||
)
|
||
new_fields = _template_from_task_or_ne(
|
||
db,
|
||
portrait=new_portrait,
|
||
existing_hf=next(iter(new_existing.values()), None),
|
||
ne=new_ne,
|
||
)
|
||
|
||
old_bindings: list[dict[str, Any]] = []
|
||
new_bindings: list[dict[str, Any]] = []
|
||
old_any_created = False
|
||
new_any_created = False
|
||
|
||
for g_iv, g_metrics in groups:
|
||
mid = g_metrics[0]
|
||
gid = "cutover:" + proj.id + ":" + hashlib.sha256(mid.encode()).hexdigest()[:16]
|
||
old_candidate, new_candidate = old_existing.get(mid), new_existing.get(mid)
|
||
if old_candidate and old_candidate.collect_group_id not in ("", gid):
|
||
old_candidate = None
|
||
if new_candidate and new_candidate.collect_group_id not in ("", gid):
|
||
new_candidate = None
|
||
old_task, old_created = _ensure_side_highfreq(
|
||
db,
|
||
existing_hf=old_candidate,
|
||
template_fields=old_fields,
|
||
project_name=proj.name,
|
||
interval_sec=g_iv,
|
||
retention_days=ret,
|
||
metric_ids=g_metrics,
|
||
status="paused",
|
||
configured_items=_portrait_metric_items(db, old_portrait, mid),
|
||
)
|
||
new_task, new_created = _ensure_side_highfreq(
|
||
db,
|
||
existing_hf=new_candidate,
|
||
template_fields=new_fields,
|
||
project_name=proj.name,
|
||
interval_sec=g_iv,
|
||
retention_days=ret,
|
||
metric_ids=g_metrics,
|
||
status="paused",
|
||
configured_items=_portrait_metric_items(db, new_portrait, mid),
|
||
)
|
||
if old_task.ne_id == new_task.ne_id:
|
||
raise HTTPException(status_code=400, detail="collect_pair_same_device")
|
||
for task in (old_task, new_task):
|
||
task.collect_group_id = gid
|
||
task.collect_manual_only = mid in MANUAL_ROUTE_METRICS
|
||
task.status = "paused" if task.collect_manual_only else want_status
|
||
db.commit()
|
||
old_any_created = old_any_created or old_created
|
||
new_any_created = new_any_created or new_created
|
||
old_bindings.append(
|
||
{"task_id": old_task.id, "metric_ids": list(g_metrics), "interval_sec": g_iv,
|
||
"collect_group_id": gid, "manual_only": mid in MANUAL_ROUTE_METRICS}
|
||
)
|
||
new_bindings.append(
|
||
{"task_id": new_task.id, "metric_ids": list(g_metrics), "interval_sec": g_iv,
|
||
"collect_group_id": gid, "manual_only": mid in MANUAL_ROUTE_METRICS}
|
||
)
|
||
|
||
proj = db.get(BizMigrationProject, project_id)
|
||
if not proj:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
proj.old_hf_bindings_json = old_bindings
|
||
proj.new_hf_bindings_json = new_bindings
|
||
proj.old_hf_task_id = old_bindings[0]["task_id"] if old_bindings else ""
|
||
proj.new_hf_task_id = new_bindings[0]["task_id"] if new_bindings else ""
|
||
proj.hf_interval_sec = iv_default
|
||
proj.updated_at = utcnow_naive()
|
||
db.commit()
|
||
|
||
# Pause HF tasks dropped from bindings (interval regroup / metric shrink)
|
||
from ..biz_state import service as biz_svc
|
||
|
||
keep_ids = {b["task_id"] for b in old_bindings} | {b["task_id"] for b in new_bindings}
|
||
for tid in (old_prev_ids | new_prev_ids) - keep_ids:
|
||
t = db.get(BizStateTask, tid)
|
||
if not t:
|
||
continue
|
||
if str(getattr(t, "purpose", None) or "").strip() not in ("", PURPOSE_CUTOVER_HF):
|
||
continue
|
||
if t.collect_group_id and not t.collect_group_id.startswith("cutover:" + proj.id + ":"):
|
||
continue
|
||
t.collect_group_id = ""
|
||
t.collect_manual_only = False
|
||
db.commit()
|
||
if t.status == "running":
|
||
try:
|
||
biz_svc.update_task(db, tid, {"status": "paused"})
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
if t.collect_running:
|
||
from ..biz_state.collect_stop import request_stop_collect
|
||
|
||
request_stop_collect(tid)
|
||
|
||
collect: dict[str, Any] = {"old": [], "new": []}
|
||
if collect_now:
|
||
collect = collect_project_now(db, project_id)
|
||
|
||
return {
|
||
"project": project_to_dict(db, proj),
|
||
"old_task": _task_brief(db, proj.old_hf_task_id),
|
||
"new_task": _task_brief(db, proj.new_hf_task_id),
|
||
"old_hf_task": _task_brief(db, proj.old_hf_task_id),
|
||
"new_hf_task": _task_brief(db, proj.new_hf_task_id),
|
||
"old_hf_bindings": old_bindings,
|
||
"new_hf_bindings": new_bindings,
|
||
"old_created": old_any_created,
|
||
"new_created": new_any_created,
|
||
"interval_sec": iv_default,
|
||
"hf_status": want_status,
|
||
"collect_metric_ids": metric_ids,
|
||
"collect": collect,
|
||
}
|
||
|
||
|
||
def collect_project_now(db: Session, project_id: str) -> dict[str, Any]:
|
||
"""Trigger immediate collect on all HF binding tasks (never portrait)."""
|
||
from ..biz_state.collect_runner import dispatch_collect
|
||
|
||
proj = db.get(BizMigrationProject, project_id)
|
||
if not proj:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
migrate_project_hf_slots(db, proj, commit=True)
|
||
want_status = _hf_window_status(proj)
|
||
all_ids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
|
||
if want_status != "running":
|
||
from ..biz_state import service as biz_svc
|
||
|
||
for tid in all_ids:
|
||
t = db.get(BizStateTask, tid)
|
||
if t and t.status == "running":
|
||
biz_svc.update_task(db, tid, {"status": "paused"})
|
||
return {
|
||
"old": [{"ok": False, "error": "hf_window_inactive"}],
|
||
"new": [{"ok": False, "error": "hf_window_inactive"}],
|
||
"hf_status": want_status,
|
||
}
|
||
|
||
out: dict[str, Any] = {"old": [], "new": [], "hf_status": want_status}
|
||
old_bindings, new_bindings = _hf_bindings(proj, "old"), _hf_bindings(proj, "new")
|
||
handled: set[str] = set()
|
||
for old_binding in old_bindings:
|
||
gid = old_binding.get("collect_group_id")
|
||
if not gid or gid in handled:
|
||
continue
|
||
handled.add(gid)
|
||
new_binding = next((b for b in new_bindings if b.get("collect_group_id") == gid), None)
|
||
if not new_binding:
|
||
result = {"ok": False, "error": "collect_pair_invalid"}
|
||
else:
|
||
result = dispatch_collect(old_binding["task_id"], manual=True)
|
||
result = {**result, "ok": bool(result.get("queued")), "error": result.get("reason", "")}
|
||
for side, binding in (("old", old_binding), ("new", new_binding)):
|
||
out[side].append({**result, "task_id": binding["task_id"] if binding else "",
|
||
"metric_ids": old_binding.get("metric_ids", [])})
|
||
# Legacy projects remain operable until their bindings are upgraded.
|
||
if not handled:
|
||
for side in ("old", "new"):
|
||
tids = _all_hf_task_ids(proj, side)
|
||
if not tids:
|
||
out[side] = [{"ok": False, "error": "hf_task_missing"}]
|
||
for tid in tids:
|
||
result = dispatch_collect(tid, manual=True)
|
||
out[side].append({**result, "ok": bool(result.get("queued")), "task_id": tid,
|
||
"error": result.get("reason", "")})
|
||
|
||
return out
|
||
|
||
|
||
def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]:
|
||
detail = dict(t.detail_json or {})
|
||
old_key = str(detail.get("old_key") or t.key_str or "")
|
||
new_key = str(detail.get("new_key") or t.new_key_str or "")
|
||
return {
|
||
"id": t.id,
|
||
"project_id": t.project_id,
|
||
"batch_id": t.batch_id,
|
||
"run_id": t.run_id,
|
||
"metric_id": t.metric_id,
|
||
"old_key": old_key,
|
||
"new_key": new_key,
|
||
"key_str": old_key,
|
||
"new_key_str": new_key,
|
||
"match_key_str": str(getattr(t, "match_key_str", "") or detail.get("match_old_key") or ""),
|
||
"match_old_key": str(detail.get("match_old_key") or ""),
|
||
"match_new_key": str(detail.get("match_new_key") or ""),
|
||
"verdict": t.verdict,
|
||
"color": t.color,
|
||
"old_status": t.old_status,
|
||
"new_status": t.new_status,
|
||
"detail": detail,
|
||
"evidence": dict(detail.get("evidence") or {}),
|
||
"status": t.status,
|
||
"carried_to_batch_id": t.carried_to_batch_id,
|
||
"note": t.note,
|
||
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||
"resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
|
||
}
|
||
|
||
|
||
def count_open_red_tickets(db: Session, project_id: str) -> int:
|
||
return (
|
||
db.query(BizMigrationRedTicket)
|
||
.filter(
|
||
BizMigrationRedTicket.project_id == project_id,
|
||
BizMigrationRedTicket.status.in_(("open", "carried")),
|
||
)
|
||
.count()
|
||
)
|
||
|
||
|
||
def list_red_tickets(
|
||
db: Session,
|
||
project_id: str,
|
||
*,
|
||
status: str = "",
|
||
limit: int = 200,
|
||
) -> dict[str, Any]:
|
||
if not db.get(BizMigrationProject, project_id):
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
q = db.query(BizMigrationRedTicket).filter(BizMigrationRedTicket.project_id == project_id)
|
||
if status.strip():
|
||
q = q.filter(BizMigrationRedTicket.status == status.strip())
|
||
rows = (
|
||
q.order_by(BizMigrationRedTicket.created_at.desc())
|
||
.limit(min(500, max(1, limit)))
|
||
.all()
|
||
)
|
||
return {
|
||
"open_count": count_open_red_tickets(db, project_id),
|
||
"items": [_red_ticket_to_dict(t) for t in rows],
|
||
}
|
||
|
||
|
||
def resolve_red_ticket(db: Session, ticket_id: str, *, note: str = "") -> dict[str, Any]:
|
||
t = db.get(BizMigrationRedTicket, ticket_id)
|
||
if not t:
|
||
raise HTTPException(status_code=404, detail="red_ticket_not_found")
|
||
t.status = "resolved"
|
||
t.resolved_at = utcnow_naive()
|
||
if note:
|
||
t.note = str(note)[:500]
|
||
db.commit()
|
||
db.refresh(t)
|
||
return _red_ticket_to_dict(t)
|
||
|
||
|
||
def _persist_red_tickets_from_run(
|
||
db: Session,
|
||
*,
|
||
project_id: str,
|
||
batch_id: str,
|
||
run_id: str,
|
||
) -> list[BizMigrationRedTicket]:
|
||
"""Upsert open/carried red tickets by (project, metric, match_key).
|
||
|
||
Same issue across batches / re-acceptance updates one ticket instead of
|
||
spawning duplicates. ``carried`` tickets are reopened when the key is still red.
|
||
"""
|
||
diffs = (
|
||
db.query(BizMigrationDiff)
|
||
.filter(BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "red")
|
||
.order_by(BizMigrationDiff.seq.asc())
|
||
.all()
|
||
)
|
||
existing = (
|
||
db.query(BizMigrationRedTicket)
|
||
.filter(
|
||
BizMigrationRedTicket.project_id == project_id,
|
||
BizMigrationRedTicket.status.in_(("open", "carried")),
|
||
)
|
||
.all()
|
||
)
|
||
by_id: dict[tuple[str, str, str], BizMigrationRedTicket] = {}
|
||
for t in existing:
|
||
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
|
||
mk = str(getattr(t, "match_key_str", "") or "").strip()
|
||
if not mk:
|
||
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
|
||
mk = str(detail.get("match_old_key") or t.key_str or "").strip()
|
||
if mk:
|
||
by_id[(str(t.metric_id or ""), str(detail.get("sheet_id") or t.metric_id or ""),
|
||
str(detail.get("match_old_key") or mk))] = t
|
||
|
||
created_or_updated: list[BizMigrationRedTicket] = []
|
||
seen: set[tuple[str, str, str]] = set()
|
||
for d in diffs:
|
||
kj = d.key_json if isinstance(d.key_json, dict) else {}
|
||
mid = str(d.metric_id or "")
|
||
match_key = str(
|
||
kj.get("match_old_key") or kj.get("key_str") or kj.get("old_key") or ""
|
||
).strip()
|
||
if not match_key:
|
||
match_key = str(kj.get("key_str") or "")[:256]
|
||
ident = (mid, str(kj.get("sheet_id") or mid), match_key)
|
||
from hashlib import sha256
|
||
stored_key = match_key if len(match_key) <= 256 else "sha256:" + sha256(match_key.encode()).hexdigest()
|
||
detail = {
|
||
"old_kind": d.old_kind,
|
||
"new_kind": d.new_kind,
|
||
"in_expect": d.in_expect,
|
||
"old": d.old_json,
|
||
"new": d.new_json,
|
||
"old_key": kj.get("old_key") or kj.get("key_str") or "",
|
||
"new_key": kj.get("new_key") or kj.get("new_key_str") or "",
|
||
"match_old_key": kj.get("match_old_key") or match_key,
|
||
"match_new_key": kj.get("match_new_key") or "",
|
||
"sheet_id": kj.get("sheet_id") or mid,
|
||
"integrity_changes": kj.get("integrity_changes") or [],
|
||
"evidence": dict(kj.get("evidence") or {}),
|
||
}
|
||
prev = by_id.get(ident)
|
||
if prev is not None:
|
||
prev.batch_id = batch_id
|
||
prev.run_id = run_id
|
||
prev.key_str = str(kj.get("key_str") or "")[:256]
|
||
prev.new_key_str = str(kj.get("new_key_str") or "")[:256]
|
||
prev.match_key_str = stored_key
|
||
prev.verdict = d.verdict
|
||
prev.color = d.color or "red"
|
||
prev.old_status = str(kj.get("old_status") or "")[:64]
|
||
prev.new_status = str(kj.get("new_status") or "")[:64]
|
||
prev.detail_json = detail
|
||
prev.status = "open"
|
||
prev.carried_to_batch_id = ""
|
||
prev.resolved_at = None
|
||
created_or_updated.append(prev)
|
||
seen.add(ident)
|
||
continue
|
||
t = BizMigrationRedTicket(
|
||
id=uuid4().hex,
|
||
project_id=project_id,
|
||
batch_id=batch_id,
|
||
run_id=run_id,
|
||
metric_id=mid,
|
||
key_str=str(kj.get("key_str") or "")[:256],
|
||
new_key_str=str(kj.get("new_key_str") or "")[:256],
|
||
match_key_str=stored_key,
|
||
verdict=d.verdict,
|
||
color=d.color or "red",
|
||
old_status=str(kj.get("old_status") or "")[:64],
|
||
new_status=str(kj.get("new_status") or "")[:64],
|
||
detail_json=detail,
|
||
status="open",
|
||
)
|
||
db.add(t)
|
||
by_id[ident] = t
|
||
created_or_updated.append(t)
|
||
seen.add(ident)
|
||
|
||
# Tickets open on this batch but no longer red → leave as open (operator resolves).
|
||
# Carried tickets for keys not in this run stay carried.
|
||
return created_or_updated
|
||
|
||
|
||
def finish_batch(
|
||
db: Session,
|
||
batch_id: str,
|
||
*,
|
||
mark_done: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""本批完成:关窗 → 终验 → 验收小结 → 红单留痕(不硬卡下一批)."""
|
||
mb = db.get(BizMigrationBatch, batch_id)
|
||
if not mb:
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
if mb.status not in ("active", "review"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"batch_not_finishable:{mb.status}",
|
||
)
|
||
|
||
# acceptance=True closes the logical window. Persist batch closure only after
|
||
# evaluation succeeds, so an API/collection failure keeps the batch operable.
|
||
run = run_evaluate(db, batch_id=batch_id, acceptance=True, purpose="acceptance", _commit=False)
|
||
summary = dict(run.get("summary") or {})
|
||
progress = dict(summary.get("progress") or {})
|
||
anomaly = int(summary.get("anomaly") or 0)
|
||
ok = int(progress.get("ok") or 0)
|
||
total = int(progress.get("total") or 0)
|
||
missing_metrics = [str(x) for x in (summary.get("missing_metrics") or []) if str(x)]
|
||
current_missing = [str(x) for x in (summary.get("current_missing_metrics") or []) if str(x)]
|
||
fail_reasons: list[str] = []
|
||
if total <= 0:
|
||
fail_reasons.append("empty_expect")
|
||
if missing_metrics:
|
||
fail_reasons.append("new_baseline_missing")
|
||
if current_missing:
|
||
fail_reasons.append("current_missing")
|
||
if not summary.get("coverage_complete", False):
|
||
fail_reasons.append("coverage_incomplete")
|
||
if summary.get("duplicate_keys"):
|
||
fail_reasons.append("duplicate_keys")
|
||
if anomaly:
|
||
fail_reasons.append("anomaly")
|
||
if total > 0 and ok < total:
|
||
fail_reasons.append("incomplete")
|
||
passed = not fail_reasons
|
||
|
||
accept_summary = {
|
||
"passed": passed,
|
||
"fail_reasons": fail_reasons,
|
||
"missing_metrics": missing_metrics,
|
||
"current_missing_metrics": current_missing,
|
||
"progress_ok": ok,
|
||
"progress_total": total,
|
||
"anomaly": anomaly,
|
||
"verdict_counts": dict(summary.get("verdict_counts") or {}),
|
||
"expect_ports": list(summary.get("expect_ports") or []),
|
||
"run_id": run.get("id"),
|
||
"old_batch_id": run.get("old_batch_id"),
|
||
"new_batch_id": run.get("new_batch_id"),
|
||
"finished_at": utcnow_naive().isoformat(),
|
||
}
|
||
|
||
mb = db.get(BizMigrationBatch, batch_id)
|
||
if not mb:
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
if mb.status == "active":
|
||
mb.ended_at = utcnow_naive()
|
||
mb.status = "done" if mark_done else "review"
|
||
mb.accept_run_id = str(run.get("id") or "")
|
||
mb.accept_status = "passed" if passed else "failed"
|
||
mb.accept_summary_json = accept_summary
|
||
mb.updated_at = utcnow_naive()
|
||
|
||
# Upsert reds (no wipe — merge by metric+match_key across re-acceptance)
|
||
reds = _persist_red_tickets_from_run(
|
||
db,
|
||
project_id=mb.project_id,
|
||
batch_id=batch_id,
|
||
run_id=str(run.get("id") or ""),
|
||
)
|
||
db.commit()
|
||
db.refresh(mb)
|
||
|
||
return {
|
||
"batch": batch_to_dict(mb),
|
||
"run": run,
|
||
"accept_summary": accept_summary,
|
||
"red_tickets": [_red_ticket_to_dict(t) for t in reds],
|
||
"open_red_count": count_open_red_tickets(db, mb.project_id),
|
||
"can_continue": True, # 带红继续:永不硬卡
|
||
}
|
||
|
||
|
||
def _validate_batch_status(db: Session, project_id: str, batch_id: str, status: str) -> None:
|
||
if status not in ("pending", "active", "review", "done"):
|
||
raise HTTPException(status_code=400, detail="batch_status_invalid")
|
||
if status == "active":
|
||
db.query(BizMigrationProject).filter(BizMigrationProject.id == project_id).with_for_update().first()
|
||
if db.query(BizMigrationBatch.id).filter(BizMigrationBatch.project_id == project_id,
|
||
BizMigrationBatch.id != batch_id, BizMigrationBatch.status == "active").first():
|
||
raise HTTPException(status_code=409, detail="another_batch_active")
|
||
|
||
|
||
def patch_batch(db: Session, batch_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||
b = db.get(BizMigrationBatch, batch_id)
|
||
if not b:
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
if body.get("status") is not None:
|
||
_validate_batch_status(db, b.project_id, b.id, str(body["status"]).strip())
|
||
if "batch_label" in body and body["batch_label"] is not None:
|
||
b.batch_label = str(body["batch_label"]).strip() or b.batch_label
|
||
if "note" in body and body["note"] is not None:
|
||
b.note = str(body["note"])[:500]
|
||
if "expect_set" in body and isinstance(body["expect_set"], dict):
|
||
b.expect_set_json = dict(body["expect_set"])
|
||
if "status" in body and body["status"] is not None:
|
||
st = str(body["status"]).strip()
|
||
# Prefer finish_batch for review/done from active (auto acceptance)
|
||
if st in ("review", "done") and b.status == "active":
|
||
return finish_batch(db, batch_id, mark_done=(st == "done"))["batch"]
|
||
prev = b.status
|
||
b.status = st or b.status
|
||
if st == "active" and prev != "active":
|
||
b.started_at = utcnow_naive()
|
||
# 带红:标记既有 open 红单为 carried(不关闭)
|
||
open_reds = (
|
||
db.query(BizMigrationRedTicket)
|
||
.filter(
|
||
BizMigrationRedTicket.project_id == b.project_id,
|
||
BizMigrationRedTicket.status == "open",
|
||
)
|
||
.all()
|
||
)
|
||
for t in open_reds:
|
||
t.status = "carried"
|
||
t.carried_to_batch_id = b.id
|
||
b.updated_at = utcnow_naive()
|
||
db.commit()
|
||
db.refresh(b)
|
||
return batch_to_dict(b)
|
||
|
||
|
||
def get_run(db: Session, run_id: str) -> dict[str, Any]:
|
||
run = db.get(BizMigrationRun, run_id)
|
||
if not run:
|
||
raise HTTPException(status_code=404, detail="run_not_found")
|
||
return run_to_dict(db, run, include_diffs=False)
|
||
|
||
|
||
def list_run_diffs(
|
||
db: Session,
|
||
run_id: str,
|
||
*,
|
||
metric_id: str = "",
|
||
sheet_id: str = "",
|
||
only_expect: bool = False,
|
||
verdict: str = "",
|
||
color: str = "",
|
||
kw: str = "",
|
||
offset: int = 0,
|
||
limit: int = 100,
|
||
) -> dict[str, Any]:
|
||
run = db.get(BizMigrationRun, run_id)
|
||
if not run:
|
||
raise HTTPException(status_code=404, detail="run_not_found")
|
||
sources = [and_(BizMigrationDiff.run_id == c["source_run_id"],
|
||
BizMigrationDiff.key_json["sheet_id"].as_string() == sheet_key(c))
|
||
for c in (run.summary_json or {}).get("sheet_cards", [])
|
||
if c.get("source_run_id") and c["source_run_id"] != run_id
|
||
and not (only_expect and c.get("expected_count") == 0)
|
||
and (not metric_id or c.get("metric_id") == metric_id)
|
||
and (not sheet_id or sheet_key(c) == sheet_id)]
|
||
q = db.query(BizMigrationDiff).filter(or_(BizMigrationDiff.run_id == run_id, *sources))
|
||
if metric_id:
|
||
q = q.filter(BizMigrationDiff.metric_id == metric_id)
|
||
if sheet_id:
|
||
q = q.filter(BizMigrationDiff.key_json["sheet_id"].as_string() == sheet_id)
|
||
if only_expect:
|
||
q = q.filter(BizMigrationDiff.in_expect.is_(True))
|
||
if verdict:
|
||
q = q.filter(BizMigrationDiff.verdict == verdict)
|
||
if color:
|
||
q = q.filter(BizMigrationDiff.color == color)
|
||
if kw.strip():
|
||
literal = kw.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
q = q.filter(BizMigrationDiff.search_text.ilike(f"%{literal}%", escape="\\"))
|
||
total = q.count()
|
||
rows = q.order_by(BizMigrationDiff.metric_id.asc(), BizMigrationDiff.seq.asc(), BizMigrationDiff.id.asc()).offset(max(0, offset)).limit(min(500, max(1, limit))).all()
|
||
return {
|
||
"total": total,
|
||
"offset": max(0, offset),
|
||
"limit": min(500, max(1, limit)),
|
||
"items": [diff_to_dict(d) for d in rows],
|
||
}
|
||
|
||
|
||
def list_runs(db: Session, batch_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.query(BizMigrationRun)
|
||
.filter(BizMigrationRun.batch_id == batch_id)
|
||
.order_by(BizMigrationRun.created_at.desc())
|
||
.limit(min(100, max(1, limit)))
|
||
.all()
|
||
)
|
||
return [run_to_dict(db, r) for r in rows]
|
||
|
||
|
||
def board(db: Session, batch_id: str, run_id: str = "") -> dict[str, Any]:
|
||
"""Progress / anomaly board for a migration batch (latest or given run)."""
|
||
mb = db.get(BizMigrationBatch, batch_id)
|
||
if not mb:
|
||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||
proj = get_project(db, mb.project_id)
|
||
run = None
|
||
if run_id:
|
||
run = db.get(BizMigrationRun, run_id)
|
||
if not run or run.batch_id != batch_id:
|
||
raise HTTPException(status_code=404, detail="run_not_found")
|
||
else:
|
||
run = (
|
||
db.query(BizMigrationRun)
|
||
.filter(BizMigrationRun.batch_id == batch_id)
|
||
.order_by(BizMigrationRun.created_at.desc())
|
||
.first()
|
||
)
|
||
return {
|
||
"project": proj,
|
||
"batch": batch_to_dict(mb),
|
||
"run": run_to_dict(db, run) if run else None,
|
||
"sampling": sampling_state(db, db.get(BizMigrationProject, mb.project_id)) if not run_id else [],
|
||
}
|
||
|
||
|
||
def sampling_state(db: Session, proj: BizMigrationProject) -> list[dict[str, Any]]:
|
||
"""Small live read independent of evaluation and route row counts."""
|
||
out = []
|
||
for binding in _hf_bindings(proj, "old"):
|
||
gid = binding.get("collect_group_id")
|
||
if not gid:
|
||
continue
|
||
latest = db.query(BizStateBatch).filter(BizStateBatch.collect_group_id == gid).order_by(
|
||
BizStateBatch.queued_at.desc(), BizStateBatch.started_at.desc()).first()
|
||
peers = db.query(BizStateBatch).filter(BizStateBatch.collect_round_id == latest.collect_round_id).all() if latest else []
|
||
old = next((b for b in peers if b.task_id == binding["task_id"]), None)
|
||
new = next((b for b in peers if b.task_id != binding["task_id"]), None)
|
||
card: dict[str, Any] = {"metric_id": (binding.get("metric_ids") or [""])[0],
|
||
"collect_group_id": gid, "collect_round_id": latest.collect_round_id if latest else "",
|
||
"manual_only": binding.get("manual_only", False), "interval_sec": binding["interval_sec"]}
|
||
for side, batch in (("old", old), ("new", new)):
|
||
card[f"{side}_collect_status"] = batch.status if batch else "idle"
|
||
card[f"{side}_duration_sec"] = max(0, ((batch.ended_at or utcnow_naive()) - batch.started_at).total_seconds()) if batch and batch.status != "queued" else 0
|
||
card[f"{side}_queue_sec"] = max(0, ((utcnow_naive() if batch.status == "queued" else batch.started_at) - (batch.queued_at or batch.started_at)).total_seconds()) if batch else 0
|
||
if old and new and all(b.status != "queued" for b in (old, new)):
|
||
card["start_skew_sec"] = abs((old.started_at - new.started_at).total_seconds())
|
||
out.append(card)
|
||
return out
|
||
|
||
|
||
def get_monitor_context(
|
||
db: Session,
|
||
*,
|
||
project_id: str = "",
|
||
task_id: str = "",
|
||
) -> dict[str, Any]:
|
||
"""Fat read for AI / ops: project definition + templates + mapping + tasks.
|
||
|
||
Prefer ``project_id`` (cutover). ``task_id`` alone returns that biz_state task
|
||
and any cutover project that references it.
|
||
"""
|
||
from ..biz_state import compare_service as cmp_svc
|
||
from ..biz_state import service as biz_svc
|
||
|
||
pid = str(project_id or "").strip()
|
||
tid = str(task_id or "").strip()
|
||
if not pid and not tid:
|
||
raise HTTPException(status_code=400, detail="project_id_or_task_id_required")
|
||
|
||
proj_row: BizMigrationProject | None = None
|
||
if pid:
|
||
proj_row = db.get(BizMigrationProject, pid)
|
||
if not proj_row:
|
||
raise HTTPException(status_code=404, detail="project_not_found")
|
||
elif tid:
|
||
proj_row = (
|
||
db.query(BizMigrationProject)
|
||
.filter(
|
||
(BizMigrationProject.old_task_id == tid)
|
||
| (BizMigrationProject.new_task_id == tid)
|
||
| (BizMigrationProject.old_hf_task_id == tid)
|
||
| (BizMigrationProject.new_hf_task_id == tid)
|
||
)
|
||
.order_by(BizMigrationProject.updated_at.desc())
|
||
.first()
|
||
)
|
||
|
||
out: dict[str, Any] = {
|
||
"project": None,
|
||
"monitor_template": None,
|
||
"compare_template": None,
|
||
"port_mapping": None,
|
||
"tasks": {},
|
||
"task": None,
|
||
}
|
||
|
||
if tid:
|
||
try:
|
||
out["task"] = biz_svc.get_task(db, tid)
|
||
except HTTPException:
|
||
if not proj_row:
|
||
raise
|
||
|
||
if not proj_row:
|
||
return out
|
||
|
||
migrate_project_hf_slots(db, proj_row, commit=False)
|
||
out["project"] = project_to_dict(db, proj_row)
|
||
|
||
mt_id = str(getattr(proj_row, "monitor_template_id", None) or "").strip()
|
||
if mt_id:
|
||
try:
|
||
out["monitor_template"] = mon_tpl.get_monitor_template(db, mt_id)
|
||
except HTTPException:
|
||
out["monitor_template"] = None
|
||
|
||
ct_id = ""
|
||
if isinstance(out.get("monitor_template"), dict):
|
||
ct_id = str(out["monitor_template"].get("compare_template_id") or "").strip()
|
||
if ct_id:
|
||
ct = db.get(BizCompareTemplate, ct_id)
|
||
if ct:
|
||
out["compare_template"] = cmp_svc._template_out(ct) # noqa: SLF001
|
||
|
||
map_id = str(proj_row.mapping_id or "").strip()
|
||
if map_id:
|
||
m = db.get(BizPortMapping, map_id)
|
||
if m:
|
||
out["port_mapping"] = cmp_svc._mapping_out(db, m) # noqa: SLF001
|
||
|
||
task_ids = {
|
||
"old_portrait": proj_row.old_task_id or "",
|
||
"new_portrait": proj_row.new_task_id or "",
|
||
"old_hf": getattr(proj_row, "old_hf_task_id", None) or "",
|
||
"new_hf": getattr(proj_row, "new_hf_task_id", None) or "",
|
||
}
|
||
for side in ("old", "new"):
|
||
for i, b in enumerate(_hf_bindings(proj_row, side)):
|
||
bid = str(b.get("task_id") or "").strip()
|
||
if bid:
|
||
task_ids[f"{side}_hf_{i}"] = bid
|
||
|
||
tasks: dict[str, Any] = {}
|
||
for label, task_ref in task_ids.items():
|
||
if not task_ref or task_ref in {t.get("id") for t in tasks.values() if isinstance(t, dict)}:
|
||
# still key by label even if duplicate id
|
||
pass
|
||
if not task_ref:
|
||
continue
|
||
try:
|
||
tasks[label] = biz_svc.get_task(db, task_ref)
|
||
except HTTPException:
|
||
tasks[label] = {"id": task_ref, "error": "task_not_found"}
|
||
out["tasks"] = tasks
|
||
return out
|