Merge pull request #948 from pikasTech/feat/935-sentinel-dashboard-overview-runs

feat: add web sentinel overview and runs views
This commit is contained in:
Lyon
2026-06-26 11:04:05 +08:00
committed by GitHub
3 changed files with 343 additions and 4 deletions
@@ -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;
}
}
@@ -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<SentinelOverview>} */
overview: () => getJson("/api/overview"),
/** @returns {Promise<SentinelRunsResponse>} */
runs: () => getJson("/api/runs?limit=20&sort=updated&direction=desc"),
runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`),
/** @returns {Promise<SentinelFindingsResponse>} */
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 = '<div class="empty-state">Loading</div>';
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 = '<div class="empty-state">No timeline</div>';
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 `<button class="timeline-node ${statusClass(run.status)}" type="button" data-run-id="${escapeAttr(runId)}" title="${escapeAttr(title)}">
<span class="timeline-dot"></span>
<span class="timeline-label">${escapeHtml(run.status || "-")}</span>
</button>`;
}).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 `<tr data-run-id="${escapeAttr(runId)}">
const selected = state.selectedRunId === runId ? " selected-row" : "";
return `<tr class="${selected}" 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>
<td><span>${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}</span><small>${escapeHtml(run.maintenance ? "maintenance" : "")}</small></td>
</tr>`;
}).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 "-";
@@ -84,12 +84,74 @@ export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig
</article>
</section>
<section class="overview-checks" aria-label="Sentinel health checks">
<span id="check-config" class="check-chip">config -</span>
<span id="check-pvc" class="check-chip">pvc -</span>
<span id="check-analyzer" class="check-chip">analyzer -</span>
<span id="check-public" class="check-chip">public -</span>
<span id="check-maintenance" class="check-chip">maintenance -</span>
</section>
<section class="panel timeline-panel" aria-labelledby="timeline-heading">
<div class="panel-header">
<h2 id="timeline-heading">Run Timeline</h2>
<span id="timeline-count" class="panel-subtitle">-</span>
</div>
<div id="run-timeline" class="run-timeline"></div>
</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>
<form id="runs-filter" class="runs-filter">
<label>
<span>Status</span>
<select id="filter-status" name="status">
<option value="">Any</option>
<option value="planned">planned</option>
<option value="running">running</option>
<option value="analyzed">analyzed</option>
<option value="blocked">blocked</option>
<option value="interrupted">interrupted</option>
</select>
</label>
<label>
<span>Severity</span>
<select id="filter-severity" name="severity">
<option value="">Any</option>
<option value="red">red</option>
<option value="warning">warning</option>
<option value="info">info</option>
</select>
</label>
<label>
<span>Time</span>
<select id="filter-window" name="window">
<option value="">All</option>
<option value="1h">1h</option>
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
</label>
<label>
<span>Sort</span>
<select id="filter-sort" name="sort">
<option value="updated">updated</option>
<option value="created">created</option>
<option value="findings">findings</option>
<option value="severity">severity</option>
</select>
</label>
<label class="filter-search">
<span>Search</span>
<input id="filter-search" name="search" type="search" placeholder="run, observer, report">
</label>
<button id="clear-filters" class="icon-button" type="button">Clear</button>
</form>
<div class="table-frame">
<table class="runs-table">
<thead>