diff --git a/docs/reviews/assets/cutover-monitor-board.png b/docs/reviews/assets/cutover-monitor-board.png index 17775b7..9fe1dcf 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 497d5ff..e9e2c1b 100644 Binary files a/docs/reviews/assets/cutover-monitor-mobile.png and b/docs/reviews/assets/cutover-monitor-mobile.png differ diff --git a/docs/reviews/assets/cutover-monitor-wall.png b/docs/reviews/assets/cutover-monitor-wall.png new file mode 100644 index 0000000..f35d5db Binary files /dev/null and b/docs/reviews/assets/cutover-monitor-wall.png differ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c32de9f..7681f11 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -696,6 +696,16 @@ const en = { colChange: "Changes", }, bizMigration: { + fullscreen: "Wall mode", + exitFullscreen: "Exit wall mode", + fullscreenHint: "Display cutover status fullscreen. Press Esc to return; use a full-window layout when fullscreen is unavailable.", + wallTitle: "Cutover monitoring · Service migration", + wallProgress: "Wave migration progress · Current snapshot", + wallFilters: "Show filters and evaluation controls", + wallState_waiting: "Awaiting evaluation", + wallState_unknown: "Data needs confirmation", + wallState_anomaly: "Service anomalies detected", + wallState_normal: "No service anomalies in current samples", 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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 928af54..07712d0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -695,6 +695,16 @@ const zh = { colChange: "变更", }, bizMigration: { + fullscreen: "大屏模式", + exitFullscreen: "退出大屏", + fullscreenHint: "全屏展示割接状态,按 Esc 返回看板;浏览器不支持全屏时使用窗口大屏。", + wallTitle: "割接监控 · 业务迁移", + wallProgress: "本批迁移进度 · 当前快照", + wallFilters: "显示筛选与判定操作", + wallState_waiting: "等待判定", + wallState_unknown: "数据待确认", + wallState_anomaly: "存在业务异常", + wallState_normal: "当前样本无业务异常", pairCollectionHint: "每个指标两侧同轮关联采集。保存配置会同步增删采集任务;路由项仅手动采集,收尾时再选择,百万路由建议使用独立业务对比任务。频率是目标间隔,实际周期受命令耗时与设备占用影响。", pairedSampling: "双端关联采集 · 及时性", manualRoute: "手动收尾", diff --git a/web/src/index.css b/web/src/index.css index 1eee27d..52627d5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11213,6 +11213,9 @@ html.login-page--paused .login-page__flare { .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; } +.bm-live-header__actions { display: flex; gap: 8px; align-items: center; } +.bm-board-controls > summary { display: none; } +.bm-board-controls > .bm-live-filters { margin-top: 8px; } @media (max-width: 720px) { .bm-live-sources { grid-template-columns: 1fr; } .bm-live-kpis { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; } @@ -15787,3 +15790,106 @@ option { .pager__jump-input { width: 4.25rem; } + +/* Cutover wall: keep one board/poller alive in native or window fullscreen. */ +.app-heroui-modal.bm-wall-modal { + width: 100vw !important; + max-width: 100vw !important; + height: 100dvh; + max-height: 100dvh; + padding: 0; + margin: 0; + transform: none; + box-sizing: border-box; + border: 0; +} +.app-heroui-modal.bm-wall-modal .app-heroui-modal__dialog { + width: 100% !important; + max-width: 100% !important; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + transform: none; + overflow: hidden; + box-sizing: border-box; +} +.bm-wall-modal .modal__header, +.bm-wall-modal .modal__footer, +.bm-wall-modal .bm-detail__chrome, +.bm-wall-modal .nm-config-modal__tabs { display: none; } +.app-heroui-modal.bm-wall-modal .bm-detail { + width: 100%; + min-width: 0; + height: 100%; + max-height: 100%; + margin: 0; + padding: 0; + gap: 0; + overflow: hidden; +} +.bm-board.is-wall, +.bm-board:fullscreen { + width: 100%; + min-width: 0; + height: 100dvh; + min-height: 0; + max-height: 100dvh; + margin: 0; + padding: clamp(12px, 1.25vw, 28px); + gap: 12px; + border-radius: 0; + background: #0b1220; + color: #e2e8f0; + box-sizing: border-box; + overflow: auto; +} +.bm-board.is-wall > * { flex-shrink: 0; } +.bm-wall-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-width: 0; } +.bm-wall-heading > div { min-width: 0; } +.bm-wall-heading__label { color: #94a3b8; font-size: 13px; letter-spacing: .08em; } +.bm-wall-heading h2 { margin: 4px 0 0; font-size: clamp(22px, 2vw, 36px); line-height: 1.2; overflow-wrap: anywhere; } +.bm-wall-heading__status { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } +.bm-wall-heading time { color: #94a3b8; font: 14px ui-monospace, monospace; white-space: nowrap; } +.bm-wall-state { padding: 6px 12px; font-size: 15px; border: 1px solid currentColor; border-radius: 3px; } +.bm-wall-state.is-normal { color: #34d399; } +.bm-wall-state.is-anomaly { color: #f87171; } +.bm-wall-state.is-waiting, .bm-wall-state.is-unknown { color: #fbbf24; } +.bm-board.is-wall .bm-live-header { position: sticky; top: 0; z-index: 5; background: #0b1220; padding: 8px 0; } +.bm-board.is-wall .bm-live-header strong { font-size: 17px; } +.bm-board.is-wall .bm-live-sources strong { font-size: 19px; } +.bm-board.is-wall .bm-live-kpis > div { padding: 16px 20px; } +.bm-board.is-wall .bm-live-kpis span { font-size: 16px; } +.bm-board.is-wall .bm-live-kpis strong { font-size: clamp(40px, 3.5vw, 68px); } +.bm-board.is-wall .bm-live-kpis strong small { font-size: 20px; } +.bm-board.is-wall .bm-live-warning { font-size: 14px; } +.bm-wall-progress { display: flex; align-items: center; gap: 20px; padding: 4px 0; color: #34d399; } +.bm-wall-progress > div:first-child { display: flex; align-items: baseline; gap: 16px; } +.bm-wall-progress span { font-size: 13px; } +.bm-wall-progress strong { font: 600 24px ui-monospace, monospace; } +.bm-wall-progress.is-unknown { color: #fbbf24; } +.bm-wall-progress__track { flex: 1; height: 8px; min-width: 60px; background: #243044; border-radius: 2px; overflow: hidden; } +.bm-wall-progress__track > span { display: block; height: 100%; background: currentColor; } +.bm-board.is-wall .bm-board-controls > summary { display: list-item; cursor: pointer; color: #94a3b8; font-size: 13px; padding: 6px 0; } +.bm-board.is-wall .bm-sampling { max-height: 240px; overflow: auto; } +.bm-board.is-wall .bm-sampling td, .bm-board.is-wall .bm-sampling th { font-size: 14px; } +.bm-board.is-wall .bs-cmp-board__body { flex: 1 0 260px; min-height: 260px; } +.bm-board.is-wall .bs-cmp-main { display: flex; min-height: 0; } +.bm-board.is-wall .bm-board__table { flex: 1; width: 100%; min-height: 0; max-height: none; } +.bm-board.is-wall .bm-board__table td { font-size: 15px; } +.bm-board.is-wall .bs-cmp-nav__list { max-height: none; } +@media (max-width: 720px) { + .bm-wall-heading { align-items: flex-start; gap: 12px; } + .bm-wall-heading__status { max-width: 48%; } + .bm-wall-heading time { font-size: 10px; white-space: normal; } + .bm-wall-state { font-size: 12px; padding: 4px 6px; } + .bm-board.is-wall .bm-live-kpis > div { padding: 12px; } + .bm-board.is-wall .bm-live-kpis strong { font-size: 36px; } + .bm-board.is-wall .bm-live-kpis span { font-size: 13px; } + .bm-wall-progress { flex-wrap: wrap; gap: 8px; } + .bm-wall-progress__track { flex-basis: 100%; } + .bm-board.is-wall .bs-cmp-board__body { flex-basis: auto; } + .bm-board.is-wall .bm-board__table { max-height: 50dvh; } +} diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index 881d9bb..b4628ac 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -489,6 +489,9 @@ export function BizMigrationPage() { const [boardLoading, setBoardLoading] = useState(false); const [boardError, setBoardError] = useState(""); const [boardClock, setBoardClock] = useState(Date.now()); + const [boardFullscreen, setBoardFullscreen] = useState(false); + const boardRef = useRef(null); + const fullscreenPending = useRef(false); const boardRequest = useRef(0); const runsRequest = useRef(0); const activeBatchRef = useRef(batchId); @@ -513,6 +516,66 @@ export function BizMigrationPage() { 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 progressPercent = liveProgress?.total + ? Math.max(0, Math.min(100, Math.round(liveProgress.ok / liveProgress.total * 100))) : 0; + const wallState = boardError ? "unknown" : !board?.run ? "waiting" : coverageUnknown ? "unknown" + : (liveSummary?.anomaly || 0) > 0 ? "anomaly" : "normal"; + + const exitBoardFullscreen = useCallback(async () => { + if (document.fullscreenElement === boardRef.current) { + try { await document.exitFullscreen(); } catch { return; } + } + setBoardFullscreen(false); + }, []); + + async function toggleBoardFullscreen() { + if (fullscreenPending.current) return; + const element = boardRef.current; + if (!element) return; + fullscreenPending.current = true; + try { + if (boardFullscreen) { + await exitBoardFullscreen(); + return; + } + try { await element.requestFullscreen(); } catch { /* Use the full-window layout below. */ } + if (boardRef.current === element) setBoardFullscreen(true); + } finally { + fullscreenPending.current = false; + } + } + + useEffect(() => { + const sync = () => setBoardFullscreen(Boolean(boardRef.current && document.fullscreenElement === boardRef.current)); + document.addEventListener("fullscreenchange", sync); + return () => document.removeEventListener("fullscreenchange", sync); + }, []); + + useEffect(() => { + const element = boardRef.current; + return () => { + if (element && document.fullscreenElement === element) void document.exitFullscreen().catch(() => {}); + }; + }, [projectId, detailTab]); + + useEffect(() => { + if (!boardFullscreen) return; + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopImmediatePropagation(); + void exitBoardFullscreen(); + }; + window.addEventListener("keydown", onEscape, true); + return () => window.removeEventListener("keydown", onEscape, true); + }, [boardFullscreen, exitBoardFullscreen]); + + useEffect(() => { + if (!projectId || detailTab !== "board") return; + // Freshness must keep advancing even when API refreshes fail. + const timer = setInterval(() => setBoardClock(Date.now()), 1000); + return () => clearInterval(timer); + }, [projectId, detailTab]); const filteredProjects = useMemo(() => { const kw = debouncedListKw.trim().toLowerCase(); @@ -1185,6 +1248,7 @@ export function BizMigrationPage() { } const openProject = (id: string, tab: DetailTab = "setup") => { + setBoardFullscreen(false); setProjectId(id); setDetailTab(tab); setSelectedExpectKeys(new Set()); @@ -1196,6 +1260,7 @@ export function BizMigrationPage() { }; const closeProject = () => { + setBoardFullscreen(false); setProjectId(""); setDetailTab("setup"); }; @@ -2185,9 +2250,10 @@ export function BizMigrationPage() { {/* Project detail */} { if (boardFullscreen) void exitBoardFullscreen(); else closeProject(); }} + dismissible={!boardFullscreen} + size={boardFullscreen ? "full" : "cover"} + className={detailTab === "board" ? `bs-cmp-board-modal${boardFullscreen ? " bm-wall-modal" : ""}` : undefined} > @@ -2707,7 +2773,15 @@ export function BizMigrationPage() { ) : null} {detailTab === "board" ? ( -
+
+ {boardFullscreen ?
+
{t("bizMigration.wallTitle")} +

{project.name}

+
+ {t(`bizMigration.wallState_${wallState}`)} + +
+
: null}
@@ -2716,8 +2790,16 @@ export function BizMigrationPage() { {batch?.batch_label || "—"}
{t("bizMigration.evaluatedAt")} {fmtTime(board?.run?.created_at)} - +
+ + + {t("bizMigration.fullscreenHint")} +
{(["old", "new"] as const).map((side) => { @@ -2737,6 +2819,13 @@ 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}
+ {boardFullscreen ?
+
{t("bizMigration.wallProgress")}{progressPercent}%
+
+ +
+
: null} {followLatest && board?.sampling?.length ?
{t("bizMigration.pairedSampling")}
@@ -2784,6 +2873,8 @@ export function BizMigrationPage() { ) : null} +
+ {t("bizMigration.wallFilters")}
: null}
+
{sheetCards.some((c) => c.new_baseline_missing) ? (
{t("bizMigration.newBaselineMissingBanner", { diff --git a/web/tests/bizMigration.browser.mjs b/web/tests/bizMigration.browser.mjs index 310ce00..d2b29b7 100644 --- a/web/tests/bizMigration.browser.mjs +++ b/web/tests/bizMigration.browser.mjs @@ -12,7 +12,7 @@ const browser = await chromium.launch({ headless: true, channel: process.env.NET 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 = ""; +let liveRunId = "live-1", uncertain = false, failBoard = false, delayedSheet = "", sampleAgeSec = 0; 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, 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" } }; @@ -22,9 +22,10 @@ const sheets = ["s1", "s2"].map((sid, i) => ({ sheet_id: sid, metric_id: "arp", 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") { + const sampleTime = sampleAgeSec ? new Date(Date.now() - sampleAgeSec * 1000).toISOString() : now; 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"] : [] })) } }; + sheet_cards: sheets.map((s) => ({ ...s, progress_ok: 49, progress_total: 50, anomaly: 0, old_collected_at: sampleTime, new_collected_at: sampleTime, 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; @@ -76,6 +77,66 @@ try { 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 page.setViewportSize({ width: 1920, height: 1080 }); + await dialog().getByRole("button", { name: "大屏模式", exact: true }).click(); + await page.locator('.bm-board.is-wall').waitFor(); + assert.ok(await page.evaluate(() => document.fullscreenElement?.classList.contains('bm-board'))); + assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [1920, 1080]); + await page.locator('.bm-wall-heading').getByRole('heading', { name: project.name, exact: true }).waitFor(); + assert.equal(await page.getByRole('progressbar').getAttribute('aria-valuenow'), '98'); + await page.locator('.bm-wall-state').getByText('存在业务异常', { exact: true }).waitFor(); + assert.equal(await table().locator('tbody tr').count(), 100); + assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); + await page.screenshot({ path: path.join(output, 'cutover-monitor-wall.png') }); + await page.setViewportSize({ width: 3840, height: 2160 }); + assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [3840, 2160]); + assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); + await page.setViewportSize({ width: 1920, height: 1080 }); + const wallRequests = requests.filter((r) => r.p.endsWith('/board')).length; + await page.waitForTimeout(10500); + assert.ok(requests.filter((r) => r.p.endsWith('/board')).length > wallRequests); + await page.getByRole('button', { name: '退出大屏', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall') && !document.fullscreenElement); + assert.ok(await dialog().isVisible()); + assert.ok(await dialog().getByRole('checkbox', { name: '仅预期', exact: true }).isChecked()); + passed.push('native wall fills 1920x1080', '4K wall fits without horizontal overflow', 'project identity and progress visible on wall', 'outside alarms stay visible on wall', 'wall keeps one bounded data page', 'live wall keeps refreshing', 'wall exit preserves project and filters'); + // Block native fullscreen to verify the window layout and Escape handling. + await page.evaluate(() => { + window.__originalFullscreen = Element.prototype.requestFullscreen; + Element.prototype.requestFullscreen = async () => { throw new Error('fixture_fullscreen_blocked'); }; + }); + await dialog().getByRole('button', { name: '大屏模式', exact: true }).click(); + await page.locator('.bm-board.is-wall').waitFor(); + assert.equal(await page.evaluate(() => document.fullscreenElement), null); + assert.deepEqual(await page.locator('.bm-board').evaluate((el) => [Math.round(el.getBoundingClientRect().width), Math.round(el.getBoundingClientRect().height)]), [1920, 1080]); + uncertain = true; + await page.getByRole('button', { name: '刷新看板', exact: true }).click(); + await page.locator('.bm-wall-state').getByText('数据待确认', { exact: true }).waitFor(); + uncertain = false; sampleAgeSec = 200; + await page.getByRole('button', { name: '刷新看板', exact: true }).click(); + await page.getByText('采集已过期', { exact: true }).waitFor(); + assert.equal(await page.locator('.bm-wall-state').textContent(), '数据待确认'); + sampleAgeSec = 0; + failBoard = true; + await page.getByRole('button', { name: '刷新看板', exact: true }).click(); + await dialog().getByRole('alert').filter({ hasText: '获取失败' }).waitFor(); + assert.equal(await page.locator('.bm-wall-state').textContent(), '数据待确认'); + const clockText = await page.locator('.bm-wall-heading time').textContent(); + await page.waitForFunction((previous) => document.querySelector('.bm-wall-heading time')?.textContent !== previous, + clockText, { timeout: 5000 }); + assert.notEqual(await page.locator('.bm-wall-heading time').textContent(), clockText); + failBoard = false; + await page.setViewportSize({ width: 390, height: 1000 }); + assert.ok(await page.locator('.bm-board').evaluate((el) => el.scrollWidth <= el.clientWidth + 1)); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall')); + assert.ok(await dialog().isVisible()); + assert.ok(await dialog().getByRole('checkbox', { name: '仅预期', exact: true }).isChecked()); + await page.evaluate(() => { Element.prototype.requestFullscreen = window.__originalFullscreen; }); + await page.setViewportSize({ width: 1440, height: 1080 }); + await dialog().getByRole('button', { name: '刷新看板', exact: true }).click(); + await page.waitForFunction(() => !document.querySelector('.bm-live-warning[role="alert"]')); + passed.push('blocked fullscreen uses full-window wall', 'unfinished pair cannot look healthy on wall', 'stale samples remain uncertain on wall', 'refresh failure retains explicit uncertainty', 'wall clock continues when refresh fails', '390px wall stays within viewport', 'Escape exits wall without closing project'); 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"); @@ -110,15 +171,24 @@ try { uncertain = false; await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption("hist"); await dialog().getByText("历史快照", { exact: true }).waitFor(); + await dialog().getByRole('button', { name: '大屏模式', exact: true }).click(); + await page.locator('.bm-board.is-wall').waitFor(); + await page.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"); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => !document.querySelector('.bm-board.is-wall') && !document.fullscreenElement); + assert.ok(await dialog().isVisible()); + assert.equal(await dialog().getByRole('combobox', { name: '判定历史', exact: true }).inputValue(), 'hist'); + passed.push("historical wall remains frozen", "native Escape preserves project and historical selection"); liveRunId = "live-2"; + const liveResponse = page.waitForResponse((r) => r.url().includes('/runs/live-2/diffs')); await dialog().getByRole("combobox", { name: "判定历史", exact: true }).selectOption(""); + await liveResponse; await waitRows(100); const liveCount = requests.filter((r) => r.p.endsWith("/board")).length; - await page.waitForTimeout(10500); + await page.waitForResponse((r) => new URL(r.url()).pathname.endsWith('/board'), { timeout: 15000 }); 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; @@ -174,6 +244,15 @@ try { 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(await page.locator('.bm-board').evaluate((el) => { + const layout = []; + for (let node = el; node; node = node.parentElement) { + const rect = node.getBoundingClientRect(), style = getComputedStyle(node); + layout.push({ class: node.className, width: rect.width, height: rect.height, x: rect.x, y: rect.y, + minWidth: style.minWidth, maxWidth: style.maxWidth, padding: style.padding, margin: style.margin, transform: style.transform }); + } + return layout; + }), null, 2)); console.log(JSON.stringify({ passed, errors, recentRequests: requests.slice(-12) }, null, 2)); throw error; } finally {