// 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); }