feat: add orchestrator canary readiness gate

This commit is contained in:
john
2026-07-24 22:09:19 +08:00
parent ff9c68e57f
commit 85e888b340
8 changed files with 534 additions and 6 deletions
@@ -1,7 +1,10 @@
import { useEffect, useState } from 'react';
import {
fetchOrchestratorCanaryReadiness,
fetchOrchestratorShadowRun,
fetchOrchestratorShadowRuns,
type OrchestratorCanaryReadiness,
type OrchestratorCanaryReadinessCheck,
type OrchestratorShadowRunDetail,
type OrchestratorShadowRunList,
} from '../../api/admin';
@@ -22,6 +25,46 @@ function shortId(value: string) {
return value.length > 16 ? `${value.slice(0, 8)}${value.slice(-6)}` : value;
}
const readinessCheckLabels: Record<string, string> = {
shadow_mode: '当前保持 Shadow',
service_healthy: '服务健康',
durable_checkpoint: '持久化 checkpoint',
observe_only: '仅观察不执行',
sample_volume: '有效样本量',
shadow_success_rate: 'Shadow 成功率',
latency_coverage: '延迟数据覆盖率',
latency_p95: '延迟 P95',
native_settled_rate: 'Native 终态覆盖率',
session_coverage: '真实会话覆盖',
sample_freshness: '最近样本新鲜度',
complete_window: '统计窗口完整',
};
function formatReadinessValue(check: OrchestratorCanaryReadinessCheck) {
const value = check.actual;
if (value == null) return '无数据';
if (['shadow_success_rate', 'latency_coverage', 'native_settled_rate'].includes(check.id)) {
return formatPercent(Number(value));
}
if (check.id === 'latency_p95') return formatLatency(Number(value));
if (check.id === 'sample_freshness') return `${Number(value).toFixed(1)} 小时`;
if (typeof value === 'boolean') return value ? '是' : '否';
return String(value);
}
function formatReadinessTarget(check: OrchestratorCanaryReadinessCheck) {
const target = check.target;
if (target == null) return '';
if (['shadow_success_rate', 'latency_coverage', 'native_settled_rate'].includes(check.id)) {
return `${formatPercent(Number(target))}`;
}
if (check.id === 'latency_p95') return `${formatLatency(Number(target))}`;
if (check.id === 'sample_freshness') return `${target} 小时`;
if (['sample_volume', 'session_coverage'].includes(check.id)) return `${target}`;
if (typeof target === 'boolean') return target ? '= 是' : '= 否';
return `= ${target}`;
}
const metricCardStyle = {
border: '1px solid #e4e9e6',
borderRadius: 10,
@@ -33,6 +76,7 @@ export function OrchestratorShadowPanel() {
const [hours, setHours] = useState(24);
const [status, setStatus] = useState<'all' | 'succeeded' | 'failed'>('all');
const [data, setData] = useState<OrchestratorShadowRunList | null>(null);
const [readiness, setReadiness] = useState<OrchestratorCanaryReadiness | null>(null);
const [selected, setSelected] = useState<OrchestratorShadowRunDetail | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
@@ -42,7 +86,12 @@ export function OrchestratorShadowPanel() {
setLoading(true);
setError(null);
try {
setData(await fetchOrchestratorShadowRuns({ hours, status, limit: 100 }));
const [runs, canaryReadiness] = await Promise.all([
fetchOrchestratorShadowRuns({ hours, status, limit: 100 }),
fetchOrchestratorCanaryReadiness(),
]);
setData(runs);
setReadiness(canaryReadiness);
} catch (err) {
setError(err instanceof Error ? err.message : 'Shadow 指标加载失败');
} finally {
@@ -100,6 +149,49 @@ export function OrchestratorShadowPanel() {
{error ? <p className="alert">{error}</p> : null}
{readiness ? (
<div
style={{
border: `1px solid ${readiness.ready ? '#8bbda8' : '#e6c98f'}`,
background: readiness.ready ? '#f1f8f4' : '#fff9ed',
borderRadius: 10,
padding: 14,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<h4 style={{ margin: 0 }}>Canary </h4>
<p style={{ margin: '5px 0 0', color: '#68716c', fontSize: 13 }}>
7 smoke
</p>
</div>
<strong style={{ color: readiness.ready ? '#2f6f57' : '#8a5a16' }}>
{readiness.ready ? '已达到门槛,等待人工审批' : '继续保持 Shadow'}
</strong>
</div>
<p style={{ margin: '10px 0' }}>
{readiness.samples.eligibleObservations} / {readiness.thresholds.minObservations}
{' · '}
smoke {readiness.samples.excludedSynthetic}
{' · '}
{readiness.samples.distinctSessions} / {readiness.thresholds.minDistinctSessions}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 6 }}>
{readiness.checks.map((check) => (
<div key={check.id} style={{ fontSize: 13, color: check.passed ? '#2f6f57' : '#8a5a16' }}>
{check.passed ? '通过' : '阻塞'} · {readinessCheckLabels[check.id] ?? check.id}
{formatReadinessValue(check)} {formatReadinessTarget(check)}
</div>
))}
</div>
{readiness.failureCodes.length ? (
<p style={{ margin: '10px 0 0', fontSize: 13 }}>
{readiness.failureCodes.map((item) => `${item.code} × ${item.count}`).join('')}
</p>
) : null}
</div>
) : null}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
<div style={metricCardStyle}>
<small></small>
@@ -168,6 +260,7 @@ export function OrchestratorShadowPanel() {
<td style={{ padding: 8 }}>{formatLatency(run.latencyMs)}</td>
<td style={{ padding: 8 }}>
{run.taskType ?? '—'}
{run.synthetic ? 'smoke' : ''}
<small style={{ display: 'block', color: '#68716c' }}>
{run.executorAdapter ?? '—'}
</small>