feat: add Vue monitor web sentinel dashboard

This commit is contained in:
Codex
2026-06-27 06:12:46 +00:00
parent 6e06972acf
commit 0fb4f9fc30
13 changed files with 1797 additions and 266 deletions
+90 -30
View File
@@ -2,6 +2,7 @@
// 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.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// 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";
@@ -147,6 +148,7 @@ interface SentinelImagePlan {
readonly entrypoint: string;
readonly dockerfileSha256: string;
readonly dockerfilePreview: string;
readonly monitorWeb: Record<string, unknown>;
}
interface SentinelObservedStatus {
@@ -190,7 +192,7 @@ interface ChildCliResult {
readonly result: CompactCommandResult & { stdoutTail: string; stderrTail: string };
}
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
@@ -367,6 +369,7 @@ function sentinelImagePlan(cicd: Record<string, unknown>, sourceHead: SourceHead
entrypoint,
dockerfileSha256: sha256(dockerfile),
dockerfilePreview: dockerfile,
monitorWeb: monitorWebCicdPlan(cicd),
};
}
@@ -376,12 +379,28 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
"WORKDIR /app",
"COPY . /app",
"RUN if [ -d /opt/hwlab-ci-node-deps/node_modules ]; then mkdir -p /app/node_modules; for dep in /opt/hwlab-ci-node-deps/node_modules/*; do ln -sf \"$dep\" \"/app/node_modules/$(basename \"$dep\")\"; done; fi",
"RUN bun scripts/verify-web-probe-sentinel-monitor-web.ts",
"ENV NODE_ENV=production",
`ENTRYPOINT ["bun", "${entrypoint}"]`,
"",
].join("\n");
}
function monitorWebCicdPlan(cicd: Record<string, unknown>): Record<string, unknown> {
return {
stack: stringAtNullable(cicd, "monitorWeb.frontendStack") ?? "vue3-vendored-runtime",
runtimeMode: stringAtNullable(cicd, "monitorWeb.runtimeMode") ?? "runner-served-bridge",
assetRoot: stringAtNullable(cicd, "monitorWeb.assetRoot") ?? "scripts/assets/web-probe-sentinel-monitor-web",
verifyCommand: "bun scripts/verify-web-probe-sentinel-monitor-web.ts",
gitMirrorReadUrl: stringAt(cicd, "source.gitMirrorReadUrl"),
sourceMode: stringAt(cicd, "builder.sourceMode"),
envReuseMode: stringAtNullable(cicd, "monitorWeb.envReuse.mode") ?? "docker-layer-and-ci-node-deps",
envReuseNodeDepsPath: stringAtNullable(cicd, "monitorWeb.envReuse.nodeDepsPath") ?? "/opt/hwlab-ci-node-deps/node_modules",
ciBudgetSeconds: numberAtNullable(cicd, "monitorWeb.ciBudget.maxSeconds") ?? numberAt(cicd, "confirmWait.maxSeconds"),
valuesRedacted: true,
};
}
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
sentinelId: string,
@@ -1964,15 +1983,16 @@ for (let attempt = 1; attempt <= maxNavigationAttempts; attempt += 1) {
httpStatus = response?.status() ?? null;
await page.waitForLoadState("networkidle", { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {});
await page.waitForFunction(() => {
const root = document.querySelector("#sentinel-dashboard");
const root = document.querySelector("#monitor-web-root");
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() !== "空闲");
const ready = root.getAttribute("data-monitor-ready") === "true";
const error = document.querySelector("#monitor-web-error");
const runs = document.querySelectorAll(".run-list .run-row").length;
const trend = document.querySelector("[data-monitor-trend-curve]");
return ready && (error || runs > 0 || trend);
}, null, { timeout: Math.min(5000, perAttemptTimeout) }).catch(() => {});
await page.waitForTimeout(500);
const shellReady = await page.evaluate(() => Boolean(document.querySelector("#sentinel-dashboard"))).catch(() => false);
const shellReady = await page.evaluate(() => Boolean(document.querySelector("#monitor-web-root"))).catch(() => false);
if (shellReady || attempt === maxNavigationAttempts) break;
} catch (error) {
navigationError = String(error?.message || error).slice(0, 500);
@@ -1990,9 +2010,13 @@ if (captureScreenshot && screenshotPath) {
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 root = document.querySelector("#monitor-web-root");
const shell = document.querySelector("[data-monitor-shell='true']");
const error = document.querySelector("#monitor-web-error");
const trend = document.querySelector("[data-monitor-trend-curve]");
const timeline = document.querySelector("[data-monitor-timeline='true']");
const workspace = document.querySelector("[data-monitor-independent-scroll='true']");
const panes = Array.from(document.querySelectorAll(".workspace-grid .pane"));
const doc = document.documentElement;
const body = document.body;
const viewport = { width: window.innerWidth, height: window.innerHeight };
@@ -2024,7 +2048,8 @@ const dom = await page.evaluate(() => {
}
}
return {
shell: Boolean(root),
shell: Boolean(root && shell),
ready: root?.getAttribute("data-monitor-ready") === "true",
dataset: root ? {
node: root.getAttribute("data-node"),
lane: root.getAttribute("data-lane"),
@@ -2034,16 +2059,39 @@ const dom = await page.evaluate(() => {
} : {},
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),
statusText: text(".topbar .pill"),
subtitle: text(".subtitle"),
summaryText: text(".status-strip"),
runRows: document.querySelectorAll(".run-list .run-row").length,
findingItems: document.querySelectorAll(".finding-list .finding-card").length,
trendCurve: Boolean(trend),
trendPanelText: text("#trend-heading"),
timelineItems: document.querySelectorAll(".timeline-list .timeline-item").length,
timelineVisible: Boolean(timeline),
errorVisible: visible(error),
errorText: visible(error) ? text("#error-banner").slice(0, 500) : "",
errorText: visible(error) ? text("#monitor-web-error").slice(0, 500) : "",
scrollModel: {
workspace: Boolean(workspace),
paneCount: panes.length,
panes: panes.map((pane) => {
const style = window.getComputedStyle(pane);
const rect = pane.getBoundingClientRect();
return {
className: String(pane.className || ""),
overflowY: style.overflowY,
scrollHeight: pane.scrollHeight,
clientHeight: pane.clientHeight,
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
}),
independentScroll: panes.length >= 3 && panes.every((pane) => {
const style = window.getComputedStyle(pane);
return style.overflowY === "auto" || style.overflowY === "scroll";
}),
},
layout: {
viewport,
documentSize,
@@ -2060,8 +2108,12 @@ const ok = !navigationError
&& httpStatus >= 200
&& httpStatus < 300
&& dom.shell === true
&& dom.ready === true
&& dom.errorVisible !== true
&& dom.runRows > 0
&& dom.trendCurve === true
&& dom.timelineVisible === true
&& dom.scrollModel?.independentScroll === true
&& dom.layout?.horizontalOverflow !== true
&& pageErrors.length === 0;
console.log("__WEB_PROBE_SENTINEL_DASHBOARD_JSON__" + JSON.stringify({
@@ -2905,28 +2957,33 @@ function probeSentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: n
function probeSentinelPublicDashboard(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const publicBaseUrl = stringAt(state.publicExposure, "publicBaseUrl").replace(/\/$/u, "");
const rootUrl = `${publicBaseUrl}/`;
const cssUrl = `${publicBaseUrl}/dashboard/assets/dashboard.css`;
const jsUrl = `${publicBaseUrl}/dashboard/assets/dashboard.js`;
const cssUrl = `${publicBaseUrl}/monitor-web/assets/monitor-web.css`;
const jsUrl = `${publicBaseUrl}/monitor-web/assets/monitor-web.js`;
const vueUrl = `${publicBaseUrl}/monitor-web/assets/vendor/vue.runtime.esm-browser.prod.js`;
const script = [
"set +e",
`root_url=${shellQuote(rootUrl)}`,
`css_url=${shellQuote(cssUrl)}`,
`js_url=${shellQuote(jsUrl)}`,
`vue_url=${shellQuote(vueUrl)}`,
"root_body=$(mktemp)",
"css_body=$(mktemp)",
"js_body=$(mktemp)",
"vue_body=$(mktemp)",
"root_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$root_body\" --write-out '%{http_code}' \"$root_url\" 2>/tmp/web-probe-sentinel-dashboard-root.err); root_rc=$?",
"css_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$css_body\" --write-out '%{http_code}' \"$css_url\" 2>/tmp/web-probe-sentinel-dashboard-css.err); css_rc=$?",
"js_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$js_body\" --write-out '%{http_code}' \"$js_url\" 2>/tmp/web-probe-sentinel-dashboard-js.err); js_rc=$?",
"node - \"$root_url\" \"$css_url\" \"$js_url\" \"$root_code\" \"$root_rc\" \"$css_code\" \"$css_rc\" \"$js_code\" \"$js_rc\" \"$root_body\" \"$css_body\" \"$js_body\" <<'NODE'",
"vue_code=$(curl -sS -L --connect-timeout 8 --max-time 20 -o \"$vue_body\" --write-out '%{http_code}' \"$vue_url\" 2>/tmp/web-probe-sentinel-dashboard-vue.err); vue_rc=$?",
"node - \"$root_url\" \"$css_url\" \"$js_url\" \"$vue_url\" \"$root_code\" \"$root_rc\" \"$css_code\" \"$css_rc\" \"$js_code\" \"$js_rc\" \"$vue_code\" \"$vue_rc\" \"$root_body\" \"$css_body\" \"$js_body\" \"$vue_body\" <<'NODE'",
"const fs=require('node:fs');",
"const [rootUrl,cssUrl,jsUrl,rootCode,rootRc,cssCode,cssRc,jsCode,jsRc,rootPath,cssPath,jsPath]=process.argv.slice(2);",
"const [rootUrl,cssUrl,jsUrl,vueUrl,rootCode,rootRc,cssCode,cssRc,jsCode,jsRc,vueCode,vueRc,rootPath,cssPath,jsPath,vuePath]=process.argv.slice(2);",
"function read(path){try{return fs.readFileSync(path,'utf8')}catch{return ''}}",
"const root=read(rootPath); const css=read(cssPath); const js=read(jsPath);",
"const rootOk=Number(rootRc)===0&&Number(rootCode)>=200&&Number(rootCode)<300&&root.includes('id=\"sentinel-dashboard\"')&&root.includes('/dashboard/assets/dashboard.js');",
"const cssOk=Number(cssRc)===0&&Number(cssCode)>=200&&Number(cssCode)<300&&css.includes('sentinel-shell')&&css.length>1000;",
"const jsOk=Number(jsRc)===0&&Number(jsCode)>=200&&Number(jsCode)<300&&js.includes('createAutoRefresh')&&js.includes('/api/overview')&&js.length>1000;",
"console.log(JSON.stringify({ok:rootOk&&cssOk&&jsOk,root:{url:rootUrl,httpStatus:Number(rootCode),bytes:Buffer.byteLength(root),shell:root.includes('id=\"sentinel-dashboard\"')},css:{url:cssUrl,httpStatus:Number(cssCode),bytes:Buffer.byteLength(css),shell:css.includes('sentinel-shell')},js:{url:jsUrl,httpStatus:Number(jsCode),bytes:Buffer.byteLength(js),apiClient:js.includes('/api/overview'),autoRefresh:js.includes('createAutoRefresh')},valuesRedacted:true}));",
"const root=read(rootPath); const css=read(cssPath); const js=read(jsPath); const vue=read(vuePath);",
"const rootOk=Number(rootRc)===0&&Number(rootCode)>=200&&Number(rootCode)<300&&root.includes('id=\"monitor-web-root\"')&&root.includes('/monitor-web/assets/monitor-web.js')&&root.includes('monitor-web-bootstrap');",
"const cssOk=Number(cssRc)===0&&Number(cssCode)>=200&&Number(cssCode)<300&&css.includes('monitor-shell')&&css.includes('workspace-grid')&&css.includes('trend-stage')&&css.length>1000;",
"const jsOk=Number(jsRc)===0&&Number(jsCode)>=200&&Number(jsCode)<300&&js.includes('createApp')&&js.includes('/api/overview')&&js.includes('data-monitor-trend-curve')&&js.length>1000;",
"const vueOk=Number(vueRc)===0&&Number(vueCode)>=200&&Number(vueCode)<300&&vue.includes('createApp')&&vue.length>80000;",
"console.log(JSON.stringify({ok:rootOk&&cssOk&&jsOk&&vueOk,root:{url:rootUrl,httpStatus:Number(rootCode),bytes:Buffer.byteLength(root),shell:root.includes('id=\"monitor-web-root\"'),contract:root.includes('draft-2026-06-27-p11-monitor-web-observability-dashboard')},css:{url:cssUrl,httpStatus:Number(cssCode),bytes:Buffer.byteLength(css),workspaceGrid:css.includes('workspace-grid'),trendStage:css.includes('trend-stage')},js:{url:jsUrl,httpStatus:Number(jsCode),bytes:Buffer.byteLength(js),vueApp:js.includes('createApp'),apiClient:js.includes('/api/overview'),trend:js.includes('data-monitor-trend-curve')},vue:{url:vueUrl,httpStatus:Number(vueCode),bytes:Buffer.byteLength(vue),runtime:vue.includes('createApp')},valuesRedacted:true}));",
"NODE",
].join("\n");
const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 30) * 1000 });
@@ -4151,6 +4208,7 @@ function renderImageResult(result: Record<string, unknown>): string {
const sourceMirror = record(result.sourceMirror);
const sourceMirrorSync = record(result.sourceMirrorSync);
const image = record(result.image);
const monitorWeb = record(image.monitorWeb);
const registry = record(result.registry);
const publish = record(result.publish);
const blocker = record(result.blocker);
@@ -4167,6 +4225,8 @@ function renderImageResult(result: Record<string, unknown>): string {
"",
table(["IMAGE", "BASE", "ENTRYPOINT", "DOCKERFILE"], [[image.ref, image.baseImage, image.entrypoint, short(image.dockerfileSha256)]]),
"",
Object.keys(monitorWeb).length === 0 ? "MONITOR_WEB\n-" : table(["STACK", "MODE", "ASSETS", "VERIFY", "ENV_REUSE"], [[monitorWeb.stack, monitorWeb.runtimeMode, monitorWeb.assetRoot, monitorWeb.verifyCommand, `${monitorWeb.envReuseMode}:${monitorWeb.envReuseNodeDepsPath}`]]),
"",
Object.keys(registry).length === 0 ? "REGISTRY\n-" : table(["PROBED", "PRESENT", "DIGEST"], [[record(registry.probe).url ?? "-", record(registry.probe).present ?? "-", short(record(registry.probe).digest)]]),
"",
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), sourceMirrorSync.elapsedMs ?? "-"]]),