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
+19 -1
View File
@@ -91,6 +91,24 @@ export interface NodeWebProbeScriptOptions {
};
}
export interface NodeWebProbeScreenshotOptions {
action: "screenshot";
node: string;
lane: string;
url: string;
path: string | null;
viewport: string;
localDir: string;
name: string;
timeoutMs: number;
waitUntil: "load" | "domcontentloaded" | "networkidle" | "commit";
fullPage: boolean;
selector: string | null;
keepRemote: boolean;
waitTimeoutMs: number;
commandTimeoutSeconds: number;
}
export type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop" | "collect" | "analyze";
export type NodeWebProbeObserveCommandType =
@@ -188,7 +206,7 @@ export interface NodeWebProbeSentinelOptions {
lane: string;
}
export type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeObserveOptions | NodeWebProbeSentinelOptions;
export type NodeWebProbeOptions = NodeWebProbeRunOptions | NodeWebProbeScriptOptions | NodeWebProbeScreenshotOptions | NodeWebProbeObserveOptions | NodeWebProbeSentinelOptions;
export interface WebObserveIndexEntry {
id: string;
+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",
+72 -1
View File
@@ -1312,7 +1312,7 @@ export function rewriteDelegatedNodeString(value: string, scoped: ReturnType<typ
export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
const [actionRaw] = args;
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|observe|sentinel --node NODE --lane vNN [--url URL]");
if (actionRaw !== "run" && actionRaw !== "script" && actionRaw !== "screenshot" && actionRaw !== "observe" && actionRaw !== "sentinel") throw new Error("web-probe usage: run|script|screenshot|observe|sentinel --node NODE --lane vNN [--url URL]");
if (actionRaw === "sentinel") return parseNodeWebProbeSentinelOptions(args.slice(1));
if (actionRaw === "observe") {
const normalized = normalizeNodeWebProbeObserveArgs(args.slice(1));
@@ -1342,6 +1342,49 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
assertLane(lane);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
if (actionRaw === "screenshot") {
assertKnownOptions(args.slice(1), new Set([
"--node",
"--lane",
"--url",
"--path",
"--viewport",
"--local-dir",
"--name",
"--timeout-ms",
"--wait-until",
"--selector",
"--wait-timeout-ms",
"--command-timeout-seconds",
]), new Set([
"--full-page",
"--no-full-page",
"--keep-remote",
]));
const url = optionValue(args, "--url");
const targetPath = optionValue(args, "--path") ?? null;
if (url !== undefined && targetPath !== null) throw new Error("web-probe screenshot accepts --url or --path, not both");
const timeoutMs = positiveIntegerOption(args, "--timeout-ms", 30000, 120000);
const waitTimeoutMs = positiveIntegerOption(args, "--wait-timeout-ms", Math.max(90000, timeoutMs + 30000), 600000);
const commandTimeoutSeconds = positiveIntegerOption(args, "--command-timeout-seconds", Math.ceil(waitTimeoutMs / 1000) + 45, 900);
return {
action: "screenshot",
node,
lane,
url: url ?? resolveWebProbeScreenshotUrl(spec, targetPath ?? "/"),
path: targetPath,
viewport: parseWebProbeScreenshotViewport(optionValue(args, "--viewport") ?? "1440x900"),
localDir: optionValue(args, "--local-dir") ?? "/tmp",
name: parseWebProbeScreenshotName(optionValue(args, "--name") ?? `web-probe-${node.toLowerCase()}-${lane}.png`),
timeoutMs,
waitUntil: parseWebProbeScreenshotWaitUntil(optionValue(args, "--wait-until") ?? "networkidle"),
fullPage: !args.includes("--no-full-page"),
selector: optionValue(args, "--selector") ?? null,
keepRemote: args.includes("--keep-remote"),
waitTimeoutMs,
commandTimeoutSeconds,
};
}
if (actionRaw === "script") {
assertKnownOptions(args.slice(1), new Set([
"--node",
@@ -1441,3 +1484,31 @@ export function parseNodeWebProbeOptions(args: string[]): NodeWebProbeOptions {
commandTimeoutUserProvided,
};
}
function resolveWebProbeScreenshotUrl(spec: HwlabRuntimeLaneSpec, targetPath: string): string {
const origin = nodeWebProbeDefaultUrl(spec);
try {
return new URL(targetPath || "/", origin).toString();
} catch {
throw new Error(`web-probe screenshot --path cannot be resolved against ${origin}: ${targetPath}`);
}
}
function parseWebProbeScreenshotViewport(value: string): string {
if (!/^[1-9][0-9]{1,4}x[1-9][0-9]{1,4}$/u.test(value)) throw new Error(`web-probe screenshot --viewport must look like 1440x900, got ${value}`);
const [widthRaw, heightRaw] = value.split("x");
const width = Number(widthRaw);
const height = Number(heightRaw);
if (width < 240 || width > 7680 || height < 240 || height > 4320) throw new Error(`web-probe screenshot --viewport out of range: ${value}`);
return value;
}
function parseWebProbeScreenshotName(value: string): string {
if (!/^[A-Za-z0-9._-]{1,120}$/u.test(value)) throw new Error("web-probe screenshot --name must contain only letters, numbers, dot, underscore, or dash, max 120 chars");
return value.endsWith(".png") ? value : `${value}.png`;
}
function parseWebProbeScreenshotWaitUntil(value: string): "load" | "domcontentloaded" | "networkidle" | "commit" {
if (value === "load" || value === "domcontentloaded" || value === "networkidle" || value === "commit") return value;
throw new Error(`web-probe screenshot --wait-until must be load, domcontentloaded, networkidle, or commit; got ${value}`);
}