From 15f117a292133be23b5d4f08ad5c685d0c123254 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 26 Jun 2026 03:03:19 +0000 Subject: [PATCH] feat: add web sentinel overview and runs views --- .../dashboard.css | 137 ++++++++++++++++ .../web-probe-sentinel-dashboard/dashboard.js | 148 +++++++++++++++++- ...wlab-node-web-sentinel-dashboard-assets.ts | 62 ++++++++ 3 files changed, 343 insertions(+), 4 deletions(-) diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css index a667cc9c..8bf26b46 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.css +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.css @@ -262,6 +262,83 @@ select { overflow-wrap: anywhere; } +.overview-checks { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 14px; +} + +.check-chip { + display: inline-flex; + align-items: center; + min-height: 30px; + max-width: 100%; + padding: 5px 10px; + border: 1px solid #d8e0ea; + border-radius: 999px; + background: #ffffff; + color: #475467; + font-size: 12px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.check-ok { + border-color: #b9e6cc; + background: #eefaf4; + color: var(--green); +} + +.check-blocked { + border-color: #f4bbb7; + background: #fff0ee; + color: var(--red); +} + +.timeline-panel { + margin-bottom: 14px; +} + +.run-timeline { + display: flex; + gap: 8px; + min-height: 68px; + padding: 14px 16px; + overflow-x: auto; +} + +.timeline-node { + display: inline-grid; + grid-template-rows: 18px auto; + justify-items: center; + gap: 4px; + min-width: 76px; + max-width: 120px; + border: 0; + background: transparent; + color: #475467; + cursor: pointer; +} + +.timeline-dot { + width: 16px; + height: 16px; + border: 3px solid currentColor; + border-radius: 999px; + background: #ffffff; +} + +.timeline-label { + width: 100%; + font-size: 11px; + font-weight: 800; + text-align: center; + text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; +} + .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr); @@ -288,6 +365,46 @@ select { overflow: auto; } +.runs-filter { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)) minmax(180px, 1.4fr) auto; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: #fbfcfe; +} + +.runs-filter label { + display: grid; + gap: 4px; + min-width: 0; +} + +.runs-filter label span { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.runs-filter input, +.runs-filter select { + width: 100%; +} + +.runs-filter input { + min-height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; + color: #1f2937; +} + +.runs-filter .icon-button { + align-self: end; +} + .runs-table { width: 100%; min-width: 760px; @@ -319,6 +436,18 @@ select { background: #f7fbff; } +.runs-table tr.selected-row { + background: #eef7ff; +} + +.runs-table td small { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} + .mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; @@ -417,6 +546,10 @@ select { grid-template-columns: 1fr; } + .runs-filter { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .metric-card { min-height: 96px; } @@ -447,4 +580,8 @@ select { align-items: flex-start; flex-direction: column; } + + .runs-filter { + grid-template-columns: 1fr; + } } diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js index e0cd56b5..9e9c9915 100644 --- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js +++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js @@ -25,6 +25,20 @@ const refs = { findingsNote: document.getElementById("metric-findings-note"), scheduler: document.getElementById("metric-scheduler"), budget: document.getElementById("metric-budget"), + checkConfig: document.getElementById("check-config"), + checkPvc: document.getElementById("check-pvc"), + checkAnalyzer: document.getElementById("check-analyzer"), + checkPublic: document.getElementById("check-public"), + checkMaintenance: document.getElementById("check-maintenance"), + timeline: document.getElementById("run-timeline"), + timelineCount: document.getElementById("timeline-count"), + filterForm: document.getElementById("runs-filter"), + filterStatus: document.getElementById("filter-status"), + filterSeverity: document.getElementById("filter-severity"), + filterWindow: document.getElementById("filter-window"), + filterSort: document.getElementById("filter-sort"), + filterSearch: document.getElementById("filter-search"), + clearFilters: document.getElementById("clear-filters"), runsBody: document.getElementById("runs-body"), runsCount: document.getElementById("runs-count"), findingsList: document.getElementById("findings-list"), @@ -43,6 +57,7 @@ const state = { findings: [], runDetail: null, lastUpdatedAt: null, + filters: readFiltersFromLocation(), }; const dashboardApi = createDashboardApi(); @@ -57,11 +72,23 @@ const autoRefresh = createAutoRefresh({ 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))); +refs.filterForm.addEventListener("submit", (event) => event.preventDefault()); +for (const control of [refs.filterStatus, refs.filterSeverity, refs.filterWindow, refs.filterSort]) { + control.addEventListener("change", () => applyFilterControls()); +} +refs.filterSearch.addEventListener("input", debounce(() => applyFilterControls(), 250)); +refs.clearFilters.addEventListener("click", () => { + state.filters = { status: "", severity: "", window: "", search: "", sort: "updated" }; + writeFiltersToControls(); + syncLocationQuery(); + loadDashboard({ silent: false }); +}); document.addEventListener("visibilitychange", () => { if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true }); }); hydrateControls(); +writeFiltersToControls(); loadDashboard({ silent: false }).catch((error) => renderError(error)); function createDashboardApi() { @@ -69,7 +96,7 @@ function createDashboardApi() { /** @returns {Promise} */ overview: () => getJson("/api/overview"), /** @returns {Promise} */ - runs: () => getJson("/api/runs?limit=20&sort=updated&direction=desc"), + runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`), /** @returns {Promise} */ findings: () => getJson("/api/findings?limit=20&window=24h"), runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`), @@ -162,18 +189,21 @@ async function loadDashboard(options) { try { const [overview, runs, findings] = await Promise.all([ dashboardApi.overview(), - dashboardApi.runs(), + dashboardApi.runs(state.filters), 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(); + const queryRun = new URLSearchParams(window.location.search).get("run"); + if (queryRun && state.selectedRunId === null) state.selectedRunId = queryRun; if (state.selectedRunId && !state.runs.some((run) => run.runId === state.selectedRunId || run.id === state.selectedRunId)) { state.selectedRunId = null; state.runDetail = null; } renderDashboard(); + if (state.selectedRunId && state.runDetail === null) await selectRun(state.selectedRunId); } catch (error) { renderError(error); } finally { @@ -185,6 +215,8 @@ async function loadDashboard(options) { async function selectRun(runId) { if (!runId) return; state.selectedRunId = runId; + syncLocationQuery(); + renderRuns(); refs.detailSubtitle.textContent = runId; refs.detailContent.innerHTML = '
Loading
'; try { @@ -198,6 +230,7 @@ async function selectRun(runId) { function renderDashboard() { refs.errorBanner.hidden = true; renderOverview(); + renderTimeline(); renderRuns(); renderFindings(); renderDetail(); @@ -225,6 +258,33 @@ function renderOverview() { refs.scheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "unknown"; const maxSeconds = overview.targetValidation?.maxSeconds ?? 120; refs.budget.textContent = `targetValidation ${maxSeconds}s`; + + const checks = overview.health?.checks || {}; + renderCheckChip(refs.checkConfig, "config", checks.config?.ok, checks.config?.status); + renderCheckChip(refs.checkPvc, "pvc", checks.pvc?.ok, checks.pvc?.stateRoot); + renderCheckChip(refs.checkAnalyzer, "analyzer", checks.analyzer?.ok, "observe analyze"); + renderCheckChip(refs.checkPublic, "public", Boolean(overview.publicOrigin), overview.publicOrigin || "-"); + const maintenance = overview.maintenance || {}; + renderCheckChip(refs.checkMaintenance, "maintenance", maintenance.active !== true, maintenance.active ? "active" : "inactive"); +} + +function renderTimeline() { + refs.timelineCount.textContent = `${state.runs.length} recent`; + if (state.runs.length === 0) { + refs.timeline.innerHTML = '
No timeline
'; + return; + } + refs.timeline.innerHTML = state.runs.slice(0, 20).map((run) => { + const runId = run.runId || run.id || "-"; + const title = `${run.status || "-"} · ${run.findingCount ?? 0} findings · ${run.updatedAt ? formatRelative(run.updatedAt) : "-"}`; + return ``; + }).join(""); + for (const node of refs.timeline.querySelectorAll("[data-run-id]")) { + node.addEventListener("click", () => selectRun(node.dataset.runId)); + } } function renderRuns() { @@ -235,12 +295,13 @@ function renderRuns() { } refs.runsBody.innerHTML = state.runs.map((run) => { const runId = run.runId || run.id || "-"; - return ` + const selected = state.selectedRunId === runId ? " selected-row" : ""; + 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) : "-")} + ${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}${escapeHtml(run.maintenance ? "maintenance" : "")} `; }).join(""); for (const row of refs.runsBody.querySelectorAll("tr[data-run-id]")) { @@ -314,6 +375,80 @@ function renderLoading(show) { refs.manualRefresh.disabled = state.loading; } +function renderCheckChip(element, label, ok, detail) { + const status = ok ? "ok" : "blocked"; + element.textContent = `${label} ${status}${detail ? ` · ${shortText(detail, 34)}` : ""}`; + element.className = `check-chip ${ok ? "check-ok" : "check-blocked"}`; +} + +function applyFilterControls() { + state.filters = { + status: refs.filterStatus.value, + severity: refs.filterSeverity.value, + window: refs.filterWindow.value, + search: refs.filterSearch.value.trim(), + sort: refs.filterSort.value || "updated", + }; + syncLocationQuery(); + loadDashboard({ silent: false }); +} + +function writeFiltersToControls() { + refs.filterStatus.value = state.filters.status || ""; + refs.filterSeverity.value = state.filters.severity || ""; + refs.filterWindow.value = state.filters.window || ""; + refs.filterSearch.value = state.filters.search || ""; + refs.filterSort.value = state.filters.sort || "updated"; +} + +function readFiltersFromLocation() { + const query = new URLSearchParams(window.location.search); + return { + status: query.get("status") || "", + severity: query.get("severity") || "", + window: query.get("window") || "", + search: query.get("search") || "", + sort: query.get("sort") || "updated", + }; +} + +function syncLocationQuery() { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(state.filters)) { + if (value && !(key === "sort" && value === "updated")) query.set(key, value); + } + if (state.selectedRunId) query.set("run", state.selectedRunId); + const next = `${window.location.pathname}${query.toString() ? `?${query.toString()}` : ""}`; + window.history.replaceState(null, "", next); +} + +function runsQuery(filters) { + const query = new URLSearchParams({ limit: "40", sort: filters.sort || "updated", direction: "desc" }); + if (filters.status) query.set("status", filters.status); + if (filters.severity) query.set("severity", filters.severity); + if (filters.search) query.set("search", filters.search); + const from = windowFrom(filters.window); + if (from) query.set("from", from); + return query.toString(); +} + +function windowFrom(value) { + if (!value) return ""; + const match = /^(\d+)(h|d)$/u.exec(value); + if (!match) return ""; + const amount = Number(match[1]); + const ms = match[2] === "h" ? amount * 3_600_000 : amount * 86_400_000; + return new Date(Date.now() - ms).toISOString(); +} + +function debounce(fn, waitMs) { + let timer = null; + return (...args) => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => fn(...args), waitMs); + }; +} + function renderError(error) { refs.errorBanner.hidden = false; refs.errorBanner.textContent = error?.message || String(error); @@ -345,6 +480,11 @@ function formatNumber(value) { return new Intl.NumberFormat("en-US").format(Number(value || 0)); } +function shortText(value, limit) { + const text = String(value || ""); + return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`; +} + function formatRelative(iso) { const ms = Date.parse(String(iso || "")); if (!Number.isFinite(ms)) return "-"; diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts index 15212c31..22eb4199 100644 --- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts +++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts @@ -84,12 +84,74 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig +
+ config - + pvc - + analyzer - + public - + maintenance - +
+ +
+
+

Run Timeline

+ - +
+
+
+

Runs

-
+
+ + + + + + +