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 ?? "-"]]),
@@ -1,6 +1,7 @@
// 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.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-27-p11-monitor-web-observability-dashboard.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
@@ -14,24 +15,35 @@ interface DashboardShellConfig {
}
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p10-monitor-web-aggregation";
const MONITOR_WEB_ASSET_ROOT = "scripts/assets/web-probe-sentinel-monitor-web";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin);
const registryHtml = renderSentinelRegistryStrip(config, basePath);
const sentinels = sentinelRegistryRows(config);
const bootstrap = {
node: config.node,
lane: config.lane,
sentinelId: config.sentinelId,
publicOrigin,
basePath,
configReady: config.plan.ok,
contractVersion: DASHBOARD_CONTRACT_VERSION,
sentinels,
valuesRedacted: true,
};
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HWLAB Web哨兵</title>
<link rel="stylesheet" href="${escapeAttr(basePath)}/dashboard/assets/dashboard.css">
<title>HWLAB Web哨兵 monitor-web</title>
<link rel="stylesheet" href="${escapeAttr(basePath)}/monitor-web/assets/monitor-web.css">
</head>
<body>
<main
id="sentinel-dashboard"
class="sentinel-shell"
id="monitor-web-root"
data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}"
data-sentinel-id="${escapeAttr(config.sentinelId)}"
@@ -39,226 +51,21 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
data-viewport=""
data-monitor-ready="false"
>
<section class="sentinel-topbar" aria-label="Web哨兵概览">
<div class="sentinel-title">
<div class="sentinel-mark" aria-hidden="true"></div>
<div>
<h1>HWLAB Web哨兵</h1>
<p id="sentinel-subtitle">
<span>${escapeHtml(config.node)} / ${escapeHtml(config.lane)}</span>
<span class="sentinel-subtitle-separator">·</span>
<code class="sentinel-id-chip">${escapeHtml(config.sentinelId)}</code>
<span id="sentinel-origin-note"></span>
</p>
</div>
</div>
<div class="sentinel-toolbar" aria-label="监控面板控制">
<span id="status-pill" class="status-pill status-idle">空闲</span>
<label class="refresh-toggle">
<input id="auto-refresh-enabled" type="checkbox">
<span>自动</span>
</label>
<select id="auto-refresh-interval" aria-label="自动刷新间隔">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
</select>
<button id="manual-refresh" class="icon-button" type="button" title="刷新 (r)" aria-label="刷新">刷新</button>
<button id="latest-run" class="icon-button" type="button" title="跳转最新运行" aria-label="跳转最新">最新</button>
<button id="filter-red" class="icon-button" type="button" title="只看红色发现" aria-label="只看红色">红</button>
</div>
</section>
<section id="status-summary" class="status-summary" aria-label="状态摘要条" hidden>
<span class="summary-item"><span class="summary-label">状态</span><strong id="summary-status">-</strong></span>
<span class="summary-item"><span class="summary-label">最近运行</span><strong id="summary-latest">-</strong><small id="summary-latest-age"></small></span>
<span class="summary-item"><span class="summary-label">发现</span><strong id="summary-findings">0</strong><small id="summary-findings-note"></small></span>
<span class="summary-item"><span class="summary-label">调度器</span><strong id="summary-scheduler">-</strong><small id="summary-budget"></small></span>
<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>
<section class="overview-checks overview-checks-collapsed" id="overview-checks" aria-label="哨兵健康检查" hidden>
<button type="button" class="check-summary-pill" id="check-summary-pill">检查 -</button>
<div class="overview-checks-detail" id="overview-checks-detail">
<span id="check-config" class="check-chip">config -</span>
<span id="check-pvc" class="check-chip">pvc -</span>
<span id="check-analyzer" class="check-chip">analyzer -</span>
<span id="check-public" class="check-chip">public -</span>
<span id="check-maintenance" class="check-chip">maintenance -</span>
</div>
</section>
<section class="dashboard-grid">
<section class="panel panel-runs" aria-labelledby="runs-heading">
<div class="panel-header">
<h2 id="runs-heading">运行历史</h2>
<span id="runs-count" class="panel-subtitle">-</span>
</div>
<div class="filter-collapse">
<button type="button" class="filter-summary" id="runs-filter-summary" aria-expanded="false">筛选: 全部</button>
<form id="runs-filter" class="runs-filter" hidden>
<label><span>状态</span>
<select id="filter-status" name="status">
<option value="">全部</option>
<option value="planned">已计划</option>
<option value="running">运行中</option>
<option value="analyzed">已分析</option>
<option value="blocked">阻塞</option>
<option value="interrupted">已中断</option>
</select>
</label>
<label><span>严重级别</span>
<select id="filter-severity" name="severity">
<option value="">全部</option>
<option value="red">红色</option>
<option value="warning">警告</option>
<option value="info">信息</option>
</select>
</label>
<label><span>时间</span>
<select id="filter-window" name="window">
<option value="">全部</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
</label>
<label><span>排序</span>
<select id="filter-sort" name="sort">
<option value="updated">按更新时间</option>
<option value="created">按创建时间</option>
<option value="findings">按发现数量</option>
<option value="severity">按严重级别</option>
</select>
</label>
<label class="filter-search"><span>搜索</span>
<input id="filter-search" name="search" type="search" placeholder="run、observer、report">
</label>
<button id="clear-filters" class="icon-button" type="button">清除</button>
</form>
</div>
<div class="table-frame">
<table class="runs-table">
<thead>
<tr>
<th>运行</th>
<th>状态</th>
<th>场景</th>
<th>发现</th>
<th>更新</th>
</tr>
</thead>
<tbody id="runs-body"></tbody>
</table>
</div>
</section>
<section class="panel panel-detail" aria-labelledby="detail-heading">
<div class="panel-header">
<h2 id="detail-heading">运行详情</h2>
<span id="detail-subtitle" class="panel-subtitle">未选择运行</span>
</div>
<nav class="detail-tabs" id="detail-tabs" aria-label="详情分页" hidden>
<button type="button" class="detail-tab active" data-detail-tab="overview">概要</button>
<button type="button" class="detail-tab" data-detail-tab="findings">发现项</button>
<button type="button" class="detail-tab" data-detail-tab="turn">多轮摘要</button>
<button type="button" class="detail-tab" data-detail-tab="trace">Trace</button>
<button type="button" class="detail-tab" data-detail-tab="evidence">证据与命令</button>
</nav>
<div id="detail-content" class="detail-content"></div>
</section>
<section class="panel panel-findings" aria-labelledby="findings-heading">
<div class="panel-header">
<h2 id="findings-heading">发现分析</h2>
<span id="findings-count" class="panel-subtitle">-</span>
</div>
<div class="filter-collapse">
<button type="button" class="filter-summary" id="findings-filter-summary" aria-expanded="false">筛选: 全部</button>
<form id="findings-filter" class="findings-filter" hidden>
<label><span>严重级别</span>
<select id="finding-filter-severity" name="fseverity">
<option value="">全部</option>
<option value="red">红色</option>
<option value="warning">警告</option>
<option value="info">信息</option>
</select>
</label>
<label><span>窗口</span>
<select id="finding-filter-window" name="fwindow">
<option value="24h">24h</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="7d">7d</option>
<option value="">全部</option>
</select>
</label>
<label><span>代码</span>
<input id="finding-filter-code" name="fcode" type="search" placeholder="finding code">
</label>
<label><span>场景</span>
<input id="finding-filter-scenario" name="fscenario" type="search" placeholder="scenario">
</label>
<button id="finding-clear-filters" class="icon-button" type="button">清除</button>
</form>
</div>
<div id="finding-aggregation" class="finding-aggregation"></div>
<div id="findings-list" class="finding-list"></div>
<div id="findings-drilldown" class="findings-drilldown" hidden></div>
</section>
</section>
<section class="panel timeline-panel" aria-labelledby="timeline-heading">
<div class="panel-header">
<h2 id="timeline-heading">运行时间线</h2>
<span id="timeline-count" class="panel-subtitle">-</span>
<button type="button" class="timeline-toggle" id="timeline-toggle" aria-expanded="true">折叠</button>
</div>
<div id="run-timeline" class="run-timeline"></div>
</section>
<div id="copy-toast" class="copy-toast" hidden>已复制</div>
加载 monitor-web...
</main>
<script type="module" src="${escapeAttr(basePath)}/dashboard/assets/dashboard.js"></script>
<script id="monitor-web-bootstrap" type="application/json">${jsonForScript(bootstrap)}</script>
<script type="module" src="${escapeAttr(basePath)}/monitor-web/assets/monitor-web.js"></script>
</body>
</html>`;
}
function renderSentinelRegistryStrip(config: DashboardShellConfig, basePath: string): string {
const rows = Array.isArray(config.plan.sentinels) ? config.plan.sentinels.map((item) => ({
function sentinelRegistryRows(config: DashboardShellConfig): Array<{ readonly id: string; readonly enabled: boolean }> {
return 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 {
@@ -273,6 +80,10 @@ function publicBasePath(publicBaseUrl: string): string {
export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null {
if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8");
if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/monitor-web.css") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/monitor-web.css`, "text/css; charset=utf-8");
if (pathname === "/monitor-web/assets/monitor-web.js") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/monitor-web.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/vendor/vue.runtime.esm-browser.prod.js") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/vendor/vue.runtime.esm-browser.prod.js`, "application/javascript; charset=utf-8");
if (pathname === "/monitor-web/assets/vendor/VUE-LICENSE") return textAsset(`${MONITOR_WEB_ASSET_ROOT}/vendor/VUE-LICENSE`, "text/plain; charset=utf-8");
return null;
}
@@ -296,3 +107,7 @@ function escapeHtml(value: string): string {
function escapeAttr(value: string): string {
return escapeHtml(value).replace(/'/gu, "&#39;");
}
function jsonForScript(value: unknown): string {
return JSON.stringify(value).replace(/</gu, "\\u003c");
}
+54 -18
View File
@@ -2,6 +2,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// 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-27-p11-monitor-web-observability-dashboard.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
@@ -14,7 +15,7 @@ import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./h
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard";
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig {
@@ -280,55 +281,90 @@ export function startWebProbeSentinelHttpService(service: WebProbeSentinelServic
async function sentinelFetch(service: WebProbeSentinelService, request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503);
if (request.method === "GET" && url.pathname === "/api/status") return jsonResponse(service.status());
if (request.method === "GET" && url.pathname === "/api/overview") return jsonResponse(service.overview());
if (request.method === "GET" && url.pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url));
if (request.method === "GET" && url.pathname === "/api/findings") return jsonResponse(service.findings(url));
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(url.pathname);
const pathname = normalizedSentinelRequestPath(service, url.pathname);
if (request.method === "GET" && pathname === "/api/health") return jsonResponse(service.health(), service.health().ok === true ? 200 : 503);
if (request.method === "GET" && pathname === "/api/status") return jsonResponse(service.status());
if (request.method === "GET" && pathname === "/api/overview") return jsonResponse(service.overview());
if (request.method === "GET" && pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url));
if (request.method === "GET" && pathname === "/api/findings") return jsonResponse(service.findings(url));
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(pathname);
if (request.method === "GET" && runViewsMatch !== null) {
const view = url.searchParams.get("view");
const result = service.runViews(decodeURIComponent(runViewsMatch[1]), view, url);
return jsonResponse(result, result.ok === false ? 404 : 200);
}
const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(url.pathname);
const runDetailMatch = /^\/api\/runs\/([^/]+)$/u.exec(pathname);
if (request.method === "GET" && runDetailMatch !== null) {
const result = service.runDetail(decodeURIComponent(runDetailMatch[1]));
return jsonResponse(result, result.ok === false ? 404 : 200);
}
if (request.method === "GET" && url.pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true });
if (request.method === "POST" && (url.pathname === "/api/maintenance/start" || url.pathname === "/api/maintenance/stop")) {
if (request.method === "GET" && pathname === "/api/maintenance") return jsonResponse({ ok: true, maintenance: service.maintenance(), valuesRedacted: true });
if (request.method === "POST" && (pathname === "/api/maintenance/start" || pathname === "/api/maintenance/stop")) {
const body = await readJsonBody(request);
const active = url.pathname.endsWith("/start");
const active = pathname.endsWith("/start");
return jsonResponse({ ok: true, maintenance: service.setMaintenance(active, body), valuesRedacted: true });
}
if (request.method === "POST" && url.pathname === "/api/runs/plan") {
if (request.method === "POST" && pathname === "/api/runs/plan") {
const body = await readJsonBody(request);
return jsonResponse(service.planScenarioRun(stringField(body, "scenarioId"), stringOrNull(body.reason) ?? "manual"));
}
if (request.method === "POST" && url.pathname === "/api/runs/record") {
if (request.method === "POST" && pathname === "/api/runs/record") {
const body = await readJsonBody(request);
return jsonResponse(service.recordRun(body));
}
if (request.method === "GET" && url.pathname === "/api/report") {
if (request.method === "GET" && pathname === "/api/report") {
const view = url.searchParams.get("view") ?? stringOrNull(service.config.reportViews.defaultView) ?? "summary";
const runId = url.searchParams.get("run") ?? url.searchParams.get("runId");
const report = service.report(view, runId);
return jsonResponse(report, report.ok === false ? 404 : 200);
}
if (request.method === "GET" && url.pathname === "/metrics") {
if (request.method === "GET" && pathname === "/metrics") {
return new Response(service.metrics(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" } });
}
if (request.method === "GET" && url.pathname.startsWith("/dashboard/assets/")) {
const asset = webProbeSentinelDashboardAssetResponse(url.pathname);
if (request.method === "GET" && (pathname.includes("/dashboard/assets/") || pathname.includes("/monitor-web/assets/"))) {
const asset = webProbeSentinelDashboardAssetResponse(normalizedDashboardAssetPath(pathname));
if (asset !== null) return asset;
}
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) {
if (request.method === "GET" && (pathname === "/" || pathname === "/dashboard" || pathname === "/monitor-web")) {
return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
}
return jsonResponse({ ok: false, error: "not-found", path: url.pathname, valuesRedacted: true }, 404);
}
function normalizedSentinelRequestPath(service: WebProbeSentinelService, pathname: string): string {
const publicPath = publicExposurePath(service.config.publicExposure);
if (publicPath.length > 0 && (pathname === publicPath || pathname === `${publicPath}/`)) return "/";
if (publicPath.length > 0 && pathname.startsWith(`${publicPath}/`)) return pathname.slice(publicPath.length) || "/";
if (/^\/sentinels\/[^/]+\/?$/u.test(pathname)) return "/";
for (const marker of ["/api/", "/monitor-web/assets/", "/dashboard/assets/"]) {
const index = pathname.indexOf(marker);
if (index > 0) return pathname.slice(index);
}
for (const marker of ["/monitor-web", "/dashboard", "/metrics"]) {
if (pathname.endsWith(marker)) return marker;
}
return pathname;
}
function publicExposurePath(publicExposure: Record<string, unknown>): string {
const publicBaseUrl = stringOrNull(publicExposure.publicBaseUrl);
if (publicBaseUrl === null) return "";
try {
const pathname = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
return pathname === "/" ? "" : pathname;
} catch {
return "";
}
}
function normalizedDashboardAssetPath(pathname: string): string {
for (const marker of ["/monitor-web/assets/", "/dashboard/assets/"]) {
const index = pathname.indexOf(marker);
if (index >= 0) return pathname.slice(index);
}
return pathname;
}
function initializeIndex(db: Database): void {
db.exec(`
PRAGMA journal_mode = WAL;