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
+56
View File
@@ -284,6 +284,7 @@ export type OrchestratorShadowRun = {
configVersion: number | null;
phase: string | null;
taskType: string | null;
synthetic: boolean;
executorAdapter: string | null;
latencyMs: number | null;
error: { code: string; message: string } | null;
@@ -298,6 +299,55 @@ export type OrchestratorShadowRunList = {
runs: OrchestratorShadowRun[];
};
export type OrchestratorCanaryReadinessCheck = {
id: string;
passed: boolean;
actual: string | number | boolean | null;
target: string | number | boolean | null;
};
export type OrchestratorCanaryReadiness = {
generatedAt: number;
ready: boolean;
recommendation: 'keep_shadow' | 'manual_canary_review';
window: { hours: number; from: number };
thresholds: {
hours: number;
minObservations: number;
minSuccessRate: number;
maxP95LatencyMs: number;
minLatencyCoverageRate: number;
minNativeSettledRate: number;
minDistinctSessions: number;
maxHoursSinceLastObservation: number;
};
samples: {
totalObservations: number;
eligibleObservations: number;
excludedSynthetic: number;
successes: number;
failures: number;
successRate: number | null;
latencyCoverageRate: number | null;
latencyP95Ms: number | null;
nativeSettledRate: number | null;
distinctSessions: number;
lastObservedAt: number | null;
hoursSinceLastObservation: number | null;
sampled: boolean;
};
service: {
status: string | null;
latencyMs: number | null;
checkpointKind: string | null;
checkpointDurable: boolean | null;
execution: string | null;
};
checks: OrchestratorCanaryReadinessCheck[];
blockers: string[];
failureCodes: Array<{ code: string; count: number }>;
};
export type OrchestratorShadowRunDetail = {
native: {
runId: string;
@@ -386,6 +436,12 @@ export async function fetchOrchestratorShadowRuns(params: {
);
}
export async function fetchOrchestratorCanaryReadiness() {
return adminFetch<OrchestratorCanaryReadiness>(
'/admin-api/orchestrator/canary-readiness',
);
}
export async function fetchOrchestratorShadowRun(runId: string) {
return adminFetch<OrchestratorShadowRunDetail>(
`/admin-api/orchestrator/shadow-runs/${encodeURIComponent(runId)}`,
@@ -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>