From 60a663e2f6eb25de3c49e118d1d1d5688535d5ba Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 18 Sep 2026 20:54:07 +0800 Subject: [PATCH] Wire cutover monitor to templates: project binding, multi-sheet evaluate, dual status semantics, and UI expect/HF. Co-authored-by: Cursor --- netx_api/biz_migration/evaluate.py | 106 +++++- netx_api/biz_migration/monitor_templates.py | 21 ++ netx_api/biz_migration/service.py | 241 ++++++++++-- netx_api/biz_migration_router.py | 13 +- netx_api/biz_state/schema_ensure.py | 2 + netx_api/models/biz_migration.py | 2 + tests/test_biz_migration_evaluate.py | 98 ++++- web/src/i18n/en.ts | 35 +- web/src/i18n/zh.ts | 33 +- web/src/pages/network/BizMigrationPage.tsx | 390 +++++++++++++++----- web/src/services/api.ts | 26 +- 11 files changed, 808 insertions(+), 159 deletions(-) diff --git a/netx_api/biz_migration/evaluate.py b/netx_api/biz_migration/evaluate.py index 223eaf5..f435a19 100644 --- a/netx_api/biz_migration/evaluate.py +++ b/netx_api/biz_migration/evaluate.py @@ -24,6 +24,39 @@ def port_status_label(row: dict[str, Any] | None) -> str: 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]]: """Return metric_id → set of key strings (old-side / before-map identity). @@ -86,6 +119,34 @@ def side_verdict( 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( *, old_kind: str, @@ -93,12 +154,25 @@ def dual_verdict( in_expect: bool, window_active: bool, acceptance: bool = False, + old_status: str = "none", + new_status: str = "none", + success_patterns: list[dict[str, Any]] | None = None, ) -> tuple[str, str]: """Synthesize old+new into migration board verdict. ``acceptance=True`` (本批完成终验): unfinished expect items become red (``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 old_kind in ("removed", "changed") or new_kind in ("removed", "changed"): 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 {}) +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( *, metric_id: str, @@ -193,9 +278,13 @@ def evaluate_metric_dual( expect: dict[str, set[str]], window_active: bool, acceptance: bool = False, + field_rules: list[dict[str, Any]] | None = None, + sheet_override: dict[str, Any] | None = None, ) -> dict[str, Any]: """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) + ov = sheet_override or {} + success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else [] old_cmp = compare_rows( before_rows=old_baseline_rows, @@ -204,6 +293,7 @@ def evaluate_metric_dual( iface_fields=[], compare_fields=compare_fields, port_map=None, + field_rules=field_rules, ) old_idx = build_diff_index_from_compare(old_cmp) @@ -224,6 +314,7 @@ def evaluate_metric_dual( iface_fields=[], compare_fields=compare_fields, port_map=None, + field_rules=field_rules, ) new_idx = build_diff_index_from_compare(new_cmp) @@ -267,21 +358,26 @@ def evaluate_metric_dual( if nd is None: 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( old_kind=old_kind or "", new_kind=new_kind or "", in_expect=in_exp, window_active=window_active, 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": progress_ok += 1 if color == "red": anomaly += 1 - old_cur = _current_row(od) - new_cur = _current_row(nd) - rows_out.append( { "metric_id": metric_id, @@ -297,10 +393,10 @@ def evaluate_metric_dual( "new": new_cur, "old_baseline": dict((od 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 else ("gone" if old_kind == "removed" else "—"), - "new_status": port_status_label(new_cur) + "new_status": status_label(new_cur, ov) if new_cur else ("gone" if new_kind == "removed" else "—"), "old_side": side_verdict( diff --git a/netx_api/biz_migration/monitor_templates.py b/netx_api/biz_migration/monitor_templates.py index 6e82a6c..44f827d 100644 --- a/netx_api/biz_migration/monitor_templates.py +++ b/netx_api/biz_migration/monitor_templates.py @@ -123,6 +123,27 @@ def ensure_default_monitor_templates(db: Session) -> None: 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]]: ensure_default_monitor_templates(db) names = _compare_name_map(db) diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index 00bd895..115f890 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -9,19 +9,28 @@ from fastapi import HTTPException from sqlalchemy.orm import Session 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 ( + BizCompareTemplate, BizMigrationBatch, BizMigrationDiff, BizMigrationProject, BizMigrationRedTicket, BizMigrationRun, + BizMonitorTemplate, BizPortMapping, BizStateBatch, BizStateTask, ) 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]: @@ -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]: return { "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, "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": _monitor_template_brief(db, getattr(p, "monitor_template_id", None) or ""), "status": p.status, "note": p.note, "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() if mapping_id and not db.get(BizPortMapping, mapping_id): 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( id=uuid4().hex, 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(), new_baseline_batch_id=str(body.get("new_baseline_batch_id") or "").strip(), mapping_id=mapping_id, + monitor_template_id=monitor_template_id, status=str(body.get("status") or "draft").strip() or "draft", 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): 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 p.updated_at = utcnow_naive() db.commit() 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 {}) # Final acceptance: window closed → unfinished expect = red 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]] = [] all_rows: list[dict[str, Any]] = [] @@ -278,13 +368,15 @@ def run_evaluate( verdict_counts: dict[str, int] = {} for sheet in sheets: - mid = sheet["metric_id"] + mid = str(sheet.get("metric_id") or "").strip() key_fields = list(sheet.get("key_fields") or []) - if not key_fields: + 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_metric(sheet_overrides, mid) old_base = apply_row_filters( _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, new_baseline_rows=new_base_rows, new_current_rows=new_now, - port_map=port_map, + port_map=port_map if iface_fields else {}, expect=expect, window_active=window_active, acceptance=acceptance, + field_rules=field_rules, + sheet_override=sheet_ov, ) sheet_cards.append( { "metric_id": mid, - "title": "端口状态", + "title": mid, "progress_ok": one["progress_ok"], "progress_total": one["progress_total"], "anomaly": one["anomaly"], @@ -347,7 +441,9 @@ def run_evaluate( purpose=str(purpose or ("acceptance" if acceptance else "manual"))[:32], status="success", 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, "sheet_cards": sheet_cards, "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 ..lldp_shared import resolve_vendor_key + mid = str(metric_id or "").strip() vkey = resolve_vendor_key(vendor, device_type) 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 { "source_profile_id": p.profile_id, "kind": "catalog", "enabled": True, - "title": p.title or "interface brief", + "title": p.title or mid, } raise HTTPException( 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]: from ..biz_state.profiles import get_profile from ..models import BizStateTaskItem @@ -525,10 +682,15 @@ def _enabled_metric_ids(db: Session, task_id: str) -> set[str]: return out -def _is_port_highfreq_task(db: Session, task: BizStateTask) -> bool: - """True when task is interface_brief-only with short interval (cutover HF).""" +def _is_highfreq_task(db: Session, task: BizStateTask, want_metrics: set[str]) -> bool: + """True when task metrics match want set and interval is short (cutover HF).""" 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( @@ -538,19 +700,31 @@ def _ensure_side_highfreq( project_name: str, interval_sec: int, retention_days: int, + metric_ids: list[str], ) -> 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 - 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": biz_svc.update_task(db, template.id, {"status": "running"}) refreshed = db.get(BizStateTask, template.id) return refreshed or template, False return template, False - item = _iface_brief_item(vendor=template.vendor, device_type=template.device_type) - note = f"割接高频-端口/{project_name}"[:256] + items = [ + _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( db, { @@ -564,7 +738,7 @@ def _ensure_side_highfreq( "status": "running", "interval_sec": interval_sec, "retention_days": retention_days, - "items": [item], + "items": items, }, ) task = db.get(BizStateTask, str(created.get("id") or "")) @@ -581,11 +755,25 @@ def ensure_port_highfreq( retention_days: int = 7, collect_now: bool = True, ) -> 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 proj = db.get(BizMigrationProject, project_id) @@ -596,6 +784,7 @@ def ensure_port_highfreq( if not old_tpl or not new_tpl: 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)) ret = max(1, int(retention_days or 7)) old_task, old_created = _ensure_side_highfreq( @@ -604,8 +793,8 @@ def ensure_port_highfreq( project_name=proj.name, interval_sec=iv, retention_days=ret, + metric_ids=metric_ids, ) - # re-load templates after possible commits inside ensure new_tpl = db.get(BizStateTask, proj.new_task_id) if not new_tpl: raise HTTPException(status_code=400, detail="new_task_required") @@ -615,6 +804,7 @@ def ensure_port_highfreq( project_name=proj.name, interval_sec=iv, retention_days=ret, + metric_ids=metric_ids, ) proj = db.get(BizMigrationProject, project_id) @@ -641,6 +831,7 @@ def ensure_port_highfreq( "old_created": old_created, "new_created": new_created, "interval_sec": iv, + "collect_metric_ids": metric_ids, "collect": collect, } diff --git a/netx_api/biz_migration_router.py b/netx_api/biz_migration_router.py index 2089c0f..633cdd9 100644 --- a/netx_api/biz_migration_router.py +++ b/netx_api/biz_migration_router.py @@ -22,6 +22,7 @@ class ProjectIn(BaseModel): old_baseline_batch_id: str = "" new_baseline_batch_id: str = "" mapping_id: str = "" + monitor_template_id: str = "" status: str = "draft" note: str = "" @@ -31,6 +32,7 @@ class ProjectPatchIn(BaseModel): old_baseline_batch_id: str | None = None new_baseline_batch_id: str | None = None mapping_id: str | None = None + monitor_template_id: str | None = None status: 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) +@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): interval_sec: int = 60 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-highfreq") def api_ensure_port_highfreq( project_id: str, body: EnsureHighfreqIn | None = None, 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 {} - return svc.ensure_port_highfreq( + return svc.ensure_highfreq( db, project_id, interval_sec=int(payload.get("interval_sec") or 60), diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index b4613c0..8764d05 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -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_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: _run_sql(conn, sql) diff --git a/netx_api/models/biz_migration.py b/netx_api/models/biz_migration.py index 5e17d9d..ca61c10 100644 --- a/netx_api/models/biz_migration.py +++ b/netx_api/models/biz_migration.py @@ -26,6 +26,8 @@ class BizMigrationProject(Base): new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True) # Reuse biz_port_mapping (before_if=old, after_if=new) 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 note: Mapped[str] = mapped_column(String(512), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) diff --git a/tests/test_biz_migration_evaluate.py b/tests/test_biz_migration_evaluate.py index 2142a33..4e715af 100644 --- a/tests/test_biz_migration_evaluate.py +++ b/tests/test_biz_migration_evaluate.py @@ -5,6 +5,7 @@ from __future__ import annotations import unittest from netx_api.biz_migration.evaluate import ( + classify_status, dual_verdict, evaluate_metric_dual, 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): def test_ports_go_to_interface_brief(self): got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]}) self.assertEqual(got["interface_brief"], {"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): def test_port_sheet_fields(self): @@ -36,6 +58,20 @@ class PortSheetTests(unittest.TestCase): 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): def test_side_anomaly_gone(self): self.assertEqual( @@ -70,7 +106,6 @@ class VerdictTests(unittest.TestCase): ), ("unfinished", "red"), ) - # window mode still yellow self.assertEqual( dual_verdict( old_kind="unchanged", @@ -82,6 +117,20 @@ class VerdictTests(unittest.TestCase): ("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): def test_port_migration_happy_path(self): @@ -101,6 +150,7 @@ class EvaluateMetricDualTests(unittest.TestCase): port_map={"gei-old": "gei-new"}, expect=expect, window_active=True, + sheet_override=PORT_OVERRIDE, ) self.assertEqual(out["progress_total"], 1) 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(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): old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] old_cur: list[dict] = [] diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d15c1a0..86243eb 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -463,13 +463,18 @@ const en = { sectionName: "1. Task name", sectionTasks: "2. Bind collect tasks (required)", sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.", - sectionOptional: "3. Port mapping & baselines (optional)", - sectionOptionalHint: "Skip for now if needed; set later under Baseline. Batches load after a collect task is chosen.", + sectionOptional: "3. Monitor template, mapping & baselines (optional)", + sectionOptionalHint: + "Skip for now if needed; set later under Baseline. Defaults to the port cutover monitor template when omitted.", 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", - 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", + 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.", baselineNeedTask: "Select the matching collect task first", baselinePickHint: "Choose from that task’s collect batches", @@ -479,8 +484,8 @@ const en = { oldBaseline: "Old baseline batch", newBaseline: "New baseline batch (optional)", needProjectFields: "Enter a name and pick old/new collect tasks", - needExpectPorts: "Select at least one expected port for this batch", - needBaselineFirst: "Save an old baseline batch first to list ports", + needExpectPorts: "Select at least one expected object for this batch", + needBaselineFirst: "Save an old baseline batch first to list expect objects", needBizStateTasks: "No business-monitor tasks yet. Create one first:", projectCreated: "Task created", projects: "Tasks", @@ -506,11 +511,12 @@ const en = { tabSetup: "Baseline", tabBatches: "Batches", tabBoard: "Board", - saveBaseline: "Save baseline / mapping", + saveBaseline: "Save baseline / mapping / template", baselineSaved: "Baseline saved", - pickExpectPorts: "Expected ports (from old baseline)", - portFilterPh: "Filter interface / description", - emptyBaselinePorts: "No interface_brief rows in baseline (enable that profile on the task)", + pickExpectPorts: "Expected objects (from old baseline)", + portFilterPh: "Filter object / description", + emptyBaselinePorts: + "No matching metric rows in baseline (confirm monitor template and collect profiles)", batchLabel: "Batch label", defaultBatchLabel: "Night N", expectPorts: "Expected ports (old side)", @@ -527,7 +533,8 @@ const en = { metricPort: "Port status", progress: "Progress", anomaly: "Anomaly", - onlyExpect: "Expected ports only", + onlyExpect: "Expected objects only", + allMetrics: "All metrics", colMetric: "Metric", colKey: "Object", colPort: "Old port", @@ -548,10 +555,10 @@ const en = { verdictNotInvolved: "Not involved", verdictUnexpected: "Unexpected new", verdictOk: "OK", - enableHighfreq: "Enable high-freq port collect", + enableHighfreq: "Enable high-freq collect", 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.", - highfreqReady: "High-freq port collect tasks ready and bound to this task", + "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 collect tasks ready and bound to this task", collectNow: "Collect old+new now", collectTriggered: "Collect triggered", openBizState: "Open business monitor", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 8a48197..9fc4a95 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -460,13 +460,17 @@ const zh = { sectionName: "1. 任务名称", sectionTasks: "2. 绑定采集任务(必填)", sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。", - sectionOptional: "3. 端口映射与基线(可选)", - sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。选了采集任务后才会加载对应批次。", + sectionOptional: "3. 监控模板、映射与基线(可选)", + sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时默认绑端口割接监控。", sectionPair: "已绑定采集任务", - sectionPairHint: "采集任务在创建时绑定;此处只调整映射与基线。", + sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。", sectionBaseline: "基线批次", - sectionBaselineHint: "老侧基线用于列出本批预期端口;新侧基线可选。", + sectionBaselineHint: "老侧基线用于列出本批预期对象;新侧基线可选。", optionalNone: "不选", + monitorTemplate: "监控模板", + monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。", + compareTemplate: "对比模板", + compareTemplateBound: "绑定对比模板:{{name}}", portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。", baselineNeedTask: "请先选择对应采集任务", baselinePickHint: "从该任务的历史采集批次中选择", @@ -476,8 +480,8 @@ const zh = { oldBaseline: "老设备基线批次", newBaseline: "新设备基线批次(可选)", needProjectFields: "请填写任务名并选择老/新采集任务", - needExpectPorts: "请先勾选本批预期迁移的端口", - needBaselineFirst: "请先保存老设备基线批次,才能列出端口", + needExpectPorts: "请先勾选本批预期迁移的对象", + needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象", needBizStateTasks: "尚无业务监控任务,请先创建:", projectCreated: "任务已创建", projects: "任务", @@ -503,11 +507,11 @@ const zh = { tabSetup: "基线配置", tabBatches: "割接批次", tabBoard: "判定看板", - saveBaseline: "保存基线/映射", + saveBaseline: "保存基线/映射/模板", baselineSaved: "基线已保存", - pickExpectPorts: "本批预期端口(来自老基线)", - portFilterPh: "筛选接口名 / 描述", - emptyBaselinePorts: "基线中无端口状态数据(确认任务已采 interface_brief)", + pickExpectPorts: "本批预期对象(来自老基线)", + portFilterPh: "筛选对象 / 描述", + emptyBaselinePorts: "基线中无对应 metric 数据(确认监控模板与采集任务已勾选相应 profile)", batchLabel: "批次标签", defaultBatchLabel: "第N晚", expectPorts: "本批预期端口(老侧)", @@ -524,7 +528,8 @@ const zh = { metricPort: "端口状态", progress: "进度", anomaly: "异常", - onlyExpect: "仅看预期端口", + onlyExpect: "仅看预期对象", + allMetrics: "全部监控项", colMetric: "监控项", colKey: "对象", colPort: "老侧端口", @@ -545,10 +550,10 @@ const zh = { verdictNotInvolved: "未涉及", verdictUnexpected: "意外新增", verdictOk: "正常", - enableHighfreq: "开启高频端口采集", + enableHighfreq: "开启高频采集", highfreqHint: - "在业务监控下为老/新设备各建(或复用)仅含 interface_brief 的任务并周期采集;后续要加 BGP 等直接去业务监控勾选即可。", - highfreqReady: "高频端口采集任务已就绪(已绑定到本任务)", + "按当前监控模板的 collect_metric_ids(空则取对比模板全量)为老/新设备各建或复用高频采集任务并勾选对应 profile。", + highfreqReady: "高频采集任务已就绪(已绑定到本任务)", collectNow: "立即采集老+新", collectTriggered: "已触发采集", openBizState: "打开业务监控", diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index 85dc922..b89fe98 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -11,11 +11,11 @@ import { bizMigrationCollectNow, bizMigrationCreateBatch, bizMigrationCreateProject, - bizMigrationEnsurePortHighfreq, + bizMigrationEnsureHighfreq, bizMigrationEvaluate, bizMigrationFinishBatch, bizMigrationGetBoard, - bizMigrationListBaselinePorts, + bizMigrationListBaselineExpect, bizMigrationListBatches, bizMigrationListDiffs, bizMigrationListProjects, @@ -23,6 +23,7 @@ import { bizMigrationPatchBatch, bizMigrationPatchProject, bizMigrationResolveRedTicket, + bizMonitorListTemplates, bizStateListBatches, bizStateListTasks, formatErr, @@ -31,6 +32,13 @@ import { formatSystemTime } from "../../utils/time"; import { jobChipColor, NmStatusChip } from "./nmChips"; 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 = { id: string; name: string; @@ -39,6 +47,8 @@ type Project = { old_baseline_batch_id: string; new_baseline_batch_id: string; mapping_id: string; + monitor_template_id?: string; + monitor_template?: MonitorTplOpt; status: string; note?: string; created_at?: string | null; @@ -49,7 +59,7 @@ type MigBatch = { id: string; batch_label: string; status: string; - expect_set?: { ports?: string[] }; + expect_set?: { ports?: string[]; items?: Array<{ metric_id: string; key?: string; keys?: string[] }> }; accept_status?: string; accept_run_id?: string; accept_summary?: { @@ -90,13 +100,18 @@ type DiffRow = { old_status?: string; new_status?: string; }; -type BaselinePort = { - interface: string; - admin?: string; - phy?: string; - prot?: string; - description?: string; +type ExpectSheetItem = { + key: string; + keys: string[]; mapped_to?: string; + label?: string; + row?: Record; +}; +type ExpectSheet = { + metric_id: string; + key_fields: string[]; + iface_fields: string[]; + items: ExpectSheetItem[]; }; type DetailTab = "setup" | "batches" | "board"; @@ -147,14 +162,17 @@ export function BizMigrationPage() { const [createOldTaskId, setCreateOldTaskId] = useState(""); const [createNewTaskId, setCreateNewTaskId] = useState(""); const [createMappingId, setCreateMappingId] = useState(""); + const [createMonitorTplId, setCreateMonitorTplId] = useState(""); const [createOldBaselineId, setCreateOldBaselineId] = useState(""); const [createNewBaselineId, setCreateNewBaselineId] = useState(""); const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]); const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]); + const [monitorTpls, setMonitorTpls] = useState([]); const [projectId, setProjectId] = useState(""); const [detailTab, setDetailTab] = useState("setup"); const [mappingId, setMappingId] = useState(""); + const [monitorTplId, setMonitorTplId] = useState(""); const [oldBaselineId, setOldBaselineId] = useState(""); const [newBaselineId, setNewBaselineId] = useState(""); const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]); @@ -174,9 +192,11 @@ export function BizMigrationPage() { } | null>(null); const [diffs, setDiffs] = useState([]); const [onlyExpect, setOnlyExpect] = useState(true); + const [boardMetricId, setBoardMetricId] = useState(""); const [batchLabel, setBatchLabel] = useState(""); - const [baselinePorts, setBaselinePorts] = useState([]); - const [selectedPorts, setSelectedPorts] = useState>(new Set()); + const [expectSheets, setExpectSheets] = useState([]); + const [expectMetricId, setExpectMetricId] = useState(""); + const [selectedExpectKeys, setSelectedExpectKeys] = useState>(new Set()); const [portFilter, setPortFilter] = useState(""); const [redTickets, setRedTickets] = useState([]); 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 sheetCards = board?.run?.summary?.sheet_cards || []; - const visibleDiffs = useMemo( - () => (onlyExpect ? diffs.filter((d) => d.in_expect) : diffs), - [diffs, onlyExpect], - ); + const visibleDiffs = useMemo(() => { + let rows = diffs; + 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(); + 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 kw = debouncedListKw.trim().toLowerCase(); @@ -227,33 +260,37 @@ export function BizMigrationPage() { } }, [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(); - if (!kw) return baselinePorts; - return baselinePorts.filter( - (p) => - p.interface.toLowerCase().includes(kw) || - String(p.description || "") - .toLowerCase() - .includes(kw), - ); - }, [baselinePorts, portFilter]); + if (!kw) return items; + return items.filter((it) => { + const blob = `${it.key} ${it.label || ""} ${JSON.stringify(it.row || {})}`.toLowerCase(); + return blob.includes(kw); + }); + }, [expectSheets, expectMetricId, portFilter]); const reloadProjects = useCallback(async () => { const res = await bizMigrationListProjects(); setProjects((res.items || []) as Project[]); }, []); - const loadBaselinePorts = useCallback(async (pid: string) => { + const loadBaselineExpect = useCallback(async (pid: string) => { if (!pid) { - setBaselinePorts([]); + setExpectSheets([]); return; } try { - const res = await bizMigrationListBaselinePorts(pid); - setBaselinePorts(res.ports || []); + const res = await bizMigrationListBaselineExpect(pid); + 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 { - setBaselinePorts([]); + setExpectSheets([]); } }, []); @@ -276,10 +313,11 @@ export function BizMigrationPage() { useEffect(() => { void (async () => { try { - const [pr, tk, mp] = await Promise.all([ + const [pr, tk, mp, mt] = await Promise.all([ bizMigrationListProjects(), bizStateListTasks(), bizCompareListMappings(), + bizMonitorListTemplates(), ]); setProjects((pr.items || []) as Project[]); setTasks( @@ -297,6 +335,20 @@ export function BizMigrationPage() { name: String(x.name || x.id || ""), })), ); + const mts = ((mt.items || []) as Record[]).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) { showError(formatErr(e)); } @@ -321,13 +373,13 @@ export function BizMigrationPage() { const items = (res.items || []) as MigBatch[]; setBatches(items); setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || "")); - await loadBaselinePorts(projectId); + await loadBaselineExpect(projectId); await loadRedTickets(projectId); } catch (e) { showError(formatErr(e)); } })(); - }, [projectId, showError, loadBaselinePorts, loadRedTickets]); + }, [projectId, showError, loadBaselineExpect, loadRedTickets]); useEffect(() => { if (!batchId) { @@ -402,6 +454,7 @@ export function BizMigrationPage() { useEffect(() => { if (project) { setMappingId(project.mapping_id || ""); + setMonitorTplId(project.monitor_template_id || project.monitor_template?.id || ""); setOldBaselineId(project.old_baseline_batch_id || ""); setNewBaselineId(project.new_baseline_batch_id || ""); } @@ -430,6 +483,8 @@ export function BizMigrationPage() { setCreateMappingId(""); setCreateOldBaselineId(""); setCreateNewBaselineId(""); + const port = monitorTpls.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port")); + setCreateMonitorTplId(port?.id || monitorTpls[0]?.id || ""); } const openCreate = () => { @@ -446,7 +501,7 @@ export function BizMigrationPage() { const openProject = (id: string, tab: DetailTab = "setup") => { setProjectId(id); setDetailTab(tab); - setSelectedPorts(new Set()); + setSelectedExpectKeys(new Set()); setPortFilter(""); setBatchLabel(""); setOnlyExpect(true); @@ -469,6 +524,7 @@ export function BizMigrationPage() { old_task_id: createOldTaskId, new_task_id: createNewTaskId, mapping_id: createMappingId || "", + monitor_template_id: createMonitorTplId || "", old_baseline_batch_id: createOldBaselineId || "", new_baseline_batch_id: createNewBaselineId || "", status: "active", @@ -492,9 +548,10 @@ export function BizMigrationPage() { old_baseline_batch_id: oldBaselineId || project?.old_baseline_batch_id || "", new_baseline_batch_id: newBaselineId || project?.new_baseline_batch_id || "", mapping_id: mappingId || project?.mapping_id || "", + monitor_template_id: monitorTplId || project?.monitor_template_id || "", }); await reloadProjects(); - await loadBaselinePorts(projectId); + await loadBaselineExpect(projectId); showOk(t("bizMigration.baselineSaved")); } catch (e) { showError(formatErr(e)); @@ -505,8 +562,15 @@ export function BizMigrationPage() { async function onCreateBatch() { if (!projectId) return; - const ports = [...selectedPorts]; - if (!ports.length) { + const items: Array<{ metric_id: string; keys: string[] }> = []; + 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")); return; } @@ -514,14 +578,14 @@ export function BizMigrationPage() { try { const b = (await bizMigrationCreateBatch(projectId, { batch_label: batchLabel.trim() || t("bizMigration.defaultBatchLabel"), - expect_set: { ports }, + expect_set: { ports, items }, status: "pending", })) as MigBatch; const res = await bizMigrationListBatches(projectId); setBatches((res.items || []) as MigBatch[]); setBatchId(b.id); setBatchLabel(""); - setSelectedPorts(new Set()); + setSelectedExpectKeys(new Set()); if (Number((b as { open_red_count?: number }).open_red_count || 0) > 0) { showOk( t("bizMigration.batchCreatedWithRed", { @@ -606,7 +670,7 @@ export function BizMigrationPage() { if (!projectId) return; setBusy(true); try { - await bizMigrationEnsurePortHighfreq(projectId, { + await bizMigrationEnsureHighfreq(projectId, { interval_sec: 60, collect_now: true, }); @@ -632,15 +696,25 @@ export function BizMigrationPage() { } } - function togglePort(name: string) { - setSelectedPorts((prev) => { + function toggleExpectKey(key: string) { + setSelectedExpectKeys((prev) => { const next = new Set(prev); - if (next.has(name)) next.delete(name); - else next.add(name); + if (next.has(key)) next.delete(key); + else next.add(key); 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) { const key = VERDICT_I18N[v]; return key ? t(key) : v; @@ -855,6 +929,34 @@ export function BizMigrationPage() {

{t("bizMigration.sectionOptionalHint")}

+ setCreateMonitorTplId(e.target.value)} + fullWidth + hint={ + createMonitorTpl?.compare_template_name + ? t("bizMigration.compareTemplateBound", { + name: createMonitorTpl.compare_template_name, + }) + : t("bizMigration.monitorTemplateHint") + } + > + + {monitorTpls.map((m) => ( + + ))} + + {createMonitorTpl?.compare_template_id ? ( +

+ {t("bizMigration.compareTemplate")}:{" "} + + {createMonitorTpl.compare_template_name || createMonitorTpl.compare_template_id} + +

+ ) : null} +
+ {t("bizMigration.monitorTemplate")} + setMonitorTplId(e.target.value)} + fullWidth + hint={t("bizMigration.monitorTemplateHint")} + > + + {monitorTpls.map((m) => ( + + ))} + + {selectedMonitorTpl?.compare_template_id || selectedMonitorTpl?.compare_template_name ? ( +

+ {t("bizMigration.compareTemplate")}:{" "} + + {selectedMonitorTpl.compare_template_name || + selectedMonitorTpl.compare_template_id} + +

+ ) : null} +
+
{t("bizMigration.portMapping")}
- {t("bizMigration.pickExpectPorts")} ({selectedPorts.size}) + {t("bizMigration.pickExpectPorts")} ({selectedExpectKeys.size})
+ {expectSheets.length > 1 ? ( +
+ {expectSheets.map((s) => ( + + ))} +
+ ) : null}
- {t("bizMigration.colPort")} - admin/phy/prot + {t("bizMigration.colKey")} {t("bizMigration.colMapped")} {t("bizMigration.colDesc")} - {filteredBaselinePorts.map((p) => ( - togglePort(p.interface)} - style={{ cursor: "pointer" }} - > - - togglePort(p.interface)} - /> - - {p.interface} - - {p.admin || "-"}/{p.phy || "-"}/{p.prot || "-"} - - {p.mapped_to || "—"} - - {p.description || ""} - - - ))} - {!filteredBaselinePorts.length ? ( + {filteredExpectItems.map((it) => { + const row = it.row || {}; + const statusBits = ["admin", "phy", "prot"] + .map((k) => (row[k] != null ? String(row[k]) : "")) + .filter(Boolean); + const desc = + it.label || + String(row.description || row.desc || row.peer || row.neighbor || "") || + (statusBits.length ? statusBits.join("/") : ""); + return ( + toggleExpectKey(it.key)} + style={{ cursor: "pointer" }} + > + + toggleExpectKey(it.key)} + /> + + {it.key} + {it.mapped_to || "—"} + + {desc} + + + ); + })} + {!filteredExpectItems.length ? ( - +
{t("bizMigration.emptyBaselinePorts")}
@@ -1226,6 +1386,21 @@ export function BizMigrationPage() { + {boardMetricOptions.length > 1 ? ( + setBoardMetricId(e.target.value)} + aria-label={t("bizMigration.colMetric")} + > + + {boardMetricOptions.map((mid) => ( + + ))} + + ) : null}