diff --git a/scripts/web-live-dom-probe.mjs b/scripts/web-live-dom-probe.mjs index 547219e6..776cca4c 100644 --- a/scripts/web-live-dom-probe.mjs +++ b/scripts/web-live-dom-probe.mjs @@ -311,6 +311,17 @@ export async function runProbe(args) { } }; } catch (error) { + const finalUrl = page ? page.url() : null; + const failureDom = page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null; + let screenshotCapture = null; + if (page && !page.isClosed()) { + try { + await page.screenshot({ path: screenshotPath, fullPage: true }); + screenshotCapture = { ok: true }; + } catch (screenshotError) { + screenshotCapture = { ok: false, error: screenshotError instanceof Error ? screenshotError.message : String(screenshotError) }; + } + } result = { ok: false, status: "blocked", @@ -322,10 +333,10 @@ export async function runProbe(args) { actions, credentials: args.credentials, error: error instanceof Error ? error.message : String(error), - degradedReason: classifyRunProbeError(error), - finalUrl: page ? page.url() : null, - failureDom: page ? await collectFailureDomEvidence(page).catch((domError) => ({ error: domError instanceof Error ? domError.message : String(domError) })) : null, - artifacts: { reportPath, screenshotPath } + degradedReason: classifyRunProbeError(error, { finalUrl, failureDom }), + finalUrl, + failureDom, + artifacts: { reportPath, screenshotPath, screenshotCapture } }; } finally { if (browser) await browser.close().catch(() => {}); @@ -889,12 +900,16 @@ async function submitPrompt(page, args, actions) { const targetConversationId = firstNonEmpty(fresh?.alignment?.check?.conversationId, fresh?.alignment?.after?.routeConversationId, fresh?.after?.routeConversationId); if (targetConversationId) await ensureConversationAligned(page, args, targetConversationId, actions, "pre-submit-realign"); await page.locator("#command-input").fill(args.message); - await page.waitForFunction((expected) => { + const enabled = await page.waitForFunction((expected) => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); return input?.value === expected && send && !send.disabled; - }, args.message, { timeout: Math.min(args.timeoutMs, 5000) }); + }, args.message, { timeout: Math.min(args.timeoutMs, 5000) }).catch(() => null); const afterFill = await collectCommandState(page); + if (!enabled) { + const session = await collectSessionState(page, args).catch((error) => ({ error: error instanceof Error ? error.message : String(error) })); + throw new Error(`command composer is not ready for submit: ${JSON.stringify({ readyBefore, afterFill, session: compactSessionState(session) })}`); + } const submittedAt = new Date().toISOString(); await page.locator("#command-send").click(); const afterClick = await collectCommandState(page); @@ -1132,8 +1147,14 @@ function runProbeDegradedReason({ traceSummary, sessionSummary, promptValidation return null; } -function classifyRunProbeError(error) { +function classifyRunProbeError(error, context = {}) { const message = error instanceof Error ? error.message : String(error); + const finalUrl = typeof context.finalUrl === "string" ? context.finalUrl : ""; + const failureDom = context.failureDom && typeof context.failureDom === "object" ? context.failureDom : {}; + const login = failureDom.login && typeof failureDom.login === "object" ? failureDom.login : {}; + if (/\/login(?:\?|$)/u.test(finalUrl) || failureDom.authState === "login" || login.cardVisible === true) return "auth-redirect-login"; + if (/session_required|session-not-selected/u.test(message)) return "session-not-selected"; + if (/composer|command-send|command bar|disabledReason|button_disabled/u.test(message)) return "composer-disabled"; if (/fresh session/u.test(message)) return "fresh-session-not-aligned"; if (/login|auth/u.test(message)) return "auth-login-failed"; if (/Timeout|timed out|timeout/u.test(message)) return "browser-timeout"; @@ -1159,12 +1180,13 @@ async function waitForCommandReady(page, args, actions) { const ready = await page.waitForFunction(() => { const input = document.querySelector("#command-input"); const send = document.querySelector("#command-send"); - return Boolean(input && send && !input.disabled); + const warning = document.querySelector(".composer-warning")?.textContent?.trim() ?? ""; + return Boolean(input && send && !input.disabled && !warning); }, null, { timeout: timeoutMs }).catch(() => null); const after = await collectCommandState(page); const result = { action: "command-ready", ready: Boolean(ready), timeoutMs, before, after }; actions.push(result); - if (!ready) throw new Error(`command bar is not ready for submit: ${JSON.stringify(result)}`); + if (!ready) throw new Error(`command composer is not ready for submit: ${JSON.stringify(result)}`); return after; } @@ -1179,6 +1201,9 @@ async function collectCommandState(page) { inputLength: input && typeof input.value === "string" ? input.value.length : null, sendVisible: Boolean(send), sendDisabled: send ? Boolean(send.disabled) : null, + sendAction: send?.getAttribute("data-action") ?? null, + disabledReason: document.querySelector(".composer-warning")?.textContent?.trim() ?? null, + modeText: document.querySelector(".composer-mode")?.textContent?.trim() ?? null, sendText: send?.textContent?.trim() ?? null, formTitle: form?.getAttribute("title") ?? null, messageCount: document.querySelectorAll(".message-card").length, diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index d7bd0e81..310bc1ad 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -142,13 +142,87 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] }); if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } }); if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] }); - if (path === "/v1/hwpod/specs") return json(response, 200, { specs: [] }); - if (path === "/v1/hwpod-node-ops") return json(response, 200, { ok: true, status: "ready", events: [], groups: [] }); + if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload()); + if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload()); + if (path === "/v1/web-performance/summary") return json(response, 200, webPerformanceSummaryPayload()); if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } }); return staticFile(response, path); } +function hwpodSpecsPayload(): JsonRecord { + return { + ok: true, + status: "completed", + count: 1, + availableCount: 0, + nodeOpsContractVersion: "hwpod-node-ops-v1", + specs: [ + { + name: "d601-f103-v2", + hwpodId: "d601-f103-v2", + nodeId: "node-d601-f103-v2", + workspacePath: "F:\\Work\\D601-HWLAB", + source: { caseId: "d601-f103-v2-compile" }, + availability: { + ok: false, + status: "blocked", + checkedAt: "2026-06-18T04:30:00.000Z", + results: [ + { opId: "op_01_workspace_ls", op: "workspace.ls", ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "no outbound WebSocket hwpod-node is connected" } } + ] + } + } + ] + }; +} + +function hwpodNodeOpsPayload(): JsonRecord { + return { + ok: true, + status: "ready", + contractVersion: "hwpod-node-ops-v1", + route: "/v1/hwpod-node-ops", + supportedOps: ["node.health", "workspace.ls", "debug.build", "debug.flash"], + websocket: { connectedCount: 0, pendingCount: 1, nodes: [] }, + events: [ + { ts: "2026-06-18T04:30:00.000Z", status: "blocked", blocker: { code: "hwpod_node_unavailable", summary: "no outbound WebSocket hwpod-node is connected" } } + ] + }; +} + +function webPerformanceSummaryPayload(): JsonRecord { + const rows = [ + performanceRow({ kind: "workbench-journey", metric: "session-switch-full-load", route: "/workbench/sessions/:sessionId", count: 21, p50: 0.18, p75: 0.31, p95: 1.12, tone: "warn", backend: "agentrun", transport: "sse", targetState: "completed" }), + performanceRow({ kind: "workbench-event-phase", metric: "trace-event-projected", route: "/workbench", count: 28, p50: 0.08, p75: 0.14, p95: 0.42, tone: "ok", eventType: "tool_result", phase: "render", source: "rum" }), + performanceRow({ kind: "workbench-backend-event", metric: "backend-event-visible", route: "/v1/workbench/events", count: 15, p50: 0.2, p75: 0.34, p95: 1.38, tone: "warn", eventType: "agentrun.stdout", backend: "agentrun", cache: "miss" }), + performanceRow({ kind: "web-vital", metric: "LCP", route: "/performance", count: 9, p50: 1.2, p75: 1.8, p95: 2.9, tone: "ok" }), + performanceRow({ kind: "api", metric: "fetchJson", route: "/v1/web-performance/summary", method: "GET", statusClass: "2xx", outcome: "ok", count: 17, p50: 0.09, p75: 0.15, p95: 0.33, tone: "ok" }), + performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy" }) + ]; + return { + ok: true, + status: "ready", + source: "fake-server", + observedAt: "2026-06-18T04:30:00.000Z", + namespace: "hwlab-v03", + gitopsTarget: "D601/v03", + summary: { status: "ready", sampleCount: 93, sampleSeries: 12, problemCount: 2, lowSampleThreshold: 5, workbenchJourneySeries: 1, workbenchEventPhaseSeries: 1, workbenchBackendEventVisibleSeries: 1 }, + rows, + problems: rows.filter((row) => row.tone === "warn"), + webVitals: rows.filter((row) => row.kind === "web-vital"), + apiRoutes: rows.filter((row) => row.kind === "api"), + longTasks: rows.filter((row) => row.kind === "long-task"), + workbenchJourneys: rows.filter((row) => row.kind === "workbench-journey"), + workbenchEventPhases: rows.filter((row) => row.kind === "workbench-event-phase"), + workbenchBackendEvents: rows.filter((row) => row.kind === "workbench-backend-event") + }; +} + +function performanceRow(row: JsonRecord): JsonRecord { + return { method: "GET", statusClass: "2xx", outcome: "ok", unit: "seconds", sampleState: "ok", ...row }; +} + function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 6ba105c8..75ee4cfc 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -1780,8 +1780,11 @@ } .data-table-wrap { + max-width: 100%; min-width: 0; overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; } .data-table { @@ -2305,10 +2308,91 @@ gap: 14px; } +.hwpod-groups-section, +.hwpod-raw-panel { + display: grid; + min-width: 0; + gap: 12px; + padding: 14px; +} + +.hwpod-node-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} + +.hwpod-node-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px 10px; + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 10px; +} + +.hwpod-node-row strong, +.hwpod-node-row small, +.hwpod-node-row code { + min-width: 0; + overflow-wrap: anywhere; +} + +.hwpod-node-row small, +.hwpod-node-row code { + grid-column: 1 / -1; + color: #475569; +} + +.hwpod-op-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hwpod-op-list code { + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + padding: 5px 8px; + font-size: 12px; +} + +.hwpod-blocker { + color: #7f1d1d; +} + .performance-wide-panel { grid-column: 1 / -1; } +.performance-page .table-page-layout { + overflow: hidden; +} + +.performance-page .data-table-wrap { + border: 1px solid #d8e1eb; + border-radius: 8px; +} + +.performance-page .data-table { + width: max-content; + min-width: 780px; + border: 0; +} + +.performance-page .data-table th, +.performance-page .data-table td { + white-space: nowrap; +} + +.performance-page .performance-dimensions { + min-width: 220px; + white-space: normal; +} + .performance-dimensions { display: block; max-width: 360px; diff --git a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue index 4c6c479e..a92960b7 100644 --- a/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/HwpodGroupsView.vue @@ -1,20 +1,144 @@ diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts new file mode 100644 index 00000000..cdf2f8c9 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "../fixtures/test"; + +test("admin HWPOD groups renders node-ops as structured UI before raw JSON is requested", async ({ page }) => { + await page.goto("/admin/hwpod-groups"); + + await expect(page.getByRole("heading", { name: "HWPOD 分组" })).toBeVisible(); + await expect(page.getByText("hwpod-node-ops-v1")).toBeVisible(); + await expect(page.getByText("/v1/hwpod-node-ops")).toBeVisible(); + await expect(page.getByText("node.health")).toBeVisible(); + await expect(page.getByText("node-d601-f103-v2")).toBeVisible(); + + await expect(page.locator("[data-testid='hwpod-raw-json']")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText('"contractVersion"'); + + await page.getByTestId("hwpod-raw-json-toggle").click(); + await expect(page.getByTestId("hwpod-raw-json")).toContainText('"contractVersion"'); +}); + +test("performance tables keep wide columns reachable inside local mobile scrollers", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/performance"); + + await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByText("Problem rows")).toBeVisible(); + + const scrollers = await page.locator(".performance-page .data-table-wrap").evaluateAll((items) => items.map((item) => ({ + clientWidth: Math.round(item.clientWidth), + scrollWidth: Math.round(item.scrollWidth), + overflowX: getComputedStyle(item).overflowX + }))); + expect(scrollers.length).toBeGreaterThanOrEqual(4); + expect(scrollers.every((item) => item.clientWidth <= 390)).toBeTruthy(); + expect(scrollers.some((item) => item.scrollWidth > item.clientWidth + 120)).toBeTruthy(); + expect(scrollers.every((item) => item.overflowX === "auto" || item.overflowX === "scroll")).toBeTruthy(); + + const documentScrollWidth = await page.evaluate(() => Math.round(document.documentElement.scrollWidth)); + expect(documentScrollWidth).toBeLessThanOrEqual(390); +});