Merge pull request #1062 from pikasTech/fix/1056-monitor-dashboard

fix(web-probe): recover monitor dashboard render
This commit is contained in:
Lyon
2026-06-27 00:08:24 +08:00
committed by GitHub
10 changed files with 713 additions and 9 deletions
@@ -299,6 +299,79 @@ select {
margin-left: auto;
}
.sentinel-registry-strip {
display: grid;
grid-template-columns: minmax(160px, 220px) 1fr;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
}
.sentinel-registry-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sentinel-registry-copy strong {
font-size: 13px;
line-height: 1.25;
}
.sentinel-registry-copy span {
color: var(--muted);
font-size: 11px;
}
.sentinel-registry-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.sentinel-registry-link {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 28px;
padding: 4px 8px;
border: 1px solid #d8e0ea;
border-radius: 6px;
background: #f8fafc;
color: #344054;
font-size: 12px;
font-weight: 650;
text-decoration: none;
}
.sentinel-registry-link.current {
border-color: #a7c5f9;
background: #edf5ff;
color: #1d4ed8;
}
.sentinel-registry-link.disabled {
opacity: 0.62;
}
.sentinel-registry-link span {
overflow-wrap: anywhere;
}
.sentinel-registry-link small {
color: var(--muted);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.check-summary-pill {
min-height: 26px;
padding: 3px 10px;
@@ -1195,6 +1268,10 @@ select {
margin-left: 0;
}
.sentinel-registry-strip {
grid-template-columns: 1fr;
}
.runs-filter {
flex-wrap: wrap;
}
@@ -73,6 +73,8 @@ const refs = {
copyToast: document.getElementById("copy-toast"),
};
const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"];
const state = {
loading: false,
selectedRunId: null,
@@ -99,8 +101,6 @@ const state = {
pausedByInteraction: false,
};
const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"];
const dashboardLimits = {
timeline: { mobile: 6, tablet: 9, desktop: 16 },
runs: { mobile: 8, tablet: 12, desktop: 30 },
+4 -2
View File
@@ -62,6 +62,8 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel dashboard verify --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x",
"bun scripts/cli.ts web-probe sentinel dashboard screenshot --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
"bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id <id>",
],
actions: {
@@ -69,7 +71,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
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.",
sentinel: "Render and operate the YAML-first web-probe sentinel wrapper, image, GitOps, dashboard verification, maintenance and report views.",
},
notes: [
"Default URL, browser proxy mode, observe/analyze thresholds, and project-management command allowlist come from config/hwlab-node-lanes.yaml webProbe.",
@@ -78,7 +80,7 @@ export function hwlabNodeWebProbeHelp(): Record<string, unknown> {
"After observe start, prefer observe status|command|stop|collect|analyze <id> instead of repeating --node/--lane/--state-dir.",
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/report require `--sentinel <id>`; plan/status without it show the registry drill-down.",
"When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/dashboard/report require `--sentinel <id>`; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
],
};
+359
View File
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -8,6 +9,7 @@ import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { transPath } from "./hwlab-node/runtime-common";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
@@ -17,6 +19,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status";
export type WebProbeSentinelImageAction = "status" | "build";
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
export type WebProbeSentinelDashboardAction = "verify" | "screenshot";
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
export type WebProbeSentinelOptions =
@@ -89,6 +92,22 @@ export type WebProbeSentinelOptions =
readonly sampleSeq: number | null;
readonly raw: boolean;
readonly timeoutSeconds: number;
}
| {
readonly kind: "dashboard";
readonly action: WebProbeSentinelDashboardAction;
readonly node: string;
readonly lane: string;
readonly sentinelId: string | null;
readonly viewport: string;
readonly localDir: string;
readonly name: string | null;
readonly timeoutMs: number;
readonly waitTimeoutMs: number;
readonly timeoutSeconds: number;
readonly commandTimeoutSeconds: number;
readonly fullPage: boolean;
readonly raw: boolean;
};
interface SentinelCicdState {
@@ -181,6 +200,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
if (options.kind === "validate") return runSentinelValidate(state, options);
if (options.kind === "dashboard") return runSentinelDashboard(state, options);
return runSentinelReport(state, options);
}
@@ -1747,6 +1767,296 @@ function runSentinelReport(state: SentinelCicdState, options: Extract<WebProbeSe
return rendered(report.ok && body.ok !== false, command, options.raw ? JSON.stringify(rawPayload, null, 2) : renderedText);
}
function runSentinelDashboard(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>): RenderedCliResult {
const command = `web-probe sentinel dashboard ${options.action}`;
const result = probeSentinelDashboardBrowser(state, options);
return rendered(result.ok === true, command, options.raw ? JSON.stringify(result, null, 2) : renderDashboardResult(result));
}
function probeSentinelDashboardBrowser(state: SentinelCicdState, options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>): Record<string, unknown> {
const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, "");
const [widthRaw, heightRaw] = options.viewport.split("x");
const screenshotName = options.action === "screenshot" ? dashboardScreenshotName(options, state) : "";
const script = [
"set -eu",
`export UNIDESK_SENTINEL_DASHBOARD_URL=${shellQuote(`${publicBaseUrl}/`)}`,
`export UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT="$UNIDESK_PLAYWRIGHT_REMOTE_DIR"/${shellQuote(screenshotName)}`,
`export UNIDESK_SENTINEL_DASHBOARD_CAPTURE=${shellQuote(options.action === "screenshot" ? "1" : "0")}`,
`export UNIDESK_SENTINEL_DASHBOARD_WIDTH=${shellQuote(widthRaw ?? "1440")}`,
`export UNIDESK_SENTINEL_DASHBOARD_HEIGHT=${shellQuote(heightRaw ?? "900")}`,
`export UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`export UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE=${shellQuote(options.fullPage ? "1" : "0")}`,
"if command -v chromium >/dev/null 2>&1; then",
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v chromium)",
"elif command -v chromium-browser >/dev/null 2>&1; then",
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v chromium-browser)",
"elif command -v google-chrome >/dev/null 2>&1; then",
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=$(command -v google-chrome)",
"else",
" export UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH=",
"fi",
"cat > \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\" <<'WEB_PROBE_SENTINEL_DASHBOARD_JS'",
sentinelDashboardBrowserModule(),
"WEB_PROBE_SENTINEL_DASHBOARD_JS",
"bun \"$UNIDESK_PLAYWRIGHT_REMOTE_DIR/web-probe-sentinel-dashboard.mjs\"",
].join("\n");
const route = `${state.spec.nodeId}:${state.spec.workspace}`;
const result = runCommand([
transPath(),
route,
"playwright",
"--local-dir",
options.localDir,
"--wait-timeout-ms",
String(options.waitTimeoutMs),
"--inactivity-timeout-ms",
"30000",
], repoRoot, { input: script, timeoutMs: options.commandTimeoutSeconds * 1000 });
const transport = record(parseJsonObject(result.stdout));
const remote = record(transport.remote);
const page = parseDashboardBrowserPayload(typeof remote.stdoutTail === "string" ? remote.stdoutTail : "");
const artifacts = Array.isArray(transport.artifacts) ? transport.artifacts.map(record).map(compactDashboardArtifact) : [];
const screenshot = artifacts.find((artifact) => typeof artifact.localPath === "string" && String(artifact.localPath).endsWith(".png")) ?? null;
const browserOk = page?.ok === true;
const screenshotOk = options.action === "verify" || screenshot !== null && screenshot.verified === true;
const ok = result.exitCode === 0 && transport.ok === true && browserOk && screenshotOk;
return {
ok,
status: ok ? "pass" : "blocked",
command: `web-probe sentinel dashboard ${options.action}`,
node: state.spec.nodeId,
lane: state.spec.lane,
sentinelId: state.sentinelId,
publicUrl: `${publicBaseUrl}/`,
route,
viewport: options.viewport,
page,
screenshot,
artifacts,
artifactCount: artifacts.length,
remote: {
exitCode: remote.exitCode ?? null,
remoteDir: remote.remoteDir ?? null,
stdoutTail: ok ? "" : typeof remote.stdoutTail === "string" ? remote.stdoutTail.slice(-1200) : "",
stderrTail: ok ? "" : typeof remote.stderrTail === "string" ? remote.stderrTail.slice(-1200) : "",
},
transport: {
ok: transport.ok ?? null,
runId: transport.runId ?? null,
artifactCount: transport.artifactCount ?? null,
expectedArtifactCount: transport.expectedArtifactCount ?? null,
downloadFailure: transport.downloadFailure ?? null,
},
result: compactCommand(result),
degradedReason: ok ? null : dashboardDegradedReason(result, transport, page, screenshotOk),
valuesRedacted: true,
};
}
function sentinelDashboardBrowserModule(): string {
return String.raw`import { chromium } from "playwright";
const url = process.env.UNIDESK_SENTINEL_DASHBOARD_URL;
const screenshotPath = process.env.UNIDESK_SENTINEL_DASHBOARD_SCREENSHOT || "";
const captureScreenshot = process.env.UNIDESK_SENTINEL_DASHBOARD_CAPTURE === "1";
const width = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_WIDTH || 1440);
const height = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_HEIGHT || 900);
const timeout = Number(process.env.UNIDESK_SENTINEL_DASHBOARD_TIMEOUT_MS || 30000);
const fullPage = process.env.UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0";
const executablePath = process.env.UNIDESK_SENTINEL_DASHBOARD_EXECUTABLE_PATH || "";
if (!url) throw new Error("missing dashboard URL");
const consoleMessages = [];
const pageErrors = [];
const requestFailures = [];
const browser = await chromium.launch({
headless: true,
args: ["--disable-gpu", "--no-sandbox"],
...(executablePath ? { executablePath } : {}),
});
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 < 30) consoleMessages.push({ type: message.type(), text: message.text().slice(0, 300) });
});
page.on("pageerror", (error) => {
if (pageErrors.length < 20) pageErrors.push({ message: String(error?.message || error).slice(0, 500) });
});
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 httpStatus = null;
let navigationError = null;
try {
const response = await page.goto(url, { timeout, waitUntil: "domcontentloaded" });
httpStatus = response?.status() ?? null;
await page.waitForLoadState("networkidle", { timeout: Math.min(15000, timeout) }).catch(() => {});
await page.waitForFunction(() => {
const root = document.querySelector("#sentinel-dashboard");
if (!root) return false;
const error = document.querySelector("#error-banner");
const runs = document.querySelectorAll("#runs-body tr").length;
const statusText = document.querySelector("#status-pill")?.textContent || "";
return (error && !error.hidden) || runs > 0 || (statusText.trim() && statusText.trim() !== "空闲");
}, null, { timeout: Math.min(15000, timeout) }).catch(() => {});
await page.waitForTimeout(500);
} catch (error) {
navigationError = String(error?.message || error).slice(0, 500);
}
if (captureScreenshot && screenshotPath) {
await page.screenshot({ path: screenshotPath, fullPage, animations: "disabled" }).catch((error) => {
pageErrors.push({ message: "screenshot failed: " + String(error?.message || error).slice(0, 400) });
});
}
const dom = await page.evaluate(() => {
const visible = (element) => Boolean(element && !element.hidden);
const text = (selector) => String(document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim();
const root = document.querySelector("#sentinel-dashboard");
const error = document.querySelector("#error-banner");
const loading = document.querySelector("#loading-banner");
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 overflowRight = rect.right - viewport.width;
const overflowLeft = -rect.left;
if (overflowRight > 1 || overflowLeft > 1) {
overflowCount += 1;
if (overflow.length < 5) {
overflow.push({
tag: element.tagName.toLowerCase(),
className: String(element.className || "").slice(0, 80),
text: String(element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 80),
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)),
});
}
}
}
return {
shell: Boolean(root),
dataset: root ? {
node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"),
sentinelId: root.getAttribute("data-sentinel-id"),
basePath: root.getAttribute("data-base-path"),
contractVersion: root.getAttribute("data-contract-version"),
} : {},
title: document.title,
finalUrl: window.location.href,
statusText: text("#status-pill"),
subtitle: text("#sentinel-subtitle"),
summaryText: text("#status-summary"),
runRows: document.querySelectorAll("#runs-body tr").length,
findingItems: document.querySelectorAll("#findings-list > *").length,
detailTabs: document.querySelectorAll("#detail-tabs [data-detail-tab]").length,
timelineItems: document.querySelectorAll("#run-timeline > *").length,
loadingVisible: visible(loading),
errorVisible: visible(error),
errorText: visible(error) ? text("#error-banner").slice(0, 500) : "",
layout: {
viewport,
documentSize,
horizontalOverflow: documentSize.width > viewport.width + 1,
overflowCount,
overflow,
},
};
});
const consoleErrors = consoleMessages.filter((item) => item.type === "error");
const ok = !navigationError
&& httpStatus !== null
&& httpStatus >= 200
&& httpStatus < 300
&& dom.shell === true
&& dom.errorVisible !== true
&& dom.runRows > 0
&& pageErrors.length === 0;
console.log("__WEB_PROBE_SENTINEL_DASHBOARD_JSON__" + JSON.stringify({
ok,
url,
httpStatus,
navigationError,
executablePath: executablePath || null,
viewport: { width, height },
screenshotPath: captureScreenshot ? screenshotPath : null,
dom,
consoleCount: consoleMessages.length,
consoleErrorCount: consoleErrors.length,
pageErrorCount: pageErrors.length,
requestFailureCount: requestFailures.length,
consoleMessages: consoleMessages.slice(0, 8),
pageErrors: pageErrors.slice(0, 8),
requestFailures: requestFailures.slice(0, 8),
valuesRedacted: true,
}));
await context.close().catch(() => {});
await browser.close().catch(() => {});
`;
}
function parseDashboardBrowserPayload(textValue: string): Record<string, unknown> | null {
const marker = "__WEB_PROBE_SENTINEL_DASHBOARD_JSON__";
const index = textValue.lastIndexOf(marker);
if (index < 0) return null;
try {
return record(JSON.parse(textValue.slice(index + marker.length).trim()));
} catch {
return null;
}
}
function dashboardScreenshotName(options: Extract<WebProbeSentinelOptions, { kind: "dashboard" }>, state: SentinelCicdState): string {
const raw = options.name ?? `sentinel-dashboard-${state.spec.nodeId.toLowerCase()}-${state.spec.lane}-${state.sentinelId}.png`;
const safe = raw.replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 120);
return safe.endsWith(".png") ? safe : `${safe}.png`;
}
function compactDashboardArtifact(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 dashboardDegradedReason(result: CommandResult, transport: Record<string, unknown>, page: Record<string, unknown> | null, screenshotOk: boolean): string {
if (result.timedOut) return "sentinel-dashboard-command-timeout";
if (transport.ok !== true) return "sentinel-dashboard-transport-failed";
if (page === null) return "sentinel-dashboard-browser-output-missing";
if (page.ok !== true) return "sentinel-dashboard-render-failed";
if (!screenshotOk) return "sentinel-dashboard-screenshot-missing";
return "sentinel-dashboard-unknown";
}
function renderAsyncP5Job(state: SentinelCicdState, subcommand: readonly string[], timeoutSeconds: number, releaseId: string | null, reason: string | null, quickVerify: boolean): RenderedCliResult {
const args = ["web-probe", "sentinel", ...subcommand, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
if (releaseId !== null) args.push("--release-id", releaseId);
@@ -3461,6 +3771,55 @@ function renderValidateResult(result: Record<string, unknown>): string {
].join("\n");
}
function renderDashboardResult(result: Record<string, unknown>): string {
const page = record(result.page);
const dom = record(page.dom);
const dataset = record(dom.dataset);
const layout = record(dom.layout);
const screenshot = record(result.screenshot);
const remote = record(result.remote);
const transport = record(result.transport);
const degradedReason = result.degradedReason ?? null;
return [
String(result.command),
"",
table(["NODE", "LANE", "SENTINEL", "STATUS", "URL"], [[result.node, result.lane, result.sentinelId, result.ok === true ? "pass" : "blocked", result.publicUrl]]),
"",
table(["HTTP", "SHELL", "RUN_ROWS", "FINDINGS", "TABS", "ERRORS", "CONSOLE_ERR", "REQ_FAIL"], [[
page.httpStatus ?? "-",
dom.shell,
dom.runRows,
dom.findingItems,
dom.detailTabs,
page.pageErrorCount,
page.consoleErrorCount,
page.requestFailureCount,
]]),
"",
table(["TITLE", "STATUS_TEXT", "CONTRACT", "BASE_PATH"], [[dom.title, dom.statusText, dataset.contractVersion, dataset.basePath ?? "-"]]),
"",
table(["VIEWPORT", "DOC", "H_OVERFLOW", "OVERFLOW_COUNT"], [[
result.viewport,
`${record(layout.documentSize).width ?? "-"}x${record(layout.documentSize).height ?? "-"}`,
layout.horizontalOverflow,
layout.overflowCount,
]]),
"",
Object.keys(screenshot).length === 0
? "SCREENSHOT\n-"
: table(["LOCAL_PATH", "BYTES", "SHA256", "VERIFIED"], [[screenshot.localPath, screenshot.bytes, short(screenshot.sha256), screenshot.verified]]),
"",
degradedReason === null ? "BLOCKER\n-" : table(["CODE", "REMOTE_EXIT", "TRANSPORT"], [[degradedReason, remote.exitCode, transport.ok]]),
"",
"NEXT",
` screenshot: bun scripts/cli.ts web-probe sentinel dashboard screenshot --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId}`,
` validate: bun scripts/cli.ts web-probe sentinel validate --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId}`,
"",
"DISCLOSURE",
" dashboard verify uses the YAML publicExposure URL and remote browser execution; it does not start a sentinel run or inspect provider payloads.",
].join("\n");
}
function renderReportResult(result: Record<string, unknown>): string {
const report = record(result.report);
const body = record(report.bodyJson);
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p10-monitor-web-aggregation.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
@@ -8,16 +9,17 @@ interface DashboardShellConfig {
readonly node: string;
readonly lane: string;
readonly sentinelId: string;
readonly plan: { readonly ok: boolean };
readonly plan: { readonly ok: boolean; readonly sentinels?: readonly Record<string, unknown>[] };
readonly publicExposure: Record<string, unknown>;
}
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p10-monitor-web-aggregation";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin);
const registryHtml = renderSentinelRegistryStrip(config, basePath);
return `<!doctype html>
<html lang="zh-CN">
<head>
@@ -77,6 +79,8 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
<span class="summary-item summary-checks" id="summary-checks" hidden></span>
</section>
${registryHtml}
<section id="loading-banner" class="banner banner-muted" hidden></section>
<section id="error-banner" class="banner banner-danger" hidden></section>
@@ -228,6 +232,35 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
</html>`;
}
function renderSentinelRegistryStrip(config: DashboardShellConfig, basePath: string): string {
const rows = Array.isArray(config.plan.sentinels) ? config.plan.sentinels.map((item) => ({
id: stringOrNull(item.id) ?? "",
enabled: item.enabled !== false,
})).filter((item) => item.id.length > 0) : [];
if (rows.length <= 1) return "";
return `<section class="sentinel-registry-strip" aria-label="多哨兵入口">
<div class="sentinel-registry-copy">
<strong></strong>
<span> workspace ${rows.length} </span>
</div>
<div class="sentinel-registry-links">
${rows.map((item) => renderSentinelRegistryLink(item.id, item.enabled, item.id === config.sentinelId, basePath)).join("")}
</div>
</section>`;
}
function renderSentinelRegistryLink(id: string, enabled: boolean, current: boolean, basePath: string): string {
const href = current
? `${basePath || "/"}`
: id === "workbench-dsflash-go-tool-call-10x"
? "/"
: `/sentinels/${encodeURIComponent(id)}/`;
return `<a class="sentinel-registry-link${current ? " current" : ""}${enabled ? "" : " disabled"}" href="${escapeAttr(href)}">
<span>${escapeHtml(id)}</span>
<small>${current ? "当前" : enabled ? "查看" : "停用"}</small>
</a>`;
}
function publicBasePath(publicBaseUrl: string): string {
try {
const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
+45 -3
View File
@@ -22,7 +22,7 @@ import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectVie
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd";
import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
@@ -46,9 +46,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "control-plane"
&& sentinelActionRaw !== "validate"
&& sentinelActionRaw !== "maintenance"
&& sentinelActionRaw !== "dashboard"
&& sentinelActionRaw !== "report"
) {
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|validate|maintenance|report --node NODE --lane vNN [--dry-run|--confirm]");
throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|validate|maintenance|dashboard|report --node NODE --lane vNN [--dry-run|--confirm]");
}
assertKnownOptions(args, new Set([
"--node",
@@ -63,7 +64,13 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--sample-seq",
"--sentinel",
"--sentinel-id",
]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"]));
"--viewport",
"--local-dir",
"--name",
"--timeout-ms",
"--wait-timeout-ms",
"--command-timeout-seconds",
]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest", "--full-page", "--no-full-page"]));
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
@@ -109,6 +116,27 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
};
} else if (sentinelActionRaw === "validate") {
sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
} else if (sentinelActionRaw === "dashboard") {
const dashboardAction = parseWebProbeSentinelDashboardAction(args[1]);
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);
sentinel = {
kind: "dashboard",
action: dashboardAction,
node,
lane,
sentinelId,
viewport: parseWebProbeSentinelDashboardViewport(optionValue(args, "--viewport") ?? "1440x900"),
localDir: optionValue(args, "--local-dir") ?? "/tmp",
name: optionValue(args, "--name") ?? null,
timeoutMs,
waitTimeoutMs,
timeoutSeconds: commandTimeoutSeconds,
commandTimeoutSeconds,
fullPage: !args.includes("--no-full-page"),
raw: args.includes("--raw"),
};
} else {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest");
@@ -140,6 +168,20 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
};
}
function parseWebProbeSentinelDashboardAction(value: string | undefined): WebProbeSentinelDashboardAction {
if (value === "verify" || value === "screenshot") return value;
throw new Error("web-probe sentinel dashboard usage: dashboard verify|screenshot --node NODE --lane vNN --sentinel <id>");
}
function parseWebProbeSentinelDashboardViewport(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 sentinel dashboard --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 sentinel dashboard --viewport out of range: ${value}`);
return value;
}
function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView {
if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame" || value === "auth-session-switch-summary") return value;
throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, trace-frame, or auth-session-switch-summary; got ${value}`);