feat: add web sentinel dashboard frontend shell
This commit is contained in:
@@ -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, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, """);
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return escapeHtml(value).replace(/'/gu, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user