fix: address monitor workbench review blockers
This commit is contained in:
@@ -13,7 +13,9 @@ button { color:inherit; }
|
||||
.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 { position:relative; gap:8px; padding:3px 8px; overflow:hidden; }
|
||||
.desktop-scope { display:flex; gap:8px; }
|
||||
.compact-scope-button, .compact-scope-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; }
|
||||
@@ -43,9 +45,17 @@ button { color:inherit; }
|
||||
.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); }
|
||||
@@ -55,8 +65,12 @@ pre { max-width:100%; overflow:auto; border:1px solid var(--line); background:#f
|
||||
.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; }
|
||||
.desktop-scope { 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; }
|
||||
.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); }
|
||||
}
|
||||
|
||||
@@ -2,93 +2,31 @@ import { createApp, computed, onMounted, ref } from "../web-probe-sentinel-monit
|
||||
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));
|
||||
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 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 }); }
|
||||
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 };
|
||||
}, 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?.label || workspace?.cadence?.state || 'unavailable' }}</span><span class="app-stat">Fresh {{ workspace?.freshness?.ageSeconds != null ? workspace.freshness.ageSeconds + 's' : workspace?.freshness?.state || 'unavailable' }}</span><span class="app-stat">Last success {{ formatDate(workspace?.summary?.latestSuccessAt) }}</span><span class="app-stat">Last failure {{ formatDate(workspace?.summary?.latestFailureAt) }}</span><button class="icon-button" @click="restore">刷新</button></header>
|
||||
<nav class="context-bar"><label>Sentinel<input :value="route.filters?.sentinelId" @change="updateFilter('sentinelId',$event.target.value)"></label><div class="desktop-scope"><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></div><button class="compact-scope-button" @click="scopeOpen=!scopeOpen">范围 {{ route.filters?.node || '—' }}/{{ route.filters?.lane || '—' }}</button><div v-if="scopeOpen" class="compact-scope-panel"><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></div><label>Status<select :value="route.filters?.status" @change="updateFilter('status',$event.target.value)"><option value="">全部</option><option value="succeeded">成功</option><option value="failed">失败</option><option value="blocked">阻塞</option><option value="running">运行中</option><option value="unknown">未知</option></select></label><label>From<input type="datetime-local" :value="route.filters?.from" @change="updateFilter('from',$event.target.value)"></label><label>To<input type="datetime-local" :value="route.filters?.to" @change="updateFilter('to',$event.target.value)"></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>{{run.status}}</span><small>{{run.sentinelId}} · {{run.node}}/{{run.lane}} · {{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="semantic-row" @click="selectRow(row)"><time>{{row.time}}</time><strong>{{row.type}}</strong><span class="severity">{{row.severity}}</span><span class="summary">{{row.summary}}</span></button><section v-if="detail?.artifacts?.length" class="artifact-section"><h2>Artifacts</h2><button v-for="artifact in detail.artifacts" :key="artifact.id" class="semantic-row" @click="selectArtifact(artifact)"><strong>{{artifact.kind||'artifact'}}</strong><span>{{artifact.relativePath||artifact.id}}</span><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>{{semanticDetail(selectedDetail).title}}</h2><dl><template v-for="item in semanticDetail(selectedDetail).fields" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></template></dl><details><summary>原始诊断数据</summary><pre>{{JSON.stringify(selectedDetail,null,2)}}</pre></details></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");
|
||||
|
||||
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 }) : "—"; }
|
||||
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 formatDate(value) { return value ? new Date(value).toLocaleString("zh-CN",{hour12:false}) : "—"; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export async function loadMonitorWorkspace(route, fetchImpl = fetch) {
|
||||
const params = new URLSearchParams({ ...compact(route.filters), limit: "100" });
|
||||
const params = new URLSearchParams(compact(route.filters));
|
||||
return requestJson(fetchImpl, `/api/monitor/workspace?${params}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +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) {
|
||||
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;
|
||||
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" };
|
||||
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, value] of Object.entries(route.filters || {})) if (value) url.searchParams.set(key, value);
|
||||
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 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)}` : "";
|
||||
}
|
||||
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" }; return route.detailType && route.detailId ? `/${segments[route.detailType]}/${encodeURIComponent(route.detailId)}` : ""; }
|
||||
|
||||
@@ -121,6 +121,20 @@ test("monitor workspace composes runs while runtime facts remain unavailable wit
|
||||
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 workspace projects cadence freshness and health only from runtime metadata", async () => {
|
||||
const store = createInMemoryMonitorCentralStore();
|
||||
const normalized = normalizeMonitorCentralIngest(input("run-metadata"));
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface MonitorCentralStore {
|
||||
runtimeMetadata(scope: Required<MonitorScope>): Promise<readonly MonitorRuntimeMetadata[]>;
|
||||
updateArtifactAvailability(scope: Required<MonitorScope>, runId: string, locatorIndex: number, reachable: boolean): Promise<void>;
|
||||
overview(scope: MonitorScope): Promise<Record<string, unknown>>;
|
||||
monitorWorkspace(scope: MonitorScope, options: MonitorWorkspaceQueryOptions): Promise<MonitorWorkspaceQueryResult>;
|
||||
runs(scope: MonitorScope, options: RunQueryOptions): Promise<readonly MonitorRun[]>;
|
||||
run(scope: Required<MonitorScope>, runId: string): Promise<Record<string, unknown> | null>;
|
||||
view(scope: Required<MonitorScope>, runId: string, view: string): Promise<Record<string, unknown> | 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<Record<string, number>>; readonly latestSuccessAt: string | null; readonly latestFailureAt: string | null; }
|
||||
|
||||
export interface MonitorCentralService {
|
||||
ingest(input: MonitorCentralIngest): Promise<Record<string, unknown>>;
|
||||
health(): Promise<Record<string, unknown>>;
|
||||
@@ -189,16 +199,10 @@ export function createMonitorCentralService(options: MonitorCentralServiceOption
|
||||
|
||||
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 result = await store.monitorWorkspace(scope, { ...runQueryOptions(url, pageSize), status, from, to });
|
||||
const runtime = await monitorRuntimeProjection(store, scope);
|
||||
return {
|
||||
ok: true,
|
||||
@@ -206,9 +210,9 @@ async function monitorWorkspace(store: MonitorCentralStore, url: URL, pageSize:
|
||||
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 },
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -313,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);
|
||||
@@ -447,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) => run.status !== "succeeded")?.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 }));
|
||||
@@ -500,6 +515,7 @@ function countBy<T>(items: readonly T[], key: (item: T) => string): Record<strin
|
||||
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 dateOrNull(value: unknown): string | null { return value ? new Date(String(value)).toISOString() : null; }
|
||||
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) }; }
|
||||
@@ -514,12 +530,36 @@ 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 compareFindings(left: Record<string, unknown>, right: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>).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<MonitorScope>, 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<MonitorRun[]> { 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<MonitorWorkspaceQueryResult> {
|
||||
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 <> '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 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<number> { 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<void> {
|
||||
for (const [index, locator] of input.artifactLocators.entries()) await transaction`
|
||||
|
||||
@@ -16,3 +16,11 @@ test("monitor route rejects unsupported paths and filter changes clear selection
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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/"]) 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", "Last success", "Last failure", "semantic-row", "原始诊断数据"]) 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