Files
memind/ops/src/pages/admin/OrchestratorShadowPanel.tsx
T

230 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}