diff --git a/netx_api/biz_migration/evaluate.py b/netx_api/biz_migration/evaluate.py index 3614246..a3a6d1b 100644 --- a/netx_api/biz_migration/evaluate.py +++ b/netx_api/biz_migration/evaluate.py @@ -83,14 +83,33 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]: continue bucket = out.setdefault(mid, set()) if it.get("key") is not None: - bucket.add(str(it.get("key") or "").strip()) + k = it.get("key") + if isinstance(k, (list, tuple)): + joined = "|".join(str(p).strip() for p in k if str(p).strip()) + if joined: + bucket.add(joined) + else: + s = str(k or "").strip() + if s: + bucket.add(s) keys = it.get("keys") if isinstance(keys, list): - if all(not isinstance(x, (list, tuple, dict)) for x in keys): - bucket.add("|".join(str(x).strip() for x in keys)) + # Flat list of segments → one composite key; else each entry is a key + # (string or nested list/tuple of segments). + if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys): + joined = "|".join(str(x).strip() for x in keys if str(x).strip()) + if joined: + bucket.add(joined) else: for x in keys: - bucket.add(str(x).strip()) + if isinstance(x, (list, tuple)): + joined = "|".join(str(p).strip() for p in x if str(p).strip()) + if joined: + bucket.add(joined) + else: + s = str(x or "").strip() + if s: + bucket.add(s) return {k: {x for x in v if x} for k, v in out.items() if v} @@ -126,7 +145,7 @@ def field_token(field: str, value: str) -> str: def _fields_for_tokens( sheet_override: dict[str, Any] | None, - success_patterns: list[dict[str, Any]] | None, + *pattern_lists: list[dict[str, Any]] | None, ) -> set[str]: """Fields whose current values should be emitted as field:* tokens.""" ov = sheet_override or {} @@ -140,17 +159,38 @@ def _fields_for_tokens( s = str(ft.get("field") or "").strip() if s: fields.add(s) - for pat in success_patterns or []: + patterns: list[Any] = [] + for pl in pattern_lists: + if pl: + patterns.extend(pl) + if not patterns: + for key in ("success", "anomaly"): + raw = ov.get(key) + if isinstance(raw, list): + patterns.extend(raw) + for pat in patterns: if not isinstance(pat, dict): continue for side in ("old", "new"): for tok in pat.get(side) or []: t = str(tok or "") if t.startswith("field:") and t.count(":") >= 2: - # field:name:value parts = t.split(":", 2) if parts[1]: fields.add(parts[1]) + for cond in pat.get(f"{side}_conds") or []: + if isinstance(cond, dict) and str(cond.get("type") or "") in ("", "field", "value"): + s = str(cond.get("field") or "").strip() + if s: + fields.add(s) + for group in pat.get(f"{side}_groups") or []: + if not isinstance(group, list): + continue + for cond in group: + if isinstance(cond, dict) and str(cond.get("type") or "") in ("", "field", "value"): + s = str(cond.get("field") or "").strip() + if s: + fields.add(s) return fields @@ -183,21 +223,313 @@ def _side_tokens( return toks +def _norm_list_values(raw: Any) -> list[str]: + if isinstance(raw, list): + return [str(x).strip().lower() for x in raw if str(x).strip()] + s = str(raw or "").strip() + if not s: + return [] + if "," in s: + return [x.strip().lower() for x in s.split(",") if x.strip()] + return [s.lower()] + + +def _eval_field_op( + row: dict[str, Any] | None, + field: str, + op: str, + value: Any, + *, + base_row: dict[str, Any] | None = None, +) -> bool: + """Evaluate a value condition on current row (optionally vs baseline).""" + f = str(field or "").strip() + if not f: + return False + raw = "" if not row else str(row.get(f) or "").strip() + val = raw.lower() + o = str(op or "eq").strip().lower() + if o in ("changed", "diff"): + base = "" if not base_row else str(base_row.get(f) or "").strip().lower() + if not row: + return False + return base != val + if o in ("unchanged", "same"): + if not row: + return False + base = "" if not base_row else str(base_row.get(f) or "").strip().lower() + return base == val + if o == "empty": + return not val + if o == "not_empty": + return bool(val) + need = _norm_list_values(value) + if o == "eq": + return bool(need) and val == need[0] + if o == "ne": + return bool(need) and val != need[0] + if o == "in": + return bool(need) and val in need + if o == "not_in": + return bool(need) and val not in need + return False + + +def _normalize_cond_type(cond: dict[str, Any]) -> str: + ctype = str(cond.get("type") or "").strip().lower() + if ctype in ("kind", "presence"): + return "presence" + if ctype in ("field", "value"): + return "value" + if ctype == "status": + # Legacy status class → treat as presence-of-status-token via status arg + return "status" + if cond.get("kind") is not None: + return "presence" + if cond.get("status") is not None: + return "status" + if cond.get("field"): + return "value" + return ctype + + +def _eval_condition( + cond: Any, + *, + kind: str, + status: str, + row: dict[str, Any] | None, + tokens: set[str], + base_row: dict[str, Any] | None = None, +) -> bool: + """One leaf: presence (no field) or value (needs field + strategy).""" + if isinstance(cond, str): + t = str(cond).strip() + return bool(t) and t in tokens + if not isinstance(cond, dict): + return False + ctype = _normalize_cond_type(cond) + if ctype == "presence": + want = str( + cond.get("value") + if cond.get("value") is not None + else cond.get("kind") + or "" + ).strip() + return bool(want) and want == str(kind or "").strip() + if ctype == "status": + want = str( + cond.get("value") + if cond.get("value") is not None + else cond.get("status") + or "" + ).strip() + return bool(want) and want == str(status or "").strip() + if ctype == "value": + field = str(cond.get("field") or "").strip() + if not field: + # Legacy status token with no field bound yet. + need = _norm_list_values(cond.get("value")) + return bool(need) and str(status or "").strip().lower() in need + # Gone / missing row: value strategies do not apply (use presence instead). + if str(kind or "").strip() == "removed" or not row: + return str(cond.get("op") or "eq").strip().lower() == "empty" + return _eval_field_op( + row, + field, + str(cond.get("op") or "eq"), + cond.get("value"), + base_row=base_row, + ) + return False + + +def _legacy_tokens_from_pat(pat: dict[str, Any], side: str) -> list[str]: + return [str(x) for x in (pat.get(side) or []) if str(x).strip()] + + +def _conds_list_for_side(pat: dict[str, Any], side: str) -> list[Any]: + key = f"{side}_conds" + raw = pat.get(key) + if isinstance(raw, list) and raw: + return list(raw) + return _legacy_tokens_from_pat(pat, side) + + +def _groups_for_side(pat: dict[str, Any], side: str) -> list[list[Any]]: + """OR-of-AND groups. Prefer ``old_groups``/``new_groups``; migrate flat conds. + + Explicit empty list means don't-care for that side (no groups to match). + """ + key = f"{side}_groups" + if key in pat and isinstance(pat.get(key), list): + raw = pat.get(key) or [] + if not raw: + return [] + groups: list[list[Any]] = [] + for g in raw: + if isinstance(g, list) and g: + groups.append(list(g)) + elif g is not None and not isinstance(g, list): + groups.append([g]) + return groups + conds = _conds_list_for_side(pat, side) + if not conds: + return [] + mode = str(pat.get(f"{side}_mode") or "any").strip().lower() + if mode == "all": + return [list(conds)] + # any → each cond is its own OR group + return [[c] for c in conds] + + +def _match_groups( + groups: list[list[Any]], + *, + kind: str, + status: str, + row: dict[str, Any] | None, + tokens: set[str], + base_row: dict[str, Any] | None = None, +) -> bool: + """Group OR; within group AND.""" + if not groups: + return False + for group in groups: + leaves = [c for c in group if c is not None] + if not leaves: + continue + if all( + _eval_condition( + c, + kind=kind, + status=status, + row=row, + tokens=tokens, + base_row=base_row, + ) + for c in leaves + ): + return True + return False + + +def _match_dual_pattern( + pat: dict[str, Any], + *, + old_kind: str, + new_kind: str, + old_status: str, + new_status: str, + old_row: dict[str, Any] | None, + new_row: dict[str, Any] | None, + old_tokens: set[str], + new_tokens: set[str], + old_base: dict[str, Any] | None, + new_base: dict[str, Any] | None, + require_both_sides: bool, +) -> bool: + """Match one dual pattern. Empty side groups = don't-care when not requiring both.""" + old_groups = _groups_for_side(pat, "old") + new_groups = _groups_for_side(pat, "new") + if require_both_sides: + if not old_groups or not new_groups: + return False + elif not old_groups and not new_groups: + return False + old_ok = True if not old_groups else _match_groups( + old_groups, + kind=old_kind, + status=old_status, + row=old_row, + tokens=old_tokens, + base_row=old_base, + ) + new_ok = True if not new_groups else _match_groups( + new_groups, + kind=new_kind, + status=new_status, + row=new_row, + tokens=new_tokens, + base_row=new_base, + ) + return old_ok and new_ok + + +def _pattern_both_sides(pat: dict[str, Any]) -> bool: + return bool(_groups_for_side(pat, "old")) and bool(_groups_for_side(pat, "new")) + + +def _match_patterns( + patterns: list[dict[str, Any]] | None, + *, + old_kind: str = "", + new_kind: str = "", + old_status: str = "none", + new_status: str = "none", + old_row: dict[str, Any] | None = None, + new_row: dict[str, Any] | None = None, + old_tokens: set[str] | None = None, + new_tokens: set[str] | None = None, + old_base: dict[str, Any] | None = None, + new_base: dict[str, Any] | None = None, + require_both_sides: bool = True, +) -> int: + """Return 1-based matched pattern index, or 0 if none match.""" + ot = old_tokens or set() + nt = new_tokens or set() + for i, pat in enumerate(patterns or [], start=1): + if not isinstance(pat, dict): + continue + if _match_dual_pattern( + pat, + old_kind=old_kind, + new_kind=new_kind, + old_status=old_status, + new_status=new_status, + old_row=old_row, + new_row=new_row, + old_tokens=ot, + new_tokens=nt, + old_base=old_base, + new_base=new_base, + require_both_sides=require_both_sides, + ): + return i + return 0 + + def _match_success( old_tokens: set[str], new_tokens: set[str], success_patterns: list[dict[str, Any]] | None, + *, + old_kind: str = "", + new_kind: str = "", + old_status: str = "none", + new_status: str = "none", + old_row: dict[str, Any] | None = None, + new_row: dict[str, Any] | None = None, + old_base: dict[str, Any] | None = None, + new_base: dict[str, Any] | None = 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 + return ( + _match_patterns( + success_patterns, + old_kind=old_kind, + new_kind=new_kind, + old_status=old_status, + new_status=new_status, + old_row=old_row, + new_row=new_row, + old_tokens=old_tokens, + new_tokens=new_tokens, + old_base=old_base, + new_base=new_base, + require_both_sides=True, + ) + > 0 + ) def _match_field_token_rules( @@ -232,63 +564,156 @@ def dual_verdict( old_status: str = "none", new_status: str = "none", success_patterns: list[dict[str, Any]] | None = None, + anomaly_patterns: list[dict[str, Any]] | None = None, out_of_expect: str = "strict", old_row: dict[str, Any] | None = None, new_row: dict[str, Any] | None = None, + old_base: dict[str, Any] | None = None, + new_base: dict[str, Any] | None = None, sheet_override: dict[str, Any] | None = None, ) -> tuple[str, str]: - """Synthesize old+new into migration board verdict. + """Synthesize old+new into migration board verdict. See dual_verdict_ex.""" + verdict, color, _hit = dual_verdict_ex( + old_kind=old_kind, + new_kind=new_kind, + in_expect=in_expect, + window_active=window_active, + acceptance=acceptance, + old_status=old_status, + new_status=new_status, + success_patterns=success_patterns, + anomaly_patterns=anomaly_patterns, + out_of_expect=out_of_expect, + old_row=old_row, + new_row=new_row, + old_base=old_base, + new_base=new_base, + sheet_override=sheet_override, + ) + return verdict, color - ``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, status classes (up/down), and ``field:name:value``. +def dual_verdict_ex( + *, + old_kind: str, + new_kind: str, + 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, + anomaly_patterns: list[dict[str, Any]] | None = None, + out_of_expect: str = "strict", + old_row: dict[str, Any] | None = None, + new_row: dict[str, Any] | None = None, + old_base: dict[str, Any] | None = None, + new_base: dict[str, Any] | None = None, + sheet_override: dict[str, Any] | None = None, +) -> tuple[str, str, str]: + """Like dual_verdict, plus rule_hit label (success#N / anomaly#N / heuristic). - ``out_of_expect``: strict (default) | warn (yellow instead of red) | ignore. + Patterns use OR-of-AND groups (``old_groups`` / ``new_groups``): + within a group = AND, between groups = OR. Leaf types: + - presence: row existence vs baseline (removed/added/unchanged/changed) + - value: field strategy (eq/ne/in/empty/changed/…) + + Order (in expect): success → anomaly → heuristics. + Anomaly may leave a side empty (= don't-care). + During an active cutover window (not acceptance), side-only anomaly + patterns are deferred so mid-migration downs do not false-red. + When success or anomaly patterns are non-empty (rules mode), skip invented + green-migrate and catch-all anomaly; keep lost / migrating only. """ ov = sheet_override or {} - fields = _fields_for_tokens(ov, success_patterns) + if anomaly_patterns is None and isinstance(ov.get("anomaly"), list): + anomaly_patterns = list(ov.get("anomaly") or []) + fields = _fields_for_tokens(ov, success_patterns, anomaly_patterns) old_toks = _side_tokens(old_kind, old_status, row=old_row, fields=fields) new_toks = _side_tokens(new_kind, new_status, row=new_row, fields=fields) field_ok = _match_field_token_rules(old_row, new_row, list(ov.get("field_tokens") or []) or None) - if in_expect and field_ok and _match_success(old_toks, new_toks, success_patterns): - return "migrated", "green" + match_kw = dict( + old_kind=old_kind, + new_kind=new_kind, + old_status=old_status, + new_status=new_status, + old_row=old_row, + new_row=new_row, + old_tokens=old_toks, + new_tokens=new_toks, + old_base=old_base, + new_base=new_base, + ) + + hit = _match_patterns(success_patterns, require_both_sides=True, **match_kw) + if in_expect and field_ok and hit: + return "migrated", "green", f"success#{hit}" ooe = str(out_of_expect or "strict").strip().lower() if not in_expect: if ooe == "ignore": - return "not_involved", "gray" + return "not_involved", "gray", "out_of_expect:ignore" if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"): + # Both gone / old gone with no new → lost; any other drift (incl. new-only + # removed while old still present) → anomaly. if old_kind == "removed" and new_kind in ("", "removed"): - return ("lost", "yellow") if ooe == "warn" else ("lost", "red") - if old_kind in ("removed", "changed") or new_kind in ("changed",): - return ("anomaly", "yellow") if ooe == "warn" else ("anomaly", "red") + label = "lost" if ooe == "warn" else "lost" + color = "yellow" if ooe == "warn" else "red" + return label, color, "out_of_expect:lost" + return ( + ("anomaly", "yellow", "out_of_expect:anomaly") + if ooe == "warn" + else ("anomaly", "red", "out_of_expect:anomaly") + ) if old_kind == "added" or new_kind == "added": - return "unexpected_new", "yellow" - return "not_involved", "gray" + return "unexpected_new", "yellow", "out_of_expect:added" + return "not_involved", "gray", "out_of_expect:none" - if old_kind in ("removed", "changed") and new_kind in ("added", "unchanged", "changed"): - return "migrated", "green" + # field_tokens gate success only — do not block anomaly on a gone side + anomaly_list = list(anomaly_patterns or []) + if window_active and not acceptance: + # Side-only anomaly = don't-care other side; defer until acceptance / window closed. + anomaly_list = [p for p in anomaly_list if isinstance(p, dict) and _pattern_both_sides(p)] + ahit = _match_patterns(anomaly_list, require_both_sides=False, **match_kw) + if in_expect and ahit: + return "anomaly", "red", f"anomaly#{ahit}" + + # Explicit success/anomaly lists mean "rules mode": do not invent green migrate + # or catch-all anomaly; keep lost / migrating as mid-state fallbacks only. + rules_mode = bool(success_patterns) or bool(anomaly_patterns) + + if ( + not rules_mode + and old_kind in ("removed", "changed") + and new_kind in ("added", "unchanged", "changed") + ): + return "migrated", "green", "heuristic:migrate" if old_kind == "removed" and new_kind in ("", "removed"): - return "lost", "red" + return "lost", "red", "heuristic:lost" if old_kind in ("unchanged", "") and new_kind in ("unchanged", "added", "changed"): if acceptance: - return "unfinished", "red" - return "migrating", "yellow" + return "unfinished", "red", "heuristic:unfinished" + return "migrating", "yellow", "heuristic:migrating" if old_kind == "unchanged" and new_kind in ("", "removed"): if acceptance: - return "unfinished", "red" - return "migrating", "yellow" - side_v, side_c = side_verdict(kind=old_kind or "unchanged", in_expect=True, window_active=window_active) - if side_c == "red" or (new_kind in ("removed",) and old_kind != "removed"): - return "anomaly", "red" + return "unfinished", "red", "heuristic:unfinished" + return "migrating", "yellow", "heuristic:migrating" + if not rules_mode: + side_v, side_c = side_verdict( + kind=old_kind or "unchanged", in_expect=True, window_active=window_active + ) + if side_c == "red" or (new_kind in ("removed",) and old_kind != "removed"): + return "anomaly", "red", "heuristic:anomaly" + else: + side_v, _side_c = side_verdict( + kind=old_kind or "unchanged", in_expect=True, window_active=window_active + ) if acceptance: - return "unfinished", "red" + return "unfinished", "red", "heuristic:unfinished" if window_active or side_v.startswith("expected"): - return "migrating", "yellow" - return "migrating", "yellow" + return "migrating", "yellow", "heuristic:migrating" + return "migrating", "yellow", "heuristic:migrating" def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]: @@ -324,11 +749,126 @@ def expect_keys_for_metric( iface_fields: list[str], ) -> set[str]: keys = set(expect.get(metric_id) or ()) - if iface_fields and expect.get("_ports"): + # ports: shorthand is only for interface_brief, not every sheet with iface fields + if metric_id == PORT_METRIC_ID and expect.get("_ports"): keys |= set(expect["_ports"]) return keys +def _map_defines_iface_expect(metric_id: str, iface_fields: list[str], port_map: dict[str, str]) -> bool: + """Iface sheets: a non-empty port map is the expect scope. + + BGP / PW keep the batch expect picker even if a column is marked iface. + """ + if not port_map or not iface_fields: + return False + mid = str(metric_id or "").strip().lower() + if "bgp" in mid or "pw" in mid or "pseudowire" in mid: + return False + return True + + +def _iface_values(key_str: str, *, key_fields: list[str], iface_fields: list[str]) -> list[str]: + parts = str(key_str or "").split("|") + iface_set = {str(f) for f in iface_fields} + if key_fields and len(parts) == len(key_fields): + return [parts[i] for i, f in enumerate(key_fields) if f in iface_set and parts[i]] + if len(key_fields) == 1 and key_fields[0] in iface_set and key_str: + return [key_str] + return [] + + +def _key_in_port_map( + key_str: str, + *, + key_fields: list[str], + iface_fields: list[str], + port_map: dict[str, str], +) -> bool: + """True when every interface segment of the key is an old-side map entry.""" + vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields) + if not vals: + return False + return all(v in port_map for v in vals) + + +def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool: + """Key is the interface itself (interface_brief), not a business row hanging off it.""" + if not key_fields or not iface_fields: + return False + iface_set = {str(f) for f in iface_fields} + return all(str(f) in iface_set for f in key_fields) + + +def _unmapped_same_iface_anomaly( + *, + old_kind: str, + new_kind: str, + old_status: str, + new_status: str, +) -> bool: + """Same name, not in the map: old down/gone + new still up is not a migration.""" + old_bad = old_kind in ("removed", "changed") or old_status == "down" + new_up = new_kind in ("added", "unchanged", "changed") and new_status != "down" + return old_bad and new_up + + +def _remap_key_str( + key_str: str, + *, + key_fields: list[str], + iface_fields: list[str], + port_map: dict[str, str], +) -> str: + """Map old-side key_str → new-side using iface segments (not whole-string only).""" + if not key_str: + return key_str + if not port_map: + return key_str + if key_str in port_map and (not key_fields or len(key_fields) == 1): + return port_map[key_str] + parts = str(key_str).split("|") + if key_fields and len(parts) == len(key_fields): + iface_set = {str(f) for f in iface_fields} + out: list[str] = [] + for i, f in enumerate(key_fields): + v = parts[i] + if f in iface_set and v in port_map: + out.append(port_map[v]) + else: + out.append(v) + return "|".join(out) + return port_map.get(key_str, key_str) + + +def _reverse_remap_key_str( + key_str: str, + *, + key_fields: list[str], + iface_fields: list[str], + rev_map: dict[str, str], +) -> str: + """Map new-side key_str → old-side canon identity.""" + if not key_str: + return key_str + if not rev_map: + return key_str + if key_str in rev_map and (not key_fields or len(key_fields) == 1): + return rev_map[key_str] + parts = str(key_str).split("|") + if key_fields and len(parts) == len(key_fields): + iface_set = {str(f) for f in iface_fields} + out: list[str] = [] + for i, f in enumerate(key_fields): + v = parts[i] + if f in iface_set and v in rev_map: + out.append(rev_map[v]) + else: + out.append(v) + return "|".join(out) + return rev_map.get(key_str, key_str) + + def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]: """Current-side snapshot from a compare diff (empty if removed / missing).""" if not diff: @@ -381,8 +921,12 @@ def evaluate_metric_dual( "anomaly": 0, "rows": [], "skipped": True, + "new_baseline_mode": "skipped", + "new_baseline_missing": False, + "anomaly_in_expect": 0, } success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else [] + anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else [] old_cmp = compare_rows( before_rows=old_baseline_rows, @@ -395,15 +939,34 @@ def evaluate_metric_dual( ) old_idx = build_diff_index_from_compare(old_cmp) + new_baseline_mode = "provided" + new_baseline_missing = False if new_baseline_rows is not None: - new_before = new_baseline_rows + new_before = list(new_baseline_rows) + new_baseline_mode = "provided" + # Explicit empty batch is as unusable as "no baseline" for presence semantics. + if not new_before and (old_baseline_rows or new_current_rows): + if port_map and iface_fields: + new_before = [ + apply_port_map(r, iface_fields=iface_fields, port_map=port_map) + for r in old_baseline_rows + ] + new_baseline_mode = "port_mapped" + new_baseline_missing = False + else: + new_baseline_missing = True + new_baseline_mode = "empty" elif port_map and iface_fields: new_before = [ apply_port_map(r, iface_fields=iface_fields, port_map=port_map) for r in old_baseline_rows ] + new_baseline_mode = "port_mapped" else: new_before = [] + new_baseline_mode = "empty" + # No new baseline and no map → every current new row looks like "added". + new_baseline_missing = bool(old_baseline_rows or new_current_rows) new_cmp = compare_rows( before_rows=new_before, @@ -417,6 +980,26 @@ def evaluate_metric_dual( new_idx = build_diff_index_from_compare(new_cmp) rev_map = {v: k for k, v in port_map.items()} + map_scoped = _map_defines_iface_expect(metric_id, iface_fields, port_map) + if map_scoped: + scoped: set[str] = set() + for ks in old_idx: + if _key_in_port_map( + ks, key_fields=key_fields, iface_fields=iface_fields, port_map=port_map + ): + scoped.add(ks) + for ks in new_idx: + canon = _reverse_remap_key_str( + ks, + key_fields=key_fields, + iface_fields=iface_fields, + rev_map=rev_map, + ) + if _key_in_port_map( + canon, key_fields=key_fields, iface_fields=iface_fields, port_map=port_map + ): + scoped.add(canon) + expect_keys = scoped canon_keys: list[str] = [] seen: set[str] = set() @@ -431,15 +1014,28 @@ def evaluate_metric_dual( for ks in sorted(old_idx): _add(ks) for ks in sorted(new_idx): - _add(rev_map.get(ks, ks)) + _add( + _reverse_remap_key_str( + ks, + key_fields=key_fields, + iface_fields=iface_fields, + rev_map=rev_map, + ) + ) rows_out: list[dict[str, Any]] = [] progress_ok = 0 progress_total = 0 anomaly = 0 + anomaly_in_expect = 0 for old_ks in canon_keys: - new_ks = port_map.get(old_ks, old_ks) + new_ks = _remap_key_str( + old_ks, + key_fields=key_fields, + iface_fields=iface_fields, + port_map=port_map, + ) in_exp = old_ks in expect_keys if in_exp: progress_total += 1 @@ -458,27 +1054,50 @@ def evaluate_metric_dual( old_cur = _current_row(od) new_cur = _current_row(nd) + old_base = dict((od or {}).get("before") or {}) + new_base = dict((nd or {}).get("before") or {}) 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, - out_of_expect=out_of_expect, - old_row=old_cur or None, - new_row=new_cur or None, - sheet_override=ov, - ) + # Mapped iface rows are the expect set. Unmapped rows stay out of the + # migration compare, except same-name ports where old went down and the + # new device's same port is up — that is anomaly, not a successful cutover. + if map_scoped and not in_exp: + if not ( + _port_identity(key_fields, iface_fields) + and _unmapped_same_iface_anomaly( + old_kind=old_kind, + new_kind=new_kind, + old_status=old_st, + new_status=new_st, + ) + ): + continue + verdict, color, rule_hit = "anomaly", "red", "unmapped_same_iface" + else: + verdict, color, rule_hit = dual_verdict_ex( + 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, + anomaly_patterns=anomaly_patterns or None, + out_of_expect=out_of_expect, + old_row=old_cur or None, + new_row=new_cur or None, + old_base=old_base or None, + new_base=new_base or None, + sheet_override=ov, + ) if in_exp and verdict == "migrated" and color == "green": progress_ok += 1 if color == "red": anomaly += 1 + if in_exp: + anomaly_in_expect += 1 rows_out.append( { @@ -488,6 +1107,7 @@ def evaluate_metric_dual( "new_key_str": new_ks, "verdict": verdict, "color": color, + "rule_hit": rule_hit, "in_expect": in_exp, "old_kind": old_kind, "new_kind": new_kind, @@ -525,7 +1145,10 @@ def evaluate_metric_dual( "progress_ok": progress_ok, "progress_total": progress_total if progress_total else len(expect_keys), "anomaly": anomaly, + "anomaly_in_expect": anomaly_in_expect, "rows": rows_out, + "new_baseline_mode": new_baseline_mode, + "new_baseline_missing": new_baseline_missing, } diff --git a/netx_api/biz_migration/monitor_templates.py b/netx_api/biz_migration/monitor_templates.py index 491ec40..2ca10dc 100644 --- a/netx_api/biz_migration/monitor_templates.py +++ b/netx_api/biz_migration/monitor_templates.py @@ -67,24 +67,117 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate: return row +def _anomaly_both_gone() -> dict[str, Any]: + return { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + + +def _anomaly_side_only(side: str, groups: list[list[dict[str, Any]]]) -> dict[str, Any]: + return { + "old_groups": groups if side == "old" else [], + "new_groups": groups if side == "new" else [], + } + + +def _default_anomaly_for_state(field: str, down_values: list[str]) -> list[dict[str, Any]]: + down = [{"type": "value", "field": field, "op": "in", "value": list(down_values)}] + gone = [{"type": "presence", "value": "removed"}] + return [ + _anomaly_both_gone(), + _anomaly_side_only("old", [gone, down]), + _anomaly_side_only("new", [gone, down]), + ] + + +def _default_anomaly_presence_only() -> list[dict[str, Any]]: + gone = [{"type": "presence", "value": "removed"}] + return [ + _anomaly_both_gone(), + _anomaly_side_only("old", [gone]), + _anomaly_side_only("new", [gone]), + ] + + +def _success_presence_migrate() -> dict[str, Any]: + """Old gone → new appeared (no bare unchanged = false green).""" + return { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + + +def _success_stateful( + *, + old_down_groups: list[list[dict[str, Any]]], + new_up_conds: list[dict[str, Any]], +) -> dict[str, Any]: + """Success requires healthy new side — bare unchanged alone is not enough.""" + return { + "old_groups": [ + [{"type": "presence", "value": "removed"}], + *old_down_groups, + ], + "new_groups": [ + [{"type": "presence", "value": "added"}, *new_up_conds], + [{"type": "presence", "value": "unchanged"}, *new_up_conds], + list(new_up_conds), + ], + } + + def preset_override_for_metric(metric_id: str) -> dict[str, Any]: """Default dual-verdict overlay for a compare-template sheet.""" mid = str(metric_id or "").strip() if mid == PORT_METRIC_ID: + up = [ + {"type": "value", "field": "admin", "op": "in", "value": ["up"]}, + {"type": "value", "field": "phy", "op": "in", "value": ["up"]}, + ] return { "metric_id": mid, "status_fields": list(PORT_STATUS_FIELDS), "down_values": ["down"], "up_values": ["up"], - "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + "success": [ + _success_stateful( + old_down_groups=[ + [ + {"type": "value", "field": "admin", "op": "in", "value": ["down"]}, + {"type": "value", "field": "phy", "op": "in", "value": ["down"]}, + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state("admin", ["down"]), } if mid == "bgp_peer": + up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}] return { "metric_id": mid, "status_fields": ["state"], "down_values": ["idle", "active", "connect", "down"], "up_values": ["established"], - "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + "success": [ + _success_stateful( + old_down_groups=[ + [ + { + "type": "value", + "field": "state", + "op": "in", + "value": ["idle", "active", "connect", "down"], + } + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state( + "state", ["idle", "active", "connect", "down"] + ), } if mid in ("arp", "nd6_cache", "lldp_neighbor"): return { @@ -92,15 +185,39 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]: "status_fields": [], "down_values": [], "up_values": [], - "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + "success": [_success_presence_migrate()], + "anomaly": _default_anomaly_presence_only(), } if "isis" in mid or "ospf" in mid or "adjacency" in mid: + up = [ + { + "type": "value", + "field": "state", + "op": "in", + "value": ["up", "full", "2way"], + } + ] return { "metric_id": mid, "status_fields": ["state"], "down_values": ["down", "init", "idle"], "up_values": ["up", "full", "2way"], - "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + "success": [ + _success_stateful( + old_down_groups=[ + [ + { + "type": "value", + "field": "state", + "op": "in", + "value": ["down", "init", "idle"], + } + ] + ], + new_up_conds=up, + ) + ], + "anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]), } if "route" in mid or "vrf" in mid: return { @@ -108,14 +225,16 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]: "status_fields": [], "down_values": [], "up_values": [], - "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + "success": [_success_presence_migrate()], + "anomaly": _default_anomaly_presence_only(), } return { "metric_id": mid, "status_fields": [], - "down_values": ["down"], - "up_values": ["up"], - "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + "down_values": [], + "up_values": [], + "success": [_success_presence_migrate()], + "anomaly": _default_anomaly_presence_only(), } diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index 365c3c9..fd5f83f 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -341,6 +341,8 @@ def run_evaluate( raise HTTPException(status_code=404, detail="project_not_found") if not proj.old_baseline_batch_id: raise HTTPException(status_code=400, detail="old_baseline_required") + if not proj.new_baseline_batch_id: + raise HTTPException(status_code=400, detail="new_baseline_required") old_batch = db.get(BizStateBatch, old_batch_id.strip()) if old_batch_id.strip() else None if not old_batch: @@ -389,12 +391,10 @@ def run_evaluate( _load_metric_rows(db, batch_id=old_cur, metric_id=mid), row_filters, ) - new_base_rows = None - if proj.new_baseline_batch_id: - new_base_rows = apply_row_filters( - _load_metric_rows(db, batch_id=proj.new_baseline_batch_id, metric_id=mid), - row_filters, - ) + new_base_rows = apply_row_filters( + _load_metric_rows(db, batch_id=proj.new_baseline_batch_id, metric_id=mid), + row_filters, + ) new_now = apply_row_filters( _load_metric_rows(db, batch_id=new_cur, metric_id=mid), row_filters, @@ -424,8 +424,11 @@ def run_evaluate( "progress_ok": one["progress_ok"], "progress_total": one["progress_total"], "anomaly": one["anomaly"], + "anomaly_in_expect": int(one.get("anomaly_in_expect") or 0), "old_summary": one["old_summary"], "new_summary": one["new_summary"], + "new_baseline_mode": one.get("new_baseline_mode") or "provided", + "new_baseline_missing": bool(one.get("new_baseline_missing")), } ) for r in one["rows"]: @@ -455,6 +458,12 @@ def run_evaluate( "total": sum(c["progress_total"] for c in sheet_cards), }, "anomaly": sum(c["anomaly"] for c in sheet_cards), + "new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in sheet_cards), + "missing_metrics": [ + str(c.get("metric_id") or "") + for c in sheet_cards + if c.get("new_baseline_missing") + ], "verdict_counts": verdict_counts, "window_active": window_active, "expect_ports": sorted(expect.get("_ports") or ()), @@ -489,6 +498,7 @@ def run_evaluate( "new_key_str": r.get("new_key_str"), "old_status": r.get("old_status"), "new_status": r.get("new_status"), + "rule_hit": r.get("rule_hit") or "", }, old_kind=str(r.get("old_kind") or ""), new_kind=str(r.get("new_kind") or ""), @@ -543,6 +553,7 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]: "new_key_str": kj.get("new_key_str") or "", "old_status": kj.get("old_status") or "", "new_status": kj.get("new_status") or "", + "rule_hit": kj.get("rule_hit") or "", "old_kind": d.old_kind, "new_kind": d.new_kind, "old": d.old_json, @@ -594,7 +605,7 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any] 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) + sheets, sheet_overrides, _ = resolve_evaluate_sheets(db, mt) port_map = _port_map_dict(db, p.mapping_id) out_sheets: list[dict[str, Any]] = [] for sheet in sheets: @@ -602,6 +613,9 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any] key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()] if not mid or not key_fields: continue + sheet_ov = override_for_metric(sheet_overrides, mid) + if sheet_ov.get("skip_dual"): + 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), @@ -1008,10 +1022,22 @@ def finish_batch( anomaly = int(summary.get("anomaly") or 0) ok = int(progress.get("ok") or 0) total = int(progress.get("total") or 0) - passed = anomaly == 0 and (total == 0 or ok >= total) + missing_metrics = [str(x) for x in (summary.get("missing_metrics") or []) if str(x)] + fail_reasons: list[str] = [] + if total <= 0: + fail_reasons.append("empty_expect") + if missing_metrics: + fail_reasons.append("new_baseline_missing") + if anomaly: + fail_reasons.append("anomaly") + if total > 0 and ok < total: + fail_reasons.append("incomplete") + passed = not fail_reasons accept_summary = { "passed": passed, + "fail_reasons": fail_reasons, + "missing_metrics": missing_metrics, "progress_ok": ok, "progress_total": total, "anomaly": anomaly, diff --git a/tests/test_biz_migration_evaluate.py b/tests/test_biz_migration_evaluate.py index 0b44098..ae767a1 100644 --- a/tests/test_biz_migration_evaluate.py +++ b/tests/test_biz_migration_evaluate.py @@ -8,10 +8,12 @@ from netx_api.biz_migration.evaluate import ( classify_status, dual_verdict, evaluate_metric_dual, + expect_keys_for_metric, parse_expect_set, port_sheet_def, port_status_label, side_verdict, + _remap_key_str, ) @@ -42,6 +44,49 @@ class ParseExpectSetTests(unittest.TestCase): self.assertEqual(got["bgp_peer"], {"AS1|1.1.1.1"}) self.assertEqual(got["arp"], {"10.0.0.1"}) + def test_multi_select_each_key_is_separate(self): + """UI must send one item per selected row, not a flat keys list.""" + got = parse_expect_set( + { + "items": [ + {"metric_id": "bgp_peer", "key": "1.1.1.1"}, + {"metric_id": "bgp_peer", "key": "2.2.2.2"}, + {"metric_id": "arp", "key": "10.0.0.1"}, + ] + } + ) + self.assertEqual(got["bgp_peer"], {"1.1.1.1", "2.2.2.2"}) + self.assertEqual(got["arp"], {"10.0.0.1"}) + + def test_flat_keys_list_is_one_composite(self): + got = parse_expect_set( + {"items": [{"metric_id": "bgp_peer", "keys": ["1.1.1.1", "2.2.2.2"]}]} + ) + self.assertEqual(got["bgp_peer"], {"1.1.1.1|2.2.2.2"}) + + def test_nested_keys_list(self): + got = parse_expect_set( + { + "items": [ + {"metric_id": "isis_adjacency", "keys": [["p1", "gei-1", "sys1"], ["p1", "gei-2", "sys2"]]}, + {"metric_id": "arp", "key": ["10.0.0.1", "vrf1"]}, + ] + } + ) + self.assertEqual(got["isis_adjacency"], {"p1|gei-1|sys1", "p1|gei-2|sys2"}) + self.assertEqual(got["arp"], {"10.0.0.1|vrf1"}) + + def test_ports_only_apply_to_interface_brief(self): + expect = parse_expect_set({"ports": ["gei-1"]}) + self.assertEqual( + expect_keys_for_metric(expect, metric_id="interface_brief", iface_fields=["interface"]), + {"gei-1"}, + ) + self.assertEqual( + expect_keys_for_metric(expect, metric_id="arp", iface_fields=["interface"]), + set(), + ) + class PortSheetTests(unittest.TestCase): def test_port_sheet_fields(self): @@ -155,6 +200,75 @@ class VerdictTests(unittest.TestCase): ("anomaly", "yellow"), ) + def test_out_of_expect_new_side_only_removed(self): + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=False, + window_active=False, + out_of_expect="strict", + ), + ("anomaly", "red"), + ) + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=False, + window_active=False, + out_of_expect="warn", + ), + ("anomaly", "yellow"), + ) + + def test_rules_mode_skips_auto_migrate(self): + """With success patterns configured, bare kind migrate is not invented.""" + verdict, color = dual_verdict( + old_kind="removed", + new_kind="added", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [ + [ + {"type": "presence", "value": "added"}, + {"type": "value", "field": "state", "op": "eq", "value": "up"}, + ] + ], + } + ], + new_row={"state": "down"}, + ) + self.assertNotEqual(verdict, "migrated") + self.assertEqual(color, "yellow") + + def test_rules_mode_skips_catch_all_anomaly(self): + """With anomaly list present, mid-cutover stays migrating not invented anomaly.""" + self.assertEqual( + dual_verdict( + old_kind="changed", + new_kind="unchanged", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + ], + ), + ("migrating", "yellow"), + ) + def test_field_token_success(self): self.assertEqual( dual_verdict( @@ -174,6 +288,259 @@ class VerdictTests(unittest.TestCase): ("migrated", "green"), ) + def test_structured_field_conds_all_mode(self): + self.assertEqual( + dual_verdict( + old_kind="changed", + new_kind="changed", + in_expect=True, + window_active=True, + old_status="other", + new_status="other", + success_patterns=[ + { + "old_mode": "all", + "new_mode": "all", + "old_conds": [ + {"type": "kind", "value": "changed"}, + {"type": "field", "field": "state", "op": "in", "value": ["idle", "active"]}, + ], + "new_conds": [ + {"type": "kind", "value": "changed"}, + {"type": "field", "field": "state", "op": "eq", "value": "established"}, + ], + } + ], + old_row={"state": "Idle"}, + new_row={"state": "Established"}, + ), + ("migrated", "green"), + ) + + def test_structured_conds_fail_when_all_incomplete(self): + verdict, _color = dual_verdict( + old_kind="unchanged", + new_kind="changed", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_mode": "all", + "new_mode": "any", + "old_conds": [ + {"type": "kind", "value": "unchanged"}, + {"type": "field", "field": "state", "op": "eq", "value": "idle"}, + ], + "new_conds": [{"type": "kind", "value": "changed"}], + } + ], + old_row={"state": "Active"}, + new_row={"state": "Established"}, + ) + self.assertNotEqual(verdict, "migrated") + + def test_groups_or_of_and(self): + """Within a group = AND; between groups = OR.""" + patterns = [ + { + "old_groups": [ + [{"type": "presence", "value": "removed"}], + [ + {"type": "presence", "value": "changed"}, + {"type": "value", "field": "state", "op": "in", "value": ["idle", "active"]}, + ], + ], + "new_groups": [ + [{"type": "value", "field": "state", "op": "eq", "value": "established"}], + ], + } + ] + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="changed", + in_expect=True, + window_active=True, + success_patterns=patterns, + old_row={}, + new_row={"state": "Established"}, + ), + ("migrated", "green"), + ) + self.assertEqual( + dual_verdict( + old_kind="changed", + new_kind="changed", + in_expect=True, + window_active=True, + success_patterns=patterns, + old_row={"state": "Idle"}, + new_row={"state": "Established"}, + ), + ("migrated", "green"), + ) + verdict, _color = dual_verdict( + old_kind="changed", + new_kind="changed", + in_expect=True, + window_active=True, + success_patterns=patterns, + old_row={"state": "Established"}, + new_row={"state": "Established"}, + ) + self.assertNotEqual(verdict, "migrated") + + def test_value_changed_vs_baseline(self): + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="changed", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "unchanged"}]], + "new_groups": [ + [{"type": "value", "field": "state", "op": "changed"}] + ], + } + ], + old_row={"state": "Idle"}, + new_row={"state": "Established"}, + old_base={"state": "Idle"}, + new_base={"state": "Idle"}, + ), + ("migrated", "green"), + ) + + def test_screenshot_style_success(self): + """Old removed OR state!=up; new added AND state=up.""" + patterns = [ + { + "old_groups": [ + [{"type": "presence", "value": "removed"}], + [{"type": "value", "field": "state", "op": "ne", "value": "up"}], + ], + "new_groups": [ + [ + {"type": "presence", "value": "added"}, + {"type": "value", "field": "state", "op": "eq", "value": "up"}, + ] + ], + } + ] + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="added", + in_expect=True, + window_active=True, + success_patterns=patterns, + old_row={}, + new_row={"state": "up"}, + ), + ("migrated", "green"), + ) + + def test_anomaly_both_gone(self): + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="removed", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + ], + ), + ("anomaly", "red"), + ) + + def test_anomaly_new_side_dont_care_old(self): + """Side-only anomaly is deferred while window is active (avoid false red).""" + anomaly = [ + { + "old_groups": [], + "new_groups": [ + [{"type": "presence", "value": "removed"}], + [ + { + "type": "value", + "field": "state", + "op": "ne", + "value": "up", + } + ], + ], + } + ] + success = [ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ] + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=True, + window_active=True, + acceptance=False, + success_patterns=success, + anomaly_patterns=anomaly, + new_row={}, + ), + ("migrating", "yellow"), + ) + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=True, + window_active=False, + acceptance=True, + success_patterns=success, + anomaly_patterns=anomaly, + new_row={}, + ), + ("anomaly", "red"), + ) + + def test_success_beats_anomaly(self): + success = [ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ] + anomaly = [ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ] + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="added", + in_expect=True, + window_active=True, + success_patterns=success, + anomaly_patterns=anomaly, + ), + ("migrated", "green"), + ) + class EvaluateMetricDualTests(unittest.TestCase): def test_port_migration_happy_path(self): @@ -349,6 +716,400 @@ class EvaluateMetricDualTests(unittest.TestCase): reds = [r for r in out["rows"] if r["color"] == "red"] self.assertTrue(reds, out["rows"]) + def test_new_baseline_missing_flag(self): + old_base = [{"peer": "1.1.1.1", "state": "Established"}] + 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=["state"], + old_baseline_rows=old_base, + old_current_rows=old_base, + new_baseline_rows=None, + new_current_rows=new_cur, + port_map={}, + expect=expect, + window_active=True, + sheet_override={}, + ) + self.assertTrue(out["new_baseline_missing"]) + self.assertEqual(out["new_baseline_mode"], "empty") + # With empty new before, current rows are all "added" + kinds = {r["new_kind"] for r in out["rows"] if r.get("in_expect")} + self.assertIn("added", kinds) + + def test_new_baseline_port_mapped_not_missing(self): + old_base = [{"interface": "gei-old", "admin": "up", "phy": "up", "prot": "up"}] + 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=[], + new_baseline_rows=None, + new_current_rows=new_cur, + port_map={"gei-old": "gei-new"}, + expect=expect, + window_active=True, + sheet_override={}, + ) + self.assertFalse(out["new_baseline_missing"]) + self.assertEqual(out["new_baseline_mode"], "port_mapped") + + def test_empty_provided_new_baseline_falls_back_or_missing(self): + old_base = [{"peer": "1.1.1.1", "state": "Established"}] + 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=["state"], + old_baseline_rows=old_base, + old_current_rows=[], + new_baseline_rows=[], + new_current_rows=new_cur, + port_map={}, + expect=expect, + window_active=True, + sheet_override={}, + ) + self.assertTrue(out["new_baseline_missing"]) + self.assertEqual(out["new_baseline_mode"], "empty") + # Still reports presence as added vs empty before — callers should heed the flag. + kinds = {r["new_kind"] for r in out["rows"] if r.get("in_expect")} + self.assertIn("added", kinds) + + def test_value_ne_false_when_removed(self): + verdict, color = dual_verdict( + old_kind="removed", + new_kind="unchanged", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [ + [{"type": "value", "field": "state", "op": "ne", "value": "up"}] + ], + "new_groups": [[{"type": "presence", "value": "unchanged"}]], + } + ], + old_row={}, + new_row={"state": "up"}, + ) + self.assertNotEqual(verdict, "migrated") + + def test_composite_key_port_remap(self): + mapped = _remap_key_str( + "gei-old|vrf1", + key_fields=["interface", "vrf"], + iface_fields=["interface"], + port_map={"gei-old": "gei-new"}, + ) + self.assertEqual(mapped, "gei-new|vrf1") + + def test_anomaly_not_gated_by_field_tokens_on_removed(self): + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="removed", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + ], + sheet_override={ + "field_tokens": [{"side": "old", "field": "state", "in": ["up"]}], + }, + old_row={}, + new_row={}, + ), + ("anomaly", "red"), + ) + + def test_side_only_anomaly_deferred_during_window(self): + """Mid-cutover: new-side gone alone must not red while window is active.""" + side_only = [ + { + "old_groups": [], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + ] + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=True, + window_active=True, + acceptance=False, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=side_only, + ), + ("migrating", "yellow"), + ) + # Acceptance / window closed → side-only fires + self.assertEqual( + dual_verdict( + old_kind="unchanged", + new_kind="removed", + in_expect=True, + window_active=False, + acceptance=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=side_only, + ), + ("anomaly", "red"), + ) + + def test_both_gone_anomaly_still_fires_during_window(self): + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="removed", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + anomaly_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "removed"}]], + } + ], + ), + ("anomaly", "red"), + ) + + def test_bare_unchanged_not_success_with_tight_preset(self): + from netx_api.biz_migration.monitor_templates import preset_override_for_metric + + ov = preset_override_for_metric("interface_brief") + # Old removed + new still present but down → not green + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="unchanged", + in_expect=True, + window_active=True, + old_status="none", + new_status="down", + success_patterns=ov["success"], + anomaly_patterns=ov["anomaly"], + old_row={}, + new_row={"admin": "down", "phy": "down"}, + ), + ("migrating", "yellow"), + ) + # Old removed + new up → green + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="unchanged", + in_expect=True, + window_active=True, + old_status="none", + new_status="up", + success_patterns=ov["success"], + anomaly_patterns=ov["anomaly"], + old_row={}, + new_row={"admin": "up", "phy": "up"}, + ), + ("migrated", "green"), + ) + + def test_arp_preset_requires_added_not_bare_unchanged(self): + from netx_api.biz_migration.monitor_templates import preset_override_for_metric + + ov = preset_override_for_metric("arp") + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="unchanged", + in_expect=True, + window_active=True, + success_patterns=ov["success"], + anomaly_patterns=ov["anomaly"], + ), + ("migrating", "yellow"), + ) + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="added", + in_expect=True, + window_active=True, + success_patterns=ov["success"], + anomaly_patterns=ov["anomaly"], + ), + ("migrated", "green"), + ) + + def test_rule_hit_labels(self): + from netx_api.biz_migration.evaluate import dual_verdict_ex + + v, c, hit = dual_verdict_ex( + old_kind="removed", + new_kind="added", + in_expect=True, + window_active=True, + success_patterns=[ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + ) + self.assertEqual((v, c), ("migrated", "green")) + self.assertTrue(hit.startswith("success#")) + + +class PortMapScopeTests(unittest.TestCase): + def test_mapping_is_expect_for_iface_sheet(self): + old_base = [ + {"interface": "gei-old", "admin": "up", "phy": "up", "prot": "up"}, + {"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}, + ] + new_base = [ + {"interface": "gei-new", "admin": "up", "phy": "up", "prot": "up"}, + {"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}, + ] + new_cur = [ + {"interface": "gei-new", "admin": "up", "phy": "up", "prot": "up"}, + {"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}, + ] + 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=[ + {"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}, + ], + new_baseline_rows=new_base, + new_current_rows=new_cur, + port_map={"gei-old": "gei-new"}, + expect=parse_expect_set({}), + window_active=True, + sheet_override=PORT_OVERRIDE, + ) + self.assertEqual(out["progress_total"], 1) + keys = {r["key_str"] for r in out["rows"]} + self.assertIn("gei-old", keys) + self.assertNotIn("gei-keep", keys) + + def test_unmapped_same_iface_old_down_new_up_is_anomaly(self): + old_base = [ + {"interface": "gei-old", "admin": "up", "phy": "up", "prot": "up"}, + {"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}, + ] + old_cur = [{"interface": "gei-keep", "admin": "down", "phy": "down", "prot": "down"}] + new_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] + new_cur = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] + 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=new_base, + new_current_rows=new_cur, + port_map={"gei-old": "gei-new"}, + expect=parse_expect_set({}), + window_active=True, + sheet_override=PORT_OVERRIDE, + ) + keep = [r for r in out["rows"] if r["key_str"] == "gei-keep"] + self.assertEqual(len(keep), 1, out["rows"]) + self.assertEqual(keep[0]["verdict"], "anomaly") + self.assertEqual(keep[0]["rule_hit"], "unmapped_same_iface") + self.assertFalse(keep[0]["in_expect"]) + + def test_arp_filtered_by_iface_mapping(self): + old_base = [ + {"ip": "10.0.0.1", "interface": "gei-old"}, + {"ip": "10.0.0.2", "interface": "gei-keep"}, + ] + new_cur = [{"ip": "10.0.0.1", "interface": "gei-new"}] + out = evaluate_metric_dual( + metric_id="arp", + key_fields=["ip", "interface"], + iface_fields=["interface"], + compare_fields=[], + old_baseline_rows=old_base, + old_current_rows=[], + new_baseline_rows=[{"ip": "9.9.9.9", "interface": "gei-other"}], + new_current_rows=new_cur, + port_map={"gei-old": "gei-new"}, + expect=parse_expect_set({}), + window_active=True, + sheet_override={ + "success": [ + { + "old_groups": [[{"type": "presence", "value": "removed"}]], + "new_groups": [[{"type": "presence", "value": "added"}]], + } + ], + }, + ) + keys = {r["key_str"] for r in out["rows"]} + self.assertIn("10.0.0.1|gei-old", keys) + self.assertNotIn("10.0.0.2|gei-keep", keys) + migrated = [r for r in out["rows"] if r["verdict"] == "migrated"] + self.assertTrue(migrated, out["rows"]) + self.assertEqual(migrated[0]["new_key_str"], "10.0.0.1|gei-new") + + def test_bgp_still_uses_expect_picker(self): + out = evaluate_metric_dual( + metric_id="bgp_peer", + key_fields=["peer"], + iface_fields=["interface"], + compare_fields=["state"], + old_baseline_rows=[{"peer": "1.1.1.1", "interface": "gei-old", "state": "Established"}], + old_current_rows=[], + new_baseline_rows=[{"peer": "1.1.1.1", "interface": "gei-new", "state": "Idle"}], + new_current_rows=[{"peer": "1.1.1.1", "interface": "gei-new", "state": "Established"}], + port_map={"gei-keep": "gei-other"}, + expect=parse_expect_set({"items": [{"metric_id": "bgp_peer", "key": "1.1.1.1"}]}), + window_active=True, + sheet_override={ + "success": [{"old": ["removed"], "new": ["changed", "up"]}], + "status_fields": ["state"], + "up_values": ["established"], + "down_values": ["idle"], + }, + ) + self.assertTrue(any(r["key_str"] == "1.1.1.1" and r["in_expect"] for r in out["rows"])) + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 293166c..1d65c47 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -288,8 +288,16 @@ const en = { stepPickMetrics: "① Pick metrics", stepConfigRules: "② Configure compare rules", pickMetricsHint: "Check metrics to add. Then configure each sheet below.", - noMetricsYet: "Select at least one metric above", + pickMetricsHintShort: "Click to add/remove; switch sheets in the left list below.", + showMetrics: "Show metric picker", + hideMetrics: "Hide metric picker", + noMetricsYet: "Select at least one metric first", selectedCount: "{{n}} selected", + tabFields: "Field rules", + tabFilters: "Row filters", + fieldsHintShort: + "Key aligns rows; Compare fields take part in value diff; empty compare = presence-only.", + tagCompareN: "{{n}} cmp", colFields: "Fields", keyFields: "Key", ifaceFields: "Interface", @@ -301,7 +309,7 @@ const en = { modeEq: "Exact equal", modeNumeric: "Abs delta", modePercent: "Relative %", - normalizeField: "Normalize", + normalizeField: "Norm", ignoreFields: "Ignore", field: "Field", note: "Note", @@ -312,12 +320,17 @@ const en = { rowFilters: "Row filter rules", rowFiltersHint: "Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.", - rowFiltersEmpty: "No filters → compare all rows. Add a condition group to start.", - filterClear: "Clear rules", + rowFiltersHintShort: "AND in group · OR across groups · empty = all rows", + rowFiltersEmpty: "No filters → compare all rows.", + filterClear: "Clear", filterAddOr: "Add group (OR)", filterAddAnd: "Add condition (AND)", + filterAddOrShort: "+ OR group", + filterAddAndShort: "+ AND", filterRemoveGroup: "Remove group", + filterGroupN: "Group {{n}}", filterOr: "OR", + filterAnd: "AND", filterField: "Field", filterOp: "Op", filterValue: "Value", @@ -455,8 +468,13 @@ const en = { hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.", hintPort: "Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.", + hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.", create: "Create task", createHint: "Name the task and bind old/new business-monitor collect tasks. Mapping and baselines can be set later.", + createHintShort: "Name + old/new collect tasks; template, mapping, baselines can come later.", + showCreateMore: "More: template / mapping / baselines", + hideCreateMore: "Hide more", + setupHintShort: "Collect tasks are fixed at create; set monitor template, mapping and dual baselines here.", createProject: "Create task", projectName: "Task name", projectNamePh: "e.g. CN1/CN2 night-1 cutover", @@ -469,23 +487,27 @@ const en = { sectionPair: "Bound collect tasks", sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.", sectionBaseline: "Baseline batches", - sectionBaselineHint: "Old baseline feeds the expected-object picker; new baseline is optional.", + sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.", 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: + "With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.", baselineNeedTask: "Select the matching collect task first", baselinePickHint: "Choose from that task’s collect batches", oldTask: "Old-device collect task", newTask: "New-device collect task", portMapping: "Port mapping", oldBaseline: "Old baseline batch", - newBaseline: "New baseline batch (optional)", + newBaseline: "New baseline batch", needProjectFields: "Enter a name and pick old/new collect tasks", needExpectPorts: "Select at least one expected object for this batch", needBaselineFirst: "Save an old baseline batch first to list expect objects", + needBothBaselines: "Save both old and new baseline batches before evaluating", + errOldBaselineRequired: "Old baseline batch is required", + errNewBaselineRequired: "New baseline batch is required", needBizStateTasks: "No business-monitor tasks yet. Create one first:", projectCreated: "Task created", projects: "Tasks", @@ -534,6 +556,15 @@ const en = { progress: "Progress", anomaly: "Anomaly", onlyExpect: "Expected objects only", + onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set", + pinCurrentBatches: "Current collect batches", + pinOldBatch: "Old current batch", + pinNewBatch: "New current batch", + pinLatest: "Auto latest success", + runHistory: "Run history", + runLatest: "Latest run", + colRuleHit: "Rule hit", + mappingBound: "Mapping saved and bound to this project", allMetrics: "All metrics", colMetric: "Metric", colKey: "Object", @@ -548,6 +579,7 @@ const en = { colNew: "New", colVerdict: "Verdict", emptyDiffs: "No rows yet — run Evaluate", + emptyRed: "No red tickets", verdictMigrated: "Migrated", verdictMigrating: "Migrating", verdictLost: "Lost", @@ -568,6 +600,7 @@ const en = { acceptFailed: "Batch acceptance failed ({{n}} red tickets; you may continue)", acceptPassedShort: "Passed", acceptFailedShort: "Failed", + newBaselineMissingBanner: "These sheets have no usable new baseline rows; verdicts are unreliable: {{metrics}}", acceptCarryHint: "Failure does not block the next batch; red tickets stay until resolved.", openRedHint: "{{n}} open red ticket(s) on this task (carry allowed)", redTitle: "Red tickets", @@ -602,7 +635,7 @@ const en = { needName: "Name is required", defaultsInvalid: "Invalid defaults JSON", overridesInvalid: "Invalid sheet_overrides JSON", - formHint: "Pick a compare template → same sheet tabs appear → configure status fields and success patterns per sheet.", + formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.", compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.", pickCompare: "Pick compare template…", openCompare: "Manage compare templates", @@ -621,26 +654,85 @@ const en = { ooeIgnore: "Ignore (always not involved)", sheetRules: "Per-sheet migrate rules", applyAllPresets: "Apply recommended presets to all", + applyAllPresetsShort: "All presets", + sheetsUnit: " sheets", sheetHowReadonly: "Compare template (read-only)", iface: "Interface", compare: "Compare", presenceOnly: "presence only", - editInCompare: "Edit HOW in compare templates", - presetAuto: "Apply recommended preset", - presetSkip: "Skip dual verdict", + editInCompare: "Edit HOW", + presetAuto: "Recommended", + presetSkip: "Skip sheet", skipDual: "Skip dual for this sheet", - skipDualHint: "This sheet is skipped in cutover evaluate (use a compare job for pure value checks).", - statusFields: "Status fields", - statusFieldsHint: "Classify current rows as up/down; ports use admin/phy/prot, BGP uses state.", + skipDualHint: "Skipped in cutover evaluate (use a compare job for counters).", + statusFields: "Fields used for up/down", + statusFieldsHint: "Ports: admin/phy/prot; BGP: state; ARP: leave empty (presence only).", + statusOptionalHint: "With no status fields, only presence (gone/added) is used.", downValues: "Down values", upValues: "Up values", noFields: "No fields (pick metrics on the compare template first)", - successPatterns: "Success patterns (old any-of × new any-of)", - successPatternsHint: "e.g. port: old removed/down + new added/up; ARP: old removed + new added; BGP: old down(idle) + new up(established).", - oldSideAny: "Old side (any match)", - newSideAny: "New side (any match)", - addSuccessRow: "Add success pattern", + successPatterns: "Success rules (old OR-groups × new OR-groups)", + successPatternsShort: "Success", + successPatternsHint: + "Presence is whether the row exists. Value change binds a field and a strategy. AND inside a group, OR between groups.", + successPatternsHintShort: "AND in group · OR across groups · old+new both match ⇒ success", + successPatternsHintFree: + "Each side has two condition kinds: presence (no field) or value change (must bind a field). Inside a group everything must match (AND). Between groups, any group is enough (OR).", + anomalyPatternsShort: "Anomaly", + anomalyPatternsHint: + "Same shape as success. Match ⇒ red. Check “don’t care” on one side to watch only the other (e.g. new-side gone/down). Not-yet-success is still migrating, not anomaly.", + anomalyPatternsHintShort: "Success first; then anomaly. Don’t-care watches one side. Mid-state ≠ anomaly.", + successRow: "Success {{n}}", + anomalyRow: "Anomaly {{n}}", + removeRow: "Remove", + oldSideAny: "Old", + newSideAny: "New", + groupLogicHint: "AND inside a group, OR between groups", + sideDontCare: "Don’t care this side", + sideDontCareHint: "Judged by the other side only.", + groupN: "Group {{n}}", + addAnd: "Add to this group (AND)", + addOr: "Add another group (OR)", + addAndShort: "+ AND", + addOrShort: "+ OR group", + addSuccessRow: "+ Success rule", + addAnomalyRow: "+ Anomaly rule", + modeAny: "Match any", + modeAll: "Match all", + addCond: "Add condition", + pickField: "Pick field…", + condValue: "Value", + opEmpty: "empty", + opNotEmpty: "not empty", + opChanged: "changed vs baseline", + opUnchanged: "same as baseline", + op_eq: "equals", + op_ne: "not equals", + op_in: "in", + op_not_in: "not in", + op_empty: "empty", + op_not_empty: "not empty", + op_changed: "changed vs baseline", + op_unchanged: "same as baseline", + and: "and", + groupPresence: "Presence", + groupValue: "Value change", + groupStatus: "Status class", + groupField: "Field condition", + tokRemoved: "Gone", + tokAdded: "Added", + tokUnchanged: "Unchanged", + tokChanged: "Changed", + tokUp: "up", + tokDown: "down", + tokOther: "other", + or: "or", + tagSkip: "skip", + tagRules: "{{n}}", + tagEmpty: "empty", needCompareFirst: "Pick a compare template that has sheets first.", + showMore: "More: note / HF collect / JSON", + hideMore: "Hide more", showJson: "Advanced: edit JSON", hideJson: "Back to visual editor", }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5a8356a..72732d0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -288,20 +288,27 @@ const zh = { stepPickMetrics: "① 选择监控项", stepConfigRules: "② 配置比对规则", pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。", - noMetricsYet: "请先在上方勾选至少一个监控项", + pickMetricsHintShort: "点选加入 / 取消;配置在下方左侧表列表中切换。", + showMetrics: "展开选表", + hideMetrics: "收起选表", + noMetricsYet: "请先勾选至少一个监控项", selectedCount: "已选 {{n}} 项", + tabFields: "字段规则", + tabFilters: "行过滤", + fieldsHintShort: "Key 对齐行;比对勾选参与值 diff;不勾比对则只看存在性。", + tagCompareN: "{{n}} 比对", colFields: "字段数", keyFields: "Key", ifaceFields: "接口", compareFields: "比对", - compareMode: "比对方式", + compareMode: "方式", tolerance: "容差", displayField: "展示", keyAlwaysDisplay: "Key 列始终展示", modeEq: "完全相等", modeNumeric: "绝对偏差", modePercent: "相对偏差 %", - normalizeField: "归一化", + normalizeField: "归一", ignoreFields: "忽略", field: "字段", note: "备注", @@ -312,12 +319,17 @@ const zh = { rowFilters: "行过滤规则", rowFiltersHint: "比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。", - rowFiltersEmpty: "无过滤则比对全部行。可添加条件组。", - filterClear: "清空规则", + rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行", + rowFiltersEmpty: "无过滤则比对全部行。", + filterClear: "清空", filterAddOr: "添加条件组(或)", filterAddAnd: "组内加条件(且)", + filterAddOrShort: "+ 或组", + filterAddAndShort: "+ 且", filterRemoveGroup: "删除本组", + filterGroupN: "组 {{n}}", filterOr: "或", + filterAnd: "且", filterField: "字段", filterOp: "运算", filterValue: "值", @@ -452,8 +464,13 @@ const zh = { title: "割接监控", hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。", hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。", + hintShort: "监控模板定判定规则;绑老/新采集与双基线 → 勾预期 → 看板评估。", create: "创建任务", createHint: "先填任务名并绑定老/新业务监控采集任务;映射与基线可稍后在详情里补。", + createHintShort: "任务名 + 老/新采集任务即可创建;模板、映射、基线可稍后补。", + showCreateMore: "更多:模板 / 映射 / 基线", + hideCreateMore: "收起更多", + setupHintShort: "采集任务创建后固定;此处配置监控模板、映射与双基线。", createProject: "创建任务", projectName: "任务名称", projectNamePh: "例如:CN1/CN2 第1夜割接", @@ -465,23 +482,27 @@ const zh = { sectionPair: "已绑定采集任务", sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。", sectionBaseline: "基线批次", - sectionBaselineHint: "老侧基线用于列出本批预期对象;新侧基线可选。", + sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。", optionalNone: "不选", monitorTemplate: "监控模板", monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。", compareTemplate: "对比模板", compareTemplateBound: "绑定对比模板:{{name}}", - portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。", + portMappingHint: + "有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。", baselineNeedTask: "请先选择对应采集任务", baselinePickHint: "从该任务的历史采集批次中选择", oldTask: "老设备采集任务", newTask: "新设备采集任务", portMapping: "端口映射", oldBaseline: "老设备基线批次", - newBaseline: "新设备基线批次(可选)", + newBaseline: "新设备基线批次", needProjectFields: "请填写任务名并选择老/新采集任务", needExpectPorts: "请先勾选本批预期迁移的对象", needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象", + needBothBaselines: "请先保存老、新两侧基线批次后再评估", + errOldBaselineRequired: "缺少老设备基线批次", + errNewBaselineRequired: "缺少新设备基线批次", needBizStateTasks: "尚无业务监控任务,请先创建:", projectCreated: "任务已创建", projects: "任务", @@ -529,6 +550,15 @@ const zh = { progress: "进度", anomaly: "异常", onlyExpect: "仅看预期对象", + onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期", + pinCurrentBatches: "当前采集批次", + pinOldBatch: "老侧当前批", + pinNewBatch: "新侧当前批", + pinLatest: "自动最新成功批", + runHistory: "判定历史", + runLatest: "最新一次", + colRuleHit: "命中规则", + mappingBound: "映射已保存并绑定到本任务", allMetrics: "全部监控项", colMetric: "监控项", colKey: "对象", @@ -543,6 +573,7 @@ const zh = { colNew: "新侧", colVerdict: "判定", emptyDiffs: "暂无明细,请先「立即判定」", + emptyRed: "暂无红单", verdictMigrated: "已迁移", verdictMigrating: "迁移中", verdictLost: "丢失", @@ -563,6 +594,7 @@ const zh = { acceptFailed: "本批验收未通过(红单 {{n}},可带红开下一批)", acceptPassedShort: "通过", acceptFailedShort: "未通过", + newBaselineMissingBanner: "这些表没有可用的新侧基线内容,判定不可信:{{metrics}}", acceptCarryHint: "验收不过不拦截:可新建下一批继续;红单保留留痕,可手动关闭。", openRedHint: "任务仍有 {{n}} 条未关闭红单(带红继续)", redTitle: "红单留痕", @@ -597,7 +629,7 @@ const zh = { needName: "请填写名称", defaultsInvalid: "defaults JSON 无效", overridesInvalid: "sheet_overrides JSON 无效", - formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置状态字段与成功模式。", + formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。", compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。", pickCompare: "选择对比模板…", openCompare: "管理对比模板", @@ -616,26 +648,84 @@ const zh = { ooeIgnore: "忽略(一律未涉及)", sheetRules: "各表搬迁判定", applyAllPresets: "全部套用推荐预设", + applyAllPresetsShort: "全部预设", + sheetsUnit: "张表", sheetHowReadonly: "对比模板(只读)", iface: "接口", compare: "比对", presenceOnly: "仅存在性", - editInCompare: "去对比模板改 HOW", - presetAuto: "套用本表推荐预设", - presetSkip: "跳过双端判定", - skipDual: "本表不参与 dual 判定", - skipDualHint: "该 sheet 将在割接判定中跳过(适合纯计数器等值校验类,改用业务对比 Job)。", - statusFields: "状态字段", - statusFieldsHint: "用于把当前行归类为 up/down;端口勾选 admin/phy/prot,BGP 勾选 state。", - downValues: "Down 取值", - upValues: "Up 取值", + editInCompare: "改 HOW", + presetAuto: "推荐预设", + presetSkip: "跳过本表", + skipDual: "本表不参与双端判定", + skipDualHint: "该表在割接判定中跳过(计数器等请用业务对比 Job)。", + statusFields: "用哪些字段判断 up/down", + statusFieldsHint: "端口勾选 admin/phy/prot;BGP 勾选 state;ARP 可不勾(只看条目有无)。", + statusOptionalHint: "未勾选状态字段时,只按「消失 / 新增」等存在性判定。", + downValues: "算 down 的取值", + upValues: "算 up 的取值", noFields: "无可用字段(先在对比模板勾选 metric)", - successPatterns: "成功模式(老侧任意命中 × 新侧任意命中)", - successPatternsHint: "例如端口:老 removed/down + 新 added/up;ARP:老 removed + 新 added;BGP:老 down(idle) + 新 up(established)。", - oldSideAny: "老侧(任意命中)", - newSideAny: "新侧(任意命中)", - addSuccessRow: "添加成功模式", + successPatterns: "成功规则(老侧组间或 × 新侧组间或)", + successPatternsShort: "成功规则", + successPatternsHint: "存在性看条目有无;值变更绑定字段并选择怎么变。组内是且,组之间是或。", + successPatternsHintShort: "组内且 · 组间或 · 老侧命中一组且新侧命中一组 → 成功", + successPatternsHintFree: + "每侧只有两种条件:存在性(不绑字段)或值变更(必须绑字段)。同一组内全部满足(且),组与组之间满足一组即可(或)。", + anomalyPatternsShort: "异常规则", + anomalyPatternsHint: + "与成功同结构。命中后标红。某一侧可勾「不限制」表示只看另一侧(如新侧现网消失/down)。未成功≠异常,中间态仍是迁移动态。", + anomalyPatternsHintShort: "先匹配成功;未命中再看异常。可勾「不限制」只盯一侧。中间态≠异常。", + successRow: "成功 {{n}}", + anomalyRow: "异常 {{n}}", + removeRow: "删除", + oldSideAny: "老设备", + newSideAny: "新设备", + groupLogicHint: "组内且,组之间或", + sideDontCare: "不限制此侧", + sideDontCareHint: "只按另一侧条件判定。", + groupN: "组 {{n}}", + addAnd: "组内再加一条(且)", + addOr: "再加一组(或)", + addAndShort: "+ 且", + addOrShort: "+ 或组", + addSuccessRow: "+ 成功规则", + addAnomalyRow: "+ 异常规则", + modeAny: "满足任一即可", + modeAll: "全部同时满足", + addCond: "添加条件", + pickField: "选择字段…", + condValue: "取值", + opEmpty: "为空", + opNotEmpty: "非空", + opChanged: "相对基线变了", + opUnchanged: "相对基线没变", + op_eq: "等于", + op_ne: "不等于", + op_in: "属于", + op_not_in: "不属于", + op_empty: "为空", + op_not_empty: "非空", + op_changed: "相对基线变了", + op_unchanged: "相对基线没变", + and: "且", + groupPresence: "存在性", + groupValue: "值变更", + groupStatus: "状态类", + groupField: "字段条件", + tokRemoved: "消失", + tokAdded: "新增", + tokUnchanged: "未变", + tokChanged: "有变化", + tokUp: "up", + tokDown: "down", + tokOther: "其它状态", + or: "或", + tagSkip: "跳过", + tagRules: "{{n}} 条", + tagEmpty: "未配", needCompareFirst: "请先选择带 sheets 的对比模板。", + showMore: "更多:备注 / 高频采集 / JSON", + hideMore: "收起更多", showJson: "高级:编辑 JSON", hideJson: "回到可视化编辑", }, diff --git a/web/src/index.css b/web/src/index.css index 2d6023c..bac1ce8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10030,6 +10030,764 @@ html.login-page--paused .login-page__flare { font-variant-numeric: tabular-nums; } +/* Monitor template visual rule editor */ +.mt-editor__basics { + display: grid; + grid-template-columns: 1.2fr 1.2fr 1fr; + gap: 12px; + align-items: start; +} +@media (max-width: 900px) { + .mt-editor__basics { + grid-template-columns: 1fr; + } +} +.mt-editor__main { + display: grid; + grid-template-columns: 200px 1fr; + gap: 12px; + min-height: 360px; + align-items: stretch; +} +@media (max-width: 900px) { + .mt-editor__main { + grid-template-columns: 1fr; + } +} +.mt-editor__nav { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px; + background: rgba(15, 23, 42, 0.35); + display: flex; + flex-direction: column; + gap: 6px; + max-height: 70vh; + overflow: auto; +} +.mt-editor__nav-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + font-size: 12px; + font-weight: 600; + padding: 2px 4px 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} +.mt-editor__nav-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.mt-editor__nav-item { + appearance: none; + text-align: left; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + padding: 8px 10px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.mt-editor__nav-item:hover { + background: rgba(30, 41, 59, 0.65); +} +.mt-editor__nav-item.is-active { + border-color: rgba(59, 130, 246, 0.55); + background: rgba(37, 99, 235, 0.18); +} +.mt-editor__nav-name { + font-size: 13px; + font-weight: 600; +} +.mt-editor__nav-tag { + font-size: 11px; + opacity: 0.7; +} +.mt-editor__pane { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.mt-editor__pane-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} +.mt-editor__metric { + font-size: 15px; + font-weight: 700; +} +.mt-editor__meta { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + font-size: 12px; + margin-top: 4px; +} +.mt-editor__uv { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 8px; +} +.mt-block__title { + font-size: 13px; + font-weight: 650; + margin-bottom: 6px; +} +.mt-block__hint { + margin: 0 0 10px; + font-size: 12px; + line-height: 1.4; +} +.mt-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.mt-chip { + appearance: none; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 999px; + background: transparent; + color: inherit; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + line-height: 1.3; +} +.mt-chip:hover { + border-color: rgba(59, 130, 246, 0.55); +} +.mt-chip.is-on { + border-color: rgba(37, 99, 235, 0.75); + background: rgba(37, 99, 235, 0.22); + font-weight: 600; +} +.mt-skip-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin: 0; + cursor: pointer; +} +.mt-rule-tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid rgba(148, 163, 184, 0.28); + padding-bottom: 0; +} +.mt-rule-tab { + appearance: none; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: transparent; + color: inherit; + padding: 7px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + opacity: 0.72; +} +.mt-rule-tab:hover { + opacity: 1; + background: rgba(30, 41, 59, 0.45); +} +.mt-rule-tab.is-active { + opacity: 1; + border-color: rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.55); + color: rgba(74, 222, 128, 0.95); +} +.mt-rule-tab--anomaly.is-active { + color: rgba(248, 113, 113, 0.95); +} +.mt-rule-tab__n { + font-size: 11px; + font-weight: 600; + opacity: 0.7; + min-width: 1.2em; + text-align: center; + padding: 0 5px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); +} +.mt-rule-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 10px; + background: rgba(15, 23, 42, 0.28); +} +.mt-rule-card--anomaly { + border-color: rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.05); +} +.mt-rule-card__head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 12px; + font-weight: 650; +} +.mt-rule-card__grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 10px; + align-items: start; +} +@media (max-width: 900px) { + .mt-rule-card__grid { + grid-template-columns: 1fr; + } + .mt-rule-arrow { + display: none; + } +} +.mt-rule-arrow { + align-self: center; + font-size: 18px; + font-weight: 700; + opacity: 0.55; + padding-top: 22px; +} +.mt-side-col__label { + font-size: 12px; + font-weight: 650; + margin-bottom: 6px; +} +.mt-side { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.mt-side-empty { + margin: 0; + font-size: 12px; +} +.mt-or-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} +.mt-and-box { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 6px; + padding: 8px; + background: rgba(2, 6, 23, 0.25); +} +.mt-and-box__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + margin-bottom: 4px; +} +.mt-logic-badge { + align-self: center; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 1px 8px; + border-radius: 999px; + opacity: 0.85; +} +.mt-logic-badge--or { + background: rgba(59, 130, 246, 0.2); + color: rgba(147, 197, 253, 0.95); +} +.mt-logic-badge--and { + background: rgba(148, 163, 184, 0.18); + color: rgba(203, 213, 225, 0.95); + margin: 2px auto; + width: fit-content; +} +.mt-select { + appearance: auto; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: rgba(15, 23, 42, 0.45); + color: inherit; + padding: 4px 8px; + font-size: 12px; + min-width: 0; +} +.mt-select--grow { + flex: 1 1 120px; +} +.mt-cond-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) auto; + gap: 6px; + align-items: center; + margin: 4px 0; +} +.mt-cond-row.is-value { + grid-template-columns: 88px minmax(72px, 1fr) minmax(88px, 1.1fr) minmax(64px, 1fr) auto; +} +.mt-cond-row .mt-cond-value, +.mt-cond-row input { + min-width: 0; + width: 100%; +} +.mt-icon-btn { + appearance: none; + border: none; + background: transparent; + color: inherit; + opacity: 0.55; + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 2px 6px; + border-radius: 4px; +} +.mt-icon-btn:hover { + opacity: 1; + background: rgba(148, 163, 184, 0.15); +} +.mt-link-btn { + appearance: none; + border: none; + background: transparent; + color: rgba(147, 197, 253, 0.95); + font-size: 12px; + cursor: pointer; + padding: 4px 0 0; + text-align: left; + width: fit-content; +} +.mt-link-btn:hover { + text-decoration: underline; +} +.mt-dontcare { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin: 0 0 4px; + cursor: pointer; +} +/* legacy aliases kept for safety */ +.mt-success-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; +} +.mt-anomaly-card { + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.06); +} +.mt-block--success .mt-block__title { + color: rgba(74, 222, 128, 0.95); +} +.mt-block--anomaly .mt-block__title { + color: rgba(248, 113, 113, 0.95); +} +.mt-rule-sections { + display: flex; + flex-direction: column; + gap: 16px; +} +.mt-success-card__head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; +} +.mt-success-card__grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 800px) { + .mt-success-card__grid { + grid-template-columns: 1fr; + } +} +.mt-success-side__label { + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; +} +.mt-group { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 8px; + padding: 8px; + margin-bottom: 6px; +} +.mt-group__head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 11px; + opacity: 0.75; +} +.mt-group__or, +.mt-group__and { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + opacity: 0.7; + margin: 2px 0; +} +.mt-side-hint { + margin: 0 0 6px; + font-size: 11px; +} +.mt-chip-group { + margin-bottom: 8px; +} +.mt-chip-group__label { + display: block; + font-size: 11px; + opacity: 0.65; + margin-bottom: 4px; +} +.mt-success-card__formula { + margin-top: 8px; + font-size: 12px; + padding-top: 8px; + border-top: 1px dashed rgba(148, 163, 184, 0.35); +} +.mt-arrow { + font-weight: 700; + opacity: 0.85; +} +.mt-editor__more { + border-top: 1px solid rgba(148, 163, 184, 0.22); + padding-top: 8px; +} + +/* Compare template editor (aligned with monitor template editor) */ +.ct-editor__basics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +@media (max-width: 720px) { + .ct-editor__basics { + grid-template-columns: 1fr; + } +} +.ct-editor__pick { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px 10px; + background: rgba(15, 23, 42, 0.28); +} +.ct-editor__pick-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.ct-editor__pick-title { + font-size: 13px; + font-weight: 650; +} +.ct-editor__pick-hint { + margin: 6px 0 8px; + font-size: 12px; +} +.ct-metric-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 140px; + overflow: auto; +} +.ct-metric-chip__n { + margin-left: 6px; + opacity: 0.65; + font-size: 11px; +} +.ct-editor__main { + display: grid; + grid-template-columns: 200px 1fr; + gap: 12px; + min-height: 360px; + align-items: stretch; +} +@media (max-width: 900px) { + .ct-editor__main { + grid-template-columns: 1fr; + } +} +.ct-editor__nav { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 8px; + background: rgba(15, 23, 42, 0.35); + display: flex; + flex-direction: column; + gap: 6px; + max-height: 70vh; + overflow: auto; +} +.ct-editor__nav-head { + font-size: 12px; + font-weight: 600; + padding: 2px 4px 6px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} +.ct-editor__nav-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.ct-editor__nav-item { + appearance: none; + text-align: left; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + padding: 8px 10px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.ct-editor__nav-item:hover { + background: rgba(30, 41, 59, 0.65); +} +.ct-editor__nav-item.is-active { + border-color: rgba(59, 130, 246, 0.55); + background: rgba(37, 99, 235, 0.18); +} +.ct-editor__nav-name { + font-size: 13px; + font-weight: 600; +} +.ct-editor__nav-tag { + font-size: 11px; + opacity: 0.7; +} +.ct-editor__pane { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} +.ct-editor__pane-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} +.ct-editor__metric { + font-size: 15px; + font-weight: 700; +} +.ct-editor__meta { + font-size: 12px; + margin-top: 4px; +} +.ct-filters__hint, +.ct-filters__empty, +.ct-field-hint { + margin: 0 0 8px; + font-size: 12px; +} +.ct-filters__actions { + display: flex; + gap: 12px; + margin-bottom: 8px; +} +.ct-filter-row { + display: grid; + grid-template-columns: minmax(90px, 1.1fr) minmax(72px, 0.8fr) minmax(80px, 1.2fr) auto; + gap: 6px; + align-items: center; + margin: 4px 0; +} +.ct-filter-row input { + min-width: 0; + width: 100%; +} +.ct-field-table-wrap { + max-height: min(55vh, 520px); + overflow: auto; +} +.ct-field-table th, +.ct-field-table td { + vertical-align: middle; +} +.ct-field-table th:nth-child(n + 2), +.ct-field-table td:nth-child(n + 2) { + text-align: center; + white-space: nowrap; +} +.ct-field-table td:first-child { + text-align: left; +} +.ct-field-name { + font-size: 12px; +} + +/* Cutover monitor (biz migration) */ +.bm-hint { + margin: 0; + font-size: 12px; +} +.bm-pair-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + align-items: start; +} +@media (max-width: 720px) { + .bm-pair-grid { + grid-template-columns: 1fr; + } +} +.bm-pair-card { + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + background: rgba(15, 23, 42, 0.28); +} +.bm-pair-card__label { + font-size: 11px; + opacity: 0.7; + margin-bottom: 4px; +} +.bm-mapping { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + padding: 10px 12px; + background: rgba(15, 23, 42, 0.22); +} +.bm-mapping__label { + font-size: 13px; + font-weight: 650; +} +.bm-mapping__row { + display: grid; + grid-template-columns: minmax(140px, 1.2fr) minmax(120px, 1fr) auto; + gap: 8px; + align-items: end; +} +@media (max-width: 720px) { + .bm-mapping__row { + grid-template-columns: 1fr; + } +} +.bm-mapping__text { + width: 100%; + min-height: 110px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: rgba(2, 6, 23, 0.35); + color: inherit; + padding: 8px 10px; + resize: vertical; +} +.bm-detail__chrome { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + align-items: center; + justify-content: space-between; +} +.bm-detail__meta { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + align-items: center; + font-size: 12px; +} +.bm-detail__arrow { + opacity: 0.55; + font-weight: 700; +} +.bm-detail__red { + margin-left: 4px; + font-size: 12px; +} +.bm-detail__actions { + flex-wrap: wrap; + gap: 8px; +} +.bm-detail__link { + font-size: 12px; + align-self: center; +} +.bm-batches { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + flex: 1; +} +.bm-batches__toolbar { + flex-wrap: wrap; + gap: 8px; + align-items: end; +} +.bm-batches__main { + min-height: 320px; + flex: 1; +} +.bm-expect-table { + max-height: min(48vh, 420px); + overflow: auto; +} +.bm-expect-desc { + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bm-board { + flex: 1; + min-height: 0; +} +.bm-board__accept { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + font-size: 12px; + font-variant-numeric: tabular-nums; + align-items: center; +} +.bm-board__table { + max-height: min(58vh, 560px); + overflow: auto; +} + .bs-bind-panel { padding: 10px 12px; border: 1px solid rgba(148, 163, 184, 0.25); diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index a72a2ce..71f7113 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -525,6 +525,152 @@ function emptyLeaf(field = ""): RowFilter { return { field, op: "eq", value: "" }; } +const FILTER_OPS = [ + "eq", + "ne", + "in", + "not_in", + "empty", + "not_empty", + "regex", + "age_timer", +] as const; + +function TplRowFiltersEditor({ + groups, + fieldOpts, + t, + onChange, +}: { + groups: RowFilter[][]; + fieldOpts: string[]; + t: (k: string, vars?: Record) => string; + onChange: (next: RowFilter[][]) => void; +}) { + const setGroups = onChange; + return ( +
+

{t("bizCompare.rowFiltersHintShort")}

+
+ + {groups.length ? ( + + ) : null} +
+ {!groups.length ?

{t("bizCompare.rowFiltersEmpty")}

: null} + {groups.map((group, gi) => ( +
+ {gi > 0 ?
{t("bizCompare.filterOr")}
: null} +
+
+ {t("bizCompare.filterGroupN", { n: String(gi + 1) })} + +
+ {group.map((filt, fi) => ( +
+ {fi > 0 ? ( +
{t("bizCompare.filterAnd")}
+ ) : null} +
+ + + { + const next = groups.map((g) => g.map((x) => ({ ...x }))); + const op = next[gi][fi]?.op || "eq"; + let value: string | string[] = e.target.value; + if (op === "in" || op === "not_in") { + value = e.target.value + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + } + next[gi][fi] = { ...next[gi][fi], value }; + setGroups(next); + }} + /> + +
+
+ ))} + +
+
+ ))} +
+ ); +} + function metricLabel(id: string) { return id; } @@ -554,6 +700,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const [tplNote, setTplNote] = useState(""); const [tplSheets, setTplSheets] = useState([]); const [tplSheetIdx, setTplSheetIdx] = useState(0); + const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields"); + const [showMetricPicker, setShowMetricPicker] = useState(true); // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); @@ -570,7 +718,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage const [afterBatchId, setAfterBatchId] = useState(""); const [mode, setMode] = useState<"manual" | "auto">("manual"); const [mapName, setMapName] = useState("端口映射"); - const [mapText, setMapText] = useState("before_if,after_if\n"); + const [mapText, setMapText] = useState(""); const [validateOut, setValidateOut] = useState(null); const [runs, setRuns] = useState([]); const [runDetail, setRunDetail] = useState(null); @@ -956,6 +1104,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage setTplNote(""); setTplSheets([]); setTplSheetIdx(0); + setTplPaneTab("fields"); + setShowMetricPicker(true); setTplOpen(true); }; @@ -966,6 +1116,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage setTplNote(tpl.note || ""); setTplSheets(sheets.length ? sheets.map(cloneSheet) : []); setTplSheetIdx(0); + setTplPaneTab("fields"); + setShowMetricPicker(!sheets.length); setTplOpen(true); }; @@ -976,6 +1128,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage if (prev.some((s) => s.metric_id === metricId)) return prev; const next = [...prev, defaultSheetForMetric(schema, metricId)]; setTplSheetIdx(next.length - 1); + if (!prev.length) setShowMetricPicker(false); return next; }); return; @@ -1141,7 +1294,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage if (!m) return; setMappingId(id); setMapName(m.name); - setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n")); + setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n")); }; const saveMapping = async () => { @@ -1196,7 +1349,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage if (preset?.mapping_id) loadMappingText(preset.mapping_id); else { setMapName(t("bizCompare.mapping")); - setMapText("before_if,after_if\n"); + setMapText(""); } }; @@ -1417,7 +1570,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage if (id) loadMappingText(id); else { setMappingId(""); - setMapText("before_if,after_if\n"); + setMapText(""); } }} > @@ -1657,473 +1810,343 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage {/* Template editor */} - setTplOpen(false)} size="lg" className="app-heroui-modal--xl"> + setTplOpen(false)} size="cover"> {tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")} - - - + +
+ setTplName(e.target.value)} + placeholder={t("bizCompare.colName")} + aria-label={t("bizCompare.colName")} + /> + setTplNote(e.target.value)} + placeholder={t("bizCompare.note")} + aria-label={t("bizCompare.note")} + /> +
-
- {t("bizCompare.stepPickMetrics")} -

- {t("bizCompare.pickMetricsHint")} -

-
- - - - - - - - +
+
+ + {t("bizCompare.stepPickMetrics")} + + {t("bizCompare.selectedCount", { n: String(tplSheets.length) })} + + + +
+ {showMetricPicker ? ( + <> +

{t("bizCompare.pickMetricsHintShort")}

+
{metrics.map((m) => { const checked = tplSheets.some((s) => s.metric_id === m.metric_id); return ( -
- - - - + ); })} {!metrics.length ? ( - - - + {t("bizCompare.noMetricFields")} ) : null} - -
- {t("bizCompare.addMetric")}{t("bizCompare.colFields")}
- toggleTplMetric(m.metric_id, e.target.checked)} - /> - - {m.metric_id} - {m.fields?.length || 0}
-
{t("bizCompare.noMetricFields")}
-
-
-

- {t("bizCompare.selectedCount", { n: String(tplSheets.length) })} -

-
- -
- {t("bizCompare.stepConfigRules")} -

- {t("bizCompare.templateHint")} -

+
+ + ) : null} {!tplSheets.length ? (
{t("bizCompare.noMetricsYet")}
) : ( - <> -
- {tplSheets.map((s, i) => ( - - ))} -
+
+ {activeTplSheet ? ( - <> -
- - {activeTplSheet.compare_fields.length - ? t("bizCompare.modeFields") - : t("bizCompare.modePresence")} - {(activeTplSheet.row_filters || []).length - ? ` · ${(activeTplSheet.row_filters || []).length} filter(s)` - : ""} - -
-
- {t("bizCompare.rowFilters")} -

- {t("bizCompare.rowFiltersHint")} -

- {(() => { - const groups = toOrGroups(activeTplSheet.row_filters || []); - const setGroups = (next: RowFilter[][]) => - updateActiveSheet({ row_filters: fromOrGroups(next) }); - const fieldOpts = activeTplFields.map((f) => f.name); - const opOpts = [ - "eq", - "ne", - "in", - "not_in", - "empty", - "not_empty", - "regex", - "age_timer", - ]; - return ( - <> -
- - -
- {!groups.length ? ( -

{t("bizCompare.rowFiltersEmpty")}

- ) : null} - {groups.map((group, gi) => ( -
- {gi > 0 ? ( -
- {t("bizCompare.filterOr")} -
- ) : null} - {group.map((filt, fi) => ( -
- - - { - const next = groups.map((g) => g.map((x) => ({ ...x }))); - const op = next[gi][fi]?.op || "eq"; - let value: string | string[] = e.target.value; - if (op === "in" || op === "not_in") { - value = e.target.value - .split(",") - .map((x) => x.trim()) - .filter(Boolean); - } - next[gi][fi] = { ...next[gi][fi], value }; - setGroups(next); - }} - /> - -
- ))} -
- - -
-
- ))} - - ); - })()} +
+ +
-
- - - - - - - - - - - - - - - {activeTplFields.map((f) => { - const isKey = activeTplSheet.key_fields.includes(f.name); - const isCompare = - !isKey && activeTplSheet.compare_fields.includes(f.name); - const isDisplay = (activeTplSheet.display_fields || []).includes(f.name); - const rule = ruleForField(activeTplSheet, f.name); - const cmpMode = (rule.compare || "eq").toLowerCase() || "eq"; - const norm = rule.normalize || "none"; - const needsTol = cmpMode === "numeric" || cmpMode === "percent"; - return ( - - - - - - - - - +
{t("bizCompare.field")}{t("bizCompare.keyFields")}{t("bizCompare.ifaceFields")}{t("bizCompare.compareFields")}{t("bizCompare.compareMode")}{t("bizCompare.tolerance")}{t("bizCompare.displayField")}{t("bizCompare.normalizeField")}
-
{f.display_name || f.name}
-
- {f.name} · {f.role} -
-
- - updateActiveSheet({ - key_fields: toggleInList( - activeTplSheet.key_fields, - f.name, - e.target.checked, - ), - }) - } - /> - - - updateActiveSheet({ - iface_fields: toggleInList( - activeTplSheet.iface_fields, - f.name, - e.target.checked, - ), - }) - } - /> - - - updateActiveSheet({ - compare_fields: toggleInList( - activeTplSheet.compare_fields, - f.name, - e.target.checked, - ), - }) - } - /> - - - - { - const raw = e.target.value.trim(); - const tol = raw === "" ? undefined : Number(raw); - updateActiveSheet({ - field_rules: upsertFieldRule(activeTplSheet, f.name, { - compare: cmpMode, - tolerance: Number.isFinite(tol as number) - ? (tol as number) - : undefined, - }), - }); - }} - /> - - - updateActiveSheet({ - display_fields: toggleInList( - activeTplSheet.display_fields || [], - f.name, - e.target.checked, - ), - }) - } - /> - - + {tplPaneTab === "filters" ? ( + f.name)} + t={t} + onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })} + /> + ) : ( +
+

{t("bizCompare.fieldsHintShort")}

+ + + + + + + + + + + + + + + {activeTplFields.map((f) => { + const isKey = activeTplSheet.key_fields.includes(f.name); + const isCompare = + !isKey && activeTplSheet.compare_fields.includes(f.name); + const isDisplay = (activeTplSheet.display_fields || []).includes(f.name); + const rule = ruleForField(activeTplSheet, f.name); + const cmpMode = (rule.compare || "eq").toLowerCase() || "eq"; + const norm = rule.normalize || "none"; + const needsTol = cmpMode === "numeric" || cmpMode === "percent"; + return ( + + + + + + + + + + + ); + })} + {!activeTplFields.length ? ( + + - ); - })} - {!activeTplFields.length ? ( - - - - ) : null} - -
{t("bizCompare.field")}{t("bizCompare.keyFields")}{t("bizCompare.ifaceFields")}{t("bizCompare.compareFields")}{t("bizCompare.compareMode")}{t("bizCompare.tolerance")}{t("bizCompare.displayField")}{t("bizCompare.normalizeField")}
+ {f.name} + + + updateActiveSheet({ + key_fields: toggleInList( + activeTplSheet.key_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + updateActiveSheet({ + iface_fields: toggleInList( + activeTplSheet.iface_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + updateActiveSheet({ + compare_fields: toggleInList( + activeTplSheet.compare_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + + { + const raw = e.target.value.trim(); + const tol = raw === "" ? undefined : Number(raw); + updateActiveSheet({ + field_rules: upsertFieldRule(activeTplSheet, f.name, { + compare: cmpMode, + tolerance: Number.isFinite(tol as number) + ? (tol as number) + : undefined, + }), + }); + }} + /> + + + updateActiveSheet({ + display_fields: toggleInList( + activeTplSheet.display_fields || [], + f.name, + e.target.checked, + ), + }) + } + /> + + +
+
{t("bizCompare.noMetricFields")}
-
{t("bizCompare.noMetricFields")}
-
-
- + ) : null} +
+
+ )} +
) : null} - +
)}
+ -
diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index a25e11e..503fa6b 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -8,6 +8,8 @@ import { useToast } from "../../hooks/useToast"; import { useI18n } from "../../i18n"; import { bizCompareListMappings, + bizCompareCreateMapping, + bizCompareUpdateMapping, bizMigrationCollectNow, bizMigrationCreateBatch, bizMigrationCreateProject, @@ -20,18 +22,25 @@ import { bizMigrationListDiffs, bizMigrationListProjects, bizMigrationListRedTickets, + bizMigrationListRuns, bizMigrationPatchBatch, bizMigrationPatchProject, bizMigrationResolveRedTicket, bizMonitorListTemplates, bizStateListBatches, bizStateListTasks, + ApiRequestError, formatErr, } from "../../services/api"; 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 PortMapping = { + id: string; + name: string; + rows: Array<{ before_if: string; after_if: string }>; +}; type MonitorTplOpt = { id: string; name: string; @@ -86,6 +95,8 @@ type SheetCard = { progress_ok: number; progress_total: number; anomaly: number; + new_baseline_missing?: boolean; + new_baseline_mode?: string; }; type DiffRow = { id: string; @@ -99,6 +110,15 @@ type DiffRow = { new_key_str?: string; old_status?: string; new_status?: string; + rule_hit?: string; +}; +type EvalRun = { + id: string; + created_at?: string | null; + purpose?: string; + old_batch_id?: string; + new_batch_id?: string; + summary?: { anomaly?: number; progress?: { ok?: number; total?: number } }; }; type ExpectSheetItem = { key: string; @@ -152,7 +172,7 @@ export function BizMigrationPage() { const [projects, setProjects] = useState([]); const [tasks, setTasks] = useState([]); - const [mappings, setMappings] = useState<{ id: string; name: string }[]>([]); + const [mappings, setMappings] = useState([]); const [busy, setBusy] = useState(false); const [listKeyword, setListKeyword] = useState(""); const debouncedListKw = useDebouncedValue(listKeyword, 250); @@ -161,17 +181,20 @@ export function BizMigrationPage() { const [createName, setCreateName] = useState(""); 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 [createShowMore, setCreateShowMore] = useState(false); const [monitorTpls, setMonitorTpls] = useState([]); + const [boardSubTab, setBoardSubTab] = useState<"diffs" | "red">("diffs"); const [projectId, setProjectId] = useState(""); const [detailTab, setDetailTab] = useState("setup"); const [mappingId, setMappingId] = useState(""); + const [mapName, setMapName] = useState(""); + const [mapText, setMapText] = useState(""); const [monitorTplId, setMonitorTplId] = useState(""); const [oldBaselineId, setOldBaselineId] = useState(""); const [newBaselineId, setNewBaselineId] = useState(""); @@ -191,7 +214,7 @@ export function BizMigrationPage() { } | null; } | null>(null); const [diffs, setDiffs] = useState([]); - const [onlyExpect, setOnlyExpect] = useState(true); + const [onlyExpect, setOnlyExpect] = useState(false); const [boardMetricId, setBoardMetricId] = useState(""); const [batchLabel, setBatchLabel] = useState(""); const [expectSheets, setExpectSheets] = useState([]); @@ -201,6 +224,10 @@ export function BizMigrationPage() { const [redTickets, setRedTickets] = useState([]); const [openRedCount, setOpenRedCount] = useState(0); const [acceptInfo, setAcceptInfo] = useState(null); + const [pinOldBatchId, setPinOldBatchId] = useState(""); + const [pinNewBatchId, setPinNewBatchId] = useState(""); + const [evalRuns, setEvalRuns] = useState([]); + const [selectedRunId, setSelectedRunId] = useState(""); const project = useMemo( () => projects.find((p) => p.id === projectId) || null, @@ -276,6 +303,71 @@ export function BizMigrationPage() { setProjects((res.items || []) as Project[]); }, []); + const reloadMappings = useCallback(async () => { + const mp = await bizCompareListMappings(); + setMappings( + ((mp.items || []) as Record[]).map((x) => ({ + id: String(x.id || ""), + name: String(x.name || x.id || ""), + rows: Array.isArray(x.rows) + ? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({ + before_if: String(r.before_if || ""), + after_if: String(r.after_if || ""), + })) + : [], + })), + ); + }, []); + + const parseMapRows = () => { + const rows: { before_if: string; after_if: string }[] = []; + for (const line of mapText.split(/\r?\n/)) { + const s = line.trim(); + if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old") || s.toLowerCase().startsWith("before")) { + continue; + } + const parts = s.split(/[,|\t]+/).map((x) => x.trim()); + if (parts.length >= 2 && parts[0] && parts[1]) { + rows.push({ before_if: parts[0], after_if: parts[1] }); + } + } + return rows; + }; + + const loadMappingText = (id: string, list?: PortMapping[]) => { + const m = (list || mappings).find((x) => x.id === id); + if (!m) { + setMappingId(id); + return; + } + setMappingId(id); + setMapName(m.name); + setMapText(m.rows.map((r) => `${r.before_if},${r.after_if}`).join("\n")); + }; + + const resetMappingEditor = () => { + setMappingId(""); + setMapName(t("bizCompare.mapping")); + setMapText(""); + }; + + const saveMapping = async (): Promise => { + const rows = parseMapRows(); + if (mappingId) { + await bizCompareUpdateMapping(mappingId, { name: mapName.trim() || t("bizCompare.mapping"), rows }); + await reloadMappings(); + return mappingId; + } + const m = await bizCompareCreateMapping({ + name: mapName.trim() || t("bizCompare.mapping"), + rows, + }); + const id = String(m.id || ""); + setMappingId(id); + await reloadMappings(); + return id; + }; + const loadBaselineExpect = useCallback(async (pid: string) => { if (!pid) { setExpectSheets([]); @@ -333,6 +425,12 @@ export function BizMigrationPage() { ((mp.items || []) as Record[]).map((x) => ({ id: String(x.id || ""), name: String(x.name || x.id || ""), + rows: Array.isArray(x.rows) + ? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({ + before_if: String(r.before_if || ""), + after_if: String(r.after_if || ""), + })) + : [], })), ); const mts = ((mt.items || []) as Record[]).map((x) => ({ @@ -387,8 +485,11 @@ export function BizMigrationPage() { if (!batchId) { setBoard(null); setDiffs([]); + setEvalRuns([]); + setSelectedRunId(""); return; } + void loadRuns(batchId); void loadBoard(batchId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [batchId]); @@ -454,19 +555,35 @@ export function BizMigrationPage() { }, [project?.new_task_id]); 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 || ""); + if (!project) return; + setMonitorTplId(project.monitor_template_id || project.monitor_template?.id || ""); + setOldBaselineId(project.old_baseline_batch_id || ""); + setNewBaselineId(project.new_baseline_batch_id || ""); + if (project.mapping_id) loadMappingText(project.mapping_id); + else resetMappingEditor(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project?.id, project?.mapping_id, mappings.length]); + + async function loadRuns(bid: string) { + try { + const res = await bizMigrationListRuns(bid, 20); + const items = (res.items || []) as EvalRun[]; + setEvalRuns(items); + setSelectedRunId((prev) => { + if (prev && items.some((r) => r.id === prev)) return prev; + return items[0]?.id || ""; + }); + } catch { + setEvalRuns([]); } - }, [project]); + } async function loadBoard(bid: string, runId = "") { try { const b = await bizMigrationGetBoard(bid, runId); setBoard(b as typeof board); - const rid = String((b as { run?: { id?: string } })?.run?.id || ""); + const rid = String((b as { run?: { id?: string } })?.run?.id || runId || ""); + if (rid) setSelectedRunId(rid); if (rid) { const d = await bizMigrationListDiffs({ runId: rid, limit: 500 }); setDiffs((d.items || []) as DiffRow[]); @@ -482,9 +599,10 @@ export function BizMigrationPage() { setCreateName(""); setCreateOldTaskId(""); setCreateNewTaskId(""); - setCreateMappingId(""); setCreateOldBaselineId(""); setCreateNewBaselineId(""); + setCreateShowMore(false); + resetMappingEditor(); const port = monitorTpls.find((x) => x.name.includes("端口") || x.name.toLowerCase().includes("port")); setCreateMonitorTplId(port?.id || monitorTpls[0]?.id || ""); } @@ -507,6 +625,8 @@ export function BizMigrationPage() { setPortFilter(""); setBatchLabel(""); setOnlyExpect(true); + setBoardSubTab("diffs"); + setBoardMetricId(""); }; const closeProject = () => { @@ -521,11 +641,15 @@ export function BizMigrationPage() { } setBusy(true); try { + let mid = mappingId; + if (parseMapRows().length) { + mid = await saveMapping(); + } const p = (await bizMigrationCreateProject({ name: createName.trim(), old_task_id: createOldTaskId, new_task_id: createNewTaskId, - mapping_id: createMappingId || "", + mapping_id: mid || "", monitor_template_id: createMonitorTplId || "", old_baseline_batch_id: createOldBaselineId || "", new_baseline_batch_id: createNewBaselineId || "", @@ -542,14 +666,37 @@ export function BizMigrationPage() { } } + async function onSaveMappingOnly() { + if (!projectId) return; + setBusy(true); + try { + const mid = await saveMapping(); + if (mid) { + await bizMigrationPatchProject(projectId, { mapping_id: mid }); + await reloadProjects(); + } + showOk(t("bizMigration.mappingBound")); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + } + async function onSaveBaseline() { if (!projectId) return; setBusy(true); try { + let mid = mappingId; + if (mappingId || parseMapRows().length) { + mid = await saveMapping(); + } else { + mid = ""; + } await bizMigrationPatchProject(projectId, { 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 || "", + mapping_id: mid || "", monitor_template_id: monitorTplId || project?.monitor_template_id || "", }); await reloadProjects(); @@ -564,12 +711,12 @@ export function BizMigrationPage() { async function onCreateBatch() { if (!projectId) return; - const items: Array<{ metric_id: string; keys: string[] }> = []; + // One item per selected key. A flat `keys: [a, b]` is parsed as one composite key. + const items: Array<{ metric_id: string; key: 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 }); + for (const key of keys) items.push({ metric_id: sheet.metric_id, key }); if (sheet.metric_id === "interface_brief") ports.push(...keys); } if (!items.length && !ports.length) { @@ -655,14 +802,26 @@ export function BizMigrationPage() { async function onEvaluate() { if (!batchId) return; + if (!project?.old_baseline_batch_id || !project?.new_baseline_batch_id) { + showError(t("bizMigration.needBothBaselines")); + return; + } setBusy(true); try { - const run = await bizMigrationEvaluate(batchId, {}); - await loadBoard(batchId, String((run as { id?: string }).id || "")); + const body: Record = {}; + if (pinOldBatchId) body.old_batch_id = pinOldBatchId; + if (pinNewBatchId) body.new_batch_id = pinNewBatchId; + const run = await bizMigrationEvaluate(batchId, body); + const rid = String((run as { id?: string }).id || ""); + await loadRuns(batchId); + await loadBoard(batchId, rid); showOk(t("bizMigration.evaluated")); setDetailTab("board"); } catch (e) { - showError(formatErr(e)); + const detail = e instanceof ApiRequestError ? String(e.detail || "") : ""; + if (detail === "old_baseline_required") showError(t("bizMigration.errOldBaselineRequired")); + else if (detail === "new_baseline_required") showError(t("bizMigration.errNewBaselineRequired")); + else showError(formatErr(e)); } finally { setBusy(false); } @@ -712,11 +871,6 @@ export function BizMigrationPage() { 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; @@ -765,7 +919,7 @@ export function BizMigrationPage() {

- {t("bizMigration.hintPort")}{" "} + {t("bizMigration.hintShort")}{" "} {t("network.nav.monitorTemplates")} {" · "} {t("network.nav.compareTemplates")} @@ -825,7 +979,7 @@ export function BizMigrationPage() { {statusLabel(row.status)} - {row.old_baseline_batch_id ? ( + {row.old_baseline_batch_id && row.new_baseline_batch_id ? ( {t("bizMigration.baselineSet")} ) : ( {t("bizMigration.baselineUnset")} @@ -837,9 +991,6 @@ export function BizMigrationPage() { - @@ -865,152 +1016,125 @@ export function BizMigrationPage() { {t("bizMigration.create")} - -

- {t("bizMigration.createHint")} -

- -
- {t("bizMigration.sectionName")} - setCreateName(e.target.value)} - placeholder={t("bizMigration.projectNamePh")} - aria-label={t("bizMigration.projectName")} - /> -
- -
- {t("bizMigration.sectionTasks")} -

- {t("bizMigration.sectionTasksHint")} -

-
- { - setCreateOldTaskId(e.target.value); - setCreateOldBaselineId(""); - }} - fullWidth - > - {taskOptions} - - { - setCreateNewTaskId(e.target.value); - setCreateNewBaselineId(""); - }} - fullWidth - > - {taskOptions} - -
- {!tasks.length ? ( -

- {t("bizMigration.needBizStateTasks")}{" "} - - {t("bizMigration.openBizState")} - -

- ) : null} -
- -
- {t("bizMigration.sectionOptional")} -

- {t("bizMigration.sectionOptionalHint")} -

+ +

{t("bizMigration.createHintShort")}

+ setCreateName(e.target.value)} + placeholder={t("bizMigration.projectNamePh")} + aria-label={t("bizMigration.projectName")} + /> +
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} - setCreateMappingId(e.target.value)} - fullWidth - hint={t("bizMigration.portMappingHint")} - > - - {mappings.map((m) => ( - - ))} - -
{ + setCreateOldTaskId(e.target.value); + setCreateOldBaselineId(""); }} + fullWidth > - setCreateOldBaselineId(e.target.value)} - fullWidth - disabled={!createOldTaskId} - hint={ - createOldTaskId - ? t("bizMigration.baselinePickHint") - : t("bizMigration.baselineNeedTask") - } - > - {batchOptions(createOldBatches)} - - setCreateNewBaselineId(e.target.value)} - fullWidth - disabled={!createNewTaskId} - hint={ - createNewTaskId - ? t("bizMigration.baselinePickHint") - : t("bizMigration.baselineNeedTask") - } - > - {batchOptions(createNewBatches)} - -
+ {taskOptions} + + { + setCreateNewTaskId(e.target.value); + setCreateNewBaselineId(""); + }} + fullWidth + > + {taskOptions} +
+ {!tasks.length ? ( +

+ {t("bizMigration.needBizStateTasks")}{" "} + + {t("bizMigration.openBizState")} + +

+ ) : null} + + + {createShowMore ? ( +
+ setCreateMonitorTplId(e.target.value)} + fullWidth + > + + {monitorTpls.map((m) => ( + + ))} + +
+
{t("bizMigration.portMapping")}
+

{t("bizCompare.mappingOptionalHint")}

+
+ { + const id = e.target.value; + if (id) loadMappingText(id); + else resetMappingEditor(); + }} + fullWidth + > + + {mappings.map((m) => ( + + ))} + + setMapName(e.target.value)} + aria-label={t("bizCompare.mapName")} + /> + +
+