feat: add web sentinel dashboard frontend shell

This commit is contained in:
Codex
2026-06-26 02:57:41 +00:00
parent 2f1911ee35
commit 05b998c326
5 changed files with 1023 additions and 22 deletions
@@ -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;
}
}
@@ -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<string, number>, severityCounts?: Record<string, number>, freshness?: Record<string, unknown>, 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<SentinelOverview>} */
overview: () => getJson("/api/overview"),
/** @returns {Promise<SentinelRunsResponse>} */
runs: () => getJson("/api/runs?limit=20&sort=updated&direction=desc"),
/** @returns {Promise<SentinelFindingsResponse>} */
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 = '<div class="empty-state">Loading</div>';
try {
state.runDetail = await dashboardApi.runDetail(runId);
renderDetail();
} catch (error) {
refs.detailContent.innerHTML = `<div class="empty-state">${escapeHtml(error.message || String(error))}</div>`;
}
}
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 = '<tr><td class="empty-state" colspan="5">No runs</td></tr>';
return;
}
refs.runsBody.innerHTML = state.runs.map((run) => {
const runId = run.runId || run.id || "-";
return `<tr data-run-id="${escapeAttr(runId)}">
<td><div class="mono">${escapeHtml(runId)}</div><small>${escapeHtml(run.observerId || "-")}</small></td>
<td><span class="status-pill ${statusClass(run.status)}">${escapeHtml(run.status || "-")}</span></td>
<td>${escapeHtml(run.scenarioId || "-")}</td>
<td>${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(run.maxSeverity)}</span>` : ""}</td>
<td>${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}</td>
</tr>`;
}).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 = '<div class="empty-state">No findings</div>';
return;
}
refs.findingsList.innerHTML = state.findings.map((item) => {
const code = item.code || item.findingId || "finding";
const latestRunId = item.latestRunId || "-";
return `<article class="finding-item">
<div class="finding-row">
<span class="finding-title">${escapeHtml(code)}</span>
<span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(item.severity || "unknown")}</span>
</div>
<div class="finding-meta">${escapeHtml(item.scenarioId || "-")} · count=${escapeHtml(String(item.count ?? 0))} · runs=${escapeHtml(String(item.runCount ?? 0))}</div>
<div class="finding-meta mono">${escapeHtml(latestRunId)}</div>
<div class="finding-meta">${escapeHtml(item.summary || "")}</div>
</article>`;
}).join("");
}
function renderDetail() {
if (!state.selectedRunId || !state.runDetail) {
refs.detailSubtitle.textContent = "No run selected";
refs.detailContent.innerHTML = '<div class="empty-state">Select a run</div>';
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 `<article class="detail-block"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${
rows.map(([key, value]) => `<div><span>${escapeHtml(key)}:</span> <span class="mono">${escapeHtml(value)}</span></div>`).join("")
}</div></article>`;
}
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, "&amp;")
.replace(/</gu, "&lt;")
.replace(/>/gu, "&gt;")
.replace(/"/gu, "&quot;")
.replace(/'/gu, "&#39;");
}
function escapeAttr(value) {
return escapeHtml(value);
}