feat: add Vue monitor web sentinel dashboard
This commit is contained in:
@@ -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 ?? "-"]]),
|
||||
|
||||
Reference in New Issue
Block a user