feat: add orchestrator shadow observability
This commit is contained in:
@@ -257,6 +257,85 @@ export type OrchestratorRuntimeState = OrchestratorConfigState & {
|
||||
serviceHealth: OrchestratorServiceHealth;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowMetrics = {
|
||||
observations: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
successRate: number | null;
|
||||
failureRate: number | null;
|
||||
latencyP50Ms: number | null;
|
||||
latencyP95Ms: number | null;
|
||||
nativeSucceeded: number;
|
||||
nativeFailed: number;
|
||||
lastObservedAt: number | null;
|
||||
sampled: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRun = {
|
||||
eventId: string;
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
nativeStatus: string;
|
||||
nativeAttempts: number;
|
||||
shadowStatus: 'succeeded' | 'failed';
|
||||
engine: string;
|
||||
configVersion: number | null;
|
||||
phase: string | null;
|
||||
taskType: string | null;
|
||||
executorAdapter: string | null;
|
||||
latencyMs: number | null;
|
||||
error: { code: string; message: string } | null;
|
||||
observedAt: number;
|
||||
nativeCompletedAt: number | null;
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRunList = {
|
||||
generatedAt: number;
|
||||
window: { hours: number; from: number };
|
||||
metrics: OrchestratorShadowMetrics;
|
||||
runs: OrchestratorShadowRun[];
|
||||
};
|
||||
|
||||
export type OrchestratorShadowRunDetail = {
|
||||
native: {
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
error: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
};
|
||||
localEvents: Array<{
|
||||
eventId: string;
|
||||
type: string;
|
||||
data: Record<string, unknown> | null;
|
||||
createdAt: number;
|
||||
}>;
|
||||
remote: {
|
||||
available: boolean;
|
||||
state: {
|
||||
status?: string;
|
||||
phase?: string;
|
||||
plan?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
} | null;
|
||||
events: Array<{
|
||||
sequence?: number;
|
||||
type?: string;
|
||||
timestamp?: number;
|
||||
data?: Record<string, unknown> | null;
|
||||
}>;
|
||||
error: { code: string; message: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminSummary() {
|
||||
@@ -293,6 +372,26 @@ export async function fetchOrchestratorRuntime() {
|
||||
return adminFetch<OrchestratorRuntimeState>('/admin-api/orchestrator/runtime');
|
||||
}
|
||||
|
||||
export async function fetchOrchestratorShadowRuns(params: {
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
status?: 'all' | 'succeeded' | 'failed';
|
||||
} = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.hours) query.set('hours', String(params.hours));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.status) query.set('status', params.status);
|
||||
return adminFetch<OrchestratorShadowRunList>(
|
||||
`/admin-api/orchestrator/shadow-runs?${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchOrchestratorShadowRun(runId: string) {
|
||||
return adminFetch<OrchestratorShadowRunDetail>(
|
||||
`/admin-api/orchestrator/shadow-runs/${encodeURIComponent(runId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminUsers(params: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user