feat: scaffold central monitor workbench

This commit is contained in:
AgentRun Codex
2026-07-13 03:18:43 +00:00
parent a7e2f42ea3
commit 6de541221e
8 changed files with 363 additions and 0 deletions
@@ -0,0 +1,62 @@
:root { color-scheme: light; font-family: Inter, "Segoe UI", sans-serif; --line:#ccd5d3; --bg:#eef2f1; --panel:#fff; --text:#17201f; --muted:#60706d; --accent:#176b5c; --danger:#a53d38; }
* { box-sizing:border-box; }
html, body, #monitor-workbench-root { width:100%; height:100%; margin:0; overflow:hidden; }
body { background:var(--bg); color:var(--text); }
button, input, select { font:inherit; }
button { color:inherit; }
.monitor-workbench { display:grid; grid-template-rows:44px 36px minmax(0,1fr) 24px; width:100%; height:100dvh; overflow:hidden; }
.app-bar, .context-bar, .status-bar { display:flex; align-items:center; min-width:0; border-bottom:1px solid var(--line); background:#f8faf9; }
.app-bar { gap:8px; padding:0 12px; font-size:13px; }
.app-bar strong { font-size:16px; }
.app-stat { color:var(--muted); }
.icon-button { margin-left:auto; }
.health-dot { width:8px; height:8px; border-radius:50%; background:#9aa6a3; }
.health-dot[data-state="healthy"] { background:#278260; }
.health-dot[data-state="degraded"] { background:#d18a24; }
.context-bar { gap:8px; padding:3px 8px; overflow:hidden; }
.context-bar label { display:flex; align-items:center; gap:4px; color:var(--muted); font-size:11px; }
.context-bar input, .context-bar select { width:132px; height:28px; border:1px solid var(--line); background:#fff; padding:0 7px; }
.result-count { margin-left:auto; white-space:nowrap; color:var(--muted); font-size:12px; }
.workspace { display:grid; grid-template-columns:360px minmax(560px,1fr); min-width:0; min-height:0; overflow:hidden; }
.workspace.detail-open { grid-template-columns:360px minmax(560px,1fr) 440px; }
.workspace.left-collapsed { grid-template-columns:40px minmax(560px,1fr); }
.workspace.left-collapsed.detail-open { grid-template-columns:40px minmax(560px,1fr) 440px; }
.run-pane, .main-pane, .detail-pane { position:relative; min-width:0; min-height:0; overflow:hidden; background:var(--panel); }
.run-pane { border-right:1px solid var(--line); }
.main-pane { display:flex; flex-direction:column; }
.detail-pane { border-left:1px solid var(--line); }
.pane-title, .detail-pane>header { display:flex; align-items:center; height:36px; padding:0 10px; border-bottom:1px solid var(--line); font-size:12px; font-weight:700; text-transform:uppercase; }
.pane-toggle { position:absolute; z-index:2; top:5px; right:5px; width:26px; height:26px; border:1px solid var(--line); background:#fff; }
.left-collapsed .pane-title, .left-collapsed .run-list { display:none; }
.run-list, .main-scroll, .detail-scroll { height:calc(100% - 36px); overflow:auto; overscroll-behavior:contain; }
.run-row { display:grid; grid-template-columns:110px minmax(0,1fr) auto; width:100%; min-height:52px; gap:2px 8px; border:0; border-bottom:1px solid #e5ebe9; background:#fff; padding:7px 10px; text-align:left; }
.run-row:hover, .run-row.active { background:#e8f2ef; }
.run-row strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.run-row small { grid-column:2/4; color:var(--muted); }
.run-header { display:flex; align-items:center; justify-content:space-between; min-height:72px; padding:10px 14px; border-bottom:1px solid var(--line); }
.run-header>div { display:grid; gap:5px; min-width:0; }
.run-header span { color:var(--muted); font-size:12px; }
.run-header nav { display:flex; gap:2px; }
.run-header button, .detail-pane button, .state-layer button, .icon-button { border:1px solid var(--line); background:#fff; min-height:28px; padding:0 10px; }
.run-header button.active { border-color:var(--accent); color:var(--accent); }
.main-scroll { height:calc(100% - 72px); padding:8px; }
.data-row { display:grid; grid-template-columns:minmax(120px,220px) minmax(0,1fr); width:100%; gap:12px; min-height:42px; align-items:center; border:0; border-bottom:1px solid #e5ebe9; background:#fff; padding:7px 9px; text-align:left; }
.data-row:hover { background:#f3f7f6; }
.data-row code { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; }
.artifact-section h2 { margin:18px 9px 6px; font-size:13px; }
.detail-pane>header { justify-content:space-between; }
.detail-scroll { padding:12px; }
pre { max-width:100%; overflow:auto; border:1px solid var(--line); background:#f6f8f7; padding:10px; font-size:12px; white-space:pre-wrap; overflow-wrap:anywhere; }
.state-layer { display:grid; place-content:center; min-height:100%; gap:10px; color:var(--muted); text-align:center; }
.state-layer.error { color:var(--danger); }
.status-bar { justify-content:space-between; padding:0 8px; border-top:1px solid var(--line); border-bottom:0; color:var(--muted); font-size:11px; }
@media (max-width:1120px) {
.monitor-workbench { grid-template-rows:40px 34px minmax(0,1fr) 20px; }
.workspace, .workspace.detail-open { grid-template-columns:280px minmax(0,1fr); }
.workspace.left-collapsed, .workspace.left-collapsed.detail-open { grid-template-columns:36px minmax(0,1fr); }
.detail-pane { position:absolute; z-index:5; top:0; right:0; bottom:0; width:min(420px, calc(100vw - 40px)); box-shadow:-10px 0 24px rgba(23,32,31,.14); }
.context-bar label:nth-of-type(2), .context-bar label:nth-of-type(3) { display:none; }
.context-bar input, .context-bar select { width:118px; }
.run-header { min-height:64px; padding:7px 10px; }
.main-scroll { height:calc(100% - 64px); }
}
@@ -0,0 +1,94 @@
import { createApp, computed, onMounted, ref } from "../web-probe-sentinel-monitor-web/vendor/vue.esm-browser.prod.js";
import { loadMonitorRun, loadMonitorWorkspace } from "./state/monitor-client.js";
import { monitorRouteHref, readMonitorRoute, withMonitorRoute } from "./state/monitor-route.js";
createApp({
setup() {
const route = ref(readMonitorRoute());
const workspace = ref(null);
const detail = ref(null);
const loading = ref(true);
const error = ref("");
const leftCollapsed = ref(false);
const detailOpen = ref(route.value.kind === "detail");
const runs = computed(() => workspace.value?.runs?.items || []);
const activeRun = computed(() => detail.value?.run || runs.value.find((item) => item.runId === route.value.runId) || null);
const activeRows = computed(() => route.value.tab === "findings" ? detail.value?.findings || [] : route.value.tab === "diagnostics" ? Object.entries(detail.value?.diagnostics || {}).map(([id, value]) => ({ id, value })) : detail.value?.timeline || []);
const selectedDetail = computed(() => findSelectedDetail(detail.value, route.value));
async function restore(options = {}) {
const next = readMonitorRoute();
route.value = next;
if (next.kind === "invalid") { error.value = `无效深链:${next.reason}`; loading.value = false; return; }
loading.value = true;
error.value = "";
try {
workspace.value = await loadMonitorWorkspace(next);
if (!next.runId && workspace.value.selection.defaultRunId) {
const canonical = withMonitorRoute(next, { kind: "run", runId: workspace.value.selection.defaultRunId });
history.replaceState(null, "", monitorRouteHref(canonical));
route.value = canonical;
}
detail.value = route.value.runId ? await loadMonitorRun(route.value) : null;
detailOpen.value = route.value.kind === "detail";
} catch (cause) {
error.value = cause.message || "monitor_request_failed";
} finally {
loading.value = false;
document.querySelector("#monitor-workbench-root")?.setAttribute("data-monitor-ready", error.value ? "false" : "true");
}
if (!options.popstate) window.scrollTo(0, 0);
}
function navigate(patch) {
const next = withMonitorRoute(route.value, patch);
history.pushState(null, "", monitorRouteHref(next));
void restore();
}
function selectRun(run) { navigate({ kind: "run", runId: run.runId, detailType: "", detailId: "" }); }
function selectTab(tab) { navigate({ tab, kind: "run", detailType: "", detailId: "" }); }
function selectRow(row) {
if (route.value.tab === "diagnostics") return;
navigate({ kind: "detail", detailType: route.value.tab === "findings" ? "finding" : "event", detailId: row.id });
}
function selectArtifact(row) { navigate({ kind: "detail", detailType: "artifact", detailId: row.id }); }
function closeDetail() { navigate({ kind: "run", detailType: "", detailId: "" }); }
function updateFilter(key, value) { navigate({ kind: "workspace", runId: "", detailType: "", detailId: "", filters: { [key]: value } }); }
onMounted(() => { window.addEventListener("popstate", () => void restore({ popstate: true })); void restore(); });
return { route, workspace, detail, loading, error, leftCollapsed, detailOpen, runs, activeRun, activeRows, selectedDetail, restore, navigate, selectRun, selectTab, selectRow, selectArtifact, closeDetail, updateFilter, formatDate };
},
template: `
<div class="monitor-workbench" data-monitor-workbench>
<header class="app-bar">
<strong>Monitor</strong><span class="health-dot" :data-state="workspace?.health?.state"></span><span>{{ workspace?.health?.state || 'loading' }}</span>
<span class="app-stat">Cadence {{ workspace?.cadence?.state || 'unavailable' }}</span><span class="app-stat">Freshness {{ workspace?.freshness?.state || 'unavailable' }}</span>
<button class="icon-button" type="button" @click="restore">刷新</button>
</header>
<nav class="context-bar" aria-label="Monitor 筛选">
<label>Sentinel<input :value="route.filters?.sentinelId" @change="updateFilter('sentinelId', $event.target.value)"></label>
<label>Node<input :value="route.filters?.node" @change="updateFilter('node', $event.target.value)"></label>
<label>Lane<input :value="route.filters?.lane" @change="updateFilter('lane', $event.target.value)"></label>
<label>Status<select :value="route.filters?.status" @change="updateFilter('status', $event.target.value)"><option value="">全部</option><option value="succeeded">成功</option><option value="failed">失败</option></select></label>
<span class="result-count">{{ workspace?.summary?.totalRuns ?? 0 }} runs · {{ workspace?.summary?.findingCount ?? 0 }} findings</span>
</nav>
<main class="workspace" :class="{ 'left-collapsed': leftCollapsed, 'detail-open': detailOpen }">
<aside class="run-pane" data-pane="runs"><button class="pane-toggle" @click="leftCollapsed=!leftCollapsed">{{ leftCollapsed ? '' : '' }}</button><div class="pane-title">Runs</div><div class="run-list"><button v-for="run in runs" :key="run.runId" class="run-row" :class="{active:run.runId===route.runId}" @click="selectRun(run)"><time>{{ formatDate(run.updatedAt) }}</time><strong>{{ run.runId }}</strong><span :data-status="run.status">{{ run.status }}</span><small>{{ run.findingCount }} findings</small></button></div></aside>
<section class="main-pane" data-pane="main">
<div v-if="loading" class="state-layer">正在加载 central Monitor view…</div><div v-else-if="error" class="state-layer error"><strong>加载失败</strong><code>{{ error }}</code><button @click="restore">重试</button></div><div v-else-if="!activeRun" class="state-layer">当前范围无 run</div>
<template v-else><header class="run-header"><div><strong>{{ activeRun.runId }}</strong><span>{{ activeRun.status }} · {{ formatDate(activeRun.updatedAt) }}</span></div><nav><button v-for="tab in ['timeline','findings','diagnostics']" :class="{active:route.tab===tab}" @click="selectTab(tab)">{{ tab }}</button></nav></header><div class="main-scroll"><button v-for="row in activeRows" :key="row.id" class="data-row" @click="selectRow(row)"><strong>{{ row.id }}</strong><code>{{ JSON.stringify(row.event || row.finding || row.value) }}</code></button><section v-if="detail?.artifacts?.length" class="artifact-section"><h2>Artifacts</h2><button v-for="artifact in detail.artifacts" :key="artifact.id" class="data-row" @click="selectArtifact(artifact)"><strong>{{ artifact.kind }}</strong><span>{{ artifact.reachable ? 'reachable' : 'unreachable' }}</span></button></section></div></template>
</section>
<aside v-if="detailOpen" class="detail-pane" data-pane="detail"><header><strong>Detail</strong><button @click="closeDetail">关闭</button></header><div class="detail-scroll"><div v-if="selectedDetail"><h2>{{ selectedDetail.id }}</h2><pre>{{ JSON.stringify(selectedDetail, null, 2) }}</pre></div><div v-else class="state-layer">对象不存在或不在当前范围</div></div></aside>
</main>
<footer class="status-bar"><span>central Monitor API</span><span>{{ workspace?.freshness?.reason || workspace?.freshness?.updatedAt || 'runtime metadata unavailable' }}</span></footer>
</div>`,
}).mount("#monitor-workbench-root");
function findSelectedDetail(detail, route) {
if (!detail || route.kind !== "detail") return null;
const rows = route.detailType === "finding" ? detail.findings : route.detailType === "event" ? detail.timeline : detail.artifacts;
return rows?.find((item) => item.id === route.detailId) || null;
}
function formatDate(value) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "—"; }
@@ -0,0 +1,20 @@
export async function loadMonitorWorkspace(route, fetchImpl = fetch) {
const params = new URLSearchParams({ ...compact(route.filters), limit: "100" });
return requestJson(fetchImpl, `/api/monitor/workspace?${params}`);
}
export async function loadMonitorRun(route, fetchImpl = fetch) {
const params = new URLSearchParams(compact({ sentinelId: route.filters.sentinelId, node: route.filters.node, lane: route.filters.lane }));
return requestJson(fetchImpl, `/api/monitor/runs/${encodeURIComponent(route.runId)}?${params}`);
}
function compact(value) {
return Object.fromEntries(Object.entries(value).filter(([, item]) => item));
}
async function requestJson(fetchImpl, path) {
const response = await fetchImpl(path, { cache: "no-store", headers: { accept: "application/json" } });
const body = await response.json().catch(() => null);
if (!response.ok || !body?.ok) throw Object.assign(new Error(body?.error?.code || `http_${response.status}`), { status: response.status, body });
return body;
}
@@ -0,0 +1,44 @@
const tabs = new Set(["timeline", "findings", "diagnostics"]);
export function readMonitorRoute(input = window.location.href) {
const url = new URL(input, window.location.origin);
const segments = url.pathname.split("/").filter(Boolean);
if (segments[0] !== "monitor") return { kind: "invalid", reason: "unsupported_path" };
const route = { kind: "workspace", runId: "", detailType: "", detailId: "", tab: normalizeTab(url.searchParams.get("tab")), filters: readFilters(url) };
if (segments.length === 1) return route;
if (segments[1] !== "runs" || !segments[2]) return { kind: "invalid", reason: "unsupported_path" };
route.kind = "run";
route.runId = decodeURIComponent(segments[2]);
if (segments.length === 3) return route;
const detailTypes = { findings: "finding", events: "event", artifacts: "artifact" };
if (!detailTypes[segments[3]] || !segments[4] || segments.length !== 5) return { kind: "invalid", reason: "unsupported_path" };
route.kind = "detail";
route.detailType = detailTypes[segments[3]];
route.detailId = decodeURIComponent(segments[4]);
return route;
}
export function monitorRouteHref(route) {
const path = route.runId ? `/monitor/runs/${encodeURIComponent(route.runId)}${detailPath(route)}` : "/monitor";
const url = new URL(path, window.location.origin);
for (const [key, value] of Object.entries(route.filters || {})) if (value) url.searchParams.set(key, value);
if (route.tab && route.tab !== "timeline") url.searchParams.set("tab", route.tab);
return `${url.pathname}${url.search}`;
}
export function withMonitorRoute(route, patch) {
return { ...route, ...patch, filters: { ...route.filters, ...(patch.filters || {}) } };
}
function readFilters(url) {
return Object.fromEntries(["sentinelId", "node", "lane", "status", "from", "to"].map((key) => [key, url.searchParams.get(key) || ""]));
}
function normalizeTab(value) {
return tabs.has(value) ? value : "timeline";
}
function detailPath(route) {
const segments = { finding: "findings", event: "events", artifact: "artifacts" };
return route.detailType && route.detailId ? `/${segments[route.detailType]}/${encodeURIComponent(route.detailId)}` : "";
}