Files
pikasTech-unidesk/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
T

299 lines
14 KiB
TypeScript

// 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.
// 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 sentinelId: string;
readonly plan: { readonly ok: boolean; readonly sentinels?: readonly Record<string, unknown>[] };
readonly publicExposure: Record<string, unknown>;
}
const DASHBOARD_ASSET_ROOT = "scripts/assets/web-probe-sentinel-dashboard";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p10-monitor-web-aggregation";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
const basePath = publicBasePath(publicOrigin);
const registryHtml = renderSentinelRegistryStrip(config, basePath);
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">
</head>
<body>
<main
id="sentinel-dashboard"
class="sentinel-shell"
data-node="${escapeAttr(config.node)}"
data-lane="${escapeAttr(config.lane)}"
data-sentinel-id="${escapeAttr(config.sentinelId)}"
data-base-path="${escapeAttr(basePath)}"
data-public-origin="${escapeAttr(publicOrigin)}"
data-config-ready="${config.plan.ok ? "true" : "false"}"
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
data-viewport=""
>
<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>
</main>
<script type="module" src="${escapeAttr(basePath)}/dashboard/assets/dashboard.js"></script>
</body>
</html>`;
}
function renderSentinelRegistryStrip(config: DashboardShellConfig, basePath: string): string {
const rows = 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 {
try {
const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
return path === "/" ? "" : path;
} catch {
return "";
}
}
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;");
}