Merge pull request #1013 from pikasTech/fix/webprobe-screenshot-monitor-1012
fix: web-probe 远程截图命令化并收紧哨兵 monitor 布局
This commit is contained in:
@@ -263,20 +263,20 @@ select {
|
||||
}
|
||||
|
||||
.overview-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.check-chip {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
max-width: 100%;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 999px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
@@ -301,11 +301,12 @@ select {
|
||||
}
|
||||
|
||||
.run-timeline {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
min-height: 68px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.timeline-node {
|
||||
@@ -313,8 +314,9 @@ select {
|
||||
grid-template-rows: 18px auto;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
min-width: 76px;
|
||||
max-width: 120px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #475467;
|
||||
@@ -342,6 +344,7 @@ select {
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr);
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -462,6 +465,33 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.run-identity {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.run-identity div {
|
||||
display: grid;
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-identity span {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.run-identity code {
|
||||
min-width: 0;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
@@ -470,10 +500,49 @@ select {
|
||||
|
||||
.finding-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.finding-group {
|
||||
min-width: 0;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.finding-group summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 10px;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.finding-group summary span {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.finding-group summary strong {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.finding-group summary small {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.finding-group-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.finding-aggregation {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -519,6 +588,13 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.finding-summary {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.finding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -827,6 +903,10 @@ select {
|
||||
.metric-card {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.run-timeline {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
@@ -862,4 +942,12 @@ select {
|
||||
.findings-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.run-timeline {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.finding-group:not([open]) summary {
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,12 +292,13 @@ function renderDashboard() {
|
||||
function renderOverview() {
|
||||
const overview = state.overview || {};
|
||||
const status = overview.status || (overview.ok ? "healthy" : "degraded");
|
||||
refs.statusPill.textContent = displayStatus(status);
|
||||
refs.statusPill.className = `status-pill ${statusClass(status)}`;
|
||||
refs.overall.textContent = displayStatus(status);
|
||||
refs.origin.textContent = overview.publicOrigin || root.dataset.publicOrigin || "-";
|
||||
|
||||
const latest = overview.latestRun || null;
|
||||
const latestStatus = latest?.status || status;
|
||||
refs.statusPill.textContent = displayStatus(latestStatus);
|
||||
refs.statusPill.className = `status-pill ${statusClass(latestStatus)}`;
|
||||
refs.overall.textContent = displayStatus(latestStatus);
|
||||
refs.origin.textContent = `${overview.publicOrigin || root.dataset.publicOrigin || "-"} · 历史 ${displayStatus(status)}`;
|
||||
|
||||
refs.latestRun.textContent = latest?.runId || latest?.id || "-";
|
||||
refs.latestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} 更新` : "-";
|
||||
|
||||
@@ -322,12 +323,13 @@ function renderOverview() {
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
refs.timelineCount.textContent = `最近 ${formatNumber(state.runs.length)} 次`;
|
||||
const visibleRuns = state.runs.slice(0, 12);
|
||||
refs.timelineCount.textContent = `最近 ${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 次`;
|
||||
if (state.runs.length === 0) {
|
||||
refs.timeline.innerHTML = '<div class="empty-state">暂无时间线</div>';
|
||||
return;
|
||||
}
|
||||
refs.timeline.innerHTML = state.runs.slice(0, 20).map((run) => {
|
||||
refs.timeline.innerHTML = visibleRuns.map((run) => {
|
||||
const runId = run.runId || run.id || "-";
|
||||
const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${run.updatedAt ? formatRelative(run.updatedAt) : "-"}`;
|
||||
return `<button class="timeline-node ${statusClass(run.status)}" type="button" data-run-id="${escapeAttr(runId)}" title="${escapeAttr(title)}">
|
||||
@@ -350,7 +352,10 @@ function renderRuns() {
|
||||
const runId = run.runId || run.id || "-";
|
||||
const selected = state.selectedRunId === runId ? " selected-row" : "";
|
||||
return `<tr class="${selected}" data-run-id="${escapeAttr(runId)}">
|
||||
<td><div class="mono">${escapeHtml(runId)}</div><small>${escapeHtml(run.observerId || "-")}</small></td>
|
||||
<td><div class="run-identity">
|
||||
<div><span>run</span><code>${escapeHtml(runId)}</code></div>
|
||||
<div><span>observer</span><code>${escapeHtml(run.observerId || "-")}</code></div>
|
||||
</div></td>
|
||||
<td><span class="status-pill ${statusClass(run.status)}">${escapeHtml(displayStatus(run.status))}</span></td>
|
||||
<td>${escapeHtml(run.scenarioId || "-")}</td>
|
||||
<td>${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(displaySeverity(run.maxSeverity))}</span>` : ""}</td>
|
||||
@@ -369,7 +374,29 @@ function renderFindings() {
|
||||
refs.findingsList.innerHTML = '<div class="empty-state">暂无发现项</div>';
|
||||
return;
|
||||
}
|
||||
refs.findingsList.innerHTML = state.findings.map((item) => {
|
||||
const groups = groupedFindingsBySeverity(state.findings);
|
||||
refs.findingsList.innerHTML = groups.map((group, groupIndex) => {
|
||||
const open = group.key === "red" || groupIndex === 0;
|
||||
const visibleItems = group.items.slice(0, group.key === "red" ? 8 : 5);
|
||||
const hiddenCount = Math.max(0, group.items.length - visibleItems.length);
|
||||
return `<details class="finding-group finding-group-${escapeAttr(group.key)}" ${open ? "open" : ""}>
|
||||
<summary>
|
||||
<span>${escapeHtml(displaySeverity(group.key))}</span>
|
||||
<strong>${formatNumber(group.totalCount)}</strong>
|
||||
<small>${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组通过过滤查看` : ""}</small>
|
||||
</summary>
|
||||
<div class="finding-group-list">${visibleItems.map(renderFindingItem).join("")}</div>
|
||||
</details>`;
|
||||
}).join("");
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) {
|
||||
button.addEventListener("click", () => selectRun(button.dataset.openFindingRun));
|
||||
}
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFindingItem(item) {
|
||||
const code = item.code || item.findingId || "finding";
|
||||
const latestRunId = item.latestRunId || "-";
|
||||
const hasLatestRun = latestRunId !== "-";
|
||||
@@ -379,23 +406,38 @@ function renderFindings() {
|
||||
<span class="finding-title">${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` <small class="mono">${escapeHtml(code)}</small>`}</span>
|
||||
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span>
|
||||
</div>
|
||||
<div class="finding-summary">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}</div>
|
||||
<div class="finding-actions" aria-label="发现项过滤">
|
||||
<button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
|
||||
<button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
|
||||
</div>
|
||||
<div class="finding-meta">次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
|
||||
<div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
|
||||
<div class="finding-meta">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 180))}</div>
|
||||
<div class="finding-meta">${escapeHtml(findingNextAction(code))}</div>
|
||||
<button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>打开最近运行</button>
|
||||
</article>`;
|
||||
}).join("");
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-open-finding-run]")) {
|
||||
button.addEventListener("click", () => selectRun(button.dataset.openFindingRun));
|
||||
}
|
||||
for (const button of refs.findingsList.querySelectorAll("[data-finding-filter]")) {
|
||||
button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
|
||||
}
|
||||
|
||||
function groupedFindingsBySeverity(findings) {
|
||||
const severityOrder = ["red", "critical", "error", "warning", "amber", "info", "unknown"];
|
||||
const groups = new Map();
|
||||
for (const item of findings) {
|
||||
const key = String(item.severity || "unknown").toLowerCase();
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(item);
|
||||
}
|
||||
return Array.from(groups.entries())
|
||||
.map(([key, items]) => ({
|
||||
key,
|
||||
items: items.sort((a, b) => Number(b.count || 0) - Number(a.count || 0)),
|
||||
totalCount: items.reduce((sum, item) => sum + Number(item.count || 0), 0),
|
||||
}))
|
||||
.sort((a, b) => severityRank(a.key, severityOrder) - severityRank(b.key, severityOrder));
|
||||
}
|
||||
|
||||
function severityRank(key, order) {
|
||||
const index = order.indexOf(key);
|
||||
return index >= 0 ? index : order.length;
|
||||
}
|
||||
|
||||
function renderFindingAggregation() {
|
||||
|
||||
@@ -43,6 +43,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts web-probe run --node D601 --lane v03 --wait-messages-ms 1000",
|
||||
"bun scripts/cli.ts web-probe run --node D601 --lane v03 --fresh-session --message 'ping'",
|
||||
"bun scripts/cli.ts web-probe script --node D601 --lane v03 --script-file .state/probes/workbench.mjs",
|
||||
"bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 1440x900",
|
||||
"bun scripts/cli.ts web-probe screenshot --node D601 --lane v03 --url https://monitor.pikapython.com --viewport 390x844 --name monitor-mobile.png",
|
||||
"bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /workbench --sample-interval-ms 5000",
|
||||
"bun scripts/cli.ts web-probe observe start --node D601 --lane v03 --target-path /projects/mdtodo --sample-interval-ms 5000",
|
||||
"bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'",
|
||||
@@ -58,6 +60,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
|
||||
actions: {
|
||||
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
|
||||
script: "Run caller-provided Playwright JS after CLI-managed /auth/login; scripts must not handle secrets themselves.",
|
||||
screenshot: "Capture a no-auth or public page through the selected node/lane remote browser and download PNG artifacts to the caller /tmp by default.",
|
||||
observe: "Start, inspect, control, stop, collect, and analyze a long-running observer that writes JSONL artifacts.",
|
||||
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, maintenance and report views.",
|
||||
},
|
||||
|
||||
@@ -63,7 +63,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
|
||||
<section class="metric-grid" aria-label="哨兵状态指标">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">总体状态</span>
|
||||
<span class="metric-label">当前状态</span>
|
||||
<strong id="metric-overall">-</strong>
|
||||
<small id="metric-origin">-</small>
|
||||
</article>
|
||||
@@ -73,7 +73,7 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
|
||||
<small id="metric-latest-age">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">发现项</span>
|
||||
<span class="metric-label">历史发现项</span>
|
||||
<strong id="metric-findings">0</strong>
|
||||
<small id="metric-findings-note">-</small>
|
||||
</article>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user