mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix: harden routing failover, model testing, and wallet queries
This commit is contained in:
@@ -501,10 +501,12 @@
|
||||
</section>
|
||||
|
||||
<RoutingFailoverPolicyEditor
|
||||
:key="draftGeneration"
|
||||
ref="routingFailoverPolicyEditor"
|
||||
:model-value="draft.config_json.default_policy"
|
||||
:disabled="saving"
|
||||
@update:model-value="draft.config_json.default_policy = { ...draft.config_json.default_policy, ...$event }"
|
||||
@update:model-value="updateRoutingFailoverPolicy"
|
||||
@pending-change="routingFailoverPending = $event"
|
||||
/>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border/60 p-4">
|
||||
@@ -890,7 +892,7 @@ import {
|
||||
type RoutingSortingScope,
|
||||
} from '@/features/routing/utils/routingPolicy'
|
||||
import { RoutingFailoverPolicyEditor, RoutingPriorityPolicyEditor } from '@/features/routing/components'
|
||||
import { validateRoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||
import { normalizeRoutingFailoverPolicy, validateRoutingFailoverPolicy, type RoutingFailoverPolicy } from '@/features/routing/utils/routingFailover'
|
||||
import {
|
||||
createRoutingGroup,
|
||||
deleteRoutingGroup,
|
||||
@@ -935,6 +937,7 @@ const groups = ref<RoutingGroupRecord[]>([])
|
||||
const selectedGroupId = ref<string | null>(null)
|
||||
const draft = ref<RoutingGroupDraft | null>(null)
|
||||
const routingFailoverPolicyEditor = ref<{ commitJsonDrafts: () => boolean } | null>(null)
|
||||
const routingFailoverPending = ref(false)
|
||||
const savedDraftSnapshot = ref<string | null>(null)
|
||||
const sortingScope = ref<RoutingSortingScope>('unified')
|
||||
const selectedPerModelName = ref<string | null>(null)
|
||||
@@ -952,7 +955,7 @@ const groupActionId = ref<string | null>(null)
|
||||
const draggedGroupId = ref<string | null>(null)
|
||||
const dragOverGroupId = ref<string | null>(null)
|
||||
const isCreating = ref(false)
|
||||
let draftGeneration = 0
|
||||
const draftGeneration = ref(0)
|
||||
|
||||
const switchModelTarget = ref<string | null>(null)
|
||||
const switchModelDialogOpen = ref(false)
|
||||
@@ -1098,7 +1101,8 @@ function paramToString(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function clearDraftState(): void {
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = null
|
||||
draft.value = null
|
||||
@@ -1113,7 +1117,8 @@ function clearDraftState(): void {
|
||||
|
||||
function selectGroup(group: RoutingGroupRecord): void {
|
||||
const normalized = normalizeRecord(group)
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = false
|
||||
selectedGroupId.value = normalized.id
|
||||
draft.value = buildDraft(normalized)
|
||||
@@ -1128,7 +1133,8 @@ function setDraftEnabled(value: boolean): void {
|
||||
}
|
||||
|
||||
function startCreate(): void {
|
||||
draftGeneration += 1
|
||||
draftGeneration.value += 1
|
||||
routingFailoverPending.value = false
|
||||
isCreating.value = true
|
||||
selectedGroupId.value = null
|
||||
draft.value = {
|
||||
@@ -1228,7 +1234,7 @@ const editingDirty = computed(() => {
|
||||
const draftDirty = computed(() => {
|
||||
if (!draft.value) return false
|
||||
if (isCreating.value) return true
|
||||
return savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
|
||||
return routingFailoverPending.value || savedDraftSnapshot.value !== draftSnapshotValue(draft.value)
|
||||
})
|
||||
|
||||
const canSaveDraft = computed(() => {
|
||||
@@ -1349,6 +1355,15 @@ function updateExecutionPolicy(
|
||||
})
|
||||
}
|
||||
|
||||
function updateRoutingFailoverPolicy(value: RoutingFailoverPolicy): void {
|
||||
if (!draft.value) return
|
||||
const patch = normalizeRoutingFailoverPolicy(value)
|
||||
Object.assign(draft.value.config_json.default_policy, patch)
|
||||
if (editingConfig.value) {
|
||||
Object.assign(editingConfig.value.default_policy, normalizeRoutingFailoverPolicy(patch))
|
||||
}
|
||||
}
|
||||
|
||||
function removePerModelPolicy(model: string): void {
|
||||
if (!draft.value) return
|
||||
if (perModelEditingActive.value && editingDirty.value) {
|
||||
@@ -1658,19 +1673,19 @@ async function saveDraft(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (routingFailoverPolicyEditor.value && !routingFailoverPolicyEditor.value.commitJsonDrafts()) return
|
||||
const config = cloneConfig(draft.value.config_json)
|
||||
const failoverError = validateRoutingFailoverPolicy(config.default_policy)
|
||||
const failoverError = validateRoutingFailoverPolicy(draft.value.config_json.default_policy)
|
||||
if (failoverError) {
|
||||
showError(failoverError)
|
||||
return
|
||||
}
|
||||
const config = cloneConfig(draft.value.config_json)
|
||||
if (sortingScope.value === 'per_model' && perModelPolicies.value.length === 0) {
|
||||
showError('按模型排序时至少选择一个模型')
|
||||
return
|
||||
}
|
||||
|
||||
const targetGroupId = draft.value.id ?? null
|
||||
const submittedGeneration = draftGeneration
|
||||
const submittedGeneration = draftGeneration.value
|
||||
const submittedSnapshot = draftSnapshotValue(draft.value)
|
||||
const wasCreating = isCreating.value || !draft.value.id
|
||||
saving.value = true
|
||||
@@ -1689,7 +1704,7 @@ async function saveDraft(): Promise<void> {
|
||||
? await createRoutingGroup(payload)
|
||||
: await updateRoutingGroup(targetGroupId, payload)
|
||||
|
||||
const sameDraftGeneration = draftGeneration === submittedGeneration
|
||||
const sameDraftGeneration = draftGeneration.value === submittedGeneration
|
||||
const stillEditingSubmittedDraft = wasCreating
|
||||
? sameDraftGeneration
|
||||
&& isCreateRoute.value
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, nextTick, reactive, type App } from 'vue'
|
||||
import RoutingProfiles from '../RoutingProfiles.vue'
|
||||
import { createEmptyRoutingGroupConfig, getModelScheduling, savePerModelRoutingConfig } from '@/features/routing/utils/routingPolicy'
|
||||
import type { RoutingGroupRecord, RoutingGroupUpdateRequest } from '@/api/routing-profiles'
|
||||
|
||||
const routingApi = vi.hoisted(() => ({
|
||||
listRoutingGroups: vi.fn(),
|
||||
updateRoutingGroup: vi.fn(),
|
||||
createRoutingGroup: vi.fn(),
|
||||
deleteRoutingGroup: vi.fn(),
|
||||
}))
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }))
|
||||
const route = reactive({ name: 'RoutingProfileDetail', params: { groupId: 'strategy-a' } })
|
||||
|
||||
vi.mock('@/api/routing-profiles', () => routingApi)
|
||||
vi.mock('@/api/global-models', () => ({ getGlobalModels: vi.fn().mockResolvedValue({ models: [] }) }))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ replace: vi.fn(), push: vi.fn() }) }))
|
||||
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn() } }))
|
||||
vi.mock('@/features/routing/components', async () => ({
|
||||
RoutingFailoverPolicyEditor: (await import('@/features/routing/components/RoutingFailoverPolicyEditor.vue')).default,
|
||||
RoutingPriorityPolicyEditor: { render: () => null },
|
||||
}))
|
||||
|
||||
const mounted: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function group(id: string): RoutingGroupRecord {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
enabled: true,
|
||||
is_system_default: false,
|
||||
sort_order: 0,
|
||||
config_json: createEmptyRoutingGroupConfig(),
|
||||
version: 1,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function mountPage(groups = [group('strategy-a'), group('strategy-b')]) {
|
||||
routingApi.listRoutingGroups.mockResolvedValue({ items: groups, total: groups.length })
|
||||
routingApi.updateRoutingGroup.mockImplementation(async (id: string, payload: RoutingGroupUpdateRequest) => ({
|
||||
...groups.find(entry => entry.id === id),
|
||||
...payload,
|
||||
version: 2,
|
||||
}))
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(RoutingProfiles)
|
||||
app.mount(root)
|
||||
mounted.push({ app, root })
|
||||
await flush()
|
||||
return root
|
||||
}
|
||||
|
||||
function element<T extends HTMLElement>(root: HTMLElement, selector: string): T {
|
||||
const found = root.querySelector<T>(selector)
|
||||
if (!found) throw new Error(`Missing element: ${selector}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function button(root: HTMLElement, label: string): HTMLButtonElement {
|
||||
return element(root, `button[aria-label="${label}"]`)
|
||||
}
|
||||
|
||||
async function input(root: HTMLElement, label: string, value: string) {
|
||||
const field = element<HTMLInputElement | HTMLTextAreaElement>(root, `[aria-label="${label}"]`)
|
||||
field.value = value
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function editJson(root: HTMLElement, section: string, value: string) {
|
||||
button(root, `切到${section} JSON`).click()
|
||||
await nextTick()
|
||||
await input(root, `${section} JSON`, value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
route.name = 'RoutingProfileDetail'
|
||||
route.params.groupId = 'strategy-a'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mounted.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('RoutingProfiles failover persistence', () => {
|
||||
it('enables Save for JSON-only edits and persists both sections together', async () => {
|
||||
const root = await mountPage()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"(?i)capacity"}]')
|
||||
await editJson(root, '错误终止规则', '[{"status_codes":[400,413]}]')
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules).toEqual({
|
||||
success_failover_patterns: [{ pattern: '(?i)capacity', status_codes: [] }],
|
||||
error_stop_patterns: [{ pattern: '', status_codes: [400, 413] }],
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not submit partial JSON drafts when either section is invalid', async () => {
|
||||
const root = await mountPage()
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"capacity"}]')
|
||||
await editJson(root, '错误终止规则', '{')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).not.toHaveBeenCalled()
|
||||
expect(root.querySelector('[role="alert"]')).not.toBeNull()
|
||||
await input(root, '错误终止规则 JSON', '[{"status_codes":[429]}]')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('discards local rule drafts when navigating to another strategy', async () => {
|
||||
const root = await mountPage()
|
||||
await editJson(root, '成功转移规则', '[{"pattern":"only-strategy-a"}]')
|
||||
route.params.groupId = 'strategy-b'
|
||||
await flush()
|
||||
expect(root.querySelector('textarea[aria-label="成功转移规则 JSON"]')).toBeNull()
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
await input(root, '全局最大转移次数', '3')
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][0]).toBe('strategy-b')
|
||||
expect(routingApi.updateRoutingGroup.mock.calls[0][1].config_json.default_policy.failover_rules.success_failover_patterns).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves global failover edits while saving an independently edited model', async () => {
|
||||
const strategy = group('strategy-a')
|
||||
strategy.config_json = savePerModelRoutingConfig(strategy.config_json, 'model-a')
|
||||
const root = await mountPage([strategy])
|
||||
const configured = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '已配置')
|
||||
configured?.click()
|
||||
await nextTick()
|
||||
const loadBalance = [...root.querySelectorAll<HTMLButtonElement>('button')].find(control => control.textContent?.trim() === '负载均衡')
|
||||
if (!loadBalance) throw new Error('Missing model scheduling control')
|
||||
loadBalance.click()
|
||||
await nextTick()
|
||||
await input(root, '全局最大转移次数', '5')
|
||||
button(root, '添加错误终止规则').click()
|
||||
await nextTick()
|
||||
await input(root, '终止规则 1 状态码', '429')
|
||||
expect(button(root, '保存').disabled).toBe(true)
|
||||
element<HTMLButtonElement>(root, 'button[title="保存到草稿"]').click()
|
||||
await nextTick()
|
||||
expect(button(root, '保存').disabled).toBe(false)
|
||||
button(root, '保存').click()
|
||||
await flush()
|
||||
expect(routingApi.updateRoutingGroup).toHaveBeenCalledTimes(1)
|
||||
const saved = routingApi.updateRoutingGroup.mock.calls[0][1].config_json
|
||||
expect(saved.default_policy.max_transfer_count).toBe(5)
|
||||
expect(saved.default_policy.failover_rules.error_stop_patterns).toEqual([{ pattern: '', status_codes: [429] }])
|
||||
expect(getModelScheduling(saved, 'model-a').scheduling_mode).toBe('load_balance')
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user