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
+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;