diff --git a/docs/reviews/assets/cutover-monitor-board.png b/docs/reviews/assets/cutover-monitor-board.png index e604100..17775b7 100644 Binary files a/docs/reviews/assets/cutover-monitor-board.png and b/docs/reviews/assets/cutover-monitor-board.png differ diff --git a/docs/reviews/assets/cutover-monitor-mobile.png b/docs/reviews/assets/cutover-monitor-mobile.png index 2773861..497d5ff 100644 Binary files a/docs/reviews/assets/cutover-monitor-mobile.png and b/docs/reviews/assets/cutover-monitor-mobile.png differ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 82a2d75..c32de9f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -696,6 +696,29 @@ const en = { colChange: "Changes", }, bizMigration: { + pairCollectionHint: "Each metric has a paired collection round. Saving settings updates task bindings. Route tables are manual-only for final checks; use a separate business comparison for millions of routes. Actual cadence depends on command duration and device availability.", + pairedSampling: "Paired sampling · Timeliness", + manualRoute: "Manual final check", + pairedPeriodic: "Paired periodic", + samplingMode: "Sampling mode", + samplingDuration: "Duration old / new", + samplingQueue: "Queue old / new", + startSkew: "Start skew", + sameRound: "Round", + metricBaselineMissing: "This metric needs a matching baseline receipt", + samplePhase_idle: "Not collected", + samplePhase_queued: "Queued", + samplePhase_running: "Collecting", + samplePhase_success: "Complete", + samplePhase_partial: "Partial", + samplePhase_failed: "Failed", + samplePhase_cancelled: "Stopped", + collect_pair_invalid: "Incomplete collection pair", + collect_pair_missing: "No complete paired sample", + collect_pair_pending: "Waiting for both sides of this round", + collect_pair_failed: "Current round failed; older samples cannot replace it", + collect_round_mismatch: "Samples belong to different rounds", + sample_start_skew: "Actual collection starts are too far apart", latestSnapshot: "Latest snapshot", steadyOutside: "{{n}} stable items outside scope", "followLive": "Follow live", @@ -848,6 +871,10 @@ const en = { tabBoard: "Board", saveBaseline: "Save", baselineSaved: "Baseline saved", + errPairCapacity: "Paired sampling needs at least two collection slots. Check task concurrency, collection threads and the CLI budget.", + errPairBusy: "This metric is still collecting or queued on either side. Wait for the round to finish.", + errPairInvalid: "The collector pair is incomplete. Save configuration again to restore it.", + errPairSameDevice: "Cutover monitoring requires two different devices.", pickExpectPorts: "Expect", portFilterPh: "Filter", emptyBaselinePorts: "No baseline data", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4697994..928af54 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -695,6 +695,29 @@ const zh = { colChange: "变更", }, bizMigration: { + pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。", + pairedSampling: "双端关联采集 · 及时性", + manualRoute: "手动收尾", + pairedPeriodic: "关联周期采集", + samplingMode: "采集方式", + samplingDuration: "采集耗时 老 / 新", + samplingQueue: "排队耗时 老 / 新", + startSkew: "启动时差", + sameRound: "同轮", + metricBaselineMissing: "基线未覆盖此指标,需要补充对应基线", + samplePhase_idle: "尚未采集", + samplePhase_queued: "排队", + samplePhase_running: "采集中", + samplePhase_success: "完成", + samplePhase_partial: "部分完成", + samplePhase_failed: "采集失败", + samplePhase_cancelled: "已停止", + collect_pair_invalid: "双端关联配置不完整", + collect_pair_missing: "尚无完整同轮样本", + collect_pair_pending: "等待两侧同轮采集完成", + collect_pair_failed: "本轮采集失败,不能复用旧样本代替", + collect_round_mismatch: "两侧样本不属于同一轮次", + sample_start_skew: "两侧实际启动时差过大", latestSnapshot: "最新快照", steadyOutside: "范围外稳定 {{n}} 条", "followLive": "实时跟随", @@ -844,6 +867,10 @@ const zh = { tabBoard: "判定看板", saveBaseline: "保存配置", baselineSaved: "基线已保存", + errPairCapacity: "双端关联采集至少需要两个采集并发槽位,请检查任务并发、采集线程和 CLI 预算配置。", + errPairBusy: "该指标的两侧仍在采集或排队,请等待本轮结束。", + errPairInvalid: "双端采集关联不完整,请重新保存配置建立关联。", + errPairSameDevice: "割接监控需要选择两台不同设备。", pickExpectPorts: "本批预期", portFilterPh: "筛选", emptyBaselinePorts: "基线无数据", diff --git a/web/src/index.css b/web/src/index.css index 6b3e694..1eee27d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11210,6 +11210,9 @@ html.login-page--paused .login-page__flare { .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; } +.bm-sampling { border: 1px solid rgba(148,163,184,.25); border-radius: 4px; min-width: 0; } +.bm-sampling > summary { padding: 8px 12px; cursor: pointer; font-size: 12px; } +.app-heroui-modal .bm-sampling .pt-list-table-wrap { max-width: 100%; min-height: 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; } diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index bf06b24..881d9bb 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -50,6 +50,8 @@ type CreateStep = 0 | 1 | 2 | 3; const CREATE_NE_PAGE_SIZE_DEFAULT = 20; const CREATE_NE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; const CREATE_STEPS = 4; +const MANUAL_ROUTE_METRICS = new Set(["ip_route", "ipv6_route", "bgp_route"]); +const isManualMetric = (mid: string) => MANUAL_ROUTE_METRICS.has(mid); function neSourceOf(row: CliTargetItem): "managed" | "ume" { return row.source === "ume" ? "ume" : "managed"; @@ -75,12 +77,13 @@ function monitorTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] { return mids.length ? mids : ["interface_brief"]; } -/** Empty override = use monitor-template default (full effective set). */ -function collectOverridePayload(selected: string[], available: string[]): string[] { - if (!available.length) return selected.filter(Boolean); - const sel = new Set(selected.filter(Boolean)); - if (sel.size === available.length && available.every((m) => sel.has(m))) return []; - return [...sel]; +// Freeze the operator selection; later template additions stay opt-in. +function collectOverridePayload(selected: string[], _available: string[]): string[] { + return [...new Set(selected.filter(Boolean))]; +} + +function availableTplMetrics(tpl: MonitorTplOpt | null | undefined): string[] { + return tpl?.available_metric_ids?.length ? tpl.available_metric_ids : monitorTplMetrics(tpl); } function metricIntervalPayload( @@ -90,7 +93,7 @@ function metricIntervalPayload( const out: Record = {}; for (const mid of selected) { const raw = intervals[mid]; - if (raw && Number(raw) >= 60) out[mid] = Math.max(60, Number(raw)); + if (!isManualMetric(mid) && raw && Number(raw) >= 60) out[mid] = Math.max(60, Number(raw)); } return out; } @@ -139,15 +142,24 @@ function mapMigrationApiErr( } if (detail === "old_baseline_required") return t("bizMigration.errOldBaselineRequired"); if (detail === "new_baseline_required") return t("bizMigration.errNewBaselineRequired"); + if (detail === "collect_pair_same_device") return t("bizMigration.errPairSameDevice"); return formatErr(e); } +function collectErrorMessage(code: string, t: (key: string) => string): string { + const keys: Record = { + collect_pair_capacity: "errPairCapacity", collect_pair_busy: "errPairBusy", + collect_pair_invalid: "errPairInvalid", hf_task_missing: "hfNotBound", + }; + return keys[code] ? t(`bizMigration.${keys[code]}`) : code || "collect_failed"; +} + function formatHfBindLine( - binds?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>, + binds?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>, ): string { if (!binds?.length) return ""; return binds - .map((b) => `${b.interval_sec || 60}s·${(b.metric_ids || []).join("+") || "—"}`) + .map((b) => `${b.manual_only ? "manual" : `${b.interval_sec || 60}s`}·${(b.metric_ids || []).join("+") || "—"}`) .join(" · "); } @@ -172,6 +184,7 @@ type MonitorTplOpt = { compare_template_name?: string; collect_metric_ids?: string[]; collect_metric_ids_effective?: string[]; + available_metric_ids?: string[]; }; type Project = { id: string; @@ -180,8 +193,8 @@ type Project = { new_task_id: string; old_hf_task_id?: string; new_hf_task_id?: string; - old_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>; - new_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number }>; + old_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>; + new_hf_bindings?: Array<{ task_id: string; metric_ids?: string[]; interval_sec?: number; manual_only?: boolean; collect_group_id?: string }>; old_baseline_batch_id: string; new_baseline_batch_id: string; mapping_id: string; @@ -276,6 +289,18 @@ type RedTicket = { status: string; evidence?: DiffRow["evidence"]; }; +type SamplingPair = { + metric_id: string; + manual_only?: boolean; + collect_round_id?: string; + old_collect_status?: string; + new_collect_status?: string; + old_duration_sec?: number; + new_duration_sec?: number; + old_queue_sec?: number; + new_queue_sec?: number; + start_skew_sec?: number; +}; type SheetCard = { metric_id: string; sheet_id?: string; @@ -294,6 +319,7 @@ type SheetCard = { duplicate_keys_before?: number; duplicate_keys_after?: number; age_limit_sec?: number; + baseline_missing_sides?: string[]; }; type EvalRun = { id: string; @@ -419,6 +445,7 @@ export function BizMigrationPage() { const [batches, setBatches] = useState([]); const [batchId, setBatchId] = useState(""); const [board, setBoard] = useState<{ + sampling?: SamplingPair[]; run?: { id: string; created_at?: string; @@ -483,7 +510,9 @@ export function BizMigrationPage() { 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 pairedPending = followLatest && board?.sampling?.some((pair) => + [pair.old_collect_status, pair.new_collect_status].some((s) => !["success", "partial"].includes(s || "idle"))); + const coverageUnknown = Boolean(board?.run && (liveSummary?.coverage_complete !== true || samplesStale || pairedPending)); const filteredProjects = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); @@ -693,6 +722,9 @@ export function BizMigrationPage() { collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective) ? (x.collect_metric_ids_effective as string[]) : [], + available_metric_ids: Array.isArray(x.available_metric_ids) + ? (x.available_metric_ids as string[]) + : [], })); setMonitorTpls(mts); if (!createMonitorTplId && mts.length) { @@ -701,7 +733,7 @@ export function BizMigrationPage() { mts.find((x) => /默认|default|状态|status/i.test(x.name)) || mts[0]; setCreateMonitorTplId(preferred.id); - setCreateCollectMetricIds(monitorTplMetrics(preferred)); + setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid))); } }; apply(await cutoverCachedGetSWR("bizMigration:bootstrap", fetchBundle, apply)); @@ -1086,7 +1118,7 @@ export function BizMigrationPage() { monitorTpls[0]; const tplId = preferred?.id || ""; setCreateMonitorTplId(tplId); - setCreateCollectMetricIds(monitorTplMetrics(preferred)); + setCreateCollectMetricIds(monitorTplMetrics(preferred).filter((mid) => !isManualMetric(mid))); } const openCreate = () => { @@ -1443,7 +1475,7 @@ export function BizMigrationPage() { } else if (sum.kind === "partial") { showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) })); } else if (sum.kind === "fail" || sum.kind === "missing") { - showError(sum.err === "hf_task_missing" ? t("bizMigration.hfNotBound") : sum.err); + showError(collectErrorMessage(sum.err, t)); } else { showOk(t("bizMigration.highfreqReady")); } @@ -1468,7 +1500,7 @@ export function BizMigrationPage() { else if (sum.kind === "missing") showError(t("bizMigration.hfNotBound")); else if (sum.kind === "partial") { showError(t("bizMigration.collectPartial", { ok: String(sum.okN), fail: String(sum.failN) })); - } else if (sum.kind === "fail") showError(sum.err || "collect_failed"); + } else if (sum.kind === "fail") showError(collectErrorMessage(sum.err, t)); else showOk(t("bizMigration.collectTriggered")); } catch (e) { showError(formatErr(e)); @@ -1525,7 +1557,7 @@ export function BizMigrationPage() { const collectMetricOptions = useMemo(() => { const set = new Set(); - for (const m of monitorTplMetrics(selectedMonitorTpl as MonitorTplOpt | null)) { + for (const m of availableTplMetrics(selectedMonitorTpl as MonitorTplOpt | null)) { if (m) set.add(m); } for (const m of project?.collect_metric_ids_effective || []) { @@ -1557,7 +1589,7 @@ export function BizMigrationPage() { const createTplMetrics = useMemo(() => { const tpl = monitorTpls.find((m) => m.id === createMonitorTplId); - const base = monitorTplMetrics(tpl); + const base = availableTplMetrics(tpl); const set = new Set([...base, ...createCollectMetricIds]); return [...set]; }, [monitorTpls, createMonitorTplId, createCollectMetricIds]); @@ -1942,7 +1974,7 @@ export function BizMigrationPage() { const id = e.target.value; setCreateMonitorTplId(id); const tpl = monitorTpls.find((m) => m.id === id); - setCreateCollectMetricIds(monitorTplMetrics(tpl)); + setCreateCollectMetricIds(monitorTplMetrics(tpl).filter((mid) => !isManualMetric(mid))); setCreateMetricIntervalSec({}); }} fullWidth @@ -1960,6 +1992,7 @@ export function BizMigrationPage() { {createCollectMetricIds.length}/{createTplMetrics.length} +

{t("bizMigration.pairCollectionHint")}

{createTplMetrics.map((mid) => { const on = createCollectMetricIds.includes(mid); @@ -1974,7 +2007,7 @@ export function BizMigrationPage() { ); }} > - {mid} + {mid}{isManualMetric(mid) ? · {t("bizMigration.manualRoute")} : null} ); })} @@ -2035,7 +2068,7 @@ export function BizMigrationPage() { {mid} - {t("bizMigration.manualRoute")} : + />} ))} @@ -2330,6 +2363,7 @@ export function BizMigrationPage() {
{t("bizMigration.projectCollectMetrics")}
+

{t("bizMigration.pairCollectionHint")}

{collectMetricOptions.map((mid) => { const on = collectMetricIds.includes(mid); @@ -2344,7 +2378,7 @@ export function BizMigrationPage() { ); }} > - {mid} + {mid}{isManualMetric(mid) ? · {t("bizMigration.manualRoute")} : null} ); })} @@ -2366,7 +2400,7 @@ export function BizMigrationPage() { {mid} - {t("bizMigration.manualRoute")} : + />} ))} @@ -2703,9 +2737,27 @@ export function BizMigrationPage() {
{t("bizMigration.anomalyInBatch")}{Math.max(0, (liveSummary?.anomaly ?? 0) - (liveSummary?.anomaly_outside_expect ?? 0))}
{t("bizMigration.anomalyOutsideBatch")}{liveSummary?.anomaly_outside_expect ?? 0}
+ {followLatest && board?.sampling?.length ?
+ {t("bizMigration.pairedSampling")} +
+ + {board.sampling.map((pair) => + + + + + + + + )} +
{t("bizMigration.sectionMetrics")}{t("bizMigration.samplingMode")}{t("bizMigration.colOld")}{t("bizMigration.colNew")}{t("bizMigration.samplingDuration")}{t("bizMigration.samplingQueue")}{t("bizMigration.startSkew")}
{pair.metric_id}{pair.collect_round_id ? t("bizMigration.sameRound") + " " + pair.collect_round_id.slice(0, 8) : "—"}{t(pair.manual_only ? "bizMigration.manualRoute" : "bizMigration.pairedPeriodic")}{t("bizMigration.samplePhase_" + (pair.old_collect_status || "idle"))}{t("bizMigration.samplePhase_" + (pair.new_collect_status || "idle"))}{Math.round(pair.old_duration_sec || 0)} / {Math.round(pair.new_duration_sec || 0)} s{Math.round(pair.old_queue_sec || 0)} / {Math.round(pair.new_queue_sec || 0)} s{pair.start_skew_sec == null ? "—" : pair.start_skew_sec.toFixed(1) + " s"}
+
: null} {coverageUnknown ?
{t("bizMigration.coverageUnknown")}{t("bizMigration.coverageUnknownHint")} {samplesStale ? {t("bizMigration.sample_stale")} : null} + {pairedPending ? {t("bizMigration.collect_pair_pending")} : null} + {sheetCards.filter((c) => c.baseline_missing_sides?.length).map((c) => + {sheetLabel(c)}: {t("bizMigration.metricBaselineMissing")})} {sheetCards.filter((c) => c.sample_issues?.length || c.awaiting_peer).map((c) => {sheetLabel(c)}: {c.awaiting_peer ? t("bizMigration.awaitingPeer") : c.sample_issues?.map((issue) => t(`bizMigration.${issue}`)).join(" · ")})} diff --git a/web/tests/bizMigration.browser.mjs b/web/tests/bizMigration.browser.mjs index 08054af..310ce00 100644 --- a/web/tests/bizMigration.browser.mjs +++ b/web/tests/bizMigration.browser.mjs @@ -15,11 +15,11 @@ 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" } }; + collect_metric_ids_effective: ["arp"], 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 monitor = { id: "mt", name: "业务连续性", compare_template_id: "ct", compare_template_name: "业务模板", collect_metric_ids: ["arp"], collect_metric_ids_effective: ["arp"], available_metric_ids: ["arp", "ip_route"], 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, @@ -32,6 +32,12 @@ await page.route("**/v1/**", async (route) => { 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() === "PATCH" && p.endsWith("/projects/p")) { + writes.push(req.postDataJSON()); Object.assign(project, req.postDataJSON()); + project.collect_metric_ids_effective = project.collect_metric_ids; + data = project; + } + else if (p.endsWith("/p/ensure-highfreq")) data = { project, hf_status: "running", collect: { old: [], new: [] } }; 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] }; @@ -42,7 +48,7 @@ await page.route("**/v1/**", async (route) => { 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") }; + data = { project, batch: wave, sampling: [{ metric_id: "arp", collect_round_id: "paired-round-1", old_collect_status: "success", new_collect_status: uncertain ? "running" : "success", old_duration_sec: 18, new_duration_sec: 20, old_queue_sec: 2, new_queue_sec: 2, start_skew_sec: 0.4 }], 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; @@ -67,7 +73,9 @@ try { 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().getByText("双端关联采集 · 及时性", { exact: true }).waitFor(); + assert.equal(await dialog().locator('.bm-sampling tbody td:last-child').textContent(), '0.4 s'); + passed.push("measured paired start skew", "paired round and duration visible", "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"); @@ -97,7 +105,8 @@ try { 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"); + await dialog().getByText("采集中", { exact: true }).waitFor(); + passed.push("in-flight paired sample remains uncertain", "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(); @@ -123,6 +132,19 @@ try { 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 page.setViewportSize({ width: 1440, height: 1080 }); + await dialog().getByRole("button", { name: "基线配置", exact: true }).click(); + await dialog().locator('.bm-metric-chip').filter({ hasText: 'ip_route' }).click(); + await dialog().getByText("手动收尾", { exact: true }).waitFor(); + assert.equal(await dialog().getByRole("spinbutton", { name: "ip_route interval", exact: true }).count(), 0); + await dialog().getByRole("button", { name: "保存配置", exact: true }).click(); + await page.waitForTimeout(500); + assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp', 'ip_route']); + await dialog().locator('.bm-metric-chip').filter({ hasText: 'ip_route' }).click(); + await dialog().getByRole("button", { name: "保存配置", exact: true }).click(); + await page.waitForTimeout(500); + assert.deepEqual(writes.at(-1).collect_metric_ids, ['arp']); + passed.push("route can be selected from comparison items", "route has manual mode instead of periodic interval", "dynamic metric additions saved explicitly", "removed metric no longer selected"); 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();