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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user