feat: scaffold central monitor workbench
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
:root { color-scheme: light; font-family: Inter, "Segoe UI", sans-serif; --line:#ccd5d3; --bg:#eef2f1; --panel:#fff; --text:#17201f; --muted:#60706d; --accent:#176b5c; --danger:#a53d38; }
|
||||
* { box-sizing:border-box; }
|
||||
html, body, #monitor-workbench-root { width:100%; height:100%; margin:0; overflow:hidden; }
|
||||
body { background:var(--bg); color:var(--text); }
|
||||
button, input, select { font:inherit; }
|
||||
button { color:inherit; }
|
||||
.monitor-workbench { display:grid; grid-template-rows:44px 36px minmax(0,1fr) 24px; width:100%; height:100dvh; overflow:hidden; }
|
||||
.app-bar, .context-bar, .status-bar { display:flex; align-items:center; min-width:0; border-bottom:1px solid var(--line); background:#f8faf9; }
|
||||
.app-bar { gap:8px; padding:0 12px; font-size:13px; }
|
||||
.app-bar strong { font-size:16px; }
|
||||
.app-stat { color:var(--muted); }
|
||||
.icon-button { margin-left:auto; }
|
||||
.health-dot { width:8px; height:8px; border-radius:50%; background:#9aa6a3; }
|
||||
.health-dot[data-state="healthy"] { background:#278260; }
|
||||
.health-dot[data-state="degraded"] { background:#d18a24; }
|
||||
.context-bar { gap:8px; padding:3px 8px; overflow:hidden; }
|
||||
.context-bar label { display:flex; align-items:center; gap:4px; color:var(--muted); font-size:11px; }
|
||||
.context-bar input, .context-bar select { width:132px; height:28px; border:1px solid var(--line); background:#fff; padding:0 7px; }
|
||||
.result-count { margin-left:auto; white-space:nowrap; color:var(--muted); font-size:12px; }
|
||||
.workspace { display:grid; grid-template-columns:360px minmax(560px,1fr); min-width:0; min-height:0; overflow:hidden; }
|
||||
.workspace.detail-open { grid-template-columns:360px minmax(560px,1fr) 440px; }
|
||||
.workspace.left-collapsed { grid-template-columns:40px minmax(560px,1fr); }
|
||||
.workspace.left-collapsed.detail-open { grid-template-columns:40px minmax(560px,1fr) 440px; }
|
||||
.run-pane, .main-pane, .detail-pane { position:relative; min-width:0; min-height:0; overflow:hidden; background:var(--panel); }
|
||||
.run-pane { border-right:1px solid var(--line); }
|
||||
.main-pane { display:flex; flex-direction:column; }
|
||||
.detail-pane { border-left:1px solid var(--line); }
|
||||
.pane-title, .detail-pane>header { display:flex; align-items:center; height:36px; padding:0 10px; border-bottom:1px solid var(--line); font-size:12px; font-weight:700; text-transform:uppercase; }
|
||||
.pane-toggle { position:absolute; z-index:2; top:5px; right:5px; width:26px; height:26px; border:1px solid var(--line); background:#fff; }
|
||||
.left-collapsed .pane-title, .left-collapsed .run-list { display:none; }
|
||||
.run-list, .main-scroll, .detail-scroll { height:calc(100% - 36px); overflow:auto; overscroll-behavior:contain; }
|
||||
.run-row { display:grid; grid-template-columns:110px minmax(0,1fr) auto; width:100%; min-height:52px; gap:2px 8px; border:0; border-bottom:1px solid #e5ebe9; background:#fff; padding:7px 10px; text-align:left; }
|
||||
.run-row:hover, .run-row.active { background:#e8f2ef; }
|
||||
.run-row strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.run-row small { grid-column:2/4; color:var(--muted); }
|
||||
.run-header { display:flex; align-items:center; justify-content:space-between; min-height:72px; padding:10px 14px; border-bottom:1px solid var(--line); }
|
||||
.run-header>div { display:grid; gap:5px; min-width:0; }
|
||||
.run-header span { color:var(--muted); font-size:12px; }
|
||||
.run-header nav { display:flex; gap:2px; }
|
||||
.run-header button, .detail-pane button, .state-layer button, .icon-button { border:1px solid var(--line); background:#fff; min-height:28px; padding:0 10px; }
|
||||
.run-header button.active { border-color:var(--accent); color:var(--accent); }
|
||||
.main-scroll { height:calc(100% - 72px); padding:8px; }
|
||||
.data-row { display:grid; grid-template-columns:minmax(120px,220px) minmax(0,1fr); width:100%; gap:12px; min-height:42px; align-items:center; border:0; border-bottom:1px solid #e5ebe9; background:#fff; padding:7px 9px; text-align:left; }
|
||||
.data-row:hover { background:#f3f7f6; }
|
||||
.data-row code { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; }
|
||||
.artifact-section h2 { margin:18px 9px 6px; font-size:13px; }
|
||||
.detail-pane>header { justify-content:space-between; }
|
||||
.detail-scroll { padding:12px; }
|
||||
pre { max-width:100%; overflow:auto; border:1px solid var(--line); background:#f6f8f7; padding:10px; font-size:12px; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.state-layer { display:grid; place-content:center; min-height:100%; gap:10px; color:var(--muted); text-align:center; }
|
||||
.state-layer.error { color:var(--danger); }
|
||||
.status-bar { justify-content:space-between; padding:0 8px; border-top:1px solid var(--line); border-bottom:0; color:var(--muted); font-size:11px; }
|
||||
@media (max-width:1120px) {
|
||||
.monitor-workbench { grid-template-rows:40px 34px minmax(0,1fr) 20px; }
|
||||
.workspace, .workspace.detail-open { grid-template-columns:280px minmax(0,1fr); }
|
||||
.workspace.left-collapsed, .workspace.left-collapsed.detail-open { grid-template-columns:36px minmax(0,1fr); }
|
||||
.detail-pane { position:absolute; z-index:5; top:0; right:0; bottom:0; width:min(420px, calc(100vw - 40px)); box-shadow:-10px 0 24px rgba(23,32,31,.14); }
|
||||
.context-bar label:nth-of-type(2), .context-bar label:nth-of-type(3) { display:none; }
|
||||
.context-bar input, .context-bar select { width:118px; }
|
||||
.run-header { min-height:64px; padding:7px 10px; }
|
||||
.main-scroll { height:calc(100% - 64px); }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createApp, computed, onMounted, ref } from "../web-probe-sentinel-monitor-web/vendor/vue.esm-browser.prod.js";
|
||||
import { loadMonitorRun, loadMonitorWorkspace } from "./state/monitor-client.js";
|
||||
import { monitorRouteHref, readMonitorRoute, withMonitorRoute } from "./state/monitor-route.js";
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const route = ref(readMonitorRoute());
|
||||
const workspace = ref(null);
|
||||
const detail = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const leftCollapsed = ref(false);
|
||||
const detailOpen = ref(route.value.kind === "detail");
|
||||
const runs = computed(() => workspace.value?.runs?.items || []);
|
||||
const activeRun = computed(() => detail.value?.run || runs.value.find((item) => item.runId === route.value.runId) || null);
|
||||
const activeRows = computed(() => route.value.tab === "findings" ? detail.value?.findings || [] : route.value.tab === "diagnostics" ? Object.entries(detail.value?.diagnostics || {}).map(([id, value]) => ({ id, value })) : detail.value?.timeline || []);
|
||||
const selectedDetail = computed(() => findSelectedDetail(detail.value, route.value));
|
||||
|
||||
async function restore(options = {}) {
|
||||
const next = readMonitorRoute();
|
||||
route.value = next;
|
||||
if (next.kind === "invalid") { error.value = `无效深链:${next.reason}`; loading.value = false; return; }
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
workspace.value = await loadMonitorWorkspace(next);
|
||||
if (!next.runId && workspace.value.selection.defaultRunId) {
|
||||
const canonical = withMonitorRoute(next, { kind: "run", runId: workspace.value.selection.defaultRunId });
|
||||
history.replaceState(null, "", monitorRouteHref(canonical));
|
||||
route.value = canonical;
|
||||
}
|
||||
detail.value = route.value.runId ? await loadMonitorRun(route.value) : null;
|
||||
detailOpen.value = route.value.kind === "detail";
|
||||
} catch (cause) {
|
||||
error.value = cause.message || "monitor_request_failed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
document.querySelector("#monitor-workbench-root")?.setAttribute("data-monitor-ready", error.value ? "false" : "true");
|
||||
}
|
||||
if (!options.popstate) window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function navigate(patch) {
|
||||
const next = withMonitorRoute(route.value, patch);
|
||||
history.pushState(null, "", monitorRouteHref(next));
|
||||
void restore();
|
||||
}
|
||||
|
||||
function selectRun(run) { navigate({ kind: "run", runId: run.runId, detailType: "", detailId: "" }); }
|
||||
function selectTab(tab) { navigate({ tab, kind: "run", detailType: "", detailId: "" }); }
|
||||
function selectRow(row) {
|
||||
if (route.value.tab === "diagnostics") return;
|
||||
navigate({ kind: "detail", detailType: route.value.tab === "findings" ? "finding" : "event", detailId: row.id });
|
||||
}
|
||||
function selectArtifact(row) { navigate({ kind: "detail", detailType: "artifact", detailId: row.id }); }
|
||||
function closeDetail() { navigate({ kind: "run", detailType: "", detailId: "" }); }
|
||||
function updateFilter(key, value) { navigate({ kind: "workspace", runId: "", detailType: "", detailId: "", filters: { [key]: value } }); }
|
||||
|
||||
onMounted(() => { window.addEventListener("popstate", () => void restore({ popstate: true })); void restore(); });
|
||||
return { route, workspace, detail, loading, error, leftCollapsed, detailOpen, runs, activeRun, activeRows, selectedDetail, restore, navigate, selectRun, selectTab, selectRow, selectArtifact, closeDetail, updateFilter, formatDate };
|
||||
},
|
||||
template: `
|
||||
<div class="monitor-workbench" data-monitor-workbench>
|
||||
<header class="app-bar">
|
||||
<strong>Monitor</strong><span class="health-dot" :data-state="workspace?.health?.state"></span><span>{{ workspace?.health?.state || 'loading' }}</span>
|
||||
<span class="app-stat">Cadence {{ workspace?.cadence?.state || 'unavailable' }}</span><span class="app-stat">Freshness {{ workspace?.freshness?.state || 'unavailable' }}</span>
|
||||
<button class="icon-button" type="button" @click="restore">刷新</button>
|
||||
</header>
|
||||
<nav class="context-bar" aria-label="Monitor 筛选">
|
||||
<label>Sentinel<input :value="route.filters?.sentinelId" @change="updateFilter('sentinelId', $event.target.value)"></label>
|
||||
<label>Node<input :value="route.filters?.node" @change="updateFilter('node', $event.target.value)"></label>
|
||||
<label>Lane<input :value="route.filters?.lane" @change="updateFilter('lane', $event.target.value)"></label>
|
||||
<label>Status<select :value="route.filters?.status" @change="updateFilter('status', $event.target.value)"><option value="">全部</option><option value="succeeded">成功</option><option value="failed">失败</option></select></label>
|
||||
<span class="result-count">{{ workspace?.summary?.totalRuns ?? 0 }} runs · {{ workspace?.summary?.findingCount ?? 0 }} findings</span>
|
||||
</nav>
|
||||
<main class="workspace" :class="{ 'left-collapsed': leftCollapsed, 'detail-open': detailOpen }">
|
||||
<aside class="run-pane" data-pane="runs"><button class="pane-toggle" @click="leftCollapsed=!leftCollapsed">{{ leftCollapsed ? '›' : '‹' }}</button><div class="pane-title">Runs</div><div class="run-list"><button v-for="run in runs" :key="run.runId" class="run-row" :class="{active:run.runId===route.runId}" @click="selectRun(run)"><time>{{ formatDate(run.updatedAt) }}</time><strong>{{ run.runId }}</strong><span :data-status="run.status">{{ run.status }}</span><small>{{ run.findingCount }} findings</small></button></div></aside>
|
||||
<section class="main-pane" data-pane="main">
|
||||
<div v-if="loading" class="state-layer">正在加载 central Monitor view…</div><div v-else-if="error" class="state-layer error"><strong>加载失败</strong><code>{{ error }}</code><button @click="restore">重试</button></div><div v-else-if="!activeRun" class="state-layer">当前范围无 run</div>
|
||||
<template v-else><header class="run-header"><div><strong>{{ activeRun.runId }}</strong><span>{{ activeRun.status }} · {{ formatDate(activeRun.updatedAt) }}</span></div><nav><button v-for="tab in ['timeline','findings','diagnostics']" :class="{active:route.tab===tab}" @click="selectTab(tab)">{{ tab }}</button></nav></header><div class="main-scroll"><button v-for="row in activeRows" :key="row.id" class="data-row" @click="selectRow(row)"><strong>{{ row.id }}</strong><code>{{ JSON.stringify(row.event || row.finding || row.value) }}</code></button><section v-if="detail?.artifacts?.length" class="artifact-section"><h2>Artifacts</h2><button v-for="artifact in detail.artifacts" :key="artifact.id" class="data-row" @click="selectArtifact(artifact)"><strong>{{ artifact.kind }}</strong><span>{{ artifact.reachable ? 'reachable' : 'unreachable' }}</span></button></section></div></template>
|
||||
</section>
|
||||
<aside v-if="detailOpen" class="detail-pane" data-pane="detail"><header><strong>Detail</strong><button @click="closeDetail">关闭</button></header><div class="detail-scroll"><div v-if="selectedDetail"><h2>{{ selectedDetail.id }}</h2><pre>{{ JSON.stringify(selectedDetail, null, 2) }}</pre></div><div v-else class="state-layer">对象不存在或不在当前范围</div></div></aside>
|
||||
</main>
|
||||
<footer class="status-bar"><span>central Monitor API</span><span>{{ workspace?.freshness?.reason || workspace?.freshness?.updatedAt || 'runtime metadata unavailable' }}</span></footer>
|
||||
</div>`,
|
||||
}).mount("#monitor-workbench-root");
|
||||
|
||||
function findSelectedDetail(detail, route) {
|
||||
if (!detail || route.kind !== "detail") return null;
|
||||
const rows = route.detailType === "finding" ? detail.findings : route.detailType === "event" ? detail.timeline : detail.artifacts;
|
||||
return rows?.find((item) => item.id === route.detailId) || null;
|
||||
}
|
||||
|
||||
function formatDate(value) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—"; }
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function loadMonitorWorkspace(route, fetchImpl = fetch) {
|
||||
const params = new URLSearchParams({ ...compact(route.filters), limit: "100" });
|
||||
return requestJson(fetchImpl, `/api/monitor/workspace?${params}`);
|
||||
}
|
||||
|
||||
export async function loadMonitorRun(route, fetchImpl = fetch) {
|
||||
const params = new URLSearchParams(compact({ sentinelId: route.filters.sentinelId, node: route.filters.node, lane: route.filters.lane }));
|
||||
return requestJson(fetchImpl, `/api/monitor/runs/${encodeURIComponent(route.runId)}?${params}`);
|
||||
}
|
||||
|
||||
function compact(value) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item));
|
||||
}
|
||||
|
||||
async function requestJson(fetchImpl, path) {
|
||||
const response = await fetchImpl(path, { cache: "no-store", headers: { accept: "application/json" } });
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.ok) throw Object.assign(new Error(body?.error?.code || `http_${response.status}`), { status: response.status, body });
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
const tabs = new Set(["timeline", "findings", "diagnostics"]);
|
||||
|
||||
export function readMonitorRoute(input = window.location.href) {
|
||||
const url = new URL(input, window.location.origin);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
if (segments[0] !== "monitor") return { kind: "invalid", reason: "unsupported_path" };
|
||||
const route = { kind: "workspace", runId: "", detailType: "", detailId: "", tab: normalizeTab(url.searchParams.get("tab")), filters: readFilters(url) };
|
||||
if (segments.length === 1) return route;
|
||||
if (segments[1] !== "runs" || !segments[2]) return { kind: "invalid", reason: "unsupported_path" };
|
||||
route.kind = "run";
|
||||
route.runId = decodeURIComponent(segments[2]);
|
||||
if (segments.length === 3) return route;
|
||||
const detailTypes = { findings: "finding", events: "event", artifacts: "artifact" };
|
||||
if (!detailTypes[segments[3]] || !segments[4] || segments.length !== 5) return { kind: "invalid", reason: "unsupported_path" };
|
||||
route.kind = "detail";
|
||||
route.detailType = detailTypes[segments[3]];
|
||||
route.detailId = decodeURIComponent(segments[4]);
|
||||
return route;
|
||||
}
|
||||
|
||||
export function monitorRouteHref(route) {
|
||||
const path = route.runId ? `/monitor/runs/${encodeURIComponent(route.runId)}${detailPath(route)}` : "/monitor";
|
||||
const url = new URL(path, window.location.origin);
|
||||
for (const [key, value] of Object.entries(route.filters || {})) if (value) url.searchParams.set(key, value);
|
||||
if (route.tab && route.tab !== "timeline") url.searchParams.set("tab", route.tab);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function withMonitorRoute(route, patch) {
|
||||
return { ...route, ...patch, filters: { ...route.filters, ...(patch.filters || {}) } };
|
||||
}
|
||||
|
||||
function readFilters(url) {
|
||||
return Object.fromEntries(["sentinelId", "node", "lane", "status", "from", "to"].map((key) => [key, url.searchParams.get(key) || ""]));
|
||||
}
|
||||
|
||||
function normalizeTab(value) {
|
||||
return tabs.has(value) ? value : "timeline";
|
||||
}
|
||||
|
||||
function detailPath(route) {
|
||||
const segments = { finding: "findings", event: "events", artifact: "artifacts" };
|
||||
return route.detailType && route.detailId ? `/${segments[route.detailType]}/${encodeURIComponent(route.detailId)}` : "";
|
||||
}
|
||||
@@ -106,6 +106,48 @@ test("run detail returns normalized locator metadata without artifact bytes", as
|
||||
assert.equal(response.valuesRedacted, true);
|
||||
});
|
||||
|
||||
test("monitor workspace composes runs while runtime facts remain unavailable without metadata", async () => {
|
||||
const target = service();
|
||||
await target.ingest({ ...input("run-ok", "2026-07-12T11:00:00.000Z"), findings: [{ id: "finding-a" }] });
|
||||
await target.ingest({ ...input("run-failed", "2026-07-12T10:00:00.000Z"), status: "failed", findings: [{ id: "finding-b" }, { id: "finding-c" }] });
|
||||
const response = await target.fetch(new Request("http://local/api/monitor/workspace?sentinelId=sentinel-a&node=NC01&lane=v03&status=failed&from=2026-07-12T09:00:00.000Z&limit=20"));
|
||||
const body = await response.json() as any;
|
||||
assert.equal(body.selection.defaultRunId, "run-failed");
|
||||
assert.equal(body.runs.items[0].findingCount, 2);
|
||||
assert.equal(body.health.state, "degraded");
|
||||
assert.equal(body.freshness.state, "unavailable");
|
||||
assert.equal(body.cadence.state, "unavailable");
|
||||
assert.equal(body.schemaVersion, undefined);
|
||||
assert.equal(body.capability, undefined);
|
||||
});
|
||||
|
||||
test("monitor workspace projects cadence freshness and health only from runtime metadata", async () => {
|
||||
const store = createInMemoryMonitorCentralStore();
|
||||
const normalized = normalizeMonitorCentralIngest(input("run-metadata"));
|
||||
await store.importSource({ manifestId: "monitor-workbench-runtime", sourceFingerprint: "sha256:runtime", rowCount: 1 }, [normalized], [
|
||||
{ sentinelId: "sentinel-a", node: "NC01", lane: "v03", key: "monitor.health", value: { state: "healthy" }, updatedAt: "2026-07-12T10:01:00.000Z" },
|
||||
{ sentinelId: "sentinel-a", node: "NC01", lane: "v03", key: "monitor.freshness", value: { state: "current", ageSeconds: 30 }, updatedAt: "2026-07-12T10:01:00.000Z" },
|
||||
{ sentinelId: "sentinel-a", node: "NC01", lane: "v03", key: "monitor.cadence", value: { state: "available", label: "owning-yaml-projected" }, updatedAt: "2026-07-12T10:01:00.000Z" },
|
||||
]);
|
||||
const target = createMonitorCentralService({ store, pageSize: 50 });
|
||||
const body = await (await target.fetch(new Request("http://local/api/monitor/workspace?sentinelId=sentinel-a&node=NC01&lane=v03"))).json() as any;
|
||||
assert.equal(body.health.state, "healthy");
|
||||
assert.equal(body.freshness.ageSeconds, 30);
|
||||
assert.equal(body.cadence.label, "owning-yaml-projected");
|
||||
assert.equal(body.cadence.sourceKey, "monitor.cadence");
|
||||
});
|
||||
|
||||
test("monitor run detail projects stable ids without a client join", async () => {
|
||||
const target = service();
|
||||
await target.ingest({ ...input("run-workbench"), payload: { diagnostics: { state: "ready" } }, timeline: [{ id: "event-done", phase: "done" }], findings: [{ id: "finding-visible" }] });
|
||||
const response = await target.fetch(new Request("http://local/api/monitor/runs/run-workbench?sentinelId=sentinel-a&node=NC01&lane=v03"));
|
||||
const body = await response.json() as any;
|
||||
assert.equal(body.timeline[0].id, "event-done");
|
||||
assert.equal(body.findings[0].id, "finding-visible");
|
||||
assert.equal(body.artifacts[0].id, "artifact-0");
|
||||
assert.deepEqual(body.diagnostics, { state: "ready" });
|
||||
});
|
||||
|
||||
test("configuration is explicit and missing environment projection fails structurally", async () => {
|
||||
const target = service();
|
||||
assert.equal((await target.health()).ok, true);
|
||||
|
||||
@@ -163,6 +163,9 @@ export function createMonitorCentralService(options: MonitorCentralServiceOption
|
||||
return json(result, result.ok === true ? 200 : 503);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/overview") return json({ ok: true, overview: await store.overview(scopeFrom(url)), valuesRedacted: true });
|
||||
if (request.method === "GET" && url.pathname === "/api/monitor/workspace") return json(await monitorWorkspace(store, url, pageSize));
|
||||
const monitorRunMatch = request.method === "GET" ? url.pathname.match(/^\/api\/monitor\/runs\/([^/]+)$/u) : null;
|
||||
if (monitorRunMatch) return monitorRunDetail(store, url, decodeURIComponent(monitorRunMatch[1]));
|
||||
if (request.method === "GET" && url.pathname === "/api/runs") return json(runPage(await store.runs(scopeFrom(url), runQueryOptions(url, pageSize))));
|
||||
if (request.method === "GET" && url.pathname === "/api/findings") return json(findingPage(await store.findings(scopeFrom(url), findingQueryOptions(url, pageSize))));
|
||||
if (request.method === "GET" && url.pathname.startsWith("/api/runs/")) {
|
||||
@@ -184,6 +187,66 @@ export function createMonitorCentralService(options: MonitorCentralServiceOption
|
||||
return { ingest, health, fetch };
|
||||
}
|
||||
|
||||
async function monitorWorkspace(store: MonitorCentralStore, url: URL, pageSize: number): Promise<Record<string, unknown>> {
|
||||
const scope = scopeFrom(url);
|
||||
const options = runQueryOptions(url, pageSize);
|
||||
const status = optional(url, "status");
|
||||
const from = optionalDate(url, "from");
|
||||
const to = optionalDate(url, "to");
|
||||
const source = await store.runs(scope, { ...options, limit: pageSize });
|
||||
const items = source.filter((run) => (!status || run.status === status) && (!from || run.updatedAt >= from) && (!to || run.updatedAt <= to));
|
||||
const findings = await store.findings(scope, { limit: pageSize, cursor: null });
|
||||
const findingCounts = new Map<string, number>();
|
||||
for (const item of findings) findingCounts.set(String(item.runId), (findingCounts.get(String(item.runId)) ?? 0) + 1);
|
||||
const runs = items.map((run) => ({ ...run, findingCount: findingCounts.get(run.runId) ?? 0 }));
|
||||
const runtime = await monitorRuntimeProjection(store, scope);
|
||||
return {
|
||||
ok: true,
|
||||
query: { sentinelId: scope.sentinelId ?? null, node: scope.node ?? null, lane: scope.lane ?? null, status: status ?? null, from: from ?? null, to: to ?? null },
|
||||
health: runtime.health,
|
||||
freshness: runtime.freshness,
|
||||
cadence: runtime.cadence,
|
||||
summary: { totalRuns: runs.length, statusCounts: countBy(runs, (run) => run.status), findingCount: runs.reduce((total, run) => total + run.findingCount, 0) },
|
||||
runs: { items: runs, nextCursor: source.length === pageSize && source.at(-1) ? cursor(source.at(-1)) : null },
|
||||
selection: { defaultRunId: runs[0]?.runId ?? null },
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function monitorRuntimeProjection(store: MonitorCentralStore, scope: MonitorScope): Promise<Record<string, Record<string, unknown>>> {
|
||||
if (!scope.sentinelId || !scope.node || !scope.lane) return unavailableRuntime("full_scope_required");
|
||||
const metadata = await store.runtimeMetadata(scope as Required<MonitorScope>);
|
||||
const values = new Map(metadata.map((item) => [item.key, item]));
|
||||
const health = projectedRuntimeValue(values, ["monitor.health", "scheduler.health"]);
|
||||
const freshness = projectedRuntimeValue(values, ["monitor.freshness", "scheduler.freshness"]);
|
||||
const cadence = projectedRuntimeValue(values, ["monitor.cadence", "scheduler.cadence"]);
|
||||
return {
|
||||
health: health ?? { state: "degraded", reason: "runtime_metadata_unavailable" },
|
||||
freshness: freshness ?? { state: "unavailable", reason: "runtime_metadata_unavailable" },
|
||||
cadence: cadence ?? { state: "unavailable", reason: "runtime_metadata_unavailable" },
|
||||
};
|
||||
}
|
||||
|
||||
function projectedRuntimeValue(values: ReadonlyMap<string, MonitorRuntimeMetadata>, keys: readonly string[]): Record<string, unknown> | null {
|
||||
for (const key of keys) {
|
||||
const item = values.get(key);
|
||||
if (item) return { ...recordOrEmpty(item.value), sourceKey: item.key, updatedAt: item.updatedAt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unavailableRuntime(reason: string): Record<string, Record<string, unknown>> {
|
||||
return { health: { state: "degraded", reason }, freshness: { state: "unavailable", reason }, cadence: { state: "unavailable", reason } };
|
||||
}
|
||||
|
||||
async function monitorRunDetail(store: MonitorCentralStore, url: URL, runId: string): Promise<Response> {
|
||||
const scope = requireFullScope(scopeFrom(url));
|
||||
const run = await store.run(scope, runId);
|
||||
if (!run) return json({ ok: false, error: { code: "run_not_found" }, valuesRedacted: true }, 404);
|
||||
const payload = recordOrEmpty(run.payload);
|
||||
return json({ ok: true, run: { sentinelId: run.sentinelId, node: run.node, lane: run.lane, runId: run.runId, updatedAt: run.updatedAt, status: run.status, payloadHash: run.payloadHash }, timeline: arrayOrEmpty(run.timeline).map((event, index) => ({ id: objectId(event, `event-${index}`), event })), findings: arrayOrEmpty(run.findings).map((finding, index) => ({ id: objectId(finding, `finding-${index}`), finding })), diagnostics: recordOrEmpty(payload.diagnostics), artifacts: arrayOrEmpty(run.artifactLocators).map((artifact, index) => ({ id: `artifact-${index}`, ...recordOrEmpty(artifact) })), valuesRedacted: true });
|
||||
}
|
||||
|
||||
export function createPostgresMonitorCentralStore(databaseUrl: string, options: PostgresMonitorCentralStoreOptions): MonitorCentralStore {
|
||||
const sql = postgres(databaseUrl, { max: requirePositiveInteger(options.poolMax, "monitor_central_pg_pool_max_missing"), idle_timeout: requirePositiveInteger(options.idleTimeoutSeconds, "monitor_central_pg_idle_timeout_missing") });
|
||||
return {
|
||||
@@ -423,6 +486,7 @@ async function requestRecord(request: Request): Promise<Record<string, unknown>>
|
||||
function scopeFrom(url: URL): MonitorScope { return { sentinelId: optional(url, "sentinelId"), node: optional(url, "node"), lane: optional(url, "lane") }; }
|
||||
function requireFullScope(scope: MonitorScope): Required<MonitorScope> { if (!scope.sentinelId || !scope.node || !scope.lane) throw Object.assign(invalid("run detail requires sentinelId, node, and lane"), { code: "scope_required" }); return scope as Required<MonitorScope>; }
|
||||
function optional(url: URL, name: string): string | undefined { return url.searchParams.get(name) || undefined; }
|
||||
function optionalDate(url: URL, name: string): string | undefined { const value = optional(url, name); if (!value) return undefined; const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) throw invalid(`${name} must be an ISO timestamp`); return new Date(timestamp).toISOString(); }
|
||||
function boundedLimit(value: string | null, pageSize: number): number { const parsed = Number(value ?? pageSize); return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, pageSize) : pageSize; }
|
||||
function runQueryOptions(url: URL, pageSize: number): RunQueryOptions { return { limit: boundedLimit(url.searchParams.get("limit"), pageSize), cursor: parseRunCursor(url.searchParams.get("cursor")) }; }
|
||||
function findingQueryOptions(url: URL, pageSize: number): FindingQueryOptions { return { limit: boundedLimit(url.searchParams.get("limit"), pageSize), cursor: parseFindingCursor(url.searchParams.get("cursor")) }; }
|
||||
@@ -432,6 +496,10 @@ function parseCursor(value: string | null, finding: boolean): RunCursor | Findin
|
||||
function runPage(items: readonly MonitorRun[]): Record<string, unknown> { const last = items.at(-1); return { ok: true, items, nextCursor: last ? cursor(last) : null, valuesRedacted: true }; }
|
||||
function findingPage(items: readonly Record<string, unknown>[]): Record<string, unknown> { const last = items.at(-1); return { ok: true, items, nextCursor: last ? cursor({ updatedAt: last.updatedAt, runId: last.runId, findingIndex: last.findingIndex }) : null, valuesRedacted: true }; }
|
||||
function cursor(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString("base64url"); }
|
||||
function countBy<T>(items: readonly T[], key: (item: T) => string): Record<string, number> { const counts: Record<string, number> = {}; for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1; return counts; }
|
||||
function recordOrEmpty(value: unknown): Record<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
function arrayOrEmpty(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : []; }
|
||||
function objectId(value: unknown, fallback: string): string { const record = recordOrEmpty(value); return String(record.id ?? record.errorId ?? fallback); }
|
||||
function json(body: Record<string, unknown>, status = 200): Response { return Response.json(body, { status }); }
|
||||
function httpStatus(error: unknown): number { return (error as { code?: string })?.code === "payload_hash_conflict" ? 409 : error instanceof MonitorCentralConfigurationError ? 503 : 400; }
|
||||
export function structuredMonitorCentralError(error: unknown): Record<string, unknown> { return error instanceof MonitorCentralConfigurationError ? error.toJSON().error as Record<string, unknown> : { code: (error as { code?: string })?.code ?? "invalid_request", message: errorMessage(error) }; }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
import { monitorRouteHref, readMonitorRoute, withMonitorRoute } from "../assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js";
|
||||
|
||||
Object.assign(globalThis, { window: { location: { origin: "https://monitor.example.test" } } });
|
||||
|
||||
test("monitor route roundtrips run detail tab and filters", () => {
|
||||
const route = readMonitorRoute("https://monitor.example.test/monitor/runs/run-1/findings/finding-2?sentinelId=s1&node=NC01&lane=v03&status=failed&tab=findings");
|
||||
assert.equal(route.kind, "detail");
|
||||
assert.equal(route.detailType, "finding");
|
||||
assert.equal(monitorRouteHref(route), "/monitor/runs/run-1/findings/finding-2?sentinelId=s1&node=NC01&lane=v03&status=failed&tab=findings");
|
||||
});
|
||||
|
||||
test("monitor route rejects unsupported paths and filter changes clear selection", () => {
|
||||
assert.deepEqual(readMonitorRoute("https://monitor.example.test/dashboard"), { kind: "invalid", reason: "unsupported_path" });
|
||||
const next = withMonitorRoute(readMonitorRoute("https://monitor.example.test/monitor/runs/run-1"), { kind: "workspace", runId: "", filters: { status: "failed" } });
|
||||
assert.equal(monitorRouteHref(next), "/monitor?status=failed");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./src/config";
|
||||
|
||||
const files = [
|
||||
"scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js",
|
||||
"scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css",
|
||||
"scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js",
|
||||
"scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-client.js",
|
||||
];
|
||||
const source = files.map((path) => readFileSync(rootPath(path), "utf8")).join("\n");
|
||||
const failures = [];
|
||||
for (const expected of ["100dvh", "grid-template-columns:360px", "grid-template-columns:280px", "overflow:hidden", "/api/monitor/workspace", "/api/monitor/runs/", "popstate", "/monitor/runs/"]) if (!source.includes(expected)) failures.push(`missing ${expected}`);
|
||||
for (const forbidden of ["/api/overview", "/api/findings", "schemaVersion", "capability", "freshnessWarningMultiple"]) if (source.includes(forbidden)) failures.push(`forbidden ${forbidden}`);
|
||||
if (failures.length) { console.error(JSON.stringify({ ok:false, component:"monitor-workbench", failures }, null, 2)); process.exit(1); }
|
||||
console.log(JSON.stringify({ ok:true, component:"monitor-workbench", exposed:false, viewports:["1920x1080","960x600"], files, valuesRedacted:true }));
|
||||
Reference in New Issue
Block a user