merge: integrate langgraph execution runtime
# Conflicts: # agent-run-gateway.test.mjs # capabilities.mjs # package.json # server.mjs
This commit is contained in:
@@ -14,6 +14,7 @@ import { BillingPage } from './pages/admin/BillingPage';
|
||||
import { AgentCodeRunPage } from './pages/admin/AgentCodeRunPage';
|
||||
import { WechatPage } from './pages/admin/WechatPage';
|
||||
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
||||
import { OrchestratorPage } from './pages/admin/OrchestratorPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -48,6 +49,7 @@ export function App() {
|
||||
<Route path="agent-code-run" element={<AgentCodeRunPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="policy" element={<SystemPolicyPage />} />
|
||||
<Route path="orchestrator" element={<OrchestratorPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -198,6 +198,290 @@ export type SystemDisclosurePolicyState = {
|
||||
lastRefreshError?: string | null;
|
||||
};
|
||||
|
||||
export type OrchestratorMode = 'off' | 'shadow' | 'canary' | 'active';
|
||||
|
||||
export type OrchestratorConfig = {
|
||||
mode: OrchestratorMode;
|
||||
primaryEngine: string;
|
||||
fallbackEngine: string;
|
||||
serviceUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
rolloutPercent: number;
|
||||
userAllowlist: string[];
|
||||
workflowAllowlist: string[];
|
||||
fallbackToNative: boolean;
|
||||
requireHealthy: boolean;
|
||||
executionEnabled: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorRuntime = {
|
||||
killSwitch: boolean;
|
||||
configured: boolean;
|
||||
effective: boolean;
|
||||
reason: string | null;
|
||||
executesLangGraph: boolean;
|
||||
plansLangGraph: boolean;
|
||||
shadowsLangGraph: boolean;
|
||||
executionHandoff: {
|
||||
implemented: boolean;
|
||||
requested: boolean;
|
||||
enabled: boolean;
|
||||
reason: string | null;
|
||||
environmentGate: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type OrchestratorEngineDescriptor = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
configured: boolean;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type OrchestratorExecutorDescriptor = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
dispatchImplemented: boolean;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type OrchestratorConfigState = {
|
||||
config: OrchestratorConfig;
|
||||
configVersion: number;
|
||||
updatedBy: string | null;
|
||||
updatedAt: number | null;
|
||||
source: string;
|
||||
runtime: OrchestratorRuntime;
|
||||
engines: OrchestratorEngineDescriptor[];
|
||||
executors: OrchestratorExecutorDescriptor[];
|
||||
};
|
||||
|
||||
export type OrchestratorServiceHealth = {
|
||||
checkedAt: number;
|
||||
ok: boolean;
|
||||
status: 'healthy' | 'unhealthy' | 'unconfigured' | 'timeout' | 'unreachable' | 'fetch_unavailable';
|
||||
latencyMs: number;
|
||||
httpStatus: number | null;
|
||||
details: {
|
||||
service: string | null;
|
||||
checkpoint: { kind?: string; durable?: boolean } | null;
|
||||
execution: string | null;
|
||||
executorGateway: {
|
||||
dispatchImplemented: boolean;
|
||||
executionEnabled: boolean;
|
||||
store: { kind: string | null; durable: boolean } | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
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;
|
||||
synthetic: boolean;
|
||||
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 OrchestratorExecutionPlan = {
|
||||
eventId: string;
|
||||
runId: string;
|
||||
requestId: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
nativeStatus: string;
|
||||
nativeAttempts: number;
|
||||
mode: string | null;
|
||||
candidateEngine: string;
|
||||
effectiveEngine: string;
|
||||
fallbackEngine: string;
|
||||
reason: string | null;
|
||||
candidateReason: string | null;
|
||||
configVersion: number | null;
|
||||
bucket: number | null;
|
||||
taskType: string | null;
|
||||
dryRun: boolean;
|
||||
handoffAllowed: boolean;
|
||||
plannedAt: number;
|
||||
nativeCompletedAt: number | null;
|
||||
};
|
||||
|
||||
export type OrchestratorExecutionPlanMetrics = {
|
||||
decisions: number;
|
||||
candidateSelections: number;
|
||||
candidateSelectionRate: number | null;
|
||||
nativeSelections: number;
|
||||
nativeSucceeded: number;
|
||||
nativeFailed: number;
|
||||
nativeSettledRate: number | null;
|
||||
handoffAllowed: number;
|
||||
distinctSessions: number;
|
||||
lastPlannedAt: number | null;
|
||||
candidateReasons: Array<{ value: string; count: number }>;
|
||||
taskTypes: Array<{ value: string; count: number }>;
|
||||
sampled: boolean;
|
||||
};
|
||||
|
||||
export type OrchestratorExecutionPlanList = {
|
||||
generatedAt: number;
|
||||
window: { hours: number; from: number };
|
||||
metrics: OrchestratorExecutionPlanMetrics;
|
||||
plans: OrchestratorExecutionPlan[];
|
||||
};
|
||||
|
||||
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;
|
||||
executorJobStoreKind: string | null;
|
||||
executorJobStoreDurable: boolean | null;
|
||||
execution: string | null;
|
||||
};
|
||||
checks: OrchestratorCanaryReadinessCheck[];
|
||||
blockers: string[];
|
||||
failureCodes: Array<{ code: string; count: number }>;
|
||||
};
|
||||
|
||||
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;
|
||||
executorJob: {
|
||||
available: boolean;
|
||||
state: {
|
||||
version?: string;
|
||||
jobId?: string;
|
||||
executor?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
attempts?: number;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
} | null;
|
||||
events: Array<{
|
||||
version?: string;
|
||||
eventId?: string;
|
||||
jobId?: string;
|
||||
sequence?: number;
|
||||
type?: string;
|
||||
timestamp?: number;
|
||||
data?: Record<string, unknown> | null;
|
||||
}>;
|
||||
error: { code: string; message: string } | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminSummary() {
|
||||
@@ -217,6 +501,63 @@ export async function updateSystemDisclosurePolicy(config: SystemDisclosurePolic
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Workflow Orchestrator ───────────────────────────────────────────────────
|
||||
|
||||
export async function fetchOrchestratorConfig() {
|
||||
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config');
|
||||
}
|
||||
|
||||
export async function updateOrchestratorConfig(config: OrchestratorConfig) {
|
||||
return adminFetch<OrchestratorConfigState>('/admin-api/orchestrator/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
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 fetchOrchestratorExecutionPlans(params: {
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
selection?: 'all' | 'candidate' | 'native';
|
||||
} = {}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params.hours) query.set('hours', String(params.hours));
|
||||
if (params.limit) query.set('limit', String(params.limit));
|
||||
if (params.selection) query.set('selection', params.selection);
|
||||
return adminFetch<OrchestratorExecutionPlanList>(
|
||||
`/admin-api/orchestrator/execution-plans?${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAdminUsers(params: {
|
||||
|
||||
@@ -7,6 +7,7 @@ const links = [
|
||||
{ to: '/admin/wechat', label: '服务号管理' },
|
||||
{ to: '/admin/agent-code-run', label: 'Code Run' },
|
||||
{ to: '/admin/policy', label: '策略中心' },
|
||||
{ to: '/admin/orchestrator', label: '任务编排' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
@@ -14,7 +15,7 @@ export function AdminLayout() {
|
||||
<div className="layout">
|
||||
<header>
|
||||
<h1>超级管理后台</h1>
|
||||
<p style={{ color: '#68716c' }}>用户、计费与服务号</p>
|
||||
<p style={{ color: '#68716c' }}>用户、计费、策略与任务编排</p>
|
||||
</header>
|
||||
<nav className="nav">
|
||||
<NavLink
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchOrchestratorExecutionPlans,
|
||||
type OrchestratorExecutionPlanList,
|
||||
} 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 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 OrchestratorExecutionPlanPanel() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const [selection, setSelection] = useState<'all' | 'candidate' | 'native'>('all');
|
||||
const [data, setData] = useState<OrchestratorExecutionPlanList | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await fetchOrchestratorExecutionPlans({
|
||||
hours,
|
||||
selection,
|
||||
limit: 100,
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Dry-run 决策加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [hours, selection]);
|
||||
|
||||
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 }}>执行路由 Dry-run</h3>
|
||||
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
Shadow 同时模拟 Canary 候选路由;所有记录的实际执行引擎仍必须是 Native。
|
||||
</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={selection}
|
||||
onChange={(event) => setSelection(event.target.value as typeof selection)}
|
||||
>
|
||||
<option value="all">全部决策</option>
|
||||
<option value="candidate">命中 LangGraph 候选</option>
|
||||
<option value="native">未命中,保持 Native</option>
|
||||
</select>
|
||||
<button type="button" className="btn secondary" onClick={() => void load()} disabled={loading}>
|
||||
{loading ? '刷新中…' : '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{(metrics?.handoffAllowed ?? 0) > 0 ? (
|
||||
<p className="alert">
|
||||
检测到 {metrics?.handoffAllowed} 条 handoffAllowed=true 记录,请立即检查执行闸门。
|
||||
</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?.decisions ?? '—'}</div>
|
||||
</div>
|
||||
<div style={metricCardStyle}>
|
||||
<small>候选命中率</small>
|
||||
<div style={{ fontSize: 24, marginTop: 4 }}>
|
||||
{formatPercent(metrics?.candidateSelectionRate ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={metricCardStyle}>
|
||||
<small>LangGraph / Native 候选</small>
|
||||
<div style={{ fontSize: 20, marginTop: 7 }}>
|
||||
{metrics ? `${metrics.candidateSelections} / ${metrics.nativeSelections}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={metricCardStyle}>
|
||||
<small>Native 终态覆盖率</small>
|
||||
<div style={{ fontSize: 24, marginTop: 4 }}>
|
||||
{formatPercent(metrics?.nativeSettledRate ?? null)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={metricCardStyle}>
|
||||
<small>Native 成功 / 失败</small>
|
||||
<div style={{ fontSize: 20, marginTop: 7 }}>
|
||||
{metrics ? `${metrics.nativeSucceeded} / ${metrics.nativeFailed}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={metricCardStyle}>
|
||||
<small>真实会话</small>
|
||||
<div style={{ fontSize: 24, marginTop: 4 }}>{metrics?.distinctSessions ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metrics ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<strong>选择原因</strong>
|
||||
<p style={{ margin: '6px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
{metrics.candidateReasons.length
|
||||
? metrics.candidateReasons.map((item) => `${item.value} × ${item.count}`).join(',')
|
||||
: '暂无数据'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>任务类型</strong>
|
||||
<p style={{ margin: '6px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
{metrics.taskTypes.length
|
||||
? metrics.taskTypes.map((item) => `${item.value} × ${item.count}`).join(',')
|
||||
: '暂无数据'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{metrics?.sampled ? (
|
||||
<p className="warn">当前指标基于最近 5000 条 Dry-run 决策采样。</p>
|
||||
) : null}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 980 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '1px solid #dce4df' }}>
|
||||
<th style={{ padding: 8 }}>时间</th>
|
||||
<th style={{ padding: 8 }}>Run</th>
|
||||
<th style={{ padding: 8 }}>模式</th>
|
||||
<th style={{ padding: 8 }}>候选 → 实际</th>
|
||||
<th style={{ padding: 8 }}>原因 / Bucket</th>
|
||||
<th style={{ padding: 8 }}>任务类型</th>
|
||||
<th style={{ padding: 8 }}>Native</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.plans ?? []).map((plan) => (
|
||||
<tr key={plan.eventId} style={{ borderBottom: '1px solid #edf1ef' }}>
|
||||
<td style={{ padding: 8, whiteSpace: 'nowrap' }}>{formatTime(plan.plannedAt)}</td>
|
||||
<td style={{ padding: 8 }} title={plan.runId}>{shortId(plan.runId)}</td>
|
||||
<td style={{ padding: 8 }}>{plan.mode ?? '—'}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
<strong style={{ color: plan.candidateEngine === 'native' ? '#68716c' : '#8a5a16' }}>
|
||||
{plan.candidateEngine}
|
||||
</strong>
|
||||
{' → '}
|
||||
{plan.effectiveEngine}
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
{plan.candidateReason ?? plan.reason ?? '—'}
|
||||
<small style={{ display: 'block', color: '#68716c' }}>
|
||||
bucket {plan.bucket ?? '—'} · config v{plan.configVersion ?? '—'}
|
||||
</small>
|
||||
</td>
|
||||
<td style={{ padding: 8 }}>{plan.taskType ?? '—'}</td>
|
||||
<td style={{ padding: 8 }}>
|
||||
{plan.nativeStatus}
|
||||
<small style={{ display: 'block', color: '#68716c' }}>
|
||||
attempts {plan.nativeAttempts}
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && !data?.plans.length ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 16, textAlign: 'center', color: '#68716c' }}>
|
||||
当前时间范围内没有 Dry-run 路由决策。
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchOrchestratorConfig,
|
||||
fetchOrchestratorRuntime,
|
||||
updateOrchestratorConfig,
|
||||
type OrchestratorConfig,
|
||||
type OrchestratorConfigState,
|
||||
type OrchestratorServiceHealth,
|
||||
} from '../../api/admin';
|
||||
import { OrchestratorExecutionPlanPanel } from './OrchestratorExecutionPlanPanel';
|
||||
import { OrchestratorShadowPanel } from './OrchestratorShadowPanel';
|
||||
|
||||
function listToText(values: string[]) {
|
||||
return values.join('\n');
|
||||
}
|
||||
|
||||
function textToList(value: string) {
|
||||
return [...new Set(value.split(/\r?\n|,/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未保存';
|
||||
}
|
||||
|
||||
const RUNTIME_REASON: Record<string, string> = {
|
||||
mode_off: '当前为关闭模式,所有任务继续由 Native Agent Run 执行。',
|
||||
service_url_missing: '尚未配置 Orchestrator 服务地址,LangGraph 不会接管任务。',
|
||||
kill_switch: '环境级紧急熔断已开启,强制回退 Native。',
|
||||
execution_gate_disabled: 'Canary / Active 仅生成 Dry-run 候选决策;执行交接闸门仍被硬锁定,Native 是唯一执行者。',
|
||||
environment_execution_gate_disabled: 'memindadm 已请求执行交接,但环境级执行闸门仍关闭;当前继续 Dry-run。',
|
||||
};
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const [state, setState] = useState<OrchestratorConfigState | null>(null);
|
||||
const [draft, setDraft] = useState<OrchestratorConfig | null>(null);
|
||||
const [userAllowlist, setUserAllowlist] = useState('');
|
||||
const [workflowAllowlist, setWorkflowAllowlist] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [serviceHealth, setServiceHealth] = useState<OrchestratorServiceHealth | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchOrchestratorConfig();
|
||||
setState(result);
|
||||
setDraft(result.config);
|
||||
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const checkService = async () => {
|
||||
setChecking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchOrchestratorRuntime();
|
||||
setServiceHealth(result.serviceHealth);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '服务检查失败');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedDraft = useMemo(() => draft ? {
|
||||
...draft,
|
||||
userAllowlist: textToList(userAllowlist),
|
||||
workflowAllowlist: textToList(workflowAllowlist),
|
||||
} : null, [draft, userAllowlist, workflowAllowlist]);
|
||||
|
||||
const dirty = Boolean(
|
||||
state && normalizedDraft && JSON.stringify(normalizedDraft) !== JSON.stringify(state.config),
|
||||
);
|
||||
|
||||
const save = async () => {
|
||||
if (!normalizedDraft) return;
|
||||
if (
|
||||
['canary', 'active'].includes(normalizedDraft.mode)
|
||||
&& !normalizedDraft.serviceUrl
|
||||
) {
|
||||
setError('Canary / Active 模式必须先配置 Orchestrator 服务地址。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalizedDraft.executionEnabled
|
||||
&& !window.confirm('确认请求执行交接?仅当环境级闸门、服务健康、用户/工作流灰度范围同时通过时才会生效。')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await updateOrchestratorConfig(normalizedDraft);
|
||||
setState(result);
|
||||
setDraft(result.config);
|
||||
setUserAllowlist(listToText(result.config.userAllowlist));
|
||||
setWorkflowAllowlist(listToText(result.config.workflowAllowlist));
|
||||
setNotice(`Orchestrator 配置 v${result.configVersion} 已保存。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>加载中…</p>;
|
||||
if (!state || !draft) return <p className="alert">{error ?? 'Orchestrator 配置不可用'}</p>;
|
||||
|
||||
const runtimeMessage = state.runtime.reason
|
||||
? (RUNTIME_REASON[state.runtime.reason] ?? state.runtime.reason)
|
||||
: state.runtime.shadowsLangGraph
|
||||
? 'Shadow 已生效:Native 继续执行,LangGraph 只观察和生成决策。'
|
||||
: state.runtime.executesLangGraph
|
||||
? '执行交接已开启。'
|
||||
: '配置有效。';
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Workflow Orchestrator</h2>
|
||||
<p style={{ color: '#68716c', marginBottom: 0 }}>
|
||||
LangGraph 通过通用 WorkflowEngine 协议接入。关闭和 Shadow 模式不会改变现有 Native Agent Run 执行路径。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
{notice ? <p style={{ color: '#2f6f57', fontSize: 13 }}>{notice}</p> : null}
|
||||
|
||||
<div className="card grid">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<strong>配置版本</strong>
|
||||
<p style={{ marginBottom: 0 }}>v{state.configVersion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>运行状态</strong>
|
||||
<p style={{ marginBottom: 0 }}>{state.runtime.effective ? '有效' : '安全回退'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>配置来源</strong>
|
||||
<p style={{ marginBottom: 0 }}>{state.source}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>更新时间</strong>
|
||||
<p style={{ marginBottom: 0 }}>{formatTime(state.updatedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>执行交接</strong>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{state.runtime.executionHandoff.enabled ? '已开启' : 'Dry-run 锁定'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className={state.runtime.reason === 'kill_switch' ? 'alert' : 'warn'} style={{ marginBottom: 0 }}>
|
||||
{runtimeMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<div>
|
||||
<h3 style={{ margin: 0 }}>Executor Gateway</h3>
|
||||
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
Worker Queue 已支持租约、心跳、重试、取消和产物引用。执行器默认关闭,实际启用状态以服务健康检查为准。
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
|
||||
{(state.executors ?? []).map((executor) => (
|
||||
<div
|
||||
key={executor.id}
|
||||
style={{ border: '1px solid #e4e9e6', borderRadius: 10, padding: 12 }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong>{executor.label}</strong>
|
||||
<span style={{ color: executor.enabled ? '#2f6f57' : '#8a5a16', fontSize: 13 }}>
|
||||
{executor.status}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0 0', color: '#68716c', fontSize: 13 }}>
|
||||
{executor.capabilities.join(' · ') || '无能力声明'}
|
||||
</p>
|
||||
<small style={{ display: 'block', marginTop: 8 }}>
|
||||
dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'}
|
||||
</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrchestratorShadowPanel />
|
||||
|
||||
<OrchestratorExecutionPlanPanel />
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>路由模式</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
<label>
|
||||
<strong>运行模式</strong>
|
||||
<select
|
||||
value={draft.mode}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
mode: event.target.value as OrchestratorConfig['mode'],
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<option value="off">Off — Native only</option>
|
||||
<option value="shadow">Shadow — Native 执行,LangGraph 观察</option>
|
||||
<option value="canary">Canary — 白名单/百分比灰度</option>
|
||||
<option value="active">Active — 工作流白名单接管</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<strong>主执行引擎</strong>
|
||||
<select
|
||||
value={draft.primaryEngine}
|
||||
onChange={(event) => setDraft({ ...draft, primaryEngine: event.target.value })}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
{state.engines.map((engine) => (
|
||||
<option key={engine.id} value={engine.id}>
|
||||
{engine.label}{engine.configured ? '' : '(未配置)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<strong>灰度百分比</strong>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draft.rolloutPercent}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
rolloutPercent: Number(event.target.value),
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>服务连接</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span>
|
||||
<strong>实时健康状态:</strong>
|
||||
{serviceHealth
|
||||
? `${serviceHealth.ok ? '健康' : '不可用'}(${serviceHealth.status},${serviceHealth.latencyMs} ms)`
|
||||
: '尚未检查'}
|
||||
{serviceHealth?.details?.checkpoint
|
||||
? ` · checkpoint ${serviceHealth.details.checkpoint.kind ?? 'unknown'}`
|
||||
: ''}
|
||||
{serviceHealth?.details?.executorGateway?.store
|
||||
? ` · executor jobs ${serviceHealth.details.executorGateway.store.kind ?? 'unknown'}`
|
||||
: ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void checkService()}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? '检查中…' : '测试连接'}
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
<strong>Orchestrator URL</strong>
|
||||
<input
|
||||
value={draft.serviceUrl}
|
||||
placeholder="http://127.0.0.1:8093"
|
||||
onChange={(event) => setDraft({ ...draft, serviceUrl: event.target.value })}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<strong>请求超时(毫秒)</strong>
|
||||
<input
|
||||
type="number"
|
||||
min={500}
|
||||
max={60000}
|
||||
value={draft.requestTimeoutMs}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
requestTimeoutMs: Number(event.target.value),
|
||||
})}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.requireHealthy}
|
||||
onChange={(event) => setDraft({ ...draft, requireHealthy: event.target.checked })}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
接管任务前要求 Orchestrator 健康
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.fallbackToNative}
|
||||
onChange={(event) => setDraft({ ...draft, fallbackToNative: event.target.checked })}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
Orchestrator 不可用时回退 Native
|
||||
</label>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
border: '1px solid #e6c98f',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
background: '#fff9ed',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.executionEnabled}
|
||||
onChange={(event) => setDraft({ ...draft, executionEnabled: event.target.checked })}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
请求启用 LangGraph 执行交接(仍受环境闸门、Kill Switch、健康检查和灰度范围约束)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card grid">
|
||||
<h3 style={{ margin: 0 }}>灰度范围</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<label>
|
||||
<strong>工作流白名单</strong>
|
||||
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||
每行一个。未来启用 Active 时也只接管这里列出的工作流。
|
||||
</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={workflowAllowlist}
|
||||
onChange={(event) => setWorkflowAllowlist(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<strong>用户白名单</strong>
|
||||
<span style={{ display: 'block', color: '#68716c', fontSize: 12, margin: '4px 0 8px' }}>
|
||||
每行一个用户 ID;Canary 下优先于百分比。
|
||||
</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={userAllowlist}
|
||||
onChange={(event) => setUserAllowlist(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" className="btn secondary" onClick={() => void load()} disabled={saving}>
|
||||
放弃修改
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => void save()} disabled={saving || !dirty}>
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchOrchestratorCanaryReadiness,
|
||||
fetchOrchestratorShadowRun,
|
||||
fetchOrchestratorShadowRuns,
|
||||
type OrchestratorCanaryReadiness,
|
||||
type OrchestratorCanaryReadinessCheck,
|
||||
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 readinessCheckLabels: Record<string, string> = {
|
||||
shadow_mode: '当前保持 Shadow',
|
||||
service_healthy: '服务健康',
|
||||
durable_checkpoint: '持久化 checkpoint',
|
||||
durable_executor_job_store: '持久化 Executor Job Store',
|
||||
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,
|
||||
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 [readiness, setReadiness] = useState<OrchestratorCanaryReadiness | 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 {
|
||||
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 {
|
||||
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}
|
||||
|
||||
{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>
|
||||
<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 ?? '—'}
|
||||
{run.synthetic ? '(smoke)' : ''}
|
||||
<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}
|
||||
{selected.remote.executorJob.error ? (
|
||||
<p className="alert">
|
||||
Executor Job:{selected.remote.executorJob.error.code}
|
||||
{' '}
|
||||
{selected.remote.executorJob.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>
|
||||
{selected.remote.executorJob.available ? (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid #edf1ef',
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Executor Job 状态</strong>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 12 }}>
|
||||
{JSON.stringify(selected.remote.executorJob.state, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Executor Job 事件</strong>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 12 }}>
|
||||
{JSON.stringify(selected.remote.executorJob.events, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user