feat: add orchestrator canary readiness gate
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user