refactor(runner): 收敛上游失败决策与轮转策略

将同平台重试、跨平台动作、健康副作用和冷却排队统一到单一失败决策,避免旧降级策略与 failover 重复执行。\n\n增加事务级单源保护、候选实时刷新、冷却错误契约、管理接口严格校验及兼容策略只读展示。\n\n验证:真实 Gateway HTTP/PostgreSQL 接受测试 10 项通过;go test ./...、pnpm openapi、pnpm lint、pnpm test、pnpm build 均通过。
This commit is contained in:
2026-07-27 23:50:17 +08:00
parent 046c16fc69
commit 31565af07a
19 changed files with 1988 additions and 307 deletions
@@ -1,13 +1,18 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Select as AntSelect } from 'antd';
import { Gauge, Pencil, Plus, RotateCcw, Route, Save, ShieldCheck, Trash2 } from 'lucide-react';
import type { GatewayRunnerPolicy, GatewayRunnerPolicyUpsertRequest, RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import type {
GatewayRunnerPolicy,
GatewayRunnerPolicyUpsertRequest,
RunnerFailoverAction,
RunnerFailoverTarget,
RuntimePolicySet,
RuntimePolicySetUpsertRequest,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select, Tabs, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type RuntimePanelTab = 'model' | 'runner';
type RunnerFailoverAction = 'next' | 'cooldown_and_next' | 'demote_and_next' | 'disable_and_next' | 'stop';
type RunnerFailoverTarget = 'platform' | 'model';
type RunnerActionRule = {
id: string;
enabled: boolean;
@@ -159,6 +164,9 @@ export function RuntimePoliciesPanel(props: {
event.preventDefault();
setLocalError('');
try {
if (runnerForm.actionRules.some((rule) => !hasRunnerActionRuleCondition(rule))) {
throw new Error('每条故障动作规则至少需要一个匹配条件');
}
await props.onSaveRunnerPolicy(runnerFormToPayload(runnerForm));
} catch (err) {
setLocalError(err instanceof Error ? err.message : '全局调度策略保存失败');
@@ -171,7 +179,7 @@ export function RuntimePoliciesPanel(props: {
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText"></p>
<p className="mutedText"> action rule </p>
</div>
{activeTab === 'model' && <Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
@@ -286,14 +294,14 @@ export function RuntimePoliciesPanel(props: {
</section>
<section className="runtimePolicySection spanTwo">
<header><strong></strong><span></span></header>
<header><strong></strong><span> Runner action rule </span></header>
<div className="runtimePolicyRows">
<Toggle checked={form.autoDisableEnabled} label="启用自动禁用" onChange={(checked) => setForm({ ...form, autoDisableEnabled: checked })} />
<Label><Input value={form.autoDisableThreshold} inputMode="numeric" onChange={(event) => setForm({ ...form, autoDisableThreshold: event.target.value })} /></Label>
<KeywordField label="自动禁用关键词" value={form.autoDisableKeywords} onChange={(value) => setForm({ ...form, autoDisableKeywords: value })} />
<Toggle checked={form.degradeEnabled} label="启用优先级降级" onChange={(checked) => setForm({ ...form, degradeEnabled: checked })} />
<Label><Input value={form.degradeCooldownSeconds} inputMode="numeric" onChange={(event) => setForm({ ...form, degradeCooldownSeconds: event.target.value })} /></Label>
<KeywordField label="降级关键词" value={form.degradeKeywords} onChange={(value) => setForm({ ...form, degradeKeywords: value })} />
<Toggle checked={form.autoDisableEnabled} disabled label="自动禁用" onChange={() => undefined} />
<Label><Input disabled value={form.autoDisableThreshold} inputMode="numeric" /></Label>
<KeywordField disabled label="自动禁用关键词" value={form.autoDisableKeywords} onChange={() => undefined} />
<Toggle checked={form.degradeEnabled} disabled label="旧模型冷却" onChange={() => undefined} />
<Label><Input disabled value={form.degradeCooldownSeconds} inputMode="numeric" /></Label>
<KeywordField disabled label="旧模型冷却关键词" value={form.degradeKeywords} onChange={() => undefined} />
</div>
</section>
@@ -313,10 +321,10 @@ export function RuntimePoliciesPanel(props: {
);
}
function Toggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
function Toggle(props: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
<input type="checkbox" checked={props.checked} disabled={props.disabled} onChange={(event) => props.onChange(event.target.checked)} />
<span><strong>{props.label}</strong></span>
</label>
);
@@ -404,7 +412,7 @@ function RunnerPolicyEditor(props: {
<Label>
<Input value={props.form.maxDurationSeconds} inputMode="numeric" onChange={(event) => patch({ maxDurationSeconds: event.target.value })} />
<span className="runtimeFieldHint"> 600 </span>
<span className="runtimeFieldHint"> 600 </span>
</Label>
<div className="runnerToggleField">
<Toggle
@@ -412,7 +420,7 @@ function RunnerPolicyEditor(props: {
label="启用单一源保护"
onChange={(checked) => patch({ singleSourceProtectionEnabled: checked })}
/>
<span className="runtimeFieldHint"> 1 </span>
<span className="runtimeFieldHint"></span>
</div>
<div className="spanTwo runnerRuleWorkbench">
<aside className="runnerRuleList">
@@ -500,7 +508,7 @@ function RunnerPolicyEditor(props: {
);
}
function KeywordField(props: { label: string; value: string[]; options?: Array<{ label: string; value: string }>; wide?: boolean; onChange: (value: string[]) => void }) {
function KeywordField(props: { disabled?: boolean; label: string; value: string[]; options?: Array<{ label: string; value: string }>; wide?: boolean; onChange: (value: string[]) => void }) {
const known = new Set((props.options ?? []).map((item) => item.value));
const options = [
...(props.options ?? []),
@@ -514,6 +522,7 @@ function KeywordField(props: { label: string; value: string[]; options?: Array<{
<AntSelect
allowClear
className="runtimeTagInput"
disabled={props.disabled}
maxTagCount="responsive"
mode="tags"
options={options}
@@ -611,11 +620,11 @@ function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm
retryAllowKeywords: ['rate_limit', 'timeout', 'server_error', 'network', '429', '5xx'],
retryDenyKeywords: ['invalid_api_key', 'insufficient_quota', 'billing_not_active', 'permission_denied'],
autoDisableEnabled: false,
autoDisableThreshold: '3',
autoDisableKeywords: ['invalid_api_key', 'account_deactivated', 'permission_denied', 'billing_not_active'],
degradeEnabled: true,
autoDisableThreshold: '1',
autoDisableKeywords: [],
degradeEnabled: false,
degradeCooldownSeconds: '300',
degradeKeywords: ['rate_limit', 'quota', 'timeout', 'temporarily_unavailable', 'overloaded'],
degradeKeywords: [],
metadataJson: '{}',
status: 'active',
};