fix: add web-probe screenshot and tighten sentinel dashboard

This commit is contained in:
Codex
2026-06-26 11:06:09 +00:00
parent 3e09444488
commit 49ce6c41be
7 changed files with 526 additions and 30 deletions
+275 -1
View File
@@ -28,7 +28,7 @@ import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRul
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";
import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry";
import type { BootstrapAdminPasswordMaterial, NodeWebProbeObserveCommandType, NodeWebProbeObserveOptions, NodeWebProbeOptions, NodeWebProbeRunOptions, NodeWebProbeScreenshotOptions, NodeWebProbeSentinelOptions, RuntimeSecretSpec, WebObserveIndexEntry, WebProbeBrowserProxyMode } from "./entry";
import { runTransWorkspaceStdinScript, runtimeSecretSpec } from "./public-exposure";
import { transPath } from "./runtime-common";
import { assertLane, assertNodeId, compactCommandResult, compactCommandResultRedacted, compactCommandResultWithStdoutTail, nullableRecord, optionValue, parseJsonObject, positiveIntegerOption, record, requiredOption, shellQuote } from "./utils";
@@ -434,6 +434,7 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, options.node);
if (options.action === "sentinel") return runWebProbeSentinelCommand(spec, options.sentinel);
if (options.action === "screenshot") return runNodeWebProbeScreenshot(options, spec);
if (options.action === "observe" && options.observeAction !== "start") return runNodeWebProbeObserve(options, spec, null, null, null);
const secretSpec = runtimeSecretSpec({ node: options.node, lane });
const material = readBootstrapAdminPasswordMaterial(secretSpec);
@@ -497,6 +498,279 @@ export function runNodeWebProbe(options: NodeWebProbeOptions): Record<string, un
});
}
export function runNodeWebProbeScreenshot(options: NodeWebProbeScreenshotOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const route = `${options.node}:${spec.workspace}`;
const script = webProbeScreenshotRemoteScript(options);
const result = runCommand([
transPath(),
route,
"playwright",
"--local-dir",
options.localDir,
"--wait-timeout-ms",
String(options.waitTimeoutMs),
"--inactivity-timeout-ms",
"30000",
...(options.keepRemote ? ["--keep-remote"] : []),
], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 });
const transport = parseJsonObject(result.stdout);
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record) : [];
const screenshot = artifacts.find((artifact) => {
const remotePath = typeof artifact.remotePath === "string" ? artifact.remotePath : "";
const localPath = typeof artifact.localPath === "string" ? artifact.localPath : "";
return remotePath.endsWith(".png") || localPath.endsWith(".png");
}) ?? null;
const remoteSummary = parseWebProbeScreenshotSummary(record(transport.remote).stdoutTail);
const compactScreenshot = screenshot === null ? null : compactWebProbeScreenshotArtifact(screenshot);
const compactArtifacts = artifacts.map(compactWebProbeScreenshotArtifact);
const remoteRecord = record(transport.remote);
const ok = result.exitCode === 0 && transport.ok === true && screenshot !== null && screenshot.verified !== false;
const degradedReason = ok
? null
: result.timedOut
? "web-probe-screenshot-command-timeout"
: transport.ok === false
? "web-probe-screenshot-remote-failed"
: screenshot === null
? "web-probe-screenshot-artifact-missing"
: "web-probe-screenshot-download-unverified";
return {
ok,
status: ok ? "pass" : "blocked",
command: `web-probe screenshot --node ${options.node} --lane ${options.lane}`,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
route,
url: options.url,
viewport: options.viewport,
localDir: options.localDir,
screenshot: compactScreenshot,
artifacts: compactArtifacts,
artifactCount: artifacts.length,
remote: {
exitCode: remoteRecord.exitCode ?? null,
remoteDir: remoteRecord.remoteDir ?? null,
defaultScreenshot: remoteRecord.defaultScreenshot ?? null,
stdoutTail: ok ? "" : typeof remoteRecord.stdoutTail === "string" ? remoteRecord.stdoutTail.slice(-1200) : "",
stderrTail: ok ? "" : typeof remoteRecord.stderrTail === "string" ? remoteRecord.stderrTail.slice(-1200) : "",
},
page: compactWebProbeScreenshotPageSummary(remoteSummary),
transport: {
runId: transport.runId ?? null,
artifactCount: transport.artifactCount ?? null,
expectedArtifactCount: transport.expectedArtifactCount ?? null,
cleanup: compactWebProbeScreenshotCleanup(transport.cleanup),
downloadFailure: transport.downloadFailure ?? null,
},
result: compactCommandResult(result),
degradedReason,
valuesRedacted: true,
};
}
function compactWebProbeScreenshotArtifact(artifact: Record<string, unknown>): Record<string, unknown> {
const transfer = record(artifact.transfer);
return {
remotePath: typeof artifact.remotePath === "string" ? artifact.remotePath : null,
localPath: typeof artifact.localPath === "string" ? artifact.localPath : null,
bytes: Number.isFinite(Number(artifact.bytes)) ? Number(artifact.bytes) : null,
sha256: typeof artifact.sha256 === "string" ? artifact.sha256 : null,
verified: artifact.verified === true,
transfer: Object.keys(transfer).length === 0 ? null : {
strategy: transfer.strategy ?? null,
transport: transfer.transport ?? null,
chunks: transfer.chunks ?? null,
elapsedMs: transfer.elapsedMs ?? null,
throughputBytesPerSecond: transfer.throughputBytesPerSecond ?? null,
},
};
}
function compactWebProbeScreenshotPageSummary(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
const layout = record(value.layout);
return {
ok: value.ok === true,
status: value.status ?? null,
title: value.title ?? null,
finalUrl: value.finalUrl ?? null,
executablePath: value.executablePath ?? null,
viewport: value.viewport ?? null,
fullPage: value.fullPage ?? null,
selector: value.selector ?? null,
layout: {
viewport: record(layout.viewport),
documentSize: record(layout.documentSize),
horizontalOverflow: layout.horizontalOverflow === true,
overflowCount: layout.overflowCount ?? null,
overflow: Array.isArray(layout.overflow) ? layout.overflow.slice(0, 5).map(record) : [],
},
consoleCount: value.consoleCount ?? null,
requestFailureCount: value.requestFailureCount ?? null,
};
}
function compactWebProbeScreenshotCleanup(value: unknown): Record<string, unknown> | null {
const cleanup = record(value);
if (Object.keys(cleanup).length === 0) return null;
return {
attempted: cleanup.attempted ?? null,
kept: cleanup.kept ?? null,
remoteDir: cleanup.remoteDir ?? null,
exitCode: cleanup.exitCode ?? null,
ok: cleanup.ok ?? null,
};
}
function webProbeScreenshotRemoteScript(options: NodeWebProbeScreenshotOptions): string {
const [widthRaw, heightRaw] = options.viewport.split("x");
return [
"set -eu",
`export UNIDESK_WEB_PROBE_SCREENSHOT_URL=${shellQuote(options.url)}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_PATH="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(options.name)}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL=${shellQuote(options.waitUntil)}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
`export UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR=${shellQuote(options.selector ?? "")}`,
"if command -v chromium >/dev/null 2>&1; then",
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v chromium)",
"elif command -v chromium-browser >/dev/null 2>&1; then",
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v chromium-browser)",
"elif command -v google-chrome >/dev/null 2>&1; then",
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=$(command -v google-chrome)",
"else",
" export UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH=",
"fi",
"cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\" <<'WEB_PROBE_SCREENSHOT_JS'",
webProbeScreenshotRemoteModule(),
"WEB_PROBE_SCREENSHOT_JS",
"bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-screenshot.mjs\"",
].join("\n");
}
function webProbeScreenshotRemoteModule(): string {
return String.raw`import { chromium } from "playwright";
const url = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_URL;
const screenshotPath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_PATH;
const width = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WIDTH || 1440);
const height = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_WEB_PROBE_SCREENSHOT_TIMEOUT_MS || 30000);
const waitUntil = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_WAIT_UNTIL || "networkidle";
const fullPage = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0";
const selector = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_SELECTOR || "";
const executablePath = process.env.UNIDESK_WEB_PROBE_SCREENSHOT_EXECUTABLE_PATH || "";
if (!url || !screenshotPath) throw new Error("missing screenshot URL or path");
const consoleMessages = [];
const requestFailures = [];
const launchOptions = {
headless: true,
args: ["--disable-gpu", "--no-sandbox"],
...(executablePath ? { executablePath } : {}),
};
const browser = await chromium.launch(launchOptions);
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, isMobile: width <= 560 });
const page = await context.newPage();
page.on("console", (message) => {
if (consoleMessages.length < 20) consoleMessages.push({ type: message.type(), text: message.text().slice(0, 240) });
});
page.on("requestfailed", (request) => {
if (requestFailures.length < 20) requestFailures.push({ url: request.url().slice(0, 240), method: request.method(), failure: request.failure()?.errorText || null });
});
let status = null;
try {
const response = await page.goto(url, { timeout, waitUntil });
status = response?.status() ?? null;
await page.waitForTimeout(350);
if (selector) await page.locator(selector).screenshot({ path: screenshotPath, timeout });
else await page.screenshot({ path: screenshotPath, fullPage, animations: "disabled" });
const layout = await page.evaluate(() => {
const doc = document.documentElement;
const body = document.body;
const viewport = { width: window.innerWidth, height: window.innerHeight };
const documentSize = {
width: Math.max(doc.scrollWidth, body?.scrollWidth || 0),
height: Math.max(doc.scrollHeight, body?.scrollHeight || 0),
};
const overflow = [];
let overflowCount = 0;
for (const element of Array.from(document.querySelectorAll("body *"))) {
const rect = element.getBoundingClientRect();
const right = rect.right;
const bottom = rect.bottom;
const overflowRight = right - viewport.width;
const overflowLeft = -rect.left;
if (overflowRight > 1 || overflowLeft > 1) {
overflowCount += 1;
if (overflow.length < 20) {
overflow.push({
tag: element.tagName.toLowerCase(),
className: String(element.className || "").slice(0, 120),
text: String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 120),
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
overflowRight: Math.max(0, Math.round(overflowRight)),
overflowLeft: Math.max(0, Math.round(overflowLeft)),
bottom: Math.round(bottom),
});
}
}
}
return {
title: document.title,
finalUrl: window.location.href,
viewport,
documentSize,
horizontalOverflow: documentSize.width > viewport.width + 1,
overflowCount,
overflow,
};
});
console.log("__WEB_PROBE_SCREENSHOT_JSON__" + JSON.stringify({
ok: true,
url,
finalUrl: page.url(),
status,
title: await page.title(),
screenshotPath,
executablePath: executablePath || null,
viewport: { width, height },
fullPage,
selector: selector || null,
layout,
consoleCount: consoleMessages.length,
requestFailureCount: requestFailures.length,
consoleMessages,
requestFailures,
valuesRedacted: true,
}));
} finally {
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
`;
}
function parseWebProbeScreenshotSummary(value: unknown): Record<string, unknown> | null {
if (typeof value !== "string" || value.length === 0) return null;
const marker = "__WEB_PROBE_SCREENSHOT_JSON__";
const index = value.lastIndexOf(marker);
if (index < 0) return null;
try {
return record(JSON.parse(value.slice(index + marker.length).trim()));
} catch {
return null;
}
}
export function nodeWebProbeRunArgs(options: NodeWebProbeRunOptions, command: "run" | "start"): string[] {
const probeArgs = [
"node",