372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
// 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, "&")
|
|
.replace(/</gu, "<")
|
|
.replace(/>/gu, ">")
|
|
.replace(/"/gu, """)
|
|
.replace(/'/gu, "'");
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value);
|
|
}
|