feat: add orchestrator shadow observability

This commit is contained in:
john
2026-07-24 21:44:21 +08:00
parent 24336d0178
commit 3e57f85d3a
18 changed files with 992 additions and 9 deletions
+3
View File
@@ -7,6 +7,7 @@ import {
type OrchestratorConfigState,
type OrchestratorServiceHealth,
} from '../../api/admin';
import { OrchestratorShadowPanel } from './OrchestratorShadowPanel';
function listToText(values: string[]) {
return values.join('\n');
@@ -160,6 +161,8 @@ export function OrchestratorPage() {
</p>
</div>
<OrchestratorShadowPanel />
<div className="card grid">
<h3 style={{ margin: 0 }}></h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
@@ -0,0 +1,229 @@
import { useEffect, useState } from 'react';
import {
fetchOrchestratorShadowRun,
fetchOrchestratorShadowRuns,
type OrchestratorShadowRunDetail,
type OrchestratorShadowRunList,
} from '../../api/admin';
function formatTime(value: number | null | undefined) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—';
}
function formatPercent(value: number | null) {
return value == null ? '—' : `${(value * 100).toFixed(1)}%`;
}
function formatLatency(value: number | null) {
return value == null ? '—' : `${value} ms`;
}
function shortId(value: string) {
return value.length > 16 ? `${value.slice(0, 8)}${value.slice(-6)}` : value;
}
const metricCardStyle = {
border: '1px solid #e4e9e6',
borderRadius: 10,
padding: 12,
minWidth: 140,
} as const;
export function OrchestratorShadowPanel() {
const [hours, setHours] = useState(24);
const [status, setStatus] = useState<'all' | 'succeeded' | 'failed'>('all');
const [data, setData] = useState<OrchestratorShadowRunList | null>(null);
const [selected, setSelected] = useState<OrchestratorShadowRunDetail | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
setData(await fetchOrchestratorShadowRuns({ hours, status, limit: 100 }));
} catch (err) {
setError(err instanceof Error ? err.message : 'Shadow 指标加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [hours, status]);
const openDetail = async (runId: string) => {
setDetailLoading(true);
setError(null);
try {
setSelected(await fetchOrchestratorShadowRun(runId));
} catch (err) {
setError(err instanceof Error ? err.message : 'Shadow run 详情加载失败');
} finally {
setDetailLoading(false);
}
};
const metrics = data?.metrics;
return (
<div className="card grid">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<h3 style={{ margin: 0 }}>Shadow </h3>
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
Native LangGraph
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select value={hours} onChange={(event) => setHours(Number(event.target.value))}>
<option value={1}> 1 </option>
<option value={24}> 24 </option>
<option value={168}> 7 </option>
<option value={720}> 30 </option>
</select>
<select
value={status}
onChange={(event) => setStatus(event.target.value as typeof status)}
>
<option value="all"></option>
<option value="succeeded"></option>
<option value="failed"></option>
</select>
<button type="button" className="btn secondary" onClick={() => void load()} disabled={loading}>
{loading ? '刷新中…' : '刷新'}
</button>
</div>
</div>
{error ? <p className="alert">{error}</p> : null}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
<div style={metricCardStyle}>
<small></small>
<div style={{ fontSize: 24, marginTop: 4 }}>{metrics?.observations ?? '—'}</div>
</div>
<div style={metricCardStyle}>
<small>Shadow </small>
<div style={{ fontSize: 24, marginTop: 4 }}>{formatPercent(metrics?.successRate ?? null)}</div>
</div>
<div style={metricCardStyle}>
<small></small>
<div style={{ fontSize: 24, marginTop: 4 }}>{metrics?.failures ?? '—'}</div>
</div>
<div style={metricCardStyle}>
<small> P50</small>
<div style={{ fontSize: 24, marginTop: 4 }}>{formatLatency(metrics?.latencyP50Ms ?? null)}</div>
</div>
<div style={metricCardStyle}>
<small> P95</small>
<div style={{ fontSize: 24, marginTop: 4 }}>{formatLatency(metrics?.latencyP95Ms ?? null)}</div>
</div>
<div style={metricCardStyle}>
<small>Native / </small>
<div style={{ fontSize: 20, marginTop: 7 }}>
{metrics ? `${metrics.nativeSucceeded} / ${metrics.nativeFailed}` : '—'}
</div>
</div>
</div>
{metrics?.sampled ? (
<p className="warn"> 5000 Shadow </p>
) : null}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 900 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid #dce4df' }}>
<th style={{ padding: 8 }}></th>
<th style={{ padding: 8 }}>Run</th>
<th style={{ padding: 8 }}>Shadow</th>
<th style={{ padding: 8 }}>Native</th>
<th style={{ padding: 8 }}></th>
<th style={{ padding: 8 }}> / Adapter</th>
<th style={{ padding: 8 }}></th>
</tr>
</thead>
<tbody>
{(data?.runs ?? []).map((run) => (
<tr key={run.eventId} style={{ borderBottom: '1px solid #edf1ef' }}>
<td style={{ padding: 8, whiteSpace: 'nowrap' }}>{formatTime(run.observedAt)}</td>
<td style={{ padding: 8 }}>
<button
type="button"
className="btn secondary"
title={run.runId}
onClick={() => void openDetail(run.runId)}
disabled={detailLoading}
>
{shortId(run.runId)}
</button>
</td>
<td style={{ padding: 8, color: run.shadowStatus === 'succeeded' ? '#2f6f57' : '#a23a32' }}>
{run.shadowStatus}
</td>
<td style={{ padding: 8 }}>{run.nativeStatus}</td>
<td style={{ padding: 8 }}>{formatLatency(run.latencyMs)}</td>
<td style={{ padding: 8 }}>
{run.taskType ?? '—'}
<small style={{ display: 'block', color: '#68716c' }}>
{run.executorAdapter ?? '—'}
</small>
</td>
<td style={{ padding: 8, maxWidth: 260 }}>
{run.error ? `${run.error.code}: ${run.error.message}` : '—'}
</td>
</tr>
))}
{!loading && !data?.runs.length ? (
<tr>
<td colSpan={7} style={{ padding: 16, textAlign: 'center', color: '#68716c' }}>
Shadow
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{selected ? (
<div style={{ borderTop: '1px solid #dce4df', paddingTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<div>
<h4 style={{ margin: 0 }}>Run {selected.native.runId}</h4>
<p style={{ margin: '6px 0', color: '#68716c' }}>
Native {selected.native.status} · attempts {selected.native.attempts}
{' · '}
LangGraph {selected.remote.available ? (selected.remote.state?.status ?? 'available') : 'unavailable'}
</p>
</div>
<button type="button" className="btn secondary" onClick={() => setSelected(null)}>
</button>
</div>
{selected.remote.error ? (
<p className="alert">
{selected.remote.error.code}: {selected.remote.error.message}
</p>
) : null}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 12 }}>
<div>
<strong>LangGraph checkpoint</strong>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 12 }}>
{JSON.stringify(selected.remote.state, null, 2)}
</pre>
</div>
<div>
<strong>LangGraph </strong>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 12 }}>
{JSON.stringify(selected.remote.events, null, 2)}
</pre>
</div>
</div>
</div>
) : null}
</div>
);
}