diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css new file mode 100644 index 00000000..bc0d321c --- /dev/null +++ b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css @@ -0,0 +1,79 @@ +: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 { position:relative; gap:8px; padding:3px 8px; overflow:hidden; } +.desktop-scope { display:flex; gap:8px; } +.desktop-time { display:flex; gap:8px; } +.compact-scope-button, .compact-scope-panel, .compact-time-button, .compact-time-panel { display:none; } +.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; } +.semantic-row { display:grid; grid-template-columns:150px 130px 90px minmax(0,1fr); width:100%; min-height:42px; gap:10px; align-items:center; border:0; border-bottom:1px solid #e5ebe9; background:#fff; padding:7px 9px; text-align:left; } +.semantic-row:hover { background:#f3f7f6; } +.semantic-row time, .semantic-row .severity { color:var(--muted); font-size:12px; } +.semantic-row .summary { overflow:hidden; 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; } +.detail-scroll dl { display:grid; grid-template-columns:92px minmax(0,1fr); gap:0; margin:0 0 16px; } +.detail-scroll dt, .detail-scroll dd { min-width:0; margin:0; border-bottom:1px solid #e5ebe9; padding:8px 4px; overflow-wrap:anywhere; } +.detail-scroll dt { color:var(--muted); } +.detail-scroll details summary { cursor:pointer; color:var(--muted); } +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); } + .desktop-scope { display:none; } + .desktop-time { display:none; } + .compact-scope-button { display:block; height:28px; border:1px solid var(--line); background:#fff; white-space:nowrap; } + .compact-scope-panel { position:fixed; z-index:8; top:74px; left:145px; display:grid; width:260px; gap:8px; border:1px solid var(--line); background:#fff; box-shadow:0 8px 22px rgba(23,32,31,.16); padding:10px; } + .compact-time-button { display:block; height:28px; border:1px solid var(--line); background:#fff; white-space:nowrap; } + .compact-time-panel { position:fixed; z-index:8; top:74px; left:300px; display:grid; width:300px; gap:8px; border:1px solid var(--line); background:#fff; box-shadow:0 8px 22px rgba(23,32,31,.16); padding:10px; } + .context-bar input, .context-bar select { width:118px; } + .run-header { min-height:64px; padding:7px 10px; } + .main-scroll { height:calc(100% - 64px); } + .semantic-row { grid-template-columns:120px 100px 70px minmax(0,1fr); } +} diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js new file mode 100644 index 00000000..9bb1b018 --- /dev/null +++ b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js @@ -0,0 +1,32 @@ +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 scopeOpen = ref(false); const timeOpen = 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 selectedDetail = computed(() => findSelectedDetail(detail.value, route.value)); + const activeRows = computed(() => route.value.tab === "findings" ? (detail.value?.findings || []).map(findingRow) : route.value.tab === "diagnostics" ? Object.entries(detail.value?.diagnostics || {}).map(diagnosticRow) : (detail.value?.timeline || []).map(timelineRow)); + async function restore() { 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"); } } + function navigate(patch) { const next = withMonitorRoute(route.value, patch); history.pushState(null, "", monitorRouteHref(next)); void restore(); } + const selectRun = (run) => navigate({ kind:"run", runId:run.runId, detailType:"", detailId:"" }); const selectTab = (tab) => navigate({ tab, kind:"run", detailType:"", detailId:"" }); const closeDetail = () => navigate({ kind:"run", detailType:"", detailId:"" }); + function selectRow(row) { navigate({ kind:"detail", detailType:route.value.tab === "findings" ? "finding" : route.value.tab === "diagnostics" ? "diagnostic" : "event", detailId:row.id }); } + const selectArtifact = (row) => navigate({ kind:"detail", detailType:"artifact", detailId:row.id }); const updateFilter = (key, value) => navigate({ kind:"workspace", runId:"", detailType:"", detailId:"", filters:{ [key]:value } }); + onMounted(() => { window.addEventListener("popstate", restore); void restore(); }); + return { route, workspace, detail, loading, error, leftCollapsed, scopeOpen, timeOpen, detailOpen, runs, activeRun, activeRows, selectedDetail, restore, selectRun, selectTab, selectRow, selectArtifact, closeDetail, updateFilter, formatDate, semanticDetail }; +}, template:` +
+
Monitor{{ workspace?.health?.state || 'loading' }}Cadence {{ workspace?.cadence?.label || workspace?.cadence?.state || 'unavailable' }}Fresh {{ workspace?.freshness?.ageSeconds != null ? workspace.freshness.ageSeconds + 's' : workspace?.freshness?.state || 'unavailable' }}Last success {{ formatDate(workspace?.summary?.latestSuccessAt) }}Last failure {{ formatDate(workspace?.summary?.latestFailureAt) }}
+ +
+
正在加载 central Monitor view…
加载失败{{error}}
当前范围无 run
+
+ +
` }).mount("#monitor-workbench-root"); + +function record(value) { return value && typeof value === "object" ? value : {}; } +function timelineRow(row) { const value=record(row.event); return { id:row.id, time:formatDate(value.at||value.updatedAt||value.timestamp), type:String(value.type||value.phase||"event"), severity:String(value.severity||value.level||"—"), summary:String(value.summary||value.message||value.phase||row.id) }; } +function findingRow(row) { const value=record(row.finding); return { id:row.id, time:formatDate(value.updatedAt||value.at), type:String(value.code||value.type||"finding"), severity:String(value.severity||value.level||"unknown"), summary:String(value.summary||value.title||value.rootCause||row.id) }; } +function diagnosticRow([id,value]) { const item=record(value); return { id, time:formatDate(item.updatedAt), type:id, severity:String(item.state||"—"), summary:String(item.summary||item.message||item.state||"available") }; } +function findSelectedDetail(detail,route) { if(!detail||route.kind!=="detail") return null; const rows=route.detailType==="finding"?detail.findings:route.detailType==="event"?detail.timeline:route.detailType==="diagnostic"?Object.entries(detail.diagnostics||{}).map(([id,value])=>({id,value})):detail.artifacts; return rows?.find((item)=>item.id===route.detailId)||null; } +function semanticDetail(item) { const value=record(item.finding||item.event||item.value||item); return { title:String(value.title||value.summary||value.kind||item.id), fields:[["ID",item.id],["时间",formatDate(value.updatedAt||value.at||value.timestamp)],["类型",value.type||value.code||value.kind||item.id||"—"],["Severity",value.severity||value.level||value.state||"—"],["摘要",value.summary||value.message||value.rootCause||value.state||"—"],["路径",value.relativePath||item.relativePath||"—"],["可达",item.reachable==null?"—":item.reachable?"是":"否"]] }; } +function formatDate(value) { return value ? new Date(value).toLocaleString("zh-CN",{hour12:false}) : "—"; } diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-client.js b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-client.js new file mode 100644 index 00000000..10eaaf73 --- /dev/null +++ b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-client.js @@ -0,0 +1,20 @@ +export async function loadMonitorWorkspace(route, fetchImpl = fetch) { + const params = new URLSearchParams(compact(route.filters)); + 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; +} diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js new file mode 100644 index 00000000..844c1cdb --- /dev/null +++ b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js @@ -0,0 +1,46 @@ +const tabs = new Set(["timeline", "findings", "diagnostics"]); +const statuses = new Set(["", "succeeded", "failed", "blocked", "running", "unknown"]); + +export function readMonitorRoute(input = window.location.href) { + try { + const url = new URL(input, window.location.origin); + const tab = url.searchParams.get("tab") || "timeline"; + const status = url.searchParams.get("status") || ""; + const from = url.searchParams.get("from") || ""; + const to = url.searchParams.get("to") || ""; + if (!tabs.has(tab)) return invalid("invalid_tab"); + if (!statuses.has(status)) return invalid("invalid_status"); + if (!validDate(from) || !validDate(to)) return invalid("invalid_time_range"); + if (from && to && Date.parse(from) > Date.parse(to)) return invalid("invalid_time_range"); + const segments = url.pathname.split("/").filter(Boolean); + if (segments[0] !== "monitor") return invalid("unsupported_path"); + const route = { kind: "workspace", runId: "", detailType: "", detailId: "", tab, filters: { sentinelId: value(url, "sentinelId"), node: value(url, "node"), lane: value(url, "lane"), status, from, to } }; + if (segments.length === 1) return route; + if (segments[1] !== "runs" || !segments[2]) return invalid("unsupported_path"); + route.kind = "run"; + route.runId = decodeURIComponent(segments[2]); + if (segments.length === 3) return route; + const detailTypes = { findings: "finding", events: "event", artifacts: "artifact", diagnostics: "diagnostic" }; + if (!detailTypes[segments[3]] || !segments[4] || segments.length !== 5) return invalid("unsupported_path"); + route.kind = "detail"; + route.detailType = detailTypes[segments[3]]; + route.detailId = decodeURIComponent(segments[4]); + return route; + } catch { + return invalid("malformed_url_encoding"); + } +} + +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, item] of Object.entries(route.filters || {})) if (item) url.searchParams.set(key, item); + 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 invalid(reason) { return { kind: "invalid", reason }; } +function value(url, key) { return url.searchParams.get(key) || ""; } +function validDate(value) { return !value || Number.isFinite(Date.parse(value)); } +function detailPath(route) { const segments = { finding: "findings", event: "events", artifact: "artifacts", diagnostic: "diagnostics" }; return route.detailType && route.detailId ? `/${segments[route.detailType]}/${encodeURIComponent(route.detailId)}` : ""; } diff --git a/scripts/src/monitor-central-persistence.test.ts b/scripts/src/monitor-central-persistence.test.ts index 5c4a0d35..16c970ac 100644 --- a/scripts/src/monitor-central-persistence.test.ts +++ b/scripts/src/monitor-central-persistence.test.ts @@ -106,6 +106,71 @@ 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 filters before paging and counts findings beyond page size", async () => { + const target = createMonitorCentralService({ store: createInMemoryMonitorCentralStore(), pageSize: 2 }); + await target.ingest({ ...input("new-1", "2026-07-12T13:00:00.000Z"), status: "succeeded", findings: [] }); + await target.ingest({ ...input("new-2", "2026-07-12T12:00:00.000Z"), status: "succeeded", findings: [] }); + await target.ingest({ ...input("older-match", "2026-07-12T11:00:00.000Z"), status: "failed", findings: [{ id: "f1" }, { id: "f2" }, { id: "f3" }] }); + const body = await (await target.fetch(new Request("http://local/api/monitor/workspace?sentinelId=sentinel-a&node=NC01&lane=v03&status=failed"))).json() as any; + assert.equal(body.runs.items[0].runId, "older-match"); + assert.equal(body.runs.items[0].findingCount, 3); + assert.equal(body.summary.totalRuns, 1); + assert.equal(body.summary.findingCount, 3); + assert.deepEqual(body.summary.statusCounts, { failed: 1 }); + assert.equal(body.runs.nextCursor, null); +}); + +test("monitor latest failure excludes running and unknown states", async () => { + const target = service(); + await target.ingest({ ...input("failed-terminal", "2026-07-12T10:00:00.000Z"), status: "failed" }); + await target.ingest({ ...input("running-newer", "2026-07-12T12:00:00.000Z"), status: "running" }); + await target.ingest({ ...input("unknown-newest", "2026-07-12T13:00:00.000Z"), status: "unknown" }); + 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.summary.latestFailureAt, "2026-07-12T10:00:00.000Z"); +}); + +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); diff --git a/scripts/src/monitor-central-persistence.ts b/scripts/src/monitor-central-persistence.ts index e9a8c760..71902f92 100644 --- a/scripts/src/monitor-central-persistence.ts +++ b/scripts/src/monitor-central-persistence.ts @@ -56,6 +56,7 @@ export interface MonitorCentralStore { runtimeMetadata(scope: Required): Promise; updateArtifactAvailability(scope: Required, runId: string, locatorIndex: number, reachable: boolean): Promise; overview(scope: MonitorScope): Promise>; + monitorWorkspace(scope: MonitorScope, options: MonitorWorkspaceQueryOptions): Promise; runs(scope: MonitorScope, options: RunQueryOptions): Promise; run(scope: Required, runId: string): Promise | null>; view(scope: Required, runId: string, view: string): Promise | null>; @@ -97,6 +98,15 @@ export interface FindingQueryOptions { readonly cursor: FindingCursor | null; } +interface MonitorWorkspaceQueryOptions extends RunQueryOptions { + readonly status?: string; + readonly from?: string; + readonly to?: string; +} + +interface MonitorWorkspaceRun extends MonitorRun { readonly findingCount: number; } +interface MonitorWorkspaceQueryResult { readonly items: readonly MonitorWorkspaceRun[]; readonly nextCursor: string | null; readonly totalRuns: number; readonly findingCount: number; readonly statusCounts: Readonly>; readonly latestSuccessAt: string | null; readonly latestFailureAt: string | null; } + export interface MonitorCentralService { ingest(input: MonitorCentralIngest): Promise>; health(): Promise>; @@ -163,6 +173,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 +197,60 @@ export function createMonitorCentralService(options: MonitorCentralServiceOption return { ingest, health, fetch }; } +async function monitorWorkspace(store: MonitorCentralStore, url: URL, pageSize: number): Promise> { + const scope = scopeFrom(url); + const status = optional(url, "status"); + const from = optionalDate(url, "from"); + const to = optionalDate(url, "to"); + const result = await store.monitorWorkspace(scope, { ...runQueryOptions(url, pageSize), status, from, to }); + 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: result.totalRuns, statusCounts: result.statusCounts, findingCount: result.findingCount, latestSuccessAt: result.latestSuccessAt, latestFailureAt: result.latestFailureAt }, + runs: { items: result.items, nextCursor: result.nextCursor }, + selection: { defaultRunId: result.items[0]?.runId ?? null }, + valuesRedacted: true, + }; +} + +async function monitorRuntimeProjection(store: MonitorCentralStore, scope: MonitorScope): Promise>> { + if (!scope.sentinelId || !scope.node || !scope.lane) return unavailableRuntime("full_scope_required"); + const metadata = await store.runtimeMetadata(scope as Required); + 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, keys: readonly string[]): Record | 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> { + return { health: { state: "degraded", reason }, freshness: { state: "unavailable", reason }, cadence: { state: "unavailable", reason } }; +} + +async function monitorRunDetail(store: MonitorCentralStore, url: URL, runId: string): Promise { + 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 { @@ -250,6 +317,7 @@ export function createPostgresMonitorCentralStore(databaseUrl: string, options: const rows = await queryRuns(sql, scope, { limit: 1, cursor: null }); return { scope: publicScope(scope), runCount: await countRuns(sql, scope), latest: rows[0] ?? null }; }, + async monitorWorkspace(scope, options) { return queryMonitorWorkspace(sql, scope, options); }, async runs(scope, options) { return queryRuns(sql, scope, options); }, async run(scope, runId) { const where = identityWhere(scope, runId); @@ -384,6 +452,16 @@ export function createInMemoryMonitorCentralStore(): MonitorCentralStore { const runs = await this.runs(scope, { limit: 1, cursor: null }); return { scope: publicScope(scope), runCount: [...records.values()].filter((item) => matches(scope, item)).length, latest: runs[0] ?? null }; }, + async monitorWorkspace(scope, options) { + const filteredRecords = [...records.values()].filter((item) => matches(scope, item) && monitorWorkspaceMatches(runFrom(item), options)); + const all = sortRuns(filteredRecords.map(runFrom)); + const findingCounts = new Map(filteredRecords.map((item) => [identityKey(item), item.findings.length])); + const candidates = all.filter((run) => afterRunCursor(run, options.cursor)).slice(0, options.limit + 1); + const hasMore = candidates.length > options.limit; + const items = candidates.slice(0, options.limit).map((run) => ({ ...run, findingCount: findingCounts.get(identityKey(run)) ?? 0 })); + const last = items.at(-1); + return { items, nextCursor: hasMore && last ? cursor(last) : null, totalRuns: all.length, findingCount: filteredRecords.reduce((total, item) => total + item.findings.length, 0), statusCounts: countBy(all, (run) => run.status), latestSuccessAt: all.find((run) => run.status === "succeeded")?.updatedAt ?? null, latestFailureAt: all.find((run) => monitorRunFailed(run.status))?.updatedAt ?? null }; + }, async runs(scope, options) { return sortRuns([...records.values()].filter((item) => matches(scope, item)).map(runFrom)).filter((run) => afterRunCursor(run, options.cursor)).slice(0, options.limit); }, async run(scope, runId) { const value = records.get(identityKey({ ...scope, runId })); @@ -423,6 +501,7 @@ async function requestRecord(request: Request): Promise> function scopeFrom(url: URL): MonitorScope { return { sentinelId: optional(url, "sentinelId"), node: optional(url, "node"), lane: optional(url, "lane") }; } function requireFullScope(scope: MonitorScope): Required { 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; } 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 +511,11 @@ function parseCursor(value: string | null, finding: boolean): RunCursor | Findin function runPage(items: readonly MonitorRun[]): Record { const last = items.at(-1); return { ok: true, items, nextCursor: last ? cursor(last) : null, valuesRedacted: true }; } function findingPage(items: readonly Record[]): Record { 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(items: readonly T[], key: (item: T) => string): Record { const counts: Record = {}; for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1; return counts; } +function recordOrEmpty(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } +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 dateOrNull(value: unknown): string | null { return value ? new Date(String(value)).toISOString() : null; } function json(body: Record, 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 { return error instanceof MonitorCentralConfigurationError ? error.toJSON().error as Record : { code: (error as { code?: string })?.code ?? "invalid_request", message: errorMessage(error) }; } @@ -446,12 +530,37 @@ function matches(scope: MonitorScope, value: MonitorScope): boolean { return (!s function runFrom(value: NormalizedMonitorCentralIngest): MonitorRun { return { sentinelId: value.sentinelId, node: value.node, lane: value.lane, runId: value.runId, updatedAt: value.updatedAt, status: value.status, payloadHash: value.payloadHash }; } function sortRuns(runs: readonly MonitorRun[]): MonitorRun[] { return [...runs].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.runId.localeCompare(left.runId)); } function afterRunCursor(run: MonitorRun, cursorValue: RunCursor | null): boolean { return !cursorValue || run.updatedAt < cursorValue.updatedAt || (run.updatedAt === cursorValue.updatedAt && run.runId < cursorValue.runId); } +function monitorWorkspaceMatches(run: MonitorRun, options: MonitorWorkspaceQueryOptions): boolean { return (!options.status || run.status === options.status) && (!options.from || run.updatedAt >= options.from) && (!options.to || run.updatedAt <= options.to); } +function monitorRunFailed(status: string): boolean { return status === "failed"; } function compareFindings(left: Record, right: Record): number { return String(right.updatedAt).localeCompare(String(left.updatedAt)) || String(right.runId).localeCompare(String(left.runId)) || Number(right.findingIndex) - Number(left.findingIndex); } function afterFindingCursor(finding: Record, cursorValue: FindingCursor | null): boolean { if (!cursorValue) return true; return String(finding.updatedAt) < cursorValue.updatedAt || (finding.updatedAt === cursorValue.updatedAt && (String(finding.runId) < cursorValue.runId || (finding.runId === cursorValue.runId && Number(finding.findingIndex) < cursorValue.findingIndex))); } export function monitorCentralStableJson(value: unknown): string { if (value === undefined) return "null"; if (Array.isArray(value)) return `[${value.map(monitorCentralStableJson).join(",")}]`; if (value && typeof value === "object") return `{${Object.entries(value as Record).filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${monitorCentralStableJson(item)}`).join(",")}}`; const json = JSON.stringify(value); return json === undefined ? "null" : json; } function scopeWhere(scope: MonitorScope): { readonly text: string; readonly values: readonly string[] } { const entries = Object.entries({ sentinel_id: scope.sentinelId, node: scope.node, lane: scope.lane }).filter(([, value]) => value); return { text: entries.length ? entries.map(([key], index) => `r.${key} = $${index + 1}`).join(" and ") : "true", values: entries.map(([, value]) => String(value)) }; } function identityWhere(scope: Required, runId: string): { readonly text: string; readonly values: readonly string[] } { return { text: "r.sentinel_id = $1 and r.node = $2 and r.lane = $3 and r.run_id = $4", values: [scope.sentinelId, scope.node, scope.lane, runId] }; } async function queryRuns(sql: postgres.Sql, scope: MonitorScope, options: RunQueryOptions): Promise { const where = scopeWhere(scope); const values: unknown[] = [...where.values]; let cursorClause = ""; if (options.cursor) { values.push(options.cursor.updatedAt, options.cursor.runId); cursorClause = `and (r.updated_at, r.run_id) < ($${values.length - 1}, $${values.length})`; } values.push(options.limit); const rows = await sql.unsafe(`select r.sentinel_id, r.node, r.lane, r.run_id, r.updated_at, r.status, r.payload_hash from monitor.runs r where ${where.text} ${cursorClause} order by r.updated_at desc, r.run_id desc limit $${values.length}`, values); return rows.map(runRow); } +async function queryMonitorWorkspace(sql: postgres.Sql, scope: MonitorScope, options: MonitorWorkspaceQueryOptions): Promise { + const where = scopeWhere(scope); + const values: unknown[] = [...where.values]; + const filters: string[] = [where.text]; + if (options.status) { values.push(options.status); filters.push(`r.status = $${values.length}`); } + if (options.from) { values.push(options.from); filters.push(`r.updated_at >= $${values.length}`); } + if (options.to) { values.push(options.to); filters.push(`r.updated_at <= $${values.length}`); } + const baseValues = [...values]; + let cursorClause = ""; + if (options.cursor) { values.push(options.cursor.updatedAt, options.cursor.runId); cursorClause = `and (r.updated_at, r.run_id) < ($${values.length - 1}, $${values.length})`; } + values.push(options.limit + 1); + const predicate = filters.join(" and "); + const [pageRows, summaryRows, statusRows] = await Promise.all([ + sql.unsafe(`select r.sentinel_id, r.node, r.lane, r.run_id, r.updated_at, r.status, r.payload_hash, count(f.finding_index)::int as finding_count from monitor.runs r left join monitor.findings f using (sentinel_id, node, lane, run_id) where ${predicate} ${cursorClause} group by r.sentinel_id, r.node, r.lane, r.run_id, r.updated_at, r.status, r.payload_hash order by r.updated_at desc, r.run_id desc limit $${values.length}`, values), + sql.unsafe(`select count(*)::int as total_runs, coalesce(sum(finding_count),0)::int as finding_count, max(updated_at) filter (where status = 'succeeded') as latest_success_at, max(updated_at) filter (where status = 'failed') as latest_failure_at from (select r.status, r.updated_at, count(f.finding_index)::int as finding_count from monitor.runs r left join monitor.findings f using (sentinel_id, node, lane, run_id) where ${predicate} group by r.sentinel_id, r.node, r.lane, r.run_id, r.status, r.updated_at) filtered`, baseValues), + sql.unsafe(`select r.status, count(*)::int as status_count from monitor.runs r where ${predicate} group by r.status`, baseValues), + ]); + const hasMore = pageRows.length > options.limit; + const items = pageRows.slice(0, options.limit).map((row) => ({ ...runRow(row), findingCount: Number(row.finding_count ?? 0) })); + const summary = summaryRows[0] ?? {}; + const last = items.at(-1); + return { items, nextCursor: hasMore && last ? cursor(last) : null, totalRuns: Number(summary.total_runs ?? 0), findingCount: Number(summary.finding_count ?? 0), statusCounts: Object.fromEntries(statusRows.map((row) => [String(row.status), Number(row.status_count)])), latestSuccessAt: dateOrNull(summary.latest_success_at), latestFailureAt: dateOrNull(summary.latest_failure_at) }; +} async function countRuns(sql: postgres.Sql, scope: MonitorScope): Promise { const where = scopeWhere(scope); const rows = await sql.unsafe(`select count(*)::int as count from monitor.runs r where ${where.text}`, where.values); return Number(rows[0]?.count ?? 0); } async function updatePostgresArtifactAvailability(transaction: postgres.TransactionSql, input: NormalizedMonitorCentralIngest): Promise { for (const [index, locator] of input.artifactLocators.entries()) await transaction` diff --git a/scripts/src/monitor-workbench-route.test.ts b/scripts/src/monitor-workbench-route.test.ts new file mode 100644 index 00000000..91265b38 --- /dev/null +++ b/scripts/src/monitor-workbench-route.test.ts @@ -0,0 +1,33 @@ +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"); +}); + +test("monitor route rejects invalid tab status time range and malformed encoding", () => { + assert.equal(readMonitorRoute("https://monitor.example.test/monitor?tab=raw").reason, "invalid_tab"); + assert.equal(readMonitorRoute("https://monitor.example.test/monitor?status=surprise").reason, "invalid_status"); + assert.equal(readMonitorRoute("https://monitor.example.test/monitor?from=invalid").reason, "invalid_time_range"); + assert.equal(readMonitorRoute("https://monitor.example.test/monitor?from=2026-07-13T05:00:00Z&to=2026-07-13T04:00:00Z").reason, "invalid_time_range"); + assert.equal(readMonitorRoute("https://monitor.example.test/monitor/runs/%E0%A4%A").reason, "malformed_url_encoding"); +}); + +test("monitor route roundtrips diagnostic detail", () => { + const route = readMonitorRoute("https://monitor.example.test/monitor/runs/run-1/diagnostics/runtime?sentinelId=s1&node=NC01&lane=v03&tab=diagnostics"); + assert.equal(route.detailType, "diagnostic"); + assert.equal(route.detailId, "runtime"); + assert.equal(monitorRouteHref(route), "/monitor/runs/run-1/diagnostics/runtime?sentinelId=s1&node=NC01&lane=v03&tab=diagnostics"); +}); diff --git a/scripts/verify-web-probe-sentinel-monitor-workbench.ts b/scripts/verify-web-probe-sentinel-monitor-workbench.ts new file mode 100644 index 00000000..a39bb42c --- /dev/null +++ b/scripts/verify-web-probe-sentinel-monitor-workbench.ts @@ -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/", "datetime-local", "compact-scope-panel", "compact-time-panel", "Last success", "Last failure", "semantic-row", "原始诊断数据", "diagnostics: \"diagnostic\"", "detailType===\"diagnostic\""]) 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 }));