Merge pull request #1062 from pikasTech/fix/1056-monitor-dashboard
fix(web-probe): recover monitor dashboard render
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user