mirror of
https://github.com/hansjone/netx.git
synced 2026-10-13 00:53:27 +08:00
Wire cutover monitor to templates: project binding, multi-sheet evaluate, dual status semantics, and UI expect/HF.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
202196a197
commit
60a663e2f6
11 changed files with 808 additions and 159 deletions
|
|
@ -24,6 +24,39 @@ def port_status_label(row: dict[str, Any] | None) -> str:
|
||||||
return "/".join(parts)
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def status_label(row: dict[str, Any] | None, sheet_override: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Human status for board; uses sheet_override.status_fields when set."""
|
||||||
|
ov = sheet_override or {}
|
||||||
|
fields = [str(f) for f in (ov.get("status_fields") or []) if str(f).strip()]
|
||||||
|
if not fields:
|
||||||
|
return port_status_label(row)
|
||||||
|
if not row:
|
||||||
|
return "—"
|
||||||
|
parts = [str(row.get(f) or "-").lower() for f in fields]
|
||||||
|
if all(p == "-" for p in parts):
|
||||||
|
return "—"
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_status(row: dict[str, Any] | None, sheet_override: dict[str, Any] | None) -> str:
|
||||||
|
"""Classify current row as up|down|other|none from sheet_override status semantics."""
|
||||||
|
ov = sheet_override or {}
|
||||||
|
fields = [str(f) for f in (ov.get("status_fields") or []) if str(f).strip()]
|
||||||
|
if not fields or not row:
|
||||||
|
return "none"
|
||||||
|
down_vals = {str(x).lower() for x in (ov.get("down_values") or ["down"])}
|
||||||
|
up_vals = {str(x).lower() for x in (ov.get("up_values") or ["up"])}
|
||||||
|
vals = [str(row.get(f) or "").strip().lower() for f in fields]
|
||||||
|
vals = [v for v in vals if v]
|
||||||
|
if not vals:
|
||||||
|
return "none"
|
||||||
|
if any(v in down_vals for v in vals):
|
||||||
|
return "down"
|
||||||
|
if vals and all(v in up_vals for v in vals):
|
||||||
|
return "up"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
||||||
"""Return metric_id → set of key strings (old-side / before-map identity).
|
"""Return metric_id → set of key strings (old-side / before-map identity).
|
||||||
|
|
||||||
|
|
@ -86,6 +119,34 @@ def side_verdict(
|
||||||
return "ok", "gray"
|
return "ok", "gray"
|
||||||
|
|
||||||
|
|
||||||
|
def _side_tokens(kind: str, status: str) -> set[str]:
|
||||||
|
toks: set[str] = set()
|
||||||
|
k = str(kind or "").strip()
|
||||||
|
if k:
|
||||||
|
toks.add(k)
|
||||||
|
s = str(status or "").strip()
|
||||||
|
if s and s != "none":
|
||||||
|
toks.add(s)
|
||||||
|
return toks
|
||||||
|
|
||||||
|
|
||||||
|
def _match_success(
|
||||||
|
old_tokens: set[str],
|
||||||
|
new_tokens: set[str],
|
||||||
|
success_patterns: list[dict[str, Any]] | None,
|
||||||
|
) -> bool:
|
||||||
|
for pat in success_patterns or []:
|
||||||
|
if not isinstance(pat, dict):
|
||||||
|
continue
|
||||||
|
old_need = {str(x) for x in (pat.get("old") or []) if str(x)}
|
||||||
|
new_need = {str(x) for x in (pat.get("new") or []) if str(x)}
|
||||||
|
if not old_need or not new_need:
|
||||||
|
continue
|
||||||
|
if (old_need & old_tokens) and (new_need & new_tokens):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def dual_verdict(
|
def dual_verdict(
|
||||||
*,
|
*,
|
||||||
old_kind: str,
|
old_kind: str,
|
||||||
|
|
@ -93,12 +154,25 @@ def dual_verdict(
|
||||||
in_expect: bool,
|
in_expect: bool,
|
||||||
window_active: bool,
|
window_active: bool,
|
||||||
acceptance: bool = False,
|
acceptance: bool = False,
|
||||||
|
old_status: str = "none",
|
||||||
|
new_status: str = "none",
|
||||||
|
success_patterns: list[dict[str, Any]] | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Synthesize old+new into migration board verdict.
|
"""Synthesize old+new into migration board verdict.
|
||||||
|
|
||||||
``acceptance=True`` (本批完成终验): unfinished expect items become red
|
``acceptance=True`` (本批完成终验): unfinished expect items become red
|
||||||
(``unfinished`` / ``lost``), not yellow migrating.
|
(``unfinished`` / ``lost``), not yellow migrating.
|
||||||
|
|
||||||
|
When ``success_patterns`` is set (from monitor sheet_overrides), tokens may
|
||||||
|
include kinds and status classes (up/down) so e.g. old down + new up → migrated.
|
||||||
"""
|
"""
|
||||||
|
if in_expect and _match_success(
|
||||||
|
_side_tokens(old_kind, old_status),
|
||||||
|
_side_tokens(new_kind, new_status),
|
||||||
|
success_patterns,
|
||||||
|
):
|
||||||
|
return "migrated", "green"
|
||||||
|
|
||||||
if not in_expect:
|
if not in_expect:
|
||||||
if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"):
|
if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"):
|
||||||
if old_kind == "removed" and new_kind in ("", "removed"):
|
if old_kind == "removed" and new_kind in ("", "removed"):
|
||||||
|
|
@ -179,6 +253,17 @@ def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
return dict(diff.get("after") or {})
|
return dict(diff.get("after") or {})
|
||||||
|
|
||||||
|
|
||||||
|
def override_for_metric(
|
||||||
|
sheet_overrides: list[dict[str, Any]] | None,
|
||||||
|
metric_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
mid = str(metric_id or "").strip()
|
||||||
|
for ov in sheet_overrides or []:
|
||||||
|
if isinstance(ov, dict) and str(ov.get("metric_id") or "").strip() == mid:
|
||||||
|
return ov
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def evaluate_metric_dual(
|
def evaluate_metric_dual(
|
||||||
*,
|
*,
|
||||||
metric_id: str,
|
metric_id: str,
|
||||||
|
|
@ -193,9 +278,13 @@ def evaluate_metric_dual(
|
||||||
expect: dict[str, set[str]],
|
expect: dict[str, set[str]],
|
||||||
window_active: bool,
|
window_active: bool,
|
||||||
acceptance: bool = False,
|
acceptance: bool = False,
|
||||||
|
field_rules: list[dict[str, Any]] | None = None,
|
||||||
|
sheet_override: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
|
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
|
||||||
expect_keys = expect_keys_for_metric(expect, metric_id=metric_id, iface_fields=iface_fields)
|
expect_keys = expect_keys_for_metric(expect, metric_id=metric_id, iface_fields=iface_fields)
|
||||||
|
ov = sheet_override or {}
|
||||||
|
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
|
||||||
|
|
||||||
old_cmp = compare_rows(
|
old_cmp = compare_rows(
|
||||||
before_rows=old_baseline_rows,
|
before_rows=old_baseline_rows,
|
||||||
|
|
@ -204,6 +293,7 @@ def evaluate_metric_dual(
|
||||||
iface_fields=[],
|
iface_fields=[],
|
||||||
compare_fields=compare_fields,
|
compare_fields=compare_fields,
|
||||||
port_map=None,
|
port_map=None,
|
||||||
|
field_rules=field_rules,
|
||||||
)
|
)
|
||||||
old_idx = build_diff_index_from_compare(old_cmp)
|
old_idx = build_diff_index_from_compare(old_cmp)
|
||||||
|
|
||||||
|
|
@ -224,6 +314,7 @@ def evaluate_metric_dual(
|
||||||
iface_fields=[],
|
iface_fields=[],
|
||||||
compare_fields=compare_fields,
|
compare_fields=compare_fields,
|
||||||
port_map=None,
|
port_map=None,
|
||||||
|
field_rules=field_rules,
|
||||||
)
|
)
|
||||||
new_idx = build_diff_index_from_compare(new_cmp)
|
new_idx = build_diff_index_from_compare(new_cmp)
|
||||||
|
|
||||||
|
|
@ -267,21 +358,26 @@ def evaluate_metric_dual(
|
||||||
if nd is None:
|
if nd is None:
|
||||||
new_kind = ""
|
new_kind = ""
|
||||||
|
|
||||||
|
old_cur = _current_row(od)
|
||||||
|
new_cur = _current_row(nd)
|
||||||
|
old_st = classify_status(old_cur, ov)
|
||||||
|
new_st = classify_status(new_cur, ov)
|
||||||
|
|
||||||
verdict, color = dual_verdict(
|
verdict, color = dual_verdict(
|
||||||
old_kind=old_kind or "",
|
old_kind=old_kind or "",
|
||||||
new_kind=new_kind or "",
|
new_kind=new_kind or "",
|
||||||
in_expect=in_exp,
|
in_expect=in_exp,
|
||||||
window_active=window_active,
|
window_active=window_active,
|
||||||
acceptance=acceptance,
|
acceptance=acceptance,
|
||||||
|
old_status=old_st,
|
||||||
|
new_status=new_st,
|
||||||
|
success_patterns=success_patterns or None,
|
||||||
)
|
)
|
||||||
if in_exp and verdict == "migrated" and color == "green":
|
if in_exp and verdict == "migrated" and color == "green":
|
||||||
progress_ok += 1
|
progress_ok += 1
|
||||||
if color == "red":
|
if color == "red":
|
||||||
anomaly += 1
|
anomaly += 1
|
||||||
|
|
||||||
old_cur = _current_row(od)
|
|
||||||
new_cur = _current_row(nd)
|
|
||||||
|
|
||||||
rows_out.append(
|
rows_out.append(
|
||||||
{
|
{
|
||||||
"metric_id": metric_id,
|
"metric_id": metric_id,
|
||||||
|
|
@ -297,10 +393,10 @@ def evaluate_metric_dual(
|
||||||
"new": new_cur,
|
"new": new_cur,
|
||||||
"old_baseline": dict((od or {}).get("before") or {}),
|
"old_baseline": dict((od or {}).get("before") or {}),
|
||||||
"new_baseline": dict((nd or {}).get("before") or {}),
|
"new_baseline": dict((nd or {}).get("before") or {}),
|
||||||
"old_status": port_status_label(old_cur)
|
"old_status": status_label(old_cur, ov)
|
||||||
if old_cur
|
if old_cur
|
||||||
else ("gone" if old_kind == "removed" else "—"),
|
else ("gone" if old_kind == "removed" else "—"),
|
||||||
"new_status": port_status_label(new_cur)
|
"new_status": status_label(new_cur, ov)
|
||||||
if new_cur
|
if new_cur
|
||||||
else ("gone" if new_kind == "removed" else "—"),
|
else ("gone" if new_kind == "removed" else "—"),
|
||||||
"old_side": side_verdict(
|
"old_side": side_verdict(
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,27 @@ def ensure_default_monitor_templates(db: Session) -> None:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def default_port_monitor_template_id(db: Session) -> str:
|
||||||
|
"""Ensure seeds exist and return id of 「端口割接监控」 (or first template)."""
|
||||||
|
ensure_default_monitor_templates(db)
|
||||||
|
row = (
|
||||||
|
db.query(BizMonitorTemplate)
|
||||||
|
.filter(BizMonitorTemplate.name == "端口割接监控")
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return row.id
|
||||||
|
first = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.created_at.asc()).first()
|
||||||
|
return first.id if first else ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor_template_row(db: Session, template_id: str) -> BizMonitorTemplate | None:
|
||||||
|
tid = str(template_id or "").strip()
|
||||||
|
if not tid:
|
||||||
|
return None
|
||||||
|
return db.get(BizMonitorTemplate, tid)
|
||||||
|
|
||||||
|
|
||||||
def list_monitor_templates(db: Session) -> list[dict[str, Any]]:
|
def list_monitor_templates(db: Session) -> list[dict[str, Any]]:
|
||||||
ensure_default_monitor_templates(db)
|
ensure_default_monitor_templates(db)
|
||||||
names = _compare_name_map(db)
|
names = _compare_name_map(db)
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,28 @@ from fastapi import HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..biz_state.compare_rules import apply_row_filters
|
from ..biz_state.compare_rules import apply_row_filters
|
||||||
from ..biz_state.compare_service import _load_metric_rows, _port_map_dict
|
from ..biz_state.compare_service import _load_metric_rows, _port_map_dict, template_metrics
|
||||||
from ..models import (
|
from ..models import (
|
||||||
|
BizCompareTemplate,
|
||||||
BizMigrationBatch,
|
BizMigrationBatch,
|
||||||
BizMigrationDiff,
|
BizMigrationDiff,
|
||||||
BizMigrationProject,
|
BizMigrationProject,
|
||||||
BizMigrationRedTicket,
|
BizMigrationRedTicket,
|
||||||
BizMigrationRun,
|
BizMigrationRun,
|
||||||
|
BizMonitorTemplate,
|
||||||
BizPortMapping,
|
BizPortMapping,
|
||||||
BizStateBatch,
|
BizStateBatch,
|
||||||
BizStateTask,
|
BizStateTask,
|
||||||
)
|
)
|
||||||
from ..timeutil import utcnow_naive
|
from ..timeutil import utcnow_naive
|
||||||
from .evaluate import PORT_METRIC_ID, evaluate_metric_dual, parse_expect_set, port_sheet_def
|
from . import monitor_templates as mon_tpl
|
||||||
|
from .evaluate import (
|
||||||
|
PORT_METRIC_ID,
|
||||||
|
evaluate_metric_dual,
|
||||||
|
override_for_metric,
|
||||||
|
parse_expect_set,
|
||||||
|
port_sheet_def,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
|
def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
|
||||||
|
|
@ -52,6 +61,72 @@ def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": ""}
|
||||||
|
row = db.get(BizMonitorTemplate, tid)
|
||||||
|
if not row:
|
||||||
|
return {"id": tid, "name": "", "compare_template_id": "", "compare_template_name": ""}
|
||||||
|
cmp_name = ""
|
||||||
|
if row.compare_template_id:
|
||||||
|
ct = db.get(BizCompareTemplate, row.compare_template_id)
|
||||||
|
cmp_name = (ct.name if ct else "") or ""
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"name": row.name or "",
|
||||||
|
"compare_template_id": row.compare_template_id or "",
|
||||||
|
"compare_template_name": cmp_name,
|
||||||
|
"collect_metric_ids": list(row.collect_metric_ids_json or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
||||||
|
"""Return (sheets, sheet_overrides, defaults) from monitor → compare template."""
|
||||||
|
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
|
||||||
|
# Fallback: built-in port sheet (legacy)
|
||||||
|
return [port_sheet_def()], overrides, defaults
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject) -> list[str]:
|
||||||
|
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)
|
||||||
|
return [str(s.get("metric_id") or "").strip() for s in sheets if str(s.get("metric_id") or "").strip()]
|
||||||
|
|
||||||
|
|
||||||
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
|
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
|
|
@ -61,6 +136,8 @@ def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
|
||||||
"old_baseline_batch_id": p.old_baseline_batch_id,
|
"old_baseline_batch_id": p.old_baseline_batch_id,
|
||||||
"new_baseline_batch_id": p.new_baseline_batch_id,
|
"new_baseline_batch_id": p.new_baseline_batch_id,
|
||||||
"mapping_id": p.mapping_id,
|
"mapping_id": p.mapping_id,
|
||||||
|
"monitor_template_id": getattr(p, "monitor_template_id", None) or "",
|
||||||
|
"monitor_template": _monitor_template_brief(db, getattr(p, "monitor_template_id", None) or ""),
|
||||||
"status": p.status,
|
"status": p.status,
|
||||||
"note": p.note,
|
"note": p.note,
|
||||||
"old_task": _task_brief(db, p.old_task_id),
|
"old_task": _task_brief(db, p.old_task_id),
|
||||||
|
|
@ -108,6 +185,12 @@ def create_project(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
mapping_id = str(body.get("mapping_id") or "").strip()
|
mapping_id = str(body.get("mapping_id") or "").strip()
|
||||||
if mapping_id and not db.get(BizPortMapping, mapping_id):
|
if mapping_id and not db.get(BizPortMapping, mapping_id):
|
||||||
raise HTTPException(status_code=404, detail="mapping_not_found")
|
raise HTTPException(status_code=404, detail="mapping_not_found")
|
||||||
|
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)
|
||||||
p = BizMigrationProject(
|
p = BizMigrationProject(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
name=name,
|
name=name,
|
||||||
|
|
@ -116,6 +199,7 @@ def create_project(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
old_baseline_batch_id=str(body.get("old_baseline_batch_id") or "").strip(),
|
old_baseline_batch_id=str(body.get("old_baseline_batch_id") or "").strip(),
|
||||||
new_baseline_batch_id=str(body.get("new_baseline_batch_id") or "").strip(),
|
new_baseline_batch_id=str(body.get("new_baseline_batch_id") or "").strip(),
|
||||||
mapping_id=mapping_id,
|
mapping_id=mapping_id,
|
||||||
|
monitor_template_id=monitor_template_id,
|
||||||
status=str(body.get("status") or "draft").strip() or "draft",
|
status=str(body.get("status") or "draft").strip() or "draft",
|
||||||
note=str(body.get("note") or "")[:500],
|
note=str(body.get("note") or "")[:500],
|
||||||
)
|
)
|
||||||
|
|
@ -157,6 +241,11 @@ def patch_project(db: Session, project_id: str, body: dict[str, Any]) -> dict[st
|
||||||
if bid and not db.get(BizStateBatch, bid):
|
if bid and not db.get(BizStateBatch, bid):
|
||||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||||
p.new_baseline_batch_id = bid
|
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
|
||||||
p.updated_at = utcnow_naive()
|
p.updated_at = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
|
|
@ -270,7 +359,8 @@ def run_evaluate(
|
||||||
expect = parse_expect_set(mb.expect_set_json if isinstance(mb.expect_set_json, dict) else {})
|
expect = parse_expect_set(mb.expect_set_json if isinstance(mb.expect_set_json, dict) else {})
|
||||||
# Final acceptance: window closed → unfinished expect = red
|
# Final acceptance: window closed → unfinished expect = red
|
||||||
window_active = (mb.status == "active") and (not acceptance)
|
window_active = (mb.status == "active") and (not acceptance)
|
||||||
sheets = [port_sheet_def()]
|
mt = resolve_project_monitor_template(db, proj)
|
||||||
|
sheets, sheet_overrides, _defaults = resolve_evaluate_sheets(db, mt)
|
||||||
|
|
||||||
sheet_cards: list[dict[str, Any]] = []
|
sheet_cards: list[dict[str, Any]] = []
|
||||||
all_rows: list[dict[str, Any]] = []
|
all_rows: list[dict[str, Any]] = []
|
||||||
|
|
@ -278,13 +368,15 @@ def run_evaluate(
|
||||||
verdict_counts: dict[str, int] = {}
|
verdict_counts: dict[str, int] = {}
|
||||||
|
|
||||||
for sheet in sheets:
|
for sheet in sheets:
|
||||||
mid = sheet["metric_id"]
|
mid = str(sheet.get("metric_id") or "").strip()
|
||||||
key_fields = list(sheet.get("key_fields") or [])
|
key_fields = list(sheet.get("key_fields") or [])
|
||||||
if not key_fields:
|
if not mid or not key_fields:
|
||||||
continue
|
continue
|
||||||
iface_fields = list(sheet.get("iface_fields") or [])
|
iface_fields = list(sheet.get("iface_fields") or [])
|
||||||
compare_fields = list(sheet.get("compare_fields") or [])
|
compare_fields = list(sheet.get("compare_fields") or [])
|
||||||
row_filters = list(sheet.get("row_filters") or [])
|
row_filters = list(sheet.get("row_filters") or [])
|
||||||
|
field_rules = list(sheet.get("field_rules") or [])
|
||||||
|
sheet_ov = override_for_metric(sheet_overrides, mid)
|
||||||
|
|
||||||
old_base = apply_row_filters(
|
old_base = apply_row_filters(
|
||||||
_load_metric_rows(db, batch_id=proj.old_baseline_batch_id, metric_id=mid),
|
_load_metric_rows(db, batch_id=proj.old_baseline_batch_id, metric_id=mid),
|
||||||
|
|
@ -314,15 +406,17 @@ def run_evaluate(
|
||||||
old_current_rows=old_now,
|
old_current_rows=old_now,
|
||||||
new_baseline_rows=new_base_rows,
|
new_baseline_rows=new_base_rows,
|
||||||
new_current_rows=new_now,
|
new_current_rows=new_now,
|
||||||
port_map=port_map,
|
port_map=port_map if iface_fields else {},
|
||||||
expect=expect,
|
expect=expect,
|
||||||
window_active=window_active,
|
window_active=window_active,
|
||||||
acceptance=acceptance,
|
acceptance=acceptance,
|
||||||
|
field_rules=field_rules,
|
||||||
|
sheet_override=sheet_ov,
|
||||||
)
|
)
|
||||||
sheet_cards.append(
|
sheet_cards.append(
|
||||||
{
|
{
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"title": "端口状态",
|
"title": mid,
|
||||||
"progress_ok": one["progress_ok"],
|
"progress_ok": one["progress_ok"],
|
||||||
"progress_total": one["progress_total"],
|
"progress_total": one["progress_total"],
|
||||||
"anomaly": one["anomaly"],
|
"anomaly": one["anomaly"],
|
||||||
|
|
@ -347,7 +441,9 @@ def run_evaluate(
|
||||||
purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32],
|
purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32],
|
||||||
status="success",
|
status="success",
|
||||||
summary_json={
|
summary_json={
|
||||||
"metric_focus": PORT_METRIC_ID,
|
"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,
|
"acceptance": acceptance,
|
||||||
"sheet_cards": sheet_cards,
|
"sheet_cards": sheet_cards,
|
||||||
"progress": {
|
"progress": {
|
||||||
|
|
@ -486,25 +582,86 @@ def list_baseline_ports(db: Session, project_id: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _iface_brief_item(*, vendor: str, device_type: str) -> dict[str, Any]:
|
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, _, _ = resolve_evaluate_sheets(db, mt)
|
||||||
|
port_map = _port_map_dict(db, p.mapping_id)
|
||||||
|
out_sheets: list[dict[str, Any]] = []
|
||||||
|
for sheet in sheets:
|
||||||
|
mid = str(sheet.get("metric_id") or "").strip()
|
||||||
|
key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()]
|
||||||
|
if not mid or not key_fields:
|
||||||
|
continue
|
||||||
|
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
|
||||||
|
rows_raw = apply_row_filters(
|
||||||
|
_load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid),
|
||||||
|
list(sheet.get("row_filters") or []),
|
||||||
|
)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for r in rows_raw:
|
||||||
|
keys = [str(r.get(f) or "").strip() for f in key_fields]
|
||||||
|
if not any(keys):
|
||||||
|
continue
|
||||||
|
key_str = "|".join(keys)
|
||||||
|
mapped = ""
|
||||||
|
if iface_fields and key_fields and key_fields[0] in iface_fields:
|
||||||
|
mapped = port_map.get(keys[0]) or ""
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"key": key_str,
|
||||||
|
"keys": keys,
|
||||||
|
"mapped_to": mapped,
|
||||||
|
"label": key_str,
|
||||||
|
"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,
|
||||||
|
"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 ..biz_state.profiles import profiles_for_vendor
|
||||||
from ..lldp_shared import resolve_vendor_key
|
from ..lldp_shared import resolve_vendor_key
|
||||||
|
|
||||||
|
mid = str(metric_id or "").strip()
|
||||||
vkey = resolve_vendor_key(vendor, device_type)
|
vkey = resolve_vendor_key(vendor, device_type)
|
||||||
for p in profiles_for_vendor(vkey):
|
for p in profiles_for_vendor(vkey):
|
||||||
if p.metric_id == PORT_METRIC_ID and p.kind == "collect":
|
if p.metric_id == mid and p.kind == "collect":
|
||||||
return {
|
return {
|
||||||
"source_profile_id": p.profile_id,
|
"source_profile_id": p.profile_id,
|
||||||
"kind": "catalog",
|
"kind": "catalog",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"title": p.title or "interface brief",
|
"title": p.title or mid,
|
||||||
}
|
}
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"no_interface_brief_profile_for_vendor:{vkey or vendor or 'unknown'}",
|
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]:
|
def _enabled_metric_ids(db: Session, task_id: str) -> set[str]:
|
||||||
from ..biz_state.profiles import get_profile
|
from ..biz_state.profiles import get_profile
|
||||||
from ..models import BizStateTaskItem
|
from ..models import BizStateTaskItem
|
||||||
|
|
@ -525,10 +682,15 @@ def _enabled_metric_ids(db: Session, task_id: str) -> set[str]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _is_port_highfreq_task(db: Session, task: BizStateTask) -> bool:
|
def _is_highfreq_task(db: Session, task: BizStateTask, want_metrics: set[str]) -> bool:
|
||||||
"""True when task is interface_brief-only with short interval (cutover HF)."""
|
"""True when task metrics match want set and interval is short (cutover HF)."""
|
||||||
metrics = _enabled_metric_ids(db, task.id)
|
metrics = _enabled_metric_ids(db, task.id)
|
||||||
return metrics == {PORT_METRIC_ID} and int(task.interval_sec or 0) <= 300
|
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 _ensure_side_highfreq(
|
def _ensure_side_highfreq(
|
||||||
|
|
@ -538,19 +700,31 @@ def _ensure_side_highfreq(
|
||||||
project_name: str,
|
project_name: str,
|
||||||
interval_sec: int,
|
interval_sec: int,
|
||||||
retention_days: int,
|
retention_days: int,
|
||||||
|
metric_ids: list[str],
|
||||||
) -> tuple[BizStateTask, bool]:
|
) -> tuple[BizStateTask, bool]:
|
||||||
"""Return (task, created). Reuse if already HF port-only; else create sibling."""
|
"""Return (task, created). Reuse if already HF for metric set; else create sibling."""
|
||||||
from ..biz_state import service as biz_svc
|
from ..biz_state import service as biz_svc
|
||||||
|
|
||||||
if _is_port_highfreq_task(db, template):
|
want = {str(m).strip() for m in metric_ids if str(m).strip()}
|
||||||
|
if not want:
|
||||||
|
want = {PORT_METRIC_ID}
|
||||||
|
|
||||||
|
if _is_highfreq_task(db, template, want):
|
||||||
if template.status != "running":
|
if template.status != "running":
|
||||||
biz_svc.update_task(db, template.id, {"status": "running"})
|
biz_svc.update_task(db, template.id, {"status": "running"})
|
||||||
refreshed = db.get(BizStateTask, template.id)
|
refreshed = db.get(BizStateTask, template.id)
|
||||||
return refreshed or template, False
|
return refreshed or template, False
|
||||||
return template, False
|
return template, False
|
||||||
|
|
||||||
item = _iface_brief_item(vendor=template.vendor, device_type=template.device_type)
|
items = [
|
||||||
note = f"割接高频-端口/{project_name}"[:256]
|
_catalog_item_for_metric(
|
||||||
|
vendor=template.vendor,
|
||||||
|
device_type=template.device_type,
|
||||||
|
metric_id=mid,
|
||||||
|
)
|
||||||
|
for mid in sorted(want)
|
||||||
|
]
|
||||||
|
note = f"割接高频/{'+'.join(sorted(want)[:3])}/{project_name}"[:256]
|
||||||
created = biz_svc.create_task(
|
created = biz_svc.create_task(
|
||||||
db,
|
db,
|
||||||
{
|
{
|
||||||
|
|
@ -564,7 +738,7 @@ def _ensure_side_highfreq(
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"interval_sec": interval_sec,
|
"interval_sec": interval_sec,
|
||||||
"retention_days": retention_days,
|
"retention_days": retention_days,
|
||||||
"items": [item],
|
"items": items,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
task = db.get(BizStateTask, str(created.get("id") or ""))
|
task = db.get(BizStateTask, str(created.get("id") or ""))
|
||||||
|
|
@ -581,11 +755,25 @@ def ensure_port_highfreq(
|
||||||
retention_days: int = 7,
|
retention_days: int = 7,
|
||||||
collect_now: bool = True,
|
collect_now: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create/bind interface_brief-only high-freq biz_state tasks for old/new NEs.
|
"""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,
|
||||||
|
)
|
||||||
|
|
||||||
Collection stays in biz_state — migration only points at the tasks.
|
|
||||||
Later metrics can be added on the same tasks via the biz-state UI.
|
def ensure_highfreq(
|
||||||
"""
|
db: Session,
|
||||||
|
project_id: str,
|
||||||
|
*,
|
||||||
|
interval_sec: int = 60,
|
||||||
|
retention_days: int = 7,
|
||||||
|
collect_now: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create/bind HF collect tasks from project's monitor template collect_metric_ids."""
|
||||||
from ..biz_state.collect_runner import dispatch_collect
|
from ..biz_state.collect_runner import dispatch_collect
|
||||||
|
|
||||||
proj = db.get(BizMigrationProject, project_id)
|
proj = db.get(BizMigrationProject, project_id)
|
||||||
|
|
@ -596,6 +784,7 @@ def ensure_port_highfreq(
|
||||||
if not old_tpl or not new_tpl:
|
if not old_tpl or not new_tpl:
|
||||||
raise HTTPException(status_code=400, detail="old_new_task_required")
|
raise HTTPException(status_code=400, detail="old_new_task_required")
|
||||||
|
|
||||||
|
metric_ids = resolve_collect_metric_ids(db, proj)
|
||||||
iv = max(60, int(interval_sec or 60))
|
iv = max(60, int(interval_sec or 60))
|
||||||
ret = max(1, int(retention_days or 7))
|
ret = max(1, int(retention_days or 7))
|
||||||
old_task, old_created = _ensure_side_highfreq(
|
old_task, old_created = _ensure_side_highfreq(
|
||||||
|
|
@ -604,8 +793,8 @@ def ensure_port_highfreq(
|
||||||
project_name=proj.name,
|
project_name=proj.name,
|
||||||
interval_sec=iv,
|
interval_sec=iv,
|
||||||
retention_days=ret,
|
retention_days=ret,
|
||||||
|
metric_ids=metric_ids,
|
||||||
)
|
)
|
||||||
# re-load templates after possible commits inside ensure
|
|
||||||
new_tpl = db.get(BizStateTask, proj.new_task_id)
|
new_tpl = db.get(BizStateTask, proj.new_task_id)
|
||||||
if not new_tpl:
|
if not new_tpl:
|
||||||
raise HTTPException(status_code=400, detail="new_task_required")
|
raise HTTPException(status_code=400, detail="new_task_required")
|
||||||
|
|
@ -615,6 +804,7 @@ def ensure_port_highfreq(
|
||||||
project_name=proj.name,
|
project_name=proj.name,
|
||||||
interval_sec=iv,
|
interval_sec=iv,
|
||||||
retention_days=ret,
|
retention_days=ret,
|
||||||
|
metric_ids=metric_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
proj = db.get(BizMigrationProject, project_id)
|
proj = db.get(BizMigrationProject, project_id)
|
||||||
|
|
@ -641,6 +831,7 @@ def ensure_port_highfreq(
|
||||||
"old_created": old_created,
|
"old_created": old_created,
|
||||||
"new_created": new_created,
|
"new_created": new_created,
|
||||||
"interval_sec": iv,
|
"interval_sec": iv,
|
||||||
|
"collect_metric_ids": metric_ids,
|
||||||
"collect": collect,
|
"collect": collect,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class ProjectIn(BaseModel):
|
||||||
old_baseline_batch_id: str = ""
|
old_baseline_batch_id: str = ""
|
||||||
new_baseline_batch_id: str = ""
|
new_baseline_batch_id: str = ""
|
||||||
mapping_id: str = ""
|
mapping_id: str = ""
|
||||||
|
monitor_template_id: str = ""
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ class ProjectPatchIn(BaseModel):
|
||||||
old_baseline_batch_id: str | None = None
|
old_baseline_batch_id: str | None = None
|
||||||
new_baseline_batch_id: str | None = None
|
new_baseline_batch_id: str | None = None
|
||||||
mapping_id: str | None = None
|
mapping_id: str | None = None
|
||||||
|
monitor_template_id: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
@ -103,6 +105,12 @@ def api_baseline_ports(project_id: str, db: Session = Depends(get_db)):
|
||||||
return svc.list_baseline_ports(db, project_id)
|
return svc.list_baseline_ports(db, project_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{project_id}/baseline-expect")
|
||||||
|
def api_baseline_expect(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Multi-metric baseline keys for expect-set picking (from monitor template sheets)."""
|
||||||
|
return svc.list_baseline_expect_objects(db, project_id)
|
||||||
|
|
||||||
|
|
||||||
class EnsureHighfreqIn(BaseModel):
|
class EnsureHighfreqIn(BaseModel):
|
||||||
interval_sec: int = 60
|
interval_sec: int = 60
|
||||||
retention_days: int = 7
|
retention_days: int = 7
|
||||||
|
|
@ -110,14 +118,15 @@ class EnsureHighfreqIn(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects/{project_id}/ensure-port-highfreq")
|
@router.post("/projects/{project_id}/ensure-port-highfreq")
|
||||||
|
@router.post("/projects/{project_id}/ensure-highfreq")
|
||||||
def api_ensure_port_highfreq(
|
def api_ensure_port_highfreq(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
body: EnsureHighfreqIn | None = None,
|
body: EnsureHighfreqIn | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Create/bind biz_state high-freq interface_brief tasks for old/new NEs."""
|
"""Create/bind HF biz_state tasks from monitor template collect_metric_ids."""
|
||||||
payload = body.model_dump() if body else {}
|
payload = body.model_dump() if body else {}
|
||||||
return svc.ensure_port_highfreq(
|
return svc.ensure_highfreq(
|
||||||
db,
|
db,
|
||||||
project_id,
|
project_id,
|
||||||
interval_sec=int(payload.get("interval_sec") or 60),
|
interval_sec=int(payload.get("interval_sec") or 60),
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
")",
|
")",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_name ON biz_monitor_template (name)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_name ON biz_monitor_template (name)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS monitor_template_id VARCHAR(64) DEFAULT ''",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_monitor_tpl ON biz_migration_project (monitor_template_id)",
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
_run_sql(conn, sql)
|
_run_sql(conn, sql)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ class BizMigrationProject(Base):
|
||||||
new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
# Reuse biz_port_mapping (before_if=old, after_if=new)
|
# Reuse biz_port_mapping (before_if=old, after_if=new)
|
||||||
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
# BizMonitorTemplate — HOW (via compare template) + dual/status rules
|
||||||
|
monitor_template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|active|done
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|active|done
|
||||||
note: Mapped[str] = mapped_column(String(512), default="")
|
note: Mapped[str] = mapped_column(String(512), default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from netx_api.biz_migration.evaluate import (
|
from netx_api.biz_migration.evaluate import (
|
||||||
|
classify_status,
|
||||||
dual_verdict,
|
dual_verdict,
|
||||||
evaluate_metric_dual,
|
evaluate_metric_dual,
|
||||||
parse_expect_set,
|
parse_expect_set,
|
||||||
|
|
@ -14,12 +15,33 @@ from netx_api.biz_migration.evaluate import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PORT_OVERRIDE = {
|
||||||
|
"metric_id": "interface_brief",
|
||||||
|
"status_fields": ["admin", "phy", "prot"],
|
||||||
|
"down_values": ["down"],
|
||||||
|
"up_values": ["up"],
|
||||||
|
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ParseExpectSetTests(unittest.TestCase):
|
class ParseExpectSetTests(unittest.TestCase):
|
||||||
def test_ports_go_to_interface_brief(self):
|
def test_ports_go_to_interface_brief(self):
|
||||||
got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]})
|
got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]})
|
||||||
self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"})
|
self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"})
|
||||||
self.assertEqual(got["_ports"], {"gei-1", "gei-2"})
|
self.assertEqual(got["_ports"], {"gei-1", "gei-2"})
|
||||||
|
|
||||||
|
def test_items_multi_metric(self):
|
||||||
|
got = parse_expect_set(
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{"metric_id": "bgp_peer", "keys": ["AS1", "1.1.1.1"]},
|
||||||
|
{"metric_id": "arp", "key": "10.0.0.1"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(got["bgp_peer"], {"AS1|1.1.1.1"})
|
||||||
|
self.assertEqual(got["arp"], {"10.0.0.1"})
|
||||||
|
|
||||||
|
|
||||||
class PortSheetTests(unittest.TestCase):
|
class PortSheetTests(unittest.TestCase):
|
||||||
def test_port_sheet_fields(self):
|
def test_port_sheet_fields(self):
|
||||||
|
|
@ -36,6 +58,20 @@ class PortSheetTests(unittest.TestCase):
|
||||||
self.assertEqual(port_status_label({}), "—")
|
self.assertEqual(port_status_label({}), "—")
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifyStatusTests(unittest.TestCase):
|
||||||
|
def test_up_down(self):
|
||||||
|
self.assertEqual(
|
||||||
|
classify_status({"admin": "up", "phy": "up", "prot": "up"}, PORT_OVERRIDE),
|
||||||
|
"up",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
classify_status({"admin": "up", "phy": "down", "prot": "up"}, PORT_OVERRIDE),
|
||||||
|
"down",
|
||||||
|
)
|
||||||
|
self.assertEqual(classify_status({}, PORT_OVERRIDE), "none")
|
||||||
|
self.assertEqual(classify_status({"admin": "up"}, None), "none")
|
||||||
|
|
||||||
|
|
||||||
class VerdictTests(unittest.TestCase):
|
class VerdictTests(unittest.TestCase):
|
||||||
def test_side_anomaly_gone(self):
|
def test_side_anomaly_gone(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
@ -70,7 +106,6 @@ class VerdictTests(unittest.TestCase):
|
||||||
),
|
),
|
||||||
("unfinished", "red"),
|
("unfinished", "red"),
|
||||||
)
|
)
|
||||||
# window mode still yellow
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dual_verdict(
|
dual_verdict(
|
||||||
old_kind="unchanged",
|
old_kind="unchanged",
|
||||||
|
|
@ -82,6 +117,20 @@ class VerdictTests(unittest.TestCase):
|
||||||
("migrating", "yellow"),
|
("migrating", "yellow"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_dual_down_up_via_success_patterns(self):
|
||||||
|
self.assertEqual(
|
||||||
|
dual_verdict(
|
||||||
|
old_kind="changed",
|
||||||
|
new_kind="changed",
|
||||||
|
in_expect=True,
|
||||||
|
window_active=True,
|
||||||
|
old_status="down",
|
||||||
|
new_status="up",
|
||||||
|
success_patterns=PORT_OVERRIDE["success"],
|
||||||
|
),
|
||||||
|
("migrated", "green"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EvaluateMetricDualTests(unittest.TestCase):
|
class EvaluateMetricDualTests(unittest.TestCase):
|
||||||
def test_port_migration_happy_path(self):
|
def test_port_migration_happy_path(self):
|
||||||
|
|
@ -101,6 +150,7 @@ class EvaluateMetricDualTests(unittest.TestCase):
|
||||||
port_map={"gei-old": "gei-new"},
|
port_map={"gei-old": "gei-new"},
|
||||||
expect=expect,
|
expect=expect,
|
||||||
window_active=True,
|
window_active=True,
|
||||||
|
sheet_override=PORT_OVERRIDE,
|
||||||
)
|
)
|
||||||
self.assertEqual(out["progress_total"], 1)
|
self.assertEqual(out["progress_total"], 1)
|
||||||
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
|
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
|
||||||
|
|
@ -110,6 +160,52 @@ class EvaluateMetricDualTests(unittest.TestCase):
|
||||||
self.assertEqual(migrated[0]["new_status"], "up/up/up")
|
self.assertEqual(migrated[0]["new_status"], "up/up/up")
|
||||||
self.assertEqual(out["progress_ok"], 1)
|
self.assertEqual(out["progress_ok"], 1)
|
||||||
|
|
||||||
|
def test_port_down_to_up_is_migrated(self):
|
||||||
|
old_base = [{"interface": "gei-old", "admin": "up", "phy": "up", "prot": "up"}]
|
||||||
|
old_cur = [{"interface": "gei-old", "admin": "down", "phy": "down", "prot": "down"}]
|
||||||
|
new_cur = [{"interface": "gei-new", "admin": "up", "phy": "up", "prot": "up"}]
|
||||||
|
expect = parse_expect_set({"ports": ["gei-old"]})
|
||||||
|
out = evaluate_metric_dual(
|
||||||
|
metric_id="interface_brief",
|
||||||
|
key_fields=["interface"],
|
||||||
|
iface_fields=["interface"],
|
||||||
|
compare_fields=["admin", "phy", "prot"],
|
||||||
|
old_baseline_rows=old_base,
|
||||||
|
old_current_rows=old_cur,
|
||||||
|
new_baseline_rows=None,
|
||||||
|
new_current_rows=new_cur,
|
||||||
|
port_map={"gei-old": "gei-new"},
|
||||||
|
expect=expect,
|
||||||
|
window_active=True,
|
||||||
|
sheet_override=PORT_OVERRIDE,
|
||||||
|
)
|
||||||
|
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
|
||||||
|
self.assertTrue(migrated, out["rows"])
|
||||||
|
self.assertEqual(out["progress_ok"], 1)
|
||||||
|
|
||||||
|
def test_bgp_presence_only_migrated(self):
|
||||||
|
"""Metrics without status_fields use kind-only dual (presence)."""
|
||||||
|
old_base = [{"peer": "1.1.1.1", "state": "Established"}]
|
||||||
|
old_cur: list[dict] = []
|
||||||
|
new_cur = [{"peer": "1.1.1.1", "state": "Established"}]
|
||||||
|
expect = parse_expect_set({"items": [{"metric_id": "bgp_peer", "key": "1.1.1.1"}]})
|
||||||
|
out = evaluate_metric_dual(
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
key_fields=["peer"],
|
||||||
|
iface_fields=[],
|
||||||
|
compare_fields=[],
|
||||||
|
old_baseline_rows=old_base,
|
||||||
|
old_current_rows=old_cur,
|
||||||
|
new_baseline_rows=None,
|
||||||
|
new_current_rows=new_cur,
|
||||||
|
port_map={},
|
||||||
|
expect=expect,
|
||||||
|
window_active=True,
|
||||||
|
sheet_override={},
|
||||||
|
)
|
||||||
|
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
|
||||||
|
self.assertTrue(migrated, out["rows"])
|
||||||
|
|
||||||
def test_unexpected_loss_is_anomaly(self):
|
def test_unexpected_loss_is_anomaly(self):
|
||||||
old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}]
|
old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}]
|
||||||
old_cur: list[dict] = []
|
old_cur: list[dict] = []
|
||||||
|
|
|
||||||
|
|
@ -463,13 +463,18 @@ const en = {
|
||||||
sectionName: "1. Task name",
|
sectionName: "1. Task name",
|
||||||
sectionTasks: "2. Bind collect tasks (required)",
|
sectionTasks: "2. Bind collect tasks (required)",
|
||||||
sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.",
|
sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.",
|
||||||
sectionOptional: "3. Port mapping & baselines (optional)",
|
sectionOptional: "3. Monitor template, mapping & baselines (optional)",
|
||||||
sectionOptionalHint: "Skip for now if needed; set later under Baseline. Batches load after a collect task is chosen.",
|
sectionOptionalHint:
|
||||||
|
"Skip for now if needed; set later under Baseline. Defaults to the port cutover monitor template when omitted.",
|
||||||
sectionPair: "Bound collect tasks",
|
sectionPair: "Bound collect tasks",
|
||||||
sectionPairHint: "Collect tasks are fixed at create time; adjust mapping and baselines here.",
|
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
|
||||||
sectionBaseline: "Baseline batches",
|
sectionBaseline: "Baseline batches",
|
||||||
sectionBaselineHint: "Old baseline feeds the expected-port picker; new baseline is optional.",
|
sectionBaselineHint: "Old baseline feeds the expected-object picker; new baseline is optional.",
|
||||||
optionalNone: "None",
|
optionalNone: "None",
|
||||||
|
monitorTemplate: "Monitor template",
|
||||||
|
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to port cutover.",
|
||||||
|
compareTemplate: "Compare template",
|
||||||
|
compareTemplateBound: "Bound compare template: {{name}}",
|
||||||
portMappingHint: "From Business compare port mappings; leave empty if none yet.",
|
portMappingHint: "From Business compare port mappings; leave empty if none yet.",
|
||||||
baselineNeedTask: "Select the matching collect task first",
|
baselineNeedTask: "Select the matching collect task first",
|
||||||
baselinePickHint: "Choose from that task’s collect batches",
|
baselinePickHint: "Choose from that task’s collect batches",
|
||||||
|
|
@ -479,8 +484,8 @@ const en = {
|
||||||
oldBaseline: "Old baseline batch",
|
oldBaseline: "Old baseline batch",
|
||||||
newBaseline: "New baseline batch (optional)",
|
newBaseline: "New baseline batch (optional)",
|
||||||
needProjectFields: "Enter a name and pick old/new collect tasks",
|
needProjectFields: "Enter a name and pick old/new collect tasks",
|
||||||
needExpectPorts: "Select at least one expected port for this batch",
|
needExpectPorts: "Select at least one expected object for this batch",
|
||||||
needBaselineFirst: "Save an old baseline batch first to list ports",
|
needBaselineFirst: "Save an old baseline batch first to list expect objects",
|
||||||
needBizStateTasks: "No business-monitor tasks yet. Create one first:",
|
needBizStateTasks: "No business-monitor tasks yet. Create one first:",
|
||||||
projectCreated: "Task created",
|
projectCreated: "Task created",
|
||||||
projects: "Tasks",
|
projects: "Tasks",
|
||||||
|
|
@ -506,11 +511,12 @@ const en = {
|
||||||
tabSetup: "Baseline",
|
tabSetup: "Baseline",
|
||||||
tabBatches: "Batches",
|
tabBatches: "Batches",
|
||||||
tabBoard: "Board",
|
tabBoard: "Board",
|
||||||
saveBaseline: "Save baseline / mapping",
|
saveBaseline: "Save baseline / mapping / template",
|
||||||
baselineSaved: "Baseline saved",
|
baselineSaved: "Baseline saved",
|
||||||
pickExpectPorts: "Expected ports (from old baseline)",
|
pickExpectPorts: "Expected objects (from old baseline)",
|
||||||
portFilterPh: "Filter interface / description",
|
portFilterPh: "Filter object / description",
|
||||||
emptyBaselinePorts: "No interface_brief rows in baseline (enable that profile on the task)",
|
emptyBaselinePorts:
|
||||||
|
"No matching metric rows in baseline (confirm monitor template and collect profiles)",
|
||||||
batchLabel: "Batch label",
|
batchLabel: "Batch label",
|
||||||
defaultBatchLabel: "Night N",
|
defaultBatchLabel: "Night N",
|
||||||
expectPorts: "Expected ports (old side)",
|
expectPorts: "Expected ports (old side)",
|
||||||
|
|
@ -527,7 +533,8 @@ const en = {
|
||||||
metricPort: "Port status",
|
metricPort: "Port status",
|
||||||
progress: "Progress",
|
progress: "Progress",
|
||||||
anomaly: "Anomaly",
|
anomaly: "Anomaly",
|
||||||
onlyExpect: "Expected ports only",
|
onlyExpect: "Expected objects only",
|
||||||
|
allMetrics: "All metrics",
|
||||||
colMetric: "Metric",
|
colMetric: "Metric",
|
||||||
colKey: "Object",
|
colKey: "Object",
|
||||||
colPort: "Old port",
|
colPort: "Old port",
|
||||||
|
|
@ -548,10 +555,10 @@ const en = {
|
||||||
verdictNotInvolved: "Not involved",
|
verdictNotInvolved: "Not involved",
|
||||||
verdictUnexpected: "Unexpected new",
|
verdictUnexpected: "Unexpected new",
|
||||||
verdictOk: "OK",
|
verdictOk: "OK",
|
||||||
enableHighfreq: "Enable high-freq port collect",
|
enableHighfreq: "Enable high-freq collect",
|
||||||
highfreqHint:
|
highfreqHint:
|
||||||
"Creates/reuses biz-state tasks with interface_brief only on old/new NEs. Add BGP etc. later on the same tasks in Business monitor.",
|
"Creates/reuses high-freq biz-state tasks on old/new NEs using the monitor template’s collect_metric_ids (empty = all compare-template metrics).",
|
||||||
highfreqReady: "High-freq port collect tasks ready and bound to this task",
|
highfreqReady: "High-freq collect tasks ready and bound to this task",
|
||||||
collectNow: "Collect old+new now",
|
collectNow: "Collect old+new now",
|
||||||
collectTriggered: "Collect triggered",
|
collectTriggered: "Collect triggered",
|
||||||
openBizState: "Open business monitor",
|
openBizState: "Open business monitor",
|
||||||
|
|
|
||||||
|
|
@ -460,13 +460,17 @@ const zh = {
|
||||||
sectionName: "1. 任务名称",
|
sectionName: "1. 任务名称",
|
||||||
sectionTasks: "2. 绑定采集任务(必填)",
|
sectionTasks: "2. 绑定采集任务(必填)",
|
||||||
sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。",
|
sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。",
|
||||||
sectionOptional: "3. 端口映射与基线(可选)",
|
sectionOptional: "3. 监控模板、映射与基线(可选)",
|
||||||
sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。选了采集任务后才会加载对应批次。",
|
sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时默认绑端口割接监控。",
|
||||||
sectionPair: "已绑定采集任务",
|
sectionPair: "已绑定采集任务",
|
||||||
sectionPairHint: "采集任务在创建时绑定;此处只调整映射与基线。",
|
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
|
||||||
sectionBaseline: "基线批次",
|
sectionBaseline: "基线批次",
|
||||||
sectionBaselineHint: "老侧基线用于列出本批预期端口;新侧基线可选。",
|
sectionBaselineHint: "老侧基线用于列出本批预期对象;新侧基线可选。",
|
||||||
optionalNone: "不选",
|
optionalNone: "不选",
|
||||||
|
monitorTemplate: "监控模板",
|
||||||
|
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。",
|
||||||
|
compareTemplate: "对比模板",
|
||||||
|
compareTemplateBound: "绑定对比模板:{{name}}",
|
||||||
portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。",
|
portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。",
|
||||||
baselineNeedTask: "请先选择对应采集任务",
|
baselineNeedTask: "请先选择对应采集任务",
|
||||||
baselinePickHint: "从该任务的历史采集批次中选择",
|
baselinePickHint: "从该任务的历史采集批次中选择",
|
||||||
|
|
@ -476,8 +480,8 @@ const zh = {
|
||||||
oldBaseline: "老设备基线批次",
|
oldBaseline: "老设备基线批次",
|
||||||
newBaseline: "新设备基线批次(可选)",
|
newBaseline: "新设备基线批次(可选)",
|
||||||
needProjectFields: "请填写任务名并选择老/新采集任务",
|
needProjectFields: "请填写任务名并选择老/新采集任务",
|
||||||
needExpectPorts: "请先勾选本批预期迁移的端口",
|
needExpectPorts: "请先勾选本批预期迁移的对象",
|
||||||
needBaselineFirst: "请先保存老设备基线批次,才能列出端口",
|
needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象",
|
||||||
needBizStateTasks: "尚无业务监控任务,请先创建:",
|
needBizStateTasks: "尚无业务监控任务,请先创建:",
|
||||||
projectCreated: "任务已创建",
|
projectCreated: "任务已创建",
|
||||||
projects: "任务",
|
projects: "任务",
|
||||||
|
|
@ -503,11 +507,11 @@ const zh = {
|
||||||
tabSetup: "基线配置",
|
tabSetup: "基线配置",
|
||||||
tabBatches: "割接批次",
|
tabBatches: "割接批次",
|
||||||
tabBoard: "判定看板",
|
tabBoard: "判定看板",
|
||||||
saveBaseline: "保存基线/映射",
|
saveBaseline: "保存基线/映射/模板",
|
||||||
baselineSaved: "基线已保存",
|
baselineSaved: "基线已保存",
|
||||||
pickExpectPorts: "本批预期端口(来自老基线)",
|
pickExpectPorts: "本批预期对象(来自老基线)",
|
||||||
portFilterPh: "筛选接口名 / 描述",
|
portFilterPh: "筛选对象 / 描述",
|
||||||
emptyBaselinePorts: "基线中无端口状态数据(确认任务已采 interface_brief)",
|
emptyBaselinePorts: "基线中无对应 metric 数据(确认监控模板与采集任务已勾选相应 profile)",
|
||||||
batchLabel: "批次标签",
|
batchLabel: "批次标签",
|
||||||
defaultBatchLabel: "第N晚",
|
defaultBatchLabel: "第N晚",
|
||||||
expectPorts: "本批预期端口(老侧)",
|
expectPorts: "本批预期端口(老侧)",
|
||||||
|
|
@ -524,7 +528,8 @@ const zh = {
|
||||||
metricPort: "端口状态",
|
metricPort: "端口状态",
|
||||||
progress: "进度",
|
progress: "进度",
|
||||||
anomaly: "异常",
|
anomaly: "异常",
|
||||||
onlyExpect: "仅看预期端口",
|
onlyExpect: "仅看预期对象",
|
||||||
|
allMetrics: "全部监控项",
|
||||||
colMetric: "监控项",
|
colMetric: "监控项",
|
||||||
colKey: "对象",
|
colKey: "对象",
|
||||||
colPort: "老侧端口",
|
colPort: "老侧端口",
|
||||||
|
|
@ -545,10 +550,10 @@ const zh = {
|
||||||
verdictNotInvolved: "未涉及",
|
verdictNotInvolved: "未涉及",
|
||||||
verdictUnexpected: "意外新增",
|
verdictUnexpected: "意外新增",
|
||||||
verdictOk: "正常",
|
verdictOk: "正常",
|
||||||
enableHighfreq: "开启高频端口采集",
|
enableHighfreq: "开启高频采集",
|
||||||
highfreqHint:
|
highfreqHint:
|
||||||
"在业务监控下为老/新设备各建(或复用)仅含 interface_brief 的任务并周期采集;后续要加 BGP 等直接去业务监控勾选即可。",
|
"按当前监控模板的 collect_metric_ids(空则取对比模板全量)为老/新设备各建或复用高频采集任务并勾选对应 profile。",
|
||||||
highfreqReady: "高频端口采集任务已就绪(已绑定到本任务)",
|
highfreqReady: "高频采集任务已就绪(已绑定到本任务)",
|
||||||
collectNow: "立即采集老+新",
|
collectNow: "立即采集老+新",
|
||||||
collectTriggered: "已触发采集",
|
collectTriggered: "已触发采集",
|
||||||
openBizState: "打开业务监控",
|
openBizState: "打开业务监控",
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ import {
|
||||||
bizMigrationCollectNow,
|
bizMigrationCollectNow,
|
||||||
bizMigrationCreateBatch,
|
bizMigrationCreateBatch,
|
||||||
bizMigrationCreateProject,
|
bizMigrationCreateProject,
|
||||||
bizMigrationEnsurePortHighfreq,
|
bizMigrationEnsureHighfreq,
|
||||||
bizMigrationEvaluate,
|
bizMigrationEvaluate,
|
||||||
bizMigrationFinishBatch,
|
bizMigrationFinishBatch,
|
||||||
bizMigrationGetBoard,
|
bizMigrationGetBoard,
|
||||||
bizMigrationListBaselinePorts,
|
bizMigrationListBaselineExpect,
|
||||||
bizMigrationListBatches,
|
bizMigrationListBatches,
|
||||||
bizMigrationListDiffs,
|
bizMigrationListDiffs,
|
||||||
bizMigrationListProjects,
|
bizMigrationListProjects,
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
bizMigrationPatchBatch,
|
bizMigrationPatchBatch,
|
||||||
bizMigrationPatchProject,
|
bizMigrationPatchProject,
|
||||||
bizMigrationResolveRedTicket,
|
bizMigrationResolveRedTicket,
|
||||||
|
bizMonitorListTemplates,
|
||||||
bizStateListBatches,
|
bizStateListBatches,
|
||||||
bizStateListTasks,
|
bizStateListTasks,
|
||||||
formatErr,
|
formatErr,
|
||||||
|
|
@ -31,6 +32,13 @@ import { formatSystemTime } from "../../utils/time";
|
||||||
import { jobChipColor, NmStatusChip } from "./nmChips";
|
import { jobChipColor, NmStatusChip } from "./nmChips";
|
||||||
|
|
||||||
type TaskOpt = { id: string; ne_name: string; ne_ip: string; note?: string; interval_sec?: number };
|
type TaskOpt = { id: string; ne_name: string; ne_ip: string; note?: string; interval_sec?: number };
|
||||||
|
type MonitorTplOpt = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
compare_template_id?: string;
|
||||||
|
compare_template_name?: string;
|
||||||
|
collect_metric_ids?: string[];
|
||||||
|
};
|
||||||
type Project = {
|
type Project = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -39,6 +47,8 @@ type Project = {
|
||||||
old_baseline_batch_id: string;
|
old_baseline_batch_id: string;
|
||||||
new_baseline_batch_id: string;
|
new_baseline_batch_id: string;
|
||||||
mapping_id: string;
|
mapping_id: string;
|
||||||
|
monitor_template_id?: string;
|
||||||
|
monitor_template?: MonitorTplOpt;
|
||||||
status: string;
|
status: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
|
|
@ -49,7 +59,7 @@ type MigBatch = {
|
||||||
id: string;
|
id: string;
|
||||||
batch_label: string;
|
batch_label: string;
|
||||||
status: string;
|
status: string;
|
||||||
expect_set?: { ports?: string[] };
|
expect_set?: { ports?: string[]; items?: Array<{ metric_id: string; key?: string; keys?: string[] }> };
|
||||||
accept_status?: string;
|
accept_status?: string;
|
||||||
accept_run_id?: string;
|
accept_run_id?: string;
|
||||||
accept_summary?: {
|
accept_summary?: {
|
||||||
|
|
@ -90,13 +100,18 @@ type DiffRow = {
|
||||||
old_status?: string;
|
old_status?: string;
|
||||||
new_status?: string;
|
new_status?: string;
|
||||||
};
|
};
|
||||||
type BaselinePort = {
|
type ExpectSheetItem = {
|
||||||
interface: string;
|
key: string;
|
||||||
admin?: string;
|
keys: string[];
|
||||||
phy?: string;
|
|
||||||
prot?: string;
|
|
||||||
description?: string;
|
|
||||||
mapped_to?: string;
|
mapped_to?: string;
|
||||||
|
label?: string;
|
||||||
|
row?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
type ExpectSheet = {
|
||||||
|
metric_id: string;
|
||||||
|
key_fields: string[];
|
||||||
|
iface_fields: string[];
|
||||||
|
items: ExpectSheetItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type DetailTab = "setup" | "batches" | "board";
|
type DetailTab = "setup" | "batches" | "board";
|
||||||
|
|
@ -147,14 +162,17 @@ export function BizMigrationPage() {
|
||||||
const [createOldTaskId, setCreateOldTaskId] = useState("");
|
const [createOldTaskId, setCreateOldTaskId] = useState("");
|
||||||
const [createNewTaskId, setCreateNewTaskId] = useState("");
|
const [createNewTaskId, setCreateNewTaskId] = useState("");
|
||||||
const [createMappingId, setCreateMappingId] = useState("");
|
const [createMappingId, setCreateMappingId] = useState("");
|
||||||
|
const [createMonitorTplId, setCreateMonitorTplId] = useState("");
|
||||||
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
|
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
|
||||||
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
|
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
|
||||||
const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
||||||
const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
||||||
|
const [monitorTpls, setMonitorTpls] = useState<MonitorTplOpt[]>([]);
|
||||||
|
|
||||||
const [projectId, setProjectId] = useState("");
|
const [projectId, setProjectId] = useState("");
|
||||||
const [detailTab, setDetailTab] = useState<DetailTab>("setup");
|
const [detailTab, setDetailTab] = useState<DetailTab>("setup");
|
||||||
const [mappingId, setMappingId] = useState("");
|
const [mappingId, setMappingId] = useState("");
|
||||||
|
const [monitorTplId, setMonitorTplId] = useState("");
|
||||||
const [oldBaselineId, setOldBaselineId] = useState("");
|
const [oldBaselineId, setOldBaselineId] = useState("");
|
||||||
const [newBaselineId, setNewBaselineId] = useState("");
|
const [newBaselineId, setNewBaselineId] = useState("");
|
||||||
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
||||||
|
|
@ -174,9 +192,11 @@ export function BizMigrationPage() {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [diffs, setDiffs] = useState<DiffRow[]>([]);
|
const [diffs, setDiffs] = useState<DiffRow[]>([]);
|
||||||
const [onlyExpect, setOnlyExpect] = useState(true);
|
const [onlyExpect, setOnlyExpect] = useState(true);
|
||||||
|
const [boardMetricId, setBoardMetricId] = useState("");
|
||||||
const [batchLabel, setBatchLabel] = useState("");
|
const [batchLabel, setBatchLabel] = useState("");
|
||||||
const [baselinePorts, setBaselinePorts] = useState<BaselinePort[]>([]);
|
const [expectSheets, setExpectSheets] = useState<ExpectSheet[]>([]);
|
||||||
const [selectedPorts, setSelectedPorts] = useState<Set<string>>(new Set());
|
const [expectMetricId, setExpectMetricId] = useState("");
|
||||||
|
const [selectedExpectKeys, setSelectedExpectKeys] = useState<Set<string>>(new Set());
|
||||||
const [portFilter, setPortFilter] = useState("");
|
const [portFilter, setPortFilter] = useState("");
|
||||||
const [redTickets, setRedTickets] = useState<RedTicket[]>([]);
|
const [redTickets, setRedTickets] = useState<RedTicket[]>([]);
|
||||||
const [openRedCount, setOpenRedCount] = useState(0);
|
const [openRedCount, setOpenRedCount] = useState(0);
|
||||||
|
|
@ -188,10 +208,23 @@ export function BizMigrationPage() {
|
||||||
);
|
);
|
||||||
const batch = useMemo(() => batches.find((b) => b.id === batchId) || null, [batches, batchId]);
|
const batch = useMemo(() => batches.find((b) => b.id === batchId) || null, [batches, batchId]);
|
||||||
const sheetCards = board?.run?.summary?.sheet_cards || [];
|
const sheetCards = board?.run?.summary?.sheet_cards || [];
|
||||||
const visibleDiffs = useMemo(
|
const visibleDiffs = useMemo(() => {
|
||||||
() => (onlyExpect ? diffs.filter((d) => d.in_expect) : diffs),
|
let rows = diffs;
|
||||||
[diffs, onlyExpect],
|
if (boardMetricId) rows = rows.filter((d) => d.metric_id === boardMetricId);
|
||||||
);
|
if (onlyExpect) rows = rows.filter((d) => d.in_expect);
|
||||||
|
return rows;
|
||||||
|
}, [diffs, onlyExpect, boardMetricId]);
|
||||||
|
|
||||||
|
const boardMetricOptions = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const c of sheetCards) {
|
||||||
|
if (c.metric_id) ids.add(c.metric_id);
|
||||||
|
}
|
||||||
|
for (const d of diffs) {
|
||||||
|
if (d.metric_id) ids.add(d.metric_id);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}, [sheetCards, diffs]);
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
const kw = debouncedListKw.trim().toLowerCase();
|
const kw = debouncedListKw.trim().toLowerCase();
|
||||||
|
|
@ -227,33 +260,37 @@ export function BizMigrationPage() {
|
||||||
}
|
}
|
||||||
}, [batch]);
|
}, [batch]);
|
||||||
|
|
||||||
const filteredBaselinePorts = useMemo(() => {
|
const filteredExpectItems = useMemo(() => {
|
||||||
|
const sheet = expectSheets.find((s) => s.metric_id === expectMetricId) || expectSheets[0];
|
||||||
|
const items = sheet?.items || [];
|
||||||
const kw = portFilter.trim().toLowerCase();
|
const kw = portFilter.trim().toLowerCase();
|
||||||
if (!kw) return baselinePorts;
|
if (!kw) return items;
|
||||||
return baselinePorts.filter(
|
return items.filter((it) => {
|
||||||
(p) =>
|
const blob = `${it.key} ${it.label || ""} ${JSON.stringify(it.row || {})}`.toLowerCase();
|
||||||
p.interface.toLowerCase().includes(kw) ||
|
return blob.includes(kw);
|
||||||
String(p.description || "")
|
});
|
||||||
.toLowerCase()
|
}, [expectSheets, expectMetricId, portFilter]);
|
||||||
.includes(kw),
|
|
||||||
);
|
|
||||||
}, [baselinePorts, portFilter]);
|
|
||||||
|
|
||||||
const reloadProjects = useCallback(async () => {
|
const reloadProjects = useCallback(async () => {
|
||||||
const res = await bizMigrationListProjects();
|
const res = await bizMigrationListProjects();
|
||||||
setProjects((res.items || []) as Project[]);
|
setProjects((res.items || []) as Project[]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadBaselinePorts = useCallback(async (pid: string) => {
|
const loadBaselineExpect = useCallback(async (pid: string) => {
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
setBaselinePorts([]);
|
setExpectSheets([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await bizMigrationListBaselinePorts(pid);
|
const res = await bizMigrationListBaselineExpect(pid);
|
||||||
setBaselinePorts(res.ports || []);
|
const sheets = (res.sheets || []) as ExpectSheet[];
|
||||||
|
setExpectSheets(sheets);
|
||||||
|
setExpectMetricId((prev) => {
|
||||||
|
if (prev && sheets.some((s) => s.metric_id === prev)) return prev;
|
||||||
|
return sheets[0]?.metric_id || "";
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setBaselinePorts([]);
|
setExpectSheets([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -276,10 +313,11 @@ export function BizMigrationPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [pr, tk, mp] = await Promise.all([
|
const [pr, tk, mp, mt] = await Promise.all([
|
||||||
bizMigrationListProjects(),
|
bizMigrationListProjects(),
|
||||||
bizStateListTasks(),
|
bizStateListTasks(),
|
||||||
bizCompareListMappings(),
|
bizCompareListMappings(),
|
||||||
|
bizMonitorListTemplates(),
|
||||||
]);
|
]);
|
||||||
setProjects((pr.items || []) as Project[]);
|
setProjects((pr.items || []) as Project[]);
|
||||||
setTasks(
|
setTasks(
|
||||||
|
|
@ -297,6 +335,20 @@ export function BizMigrationPage() {
|
||||||
name: String(x.name || x.id || ""),
|
name: String(x.name || x.id || ""),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const mts = ((mt.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
|
id: String(x.id || ""),
|
||||||
|
name: String(x.name || x.id || ""),
|
||||||
|
compare_template_id: String(x.compare_template_id || ""),
|
||||||
|
compare_template_name: String(x.compare_template_name || ""),
|
||||||
|
collect_metric_ids: Array.isArray(x.collect_metric_ids)
|
||||||
|
? (x.collect_metric_ids as string[])
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
setMonitorTpls(mts);
|
||||||
|
if (!createMonitorTplId && mts.length) {
|
||||||
|
const port = mts.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port"));
|
||||||
|
setCreateMonitorTplId(port?.id || mts[0].id);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
}
|
}
|
||||||
|
|
@ -321,13 +373,13 @@ export function BizMigrationPage() {
|
||||||
const items = (res.items || []) as MigBatch[];
|
const items = (res.items || []) as MigBatch[];
|
||||||
setBatches(items);
|
setBatches(items);
|
||||||
setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || ""));
|
setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || ""));
|
||||||
await loadBaselinePorts(projectId);
|
await loadBaselineExpect(projectId);
|
||||||
await loadRedTickets(projectId);
|
await loadRedTickets(projectId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [projectId, showError, loadBaselinePorts, loadRedTickets]);
|
}, [projectId, showError, loadBaselineExpect, loadRedTickets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!batchId) {
|
if (!batchId) {
|
||||||
|
|
@ -402,6 +454,7 @@ export function BizMigrationPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (project) {
|
if (project) {
|
||||||
setMappingId(project.mapping_id || "");
|
setMappingId(project.mapping_id || "");
|
||||||
|
setMonitorTplId(project.monitor_template_id || project.monitor_template?.id || "");
|
||||||
setOldBaselineId(project.old_baseline_batch_id || "");
|
setOldBaselineId(project.old_baseline_batch_id || "");
|
||||||
setNewBaselineId(project.new_baseline_batch_id || "");
|
setNewBaselineId(project.new_baseline_batch_id || "");
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +483,8 @@ export function BizMigrationPage() {
|
||||||
setCreateMappingId("");
|
setCreateMappingId("");
|
||||||
setCreateOldBaselineId("");
|
setCreateOldBaselineId("");
|
||||||
setCreateNewBaselineId("");
|
setCreateNewBaselineId("");
|
||||||
|
const port = monitorTpls.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port"));
|
||||||
|
setCreateMonitorTplId(port?.id || monitorTpls[0]?.id || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
|
|
@ -446,7 +501,7 @@ export function BizMigrationPage() {
|
||||||
const openProject = (id: string, tab: DetailTab = "setup") => {
|
const openProject = (id: string, tab: DetailTab = "setup") => {
|
||||||
setProjectId(id);
|
setProjectId(id);
|
||||||
setDetailTab(tab);
|
setDetailTab(tab);
|
||||||
setSelectedPorts(new Set());
|
setSelectedExpectKeys(new Set());
|
||||||
setPortFilter("");
|
setPortFilter("");
|
||||||
setBatchLabel("");
|
setBatchLabel("");
|
||||||
setOnlyExpect(true);
|
setOnlyExpect(true);
|
||||||
|
|
@ -469,6 +524,7 @@ export function BizMigrationPage() {
|
||||||
old_task_id: createOldTaskId,
|
old_task_id: createOldTaskId,
|
||||||
new_task_id: createNewTaskId,
|
new_task_id: createNewTaskId,
|
||||||
mapping_id: createMappingId || "",
|
mapping_id: createMappingId || "",
|
||||||
|
monitor_template_id: createMonitorTplId || "",
|
||||||
old_baseline_batch_id: createOldBaselineId || "",
|
old_baseline_batch_id: createOldBaselineId || "",
|
||||||
new_baseline_batch_id: createNewBaselineId || "",
|
new_baseline_batch_id: createNewBaselineId || "",
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|
@ -492,9 +548,10 @@ export function BizMigrationPage() {
|
||||||
old_baseline_batch_id: oldBaselineId || project?.old_baseline_batch_id || "",
|
old_baseline_batch_id: oldBaselineId || project?.old_baseline_batch_id || "",
|
||||||
new_baseline_batch_id: newBaselineId || project?.new_baseline_batch_id || "",
|
new_baseline_batch_id: newBaselineId || project?.new_baseline_batch_id || "",
|
||||||
mapping_id: mappingId || project?.mapping_id || "",
|
mapping_id: mappingId || project?.mapping_id || "",
|
||||||
|
monitor_template_id: monitorTplId || project?.monitor_template_id || "",
|
||||||
});
|
});
|
||||||
await reloadProjects();
|
await reloadProjects();
|
||||||
await loadBaselinePorts(projectId);
|
await loadBaselineExpect(projectId);
|
||||||
showOk(t("bizMigration.baselineSaved"));
|
showOk(t("bizMigration.baselineSaved"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
|
|
@ -505,8 +562,15 @@ export function BizMigrationPage() {
|
||||||
|
|
||||||
async function onCreateBatch() {
|
async function onCreateBatch() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const ports = [...selectedPorts];
|
const items: Array<{ metric_id: string; keys: string[] }> = [];
|
||||||
if (!ports.length) {
|
const ports: string[] = [];
|
||||||
|
for (const sheet of expectSheets) {
|
||||||
|
const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k));
|
||||||
|
if (!keys.length) continue;
|
||||||
|
items.push({ metric_id: sheet.metric_id, keys });
|
||||||
|
if (sheet.metric_id === "interface_brief") ports.push(...keys);
|
||||||
|
}
|
||||||
|
if (!items.length && !ports.length) {
|
||||||
showError(t("bizMigration.needExpectPorts"));
|
showError(t("bizMigration.needExpectPorts"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -514,14 +578,14 @@ export function BizMigrationPage() {
|
||||||
try {
|
try {
|
||||||
const b = (await bizMigrationCreateBatch(projectId, {
|
const b = (await bizMigrationCreateBatch(projectId, {
|
||||||
batch_label: batchLabel.trim() || t("bizMigration.defaultBatchLabel"),
|
batch_label: batchLabel.trim() || t("bizMigration.defaultBatchLabel"),
|
||||||
expect_set: { ports },
|
expect_set: { ports, items },
|
||||||
status: "pending",
|
status: "pending",
|
||||||
})) as MigBatch;
|
})) as MigBatch;
|
||||||
const res = await bizMigrationListBatches(projectId);
|
const res = await bizMigrationListBatches(projectId);
|
||||||
setBatches((res.items || []) as MigBatch[]);
|
setBatches((res.items || []) as MigBatch[]);
|
||||||
setBatchId(b.id);
|
setBatchId(b.id);
|
||||||
setBatchLabel("");
|
setBatchLabel("");
|
||||||
setSelectedPorts(new Set());
|
setSelectedExpectKeys(new Set());
|
||||||
if (Number((b as { open_red_count?: number }).open_red_count || 0) > 0) {
|
if (Number((b as { open_red_count?: number }).open_red_count || 0) > 0) {
|
||||||
showOk(
|
showOk(
|
||||||
t("bizMigration.batchCreatedWithRed", {
|
t("bizMigration.batchCreatedWithRed", {
|
||||||
|
|
@ -606,7 +670,7 @@ export function BizMigrationPage() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await bizMigrationEnsurePortHighfreq(projectId, {
|
await bizMigrationEnsureHighfreq(projectId, {
|
||||||
interval_sec: 60,
|
interval_sec: 60,
|
||||||
collect_now: true,
|
collect_now: true,
|
||||||
});
|
});
|
||||||
|
|
@ -632,15 +696,25 @@ export function BizMigrationPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePort(name: string) {
|
function toggleExpectKey(key: string) {
|
||||||
setSelectedPorts((prev) => {
|
setSelectedExpectKeys((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(name)) next.delete(name);
|
if (next.has(key)) next.delete(key);
|
||||||
else next.add(name);
|
else next.add(key);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedMonitorTpl = useMemo(() => {
|
||||||
|
const id = monitorTplId || project?.monitor_template_id || "";
|
||||||
|
return monitorTpls.find((x) => x.id === id) || project?.monitor_template || null;
|
||||||
|
}, [monitorTplId, monitorTpls, project]);
|
||||||
|
|
||||||
|
const createMonitorTpl = useMemo(
|
||||||
|
() => monitorTpls.find((x) => x.id === createMonitorTplId) || null,
|
||||||
|
[monitorTpls, createMonitorTplId],
|
||||||
|
);
|
||||||
|
|
||||||
function verdictLabel(v: string) {
|
function verdictLabel(v: string) {
|
||||||
const key = VERDICT_I18N[v];
|
const key = VERDICT_I18N[v];
|
||||||
return key ? t(key) : v;
|
return key ? t(key) : v;
|
||||||
|
|
@ -855,6 +929,34 @@ export function BizMigrationPage() {
|
||||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||||
{t("bizMigration.sectionOptionalHint")}
|
{t("bizMigration.sectionOptionalHint")}
|
||||||
</p>
|
</p>
|
||||||
|
<FieldSelect
|
||||||
|
label={t("bizMigration.monitorTemplate")}
|
||||||
|
value={createMonitorTplId}
|
||||||
|
onChange={(e) => setCreateMonitorTplId(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
hint={
|
||||||
|
createMonitorTpl?.compare_template_name
|
||||||
|
? t("bizMigration.compareTemplateBound", {
|
||||||
|
name: createMonitorTpl.compare_template_name,
|
||||||
|
})
|
||||||
|
: t("bizMigration.monitorTemplateHint")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t("bizMigration.optionalNone")}</option>
|
||||||
|
{monitorTpls.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FieldSelect>
|
||||||
|
{createMonitorTpl?.compare_template_id ? (
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
{t("bizMigration.compareTemplate")}:{" "}
|
||||||
|
<Link to="/network/cutover/compare-templates" onClick={closeCreate}>
|
||||||
|
{createMonitorTpl.compare_template_name || createMonitorTpl.compare_template_id}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
label={t("bizMigration.portMapping")}
|
label={t("bizMigration.portMapping")}
|
||||||
value={createMappingId}
|
value={createMappingId}
|
||||||
|
|
@ -1032,6 +1134,32 @@ export function BizMigrationPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<strong style={{ fontSize: 13 }}>{t("bizMigration.monitorTemplate")}</strong>
|
||||||
|
<FieldSelect
|
||||||
|
value={monitorTplId}
|
||||||
|
onChange={(e) => setMonitorTplId(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
hint={t("bizMigration.monitorTemplateHint")}
|
||||||
|
>
|
||||||
|
<option value="">{t("bizMigration.optionalNone")}</option>
|
||||||
|
{monitorTpls.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FieldSelect>
|
||||||
|
{selectedMonitorTpl?.compare_template_id || selectedMonitorTpl?.compare_template_name ? (
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
{t("bizMigration.compareTemplate")}:{" "}
|
||||||
|
<Link to="/network/cutover/compare-templates">
|
||||||
|
{selectedMonitorTpl.compare_template_name ||
|
||||||
|
selectedMonitorTpl.compare_template_id}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<strong style={{ fontSize: 13 }}>{t("bizMigration.portMapping")}</strong>
|
<strong style={{ fontSize: 13 }}>{t("bizMigration.portMapping")}</strong>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
|
|
@ -1095,8 +1223,28 @@ export function BizMigrationPage() {
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||||
{t("bizMigration.pickExpectPorts")} ({selectedPorts.size})
|
{t("bizMigration.pickExpectPorts")} ({selectedExpectKeys.size})
|
||||||
</div>
|
</div>
|
||||||
|
{expectSheets.length > 1 ? (
|
||||||
|
<div className="btn-row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
|
||||||
|
{expectSheets.map((s) => (
|
||||||
|
<Button
|
||||||
|
key={s.metric_id}
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
(expectMetricId || expectSheets[0]?.metric_id) === s.metric_id
|
||||||
|
? "primary"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
setExpectMetricId(s.metric_id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.metric_id} ({s.items.length})
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||||
<Input
|
<Input
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1115,39 +1263,51 @@ export function BizMigrationPage() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 36 }} />
|
<th style={{ width: 36 }} />
|
||||||
<th>{t("bizMigration.colPort")}</th>
|
<th>{t("bizMigration.colKey")}</th>
|
||||||
<th>admin/phy/prot</th>
|
|
||||||
<th>{t("bizMigration.colMapped")}</th>
|
<th>{t("bizMigration.colMapped")}</th>
|
||||||
<th>{t("bizMigration.colDesc")}</th>
|
<th>{t("bizMigration.colDesc")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredBaselinePorts.map((p) => (
|
{filteredExpectItems.map((it) => {
|
||||||
<tr
|
const row = it.row || {};
|
||||||
key={p.interface}
|
const statusBits = ["admin", "phy", "prot"]
|
||||||
onClick={() => togglePort(p.interface)}
|
.map((k) => (row[k] != null ? String(row[k]) : ""))
|
||||||
style={{ cursor: "pointer" }}
|
.filter(Boolean);
|
||||||
>
|
const desc =
|
||||||
<td>
|
it.label ||
|
||||||
<input
|
String(row.description || row.desc || row.peer || row.neighbor || "") ||
|
||||||
type="checkbox"
|
(statusBits.length ? statusBits.join("/") : "");
|
||||||
checked={selectedPorts.has(p.interface)}
|
return (
|
||||||
onChange={() => togglePort(p.interface)}
|
<tr
|
||||||
/>
|
key={it.key}
|
||||||
</td>
|
onClick={() => toggleExpectKey(it.key)}
|
||||||
<td>{p.interface}</td>
|
style={{ cursor: "pointer" }}
|
||||||
<td>
|
>
|
||||||
{p.admin || "-"}/{p.phy || "-"}/{p.prot || "-"}
|
<td>
|
||||||
</td>
|
<input
|
||||||
<td>{p.mapped_to || "—"}</td>
|
type="checkbox"
|
||||||
<td style={{ maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis" }}>
|
checked={selectedExpectKeys.has(it.key)}
|
||||||
{p.description || ""}
|
onChange={() => toggleExpectKey(it.key)}
|
||||||
</td>
|
/>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
<td>{it.key}</td>
|
||||||
{!filteredBaselinePorts.length ? (
|
<td>{it.mapped_to || "—"}</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
maxWidth: 220,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{desc}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!filteredExpectItems.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>
|
<td colSpan={4}>
|
||||||
<div className="pt-list-empty">{t("bizMigration.emptyBaselinePorts")}</div>
|
<div className="pt-list-empty">{t("bizMigration.emptyBaselinePorts")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1226,6 +1386,21 @@ export function BizMigrationPage() {
|
||||||
<Button size="sm" variant="primary" isDisabled={busy || !batchId} onPress={() => void onEvaluate()}>
|
<Button size="sm" variant="primary" isDisabled={busy || !batchId} onPress={() => void onEvaluate()}>
|
||||||
{t("bizMigration.evaluate")}
|
{t("bizMigration.evaluate")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{boardMetricOptions.length > 1 ? (
|
||||||
|
<FieldSelect
|
||||||
|
label={t("bizMigration.colMetric")}
|
||||||
|
value={boardMetricId}
|
||||||
|
onChange={(e) => setBoardMetricId(e.target.value)}
|
||||||
|
aria-label={t("bizMigration.colMetric")}
|
||||||
|
>
|
||||||
|
<option value="">{t("bizMigration.allMetrics")}</option>
|
||||||
|
{boardMetricOptions.map((mid) => (
|
||||||
|
<option key={mid} value={mid}>
|
||||||
|
{mid}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</FieldSelect>
|
||||||
|
) : null}
|
||||||
<label className="config-sync-policy-check">
|
<label className="config-sync-policy-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -1310,43 +1485,66 @@ export function BizMigrationPage() {
|
||||||
|
|
||||||
{board?.run ? (
|
{board?.run ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<strong>
|
<strong>{t("bizMigration.board")}</strong>
|
||||||
{t("bizMigration.board")} · {t("bizMigration.metricPort")}
|
{sheetCards.length ? (
|
||||||
</strong>
|
<div style={{ display: "flex", gap: 16, flexWrap: "wrap", fontVariantNumeric: "tabular-nums" }}>
|
||||||
<div style={{ display: "flex", gap: 24, fontVariantNumeric: "tabular-nums", flexWrap: "wrap" }}>
|
{sheetCards.map((c) => (
|
||||||
<div>
|
<div
|
||||||
{t("bizMigration.progress")}:{" "}
|
key={c.metric_id}
|
||||||
<b>
|
style={{
|
||||||
{board.run.summary?.progress?.ok ?? 0}/{board.run.summary?.progress?.total ?? 0}
|
minWidth: 140,
|
||||||
</b>
|
cursor: "pointer",
|
||||||
|
opacity: !boardMetricId || boardMetricId === c.metric_id ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
setBoardMetricId((prev) => (prev === c.metric_id ? "" : c.metric_id))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
{c.title || c.metric_id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{t("bizMigration.progress")}: {c.progress_ok}/{c.progress_total}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: COLOR.red }}>
|
||||||
|
{t("bizMigration.anomaly")}: {c.anomaly}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: COLOR.red }}>
|
) : (
|
||||||
{t("bizMigration.anomaly")}: <b>{board.run.summary?.anomaly ?? 0}</b>
|
<div style={{ display: "flex", gap: 24, fontVariantNumeric: "tabular-nums", flexWrap: "wrap" }}>
|
||||||
</div>
|
<div>
|
||||||
{sheetCards[0] ? (
|
{t("bizMigration.progress")}:{" "}
|
||||||
<div className="muted" style={{ fontSize: 12 }}>
|
<b>
|
||||||
{sheetCards[0].title || sheetCards[0].metric_id}
|
{board.run.summary?.progress?.ok ?? 0}/{board.run.summary?.progress?.total ?? 0}
|
||||||
|
</b>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div style={{ color: COLOR.red }}>
|
||||||
</div>
|
{t("bizMigration.anomaly")}: <b>{board.run.summary?.anomaly ?? 0}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="pt-list-table-wrap">
|
<div className="pt-list-table-wrap">
|
||||||
<table className="data-table pt-list-table">
|
<table className="data-table pt-list-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("bizMigration.colOldPort")}</th>
|
<th>{t("bizMigration.colMetric")}</th>
|
||||||
<th>{t("bizMigration.colNewPort")}</th>
|
<th>{t("bizMigration.colKey")}</th>
|
||||||
<th>{t("bizMigration.colOldStatus")}</th>
|
<th>{t("bizMigration.colMapped")}</th>
|
||||||
<th>{t("bizMigration.colNewStatus")}</th>
|
<th>{t("bizMigration.colOld")}</th>
|
||||||
|
<th>{t("bizMigration.colNew")}</th>
|
||||||
<th>{t("bizMigration.colVerdict")}</th>
|
<th>{t("bizMigration.colVerdict")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleDiffs.map((d) => (
|
{visibleDiffs.map((d) => (
|
||||||
<tr key={d.id}>
|
<tr key={d.id}>
|
||||||
|
<td>{d.metric_id || "—"}</td>
|
||||||
<td>{d.key_str || "—"}</td>
|
<td>{d.key_str || "—"}</td>
|
||||||
<td>{d.new_key_str || "—"}</td>
|
<td>{d.new_key_str || "—"}</td>
|
||||||
<td>{d.old_status || "—"}</td>
|
<td>{d.old_status || d.old_kind || "—"}</td>
|
||||||
<td>{d.new_status || "—"}</td>
|
<td>{d.new_status || d.new_kind || "—"}</td>
|
||||||
<td style={{ color: COLOR[d.color] || COLOR.gray, fontWeight: 600 }}>
|
<td style={{ color: COLOR[d.color] || COLOR.gray, fontWeight: 600 }}>
|
||||||
{verdictLabel(d.verdict)}
|
{verdictLabel(d.verdict)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1354,7 +1552,7 @@ export function BizMigrationPage() {
|
||||||
))}
|
))}
|
||||||
{!visibleDiffs.length ? (
|
{!visibleDiffs.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>
|
<td colSpan={6}>
|
||||||
<div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div>
|
<div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -2016,15 +2016,37 @@ export const bizMigrationListBaselinePorts = (projectId: string) =>
|
||||||
}[];
|
}[];
|
||||||
}>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-ports`);
|
}>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-ports`);
|
||||||
|
|
||||||
export const bizMigrationEnsurePortHighfreq = (
|
export const bizMigrationListBaselineExpect = (projectId: string) =>
|
||||||
|
apiGet<{
|
||||||
|
batch_id: string;
|
||||||
|
monitor_template_id?: string;
|
||||||
|
sheets: Array<{
|
||||||
|
metric_id: string;
|
||||||
|
key_fields: string[];
|
||||||
|
iface_fields: string[];
|
||||||
|
items: Array<{
|
||||||
|
key: string;
|
||||||
|
keys: string[];
|
||||||
|
mapped_to?: string;
|
||||||
|
label?: string;
|
||||||
|
row?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
mapped?: Record<string, string>;
|
||||||
|
}>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/baseline-expect`);
|
||||||
|
|
||||||
|
export const bizMigrationEnsureHighfreq = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
body: { interval_sec?: number; retention_days?: number; collect_now?: boolean } = {},
|
body: { interval_sec?: number; retention_days?: number; collect_now?: boolean } = {},
|
||||||
) =>
|
) =>
|
||||||
apiPost<Record<string, unknown>>(
|
apiPost<Record<string, unknown>>(
|
||||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-port-highfreq`,
|
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** @deprecated use bizMigrationEnsureHighfreq */
|
||||||
|
export const bizMigrationEnsurePortHighfreq = bizMigrationEnsureHighfreq;
|
||||||
|
|
||||||
export const bizMigrationCollectNow = (projectId: string) =>
|
export const bizMigrationCollectNow = (projectId: string) =>
|
||||||
apiPost<Record<string, unknown>>(
|
apiPost<Record<string, unknown>>(
|
||||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`,
|
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/collect-now`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue