diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css new file mode 100644 index 00000000..a667cc9c --- /dev/null +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -0,0 +1,450 @@ +/* SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard. */ +/* Responsibility: Responsive visual foundation for the web-probe sentinel dashboard. */ +:root { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --panel-soft: #f0f5f7; + --text: #18212f; + --muted: #667085; + --border: #d9e0e7; + --blue: #2563eb; + --green: #059669; + --amber: #b7791f; + --red: #c2413d; + --violet: #6d5bd0; + --shadow: 0 8px 24px rgb(30 41 59 / 8%); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +select, +input { + font: inherit; +} + +.sentinel-shell { + width: min(1440px, 100%); + margin: 0 auto; + padding: 24px; +} + +.sentinel-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.sentinel-title { + display: flex; + align-items: center; + min-width: 0; + gap: 12px; +} + +.sentinel-mark { + width: 36px; + height: 36px; + border-radius: 8px; + background: + linear-gradient(135deg, rgb(37 99 235 / 90%), rgb(5 150 105 / 88%)), + #2563eb; + box-shadow: inset 0 0 0 1px rgb(255 255 255 / 30%); + flex: 0 0 auto; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 22px; + line-height: 1.18; + font-weight: 700; +} + +h2 { + font-size: 15px; + line-height: 1.3; + font-weight: 700; +} + +#sentinel-subtitle, +.panel-subtitle, +.metric-card small { + color: var(--muted); +} + +#sentinel-subtitle { + margin-top: 3px; + font-size: 13px; + overflow-wrap: anywhere; +} + +.sentinel-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.status-pill, +.severity-pill { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.status-healthy, +.status-operational { + background: #e8f7ef; + color: var(--green); + border-color: #b9e6cc; +} + +.status-warning, +.status-degraded, +.severity-warning, +.severity-amber { + background: #fff6df; + color: var(--amber); + border-color: #f2d893; +} + +.status-blocked, +.status-error, +.severity-red, +.severity-critical, +.severity-error { + background: #fdecea; + color: var(--red); + border-color: #f4bbb7; +} + +.status-idle, +.severity-info, +.severity-unknown { + background: #eef2f7; + color: #526071; + border-color: #d8e0ea; +} + +.refresh-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 4px 8px; + color: #344054; + font-size: 13px; + white-space: nowrap; +} + +.refresh-toggle input { + width: 16px; + height: 16px; +} + +select, +.icon-button { + min-height: 34px; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + color: #1f2937; +} + +select { + padding: 0 28px 0 10px; +} + +.icon-button { + padding: 0 12px; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.icon-button:disabled { + cursor: progress; + opacity: 0.62; +} + +.banner { + margin-bottom: 14px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + font-size: 13px; + overflow-wrap: anywhere; +} + +.banner-muted { + background: #edf4ff; + color: #1d4ed8; + border-color: #bfd5ff; +} + +.banner-danger { + background: #fff0ee; + color: var(--red); + border-color: #f2c0bc; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.metric-card, +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.metric-card { + min-height: 112px; + padding: 14px; + display: grid; + align-content: space-between; + gap: 8px; +} + +.metric-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.metric-card strong { + min-width: 0; + font-size: 24px; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.metric-card small { + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr); + gap: 14px; +} + +.panel { + min-width: 0; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 52px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.table-frame { + width: 100%; + overflow: auto; +} + +.runs-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; +} + +.runs-table th, +.runs-table td { + padding: 11px 12px; + border-bottom: 1px solid #edf0f4; + text-align: left; + vertical-align: top; + font-size: 13px; +} + +.runs-table th { + color: var(--muted); + background: #f8fafc; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.runs-table tr { + cursor: pointer; +} + +.runs-table tr:hover { + background: #f7fbff; +} + +.mono { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + +.finding-list { + display: grid; + gap: 10px; + padding: 12px; +} + +.finding-item { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #e3e8ef; + border-radius: 8px; + background: var(--panel-soft); +} + +.finding-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.finding-title { + min-width: 0; + font-weight: 700; + overflow-wrap: anywhere; +} + +.finding-meta, +.detail-grid { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.detail-panel { + margin-top: 14px; +} + +.detail-content { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 14px 16px 16px; +} + +.detail-block { + min-width: 0; + padding: 2px 0 2px 12px; + border-left: 3px solid #dbe5ee; +} + +.detail-block strong { + display: block; + margin-bottom: 8px; + font-size: 12px; + text-transform: uppercase; + color: var(--muted); +} + +.empty-state { + padding: 28px 16px; + color: var(--muted); + text-align: center; + font-size: 13px; +} + +[hidden] { + display: none !important; +} + +@media (max-width: 980px) { + .sentinel-shell { + padding: 16px; + } + + .sentinel-topbar { + align-items: flex-start; + flex-direction: column; + } + + .sentinel-toolbar { + justify-content: flex-start; + width: 100%; + } + + .metric-grid, + .dashboard-grid, + .detail-content { + grid-template-columns: 1fr; + } + + .metric-card { + min-height: 96px; + } +} + +@media (max-width: 560px) { + .sentinel-shell { + padding: 12px; + } + + h1 { + font-size: 19px; + } + + .sentinel-toolbar > * { + flex: 1 1 auto; + } + + .status-pill { + justify-content: center; + } + + .icon-button { + width: 100%; + } + + .panel-header { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js new file mode 100644 index 00000000..e0cd56b5 --- /dev/null +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -0,0 +1,371 @@ +// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard. +// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering. + +/** + * @typedef {{runId?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null}} Traceability + * @typedef {{id?: string|null, runId?: string|null, scenarioId?: string|null, status?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, findingCount?: number, artifactCount?: number, updatedAt?: string|null, maxSeverity?: string|null, traceability?: Traceability}} SentinelRun + * @typedef {{ok: boolean, status?: string, node?: string, lane?: string, publicOrigin?: string|null, latestRun?: SentinelRun|null, runCounts?: Record, severityCounts?: Record, freshness?: Record, targetValidation?: {maxSeconds?: number, scenarioId?: string|null}, valuesRedacted?: boolean}} SentinelOverview + * @typedef {{groups?: Array<{code?: string|null, findingId?: string|null, severity?: string|null, scenarioId?: string|null, count?: number, runCount?: number, latestAt?: string|null, latestRunId?: string|null, summary?: string|null}>}} SentinelFindingsResponse + * @typedef {{items?: SentinelRun[], runs?: SentinelRun[], page?: {limit?: number, nextCursor?: string|null, hasMore?: boolean}}} SentinelRunsResponse + */ + +const root = document.getElementById("sentinel-dashboard"); +const refs = { + statusPill: document.getElementById("status-pill"), + loadingBanner: document.getElementById("loading-banner"), + errorBanner: document.getElementById("error-banner"), + manualRefresh: document.getElementById("manual-refresh"), + autoRefreshEnabled: document.getElementById("auto-refresh-enabled"), + autoRefreshInterval: document.getElementById("auto-refresh-interval"), + overall: document.getElementById("metric-overall"), + origin: document.getElementById("metric-origin"), + latestRun: document.getElementById("metric-latest-run"), + latestAge: document.getElementById("metric-latest-age"), + findings: document.getElementById("metric-findings"), + findingsNote: document.getElementById("metric-findings-note"), + scheduler: document.getElementById("metric-scheduler"), + budget: document.getElementById("metric-budget"), + runsBody: document.getElementById("runs-body"), + runsCount: document.getElementById("runs-count"), + findingsList: document.getElementById("findings-list"), + findingsCount: document.getElementById("findings-count"), + detailSubtitle: document.getElementById("detail-subtitle"), + detailContent: document.getElementById("detail-content"), +}; + +const state = { + loading: false, + selectedRunId: null, + /** @type {SentinelOverview|null} */ + overview: null, + /** @type {SentinelRun[]} */ + runs: [], + findings: [], + runDetail: null, + lastUpdatedAt: null, +}; + +const dashboardApi = createDashboardApi(); +const autoRefresh = createAutoRefresh({ + storageKey: "hwlab.webProbeSentinel.dashboard.autoRefresh", + intervals: [5, 10, 30], + defaultInterval: 10, + onRefresh: () => loadDashboard({ silent: true }), + shouldPause: () => document.hidden || state.loading, +}); + +refs.manualRefresh.addEventListener("click", () => loadDashboard({ silent: false })); +refs.autoRefreshEnabled.addEventListener("change", () => autoRefresh.setEnabled(refs.autoRefreshEnabled.checked)); +refs.autoRefreshInterval.addEventListener("change", () => autoRefresh.setInterval(Number(refs.autoRefreshInterval.value))); +document.addEventListener("visibilitychange", () => { + if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true }); +}); + +hydrateControls(); +loadDashboard({ silent: false }).catch((error) => renderError(error)); + +function createDashboardApi() { + return { + /** @returns {Promise} */ + overview: () => getJson("/api/overview"), + /** @returns {Promise} */ + runs: () => getJson("/api/runs?limit=20&sort=updated&direction=desc"), + /** @returns {Promise} */ + findings: () => getJson("/api/findings?limit=20&window=24h"), + runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`), + }; +} + +async function getJson(path) { + const response = await fetch(path, { headers: { accept: "application/json" } }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok === false) { + const message = body.error || `${response.status} ${response.statusText}`; + throw new Error(message); + } + return body; +} + +function createAutoRefresh(options) { + let enabled = false; + let intervalSeconds = options.defaultInterval; + let countdown = intervalSeconds; + let timer = null; + + try { + const saved = JSON.parse(localStorage.getItem(options.storageKey) || "{}"); + if (saved.enabled === true) enabled = true; + if (options.intervals.includes(Number(saved.intervalSeconds))) intervalSeconds = Number(saved.intervalSeconds); + } catch { + enabled = false; + } + + function persist() { + try { + localStorage.setItem(options.storageKey, JSON.stringify({ enabled, intervalSeconds })); + } catch { + /* ignore */ + } + } + + async function tick() { + if (!enabled || options.shouldPause()) return; + if (countdown > 0) { + countdown -= 1; + return; + } + countdown = intervalSeconds; + await options.onRefresh(); + } + + function start() { + if (timer !== null) return; + timer = window.setInterval(tick, 1000); + } + + function stop() { + if (timer === null) return; + window.clearInterval(timer); + timer = null; + } + + return { + enabled: () => enabled, + intervalSeconds: () => intervalSeconds, + setEnabled(value) { + enabled = value; + countdown = intervalSeconds; + persist(); + if (enabled) start(); + else stop(); + }, + setInterval(value) { + if (!options.intervals.includes(value)) return; + intervalSeconds = value; + countdown = value; + persist(); + }, + start, + }; +} + +function hydrateControls() { + refs.autoRefreshEnabled.checked = autoRefresh.enabled(); + refs.autoRefreshInterval.value = String(autoRefresh.intervalSeconds()); + if (autoRefresh.enabled()) autoRefresh.start(); +} + +async function loadDashboard(options) { + if (state.loading) return; + state.loading = true; + renderLoading(!options.silent); + try { + const [overview, runs, findings] = await Promise.all([ + dashboardApi.overview(), + dashboardApi.runs(), + dashboardApi.findings(), + ]); + state.overview = overview; + state.runs = Array.isArray(runs.items) ? runs.items : Array.isArray(runs.runs) ? runs.runs : []; + state.findings = Array.isArray(findings.groups) ? findings.groups : []; + state.lastUpdatedAt = new Date(); + if (state.selectedRunId && !state.runs.some((run) => run.runId === state.selectedRunId || run.id === state.selectedRunId)) { + state.selectedRunId = null; + state.runDetail = null; + } + renderDashboard(); + } catch (error) { + renderError(error); + } finally { + state.loading = false; + renderLoading(false); + } +} + +async function selectRun(runId) { + if (!runId) return; + state.selectedRunId = runId; + refs.detailSubtitle.textContent = runId; + refs.detailContent.innerHTML = '
Loading
'; + try { + state.runDetail = await dashboardApi.runDetail(runId); + renderDetail(); + } catch (error) { + refs.detailContent.innerHTML = `
${escapeHtml(error.message || String(error))}
`; + } +} + +function renderDashboard() { + refs.errorBanner.hidden = true; + renderOverview(); + renderRuns(); + renderFindings(); + renderDetail(); +} + +function renderOverview() { + const overview = state.overview || {}; + const status = overview.status || (overview.ok ? "healthy" : "degraded"); + refs.statusPill.textContent = status; + refs.statusPill.className = `status-pill ${statusClass(status)}`; + refs.overall.textContent = status; + refs.origin.textContent = overview.publicOrigin || root.dataset.publicOrigin || "-"; + + const latest = overview.latestRun || null; + refs.latestRun.textContent = latest?.runId || latest?.id || "-"; + refs.latestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} updated` : "-"; + + const severityCounts = overview.severityCounts || {}; + const totalFindings = Object.values(severityCounts).reduce((sum, value) => sum + Number(value || 0), 0); + refs.findings.textContent = formatNumber(totalFindings); + refs.findingsNote.textContent = formatSeveritySummary(severityCounts); + + const scheduler = overview.scheduler || {}; + const heartbeat = scheduler.heartbeat || {}; + refs.scheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "unknown"; + const maxSeconds = overview.targetValidation?.maxSeconds ?? 120; + refs.budget.textContent = `targetValidation ${maxSeconds}s`; +} + +function renderRuns() { + refs.runsCount.textContent = `${state.runs.length} visible`; + if (state.runs.length === 0) { + refs.runsBody.innerHTML = 'No runs'; + return; + } + refs.runsBody.innerHTML = state.runs.map((run) => { + const runId = run.runId || run.id || "-"; + return ` +
${escapeHtml(runId)}
${escapeHtml(run.observerId || "-")} + ${escapeHtml(run.status || "-")} + ${escapeHtml(run.scenarioId || "-")} + ${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` ${escapeHtml(run.maxSeverity)}` : ""} + ${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")} + `; + }).join(""); + for (const row of refs.runsBody.querySelectorAll("tr[data-run-id]")) { + row.addEventListener("click", () => selectRun(row.dataset.runId)); + } +} + +function renderFindings() { + refs.findingsCount.textContent = `${state.findings.length} groups`; + if (state.findings.length === 0) { + refs.findingsList.innerHTML = '
No findings
'; + return; + } + refs.findingsList.innerHTML = state.findings.map((item) => { + const code = item.code || item.findingId || "finding"; + const latestRunId = item.latestRunId || "-"; + return `
+
+ ${escapeHtml(code)} + ${escapeHtml(item.severity || "unknown")} +
+
${escapeHtml(item.scenarioId || "-")} · count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))}
+
${escapeHtml(latestRunId)}
+
${escapeHtml(item.summary || "")}
+
`; + }).join(""); +} + +function renderDetail() { + if (!state.selectedRunId || !state.runDetail) { + refs.detailSubtitle.textContent = "No run selected"; + refs.detailContent.innerHTML = '
Select a run
'; + return; + } + const detail = state.runDetail; + const run = detail.run || {}; + refs.detailSubtitle.textContent = run.runId || state.selectedRunId; + refs.detailContent.innerHTML = [ + detailBlock("Traceability", [ + ["run", run.runId || "-"], + ["observer", run.observerId || "-"], + ["stateDir", run.stateDir || "-"], + ["report", run.reportJsonSha256 || "-"], + ]), + detailBlock("Summary", [ + ["status", run.status || "-"], + ["scenario", run.scenarioId || "-"], + ["findings", String(run.findingCount ?? 0)], + ["artifacts", String(run.artifactCount ?? 0)], + ]), + detailBlock("Commands", [ + ["summary", detail.commands?.summary || "-"], + ["trace", detail.commands?.traceFrame || "-"], + ]), + detailBlock("Redaction", [ + ["values", detail.valuesRedacted === true ? "redacted" : "-"], + ["prompt", detail.redaction?.prompt || "-"], + ["assistant", detail.redaction?.assistantFinal || "-"], + ]), + ].join(""); +} + +function detailBlock(title, rows) { + return `
${escapeHtml(title)}
${ + rows.map(([key, value]) => `
${escapeHtml(key)}: ${escapeHtml(value)}
`).join("") + }
`; +} + +function renderLoading(show) { + refs.loadingBanner.hidden = !show; + refs.manualRefresh.disabled = state.loading; +} + +function renderError(error) { + refs.errorBanner.hidden = false; + refs.errorBanner.textContent = error?.message || String(error); +} + +function statusClass(value) { + const normalized = String(value || "idle").toLowerCase(); + if (["healthy", "observed", "analyzed", "pass", "ok"].includes(normalized)) return "status-healthy"; + if (["warning", "degraded", "planned", "running", "queued"].includes(normalized)) return "status-warning"; + if (["blocked", "failed", "error", "timeout", "interrupted"].includes(normalized)) return "status-blocked"; + return "status-idle"; +} + +function severityClass(value) { + const normalized = String(value || "unknown").toLowerCase(); + if (["red", "critical", "error"].includes(normalized)) return `severity-${normalized}`; + if (["warning", "amber"].includes(normalized)) return `severity-${normalized}`; + if (["info"].includes(normalized)) return "severity-info"; + return "severity-unknown"; +} + +function formatSeveritySummary(counts) { + const entries = Object.entries(counts || {}).filter(([, value]) => Number(value || 0) > 0); + if (entries.length === 0) return "none"; + return entries.map(([key, value]) => `${key} ${formatNumber(Number(value || 0))}`).join(" · "); +} + +function formatNumber(value) { + return new Intl.NumberFormat("en-US").format(Number(value || 0)); +} + +function formatRelative(iso) { + const ms = Date.parse(String(iso || "")); + if (!Number.isFinite(ms)) return "-"; + const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """) + .replace(/'/gu, "'"); +} + +function escapeAttr(value) { + return escapeHtml(value); +} diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index 40df9476..7503769c 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -1245,12 +1245,14 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract { + 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 { 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, metrics: Record, report: Record, publicExposure: Record, quickVerify: Record | null): Record { +function validationBlocker(health: Record, metrics: Record, report: Record, publicExposure: Record, publicDashboard: Record, quickVerify: Record | null): Record { 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 { 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 { ["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)}`], ]), "", diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts new file mode 100644 index 00000000..15212c31 --- /dev/null +++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts @@ -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; +} + +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 ` + + + + + HWLAB Web Probe Sentinel + + + +
+
+
+ +
+

HWLAB Web Probe Sentinel

+

${escapeHtml(config.node)} / ${escapeHtml(config.lane)}

+
+
+
+ idle + + + +
+
+ + + + +
+
+ Overall + - + - +
+
+ Latest run + - + - +
+
+ Findings + 0 + - +
+
+ Scheduler + - + - +
+
+ +
+
+
+

Runs

+ - +
+
+ + + + + + + + + + + +
RunStatusScenarioFindingsUpdated
+
+
+ +
+
+

Findings

+ - +
+
+
+
+ +
+
+

Run Detail

+ No run selected +
+
+
+
+ + +`; +} + +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, """); +} + +function escapeAttr(value: string): string { + return escapeHtml(value).replace(/'/gu, "'"); +} diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts index 5a78d076..ca4d794a 100644 --- a/scripts/src/hwlab-node-web-sentinel-service.ts +++ b/scripts/src/hwlab-node-web-sentinel-service.ts @@ -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 { - const runs = Array.isArray(status.latestRuns) ? status.latestRuns.map(record) : []; - const rows = runs.map((run) => `${escapeHtml(stringOrNull(run.id) ?? "-")}${escapeHtml(stringOrNull(run.scenario_id) ?? "-")}${escapeHtml(stringOrNull(run.status) ?? "-")}${escapeHtml(stringOrNull(run.report_json_sha256) ?? "-")}${escapeHtml(String(run.finding_count ?? 0))}`).join(""); - return ` -HWLAB Web Probe Sentinel - - -

HWLAB Web Probe Sentinel

-

${escapeHtml(config.node)} / ${escapeHtml(config.lane)} configReady=${config.plan.ok ? "true" : "false"}

-

Dashboard is redacted: prompt text, assistant body, cookies, tokens, API keys, provider payload, and stdout/stderr are not displayed.

-

Latest Runs

-${rows}
RunScenarioStatusReport SHAFindings
-`; -} - 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, "&").replace(//gu, ">").replace(/"/gu, """); -}