feat: add web sentinel dashboard frontend shell

This commit is contained in:
Codex
2026-06-26 02:57:41 +00:00
parent 2f1911ee35
commit 05b998c326
5 changed files with 1023 additions and 22 deletions
+40 -2
View File
@@ -1245,12 +1245,14 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract<WebProbe
const metrics = callSentinelService(state, "GET", "/metrics", null, options.timeoutSeconds);
const report = callSentinelService(state, "GET", "/api/report?view=summary", null, options.timeoutSeconds);
const publicExposure = probeSentinelPublicExposure(state, options.timeoutSeconds);
const publicDashboard = probeSentinelPublicDashboard(state, options.timeoutSeconds);
const ok = health.ok
&& record(health.bodyJson).ok === true
&& metrics.ok
&& metricNames(record(metrics).bodyTextPreview).includes("web_probe_sentinel_health")
&& report.ok
&& publicExposure.ok === true
&& publicDashboard.ok === true
&& (quickVerify === null || quickVerify.ok === true);
const result = {
ok,
@@ -1262,8 +1264,9 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract<WebProbe
metrics,
report,
publicExposure,
publicDashboard,
quickVerify,
blocker: ok ? null : validationBlocker(health, metrics, report, publicExposure, quickVerify),
blocker: ok ? null : validationBlocker(health, metrics, report, publicExposure, publicDashboard, quickVerify),
next: sentinelP5Next(state),
valuesRedacted: true,
};
@@ -1618,6 +1621,38 @@ function probeSentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: n
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
}
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 script = [
"set +e",
`root_url=${shellQuote(rootUrl)}`,
`css_url=${shellQuote(cssUrl)}`,
`js_url=${shellQuote(jsUrl)}`,
"root_body=$(mktemp)",
"css_body=$(mktemp)",
"js_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'",
"const fs=require('node:fs');",
"const [rootUrl,cssUrl,jsUrl,rootCode,rootRc,cssCode,cssRc,jsCode,jsRc,rootPath,cssPath,jsPath]=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}));",
"NODE",
].join("\n");
const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 30) * 1000 });
const parsed = parseJsonObject(result.stdout);
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
}
function applySentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
const material = readSentinelFrpcMaterial(state);
if (!material.ok) return { ok: false, hostname: stringAt(state.publicExposure, "hostname"), material, valuesRedacted: true };
@@ -2109,12 +2144,13 @@ function metricNames(textValue: unknown): string[] {
return textValue.split(/\r?\n/u).map((line) => /^([A-Za-z_:][A-Za-z0-9_:]*)/u.exec(line)?.[1]).filter((item): item is string => typeof item === "string");
}
function validationBlocker(health: Record<string, unknown>, metrics: Record<string, unknown>, report: Record<string, unknown>, publicExposure: Record<string, unknown>, quickVerify: Record<string, unknown> | null): Record<string, unknown> {
function validationBlocker(health: Record<string, unknown>, metrics: Record<string, unknown>, report: Record<string, unknown>, publicExposure: Record<string, unknown>, publicDashboard: Record<string, unknown>, quickVerify: Record<string, unknown> | null): Record<string, unknown> {
const blockers = [];
if (!health.ok || record(health.bodyJson).ok !== true) blockers.push("health");
if (!metrics.ok || !metricNames(metrics.bodyTextPreview).includes("web_probe_sentinel_health")) blockers.push("metrics");
if (!report.ok) blockers.push("recent-report");
if (publicExposure.ok !== true) blockers.push("public-exposure");
if (publicDashboard.ok !== true) blockers.push("public-dashboard");
if (quickVerify !== null && quickVerify.ok !== true) blockers.push("quick-verify");
return { code: "sentinel-validation-failed", blockers, valuesRedacted: true };
}
@@ -2232,6 +2268,7 @@ function renderValidateResult(result: Record<string, unknown>): string {
const metrics = record(result.metrics);
const report = record(result.report);
const publicExposure = record(result.publicExposure);
const publicDashboard = record(result.publicDashboard);
const quickVerify = record(result.quickVerify);
const blocker = record(result.blocker);
const next = record(result.next);
@@ -2246,6 +2283,7 @@ function renderValidateResult(result: Record<string, unknown>): string {
["metrics", metrics.ok && metricNames(metrics.bodyTextPreview).includes("web_probe_sentinel_health"), `bytes=${metrics.bodyBytes ?? "-"} metric=web_probe_sentinel_health`],
["recent-report", report.ok, `${record(record(report.bodyJson).run).id ?? "-"} ${short(record(record(report.bodyJson).run).report_json_sha256)}`],
["public-exposure", publicExposure.ok, `${record(publicExposure.dns).expectedA ?? "-"} http=${record(publicExposure.https).httpStatus ?? "-"}`],
["public-dashboard", publicDashboard.ok, `${record(publicDashboard.root).url ?? "-"} root=${record(publicDashboard.root).httpStatus ?? "-"} css=${record(publicDashboard.css).httpStatus ?? "-"} js=${record(publicDashboard.js).httpStatus ?? "-"}`],
["quick-verify", Object.keys(quickVerify).length === 0 ? "skipped" : quickVerify.ok, `${quickVerify.runId ?? "-"} ${short(quickVerify.reportJsonSha256)}`],
]),
"",
@@ -0,0 +1,156 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
interface DashboardShellConfig {
readonly node: string;
readonly lane: string;
readonly plan: { readonly ok: boolean };
readonly publicExposure: Record<string, unknown>;
}
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p7-web-probe-sentinel-dashboard";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
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 Probe Sentinel</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
</head>
<body>
<main
id="sentinel-dashboard"
class="sentinel-shell"
data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}"
data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
>
<section class="sentinel-topbar" aria-label="Web probe sentinel overview">
<div class="sentinel-title">
<div class="sentinel-mark" aria-hidden="true"></div>
<div>
<h1>HWLAB Web Probe Sentinel</h1>
<p id="sentinel-subtitle">${escapeHtml(config.node)} / ${escapeHtml(config.lane)}</p>
</div>
</div>
<div class="sentinel-toolbar" aria-label="Dashboard controls">
<span id="status-pill" class="status-pill status-idle">idle</span>
<label class="refresh-toggle">
<input id="auto-refresh-enabled" type="checkbox">
<span>Auto refresh</span>
</label>
<select id="auto-refresh-interval" aria-label="Auto refresh interval">
<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="Refresh" aria-label="Refresh">
<span aria-hidden="true">Refresh</span>
</button>
</div>
</section>
<section id="loading-banner" class="banner banner-muted">Loading</section>
<section id="error-banner" class="banner banner-danger" hidden></section>
<section class="metric-grid" aria-label="Sentinel status metrics">
<article class="metric-card">
<span class="metric-label">Overall</span>
<strong id="metric-overall">-</strong>
<small id="metric-origin">-</small>
</article>
<article class="metric-card">
<span class="metric-label">Latest run</span>
<strong id="metric-latest-run">-</strong>
<small id="metric-latest-age">-</small>
</article>
<article class="metric-card">
<span class="metric-label">Findings</span>
<strong id="metric-findings">0</strong>
<small id="metric-findings-note">-</small>
</article>
<article class="metric-card">
<span class="metric-label">Scheduler</span>
<strong id="metric-scheduler">-</strong>
<small id="metric-budget">-</small>
</article>
</section>
<section class="dashboard-grid">
<section class="panel panel-wide" aria-labelledby="runs-heading">
<div class="panel-header">
<h2 id="runs-heading">Runs</h2>
<span id="runs-count" class="panel-subtitle">-</span>
</div>
<div class="table-frame">
<table class="runs-table">
<thead>
<tr>
<th>Run</th>
<th>Status</th>
<th>Scenario</th>
<th>Findings</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="runs-body"></tbody>
</table>
</div>
</section>
<section class="panel" aria-labelledby="findings-heading">
<div class="panel-header">
<h2 id="findings-heading">Findings</h2>
<span id="findings-count" class="panel-subtitle">-</span>
</div>
<div id="findings-list" class="finding-list"></div>
</section>
</section>
<section class="panel detail-panel" aria-labelledby="detail-heading">
<div class="panel-header">
<h2 id="detail-heading">Run Detail</h2>
<span id="detail-subtitle" class="panel-subtitle">No run selected</span>
</div>
<div id="detail-content" class="detail-content"></div>
</section>
</main>
<script type="module" src="/dashboard/assets/dashboard.js"></script>
</body>
</html>`;
}
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");
return null;
}
function textAsset(path: string, contentType: string): Response {
return new Response(readFileSync(rootPath(path), "utf8"), {
headers: {
"cache-control": "no-store",
"content-type": contentType,
},
});
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function escapeHtml(value: string): string {
return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;");
}
function escapeAttr(value: string): string {
return escapeHtml(value).replace(/'/gu, "&#39;");
}
+6 -20
View File
@@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Database } from "bun:sqlite";
import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
@@ -249,7 +250,7 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
return renderMetrics(config, db, this.health(), this.maintenance());
},
dashboardHtml() {
return renderDashboard(config, this.status());
return renderWebProbeSentinelDashboardHtml(config);
},
async fetch(request: Request) {
return sentinelFetch(service, request);
@@ -312,6 +313,10 @@ async function sentinelFetch(service: WebProbeSentinelService, request: Request)
if (request.method === "GET" && url.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 (asset !== null) return asset;
}
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/dashboard")) {
return new Response(service.dashboardHtml(), { headers: { "content-type": "text/html; charset=utf-8" } });
}
@@ -479,21 +484,6 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
return `${lines.join("\n")}\n`;
}
function renderDashboard(config: WebProbeSentinelServiceConfig, status: Record<string, unknown>): string {
const runs = Array.isArray(status.latestRuns) ? status.latestRuns.map(record) : [];
const rows = runs.map((run) => `<tr><td>${escapeHtml(stringOrNull(run.id) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.scenario_id) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.status) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.report_json_sha256) ?? "-")}</td><td>${escapeHtml(String(run.finding_count ?? 0))}</td></tr>`).join("");
return `<!doctype html>
<html><head><meta charset="utf-8"><title>HWLAB Web Probe Sentinel</title>
<style>body{font-family:system-ui,sans-serif;margin:24px;color:#18202a}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d7dde5;padding:6px;text-align:left}code{background:#f2f4f7;padding:2px 4px}</style></head>
<body>
<h1>HWLAB Web Probe Sentinel</h1>
<p><code>${escapeHtml(config.node)}</code> / <code>${escapeHtml(config.lane)}</code> configReady=${config.plan.ok ? "true" : "false"}</p>
<p>Dashboard is redacted: prompt text, assistant body, cookies, tokens, API keys, provider payload, and stdout/stderr are not displayed.</p>
<h2>Latest Runs</h2>
<table><thead><tr><th>Run</th><th>Scenario</th><th>Status</th><th>Report SHA</th><th>Findings</th></tr></thead><tbody>${rows}</tbody></table>
</body></html>`;
}
function readConfigRefTarget(ref: string): unknown {
const [file, path] = ref.split("#");
if (file === undefined || path === undefined) throw new Error(`invalid configRef: ${ref}`);
@@ -1270,7 +1260,3 @@ function nowIso(): string {
function metricLabel(value: string): string {
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"').replace(/\n/gu, "\\n");
}
function escapeHtml(value: string): string {
return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;");
}