mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 05:20:44 +08:00
Tighten cutover verdicts and scope iface metrics by port mapping.
Mapped interfaces are the expect set and must appear after cutover; unmapped same-name down/up is anomaly. Success presets no longer treat bare unchanged as green. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
27c6b7c61d
commit
88b7558a48
11 changed files with 4663 additions and 1497 deletions
|
|
@ -83,14 +83,33 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
||||||
continue
|
continue
|
||||||
bucket = out.setdefault(mid, set())
|
bucket = out.setdefault(mid, set())
|
||||||
if it.get("key") is not None:
|
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")
|
keys = it.get("keys")
|
||||||
if isinstance(keys, list):
|
if isinstance(keys, list):
|
||||||
if all(not isinstance(x, (list, tuple, dict)) for x in keys):
|
# Flat list of segments → one composite key; else each entry is a key
|
||||||
bucket.add("|".join(str(x).strip() for x in keys))
|
# (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:
|
else:
|
||||||
for x in keys:
|
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}
|
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(
|
def _fields_for_tokens(
|
||||||
sheet_override: dict[str, Any] | None,
|
sheet_override: dict[str, Any] | None,
|
||||||
success_patterns: list[dict[str, Any]] | None,
|
*pattern_lists: list[dict[str, Any]] | None,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
"""Fields whose current values should be emitted as field:* tokens."""
|
"""Fields whose current values should be emitted as field:* tokens."""
|
||||||
ov = sheet_override or {}
|
ov = sheet_override or {}
|
||||||
|
|
@ -140,17 +159,38 @@ def _fields_for_tokens(
|
||||||
s = str(ft.get("field") or "").strip()
|
s = str(ft.get("field") or "").strip()
|
||||||
if s:
|
if s:
|
||||||
fields.add(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):
|
if not isinstance(pat, dict):
|
||||||
continue
|
continue
|
||||||
for side in ("old", "new"):
|
for side in ("old", "new"):
|
||||||
for tok in pat.get(side) or []:
|
for tok in pat.get(side) or []:
|
||||||
t = str(tok or "")
|
t = str(tok or "")
|
||||||
if t.startswith("field:") and t.count(":") >= 2:
|
if t.startswith("field:") and t.count(":") >= 2:
|
||||||
# field:name:value
|
|
||||||
parts = t.split(":", 2)
|
parts = t.split(":", 2)
|
||||||
if parts[1]:
|
if parts[1]:
|
||||||
fields.add(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
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -183,21 +223,313 @@ def _side_tokens(
|
||||||
return toks
|
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(
|
def _match_success(
|
||||||
old_tokens: set[str],
|
old_tokens: set[str],
|
||||||
new_tokens: set[str],
|
new_tokens: set[str],
|
||||||
success_patterns: list[dict[str, Any]] | None,
|
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:
|
) -> bool:
|
||||||
for pat in success_patterns or []:
|
return (
|
||||||
if not isinstance(pat, dict):
|
_match_patterns(
|
||||||
continue
|
success_patterns,
|
||||||
old_need = {str(x) for x in (pat.get("old") or []) if str(x)}
|
old_kind=old_kind,
|
||||||
new_need = {str(x) for x in (pat.get("new") or []) if str(x)}
|
new_kind=new_kind,
|
||||||
if not old_need or not new_need:
|
old_status=old_status,
|
||||||
continue
|
new_status=new_status,
|
||||||
if (old_need & old_tokens) and (new_need & new_tokens):
|
old_row=old_row,
|
||||||
return True
|
new_row=new_row,
|
||||||
return False
|
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(
|
def _match_field_token_rules(
|
||||||
|
|
@ -232,63 +564,156 @@ def dual_verdict(
|
||||||
old_status: str = "none",
|
old_status: str = "none",
|
||||||
new_status: str = "none",
|
new_status: str = "none",
|
||||||
success_patterns: list[dict[str, Any]] | None = None,
|
success_patterns: list[dict[str, Any]] | None = None,
|
||||||
|
anomaly_patterns: list[dict[str, Any]] | None = None,
|
||||||
out_of_expect: str = "strict",
|
out_of_expect: str = "strict",
|
||||||
old_row: dict[str, Any] | None = None,
|
old_row: dict[str, Any] | None = None,
|
||||||
new_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,
|
sheet_override: dict[str, Any] | None = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Synthesize old+new into migration board verdict.
|
"""Synthesize old+new into migration board verdict. 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
|
def dual_verdict_ex(
|
||||||
include kinds, status classes (up/down), and ``field:name:value``.
|
*,
|
||||||
|
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 {}
|
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)
|
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)
|
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)
|
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):
|
match_kw = dict(
|
||||||
return "migrated", "green"
|
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()
|
ooe = str(out_of_expect or "strict").strip().lower()
|
||||||
if not in_expect:
|
if not in_expect:
|
||||||
if ooe == "ignore":
|
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"):
|
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"):
|
if old_kind == "removed" and new_kind in ("", "removed"):
|
||||||
return ("lost", "yellow") if ooe == "warn" else ("lost", "red")
|
label = "lost" if ooe == "warn" else "lost"
|
||||||
if old_kind in ("removed", "changed") or new_kind in ("changed",):
|
color = "yellow" if ooe == "warn" else "red"
|
||||||
return ("anomaly", "yellow") if ooe == "warn" else ("anomaly", "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":
|
if old_kind == "added" or new_kind == "added":
|
||||||
return "unexpected_new", "yellow"
|
return "unexpected_new", "yellow", "out_of_expect:added"
|
||||||
return "not_involved", "gray"
|
return "not_involved", "gray", "out_of_expect:none"
|
||||||
|
|
||||||
if old_kind in ("removed", "changed") and new_kind in ("added", "unchanged", "changed"):
|
# field_tokens gate success only — do not block anomaly on a gone side
|
||||||
return "migrated", "green"
|
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"):
|
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 old_kind in ("unchanged", "") and new_kind in ("unchanged", "added", "changed"):
|
||||||
if acceptance:
|
if acceptance:
|
||||||
return "unfinished", "red"
|
return "unfinished", "red", "heuristic:unfinished"
|
||||||
return "migrating", "yellow"
|
return "migrating", "yellow", "heuristic:migrating"
|
||||||
if old_kind == "unchanged" and new_kind in ("", "removed"):
|
if old_kind == "unchanged" and new_kind in ("", "removed"):
|
||||||
if acceptance:
|
if acceptance:
|
||||||
return "unfinished", "red"
|
return "unfinished", "red", "heuristic:unfinished"
|
||||||
return "migrating", "yellow"
|
return "migrating", "yellow", "heuristic:migrating"
|
||||||
side_v, side_c = side_verdict(kind=old_kind or "unchanged", in_expect=True, window_active=window_active)
|
if not rules_mode:
|
||||||
if side_c == "red" or (new_kind in ("removed",) and old_kind != "removed"):
|
side_v, side_c = side_verdict(
|
||||||
return "anomaly", "red"
|
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:
|
if acceptance:
|
||||||
return "unfinished", "red"
|
return "unfinished", "red", "heuristic:unfinished"
|
||||||
if window_active or side_v.startswith("expected"):
|
if window_active or side_v.startswith("expected"):
|
||||||
return "migrating", "yellow"
|
return "migrating", "yellow", "heuristic:migrating"
|
||||||
return "migrating", "yellow"
|
return "migrating", "yellow", "heuristic:migrating"
|
||||||
|
|
||||||
|
|
||||||
def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
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],
|
iface_fields: list[str],
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
keys = set(expect.get(metric_id) or ())
|
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"])
|
keys |= set(expect["_ports"])
|
||||||
return keys
|
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]:
|
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Current-side snapshot from a compare diff (empty if removed / missing)."""
|
"""Current-side snapshot from a compare diff (empty if removed / missing)."""
|
||||||
if not diff:
|
if not diff:
|
||||||
|
|
@ -381,8 +921,12 @@ def evaluate_metric_dual(
|
||||||
"anomaly": 0,
|
"anomaly": 0,
|
||||||
"rows": [],
|
"rows": [],
|
||||||
"skipped": True,
|
"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 []
|
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(
|
old_cmp = compare_rows(
|
||||||
before_rows=old_baseline_rows,
|
before_rows=old_baseline_rows,
|
||||||
|
|
@ -395,15 +939,34 @@ def evaluate_metric_dual(
|
||||||
)
|
)
|
||||||
old_idx = build_diff_index_from_compare(old_cmp)
|
old_idx = build_diff_index_from_compare(old_cmp)
|
||||||
|
|
||||||
|
new_baseline_mode = "provided"
|
||||||
|
new_baseline_missing = False
|
||||||
if new_baseline_rows is not None:
|
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:
|
elif port_map and iface_fields:
|
||||||
new_before = [
|
new_before = [
|
||||||
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
|
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
|
||||||
for r in old_baseline_rows
|
for r in old_baseline_rows
|
||||||
]
|
]
|
||||||
|
new_baseline_mode = "port_mapped"
|
||||||
else:
|
else:
|
||||||
new_before = []
|
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(
|
new_cmp = compare_rows(
|
||||||
before_rows=new_before,
|
before_rows=new_before,
|
||||||
|
|
@ -417,6 +980,26 @@ def evaluate_metric_dual(
|
||||||
new_idx = build_diff_index_from_compare(new_cmp)
|
new_idx = build_diff_index_from_compare(new_cmp)
|
||||||
|
|
||||||
rev_map = {v: k for k, v in port_map.items()}
|
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] = []
|
canon_keys: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
@ -431,15 +1014,28 @@ def evaluate_metric_dual(
|
||||||
for ks in sorted(old_idx):
|
for ks in sorted(old_idx):
|
||||||
_add(ks)
|
_add(ks)
|
||||||
for ks in sorted(new_idx):
|
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]] = []
|
rows_out: list[dict[str, Any]] = []
|
||||||
progress_ok = 0
|
progress_ok = 0
|
||||||
progress_total = 0
|
progress_total = 0
|
||||||
anomaly = 0
|
anomaly = 0
|
||||||
|
anomaly_in_expect = 0
|
||||||
|
|
||||||
for old_ks in canon_keys:
|
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
|
in_exp = old_ks in expect_keys
|
||||||
if in_exp:
|
if in_exp:
|
||||||
progress_total += 1
|
progress_total += 1
|
||||||
|
|
@ -458,27 +1054,50 @@ def evaluate_metric_dual(
|
||||||
|
|
||||||
old_cur = _current_row(od)
|
old_cur = _current_row(od)
|
||||||
new_cur = _current_row(nd)
|
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)
|
old_st = classify_status(old_cur, ov)
|
||||||
new_st = classify_status(new_cur, ov)
|
new_st = classify_status(new_cur, ov)
|
||||||
|
|
||||||
verdict, color = dual_verdict(
|
# Mapped iface rows are the expect set. Unmapped rows stay out of the
|
||||||
old_kind=old_kind or "",
|
# migration compare, except same-name ports where old went down and the
|
||||||
new_kind=new_kind or "",
|
# new device's same port is up — that is anomaly, not a successful cutover.
|
||||||
in_expect=in_exp,
|
if map_scoped and not in_exp:
|
||||||
window_active=window_active,
|
if not (
|
||||||
acceptance=acceptance,
|
_port_identity(key_fields, iface_fields)
|
||||||
old_status=old_st,
|
and _unmapped_same_iface_anomaly(
|
||||||
new_status=new_st,
|
old_kind=old_kind,
|
||||||
success_patterns=success_patterns or None,
|
new_kind=new_kind,
|
||||||
out_of_expect=out_of_expect,
|
old_status=old_st,
|
||||||
old_row=old_cur or None,
|
new_status=new_st,
|
||||||
new_row=new_cur or None,
|
)
|
||||||
sheet_override=ov,
|
):
|
||||||
)
|
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":
|
if in_exp and verdict == "migrated" and color == "green":
|
||||||
progress_ok += 1
|
progress_ok += 1
|
||||||
if color == "red":
|
if color == "red":
|
||||||
anomaly += 1
|
anomaly += 1
|
||||||
|
if in_exp:
|
||||||
|
anomaly_in_expect += 1
|
||||||
|
|
||||||
rows_out.append(
|
rows_out.append(
|
||||||
{
|
{
|
||||||
|
|
@ -488,6 +1107,7 @@ def evaluate_metric_dual(
|
||||||
"new_key_str": new_ks,
|
"new_key_str": new_ks,
|
||||||
"verdict": verdict,
|
"verdict": verdict,
|
||||||
"color": color,
|
"color": color,
|
||||||
|
"rule_hit": rule_hit,
|
||||||
"in_expect": in_exp,
|
"in_expect": in_exp,
|
||||||
"old_kind": old_kind,
|
"old_kind": old_kind,
|
||||||
"new_kind": new_kind,
|
"new_kind": new_kind,
|
||||||
|
|
@ -525,7 +1145,10 @@ def evaluate_metric_dual(
|
||||||
"progress_ok": progress_ok,
|
"progress_ok": progress_ok,
|
||||||
"progress_total": progress_total if progress_total else len(expect_keys),
|
"progress_total": progress_total if progress_total else len(expect_keys),
|
||||||
"anomaly": anomaly,
|
"anomaly": anomaly,
|
||||||
|
"anomaly_in_expect": anomaly_in_expect,
|
||||||
"rows": rows_out,
|
"rows": rows_out,
|
||||||
|
"new_baseline_mode": new_baseline_mode,
|
||||||
|
"new_baseline_missing": new_baseline_missing,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,24 +67,117 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
|
||||||
return row
|
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]:
|
def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
|
||||||
"""Default dual-verdict overlay for a compare-template sheet."""
|
"""Default dual-verdict overlay for a compare-template sheet."""
|
||||||
mid = str(metric_id or "").strip()
|
mid = str(metric_id or "").strip()
|
||||||
if mid == PORT_METRIC_ID:
|
if mid == PORT_METRIC_ID:
|
||||||
|
up = [
|
||||||
|
{"type": "value", "field": "admin", "op": "in", "value": ["up"]},
|
||||||
|
{"type": "value", "field": "phy", "op": "in", "value": ["up"]},
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"status_fields": list(PORT_STATUS_FIELDS),
|
"status_fields": list(PORT_STATUS_FIELDS),
|
||||||
"down_values": ["down"],
|
"down_values": ["down"],
|
||||||
"up_values": ["up"],
|
"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":
|
if mid == "bgp_peer":
|
||||||
|
up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}]
|
||||||
return {
|
return {
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"status_fields": ["state"],
|
"status_fields": ["state"],
|
||||||
"down_values": ["idle", "active", "connect", "down"],
|
"down_values": ["idle", "active", "connect", "down"],
|
||||||
"up_values": ["established"],
|
"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"):
|
if mid in ("arp", "nd6_cache", "lldp_neighbor"):
|
||||||
return {
|
return {
|
||||||
|
|
@ -92,15 +185,39 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
|
||||||
"status_fields": [],
|
"status_fields": [],
|
||||||
"down_values": [],
|
"down_values": [],
|
||||||
"up_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:
|
if "isis" in mid or "ospf" in mid or "adjacency" in mid:
|
||||||
|
up = [
|
||||||
|
{
|
||||||
|
"type": "value",
|
||||||
|
"field": "state",
|
||||||
|
"op": "in",
|
||||||
|
"value": ["up", "full", "2way"],
|
||||||
|
}
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"status_fields": ["state"],
|
"status_fields": ["state"],
|
||||||
"down_values": ["down", "init", "idle"],
|
"down_values": ["down", "init", "idle"],
|
||||||
"up_values": ["up", "full", "2way"],
|
"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:
|
if "route" in mid or "vrf" in mid:
|
||||||
return {
|
return {
|
||||||
|
|
@ -108,14 +225,16 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
|
||||||
"status_fields": [],
|
"status_fields": [],
|
||||||
"down_values": [],
|
"down_values": [],
|
||||||
"up_values": [],
|
"up_values": [],
|
||||||
"success": [{"old": ["removed"], "new": ["added", "unchanged"]}],
|
"success": [_success_presence_migrate()],
|
||||||
|
"anomaly": _default_anomaly_presence_only(),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"status_fields": [],
|
"status_fields": [],
|
||||||
"down_values": ["down"],
|
"down_values": [],
|
||||||
"up_values": ["up"],
|
"up_values": [],
|
||||||
"success": [{"old": ["removed"], "new": ["added", "unchanged"]}],
|
"success": [_success_presence_migrate()],
|
||||||
|
"anomaly": _default_anomaly_presence_only(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,8 @@ def run_evaluate(
|
||||||
raise HTTPException(status_code=404, detail="project_not_found")
|
raise HTTPException(status_code=404, detail="project_not_found")
|
||||||
if not proj.old_baseline_batch_id:
|
if not proj.old_baseline_batch_id:
|
||||||
raise HTTPException(status_code=400, detail="old_baseline_required")
|
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
|
old_batch = db.get(BizStateBatch, old_batch_id.strip()) if old_batch_id.strip() else None
|
||||||
if not old_batch:
|
if not old_batch:
|
||||||
|
|
@ -389,12 +391,10 @@ def run_evaluate(
|
||||||
_load_metric_rows(db, batch_id=old_cur, metric_id=mid),
|
_load_metric_rows(db, batch_id=old_cur, metric_id=mid),
|
||||||
row_filters,
|
row_filters,
|
||||||
)
|
)
|
||||||
new_base_rows = None
|
new_base_rows = apply_row_filters(
|
||||||
if proj.new_baseline_batch_id:
|
_load_metric_rows(db, batch_id=proj.new_baseline_batch_id, metric_id=mid),
|
||||||
new_base_rows = apply_row_filters(
|
row_filters,
|
||||||
_load_metric_rows(db, batch_id=proj.new_baseline_batch_id, metric_id=mid),
|
)
|
||||||
row_filters,
|
|
||||||
)
|
|
||||||
new_now = apply_row_filters(
|
new_now = apply_row_filters(
|
||||||
_load_metric_rows(db, batch_id=new_cur, metric_id=mid),
|
_load_metric_rows(db, batch_id=new_cur, metric_id=mid),
|
||||||
row_filters,
|
row_filters,
|
||||||
|
|
@ -424,8 +424,11 @@ def run_evaluate(
|
||||||
"progress_ok": one["progress_ok"],
|
"progress_ok": one["progress_ok"],
|
||||||
"progress_total": one["progress_total"],
|
"progress_total": one["progress_total"],
|
||||||
"anomaly": one["anomaly"],
|
"anomaly": one["anomaly"],
|
||||||
|
"anomaly_in_expect": int(one.get("anomaly_in_expect") or 0),
|
||||||
"old_summary": one["old_summary"],
|
"old_summary": one["old_summary"],
|
||||||
"new_summary": one["new_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"]:
|
for r in one["rows"]:
|
||||||
|
|
@ -455,6 +458,12 @@ def run_evaluate(
|
||||||
"total": sum(c["progress_total"] for c in sheet_cards),
|
"total": sum(c["progress_total"] for c in sheet_cards),
|
||||||
},
|
},
|
||||||
"anomaly": sum(c["anomaly"] 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,
|
"verdict_counts": verdict_counts,
|
||||||
"window_active": window_active,
|
"window_active": window_active,
|
||||||
"expect_ports": sorted(expect.get("_ports") or ()),
|
"expect_ports": sorted(expect.get("_ports") or ()),
|
||||||
|
|
@ -489,6 +498,7 @@ def run_evaluate(
|
||||||
"new_key_str": r.get("new_key_str"),
|
"new_key_str": r.get("new_key_str"),
|
||||||
"old_status": r.get("old_status"),
|
"old_status": r.get("old_status"),
|
||||||
"new_status": r.get("new_status"),
|
"new_status": r.get("new_status"),
|
||||||
|
"rule_hit": r.get("rule_hit") or "",
|
||||||
},
|
},
|
||||||
old_kind=str(r.get("old_kind") or ""),
|
old_kind=str(r.get("old_kind") or ""),
|
||||||
new_kind=str(r.get("new_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 "",
|
"new_key_str": kj.get("new_key_str") or "",
|
||||||
"old_status": kj.get("old_status") or "",
|
"old_status": kj.get("old_status") or "",
|
||||||
"new_status": kj.get("new_status") or "",
|
"new_status": kj.get("new_status") or "",
|
||||||
|
"rule_hit": kj.get("rule_hit") or "",
|
||||||
"old_kind": d.old_kind,
|
"old_kind": d.old_kind,
|
||||||
"new_kind": d.new_kind,
|
"new_kind": d.new_kind,
|
||||||
"old": d.old_json,
|
"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:
|
if not p.old_baseline_batch_id:
|
||||||
return {"batch_id": "", "sheets": [], "mapped": {}}
|
return {"batch_id": "", "sheets": [], "mapped": {}}
|
||||||
mt = resolve_project_monitor_template(db, p)
|
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)
|
port_map = _port_map_dict(db, p.mapping_id)
|
||||||
out_sheets: list[dict[str, Any]] = []
|
out_sheets: list[dict[str, Any]] = []
|
||||||
for sheet in sheets:
|
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()]
|
key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()]
|
||||||
if not mid or not key_fields:
|
if not mid or not key_fields:
|
||||||
continue
|
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()]
|
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
|
||||||
rows_raw = apply_row_filters(
|
rows_raw = apply_row_filters(
|
||||||
_load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid),
|
_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)
|
anomaly = int(summary.get("anomaly") or 0)
|
||||||
ok = int(progress.get("ok") or 0)
|
ok = int(progress.get("ok") or 0)
|
||||||
total = int(progress.get("total") 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 = {
|
accept_summary = {
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
|
"fail_reasons": fail_reasons,
|
||||||
|
"missing_metrics": missing_metrics,
|
||||||
"progress_ok": ok,
|
"progress_ok": ok,
|
||||||
"progress_total": total,
|
"progress_total": total,
|
||||||
"anomaly": anomaly,
|
"anomaly": anomaly,
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ from netx_api.biz_migration.evaluate import (
|
||||||
classify_status,
|
classify_status,
|
||||||
dual_verdict,
|
dual_verdict,
|
||||||
evaluate_metric_dual,
|
evaluate_metric_dual,
|
||||||
|
expect_keys_for_metric,
|
||||||
parse_expect_set,
|
parse_expect_set,
|
||||||
port_sheet_def,
|
port_sheet_def,
|
||||||
port_status_label,
|
port_status_label,
|
||||||
side_verdict,
|
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["bgp_peer"], {"AS1|1.1.1.1"})
|
||||||
self.assertEqual(got["arp"], {"10.0.0.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):
|
class PortSheetTests(unittest.TestCase):
|
||||||
def test_port_sheet_fields(self):
|
def test_port_sheet_fields(self):
|
||||||
|
|
@ -155,6 +200,75 @@ class VerdictTests(unittest.TestCase):
|
||||||
("anomaly", "yellow"),
|
("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):
|
def test_field_token_success(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dual_verdict(
|
dual_verdict(
|
||||||
|
|
@ -174,6 +288,259 @@ class VerdictTests(unittest.TestCase):
|
||||||
("migrated", "green"),
|
("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):
|
class EvaluateMetricDualTests(unittest.TestCase):
|
||||||
def test_port_migration_happy_path(self):
|
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"]
|
reds = [r for r in out["rows"] if r["color"] == "red"]
|
||||||
self.assertTrue(reds, out["rows"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -288,8 +288,16 @@ const en = {
|
||||||
stepPickMetrics: "① Pick metrics",
|
stepPickMetrics: "① Pick metrics",
|
||||||
stepConfigRules: "② Configure compare rules",
|
stepConfigRules: "② Configure compare rules",
|
||||||
pickMetricsHint: "Check metrics to add. Then configure each sheet below.",
|
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",
|
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",
|
colFields: "Fields",
|
||||||
keyFields: "Key",
|
keyFields: "Key",
|
||||||
ifaceFields: "Interface",
|
ifaceFields: "Interface",
|
||||||
|
|
@ -301,7 +309,7 @@ const en = {
|
||||||
modeEq: "Exact equal",
|
modeEq: "Exact equal",
|
||||||
modeNumeric: "Abs delta",
|
modeNumeric: "Abs delta",
|
||||||
modePercent: "Relative %",
|
modePercent: "Relative %",
|
||||||
normalizeField: "Normalize",
|
normalizeField: "Norm",
|
||||||
ignoreFields: "Ignore",
|
ignoreFields: "Ignore",
|
||||||
field: "Field",
|
field: "Field",
|
||||||
note: "Note",
|
note: "Note",
|
||||||
|
|
@ -312,12 +320,17 @@ const en = {
|
||||||
rowFilters: "Row filter rules",
|
rowFilters: "Row filter rules",
|
||||||
rowFiltersHint:
|
rowFiltersHint:
|
||||||
"Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.",
|
"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.",
|
rowFiltersHintShort: "AND in group · OR across groups · empty = all rows",
|
||||||
filterClear: "Clear rules",
|
rowFiltersEmpty: "No filters → compare all rows.",
|
||||||
|
filterClear: "Clear",
|
||||||
filterAddOr: "Add group (OR)",
|
filterAddOr: "Add group (OR)",
|
||||||
filterAddAnd: "Add condition (AND)",
|
filterAddAnd: "Add condition (AND)",
|
||||||
|
filterAddOrShort: "+ OR group",
|
||||||
|
filterAddAndShort: "+ AND",
|
||||||
filterRemoveGroup: "Remove group",
|
filterRemoveGroup: "Remove group",
|
||||||
|
filterGroupN: "Group {{n}}",
|
||||||
filterOr: "OR",
|
filterOr: "OR",
|
||||||
|
filterAnd: "AND",
|
||||||
filterField: "Field",
|
filterField: "Field",
|
||||||
filterOp: "Op",
|
filterOp: "Op",
|
||||||
filterValue: "Value",
|
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.",
|
hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.",
|
||||||
hintPort:
|
hintPort:
|
||||||
"Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.",
|
"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",
|
create: "Create task",
|
||||||
createHint: "Name the task and bind old/new business-monitor collect tasks. Mapping and baselines can be set later.",
|
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",
|
createProject: "Create task",
|
||||||
projectName: "Task name",
|
projectName: "Task name",
|
||||||
projectNamePh: "e.g. CN1/CN2 night-1 cutover",
|
projectNamePh: "e.g. CN1/CN2 night-1 cutover",
|
||||||
|
|
@ -469,23 +487,27 @@ const en = {
|
||||||
sectionPair: "Bound collect tasks",
|
sectionPair: "Bound collect tasks",
|
||||||
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
|
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
|
||||||
sectionBaseline: "Baseline batches",
|
sectionBaseline: "Baseline batches",
|
||||||
sectionBaselineHint: "Old baseline feeds the expected-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",
|
optionalNone: "None",
|
||||||
monitorTemplate: "Monitor template",
|
monitorTemplate: "Monitor template",
|
||||||
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to port cutover.",
|
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to port cutover.",
|
||||||
compareTemplate: "Compare template",
|
compareTemplate: "Compare template",
|
||||||
compareTemplateBound: "Bound compare template: {{name}}",
|
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",
|
baselineNeedTask: "Select the matching collect task first",
|
||||||
baselinePickHint: "Choose from that task’s collect batches",
|
baselinePickHint: "Choose from that task’s collect batches",
|
||||||
oldTask: "Old-device collect task",
|
oldTask: "Old-device collect task",
|
||||||
newTask: "New-device collect task",
|
newTask: "New-device collect task",
|
||||||
portMapping: "Port mapping",
|
portMapping: "Port mapping",
|
||||||
oldBaseline: "Old baseline batch",
|
oldBaseline: "Old baseline batch",
|
||||||
newBaseline: "New baseline batch (optional)",
|
newBaseline: "New baseline batch",
|
||||||
needProjectFields: "Enter a name and pick old/new collect tasks",
|
needProjectFields: "Enter a name and pick old/new collect tasks",
|
||||||
needExpectPorts: "Select at least one expected object for this batch",
|
needExpectPorts: "Select at least one expected object for this batch",
|
||||||
needBaselineFirst: "Save an old baseline batch first to list expect objects",
|
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:",
|
needBizStateTasks: "No business-monitor tasks yet. Create one first:",
|
||||||
projectCreated: "Task created",
|
projectCreated: "Task created",
|
||||||
projects: "Tasks",
|
projects: "Tasks",
|
||||||
|
|
@ -534,6 +556,15 @@ const en = {
|
||||||
progress: "Progress",
|
progress: "Progress",
|
||||||
anomaly: "Anomaly",
|
anomaly: "Anomaly",
|
||||||
onlyExpect: "Expected objects only",
|
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",
|
allMetrics: "All metrics",
|
||||||
colMetric: "Metric",
|
colMetric: "Metric",
|
||||||
colKey: "Object",
|
colKey: "Object",
|
||||||
|
|
@ -548,6 +579,7 @@ const en = {
|
||||||
colNew: "New",
|
colNew: "New",
|
||||||
colVerdict: "Verdict",
|
colVerdict: "Verdict",
|
||||||
emptyDiffs: "No rows yet — run Evaluate",
|
emptyDiffs: "No rows yet — run Evaluate",
|
||||||
|
emptyRed: "No red tickets",
|
||||||
verdictMigrated: "Migrated",
|
verdictMigrated: "Migrated",
|
||||||
verdictMigrating: "Migrating",
|
verdictMigrating: "Migrating",
|
||||||
verdictLost: "Lost",
|
verdictLost: "Lost",
|
||||||
|
|
@ -568,6 +600,7 @@ const en = {
|
||||||
acceptFailed: "Batch acceptance failed ({{n}} red tickets; you may continue)",
|
acceptFailed: "Batch acceptance failed ({{n}} red tickets; you may continue)",
|
||||||
acceptPassedShort: "Passed",
|
acceptPassedShort: "Passed",
|
||||||
acceptFailedShort: "Failed",
|
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.",
|
acceptCarryHint: "Failure does not block the next batch; red tickets stay until resolved.",
|
||||||
openRedHint: "{{n}} open red ticket(s) on this task (carry allowed)",
|
openRedHint: "{{n}} open red ticket(s) on this task (carry allowed)",
|
||||||
redTitle: "Red tickets",
|
redTitle: "Red tickets",
|
||||||
|
|
@ -602,7 +635,7 @@ const en = {
|
||||||
needName: "Name is required",
|
needName: "Name is required",
|
||||||
defaultsInvalid: "Invalid defaults JSON",
|
defaultsInvalid: "Invalid defaults JSON",
|
||||||
overridesInvalid: "Invalid sheet_overrides 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.",
|
compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.",
|
||||||
pickCompare: "Pick compare template…",
|
pickCompare: "Pick compare template…",
|
||||||
openCompare: "Manage compare templates",
|
openCompare: "Manage compare templates",
|
||||||
|
|
@ -621,26 +654,85 @@ const en = {
|
||||||
ooeIgnore: "Ignore (always not involved)",
|
ooeIgnore: "Ignore (always not involved)",
|
||||||
sheetRules: "Per-sheet migrate rules",
|
sheetRules: "Per-sheet migrate rules",
|
||||||
applyAllPresets: "Apply recommended presets to all",
|
applyAllPresets: "Apply recommended presets to all",
|
||||||
|
applyAllPresetsShort: "All presets",
|
||||||
|
sheetsUnit: " sheets",
|
||||||
sheetHowReadonly: "Compare template (read-only)",
|
sheetHowReadonly: "Compare template (read-only)",
|
||||||
iface: "Interface",
|
iface: "Interface",
|
||||||
compare: "Compare",
|
compare: "Compare",
|
||||||
presenceOnly: "presence only",
|
presenceOnly: "presence only",
|
||||||
editInCompare: "Edit HOW in compare templates",
|
editInCompare: "Edit HOW",
|
||||||
presetAuto: "Apply recommended preset",
|
presetAuto: "Recommended",
|
||||||
presetSkip: "Skip dual verdict",
|
presetSkip: "Skip sheet",
|
||||||
skipDual: "Skip dual for this sheet",
|
skipDual: "Skip dual for this sheet",
|
||||||
skipDualHint: "This sheet is skipped in cutover evaluate (use a compare job for pure value checks).",
|
skipDualHint: "Skipped in cutover evaluate (use a compare job for counters).",
|
||||||
statusFields: "Status fields",
|
statusFields: "Fields used for up/down",
|
||||||
statusFieldsHint: "Classify current rows as up/down; ports use admin/phy/prot, BGP uses state.",
|
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",
|
downValues: "Down values",
|
||||||
upValues: "Up values",
|
upValues: "Up values",
|
||||||
noFields: "No fields (pick metrics on the compare template first)",
|
noFields: "No fields (pick metrics on the compare template first)",
|
||||||
successPatterns: "Success patterns (old any-of × new any-of)",
|
successPatterns: "Success rules (old OR-groups × new OR-groups)",
|
||||||
successPatternsHint: "e.g. port: old removed/down + new added/up; ARP: old removed + new added; BGP: old down(idle) + new up(established).",
|
successPatternsShort: "Success",
|
||||||
oldSideAny: "Old side (any match)",
|
successPatternsHint:
|
||||||
newSideAny: "New side (any match)",
|
"Presence is whether the row exists. Value change binds a field and a strategy. AND inside a group, OR between groups.",
|
||||||
addSuccessRow: "Add success pattern",
|
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.",
|
needCompareFirst: "Pick a compare template that has sheets first.",
|
||||||
|
showMore: "More: note / HF collect / JSON",
|
||||||
|
hideMore: "Hide more",
|
||||||
showJson: "Advanced: edit JSON",
|
showJson: "Advanced: edit JSON",
|
||||||
hideJson: "Back to visual editor",
|
hideJson: "Back to visual editor",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -288,20 +288,27 @@ const zh = {
|
||||||
stepPickMetrics: "① 选择监控项",
|
stepPickMetrics: "① 选择监控项",
|
||||||
stepConfigRules: "② 配置比对规则",
|
stepConfigRules: "② 配置比对规则",
|
||||||
pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。",
|
pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。",
|
||||||
noMetricsYet: "请先在上方勾选至少一个监控项",
|
pickMetricsHintShort: "点选加入 / 取消;配置在下方左侧表列表中切换。",
|
||||||
|
showMetrics: "展开选表",
|
||||||
|
hideMetrics: "收起选表",
|
||||||
|
noMetricsYet: "请先勾选至少一个监控项",
|
||||||
selectedCount: "已选 {{n}} 项",
|
selectedCount: "已选 {{n}} 项",
|
||||||
|
tabFields: "字段规则",
|
||||||
|
tabFilters: "行过滤",
|
||||||
|
fieldsHintShort: "Key 对齐行;比对勾选参与值 diff;不勾比对则只看存在性。",
|
||||||
|
tagCompareN: "{{n}} 比对",
|
||||||
colFields: "字段数",
|
colFields: "字段数",
|
||||||
keyFields: "Key",
|
keyFields: "Key",
|
||||||
ifaceFields: "接口",
|
ifaceFields: "接口",
|
||||||
compareFields: "比对",
|
compareFields: "比对",
|
||||||
compareMode: "比对方式",
|
compareMode: "方式",
|
||||||
tolerance: "容差",
|
tolerance: "容差",
|
||||||
displayField: "展示",
|
displayField: "展示",
|
||||||
keyAlwaysDisplay: "Key 列始终展示",
|
keyAlwaysDisplay: "Key 列始终展示",
|
||||||
modeEq: "完全相等",
|
modeEq: "完全相等",
|
||||||
modeNumeric: "绝对偏差",
|
modeNumeric: "绝对偏差",
|
||||||
modePercent: "相对偏差 %",
|
modePercent: "相对偏差 %",
|
||||||
normalizeField: "归一化",
|
normalizeField: "归一",
|
||||||
ignoreFields: "忽略",
|
ignoreFields: "忽略",
|
||||||
field: "字段",
|
field: "字段",
|
||||||
note: "备注",
|
note: "备注",
|
||||||
|
|
@ -312,12 +319,17 @@ const zh = {
|
||||||
rowFilters: "行过滤规则",
|
rowFilters: "行过滤规则",
|
||||||
rowFiltersHint:
|
rowFiltersHint:
|
||||||
"比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。",
|
"比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。",
|
||||||
rowFiltersEmpty: "无过滤则比对全部行。可添加条件组。",
|
rowFiltersHintShort: "组内且 · 组间或 · 无过滤则比对全部行",
|
||||||
filterClear: "清空规则",
|
rowFiltersEmpty: "无过滤则比对全部行。",
|
||||||
|
filterClear: "清空",
|
||||||
filterAddOr: "添加条件组(或)",
|
filterAddOr: "添加条件组(或)",
|
||||||
filterAddAnd: "组内加条件(且)",
|
filterAddAnd: "组内加条件(且)",
|
||||||
|
filterAddOrShort: "+ 或组",
|
||||||
|
filterAddAndShort: "+ 且",
|
||||||
filterRemoveGroup: "删除本组",
|
filterRemoveGroup: "删除本组",
|
||||||
|
filterGroupN: "组 {{n}}",
|
||||||
filterOr: "或",
|
filterOr: "或",
|
||||||
|
filterAnd: "且",
|
||||||
filterField: "字段",
|
filterField: "字段",
|
||||||
filterOp: "运算",
|
filterOp: "运算",
|
||||||
filterValue: "值",
|
filterValue: "值",
|
||||||
|
|
@ -452,8 +464,13 @@ const zh = {
|
||||||
title: "割接监控",
|
title: "割接监控",
|
||||||
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
||||||
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
||||||
|
hintShort: "监控模板定判定规则;绑老/新采集与双基线 → 勾预期 → 看板评估。",
|
||||||
create: "创建任务",
|
create: "创建任务",
|
||||||
createHint: "先填任务名并绑定老/新业务监控采集任务;映射与基线可稍后在详情里补。",
|
createHint: "先填任务名并绑定老/新业务监控采集任务;映射与基线可稍后在详情里补。",
|
||||||
|
createHintShort: "任务名 + 老/新采集任务即可创建;模板、映射、基线可稍后补。",
|
||||||
|
showCreateMore: "更多:模板 / 映射 / 基线",
|
||||||
|
hideCreateMore: "收起更多",
|
||||||
|
setupHintShort: "采集任务创建后固定;此处配置监控模板、映射与双基线。",
|
||||||
createProject: "创建任务",
|
createProject: "创建任务",
|
||||||
projectName: "任务名称",
|
projectName: "任务名称",
|
||||||
projectNamePh: "例如:CN1/CN2 第1夜割接",
|
projectNamePh: "例如:CN1/CN2 第1夜割接",
|
||||||
|
|
@ -465,23 +482,27 @@ const zh = {
|
||||||
sectionPair: "已绑定采集任务",
|
sectionPair: "已绑定采集任务",
|
||||||
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
|
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
|
||||||
sectionBaseline: "基线批次",
|
sectionBaseline: "基线批次",
|
||||||
sectionBaselineHint: "老侧基线用于列出本批预期对象;新侧基线可选。",
|
sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。",
|
||||||
optionalNone: "不选",
|
optionalNone: "不选",
|
||||||
monitorTemplate: "监控模板",
|
monitorTemplate: "监控模板",
|
||||||
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。",
|
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。",
|
||||||
compareTemplate: "对比模板",
|
compareTemplate: "对比模板",
|
||||||
compareTemplateBound: "绑定对比模板:{{name}}",
|
compareTemplateBound: "绑定对比模板:{{name}}",
|
||||||
portMappingHint: "来自「业务对比」中的端口映射;没有可先不选。",
|
portMappingHint:
|
||||||
|
"有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。",
|
||||||
baselineNeedTask: "请先选择对应采集任务",
|
baselineNeedTask: "请先选择对应采集任务",
|
||||||
baselinePickHint: "从该任务的历史采集批次中选择",
|
baselinePickHint: "从该任务的历史采集批次中选择",
|
||||||
oldTask: "老设备采集任务",
|
oldTask: "老设备采集任务",
|
||||||
newTask: "新设备采集任务",
|
newTask: "新设备采集任务",
|
||||||
portMapping: "端口映射",
|
portMapping: "端口映射",
|
||||||
oldBaseline: "老设备基线批次",
|
oldBaseline: "老设备基线批次",
|
||||||
newBaseline: "新设备基线批次(可选)",
|
newBaseline: "新设备基线批次",
|
||||||
needProjectFields: "请填写任务名并选择老/新采集任务",
|
needProjectFields: "请填写任务名并选择老/新采集任务",
|
||||||
needExpectPorts: "请先勾选本批预期迁移的对象",
|
needExpectPorts: "请先勾选本批预期迁移的对象",
|
||||||
needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象",
|
needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象",
|
||||||
|
needBothBaselines: "请先保存老、新两侧基线批次后再评估",
|
||||||
|
errOldBaselineRequired: "缺少老设备基线批次",
|
||||||
|
errNewBaselineRequired: "缺少新设备基线批次",
|
||||||
needBizStateTasks: "尚无业务监控任务,请先创建:",
|
needBizStateTasks: "尚无业务监控任务,请先创建:",
|
||||||
projectCreated: "任务已创建",
|
projectCreated: "任务已创建",
|
||||||
projects: "任务",
|
projects: "任务",
|
||||||
|
|
@ -529,6 +550,15 @@ const zh = {
|
||||||
progress: "进度",
|
progress: "进度",
|
||||||
anomaly: "异常",
|
anomaly: "异常",
|
||||||
onlyExpect: "仅看预期对象",
|
onlyExpect: "仅看预期对象",
|
||||||
|
onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期",
|
||||||
|
pinCurrentBatches: "当前采集批次",
|
||||||
|
pinOldBatch: "老侧当前批",
|
||||||
|
pinNewBatch: "新侧当前批",
|
||||||
|
pinLatest: "自动最新成功批",
|
||||||
|
runHistory: "判定历史",
|
||||||
|
runLatest: "最新一次",
|
||||||
|
colRuleHit: "命中规则",
|
||||||
|
mappingBound: "映射已保存并绑定到本任务",
|
||||||
allMetrics: "全部监控项",
|
allMetrics: "全部监控项",
|
||||||
colMetric: "监控项",
|
colMetric: "监控项",
|
||||||
colKey: "对象",
|
colKey: "对象",
|
||||||
|
|
@ -543,6 +573,7 @@ const zh = {
|
||||||
colNew: "新侧",
|
colNew: "新侧",
|
||||||
colVerdict: "判定",
|
colVerdict: "判定",
|
||||||
emptyDiffs: "暂无明细,请先「立即判定」",
|
emptyDiffs: "暂无明细,请先「立即判定」",
|
||||||
|
emptyRed: "暂无红单",
|
||||||
verdictMigrated: "已迁移",
|
verdictMigrated: "已迁移",
|
||||||
verdictMigrating: "迁移中",
|
verdictMigrating: "迁移中",
|
||||||
verdictLost: "丢失",
|
verdictLost: "丢失",
|
||||||
|
|
@ -563,6 +594,7 @@ const zh = {
|
||||||
acceptFailed: "本批验收未通过(红单 {{n}},可带红开下一批)",
|
acceptFailed: "本批验收未通过(红单 {{n}},可带红开下一批)",
|
||||||
acceptPassedShort: "通过",
|
acceptPassedShort: "通过",
|
||||||
acceptFailedShort: "未通过",
|
acceptFailedShort: "未通过",
|
||||||
|
newBaselineMissingBanner: "这些表没有可用的新侧基线内容,判定不可信:{{metrics}}",
|
||||||
acceptCarryHint: "验收不过不拦截:可新建下一批继续;红单保留留痕,可手动关闭。",
|
acceptCarryHint: "验收不过不拦截:可新建下一批继续;红单保留留痕,可手动关闭。",
|
||||||
openRedHint: "任务仍有 {{n}} 条未关闭红单(带红继续)",
|
openRedHint: "任务仍有 {{n}} 条未关闭红单(带红继续)",
|
||||||
redTitle: "红单留痕",
|
redTitle: "红单留痕",
|
||||||
|
|
@ -597,7 +629,7 @@ const zh = {
|
||||||
needName: "请填写名称",
|
needName: "请填写名称",
|
||||||
defaultsInvalid: "defaults JSON 无效",
|
defaultsInvalid: "defaults JSON 无效",
|
||||||
overridesInvalid: "sheet_overrides JSON 无效",
|
overridesInvalid: "sheet_overrides JSON 无效",
|
||||||
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置状态字段与成功模式。",
|
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。",
|
||||||
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
|
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
|
||||||
pickCompare: "选择对比模板…",
|
pickCompare: "选择对比模板…",
|
||||||
openCompare: "管理对比模板",
|
openCompare: "管理对比模板",
|
||||||
|
|
@ -616,26 +648,84 @@ const zh = {
|
||||||
ooeIgnore: "忽略(一律未涉及)",
|
ooeIgnore: "忽略(一律未涉及)",
|
||||||
sheetRules: "各表搬迁判定",
|
sheetRules: "各表搬迁判定",
|
||||||
applyAllPresets: "全部套用推荐预设",
|
applyAllPresets: "全部套用推荐预设",
|
||||||
|
applyAllPresetsShort: "全部预设",
|
||||||
|
sheetsUnit: "张表",
|
||||||
sheetHowReadonly: "对比模板(只读)",
|
sheetHowReadonly: "对比模板(只读)",
|
||||||
iface: "接口",
|
iface: "接口",
|
||||||
compare: "比对",
|
compare: "比对",
|
||||||
presenceOnly: "仅存在性",
|
presenceOnly: "仅存在性",
|
||||||
editInCompare: "去对比模板改 HOW",
|
editInCompare: "改 HOW",
|
||||||
presetAuto: "套用本表推荐预设",
|
presetAuto: "推荐预设",
|
||||||
presetSkip: "跳过双端判定",
|
presetSkip: "跳过本表",
|
||||||
skipDual: "本表不参与 dual 判定",
|
skipDual: "本表不参与双端判定",
|
||||||
skipDualHint: "该 sheet 将在割接判定中跳过(适合纯计数器等值校验类,改用业务对比 Job)。",
|
skipDualHint: "该表在割接判定中跳过(计数器等请用业务对比 Job)。",
|
||||||
statusFields: "状态字段",
|
statusFields: "用哪些字段判断 up/down",
|
||||||
statusFieldsHint: "用于把当前行归类为 up/down;端口勾选 admin/phy/prot,BGP 勾选 state。",
|
statusFieldsHint: "端口勾选 admin/phy/prot;BGP 勾选 state;ARP 可不勾(只看条目有无)。",
|
||||||
downValues: "Down 取值",
|
statusOptionalHint: "未勾选状态字段时,只按「消失 / 新增」等存在性判定。",
|
||||||
upValues: "Up 取值",
|
downValues: "算 down 的取值",
|
||||||
|
upValues: "算 up 的取值",
|
||||||
noFields: "无可用字段(先在对比模板勾选 metric)",
|
noFields: "无可用字段(先在对比模板勾选 metric)",
|
||||||
successPatterns: "成功模式(老侧任意命中 × 新侧任意命中)",
|
successPatterns: "成功规则(老侧组间或 × 新侧组间或)",
|
||||||
successPatternsHint: "例如端口:老 removed/down + 新 added/up;ARP:老 removed + 新 added;BGP:老 down(idle) + 新 up(established)。",
|
successPatternsShort: "成功规则",
|
||||||
oldSideAny: "老侧(任意命中)",
|
successPatternsHint: "存在性看条目有无;值变更绑定字段并选择怎么变。组内是且,组之间是或。",
|
||||||
newSideAny: "新侧(任意命中)",
|
successPatternsHintShort: "组内且 · 组间或 · 老侧命中一组且新侧命中一组 → 成功",
|
||||||
addSuccessRow: "添加成功模式",
|
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 的对比模板。",
|
needCompareFirst: "请先选择带 sheets 的对比模板。",
|
||||||
|
showMore: "更多:备注 / 高频采集 / JSON",
|
||||||
|
hideMore: "收起更多",
|
||||||
showJson: "高级:编辑 JSON",
|
showJson: "高级:编辑 JSON",
|
||||||
hideJson: "回到可视化编辑",
|
hideJson: "回到可视化编辑",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10030,6 +10030,764 @@ html.login-page--paused .login-page__flare {
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.bs-bind-panel {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
|
|
||||||
|
|
@ -525,6 +525,152 @@ function emptyLeaf(field = ""): RowFilter {
|
||||||
return { field, op: "eq", value: "" };
|
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, string | number>) => string;
|
||||||
|
onChange: (next: RowFilter[][]) => void;
|
||||||
|
}) {
|
||||||
|
const setGroups = onChange;
|
||||||
|
return (
|
||||||
|
<div className="ct-filters">
|
||||||
|
<p className="muted ct-filters__hint">{t("bizCompare.rowFiltersHintShort")}</p>
|
||||||
|
<div className="ct-filters__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-link-btn"
|
||||||
|
onClick={() => setGroups([...(groups.length ? groups : []), [emptyLeaf(fieldOpts[0] || "")]])}
|
||||||
|
>
|
||||||
|
{t("bizCompare.filterAddOrShort")}
|
||||||
|
</button>
|
||||||
|
{groups.length ? (
|
||||||
|
<button type="button" className="mt-link-btn" onClick={() => setGroups([])}>
|
||||||
|
{t("bizCompare.filterClear")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!groups.length ? <p className="muted ct-filters__empty">{t("bizCompare.rowFiltersEmpty")}</p> : null}
|
||||||
|
{groups.map((group, gi) => (
|
||||||
|
<div key={gi} className="mt-or-wrap">
|
||||||
|
{gi > 0 ? <div className="mt-logic-badge mt-logic-badge--or">{t("bizCompare.filterOr")}</div> : null}
|
||||||
|
<div className="mt-and-box">
|
||||||
|
<div className="mt-and-box__head">
|
||||||
|
<span className="muted">{t("bizCompare.filterGroupN", { n: String(gi + 1) })}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-icon-btn"
|
||||||
|
aria-label={t("bizCompare.filterRemoveGroup")}
|
||||||
|
onClick={() => setGroups(groups.filter((_, i) => i !== gi))}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{group.map((filt, fi) => (
|
||||||
|
<div key={fi}>
|
||||||
|
{fi > 0 ? (
|
||||||
|
<div className="mt-logic-badge mt-logic-badge--and">{t("bizCompare.filterAnd")}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="ct-filter-row">
|
||||||
|
<select
|
||||||
|
className="mt-select"
|
||||||
|
value={filt.field || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
|
next[gi][fi] = { ...next[gi][fi], field: e.target.value };
|
||||||
|
setGroups(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("bizCompare.filterField")}</option>
|
||||||
|
{fieldOpts.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="mt-select"
|
||||||
|
value={filt.op || "eq"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
|
next[gi][fi] = { ...next[gi][fi], op: e.target.value };
|
||||||
|
setGroups(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FILTER_OPS.map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{op}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
Array.isArray(filt.value) ? filt.value.join(",") : String(filt.value ?? "")
|
||||||
|
}
|
||||||
|
placeholder={t("bizCompare.filterValue")}
|
||||||
|
isDisabled={["empty", "not_empty", "age_timer"].includes(filt.op || "")}
|
||||||
|
onChange={(e) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-icon-btn"
|
||||||
|
aria-label={t("bizCompare.filterRemoveGroup")}
|
||||||
|
onClick={() => {
|
||||||
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
|
next[gi].splice(fi, 1);
|
||||||
|
setGroups(next.filter((g) => g.length));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
||||||
|
next[gi].push(emptyLeaf(fieldOpts[0] || ""));
|
||||||
|
setGroups(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("bizCompare.filterAddAndShort")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function metricLabel(id: string) {
|
function metricLabel(id: string) {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
@ -554,6 +700,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [tplNote, setTplNote] = useState("");
|
const [tplNote, setTplNote] = useState("");
|
||||||
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
|
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
|
||||||
const [tplSheetIdx, setTplSheetIdx] = useState(0);
|
const [tplSheetIdx, setTplSheetIdx] = useState(0);
|
||||||
|
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
|
||||||
|
const [showMetricPicker, setShowMetricPicker] = useState(true);
|
||||||
|
|
||||||
// job create / detail
|
// job create / detail
|
||||||
const [jobCreateOpen, setJobCreateOpen] = useState(false);
|
const [jobCreateOpen, setJobCreateOpen] = useState(false);
|
||||||
|
|
@ -570,7 +718,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [afterBatchId, setAfterBatchId] = useState("");
|
const [afterBatchId, setAfterBatchId] = useState("");
|
||||||
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
||||||
const [mapName, setMapName] = useState("端口映射");
|
const [mapName, setMapName] = useState("端口映射");
|
||||||
const [mapText, setMapText] = useState("before_if,after_if\n");
|
const [mapText, setMapText] = useState("");
|
||||||
const [validateOut, setValidateOut] = useState<any>(null);
|
const [validateOut, setValidateOut] = useState<any>(null);
|
||||||
const [runs, setRuns] = useState<any[]>([]);
|
const [runs, setRuns] = useState<any[]>([]);
|
||||||
const [runDetail, setRunDetail] = useState<any>(null);
|
const [runDetail, setRunDetail] = useState<any>(null);
|
||||||
|
|
@ -956,6 +1104,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setTplNote("");
|
setTplNote("");
|
||||||
setTplSheets([]);
|
setTplSheets([]);
|
||||||
setTplSheetIdx(0);
|
setTplSheetIdx(0);
|
||||||
|
setTplPaneTab("fields");
|
||||||
|
setShowMetricPicker(true);
|
||||||
setTplOpen(true);
|
setTplOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -966,6 +1116,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setTplNote(tpl.note || "");
|
setTplNote(tpl.note || "");
|
||||||
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
|
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
|
||||||
setTplSheetIdx(0);
|
setTplSheetIdx(0);
|
||||||
|
setTplPaneTab("fields");
|
||||||
|
setShowMetricPicker(!sheets.length);
|
||||||
setTplOpen(true);
|
setTplOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -976,6 +1128,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
if (prev.some((s) => s.metric_id === metricId)) return prev;
|
if (prev.some((s) => s.metric_id === metricId)) return prev;
|
||||||
const next = [...prev, defaultSheetForMetric(schema, metricId)];
|
const next = [...prev, defaultSheetForMetric(schema, metricId)];
|
||||||
setTplSheetIdx(next.length - 1);
|
setTplSheetIdx(next.length - 1);
|
||||||
|
if (!prev.length) setShowMetricPicker(false);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -1141,7 +1294,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
setMappingId(id);
|
setMappingId(id);
|
||||||
setMapName(m.name);
|
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 () => {
|
const saveMapping = async () => {
|
||||||
|
|
@ -1196,7 +1349,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
|
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
|
||||||
else {
|
else {
|
||||||
setMapName(t("bizCompare.mapping"));
|
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);
|
if (id) loadMappingText(id);
|
||||||
else {
|
else {
|
||||||
setMappingId("");
|
setMappingId("");
|
||||||
setMapText("before_if,after_if\n");
|
setMapText("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -1657,473 +1810,343 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template editor */}
|
{/* Template editor */}
|
||||||
<AppModalShell open={tplOpen} onClose={() => setTplOpen(false)} size="lg" className="app-heroui-modal--xl">
|
<AppModalShell open={tplOpen} onClose={() => setTplOpen(false)} size="cover">
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Heading>
|
<Modal.Heading>
|
||||||
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
{tplEditId ? t("bizCompare.editTemplate") : t("bizCompare.newTemplate")}
|
||||||
</Modal.Heading>
|
</Modal.Heading>
|
||||||
<Modal.CloseTrigger />
|
<Modal.CloseTrigger />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="flex flex-col gap-3">
|
<Modal.Body className="flex flex-col gap-3 ct-editor">
|
||||||
<label className="ui-field ui-field--full">
|
<div className="ct-editor__basics">
|
||||||
<span className="ui-field__label">{t("bizCompare.colName")}</span>
|
<Input
|
||||||
<Input value={tplName} onChange={(e) => setTplName(e.target.value)} />
|
value={tplName}
|
||||||
</label>
|
onChange={(e) => setTplName(e.target.value)}
|
||||||
<label className="ui-field ui-field--full">
|
placeholder={t("bizCompare.colName")}
|
||||||
<span className="ui-field__label">{t("bizCompare.note")}</span>
|
aria-label={t("bizCompare.colName")}
|
||||||
<Input value={tplNote} onChange={(e) => setTplNote(e.target.value)} />
|
/>
|
||||||
</label>
|
<Input
|
||||||
|
value={tplNote}
|
||||||
|
onChange={(e) => setTplNote(e.target.value)}
|
||||||
|
placeholder={t("bizCompare.note")}
|
||||||
|
aria-label={t("bizCompare.note")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ui-field ui-field--full">
|
<div className="ct-editor__pick">
|
||||||
<span className="ui-field__label">{t("bizCompare.stepPickMetrics")}</span>
|
<div className="ct-editor__pick-head">
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<span className="ct-editor__pick-title">
|
||||||
{t("bizCompare.pickMetricsHint")}
|
{t("bizCompare.stepPickMetrics")}
|
||||||
</p>
|
<span className="muted" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||||
<div
|
{t("bizCompare.selectedCount", { n: String(tplSheets.length) })}
|
||||||
className="pt-list-table-wrap"
|
</span>
|
||||||
style={{ maxHeight: 220, overflow: "auto", border: "1px solid var(--border, #ddd)", borderRadius: 6 }}
|
</span>
|
||||||
>
|
<Button size="sm" variant="ghost" onPress={() => setShowMetricPicker((v) => !v)}>
|
||||||
<table className="data-table pt-list-table">
|
{showMetricPicker ? t("bizCompare.hideMetrics") : t("bizCompare.showMetrics")}
|
||||||
<thead>
|
</Button>
|
||||||
<tr>
|
</div>
|
||||||
<th style={{ width: 48 }} />
|
{showMetricPicker ? (
|
||||||
<th>{t("bizCompare.addMetric")}</th>
|
<>
|
||||||
<th>{t("bizCompare.colFields")}</th>
|
<p className="muted ct-editor__pick-hint">{t("bizCompare.pickMetricsHintShort")}</p>
|
||||||
</tr>
|
<div className="ct-metric-chips">
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{metrics.map((m) => {
|
{metrics.map((m) => {
|
||||||
const checked = tplSheets.some((s) => s.metric_id === m.metric_id);
|
const checked = tplSheets.some((s) => s.metric_id === m.metric_id);
|
||||||
return (
|
return (
|
||||||
<tr key={m.metric_id}>
|
<button
|
||||||
<td>
|
key={m.metric_id}
|
||||||
<input
|
type="button"
|
||||||
type="checkbox"
|
className={`mt-chip${checked ? " is-on" : ""}`}
|
||||||
checked={checked}
|
aria-pressed={checked}
|
||||||
onChange={(e) => toggleTplMetric(m.metric_id, e.target.checked)}
|
onClick={() => toggleTplMetric(m.metric_id, !checked)}
|
||||||
/>
|
>
|
||||||
</td>
|
{m.metric_id}
|
||||||
<td>
|
<span className="ct-metric-chip__n">{m.fields?.length || 0}</span>
|
||||||
<code>{m.metric_id}</code>
|
</button>
|
||||||
</td>
|
|
||||||
<td className="muted">{m.fields?.length || 0}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!metrics.length ? (
|
{!metrics.length ? (
|
||||||
<tr>
|
<span className="muted">{t("bizCompare.noMetricFields")}</span>
|
||||||
<td colSpan={3}>
|
|
||||||
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</>
|
||||||
</div>
|
) : null}
|
||||||
<p className="muted">
|
|
||||||
{t("bizCompare.selectedCount", { n: String(tplSheets.length) })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ui-field ui-field--full">
|
|
||||||
<span className="ui-field__label">{t("bizCompare.stepConfigRules")}</span>
|
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
|
||||||
{t("bizCompare.templateHint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!tplSheets.length ? (
|
{!tplSheets.length ? (
|
||||||
<div className="pt-list-empty">{t("bizCompare.noMetricsYet")}</div>
|
<div className="pt-list-empty">{t("bizCompare.noMetricsYet")}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="ct-editor__main">
|
||||||
<div className="bs-sheet-tabs" role="tablist">
|
<aside className="ct-editor__nav" aria-label={t("bizCompare.colSheets")}>
|
||||||
{tplSheets.map((s, i) => (
|
<div className="ct-editor__nav-head">
|
||||||
<button
|
<span>{t("bizCompare.colSheets")}</span>
|
||||||
key={s.metric_id}
|
</div>
|
||||||
type="button"
|
<div className="ct-editor__nav-list" role="tablist">
|
||||||
className={`bs-sheet-tab${i === tplSheetIdx ? " is-active" : ""}`}
|
{tplSheets.map((s, i) => {
|
||||||
onClick={() => setTplSheetIdx(i)}
|
const filterN = toOrGroups(s.row_filters || []).length;
|
||||||
>
|
return (
|
||||||
{s.metric_id}
|
<button
|
||||||
{!s.compare_fields.length ? (
|
key={s.metric_id}
|
||||||
<span className="bs-sheet-tab__count">{t("bizCompare.presenceShort")}</span>
|
type="button"
|
||||||
) : (
|
role="tab"
|
||||||
<span className="bs-sheet-tab__count">{s.compare_fields.length}</span>
|
aria-selected={tplSheetIdx === i}
|
||||||
)}
|
className={`ct-editor__nav-item${tplSheetIdx === i ? " is-active" : ""}`}
|
||||||
{(s.row_filters || []).length ? (
|
onClick={() => {
|
||||||
<span className="bs-sheet-tab__count">F{(s.row_filters || []).length}</span>
|
setTplSheetIdx(i);
|
||||||
) : null}
|
setTplPaneTab("fields");
|
||||||
</button>
|
}}
|
||||||
))}
|
>
|
||||||
</div>
|
<span className="ct-editor__nav-name">{s.metric_id}</span>
|
||||||
|
<span className="ct-editor__nav-tag">
|
||||||
|
{!s.compare_fields.length
|
||||||
|
? t("bizCompare.presenceShort")
|
||||||
|
: t("bizCompare.tagCompareN", { n: String(s.compare_fields.length) })}
|
||||||
|
{filterN ? ` · F${filterN}` : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
{activeTplSheet ? (
|
{activeTplSheet ? (
|
||||||
<>
|
<div className="ct-editor__pane">
|
||||||
<div className="filter-inline" style={{ justifyContent: "space-between" }}>
|
<div className="ct-editor__pane-head">
|
||||||
<span className="muted">
|
<div>
|
||||||
{activeTplSheet.compare_fields.length
|
<div className="ct-editor__metric">{activeTplSheet.metric_id}</div>
|
||||||
? t("bizCompare.modeFields")
|
<div className="ct-editor__meta muted">
|
||||||
: t("bizCompare.modePresence")}
|
{activeTplSheet.compare_fields.length
|
||||||
{(activeTplSheet.row_filters || []).length
|
? t("bizCompare.modeFieldsShort")
|
||||||
? ` · ${(activeTplSheet.row_filters || []).length} filter(s)`
|
: t("bizCompare.presenceShort")}
|
||||||
: ""}
|
{" · "}
|
||||||
</span>
|
Key {(activeTplSheet.key_fields || []).join(" · ") || "—"}
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
variant="ghost"
|
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
|
||||||
onPress={() => removeTplMetric(tplSheetIdx)}
|
|
||||||
>
|
|
||||||
{t("bizCompare.removeSheet")}
|
{t("bizCompare.removeSheet")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-field ui-field--full">
|
<div className="mt-rule-tabs" role="tablist">
|
||||||
<span className="ui-field__label">{t("bizCompare.rowFilters")}</span>
|
<button
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
type="button"
|
||||||
{t("bizCompare.rowFiltersHint")}
|
role="tab"
|
||||||
</p>
|
aria-selected={tplPaneTab === "fields"}
|
||||||
{(() => {
|
className={`mt-rule-tab${tplPaneTab === "fields" ? " is-active" : ""}`}
|
||||||
const groups = toOrGroups(activeTplSheet.row_filters || []);
|
onClick={() => setTplPaneTab("fields")}
|
||||||
const setGroups = (next: RowFilter[][]) =>
|
>
|
||||||
updateActiveSheet({ row_filters: fromOrGroups(next) });
|
{t("bizCompare.tabFields")}
|
||||||
const fieldOpts = activeTplFields.map((f) => f.name);
|
<span className="mt-rule-tab__n">{activeTplFields.length}</span>
|
||||||
const opOpts = [
|
</button>
|
||||||
"eq",
|
<button
|
||||||
"ne",
|
type="button"
|
||||||
"in",
|
role="tab"
|
||||||
"not_in",
|
aria-selected={tplPaneTab === "filters"}
|
||||||
"empty",
|
className={`mt-rule-tab${tplPaneTab === "filters" ? " is-active" : ""}`}
|
||||||
"not_empty",
|
onClick={() => setTplPaneTab("filters")}
|
||||||
"regex",
|
>
|
||||||
"age_timer",
|
{t("bizCompare.tabFilters")}
|
||||||
];
|
<span className="mt-rule-tab__n">
|
||||||
return (
|
{toOrGroups(activeTplSheet.row_filters || []).length}
|
||||||
<>
|
</span>
|
||||||
<div className="filter-inline" style={{ flexWrap: "wrap", gap: 8 }}>
|
</button>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onPress={() => {
|
|
||||||
const g = groups.length ? groups : [];
|
|
||||||
setGroups([
|
|
||||||
...g,
|
|
||||||
[emptyLeaf(fieldOpts[0] || "")],
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("bizCompare.filterAddOr")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() => setGroups([])}
|
|
||||||
>
|
|
||||||
{t("bizCompare.filterClear")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{!groups.length ? (
|
|
||||||
<p className="muted">{t("bizCompare.rowFiltersEmpty")}</p>
|
|
||||||
) : null}
|
|
||||||
{groups.map((group, gi) => (
|
|
||||||
<div
|
|
||||||
key={gi}
|
|
||||||
style={{
|
|
||||||
marginTop: 10,
|
|
||||||
padding: "8px 10px",
|
|
||||||
border: "1px solid var(--border, #ddd)",
|
|
||||||
borderRadius: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{gi > 0 ? (
|
|
||||||
<div className="muted" style={{ marginBottom: 6 }}>
|
|
||||||
{t("bizCompare.filterOr")}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{group.map((filt, fi) => (
|
|
||||||
<div key={fi} className="filter-inline" style={{ marginTop: fi ? 6 : 0 }}>
|
|
||||||
<select
|
|
||||||
value={filt.field || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
|
||||||
next[gi][fi] = { ...next[gi][fi], field: e.target.value };
|
|
||||||
setGroups(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t("bizCompare.filterField")}</option>
|
|
||||||
{fieldOpts.map((name) => (
|
|
||||||
<option key={name} value={name}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={filt.op || "eq"}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
|
||||||
next[gi][fi] = { ...next[gi][fi], op: e.target.value };
|
|
||||||
setGroups(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opOpts.map((op) => (
|
|
||||||
<option key={op} value={op}>
|
|
||||||
{op}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Input
|
|
||||||
value={
|
|
||||||
Array.isArray(filt.value)
|
|
||||||
? filt.value.join(",")
|
|
||||||
: String(filt.value ?? "")
|
|
||||||
}
|
|
||||||
placeholder={t("bizCompare.filterValue")}
|
|
||||||
isDisabled={["empty", "not_empty", "age_timer"].includes(
|
|
||||||
filt.op || "",
|
|
||||||
)}
|
|
||||||
onChange={(e) => {
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() => {
|
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
|
||||||
next[gi].splice(fi, 1);
|
|
||||||
setGroups(next.filter((g) => g.length));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="filter-inline" style={{ marginTop: 8, gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() => {
|
|
||||||
const next = groups.map((g) => g.map((x) => ({ ...x })));
|
|
||||||
next[gi].push(emptyLeaf(fieldOpts[0] || ""));
|
|
||||||
setGroups(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("bizCompare.filterAddAnd")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() => {
|
|
||||||
const next = groups.filter((_, i) => i !== gi);
|
|
||||||
setGroups(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("bizCompare.filterRemoveGroup")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-list-table-wrap">
|
{tplPaneTab === "filters" ? (
|
||||||
<table className="data-table pt-list-table">
|
<TplRowFiltersEditor
|
||||||
<thead>
|
groups={toOrGroups(activeTplSheet.row_filters || [])}
|
||||||
<tr>
|
fieldOpts={activeTplFields.map((f) => f.name)}
|
||||||
<th>{t("bizCompare.field")}</th>
|
t={t}
|
||||||
<th>{t("bizCompare.keyFields")}</th>
|
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
|
||||||
<th>{t("bizCompare.ifaceFields")}</th>
|
/>
|
||||||
<th>{t("bizCompare.compareFields")}</th>
|
) : (
|
||||||
<th>{t("bizCompare.compareMode")}</th>
|
<div className="pt-list-table-wrap ct-field-table-wrap">
|
||||||
<th>{t("bizCompare.tolerance")}</th>
|
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
|
||||||
<th>{t("bizCompare.displayField")}</th>
|
<table className="data-table pt-list-table ct-field-table">
|
||||||
<th>{t("bizCompare.normalizeField")}</th>
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th>{t("bizCompare.field")}</th>
|
||||||
<tbody>
|
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
|
||||||
{activeTplFields.map((f) => {
|
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
|
||||||
const isKey = activeTplSheet.key_fields.includes(f.name);
|
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
|
||||||
const isCompare =
|
<th>{t("bizCompare.compareMode")}</th>
|
||||||
!isKey && activeTplSheet.compare_fields.includes(f.name);
|
<th>{t("bizCompare.tolerance")}</th>
|
||||||
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
<th>{t("bizCompare.displayField")}</th>
|
||||||
const rule = ruleForField(activeTplSheet, f.name);
|
<th>{t("bizCompare.normalizeField")}</th>
|
||||||
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
|
</tr>
|
||||||
const norm = rule.normalize || "none";
|
</thead>
|
||||||
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
|
<tbody>
|
||||||
return (
|
{activeTplFields.map((f) => {
|
||||||
<tr key={f.name}>
|
const isKey = activeTplSheet.key_fields.includes(f.name);
|
||||||
<td>
|
const isCompare =
|
||||||
<div className="pt-list-task-name">{f.display_name || f.name}</div>
|
!isKey && activeTplSheet.compare_fields.includes(f.name);
|
||||||
<div className="muted">
|
const isDisplay = (activeTplSheet.display_fields || []).includes(f.name);
|
||||||
<code>{f.name}</code> · {f.role}
|
const rule = ruleForField(activeTplSheet, f.name);
|
||||||
</div>
|
const cmpMode = (rule.compare || "eq").toLowerCase() || "eq";
|
||||||
</td>
|
const norm = rule.normalize || "none";
|
||||||
<td>
|
const needsTol = cmpMode === "numeric" || cmpMode === "percent";
|
||||||
<input
|
return (
|
||||||
type="checkbox"
|
<tr key={f.name}>
|
||||||
checked={isKey}
|
<td>
|
||||||
onChange={(e) =>
|
<code className="ct-field-name">{f.name}</code>
|
||||||
updateActiveSheet({
|
</td>
|
||||||
key_fields: toggleInList(
|
<td>
|
||||||
activeTplSheet.key_fields,
|
<input
|
||||||
f.name,
|
type="checkbox"
|
||||||
e.target.checked,
|
checked={isKey}
|
||||||
),
|
onChange={(e) =>
|
||||||
})
|
updateActiveSheet({
|
||||||
}
|
key_fields: toggleInList(
|
||||||
/>
|
activeTplSheet.key_fields,
|
||||||
</td>
|
f.name,
|
||||||
<td>
|
e.target.checked,
|
||||||
<input
|
),
|
||||||
type="checkbox"
|
})
|
||||||
checked={activeTplSheet.iface_fields.includes(f.name)}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
updateActiveSheet({
|
</td>
|
||||||
iface_fields: toggleInList(
|
<td>
|
||||||
activeTplSheet.iface_fields,
|
<input
|
||||||
f.name,
|
type="checkbox"
|
||||||
e.target.checked,
|
checked={activeTplSheet.iface_fields.includes(f.name)}
|
||||||
),
|
onChange={(e) =>
|
||||||
})
|
updateActiveSheet({
|
||||||
}
|
iface_fields: toggleInList(
|
||||||
/>
|
activeTplSheet.iface_fields,
|
||||||
</td>
|
f.name,
|
||||||
<td>
|
e.target.checked,
|
||||||
<input
|
),
|
||||||
type="checkbox"
|
})
|
||||||
disabled={isKey}
|
}
|
||||||
title={isKey ? t("bizCompare.keyIsIdentity") : undefined}
|
/>
|
||||||
checked={isCompare}
|
</td>
|
||||||
onChange={(e) =>
|
<td>
|
||||||
updateActiveSheet({
|
<input
|
||||||
compare_fields: toggleInList(
|
type="checkbox"
|
||||||
activeTplSheet.compare_fields,
|
disabled={isKey}
|
||||||
f.name,
|
title={isKey ? t("bizCompare.keyIsIdentity") : undefined}
|
||||||
e.target.checked,
|
checked={isCompare}
|
||||||
),
|
onChange={(e) =>
|
||||||
})
|
updateActiveSheet({
|
||||||
}
|
compare_fields: toggleInList(
|
||||||
/>
|
activeTplSheet.compare_fields,
|
||||||
</td>
|
f.name,
|
||||||
<td>
|
e.target.checked,
|
||||||
<select
|
),
|
||||||
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
})
|
||||||
disabled={!isCompare}
|
}
|
||||||
onChange={(e) =>
|
/>
|
||||||
updateActiveSheet({
|
</td>
|
||||||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
<td>
|
||||||
compare: e.target.value,
|
<select
|
||||||
tolerance:
|
className="mt-select"
|
||||||
e.target.value === "eq"
|
value={cmpMode === "ignore" ? "eq" : cmpMode}
|
||||||
? undefined
|
disabled={!isCompare}
|
||||||
: rule.tolerance ?? 0,
|
onChange={(e) =>
|
||||||
}),
|
updateActiveSheet({
|
||||||
})
|
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||||||
}
|
compare: e.target.value,
|
||||||
>
|
tolerance:
|
||||||
<option value="eq">{t("bizCompare.modeEq")}</option>
|
e.target.value === "eq" ? undefined : rule.tolerance ?? 0,
|
||||||
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
|
}),
|
||||||
<option value="percent">{t("bizCompare.modePercent")}</option>
|
})
|
||||||
</select>
|
}
|
||||||
</td>
|
>
|
||||||
<td>
|
<option value="eq">{t("bizCompare.modeEq")}</option>
|
||||||
<Input
|
<option value="numeric">{t("bizCompare.modeNumeric")}</option>
|
||||||
type="number"
|
<option value="percent">{t("bizCompare.modePercent")}</option>
|
||||||
value={
|
</select>
|
||||||
needsTol && rule.tolerance !== undefined && rule.tolerance !== null
|
</td>
|
||||||
? String(rule.tolerance)
|
<td>
|
||||||
: ""
|
<Input
|
||||||
}
|
type="number"
|
||||||
placeholder={cmpMode === "percent" ? "%" : ""}
|
value={
|
||||||
isDisabled={!isCompare || !needsTol}
|
needsTol &&
|
||||||
onChange={(e) => {
|
rule.tolerance !== undefined &&
|
||||||
const raw = e.target.value.trim();
|
rule.tolerance !== null
|
||||||
const tol = raw === "" ? undefined : Number(raw);
|
? String(rule.tolerance)
|
||||||
updateActiveSheet({
|
: ""
|
||||||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
}
|
||||||
compare: cmpMode,
|
placeholder={cmpMode === "percent" ? "%" : ""}
|
||||||
tolerance: Number.isFinite(tol as number)
|
isDisabled={!isCompare || !needsTol}
|
||||||
? (tol as number)
|
onChange={(e) => {
|
||||||
: undefined,
|
const raw = e.target.value.trim();
|
||||||
}),
|
const tol = raw === "" ? undefined : Number(raw);
|
||||||
});
|
updateActiveSheet({
|
||||||
}}
|
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||||||
/>
|
compare: cmpMode,
|
||||||
</td>
|
tolerance: Number.isFinite(tol as number)
|
||||||
<td>
|
? (tol as number)
|
||||||
<input
|
: undefined,
|
||||||
type="checkbox"
|
}),
|
||||||
disabled={isKey}
|
});
|
||||||
title={isKey ? t("bizCompare.keyAlwaysDisplay") : undefined}
|
}}
|
||||||
checked={isKey || isDisplay}
|
/>
|
||||||
onChange={(e) =>
|
</td>
|
||||||
updateActiveSheet({
|
<td>
|
||||||
display_fields: toggleInList(
|
<input
|
||||||
activeTplSheet.display_fields || [],
|
type="checkbox"
|
||||||
f.name,
|
disabled={isKey}
|
||||||
e.target.checked,
|
title={isKey ? t("bizCompare.keyAlwaysDisplay") : undefined}
|
||||||
),
|
checked={isKey || isDisplay}
|
||||||
})
|
onChange={(e) =>
|
||||||
}
|
updateActiveSheet({
|
||||||
/>
|
display_fields: toggleInList(
|
||||||
</td>
|
activeTplSheet.display_fields || [],
|
||||||
<td>
|
f.name,
|
||||||
<select
|
e.target.checked,
|
||||||
value={norm}
|
),
|
||||||
disabled={!isCompare}
|
})
|
||||||
onChange={(e) =>
|
}
|
||||||
updateActiveSheet({
|
/>
|
||||||
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
</td>
|
||||||
normalize: e.target.value,
|
<td>
|
||||||
}),
|
<select
|
||||||
})
|
className="mt-select"
|
||||||
}
|
value={norm}
|
||||||
>
|
disabled={!isCompare}
|
||||||
<option value="none">{t("bizCompare.normalizeNone")}</option>
|
onChange={(e) =>
|
||||||
<option value="lower">{t("bizCompare.normalizeLower")}</option>
|
updateActiveSheet({
|
||||||
<option value="upper">{t("bizCompare.normalizeUpper")}</option>
|
field_rules: upsertFieldRule(activeTplSheet, f.name, {
|
||||||
<option value="mac">{t("bizCompare.normalizeMac")}</option>
|
normalize: e.target.value,
|
||||||
<option value="empty_as_blank">
|
}),
|
||||||
{t("bizCompare.normalizeEmpty")}
|
})
|
||||||
</option>
|
}
|
||||||
</select>
|
>
|
||||||
|
<option value="none">{t("bizCompare.normalizeNone")}</option>
|
||||||
|
<option value="lower">{t("bizCompare.normalizeLower")}</option>
|
||||||
|
<option value="upper">{t("bizCompare.normalizeUpper")}</option>
|
||||||
|
<option value="mac">{t("bizCompare.normalizeMac")}</option>
|
||||||
|
<option value="empty_as_blank">
|
||||||
|
{t("bizCompare.normalizeEmpty")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!activeTplFields.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8}>
|
||||||
|
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
) : null}
|
||||||
})}
|
</tbody>
|
||||||
{!activeTplFields.length ? (
|
</table>
|
||||||
<tr>
|
</div>
|
||||||
<td colSpan={8}>
|
)}
|
||||||
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
<Button size="sm" variant="secondary" onPress={() => setTplOpen(false)}>
|
||||||
|
{t("bizState.cancel")}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
|
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void saveTemplate()}>
|
||||||
{t("bizCompare.saveTemplate")}
|
{t("bizCompare.saveTemplate")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onPress={() => setTplOpen(false)}>
|
|
||||||
{t("bizState.cancel")}
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</AppModalShell>
|
</AppModalShell>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -2090,6 +2090,11 @@ export const bizMigrationEvaluate = (batchId: string, body: Record<string, unkno
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const bizMigrationListRuns = (batchId: string, limit = 20) =>
|
||||||
|
apiGet<{ items?: Array<Record<string, unknown>> }>(
|
||||||
|
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs?limit=${limit}`,
|
||||||
|
);
|
||||||
|
|
||||||
export const bizMigrationGetBoard = (batchId: string, runId = "") => {
|
export const bizMigrationGetBoard = (batchId: string, runId = "") => {
|
||||||
const q = runId ? `?run_id=${encodeURIComponent(runId)}` : "";
|
const q = runId ? `?run_id=${encodeURIComponent(runId)}` : "";
|
||||||
return apiGet<Record<string, unknown>>(
|
return apiGet<Record<string, unknown>>(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue