mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 22:00:48 +08:00
feat: refine cutover operations board and monitoring performance
Protect previous wave coverage, normalize batch identities, cache shared metric evidence, and batch diff inserts. Add live continuity KPIs, server paging and filters, scoped business selection, and preserve legacy monitor rule metadata. Validation: 198 backend tests passed with 1 opt-in benchmark skipped; 14 frontend unit tests and 24 browser checks passed; TypeScript and production build passed.
This commit is contained in:
parent
fe4c1db763
commit
32d7d969f6
12 changed files with 731 additions and 151 deletions
BIN
docs/reviews/assets/cutover-monitor-board.png
Normal file
BIN
docs/reviews/assets/cutover-monitor-board.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/reviews/assets/cutover-monitor-mobile.png
Normal file
BIN
docs/reviews/assets/cutover-monitor-mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -108,11 +108,11 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
|||
if it.get("key") is not None:
|
||||
k = it.get("key")
|
||||
if isinstance(k, (list, tuple)):
|
||||
joined = KEY_SEP.join(str(p).strip() for p in k if str(p).strip())
|
||||
if joined:
|
||||
joined = KEY_SEP.join(scalar_text(p).strip() for p in k)
|
||||
if joined.strip(KEY_SEP):
|
||||
bucket.add(joined)
|
||||
else:
|
||||
s = normalize_expect_key(str(k or ""))
|
||||
s = normalize_expect_key(scalar_text(k))
|
||||
if s:
|
||||
bucket.add(s)
|
||||
keys = it.get("keys")
|
||||
|
|
@ -120,14 +120,14 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
|||
# Flat list of segments → one composite key; else each entry is a key
|
||||
# (string or nested list/tuple of segments).
|
||||
if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys):
|
||||
joined = KEY_SEP.join(str(x).strip() for x in keys if str(x).strip())
|
||||
if joined:
|
||||
joined = KEY_SEP.join(scalar_text(x).strip() for x in keys)
|
||||
if joined.strip(KEY_SEP):
|
||||
bucket.add(joined)
|
||||
else:
|
||||
for x in keys:
|
||||
if isinstance(x, (list, tuple)):
|
||||
joined = KEY_SEP.join(str(p).strip() for p in x if str(p).strip())
|
||||
if joined:
|
||||
joined = KEY_SEP.join(scalar_text(p).strip() for p in x)
|
||||
if joined.strip(KEY_SEP):
|
||||
bucket.add(joined)
|
||||
else:
|
||||
s = normalize_expect_key(str(x or ""))
|
||||
|
|
@ -272,6 +272,10 @@ def _eval_field_op(
|
|||
raw = "" if not row else scalar_text(row.get(f)).strip()
|
||||
val = raw.lower()
|
||||
o = str(op or "eq").strip().lower()
|
||||
if row and f not in row:
|
||||
# A missing parser field is not evidence for a negative condition such as
|
||||
# state != down. Only an explicit empty check may match that absence.
|
||||
return o == "empty"
|
||||
if o in ("changed", "diff"):
|
||||
base = "" if not base_row else scalar_text(base_row.get(f)).strip().lower()
|
||||
if not row:
|
||||
|
|
@ -1119,6 +1123,7 @@ def evaluate_metric_dual(
|
|||
from ..biz_state.iface_normalize import (
|
||||
apply_iface_normalize_rows,
|
||||
normalize_iface_rules,
|
||||
normalize_iface_name,
|
||||
)
|
||||
|
||||
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
|
||||
|
|
@ -1155,6 +1160,15 @@ def evaluate_metric_dual(
|
|||
)
|
||||
|
||||
norm_rules = normalize_iface_rules(iface_normalize_rules)
|
||||
def normalized_key(key: str) -> str:
|
||||
parts = key.split(KEY_SEP) if KEY_SEP in key else ([key] if len(key_fields) == 1 else key.split("|"))
|
||||
if len(parts) != len(key_fields):
|
||||
return key
|
||||
return KEY_SEP.join(normalize_iface_name(p, norm_rules) if f in iface_fields else p
|
||||
for p, f in zip(parts, key_fields))
|
||||
|
||||
expect_keys = {normalized_key(key) for key in expect_keys}
|
||||
previous_keys = {normalized_key(key) for key in previous_keys}
|
||||
old_baseline_rows = apply_iface_normalize_rows(
|
||||
[strip_netx(r) for r in raw_old_base], iface_fields=iface_fields, rules=norm_rules
|
||||
)
|
||||
|
|
@ -1238,7 +1252,7 @@ def evaluate_metric_dual(
|
|||
# Mapping describes identity; an explicit batch selection defines scope.
|
||||
# Port selection also covers business rows attached to those interfaces.
|
||||
for scope, keys in ((expect, expect_keys), (previous_expect or {}, previous_keys)):
|
||||
ports = scope.get("_ports") or set()
|
||||
ports = {normalize_iface_name(p, norm_rules) for p in scope.get("_ports") or set()}
|
||||
if ports and not keys and iface_fields:
|
||||
for ks, diff in old_idx.items():
|
||||
row = diff.get("before") or diff.get("after") or {}
|
||||
|
|
@ -1293,6 +1307,7 @@ def evaluate_metric_dual(
|
|||
progress_total = 0
|
||||
anomaly = 0
|
||||
anomaly_in_expect = 0
|
||||
steady_outside = 0
|
||||
rules_by_field = field_rule_map(field_rules)
|
||||
integrity_fields = effective_compare_fields(compare_fields, field_rules)
|
||||
# Status transitions are interpreted by the monitor's correction rules.
|
||||
|
|
@ -1390,9 +1405,10 @@ def evaluate_metric_dual(
|
|||
verdict, rule_hit = "migrated_previous", "previous_batch_received"
|
||||
elif color != "red":
|
||||
verdict, color, rule_hit = "unfinished_previous", "red", "previous_batch_not_received"
|
||||
# Retain all drift outside scope, but avoid filling the board with stable
|
||||
# unmapped inventory when a port map defines the legacy migration scope.
|
||||
if map_scoped and not in_exp and verdict == "not_involved":
|
||||
# Stable inventory is covered by the comparison and counted, but does
|
||||
# not need a full persisted evidence card on every high-frequency run.
|
||||
if not in_exp and verdict == "not_involved":
|
||||
steady_outside += 1
|
||||
continue
|
||||
if in_exp and verdict == "migrated" and color == "green":
|
||||
progress_ok += 1
|
||||
|
|
@ -1404,9 +1420,9 @@ def evaluate_metric_dual(
|
|||
# Prefer live raw rows; fall back to baseline raw. Never use port-mapped
|
||||
# synthetic "before" as the new-side display row.
|
||||
raw_old = old_raw_cur_idx.get(old_ks) or old_raw_base_idx.get(old_ks)
|
||||
raw_new = new_raw_cur_idx.get(new_ks) or new_raw_cur_idx.get(old_ks)
|
||||
raw_new = new_raw_cur_idx.get(new_ks)
|
||||
if not raw_new:
|
||||
raw_new = new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks)
|
||||
raw_new = new_raw_base_idx.get(new_ks)
|
||||
|
||||
display_old = display_key_from_raw(raw_old, key_fields) or old_ks
|
||||
display_new = display_key_from_raw(raw_new, key_fields) or (
|
||||
|
|
@ -1420,8 +1436,8 @@ def evaluate_metric_dual(
|
|||
new_disp = strip_netx(raw_new) if raw_new else {}
|
||||
old_base_raw = strip_netx(old_raw_base_idx.get(old_ks)) if old_raw_base_idx.get(old_ks) else {}
|
||||
new_base_raw = (
|
||||
strip_netx(new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
|
||||
if (new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
|
||||
strip_netx(new_raw_base_idx.get(new_ks))
|
||||
if new_raw_base_idx.get(new_ks)
|
||||
else {}
|
||||
)
|
||||
|
||||
|
|
@ -1512,6 +1528,7 @@ def evaluate_metric_dual(
|
|||
"progress_total": progress_total,
|
||||
"anomaly": anomaly,
|
||||
"anomaly_in_expect": anomaly_in_expect,
|
||||
"steady_outside": steady_outside,
|
||||
"rows": rows_out,
|
||||
"new_baseline_mode": new_baseline_mode,
|
||||
"new_baseline_missing": new_baseline_missing,
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ from typing import Any
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..biz_state.compare_rules import apply_row_filters
|
||||
from ..biz_state.compare_rules import apply_row_filters, scalar_text
|
||||
from ..biz_state.compare_service import (
|
||||
_load_metric_rows,
|
||||
_port_map_dict,
|
||||
|
|
@ -949,6 +950,13 @@ def run_evaluate(
|
|||
from ..biz_state.compare_validation import validate_template_body
|
||||
|
||||
validate_template_body({"metrics": sheets, "iface_normalize_rules": iface_norm})
|
||||
from ..biz_state.iface_normalize import normalize_iface_name
|
||||
|
||||
normalized_map = {normalize_iface_name(k, iface_norm): normalize_iface_name(v, iface_norm)
|
||||
for k, v in port_map.items()}
|
||||
if len(normalized_map) != len(port_map) or len(set(normalized_map.values())) != len(normalized_map):
|
||||
raise HTTPException(status_code=400, detail="mapping_target_ambiguous")
|
||||
port_map = normalized_map
|
||||
known_sheets = {sheet_key(s) for s in sheets}
|
||||
known_metrics = {str(s.get("metric_id") or "") for s in sheets}
|
||||
for override in sheet_overrides:
|
||||
|
|
@ -967,7 +975,8 @@ def run_evaluate(
|
|||
rows_cache: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
command_cache: dict[str, dict[str, Any]] = {}
|
||||
scope_ids = {str(s.get("metric_id") or "") for s in sheets} | {sheet_key(s) for s in sheets} | {"_ports"}
|
||||
uncovered_scope = sorted(k for k, values in expect.items() if values and k not in scope_ids)
|
||||
uncovered_scope = sorted({k for scope in (expect, previous_expect) for k, values in scope.items()
|
||||
if values and k not in scope_ids})
|
||||
|
||||
def metric_rows(bid: str, mid: str) -> list[dict[str, Any]]:
|
||||
key = (bid, mid)
|
||||
|
|
@ -1116,6 +1125,7 @@ def run_evaluate(
|
|||
"progress_total": one["progress_total"],
|
||||
"anomaly": one["anomaly"],
|
||||
"anomaly_in_expect": int(one.get("anomaly_in_expect") or 0),
|
||||
"steady_outside": int(one.get("steady_outside") or 0),
|
||||
"old_summary": one["old_summary"],
|
||||
"new_summary": one["new_summary"],
|
||||
"new_baseline_mode": one.get("new_baseline_mode") or "provided",
|
||||
|
|
@ -1189,12 +1199,14 @@ def run_evaluate(
|
|||
],
|
||||
"verdict_counts": verdict_counts,
|
||||
"anomaly_outside_expect": sum(int(c.get("anomaly") or 0) - int(c.get("anomaly_in_expect") or 0) for c in active_cards),
|
||||
"steady_outside": sum(int(c.get("steady_outside") or 0) for c in active_cards),
|
||||
"duplicate_keys": sum(int(c.get("duplicate_keys_before") or 0) + int(c.get("duplicate_keys_after") or 0) for c in active_cards),
|
||||
"uncovered_scope": uncovered_scope,
|
||||
"coverage_complete": bool(sheet_cards) and not uncovered_scope and not any(c.get("collect_skipped") or c.get("current_missing") or c.get("awaiting_peer") or c.get("duplicate_keys_before") or c.get("duplicate_keys_after") for c in sheet_cards),
|
||||
"config_snapshot": {"sheets": sheets, "sheet_overrides": sheet_overrides,
|
||||
"defaults": defaults, "iface_normalize": iface_norm,
|
||||
"port_map": port_map, "expect_set": mb.expect_set_json},
|
||||
"port_map": port_map, "expect_set": mb.expect_set_json,
|
||||
"previous_expect": {k: sorted(v) for k, v in previous_expect.items()}},
|
||||
"window_active": window_active,
|
||||
"expect_ports": sorted(expect.get("_ports") or ()),
|
||||
"old_current_task_id": current_task_id(proj, "old"),
|
||||
|
|
@ -1204,6 +1216,7 @@ def run_evaluate(
|
|||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
pending_diffs: list[dict[str, Any]] = []
|
||||
for r in all_rows:
|
||||
key_list = r.get("key") or []
|
||||
ev = r.get("evidence") if isinstance(r.get("evidence"), dict) else {}
|
||||
|
|
@ -1222,8 +1235,8 @@ def run_evaluate(
|
|||
str(r.get("metric_id") or ""),
|
||||
]
|
||||
)
|
||||
db.add(
|
||||
BizMigrationDiff(
|
||||
pending_diffs.append(
|
||||
dict(
|
||||
id=uuid4().hex,
|
||||
run_id=run.id,
|
||||
metric_id=str(r.get("metric_id") or ""),
|
||||
|
|
@ -1254,6 +1267,11 @@ def run_evaluate(
|
|||
search_text=search[:2000],
|
||||
)
|
||||
)
|
||||
if len(pending_diffs) >= 500:
|
||||
db.execute(insert(BizMigrationDiff), pending_diffs)
|
||||
pending_diffs.clear()
|
||||
if pending_diffs:
|
||||
db.execute(insert(BizMigrationDiff), pending_diffs)
|
||||
db.flush()
|
||||
if _commit:
|
||||
db.commit()
|
||||
|
|
@ -1436,6 +1454,11 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]
|
|||
collect_ids = set(resolve_collect_metric_ids(db, p))
|
||||
port_map = _port_map_dict(db, p.mapping_id)
|
||||
out_sheets: list[dict[str, Any]] = []
|
||||
from ..biz_state.iface_normalize import apply_iface_normalize, resolve_mapped_iface, normalize_iface_name
|
||||
from .evaluate import KEY_SEP
|
||||
|
||||
rows_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
port_map = {normalize_iface_name(k, _norm): normalize_iface_name(v, _norm) for k, v in port_map.items()}
|
||||
for sheet in sheets:
|
||||
mid = str(sheet.get("metric_id") or "").strip()
|
||||
sid = sheet_key(sheet)
|
||||
|
|
@ -1449,25 +1472,32 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]
|
|||
if sheet_ov.get("skip_dual"):
|
||||
continue
|
||||
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
|
||||
if mid not in rows_cache:
|
||||
rows_cache[mid] = _load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid)
|
||||
rows_raw = apply_row_filters(
|
||||
_load_metric_rows(db, batch_id=p.old_baseline_batch_id, metric_id=mid),
|
||||
rows_cache[mid],
|
||||
list(sheet.get("row_filters") or []),
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
seen_keys: set[str] = set()
|
||||
for r in rows_raw:
|
||||
keys = [str(r.get(f) or "").strip() for f in key_fields]
|
||||
normalized = apply_iface_normalize(dict(r), iface_fields=iface_fields, rules=_norm)
|
||||
keys = [scalar_text(normalized.get(f)).strip() for f in key_fields]
|
||||
if not any(keys):
|
||||
continue
|
||||
key_str = "|".join(keys)
|
||||
key_str = KEY_SEP.join(keys)
|
||||
if key_str in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key_str)
|
||||
mapped = ""
|
||||
if iface_fields and key_fields and key_fields[0] in iface_fields:
|
||||
mapped = port_map.get(keys[0]) or ""
|
||||
mapped = resolve_mapped_iface(keys[0], port_map) if port_map else ""
|
||||
items.append(
|
||||
{
|
||||
"key": key_str,
|
||||
"keys": keys,
|
||||
"mapped_to": mapped,
|
||||
"label": key_str,
|
||||
"label": "|".join(scalar_text(r.get(f)).strip() for f in key_fields),
|
||||
"row": {f: r.get(f) for f in list(dict.fromkeys([*key_fields, *iface_fields, "description", "admin", "phy", "prot"])) if f in r},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -197,6 +197,84 @@ def test_port_mapping_does_not_remap_business_key_with_same_text():
|
|||
assert out["progress_ok"] == 1
|
||||
|
||||
|
||||
def test_unmapped_same_name_target_is_not_used_as_mapped_target_evidence():
|
||||
out = evaluate(metric_id="interface_brief", key_fields=["port"], compare_fields=[],
|
||||
old_baseline_rows=[{"port": "old"}], new_current_rows=[{"port": "old"}],
|
||||
expect=parse_expect_set({"ports": ["old"]}))
|
||||
row = next(r for r in out["rows"] if r["in_expect"])
|
||||
assert row["verdict"] == "lost"
|
||||
assert row["new"] == {}
|
||||
assert row["new_key"] == ""
|
||||
assert row["match_new_key"] == "new"
|
||||
|
||||
|
||||
def test_legacy_raw_interface_expect_uses_same_alias_normalization():
|
||||
out = evaluate(metric_id="interface_brief", key_fields=["port"],
|
||||
old_baseline_rows=[{"port": "GE-1"}], new_current_rows=[{"port": "GE-2"}],
|
||||
compare_fields=[], port_map={"gei-1": "gei-2"},
|
||||
expect=parse_expect_set({"ports": ["GE-1"]}),
|
||||
iface_normalize_rules=[{"from": "GE", "to": "gei"}])
|
||||
assert out["progress_ok"] == 1
|
||||
|
||||
|
||||
def test_baseline_picker_matches_normalized_keys_but_displays_originals(db):
|
||||
ct = db.get(BizCompareTemplate, "ct")
|
||||
ct.metrics_json = [{"metric_id": "arp", "sheet_id": "s", "key_fields": ["ip", "port"], "iface_fields": ["port"]}]
|
||||
ct.iface_normalize_json = [{"from": "GE", "to": "gei"}]
|
||||
db.get(BizStateMetricRow, "row-ob").data_json = {"ip": 0, "port": "GE-1"}
|
||||
db.commit()
|
||||
item = svc.list_baseline_expect_objects(db, "p")["sheets"][0]["items"][0]
|
||||
assert item["keys"] == ["0", "gei-1"]
|
||||
assert item["label"] == "0|GE-1"
|
||||
assert parse_expect_set({"items": [{"metric_id": "arp", "key": item["keys"]}]})["arp"] == {"0\x1fgei-1"}
|
||||
|
||||
|
||||
def test_empty_composite_segments_are_preserved_and_false_is_not_blank():
|
||||
assert parse_expect_set({"items": [{"metric_id": "x", "key": [0, "", False]}]})["x"] == {"0\x1f\x1ffalse"}
|
||||
|
||||
|
||||
def test_missing_status_field_cannot_match_negative_success_condition():
|
||||
assert not _eval_field_op({"ip": "a"}, "state", "ne", "down")
|
||||
assert not _eval_field_op({"ip": "a"}, "state", "not_in", ["down"])
|
||||
assert _eval_field_op({"ip": "a"}, "state", "empty", None)
|
||||
|
||||
|
||||
def test_stable_outside_inventory_is_counted_without_repeated_evidence_cards():
|
||||
row = {"ip": "a", "mac": "good", "port": "old"}
|
||||
out = evaluate(expect={}, old_current_rows=[row], new_current_rows=[])
|
||||
assert out["rows"] == []
|
||||
assert out["steady_outside"] == 1
|
||||
assert out["anomaly"] == 0
|
||||
|
||||
|
||||
def test_removed_previous_wave_sheet_blocks_full_coverage_acceptance(db):
|
||||
db.add(BizMigrationBatch(id="prior", project_id="p", status="done",
|
||||
created_at=utcnow_naive() - timedelta(days=1),
|
||||
expect_set_json={"items": [{"sheet_id": "removed-sheet", "metric_id": "arp", "key": "old-customer"}]}))
|
||||
db.commit()
|
||||
finished = svc.finish_batch(db, "wave")
|
||||
assert not finished["accept_summary"]["passed"]
|
||||
assert finished["run"]["summary"]["uncovered_scope"] == ["removed-sheet"]
|
||||
|
||||
|
||||
def test_split_sheets_share_loaded_rows_and_command_metadata(db):
|
||||
from unittest.mock import patch
|
||||
|
||||
ct = db.get(BizCompareTemplate, "ct")
|
||||
sheet = ct.metrics_json[0]
|
||||
ct.metrics_json = [sheet, {**sheet, "sheet_id": "s2"}]
|
||||
for i in range(500):
|
||||
for bid in ("ob", "nc"):
|
||||
db.add(BizStateMetricRow(id=f"bulk-{bid}-{i}", batch_id=bid,
|
||||
batch_command_id="cmd-" + bid, metric_id="arp", data_json={"ip": f"bulk-{i}", "mac": "good"}))
|
||||
db.commit()
|
||||
with patch.object(svc, "_load_metric_rows", wraps=svc._load_metric_rows) as load, patch.object(svc, "_command_brief", wraps=svc._command_brief) as command:
|
||||
run = svc.run_evaluate(db, batch_id="wave")
|
||||
assert load.call_count == 4 # once for each (batch, metric), not once per sheet
|
||||
assert command.call_count == 2 # baseline + current metadata, independent of row count
|
||||
assert svc.list_run_diffs(db, run["id"], limit=1)["total"] == 1002
|
||||
|
||||
|
||||
def test_wave_acceptance_rolls_back_whole_transaction_on_ticket_failure(db):
|
||||
from unittest.mock import patch
|
||||
|
||||
|
|
|
|||
|
|
@ -696,6 +696,55 @@ const en = {
|
|||
colChange: "Changes",
|
||||
},
|
||||
bizMigration: {
|
||||
latestSnapshot: "Latest snapshot",
|
||||
steadyOutside: "{{n}} stable items outside scope",
|
||||
"followLive": "Follow live",
|
||||
"historySnapshot": "Historical snapshot",
|
||||
"evaluatedAt": "Evaluated",
|
||||
"refreshBoard": "Refresh board",
|
||||
"oldestSample": "Oldest sample",
|
||||
"liveOverview": "Business migration overview",
|
||||
"remaining": "Remaining items",
|
||||
"anomalyInBatch": "Current wave anomalies",
|
||||
"anomalyOutsideBatch": "Outside wave anomalies",
|
||||
"coverageUnknown": "Uncertain coverage; acceptance cannot pass",
|
||||
"coverageUnknownHint": "Resolve missing, stale or misaligned samples and duplicate keys. Healthy samples cover their own monitor items.",
|
||||
"awaitingPeer": "Awaiting target sample",
|
||||
"migratedPrevious": "Previous wave received",
|
||||
"unfinishedPrevious": "Previous wave unfinished",
|
||||
"sample_time_missing": "Sample time missing",
|
||||
"sample_skew": "Samples too far apart",
|
||||
"sample_stale": "Stale samples",
|
||||
"duplicateKeys": "Duplicate business keys",
|
||||
"uncoveredScope": "Missing scope sheets",
|
||||
"refreshFailed": "Refresh failed; showing retained snapshot:",
|
||||
"lastAcceptance": "Last acceptance",
|
||||
"diffSearch": "Search business key / verdict / status",
|
||||
"verdictFilter": "Verdict filter",
|
||||
"allVerdicts": "All verdicts",
|
||||
"warningVerdict": "In progress / warning",
|
||||
"healthyVerdict": "Received / healthy",
|
||||
"unknownVerdict": "Uncertain",
|
||||
"loadingBoard": "Updating…",
|
||||
"rowsMatched": "{{n}} matching items",
|
||||
"showOutsideAnomalies": "Show outside anomalies",
|
||||
"scopeCurrent": "Current wave",
|
||||
"scopePrevious": "Previous wave",
|
||||
"scopeOutside": "Outside scope",
|
||||
"fieldChanges": "{{n}} changed fields",
|
||||
"outsideProtection": "Protect businesses outside this wave",
|
||||
"outsideProtectionHint": "Report drift outside this wave. Continue checking migrated businesses from previous waves without counting them in current progress.",
|
||||
"reason_service_not_received": "Source departed; target not healthy",
|
||||
"reason_target_unhealthy": "Target unhealthy",
|
||||
"reason_old_service_not_in_baseline": "Business missing from source baseline",
|
||||
"reason_service_content_changed": "Received business content differs",
|
||||
"reason_old_service_still_present": "Business still on source",
|
||||
"reason_target_sample_predates_source": "Target sample predates source departure",
|
||||
"reason_previous_batch_received": "Previous wave remains healthy",
|
||||
"reason_previous_batch_not_received": "Previous wave not received",
|
||||
"reason_unmapped_same_iface": "Drift on unmapped interface",
|
||||
"reason_out_of_expect:anomaly": "Business drift outside scope",
|
||||
"reason_out_of_expect:lost": "Business lost outside scope",
|
||||
title: "Cutover monitor",
|
||||
hint: "Separate from business compare: baseline drift, expect set, dual-device verdict. Reuses collect tasks and port mappings.",
|
||||
hintPort:
|
||||
|
|
@ -862,8 +911,8 @@ const en = {
|
|||
acceptFailedShort: "Fail",
|
||||
colMapped: "Mapped new",
|
||||
colDesc: "Description",
|
||||
colOldPort: "Old port",
|
||||
colNewPort: "New port",
|
||||
colOldPort: "Old business key",
|
||||
colNewPort: "New business key",
|
||||
colOldStatus: "Old admin/phy/prot",
|
||||
colNewStatus: "New admin/phy/prot",
|
||||
colOld: "Old",
|
||||
|
|
|
|||
|
|
@ -695,6 +695,55 @@ const zh = {
|
|||
colChange: "变更",
|
||||
},
|
||||
bizMigration: {
|
||||
latestSnapshot: "最新快照",
|
||||
steadyOutside: "范围外稳定 {{n}} 条",
|
||||
"followLive": "实时跟随",
|
||||
"historySnapshot": "历史快照",
|
||||
"evaluatedAt": "最近判定",
|
||||
"refreshBoard": "刷新看板",
|
||||
"oldestSample": "最早采集",
|
||||
"liveOverview": "业务迁移概览",
|
||||
"remaining": "待迁移条目",
|
||||
"anomalyInBatch": "本批异常",
|
||||
"anomalyOutsideBatch": "范围外异常",
|
||||
"coverageUnknown": "数据待确认,不能视为验收通过",
|
||||
"coverageUnknownHint": "采集缺失、过期、不同步或键冲突需要处理;正常样本仅代表对应监控条目。",
|
||||
"awaitingPeer": "等待新侧采集确认",
|
||||
"migratedPrevious": "前批已接续",
|
||||
"unfinishedPrevious": "前批未接续",
|
||||
"sample_time_missing": "采集时间缺失",
|
||||
"sample_skew": "两侧采集间隔过大",
|
||||
"sample_stale": "采集已过期",
|
||||
"duplicateKeys": "重复业务键",
|
||||
"uncoveredScope": "范围对应表已失效",
|
||||
"refreshFailed": "获取失败,当前显示保留快照:",
|
||||
"lastAcceptance": "上次终验",
|
||||
"diffSearch": "搜索业务键 / 判定 / 状态",
|
||||
"verdictFilter": "判定筛选",
|
||||
"allVerdicts": "全部判定",
|
||||
"warningVerdict": "迁移中 / 提示",
|
||||
"healthyVerdict": "已接续 / 正常",
|
||||
"unknownVerdict": "待确认",
|
||||
"loadingBoard": "正在更新…",
|
||||
"rowsMatched": "匹配 {{n}} 条",
|
||||
"showOutsideAnomalies": "查看范围外异常",
|
||||
"scopeCurrent": "本批范围",
|
||||
"scopePrevious": "前批范围",
|
||||
"scopeOutside": "范围之外",
|
||||
"fieldChanges": "字段变化 {{n}} 项",
|
||||
"outsideProtection": "范围外业务持续保护",
|
||||
"outsideProtectionHint": "本批之外的业务漂移仍报异常;前批已迁移业务持续检查新侧健康,不计入本批进度。",
|
||||
"reason_service_not_received": "老侧退出,新侧未健康接续",
|
||||
"reason_target_unhealthy": "新侧业务状态异常",
|
||||
"reason_old_service_not_in_baseline": "老基线中未找到业务",
|
||||
"reason_service_content_changed": "接续业务内容不一致",
|
||||
"reason_old_service_still_present": "老侧业务尚未退出",
|
||||
"reason_target_sample_predates_source": "新侧样本早于老侧退出",
|
||||
"reason_previous_batch_received": "前批业务持续健康",
|
||||
"reason_previous_batch_not_received": "前批业务未正常接续",
|
||||
"reason_unmapped_same_iface": "未映射端口发生迁移变化",
|
||||
"reason_out_of_expect:anomaly": "范围外业务漂移",
|
||||
"reason_out_of_expect:lost": "范围外业务丢失",
|
||||
title: "割接监控",
|
||||
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
||||
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
||||
|
|
@ -857,8 +906,8 @@ const zh = {
|
|||
acceptFailedShort: "未过",
|
||||
colMapped: "映射新口",
|
||||
colDesc: "描述",
|
||||
colOldPort: "老端口",
|
||||
colNewPort: "新端口",
|
||||
colOldPort: "老侧业务标识",
|
||||
colNewPort: "新侧业务标识",
|
||||
colOldStatus: "老状态 admin/phy/prot",
|
||||
colNewStatus: "新状态 admin/phy/prot",
|
||||
colOld: "老侧",
|
||||
|
|
|
|||
|
|
@ -11175,6 +11175,54 @@ html.login-page--paused .login-page__flare {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Cutover operations console: readable device samples, continuity and scope. */
|
||||
.bm-live-header, .bm-live-header > div, .bm-live-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.bm-live-header { justify-content: space-between; border-bottom: 1px solid rgba(148,163,184,.25); padding-bottom: 10px; }
|
||||
.bm-live-state { font-size: 11px; padding: 3px 8px; border: 1px solid rgba(148,163,184,.35); border-radius: 3px; }
|
||||
.bm-live-state.is-live { color: #34d399; border-color: rgba(52,211,153,.45); }
|
||||
.bm-live-state.is-live::before { content: ""; display: inline-block; width: 6px; height: 6px; background: currentColor; margin-right: 6px; border-radius: 50%; }
|
||||
.bm-live-sources { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||
.bm-live-sources > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 12px; padding: 10px 12px; border: 1px solid rgba(148,163,184,.24); border-left: 3px solid #64748b; border-radius: 4px; min-width: 0; }
|
||||
.bm-live-sources > div:last-child { border-left-color: #38bdf8; }
|
||||
.bm-live-sources small { flex-basis: 100%; font-variant-numeric: tabular-nums; }
|
||||
.bm-live-sources strong { overflow-wrap: anywhere; }
|
||||
.bm-live-kpis { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; }
|
||||
.bm-live-kpis > div { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px; border: 1px solid rgba(148,163,184,.25); border-radius: 4px; }
|
||||
.bm-live-kpis span { font-size: 12px; opacity: .8; }
|
||||
.bm-live-kpis strong { font: 600 26px/1.2 ui-monospace,monospace; font-variant-numeric: tabular-nums; }
|
||||
.bm-live-kpis strong small { font-size: 13px; opacity: .7; }
|
||||
.bm-live-kpis .is-ok strong { color: #34d399; }
|
||||
.bm-live-kpis .is-bad strong { color: #f87171; }
|
||||
.bm-live-warning { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 10px 12px; border: 1px solid rgba(251,191,36,.3); border-left: 3px solid #fbbf24; border-radius: 3px; background: rgba(251,191,36,.06); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.bm-live-warning strong { flex-basis: 100%; }
|
||||
.bm-live-filters > input { flex: 1 1 240px; min-width: 0; }
|
||||
.bm-live-filters > .ui-field-select { max-width: 230px; }
|
||||
.bm-diff-row.is-red > td:first-child { box-shadow: inset 3px 0 #f87171; }
|
||||
.bm-diff-row.is-yellow > td:first-child { box-shadow: inset 3px 0 #fbbf24; }
|
||||
.bm-scope-label { display: block; font-size: 10px; opacity: .65; padding-top: 3px; }
|
||||
.bm-field-changes { font-size: 11px; margin-top: 5px; }
|
||||
.bm-field-changes summary { cursor: pointer; color: #fbbf24; }
|
||||
.bm-field-changes div { padding-top: 5px; overflow-wrap: anywhere; }
|
||||
.bm-field-changes ins { color: #f87171; text-decoration: none; }
|
||||
.bm-board .bs-cmp-main { min-width: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.bm-live-sources { grid-template-columns: 1fr; }
|
||||
.bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; }
|
||||
.bm-live-header > .muted { flex: 1 1 100%; font-size: 11px; }
|
||||
.bm-board .bs-cmp-board__toolbar { display: grid; grid-template-columns: minmax(0,1fr); }
|
||||
.bm-board .bs-id-row { min-width: 0; }
|
||||
.bm-board .bs-cmp-board__body { display: flex; flex-direction: column; min-width: 0; }
|
||||
.bm-board .bs-cmp-nav { width: 100%; max-width: none; }
|
||||
.bm-board .bs-cmp-nav__list { max-height: 180px; }
|
||||
.bm-live-filters > .ui-field-select { max-width: none; width: 100%; }
|
||||
.bm-board__table { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* Cutover create wizard */
|
||||
.bm-create {
|
||||
min-height: min(52vh, 520px);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
|
|
@ -237,6 +237,8 @@ type DiffRow = {
|
|||
old_status?: string;
|
||||
new_status?: string;
|
||||
rule_hit?: string;
|
||||
integrity_changes?: Array<{ field: string; before: unknown; after: unknown }>;
|
||||
previous_batch?: boolean;
|
||||
evidence?: {
|
||||
old?: {
|
||||
device?: { ne_name?: string; ne_ip?: string };
|
||||
|
|
@ -285,6 +287,13 @@ type SheetCard = {
|
|||
new_baseline_mode?: string;
|
||||
collect_skipped?: boolean;
|
||||
current_missing?: boolean;
|
||||
awaiting_peer?: number;
|
||||
sample_issues?: string[];
|
||||
old_collected_at?: string;
|
||||
new_collected_at?: string;
|
||||
duplicate_keys_before?: number;
|
||||
duplicate_keys_after?: number;
|
||||
age_limit_sec?: number;
|
||||
};
|
||||
type EvalRun = {
|
||||
id: string;
|
||||
|
|
@ -335,6 +344,9 @@ const VERDICT_I18N: Record<string, string> = {
|
|||
not_involved: "bizMigration.verdictNotInvolved",
|
||||
unexpected_new: "bizMigration.verdictUnexpected",
|
||||
unfinished: "bizMigration.verdictUnfinished",
|
||||
awaiting_peer: "bizMigration.awaitingPeer",
|
||||
migrated_previous: "bizMigration.migratedPrevious",
|
||||
unfinished_previous: "bizMigration.unfinishedPrevious",
|
||||
ok: "bizMigration.verdictOk",
|
||||
};
|
||||
|
||||
|
|
@ -343,13 +355,6 @@ function fmtTime(v?: string | null) {
|
|||
return formatSystemTime(v) || v;
|
||||
}
|
||||
|
||||
function taskLabel(x: TaskOpt) {
|
||||
const base = `${x.ne_name || x.id} (${x.ne_ip || "-"})`;
|
||||
const note = x.note ? ` · ${x.note}` : "";
|
||||
const iv = x.interval_sec ? ` · ${x.interval_sec}s` : "";
|
||||
return `${base}${note}${iv}`;
|
||||
}
|
||||
|
||||
export function BizMigrationPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
|
|
@ -416,11 +421,19 @@ export function BizMigrationPage() {
|
|||
const [board, setBoard] = useState<{
|
||||
run?: {
|
||||
id: string;
|
||||
created_at?: string;
|
||||
purpose?: string;
|
||||
summary?: {
|
||||
sheet_cards?: SheetCard[];
|
||||
progress?: { ok: number; total: number };
|
||||
anomaly?: number;
|
||||
expect_ports?: string[];
|
||||
coverage_complete?: boolean;
|
||||
anomaly_outside_expect?: number;
|
||||
steady_outside?: number;
|
||||
verdict_counts?: Record<string, number>;
|
||||
duplicate_keys?: number;
|
||||
uncovered_scope?: string[];
|
||||
};
|
||||
} | null;
|
||||
} | null>(null);
|
||||
|
|
@ -432,6 +445,7 @@ export function BizMigrationPage() {
|
|||
const [expectMetricId, setExpectMetricId] = useState("");
|
||||
const [selectedExpectKeys, setSelectedExpectKeys] = useState<Set<string>>(new Set());
|
||||
const [portFilter, setPortFilter] = useState("");
|
||||
const [expectPage, setExpectPage] = useState(1);
|
||||
const [redTickets, setRedTickets] = useState<RedTicket[]>([]);
|
||||
const [openRedCount, setOpenRedCount] = useState(0);
|
||||
const [acceptInfo, setAcceptInfo] = useState<MigBatch["accept_summary"] | null>(null);
|
||||
|
|
@ -439,6 +453,22 @@ export function BizMigrationPage() {
|
|||
const [pinNewBatchId, setPinNewBatchId] = useState("");
|
||||
const [evalRuns, setEvalRuns] = useState<EvalRun[]>([]);
|
||||
const [selectedRunId, setSelectedRunId] = useState("");
|
||||
const [followLatest, setFollowLatest] = useState(true);
|
||||
const [diffPage, setDiffPage] = useState(1);
|
||||
const [diffTotal, setDiffTotal] = useState(0);
|
||||
const [boardColor, setBoardColor] = useState("");
|
||||
const [boardKeyword, setBoardKeyword] = useState("");
|
||||
const boardSearch = useDebouncedValue(boardKeyword, 300);
|
||||
const [boardLoading, setBoardLoading] = useState(false);
|
||||
const [boardError, setBoardError] = useState("");
|
||||
const [boardClock, setBoardClock] = useState(Date.now());
|
||||
const boardRequest = useRef(0);
|
||||
const runsRequest = useRef(0);
|
||||
const activeBatchRef = useRef(batchId);
|
||||
activeBatchRef.current = batchId;
|
||||
const activeProjectRef = useRef(projectId);
|
||||
activeProjectRef.current = projectId;
|
||||
const historyRunId = followLatest ? "" : selectedRunId;
|
||||
|
||||
const project = useMemo(
|
||||
() => projects.find((p) => p.id === projectId) || null,
|
||||
|
|
@ -446,29 +476,14 @@ export function BizMigrationPage() {
|
|||
);
|
||||
const batch = useMemo(() => batches.find((b) => b.id === batchId) || null, [batches, batchId]);
|
||||
const sheetCards = board?.run?.summary?.sheet_cards || [];
|
||||
const visibleDiffs = useMemo(() => {
|
||||
let rows = diffs;
|
||||
if (boardMetricId) {
|
||||
rows = rows.filter(
|
||||
(d) => sheetIdentity(d) === boardMetricId || d.metric_id === boardMetricId,
|
||||
);
|
||||
}
|
||||
if (onlyExpect) rows = rows.filter((d) => d.in_expect);
|
||||
return rows;
|
||||
}, [diffs, onlyExpect, boardMetricId]);
|
||||
|
||||
const boardMetricOptions = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const c of sheetCards) {
|
||||
const id = sheetIdentity(c);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
for (const d of diffs) {
|
||||
const id = sheetIdentity(d);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}, [sheetCards, diffs]);
|
||||
const visibleDiffs = diffs;
|
||||
const liveSummary = board?.run?.summary;
|
||||
const liveProgress = liveSummary?.progress;
|
||||
const monitorIsLive = followLatest && batch?.status === "active" && project?.status !== "done";
|
||||
const samplesStale = monitorIsLive && sheetCards.some((c) =>
|
||||
[c.old_collected_at, c.new_collected_at].some((at) => at &&
|
||||
boardClock - Date.parse(at) > (c.age_limit_sec || 180) * 1000));
|
||||
const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale));
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const kw = debouncedListKw.trim().toLowerCase();
|
||||
|
|
@ -519,6 +534,9 @@ export function BizMigrationPage() {
|
|||
return blob.includes(kw);
|
||||
});
|
||||
}, [expectSheets, expectMetricId, portFilter]);
|
||||
const expectPages = Math.max(1, Math.ceil(filteredExpectItems.length / 100));
|
||||
const safeExpectPage = Math.min(expectPage, expectPages);
|
||||
const visibleExpectItems = filteredExpectItems.slice((safeExpectPage - 1) * 100, safeExpectPage * 100);
|
||||
|
||||
const reloadProjects = useCallback(async () => {
|
||||
const res = await cutoverCachedGet("bizMigration:projects", () => bizMigrationListProjects(), {
|
||||
|
|
@ -603,16 +621,20 @@ export function BizMigrationPage() {
|
|||
}
|
||||
try {
|
||||
const res = await bizMigrationListBaselineExpect(pid);
|
||||
if (pid !== activeProjectRef.current) return;
|
||||
const sheets = (res.sheets || []) as ExpectSheet[];
|
||||
setExpectSheets(sheets);
|
||||
setExpectMetricId((prev) => {
|
||||
if (prev && sheets.some((s) => sheetIdentity(s) === prev)) return prev;
|
||||
return sheets[0] ? sheetIdentity(sheets[0]) : "";
|
||||
});
|
||||
} catch {
|
||||
setExpectSheets([]);
|
||||
} catch (e) {
|
||||
if (pid === activeProjectRef.current) {
|
||||
setExpectSheets([]);
|
||||
showError(formatErr(e));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [showError]);
|
||||
|
||||
const loadRedTickets = useCallback(async (pid: string) => {
|
||||
if (!pid) {
|
||||
|
|
@ -622,13 +644,13 @@ export function BizMigrationPage() {
|
|||
}
|
||||
try {
|
||||
const res = await bizMigrationListRedTickets(pid);
|
||||
if (pid !== activeProjectRef.current) return;
|
||||
setRedTickets((res.items || []) as RedTicket[]);
|
||||
setOpenRedCount(Number(res.open_count || 0));
|
||||
} catch {
|
||||
setRedTickets([]);
|
||||
setOpenRedCount(0);
|
||||
} catch (e) {
|
||||
if (pid === activeProjectRef.current) showError(formatErr(e));
|
||||
}
|
||||
}, []);
|
||||
}, [showError]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
|
|
@ -690,6 +712,12 @@ export function BizMigrationPage() {
|
|||
}, [showError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setBatchId("");
|
||||
setBatches([]);
|
||||
setAcceptInfo(null);
|
||||
setRedTickets([]);
|
||||
setOpenRedCount(0);
|
||||
if (!projectId) {
|
||||
setBatches([]);
|
||||
setBatchId("");
|
||||
|
|
@ -706,18 +734,29 @@ export function BizMigrationPage() {
|
|||
void (async () => {
|
||||
try {
|
||||
const res = await bizMigrationListBatches(projectId);
|
||||
if (cancelled || projectId !== activeProjectRef.current) return;
|
||||
const items = (res.items || []) as MigBatch[];
|
||||
setBatches(items);
|
||||
setBatchId((prev) => (items.find((b) => b.id === prev) ? prev : items[0]?.id || ""));
|
||||
setBatchId(items.find((b) => b.status === "active")?.id || items[0]?.id || "");
|
||||
await loadBaselineExpect(projectId);
|
||||
await loadRedTickets(projectId);
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (!cancelled) showError(formatErr(e));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId, showError, loadBaselineExpect, loadRedTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
setBoard(null);
|
||||
setDiffs([]);
|
||||
setDiffTotal(0);
|
||||
setDiffPage(1);
|
||||
setBoardMetricId("");
|
||||
setFollowLatest(true);
|
||||
setBoardError("");
|
||||
boardRequest.current += 1;
|
||||
runsRequest.current += 1;
|
||||
if (!batchId) {
|
||||
setBoard(null);
|
||||
setDiffs([]);
|
||||
|
|
@ -726,10 +765,33 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void loadRuns(batchId);
|
||||
void loadBoard(batchId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchId || detailTab !== "board") return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const refresh = async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
await loadBoard(batchId, historyRunId);
|
||||
if (!cancelled && followLatest) await loadRuns(batchId);
|
||||
}
|
||||
if (!cancelled && monitorIsLive) {
|
||||
timer = setTimeout(() => void refresh(), 10000);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
boardRequest.current += 1;
|
||||
runsRequest.current += 1;
|
||||
};
|
||||
// Requests are serial within each refresh cycle; filters start a new cycle.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchId, detailTab, monitorIsLive, historyRunId, followLatest, boardMetricId, onlyExpect, boardColor, boardSearch, diffPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return;
|
||||
let cancelled = false;
|
||||
|
|
@ -757,84 +819,101 @@ export function BizMigrationPage() {
|
|||
}, [createOpen, createNeSource, debouncedCreateNeKw, createNePage, createNePageSize, showError]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!createOldNe) {
|
||||
setCreateOldBatches([]);
|
||||
setCreateOldHasPortrait(false);
|
||||
setCreateOldBaselineId("");
|
||||
return;
|
||||
}
|
||||
setCreateOldBaselineId("");
|
||||
setCreateOldBatches([]);
|
||||
void bizMigrationNePortrait({
|
||||
source: neSourceOf(createOldNe),
|
||||
ne_id: createOldNe.id,
|
||||
limit: 30,
|
||||
}).then((r) => {
|
||||
if (cancelled) return;
|
||||
setCreateOldHasPortrait(Boolean(r.task?.id));
|
||||
setCreateOldBatches(
|
||||
(r.batches || []).map((x) => ({
|
||||
(r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: x.started_at || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [createOldNe]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!createNewNe) {
|
||||
setCreateNewBatches([]);
|
||||
setCreateNewHasPortrait(false);
|
||||
setCreateNewBaselineId("");
|
||||
return;
|
||||
}
|
||||
setCreateNewBaselineId("");
|
||||
setCreateNewBatches([]);
|
||||
void bizMigrationNePortrait({
|
||||
source: neSourceOf(createNewNe),
|
||||
ne_id: createNewNe.id,
|
||||
limit: 30,
|
||||
}).then((r) => {
|
||||
if (cancelled) return;
|
||||
setCreateNewHasPortrait(Boolean(r.task?.id));
|
||||
setCreateNewBatches(
|
||||
(r.batches || []).map((x) => ({
|
||||
(r.batches || []).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: x.started_at || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [createNewNe]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!project?.old_task_id) {
|
||||
setOldBatches([]);
|
||||
return;
|
||||
}
|
||||
void bizStateListBatches(project.old_task_id, 30).then((r) => {
|
||||
if (cancelled) return;
|
||||
setOldBatches(
|
||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||
((r.items || []) as Record<string, unknown>[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: (x.started_at as string) || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.old_task_id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!project?.new_task_id) {
|
||||
setNewBatches([]);
|
||||
return;
|
||||
}
|
||||
void bizStateListBatches(project.new_task_id, 30).then((r) => {
|
||||
if (cancelled) return;
|
||||
setNewBatches(
|
||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||
((r.items || []) as Record<string, unknown>[]).filter((x) => ["success", "partial"].includes(String(x.status || ""))).map((x) => ({
|
||||
id: String(x.id || ""),
|
||||
started_at: (x.started_at as string) || null,
|
||||
alias: String(x.alias || ""),
|
||||
})),
|
||||
);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.new_task_id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ids = [
|
||||
...(project?.old_hf_bindings || []).map((b) => b.task_id),
|
||||
project?.old_hf_task_id || "",
|
||||
|
|
@ -845,6 +924,7 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set<string>();
|
||||
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||
for (const r of results) {
|
||||
|
|
@ -860,10 +940,12 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
setHfOldBatches(items);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.old_hf_task_id, project?.old_hf_bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ids = [
|
||||
...(project?.new_hf_bindings || []).map((b) => b.task_id),
|
||||
project?.new_hf_task_id || "",
|
||||
|
|
@ -874,6 +956,7 @@ export function BizMigrationPage() {
|
|||
return;
|
||||
}
|
||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set<string>();
|
||||
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||
for (const r of results) {
|
||||
|
|
@ -889,7 +972,8 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
setHfNewBatches(items);
|
||||
});
|
||||
}).catch((e) => { if (!cancelled) showError(formatErr(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [project?.new_hf_task_id, project?.new_hf_bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -926,8 +1010,10 @@ export function BizMigrationPage() {
|
|||
]);
|
||||
|
||||
async function loadRuns(bid: string) {
|
||||
const request = ++runsRequest.current;
|
||||
try {
|
||||
const res = await bizMigrationListRuns(bid, 20);
|
||||
if (request !== runsRequest.current || bid !== activeBatchRef.current) return;
|
||||
const items = (res.items || []) as EvalRun[];
|
||||
setEvalRuns(items);
|
||||
setSelectedRunId((prev) => {
|
||||
|
|
@ -935,24 +1021,39 @@ export function BizMigrationPage() {
|
|||
return items[0]?.id || "";
|
||||
});
|
||||
} catch {
|
||||
setEvalRuns([]);
|
||||
if (request === runsRequest.current && bid === activeBatchRef.current) setEvalRuns([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBoard(bid: string, runId = "") {
|
||||
const request = ++boardRequest.current;
|
||||
setBoardLoading(true);
|
||||
try {
|
||||
const b = await bizMigrationGetBoard(bid, runId);
|
||||
setBoard(b as typeof board);
|
||||
if (request !== boardRequest.current || bid !== activeBatchRef.current) return;
|
||||
const rid = String((b as { run?: { id?: string } })?.run?.id || runId || "");
|
||||
if (rid) setSelectedRunId(rid);
|
||||
let rows: DiffRow[] = [];
|
||||
let total = 0;
|
||||
if (rid) {
|
||||
const d = await bizMigrationListDiffs({ runId: rid, limit: 500 });
|
||||
setDiffs((d.items || []) as DiffRow[]);
|
||||
} else {
|
||||
setDiffs([]);
|
||||
const d = await bizMigrationListDiffs({ runId: rid, sheetId: boardMetricId,
|
||||
onlyExpect, color: boardColor, kw: boardSearch, offset: (diffPage - 1) * 100, limit: 100 });
|
||||
rows = (d.items || []) as DiffRow[];
|
||||
total = Number(d.total || 0);
|
||||
}
|
||||
if (request !== boardRequest.current || bid !== activeBatchRef.current) return;
|
||||
setBoard(b as typeof board);
|
||||
setBoardClock(Date.now());
|
||||
if (rid) setSelectedRunId(rid);
|
||||
setDiffs(rows);
|
||||
setDiffTotal(total);
|
||||
setBoardError("");
|
||||
if (diffPage > Math.max(1, Math.ceil(total / 100))) setDiffPage(Math.max(1, Math.ceil(total / 100)));
|
||||
} catch (e) {
|
||||
showError(formatErr(e));
|
||||
if (request === boardRequest.current && bid === activeBatchRef.current) {
|
||||
setBoardError(formatErr(e));
|
||||
}
|
||||
} finally {
|
||||
if (request === boardRequest.current) setBoardLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1189,17 +1290,19 @@ export function BizMigrationPage() {
|
|||
async function onCreateBatch() {
|
||||
if (!projectId) return;
|
||||
// One item per selected key. A flat `keys: [a, b]` is parsed as one composite key.
|
||||
const items: Array<{ metric_id: string; sheet_id?: string; key: string }> = [];
|
||||
const items: Array<{ metric_id: string; sheet_id?: string; key: string[] }> = [];
|
||||
const ports: string[] = [];
|
||||
for (const sheet of expectSheets) {
|
||||
const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k));
|
||||
const sid = sheetIdentity(sheet);
|
||||
for (const key of keys) {
|
||||
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key });
|
||||
for (const item of sheet.items) {
|
||||
if (selectedExpectKeys.has(JSON.stringify([sid, item.key]))) {
|
||||
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key: item.keys });
|
||||
if (sheet.metric_id === "interface_brief" && sheet.key_fields.length === 1 &&
|
||||
sheet.iface_fields.includes(sheet.key_fields[0])) ports.push(item.keys[0]);
|
||||
}
|
||||
}
|
||||
if (sheet.metric_id === "interface_brief") ports.push(...keys);
|
||||
}
|
||||
if (!items.length && !ports.length) {
|
||||
if (!items.length) {
|
||||
showError(t("bizMigration.needExpectPorts"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -1405,7 +1508,8 @@ export function BizMigrationPage() {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleExpectKey(key: string) {
|
||||
function toggleExpectKey(rawKey: string) {
|
||||
const key = JSON.stringify([expectMetricId || sheetIdentity(expectSheets[0]), rawKey]);
|
||||
setSelectedExpectKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
|
|
@ -2421,7 +2525,6 @@ export function BizMigrationPage() {
|
|||
<Input
|
||||
value={batchLabel}
|
||||
onChange={(e) => setBatchLabel(e.target.value)}
|
||||
size="sm"
|
||||
placeholder={t("bizMigration.defaultBatchLabel")}
|
||||
aria-label={t("bizMigration.batchLabel")}
|
||||
style={{ minWidth: 100 }}
|
||||
|
|
@ -2492,7 +2595,7 @@ export function BizMigrationPage() {
|
|||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`ct-editor__nav-item${active ? " is-active" : ""}`}
|
||||
onClick={() => setExpectMetricId(sid)}
|
||||
onClick={() => { setExpectMetricId(sid); setExpectPage(1); }}
|
||||
>
|
||||
<span className="ct-editor__nav-name">{sheetLabel(s)}</span>
|
||||
<span className="ct-editor__nav-tag">{s.items.length}</span>
|
||||
|
|
@ -2504,9 +2607,8 @@ export function BizMigrationPage() {
|
|||
<div className="ct-editor__pane">
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
size="sm"
|
||||
value={portFilter}
|
||||
onChange={(e) => setPortFilter(e.target.value)}
|
||||
onChange={(e) => { setPortFilter(e.target.value); setExpectPage(1); }}
|
||||
placeholder={t("bizMigration.portFilterPh")}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2521,7 +2623,7 @@ export function BizMigrationPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredExpectItems.map((it) => {
|
||||
{visibleExpectItems.map((it) => {
|
||||
const row = it.row || {};
|
||||
const statusBits = ["admin", "phy", "prot"]
|
||||
.map((k) => (row[k] != null ? String(row[k]) : ""))
|
||||
|
|
@ -2539,12 +2641,14 @@ export function BizMigrationPage() {
|
|||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedExpectKeys.has(it.key)}
|
||||
aria-label={`${sheetLabel(expectSheets.find((s) => sheetIdentity(s) === expectMetricId) || expectSheets[0])} ${it.label || it.key}`}
|
||||
checked={selectedExpectKeys.has(JSON.stringify([expectMetricId || sheetIdentity(expectSheets[0]), it.key]))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleExpectKey(it.key)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<code>{it.key}</code>
|
||||
<code>{it.label || it.key}</code>
|
||||
</td>
|
||||
<td>{it.mapped_to || "—"}</td>
|
||||
<td className="bm-expect-desc">{desc || "—"}</td>
|
||||
|
|
@ -2561,6 +2665,8 @@ export function BizMigrationPage() {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ListPager page={safeExpectPage} pages={expectPages} total={filteredExpectItems.length}
|
||||
pageSize={100} onPageChange={setExpectPage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2568,8 +2674,48 @@ export function BizMigrationPage() {
|
|||
|
||||
{detailTab === "board" ? (
|
||||
<div className="bs-cmp-board bm-board">
|
||||
<div className="bm-live-header">
|
||||
<div>
|
||||
<span className={`bm-live-state${monitorIsLive ? " is-live" : ""}`}>
|
||||
{followLatest ? t(monitorIsLive ? "bizMigration.followLive" : "bizMigration.latestSnapshot") : t("bizMigration.historySnapshot")}
|
||||
</span>
|
||||
<strong>{batch?.batch_label || "—"}</strong>
|
||||
</div>
|
||||
<span className="muted">{t("bizMigration.evaluatedAt")} {fmtTime(board?.run?.created_at)}</span>
|
||||
<Button size="sm" variant="secondary" isDisabled={boardLoading || !batchId}
|
||||
onPress={() => void loadBoard(batchId, historyRunId)}>{t("bizMigration.refreshBoard")}</Button>
|
||||
</div>
|
||||
<div className="bm-live-sources">
|
||||
{(["old", "new"] as const).map((side) => {
|
||||
const device = side === "old" ? project?.old_task : project?.new_task;
|
||||
const times = sheetCards.map((c) => side === "old" ? c.old_collected_at : c.new_collected_at)
|
||||
.filter((v): v is string => Boolean(v)).sort();
|
||||
return <div key={side}>
|
||||
<span className="bm-pair-card__label">{t(side === "old" ? "bizMigration.pickOldNe" : "bizMigration.pickNewNe")}</span>
|
||||
<strong>{device?.ne_name || "—"}</strong><code>{device?.ne_ip || "—"}</code>
|
||||
<small className="muted">{t("bizMigration.oldestSample")} {fmtTime(times[0])}</small>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="bm-live-kpis" aria-label={t("bizMigration.liveOverview")}>
|
||||
<div className="is-ok"><span>{t("bizMigration.progress")}</span><strong>{liveProgress?.ok ?? 0}<small> / {liveProgress?.total ?? 0}</small></strong></div>
|
||||
<div><span>{t("bizMigration.remaining")}</span><strong>{Math.max(0, (liveProgress?.total ?? 0) - (liveProgress?.ok ?? 0))}</strong></div>
|
||||
<div className="is-bad"><span>{t("bizMigration.anomalyInBatch")}</span><strong>{Math.max(0, (liveSummary?.anomaly ?? 0) - (liveSummary?.anomaly_outside_expect ?? 0))}</strong></div>
|
||||
<div className="is-bad"><span>{t("bizMigration.anomalyOutsideBatch")}</span><strong>{liveSummary?.anomaly_outside_expect ?? 0}</strong></div>
|
||||
</div>
|
||||
{coverageUnknown ? <div className="bm-live-warning" role="status">
|
||||
<strong>{t("bizMigration.coverageUnknown")}</strong><span>{t("bizMigration.coverageUnknownHint")}</span>
|
||||
{samplesStale ? <span>{t("bizMigration.sample_stale")}</span> : null}
|
||||
{sheetCards.filter((c) => c.sample_issues?.length || c.awaiting_peer).map((c) =>
|
||||
<span key={sheetIdentity(c)}>{sheetLabel(c)}: {c.awaiting_peer ? t("bizMigration.awaitingPeer") :
|
||||
c.sample_issues?.map((issue) => t(`bizMigration.${issue}`)).join(" · ")}</span>)}
|
||||
{liveSummary?.duplicate_keys ? <span>{t("bizMigration.duplicateKeys")} {liveSummary.duplicate_keys}</span> : null}
|
||||
{liveSummary?.uncovered_scope?.length ? <span>{t("bizMigration.uncoveredScope")}: {liveSummary.uncovered_scope.join(", ")}</span> : null}
|
||||
</div> : null}
|
||||
{boardError ? <div className="form-error bm-live-warning" role="alert">{t("bizMigration.refreshFailed")} {boardError}</div> : null}
|
||||
{acceptInfo ? (
|
||||
<div className="bm-board__status">
|
||||
<span className="muted">{t("bizMigration.lastAcceptance")}</span>
|
||||
<span
|
||||
className={`bm-board__verdict${acceptInfo.passed ? " is-ok" : " is-bad"}`}
|
||||
>
|
||||
|
|
@ -2624,17 +2770,16 @@ export function BizMigrationPage() {
|
|||
{batchOptions(hfNewBatches)}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={selectedRunId}
|
||||
value={followLatest ? "" : selectedRunId}
|
||||
onChange={(e) => {
|
||||
const rid = e.target.value;
|
||||
setFollowLatest(!rid);
|
||||
setDiffPage(1);
|
||||
setSelectedRunId(rid);
|
||||
if (batchId && rid) void loadBoard(batchId, rid);
|
||||
}}
|
||||
aria-label={t("bizMigration.runHistory")}
|
||||
>
|
||||
{!evalRuns.length ? (
|
||||
<option value="">{t("bizMigration.runLatest")}</option>
|
||||
) : null}
|
||||
<option value="">{t("bizMigration.followLive")}</option>
|
||||
{evalRuns.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{fmtTime(r.created_at)} · {r.purpose || "manual"}
|
||||
|
|
@ -2659,12 +2804,29 @@ export function BizMigrationPage() {
|
|||
<input
|
||||
type="checkbox"
|
||||
checked={onlyExpect}
|
||||
onChange={(e) => setOnlyExpect(e.target.checked)}
|
||||
onChange={(e) => { setOnlyExpect(e.target.checked); setDiffPage(1); }}
|
||||
/>
|
||||
<span>{t("bizMigration.onlyExpect")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bm-live-filters">
|
||||
<Input value={boardKeyword} onChange={(e) => { setBoardKeyword(e.target.value); setDiffPage(1); }}
|
||||
placeholder={t("bizMigration.diffSearch")} aria-label={t("bizMigration.diffSearch")} />
|
||||
<FieldSelect value={boardColor} aria-label={t("bizMigration.verdictFilter")}
|
||||
onChange={(e) => { setBoardColor(e.target.value); setDiffPage(1); }}>
|
||||
<option value="">{t("bizMigration.allVerdicts")}</option>
|
||||
<option value="red">{t("bizMigration.anomaly")}</option>
|
||||
<option value="yellow">{t("bizMigration.warningVerdict")}</option>
|
||||
<option value="green">{t("bizMigration.healthyVerdict")}</option>
|
||||
<option value="gray">{t("bizMigration.unknownVerdict")}</option>
|
||||
</FieldSelect>
|
||||
<span className="muted" role="status">{boardLoading ? t("bizMigration.loadingBoard") : t("bizMigration.rowsMatched", { n: String(diffTotal) })}</span>
|
||||
{liveSummary?.steady_outside ? <span className="muted">{t("bizMigration.steadyOutside", { n: String(liveSummary.steady_outside) })}</span> : null}
|
||||
{onlyExpect && (liveSummary?.anomaly_outside_expect ?? 0) > 0 ?
|
||||
<Button size="sm" variant="danger-soft" onPress={() => { setOnlyExpect(false); setBoardColor("red"); setBoardMetricId(""); setDiffPage(1); }}>
|
||||
{t("bizMigration.showOutsideAnomalies")}
|
||||
</Button> : null}
|
||||
</div>
|
||||
{sheetCards.some((c) => c.new_baseline_missing) ? (
|
||||
<div className="form-error bm-hint">
|
||||
{t("bizMigration.newBaselineMissingBanner", {
|
||||
|
|
@ -2705,7 +2867,7 @@ export function BizMigrationPage() {
|
|||
onClick={() => setBoardSubTab("diffs")}
|
||||
>
|
||||
{t("bizMigration.board")}
|
||||
<span className="mt-rule-tab__n">{visibleDiffs.length}</span>
|
||||
<span className="mt-rule-tab__n">{diffTotal}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2784,7 +2946,7 @@ export function BizMigrationPage() {
|
|||
role="tab"
|
||||
aria-selected={!boardMetricId}
|
||||
className={`bs-cmp-nav__item${!boardMetricId ? " is-active" : ""}`}
|
||||
onClick={() => setBoardMetricId("")}
|
||||
onClick={() => { setBoardMetricId(""); setDiffPage(1); }}
|
||||
>
|
||||
<span className="bs-cmp-nav__dot" />
|
||||
<span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span>
|
||||
|
|
@ -2807,7 +2969,7 @@ export function BizMigrationPage() {
|
|||
mutedCard ? " is-clean" : hot ? " has-diff" : " is-clean"
|
||||
}`}
|
||||
style={mutedCard ? { opacity: 0.55 } : undefined}
|
||||
onClick={() => setBoardMetricId(sid)}
|
||||
onClick={() => { setBoardMetricId(sid); setDiffPage(1); }}
|
||||
>
|
||||
<span className="bs-cmp-nav__dot" />
|
||||
<span className="bs-cmp-nav__name">
|
||||
|
|
@ -2882,8 +3044,8 @@ export function BizMigrationPage() {
|
|||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return (
|
||||
<tr key={d.id} title={cmdTitle || undefined}>
|
||||
<td>{sheetLabel(d)}</td>
|
||||
<tr key={d.id} title={cmdTitle || undefined} className={`bm-diff-row is-${d.color}`}>
|
||||
<td>{sheetLabel(sheetCards.find((c) => sheetIdentity(c) === sheetIdentity(d)) || d)}<small className="bm-scope-label">{t(d.in_expect ? "bizMigration.scopeCurrent" : d.previous_batch ? "bizMigration.scopePrevious" : "bizMigration.scopeOutside")}</small></td>
|
||||
<td>
|
||||
<code>{oldKey}</code>
|
||||
</td>
|
||||
|
|
@ -2904,7 +3066,13 @@ export function BizMigrationPage() {
|
|||
{verdictLabel(d.verdict)}
|
||||
</td>
|
||||
<td className="muted">
|
||||
<code style={{ fontSize: 11 }}>{d.rule_hit || "—"}</code>
|
||||
<span>{d.rule_hit ? t(`bizMigration.reason_${d.rule_hit}`) === `bizMigration.reason_${d.rule_hit}` ? d.rule_hit : t(`bizMigration.reason_${d.rule_hit}`) : "—"}</span>
|
||||
{d.integrity_changes?.length ? <details className="bm-field-changes">
|
||||
<summary>{t("bizMigration.fieldChanges", { n: String(d.integrity_changes.length) })}</summary>
|
||||
{d.integrity_changes.map((change) => <div key={change.field}>
|
||||
<code>{change.field}</code>: <del>{String(change.before ?? "—")}</del> → <ins>{String(change.after ?? "—")}</ins>
|
||||
</div>)}
|
||||
</details> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
|
@ -2925,6 +3093,9 @@ export function BizMigrationPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{boardSubTab === "diffs" && board?.run ? <ListPager page={diffPage}
|
||||
pages={Math.max(1, Math.ceil(diffTotal / 100))} total={diffTotal} pageSize={100}
|
||||
onPageChange={setDiffPage} disabled={boardLoading} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function normalizeGroups(
|
|||
.map((g) =>
|
||||
(g || [])
|
||||
.filter(Boolean)
|
||||
.map((c) => migrateLegacyCond(c as unknown as Record<string, unknown>)),
|
||||
.map((c) => typeof c === "string" ? tokenToCond(c) : migrateLegacyCond(c as unknown as Record<string, unknown>)),
|
||||
)
|
||||
.filter((g) => g.length > 0);
|
||||
}
|
||||
|
|
@ -251,9 +251,10 @@ function normalizePat(pat: DualPat, opts?: { allowEmptySide?: boolean }): DualPa
|
|||
const old_groups = normalizeGroups(pat.old_groups, pat.old_conds, pat.old_mode, pat.old);
|
||||
const new_groups = normalizeGroups(pat.new_groups, pat.new_conds, pat.new_mode, pat.new);
|
||||
if (allowEmpty) {
|
||||
return { old_groups, new_groups };
|
||||
return { ...pat, old_groups, new_groups };
|
||||
}
|
||||
return {
|
||||
...pat,
|
||||
old_groups: old_groups.length ? old_groups : [emptyGroup("presence")],
|
||||
new_groups: new_groups.length ? new_groups : [emptyGroup("presence")],
|
||||
};
|
||||
|
|
@ -819,7 +820,7 @@ export function BizMonitorTemplatesPage() {
|
|||
const [name, setName] = useState("");
|
||||
const [compareId, setCompareId] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [outOfExpect, setOutOfExpect] = useState("strict");
|
||||
const [defaultsDraft, setDefaultsDraft] = useState<Record<string, unknown>>({});
|
||||
const [collectIds, setCollectIds] = useState<string[]>([]);
|
||||
const [overrides, setOverrides] = useState<SheetOverride[]>([]);
|
||||
const [activeSheetIdx, setActiveSheetIdx] = useState(0);
|
||||
|
|
@ -908,8 +909,6 @@ export function BizMonitorTemplatesPage() {
|
|||
return schema?.fields || [];
|
||||
}, [metricSchemas, activeMetricId]);
|
||||
|
||||
const sheetKey = useMemo(() => sheets.map((s) => sheetIdentity(s)).join("|"), [sheets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheets.length) {
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -918,18 +917,6 @@ export function BizMonitorTemplatesPage() {
|
|||
if (activeSheetIdx >= sheets.length) setActiveSheetIdx(0);
|
||||
}, [sheets, activeSheetIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheetKey) return;
|
||||
setOverrides((prev) => {
|
||||
const byId = new Map(prev.map((o) => [o.sheet_id || o.metric_id, o]));
|
||||
return sheets.map(
|
||||
(s) =>
|
||||
byId.get(sheetIdentity(s)) ||
|
||||
emptyOverride(s.metric_id, sheetIdentity(s)),
|
||||
);
|
||||
});
|
||||
}, [compareId, sheetKey]);
|
||||
|
||||
const syncOverride = (metricId: string, patch: Partial<SheetOverride>, sheetId = "") => {
|
||||
const sid = sheetId || sheetIdentity({ metric_id: metricId, sheet_id: sheetId });
|
||||
setOverrides((prev) => {
|
||||
|
|
@ -944,7 +931,7 @@ export function BizMonitorTemplatesPage() {
|
|||
setName("");
|
||||
setCompareId(compareTpls[0]?.id || "");
|
||||
setNote("");
|
||||
setOutOfExpect("strict");
|
||||
setDefaultsDraft({});
|
||||
setCollectIds([]);
|
||||
setOverrides([]);
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -960,7 +947,7 @@ export function BizMonitorTemplatesPage() {
|
|||
setName(row.name || "");
|
||||
setCompareId(row.compare_template_id || "");
|
||||
setNote(row.note || "");
|
||||
setOutOfExpect(String((row.defaults || {}).out_of_expect || "strict"));
|
||||
setDefaultsDraft({ ...(row.defaults || {}) });
|
||||
setCollectIds(Array.isArray(row.collect_metric_ids) ? [...row.collect_metric_ids] : []);
|
||||
setOverrides(Array.isArray(row.sheet_overrides) ? (row.sheet_overrides as SheetOverride[]) : []);
|
||||
setActiveSheetIdx(0);
|
||||
|
|
@ -993,7 +980,7 @@ export function BizMonitorTemplatesPage() {
|
|||
showError(t("bizMonitorTpl.needName"));
|
||||
return;
|
||||
}
|
||||
let sheet_overrides = overrides
|
||||
let sheet_overrides: SheetOverride[] = overrides
|
||||
.filter((o) => o.metric_id)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
|
|
@ -1004,11 +991,8 @@ export function BizMonitorTemplatesPage() {
|
|||
try {
|
||||
const parsed = JSON.parse(overridesText || "[]") as unknown[];
|
||||
if (!Array.isArray(parsed)) throw new Error("overrides");
|
||||
sheet_overrides = (parsed as SheetOverride[]).map((o) => ({
|
||||
...o,
|
||||
success: (o.success || []).map((p) => normalizePat(p)),
|
||||
anomaly: (o.anomaly || []).map((p) => normalizePat(p, { allowEmptySide: true })),
|
||||
}));
|
||||
// Advanced JSON is authoritative; the API validates its complete shape.
|
||||
sheet_overrides = parsed as SheetOverride[];
|
||||
} catch {
|
||||
showError(t("bizMonitorTpl.overridesInvalid"));
|
||||
return;
|
||||
|
|
@ -1018,7 +1002,7 @@ export function BizMonitorTemplatesPage() {
|
|||
name: name.trim(),
|
||||
compare_template_id: compareId,
|
||||
collect_metric_ids: collectIds,
|
||||
defaults: { dual_mode: "migrate_pair", out_of_expect: outOfExpect || "strict" },
|
||||
defaults: { ...defaultsDraft, dual_mode: "migrate_pair", out_of_expect: "strict" },
|
||||
sheet_overrides,
|
||||
note: note.trim(),
|
||||
};
|
||||
|
|
@ -1251,16 +1235,7 @@ export function BizMonitorTemplatesPage() {
|
|||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
label={t("bizMonitorTpl.outOfExpect")}
|
||||
value={outOfExpect}
|
||||
onChange={(e) => setOutOfExpect(e.target.value)}
|
||||
fullWidth
|
||||
>
|
||||
<option value="strict">{t("bizMonitorTpl.ooeStrict")}</option>
|
||||
<option value="warn">{t("bizMonitorTpl.ooeWarn")}</option>
|
||||
<option value="ignore">{t("bizMonitorTpl.ooeIgnore")}</option>
|
||||
</FieldSelect>
|
||||
<div className="bm-live-warning"><strong>{t("bizMigration.outsideProtection")}</strong><span>{t("bizMigration.outsideProtectionHint")}</span></div>
|
||||
</div>
|
||||
|
||||
{!sheets.length ? (
|
||||
|
|
|
|||
|
|
@ -2380,6 +2380,8 @@ export const bizMigrationGetBoard = (batchId: string, runId = "") => {
|
|||
export const bizMigrationListDiffs = (params: {
|
||||
runId: string;
|
||||
metricId?: string;
|
||||
sheetId?: string;
|
||||
onlyExpect?: boolean;
|
||||
verdict?: string;
|
||||
color?: string;
|
||||
kw?: string;
|
||||
|
|
@ -2388,6 +2390,8 @@ export const bizMigrationListDiffs = (params: {
|
|||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (params.metricId) p.set("metric_id", params.metricId);
|
||||
if (params.sheetId) p.set("sheet_id", params.sheetId);
|
||||
if (params.onlyExpect) p.set("only_expect", "true");
|
||||
if (params.verdict) p.set("verdict", params.verdict);
|
||||
if (params.color) p.set("color", params.color);
|
||||
if (params.kw) p.set("kw", params.kw);
|
||||
|
|
|
|||
159
web/tests/bizMigration.browser.mjs
Normal file
159
web/tests/bizMigration.browser.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Local Vite + fixture APIs only. No device connection or production database.
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(process.env.NETX_PLAYWRIGHT_MODULE || "playwright");
|
||||
const base = process.env.NETX_TEST_URL || "http://127.0.0.1:5179";
|
||||
const output = path.resolve(process.env.NETX_TEST_OUTPUT || "../docs/reviews/assets");
|
||||
await mkdir(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, channel: process.env.NETX_TEST_BROWSER || "chrome" });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1080 } });
|
||||
const errors = [], passed = [], requests = [], writes = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "";
|
||||
const now = new Date().toISOString();
|
||||
const project = { id: "p", name: "核心 PE 替换", old_task_id: "op", new_task_id: "np", old_hf_task_id: "oh", new_hf_task_id: "nh", old_baseline_batch_id: "ob", new_baseline_batch_id: "nb", monitor_template_id: "mt", hf_interval_sec: 60, status: "active", created_at: now,
|
||||
old_task: { id: "op", ne_name: "PE-OLD", ne_ip: "10.0.0.1" }, new_task: { id: "np", ne_name: "PE-NEW", ne_ip: "10.0.0.2" } };
|
||||
const second = { ...project, id: "p2", name: "第二割接项目" };
|
||||
const wave = { id: "wave", batch_label: "第一批 · 客户专线", status: "active", accept_status: "none" };
|
||||
const sheets = ["s1", "s2"].map((sid, i) => ({ sheet_id: sid, metric_id: "arp", title: `ARP · ${i ? "专线" : "公网"}`, key_fields: ["ip"], compare_fields: ["mac"], iface_fields: [] }));
|
||||
const monitor = { id: "mt", name: "业务连续性", compare_template_id: "ct", compare_template_name: "业务模板", collect_metric_ids: ["arp"], collect_metric_ids_effective: ["arp"], defaults: { custom_setting: false, threshold: 7, out_of_expect: "ignore" }, sheet_overrides: [{ metric_id: "arp", success: [{ old_groups: [["removed"]], new_groups: [["added"]], name: "客户迁移规则" }], anomaly: [] }] };
|
||||
const dataset = Array.from({ length: 601 }, (_, i) => ({ id: `row-${i}`, metric_id: "arp", sheet_id: i % 2 ? "s2" : "s1", verdict: i === 600 ? "anomaly" : "migrated", color: i === 600 ? "red" : "green", in_expect: i !== 600, old_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, new_key: `10.1.${Math.floor(i / 255)}.${i % 255}`, old_status: "gone", new_status: "up", rule_hit: i === 600 ? "service_content_changed" : "success#1", integrity_changes: i === 600 ? [{ field: "mac", before: "aa:bb", after: "cc:dd" }] : [] }));
|
||||
function run(rid, pid = "p") {
|
||||
return { id: rid, purpose: rid === "hist" ? "manual" : "auto", created_at: now,
|
||||
summary: { progress: { ok: pid === "p2" ? 3 : 99, total: pid === "p2" ? 3 : 101 }, anomaly: 1, anomaly_outside_expect: 1, coverage_complete: !uncertain, duplicate_keys: uncertain ? 2 : 0,
|
||||
sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: now, new_collected_at: now, age_limit_sec: 180, sample_issues: uncertain ? ["sample_skew"] : [] })) } };
|
||||
}
|
||||
await page.route("**/v1/**", async (route) => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
requests.push({ p, query: Object.fromEntries(url.searchParams), method: req.method() });
|
||||
let data = { items: [] };
|
||||
if (p === "/v1/auth/me") data = { user: { id: "fixture", username: "fixture", role: "admin", is_active: true } };
|
||||
else if (req.method() === "PATCH" && p.endsWith("/monitor-templates/mt")) { data = { ...monitor, ...req.postDataJSON() }; writes.push(req.postDataJSON()); }
|
||||
else if (req.method() === "POST" && p.endsWith("/p/batches")) { writes.push(req.postDataJSON()); data = { ...wave, id: "created-wave", ...req.postDataJSON() }; }
|
||||
else if (p.endsWith("/biz-migration/projects")) data = { items: [project, second] };
|
||||
else if (p.endsWith("/monitor-templates")) data = { items: [monitor] };
|
||||
else if (p.endsWith("/compare/templates")) data = { items: [{ id: "ct", name: "业务模板", metrics: sheets }] };
|
||||
else if (p.endsWith("/compare/metrics")) data = { items: [{ metric_id: "arp", fields: [{ name: "ip" }, { name: "mac" }] }] };
|
||||
else if (p.endsWith("/baseline-expect")) data = { sheets: sheets.map((sheet) => ({ ...sheet, items: Array.from({ length: 201 }, (_, i) => ({ key: `biz-${i}`, keys: [`biz-${i}`], label: `业务 ${i}`, row: { ip: `biz-${i}` } })) })) };
|
||||
else if (p.endsWith("/red-tickets")) data = { items: [], open_count: 0 };
|
||||
else if (p.includes("/biz-migration/projects/") && p.endsWith("/batches")) data = { items: [{ ...wave, id: p.includes("/p2/") ? "wave2" : "wave" }] };
|
||||
else if (p.endsWith("/board")) {
|
||||
if (failBoard) { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: "fixture_refresh_error" }) }); return; }
|
||||
data = { project, batch: wave, run: run(url.searchParams.get("run_id") || liveRunId, p.includes("wave2") ? "p2" : "p") };
|
||||
} else if (p.endsWith("/runs")) data = { items: [run(liveRunId), run("hist")] };
|
||||
else if (p.endsWith("/diffs")) {
|
||||
let rows = dataset;
|
||||
if (url.searchParams.get("sheet_id")) rows = rows.filter((r) => r.sheet_id === url.searchParams.get("sheet_id"));
|
||||
if (url.searchParams.get("only_expect")) rows = rows.filter((r) => r.in_expect);
|
||||
if (url.searchParams.get("color")) rows = rows.filter((r) => r.color === url.searchParams.get("color"));
|
||||
if (url.searchParams.get("kw")) rows = rows.filter((r) => r.old_key.includes(url.searchParams.get("kw")));
|
||||
const offset = Number(url.searchParams.get("offset") || 0), limit = Number(url.searchParams.get("limit") || 100);
|
||||
data = { items: rows.slice(offset, offset + limit), total: rows.length };
|
||||
if (delayedSheet && url.searchParams.get("sheet_id") === delayedSheet) await new Promise((resolve) => setTimeout(resolve, 900));
|
||||
} else if (p.endsWith("/batches")) data = { items: [{ id: "ok", status: "success", started_at: now }, { id: "bad", status: "failed" }, { id: "running", status: "running" }] };
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
|
||||
});
|
||||
const dialog = () => page.getByRole("dialog");
|
||||
const table = () => dialog().locator(".bm-board__table table");
|
||||
const waitRows = (n) => page.waitForFunction((count) => document.querySelectorAll(".bm-board__table tbody tr").length === count, n);
|
||||
try {
|
||||
await page.goto(`${base}/network/cutover/biz-migration`);
|
||||
const projectRow = page.locator(".pt-list table tbody tr").filter({ has: page.getByText(project.name, { exact: true }) });
|
||||
await projectRow.getByRole("button", { name: "进度看板", exact: true }).click();
|
||||
await waitRows(100);
|
||||
await dialog().getByText("范围外异常", { exact: true }).waitFor();
|
||||
assert.equal(await dialog().locator(".bm-live-kpis .is-bad").last().locator("strong").textContent(), "1");
|
||||
assert.equal(requests.filter((r) => r.p.endsWith("/diffs")).at(-1).query.limit, "100");
|
||||
passed.push("live continuity KPIs", "outside anomaly visible before acceptance", "100 row page bound");
|
||||
await dialog().getByRole("button", { name: "下一页", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector('.bm-board__table tbody tr code')?.textContent === '10.1.0.100');
|
||||
passed.push("server pagination beyond first page");
|
||||
await dialog().getByRole("checkbox", { name: "仅预期", exact: true }).check();
|
||||
await dialog().getByRole("button", { name: "查看范围外异常", exact: true }).click();
|
||||
await waitRows(1);
|
||||
await dialog().getByText("接续业务内容不一致", { exact: true }).waitFor();
|
||||
assert.ok(requests.filter((r) => r.p.endsWith("/diffs")).at(-1).query.color === "red");
|
||||
await dialog().getByText("字段变化 1 项", { exact: true }).click();
|
||||
await dialog().getByText("aa:bb", { exact: true }).waitFor();
|
||||
passed.push("late outside anomaly found with server filter", "scope switch resets pagination", "field integrity evidence readable");
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-board.png"), fullPage: true });
|
||||
await dialog().getByRole("combobox", { name: "判定筛选", exact: true }).selectOption("");
|
||||
await waitRows(100);
|
||||
// Slow response for one sheet must not overwrite the next sheet selection.
|
||||
delayedSheet = "s1";
|
||||
const delayedRequest = page.waitForRequest((r) => r.url().includes("sheet_id=s1"));
|
||||
await dialog().locator(".bs-cmp-nav").getByRole("tab", { name: /ARP · 公网/ }).click();
|
||||
await delayedRequest;
|
||||
await dialog().locator(".bs-cmp-nav").getByRole("tab", { name: /ARP · 专线/ }).click();
|
||||
await page.waitForTimeout(1300);
|
||||
assert.ok((await table().locator("tbody tr td:first-child").allTextContents()).every((v) => v.includes("专线")));
|
||||
passed.push("slow sheet response cannot overwrite current sheet");
|
||||
delayedSheet = "";
|
||||
await dialog().locator(".bs-cmp-nav").getByRole("tab", { name: "全部监控项", exact: true }).click();
|
||||
uncertain = true;
|
||||
await dialog().getByRole("button", { name: "刷新看板", exact: true }).click();
|
||||
await dialog().getByText("数据待确认,不能视为验收通过", { exact: true }).waitFor();
|
||||
await dialog().getByText(/两侧采集间隔过大/).first().waitFor();
|
||||
passed.push("uncertain sampling visibly blocks healthy appearance", "duplicate key warning");
|
||||
uncertain = false;
|
||||
await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("hist");
|
||||
await dialog().getByText("历史快照", { exact: true }).waitFor();
|
||||
const historyBoardRequests = requests.filter((r) => r.p.endsWith("/board")).length;
|
||||
await page.waitForTimeout(10500);
|
||||
assert.equal(requests.filter((r) => r.p.endsWith("/board")).length, historyBoardRequests);
|
||||
passed.push("historical view remains frozen");
|
||||
liveRunId = "live-2";
|
||||
await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("");
|
||||
await waitRows(100);
|
||||
const liveCount = requests.filter((r) => r.p.endsWith("/board")).length;
|
||||
await page.waitForTimeout(10500);
|
||||
assert.ok(requests.filter((r) => r.p.endsWith("/board")).length > liveCount);
|
||||
passed.push("active live board automatically refreshes", "refresh cycles do not overlap");
|
||||
failBoard = true;
|
||||
await dialog().getByRole("button", { name: "刷新看板", exact: true }).click();
|
||||
await dialog().getByRole("alert").filter({ hasText: "获取失败" }).waitFor();
|
||||
assert.equal(await table().locator("tbody tr").count(), 100);
|
||||
passed.push("refresh failure keeps snapshot with explicit error");
|
||||
failBoard = false;
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
assert.ok(await dialog().locator(".bm-live-kpis").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
assert.ok(await dialog().locator(".bm-board").evaluate((el) => el.scrollWidth <= el.clientWidth + 1));
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-mobile.png"), fullPage: true });
|
||||
passed.push("390px responsive board", "table overflow stays within scroll container");
|
||||
await dialog().getByRole("button", { name: "割接批次", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.bm-expect-table tbody tr').length === 100);
|
||||
await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 0", exact: true }).check();
|
||||
assert.ok(await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 0", exact: true }).isChecked());
|
||||
await dialog().getByRole("button", { name: "下一页", exact: true }).click();
|
||||
await dialog().getByRole("checkbox", { name: "ARP · 公网 业务 100", exact: true }).check();
|
||||
await dialog().locator(".ct-editor__nav").getByRole("tab", { name: /ARP · 专线/ }).click();
|
||||
assert.equal(await dialog().getByRole("checkbox", { name: "ARP · 专线 业务 0", exact: true }).isChecked(), false);
|
||||
await dialog().getByRole("button", { name: "新建", exact: true }).click();
|
||||
const create = writes.at(-1);
|
||||
assert.deepEqual(create.expect_set.items, [{ metric_id: "arp", sheet_id: "s1", key: ["biz-0"] }, { metric_id: "arp", sheet_id: "s1", key: ["biz-100"] }]);
|
||||
passed.push("scope picker renders at most 100 rows", "checkbox click toggles exactly once", "selection survives pagination", "same business key in another sheet stays unselected", "wave stores structured keys and precise sheet scope");
|
||||
await dialog().getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||||
await page.goto(`${base}/network/cutover/monitor-templates`);
|
||||
await page.locator("table tbody tr").filter({ hasText: monitor.name }).getByRole("button", { name: "编辑", exact: true }).click();
|
||||
await dialog().getByText("范围外业务持续保护", { exact: true }).waitFor();
|
||||
await dialog().getByRole("button", { name: /保存/, exact: true }).click();
|
||||
await dialog().waitFor({ state: "hidden" });
|
||||
assert.equal(writes.at(-1).defaults.custom_setting, false);
|
||||
assert.equal(writes.at(-1).defaults.threshold, 7);
|
||||
assert.equal(writes.at(-1).defaults.out_of_expect, "strict");
|
||||
assert.equal(writes.at(-1).sheet_overrides[0].success[0].new_groups[0][0].value, "added");
|
||||
assert.equal(writes.at(-1).sheet_overrides[0].success[0].name, "客户迁移规则");
|
||||
passed.push("monitor edit preserves extended defaults", "outside protection policy explained", "legacy group tokens and rule metadata preserved");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ passed, errors }, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(output, "cutover-monitor-failure.png"), fullPage: true });
|
||||
console.log(JSON.stringify({ passed, errors, recentRequests: requests.slice(-12) }, null, 2));
|
||||
throw error;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue