diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css
index 4cf4a8a1..bc0d321c 100644
--- a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css
+++ b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.css
@@ -15,7 +15,8 @@ button { color:inherit; }
.health-dot[data-state="degraded"] { background:#d18a24; }
.context-bar { position:relative; gap:8px; padding:3px 8px; overflow:hidden; }
.desktop-scope { display:flex; gap:8px; }
-.compact-scope-button, .compact-scope-panel { display:none; }
+.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; }
@@ -66,9 +67,11 @@ pre { max-width:100%; overflow:auto; border:1px solid var(--line); background:#f
.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; }
- .context-bar>label:nth-of-type(3), .context-bar>label:nth-of-type(4) { display:none; }
+ .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); }
diff --git a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js
index dcb9f1df..9bb1b018 100644
--- a/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js
+++ b/scripts/assets/web-probe-sentinel-monitor-workbench/monitor-workbench.js
@@ -3,20 +3,20 @@ 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 detailOpen = ref(route.value.kind === "detail");
+ 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) { if (route.value.tab !== "diagnostics") navigate({ kind:"detail", detailType:route.value.tab === "findings" ? "finding" : "event", detailId:row.id }); }
+ 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, detailOpen, runs, activeRun, activeRows, selectedDetail, restore, selectRun, selectTab, selectRow, selectArtifact, closeDetail, updateFilter, formatDate, semanticDetail };
+ 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
Artifacts
@@ -27,6 +27,6 @@ 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:detail.artifacts; return rows?.find((item)=>item.id===route.detailId)||null; }
-function semanticDetail(item) { const value=record(item.finding||item.event||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||"—"],["Severity",value.severity||value.level||"—"],["摘要",value.summary||value.message||value.rootCause||"—"],["路径",value.relativePath||item.relativePath||"—"],["可达",item.reachable==null?"—":item.reachable?"是":"否"]] }; }
+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-route.js b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js
index c1689df0..844c1cdb 100644
--- a/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js
+++ b/scripts/assets/web-probe-sentinel-monitor-workbench/state/monitor-route.js
@@ -20,7 +20,7 @@ export function readMonitorRoute(input = window.location.href) {
route.kind = "run";
route.runId = decodeURIComponent(segments[2]);
if (segments.length === 3) return route;
- const detailTypes = { findings: "finding", events: "event", artifacts: "artifact" };
+ 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]];
@@ -43,4 +43,4 @@ export function withMonitorRoute(route, patch) { return { ...route, ...patch, fi
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" }; return route.detailType && route.detailId ? `/${segments[route.detailType]}/${encodeURIComponent(route.detailId)}` : ""; }
+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 4408828b..16c970ac 100644
--- a/scripts/src/monitor-central-persistence.test.ts
+++ b/scripts/src/monitor-central-persistence.test.ts
@@ -135,6 +135,15 @@ test("monitor workspace filters before paging and counts findings beyond page si
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"));
diff --git a/scripts/src/monitor-central-persistence.ts b/scripts/src/monitor-central-persistence.ts
index 5964f534..71902f92 100644
--- a/scripts/src/monitor-central-persistence.ts
+++ b/scripts/src/monitor-central-persistence.ts
@@ -460,7 +460,7 @@ export function createInMemoryMonitorCentralStore(): MonitorCentralStore {
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) => run.status !== "succeeded")?.updatedAt ?? null };
+ 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) {
@@ -531,6 +531,7 @@ function runFrom(value: NormalizedMonitorCentralIngest): MonitorRun { return { s
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; }
@@ -551,7 +552,7 @@ async function queryMonitorWorkspace(sql: postgres.Sql, scope: MonitorScope, opt
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 <> 'succeeded') 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 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;
diff --git a/scripts/src/monitor-workbench-route.test.ts b/scripts/src/monitor-workbench-route.test.ts
index 51de66d3..91265b38 100644
--- a/scripts/src/monitor-workbench-route.test.ts
+++ b/scripts/src/monitor-workbench-route.test.ts
@@ -24,3 +24,10 @@ test("monitor route rejects invalid tab status time range and malformed encoding
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
index 06ff6b54..a39bb42c 100644
--- a/scripts/verify-web-probe-sentinel-monitor-workbench.ts
+++ b/scripts/verify-web-probe-sentinel-monitor-workbench.ts
@@ -9,7 +9,7 @@ const files = [
];
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", "Last success", "Last failure", "semantic-row", "原始诊断数据"]) if (!source.includes(expected)) failures.push(`missing ${expected}`);
+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 }));