netx/netx_api/biz_migration/service.py

2690 lines
111 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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