feat: add web sentinel dashboard frontend shell
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</gu, "<")
|
||||
.replace(/>/gu, ">")
|
||||
.replace(/"/gu, """)
|
||||
.replace(/'/gu, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value);
|
||||
}
|
||||
@@ -1245,12 +1245,14 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract<WebProbe
|
||||
const metrics = callSentinelService(state, "GET", "/metrics", null, options.timeoutSeconds);
|
||||
const report = callSentinelService(state, "GET", "/api/report?view=summary", null, options.timeoutSeconds);
|
||||
const publicExposure = probeSentinelPublicExposure(state, options.timeoutSeconds);
|
||||
const publicDashboard = probeSentinelPublicDashboard(state, options.timeoutSeconds);
|
||||
const ok = health.ok
|
||||
&& record(health.bodyJson).ok === true
|
||||
&& metrics.ok
|
||||
&& metricNames(record(metrics).bodyTextPreview).includes("web_probe_sentinel_health")
|
||||
&& report.ok
|
||||
&& publicExposure.ok === true
|
||||
&& publicDashboard.ok === true
|
||||
&& (quickVerify === null || quickVerify.ok === true);
|
||||
const result = {
|
||||
ok,
|
||||
@@ -1262,8 +1264,9 @@ function runSentinelValidate(state: SentinelCicdState, options: Extract<WebProbe
|
||||
metrics,
|
||||
report,
|
||||
publicExposure,
|
||||
publicDashboard,
|
||||
quickVerify,
|
||||
blocker: ok ? null : validationBlocker(health, metrics, report, publicExposure, quickVerify),
|
||||
blocker: ok ? null : validationBlocker(health, metrics, report, publicExposure, publicDashboard, quickVerify),
|
||||
next: sentinelP5Next(state),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
@@ -1618,6 +1621,38 @@ function probeSentinelPublicExposure(state: SentinelCicdState, timeoutSeconds: n
|
||||
return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
|
||||
}
|
||||
|
||||
function probeSentinelPublicDashboard(state: SentinelCicdState, timeoutSeconds: number): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>, metrics: Record<string, unknown>, report: Record<string, unknown>, publicExposure: Record<string, unknown>, quickVerify: Record<string, unknown> | null): Record<string, unknown> {
|
||||
function validationBlocker(health: Record<string, unknown>, metrics: Record<string, unknown>, report: Record<string, unknown>, publicExposure: Record<string, unknown>, publicDashboard: Record<string, unknown>, quickVerify: Record<string, unknown> | null): Record<string, unknown> {
|
||||
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, unknown>): 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, unknown>): 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)}`],
|
||||
]),
|
||||
"",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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 `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HWLAB Web Probe Sentinel</title>
|
||||
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<main
|
||||
id="sentinel-dashboard"
|
||||
class="sentinel-shell"
|
||||
data-node="${escapeAttr(config.node)}"
|
||||
data-lane="${escapeAttr(config.lane)}"
|
||||
data-public-origin="${escapeAttr(publicOrigin)}"
|
||||
data-config-ready="${config.plan.ok ? "true" : "false"}"
|
||||
data-contract-version="${DASHBOARD_CONTRACT_VERSION}"
|
||||
>
|
||||
<section class="sentinel-topbar" aria-label="Web probe sentinel overview">
|
||||
<div class="sentinel-title">
|
||||
<div class="sentinel-mark" aria-hidden="true"></div>
|
||||
<div>
|
||||
<h1>HWLAB Web Probe Sentinel</h1>
|
||||
<p id="sentinel-subtitle">${escapeHtml(config.node)} / ${escapeHtml(config.lane)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sentinel-toolbar" aria-label="Dashboard controls">
|
||||
<span id="status-pill" class="status-pill status-idle">idle</span>
|
||||
<label class="refresh-toggle">
|
||||
<input id="auto-refresh-enabled" type="checkbox">
|
||||
<span>Auto refresh</span>
|
||||
</label>
|
||||
<select id="auto-refresh-interval" aria-label="Auto refresh interval">
|
||||
<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="Refresh" aria-label="Refresh">
|
||||
<span aria-hidden="true">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loading-banner" class="banner banner-muted">Loading</section>
|
||||
<section id="error-banner" class="banner banner-danger" hidden></section>
|
||||
|
||||
<section class="metric-grid" aria-label="Sentinel status metrics">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Overall</span>
|
||||
<strong id="metric-overall">-</strong>
|
||||
<small id="metric-origin">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Latest run</span>
|
||||
<strong id="metric-latest-run">-</strong>
|
||||
<small id="metric-latest-age">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Findings</span>
|
||||
<strong id="metric-findings">0</strong>
|
||||
<small id="metric-findings-note">-</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Scheduler</span>
|
||||
<strong id="metric-scheduler">-</strong>
|
||||
<small id="metric-budget">-</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<section class="panel panel-wide" aria-labelledby="runs-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="runs-heading">Runs</h2>
|
||||
<span id="runs-count" class="panel-subtitle">-</span>
|
||||
</div>
|
||||
<div class="table-frame">
|
||||
<table class="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Status</th>
|
||||
<th>Scenario</th>
|
||||
<th>Findings</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runs-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="findings-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="findings-heading">Findings</h2>
|
||||
<span id="findings-count" class="panel-subtitle">-</span>
|
||||
</div>
|
||||
<div id="findings-list" class="finding-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel" aria-labelledby="detail-heading">
|
||||
<div class="panel-header">
|
||||
<h2 id="detail-heading">Run Detail</h2>
|
||||
<span id="detail-subtitle" class="panel-subtitle">No run selected</span>
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/dashboard/assets/dashboard.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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, ">").replace(/"/gu, """);
|
||||
}
|
||||
|
||||
function escapeAttr(value: string): string {
|
||||
return escapeHtml(value).replace(/'/gu, "'");
|
||||
}
|
||||
@@ -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, unknown>): string {
|
||||
const runs = Array.isArray(status.latestRuns) ? status.latestRuns.map(record) : [];
|
||||
const rows = runs.map((run) => `<tr><td>${escapeHtml(stringOrNull(run.id) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.scenario_id) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.status) ?? "-")}</td><td>${escapeHtml(stringOrNull(run.report_json_sha256) ?? "-")}</td><td>${escapeHtml(String(run.finding_count ?? 0))}</td></tr>`).join("");
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>HWLAB Web Probe Sentinel</title>
|
||||
<style>body{font-family:system-ui,sans-serif;margin:24px;color:#18202a}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d7dde5;padding:6px;text-align:left}code{background:#f2f4f7;padding:2px 4px}</style></head>
|
||||
<body>
|
||||
<h1>HWLAB Web Probe Sentinel</h1>
|
||||
<p><code>${escapeHtml(config.node)}</code> / <code>${escapeHtml(config.lane)}</code> configReady=${config.plan.ok ? "true" : "false"}</p>
|
||||
<p>Dashboard is redacted: prompt text, assistant body, cookies, tokens, API keys, provider payload, and stdout/stderr are not displayed.</p>
|
||||
<h2>Latest Runs</h2>
|
||||
<table><thead><tr><th>Run</th><th>Scenario</th><th>Status</th><th>Report SHA</th><th>Findings</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
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, ">").replace(/"/gu, """);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user