mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(failover): 支持 Provider 级别故障转移规则,默认全部转移策略
- 新增 failover_rules 配置:支持 success_failover_patterns(成功响应匹配时转移) 和 error_stop_patterns(错误响应匹配时终止),支持按 status_code 过滤 - 修改默认转移策略:ErrorClassifier 不再返回 RAISE,所有错误默认继续转移 - TaskService 中客户端错误不再直接抛出,改为 break 继续尝试下一个候选 - 修复 proxy tunnel 连接/断连竞态:引入 per-node 锁和事件时间戳排序 - 优化 ProxyNode 状态判定:OFFLINE 统一由心跳超时判定,兼容多 worker 场景 - has_tunnel 改为纯检查方法,避免在 finally 块中误清理新注册连接 - Redis stream NOGROUP 异常自愈处理 - OAuthAccountDialog 输入框焦点样式补全
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import client from '../client'
|
||||
import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
FailoverRulesConfig,
|
||||
PoolAdvancedConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
ProxyConfig,
|
||||
@@ -48,6 +49,7 @@ export async function updateProvider(
|
||||
is_active: boolean
|
||||
claude_code_advanced: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced: PoolAdvancedConfig | null
|
||||
failover_rules: FailoverRulesConfig | null
|
||||
}>
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
|
||||
@@ -77,6 +79,7 @@ export async function createProvider(
|
||||
proxy?: ProxyConfig | null
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
}
|
||||
): Promise<{ id: string; name: string; message?: string }> {
|
||||
const response = await client.post('/api/admin/providers/', data)
|
||||
|
||||
@@ -471,6 +471,17 @@ export interface PoolAdvancedConfig {
|
||||
unschedulable_rules?: Array<Record<string, unknown>> | null
|
||||
}
|
||||
|
||||
export interface FailoverRuleItem {
|
||||
pattern: string
|
||||
description?: string
|
||||
status_codes?: number[]
|
||||
}
|
||||
|
||||
export interface FailoverRulesConfig {
|
||||
success_failover_patterns: FailoverRuleItem[]
|
||||
error_stop_patterns: FailoverRuleItem[]
|
||||
}
|
||||
|
||||
export interface ProviderWithEndpointsSummary {
|
||||
id: string
|
||||
name: string
|
||||
@@ -506,6 +517,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
failover_rules?: FailoverRulesConfig | null
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
created_at: string
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
title="故障转移规则"
|
||||
description="配置提供商级别的故障转移规则。默认所有错误都会触发转移,此处可自定义例外。"
|
||||
:icon="GitBranch"
|
||||
size="lg"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<div class="space-y-5 max-h-[60vh] overflow-y-auto px-0.5 py-0.5 -mx-0.5">
|
||||
<!-- 成功转移规则 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
成功转移规则
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
HTTP 200 但响应体匹配正则时,视为失败并触发转移
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="addRule('success')"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="successPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
>
|
||||
暂无规则
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in successPatterns"
|
||||
:key="'s-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="例如: relay:.*格式错误"
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('success', index)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误终止规则 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium">
|
||||
错误终止规则
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
HTTP 非 200 且响应体匹配正则时,停止转移并直接返回错误。可选填状态码缩小匹配范围
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="addRule('error')"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorPatterns.length === 0"
|
||||
class="text-xs text-muted-foreground px-3 py-4 border border-dashed rounded-lg text-center"
|
||||
>
|
||||
暂无规则
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(rule, index) in errorPatterns"
|
||||
:key="'e-' + index"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
:model-value="formatStatusCodes(rule.status_codes)"
|
||||
placeholder="状态码"
|
||||
size="sm"
|
||||
class="font-mono text-xs w-24 shrink-0"
|
||||
@update:model-value="(v: string | number) => rule.status_codes = parseStatusCodes(String(v))"
|
||||
/>
|
||||
<Input
|
||||
v-model="rule.pattern"
|
||||
placeholder="例如: content_policy_violation"
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
@click="removeRule('error', index)"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
} from '@/components/ui'
|
||||
import { GitBranch, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { FailoverRuleItem } from '@/api/endpoints/types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
provider: ProviderWithEndpointsSummary | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const saving = ref(false)
|
||||
|
||||
const successPatterns = ref<FailoverRuleItem[]>([])
|
||||
const errorPatterns = ref<FailoverRuleItem[]>([])
|
||||
|
||||
watch(() => [props.open, props.provider], () => {
|
||||
if (props.open && props.provider) {
|
||||
const rules = props.provider.failover_rules
|
||||
successPatterns.value = (rules?.success_failover_patterns || []).map(r => ({ ...r }))
|
||||
errorPatterns.value = (rules?.error_stop_patterns || []).map(r => ({ ...r }))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addRule(type: 'success' | 'error') {
|
||||
const rule: FailoverRuleItem = { pattern: '', description: '' }
|
||||
if (type === 'success') {
|
||||
successPatterns.value.push(rule)
|
||||
} else {
|
||||
errorPatterns.value.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(type: 'success' | 'error', index: number) {
|
||||
if (type === 'success') {
|
||||
successPatterns.value.splice(index, 1)
|
||||
} else {
|
||||
errorPatterns.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
function formatStatusCodes(codes: number[] | undefined): string {
|
||||
if (!codes || codes.length === 0) return ''
|
||||
return codes.join(',')
|
||||
}
|
||||
|
||||
function parseStatusCodes(input: string): number[] | undefined {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return undefined
|
||||
const codes = trimmed
|
||||
.split(/[,\s]+/)
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !isNaN(n) && n >= 100 && n <= 599)
|
||||
return codes.length > 0 ? codes : undefined
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.provider) return
|
||||
|
||||
// Validate patterns
|
||||
const allPatterns = [...successPatterns.value, ...errorPatterns.value]
|
||||
for (const rule of allPatterns) {
|
||||
if (!rule.pattern.trim()) {
|
||||
showError('正则表达式不能为空', '验证失败')
|
||||
return
|
||||
}
|
||||
try {
|
||||
new RegExp(rule.pattern)
|
||||
} catch {
|
||||
showError(`无效的正则表达式: ${rule.pattern}`, '验证失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const filteredSuccess = successPatterns.value.filter(r => r.pattern.trim())
|
||||
const filteredError = errorPatterns.value.filter(r => r.pattern.trim())
|
||||
|
||||
const hasRules = filteredSuccess.length > 0 || filteredError.length > 0
|
||||
|
||||
await updateProvider(props.provider.id, {
|
||||
failover_rules: hasRules
|
||||
? {
|
||||
success_failover_patterns: filteredSuccess,
|
||||
error_stop_patterns: filteredError,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
success('故障转移规则已保存')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '保存故障转移规则失败'), '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -138,7 +138,7 @@
|
||||
v-model="device.start_url"
|
||||
type="text"
|
||||
placeholder="https://your-org.awsapps.com/start"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@
|
||||
<ComboboxInput
|
||||
:display-value="() => device.region"
|
||||
placeholder="输入或选择 Region"
|
||||
class="w-full h-8 px-2 pr-7 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
class="w-full h-8 px-2 pr-7 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
@input="(e: Event) => regionSearch = (e.target as HTMLInputElement).value"
|
||||
@keydown.enter.prevent="onRegionEnter"
|
||||
@@ -193,7 +193,7 @@
|
||||
v-model="device.totp_secret"
|
||||
type="text"
|
||||
placeholder="Base32 secret, 如 JBSWY3DPEHPK3PXP"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono"
|
||||
class="w-full h-8 px-2 text-xs rounded-md border border-border bg-background font-mono focus:outline-none focus:ring-1 focus:ring-ring focus:relative focus:z-10"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,16 @@
|
||||
<Shuffle class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<span :title="hasFailoverRules ? '已配置故障转移规则(点击编辑)' : '配置故障转移规则'">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
:class="hasFailoverRules ? 'text-orange-500 dark:text-orange-400' : ''"
|
||||
@click="failoverRulesDialogOpen = true"
|
||||
>
|
||||
<GitBranch class="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
<Popover
|
||||
:open="providerProxyPopoverOpen"
|
||||
@update:open="handleProviderProxyPopoverToggle"
|
||||
@@ -1029,6 +1039,14 @@
|
||||
:key-id="antigravityQuotaDialogKey.id"
|
||||
@update:open="antigravityQuotaDialogOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- 故障转移规则弹窗 -->
|
||||
<FailoverRulesDialog
|
||||
:open="failoverRulesDialogOpen"
|
||||
:provider="provider ?? null"
|
||||
@update:open="failoverRulesDialogOpen = $event"
|
||||
@saved="loadProvider()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -1050,6 +1068,7 @@ import {
|
||||
BarChart3,
|
||||
ShieldX,
|
||||
Globe,
|
||||
GitBranch,
|
||||
} from 'lucide-vue-next'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
@@ -1084,6 +1103,7 @@ import EndpointFormDialog from '@/features/providers/components/EndpointFormDial
|
||||
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import AntigravityQuotaDialog from '@/features/providers/components/AntigravityQuotaDialog.vue'
|
||||
import FailoverRulesDialog from '@/features/providers/components/FailoverRulesDialog.vue'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import {
|
||||
@@ -1189,6 +1209,15 @@ const refreshingQuota = ref(false)
|
||||
const antigravityQuotaDialogOpen = ref(false)
|
||||
const antigravityQuotaDialogKey = ref<EndpointAPIKey | null>(null)
|
||||
|
||||
// 故障转移规则
|
||||
const failoverRulesDialogOpen = ref(false)
|
||||
const hasFailoverRules = computed(() => {
|
||||
const rules = provider.value?.failover_rules
|
||||
if (!rules) return false
|
||||
return (rules.success_failover_patterns?.length || 0) > 0
|
||||
|| (rules.error_stop_patterns?.length || 0) > 0
|
||||
})
|
||||
|
||||
// Provider 级别代理配置状态
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
const providerProxyPopoverOpen = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user