mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy): 节点状态简化、连接事件记录、可靠性指标与批量删除
- 移除 UNHEALTHY 中间状态,节点状态简化为 ONLINE/OFFLINE - 新增 proxy_node_events 表记录 tunnel 连接/断开/错误事件 - 新增 failed_requests/dns_failures/stream_errors 可靠性指标(增量累加) - tunnel 重连改为固定 1s 延迟,移除指数退避逻辑 - resolver/service 改为以 TunnelManager 内存状态判断节点可用性,避免 DB 竞态 - 修正 Claude cache_control 字段格式,使用 ttl 字段控制缓存时长 - 移除前端手动勾选 capability 的 UI,改为从价格配置自动推断 - 新增全局模型批量删除 API,替换前端并行单个删除
This commit is contained in:
@@ -68,6 +68,16 @@ export async function deleteGlobalModel(
|
||||
await client.delete(`/api/admin/models/global/${id}`, { params: { force } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除 GlobalModel
|
||||
*/
|
||||
export async function batchDeleteGlobalModels(
|
||||
ids: string[]
|
||||
): Promise<{ success_count: number; failed: Array<{ id: string; error: string }> }> {
|
||||
const response = await client.post('/api/admin/models/global/batch-delete', { ids })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量为 GlobalModel 添加关联提供商
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ProxyNode {
|
||||
ip: string
|
||||
port: number
|
||||
region: string | null
|
||||
status: 'online' | 'unhealthy' | 'offline'
|
||||
status: 'online' | 'offline'
|
||||
is_manual: boolean
|
||||
tunnel_mode: boolean
|
||||
tunnel_connected: boolean
|
||||
@@ -34,10 +34,20 @@ export interface ProxyNode {
|
||||
active_connections: number
|
||||
total_requests: number
|
||||
avg_latency_ms: number | null
|
||||
failed_requests: number
|
||||
dns_failures: number
|
||||
stream_errors: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeEvent {
|
||||
id: number
|
||||
event_type: 'connected' | 'disconnected' | 'error'
|
||||
detail: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeListResponse {
|
||||
items: ProxyNode[]
|
||||
total: number
|
||||
@@ -103,4 +113,9 @@ export const proxyNodesApi = {
|
||||
const response = await apiClient.post<ProxyNodeTestResult>('/api/admin/proxy-nodes/test-url', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listNodeEvents(nodeId: string, limit = 50): Promise<{ items: ProxyNodeEvent[] }> {
|
||||
const response = await apiClient.get<{ items: ProxyNodeEvent[] }>(`/api/admin/proxy-nodes/${nodeId}/events`, { params: { limit } })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -179,31 +179,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Key 能力配置 -->
|
||||
<section
|
||||
v-if="availableCapabilities.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h4 class="font-medium text-sm">
|
||||
模型偏好
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="cap in availableCapabilities"
|
||||
:key="cap.name"
|
||||
class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.supported_capabilities?.includes(cap.name)"
|
||||
class="rounded"
|
||||
@change="toggleCapability(cap.name)"
|
||||
>
|
||||
<span>{{ cap.display_name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<section class="space-y-3">
|
||||
<h4 class="font-medium text-sm">
|
||||
@@ -212,7 +187,7 @@
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
|
||||
:show-cache1h="true"
|
||||
/>
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费</Label>
|
||||
@@ -353,7 +328,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Loader2, Layers, SquarePen,
|
||||
Search, ChevronRight, Plus, Trash2
|
||||
@@ -378,7 +353,6 @@ import {
|
||||
type GlobalModelUpdate,
|
||||
} from '@/api/global-models'
|
||||
import type { TieredPricingConfig } from '@/api/endpoints/types'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -665,34 +639,6 @@ function fillVideoResolutionPricePreset(preset: 'common' | 'sora' | 'veo') {
|
||||
}))
|
||||
}
|
||||
|
||||
// Key 能力选项
|
||||
const availableCapabilities = ref<CapabilityDefinition[]>([])
|
||||
|
||||
// 加载可用能力列表
|
||||
async function loadCapabilities() {
|
||||
try {
|
||||
availableCapabilities.value = await getAllCapabilities()
|
||||
} catch (err) {
|
||||
log.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换能力
|
||||
function toggleCapability(capName: string) {
|
||||
if (!form.value.supported_capabilities) {
|
||||
form.value.supported_capabilities = []
|
||||
}
|
||||
const index = form.value.supported_capabilities.indexOf(capName)
|
||||
if (index >= 0) {
|
||||
form.value.supported_capabilities.splice(index, 1)
|
||||
} else {
|
||||
form.value.supported_capabilities.push(capName)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCapabilities()
|
||||
})
|
||||
|
||||
// 加载模型列表
|
||||
async function loadModels() {
|
||||
@@ -829,6 +775,19 @@ async function handleSubmit() {
|
||||
// Apply billing (video) pricing into config before cleaning/submitting.
|
||||
applyVideoPricingToConfig()
|
||||
|
||||
// Auto-infer supported_capabilities from tiered pricing config
|
||||
const caps = new Set(form.value.supported_capabilities || [])
|
||||
const has1hPricing = finalTieredPricing?.tiers?.some(
|
||||
(t: Record<string, unknown>) => Array.isArray(t.cache_ttl_pricing)
|
||||
&& (t.cache_ttl_pricing as Array<Record<string, unknown>>).some(c => c.ttl_minutes === 60)
|
||||
)
|
||||
if (has1hPricing) {
|
||||
caps.add('cache_1h')
|
||||
} else {
|
||||
caps.delete('cache_1h')
|
||||
}
|
||||
form.value.supported_capabilities = caps.size > 0 ? [...caps] : []
|
||||
|
||||
// 清理空的 config
|
||||
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
|
||||
? form.value.config
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存</Label>
|
||||
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
v-if="showCache1h"
|
||||
class="space-y-1"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground">1h 缓存创建</Label>
|
||||
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
|
||||
<Input
|
||||
:model-value="getCache1hDisplay(index)"
|
||||
type="number"
|
||||
@@ -209,26 +209,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听 showCache1h 变化
|
||||
watch(
|
||||
() => props.showCache1h,
|
||||
(newValue, oldValue) => {
|
||||
if (oldValue === true && newValue === false) {
|
||||
// 取消勾选时,清除本地的 1h 缓存数据和手动设置标记
|
||||
localTiers.value.forEach((tier, i) => {
|
||||
tier.cache_ttl_pricing = undefined
|
||||
if (cacheManuallySet[i]) {
|
||||
cacheManuallySet[i].cache1h = false
|
||||
}
|
||||
})
|
||||
syncToParent()
|
||||
} else if (oldValue === false && newValue === true) {
|
||||
// 勾选时,同步自动计算的价格到父组件
|
||||
syncToParent()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 验证错误
|
||||
const validationError = computed(() => {
|
||||
if (localTiers.value.length === 0) {
|
||||
|
||||
@@ -245,18 +245,8 @@ const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | nu
|
||||
|
||||
const isEditing = computed(() => !!props.editingModel)
|
||||
|
||||
// 计算是否显示 1h 缓存输入框
|
||||
const showCache1h = computed(() => {
|
||||
if (isEditing.value) {
|
||||
// 编辑模式:检查当前配置是否有 1h 缓存配置(从 tiered_pricing 或 effective_tiered_pricing 中检测)
|
||||
const pricing = props.editingModel?.tiered_pricing || props.editingModel?.effective_tiered_pricing
|
||||
return pricing?.tiers?.some(t => t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60)) ?? false
|
||||
} else {
|
||||
// 添加模式:从选中的全局模型中读取 supported_capabilities
|
||||
const selectedModel = availableGlobalModels.value.find(m => m.id === form.value.global_model_id)
|
||||
return selectedModel?.supported_capabilities?.includes('cache_1h') ?? false
|
||||
}
|
||||
})
|
||||
// 1h 缓存定价始终显示
|
||||
const showCache1h = true
|
||||
|
||||
// 表单状态
|
||||
const submitting = ref(false)
|
||||
|
||||
@@ -656,6 +656,7 @@ import {
|
||||
getGlobalModel,
|
||||
updateGlobalModel,
|
||||
deleteGlobalModel,
|
||||
batchDeleteGlobalModels,
|
||||
batchAssignToProviders,
|
||||
getGlobalModelProviders,
|
||||
type GlobalModelResponse,
|
||||
@@ -1276,15 +1277,13 @@ async function confirmBatchDeleteModels() {
|
||||
submittingBatchManage.value = true
|
||||
try {
|
||||
const ids = Array.from(selectedBatchManageModelIds.value)
|
||||
const results = await Promise.allSettled(ids.map(id => deleteGlobalModel(id)))
|
||||
const successCount = results.filter(r => r.status === 'fulfilled').length
|
||||
const failCount = results.filter(r => r.status === 'rejected').length
|
||||
const result = await batchDeleteGlobalModels(ids)
|
||||
|
||||
if (successCount > 0) {
|
||||
success(`成功删除 ${successCount} 个模型`)
|
||||
if (result.success_count > 0) {
|
||||
success(`成功删除 ${result.success_count} 个模型`)
|
||||
}
|
||||
if (failCount > 0) {
|
||||
showError(`${failCount} 个模型删除失败`, '部分失败')
|
||||
if (result.failed.length > 0) {
|
||||
showError(`${result.failed.length} 个模型删除失败`, '部分失败')
|
||||
}
|
||||
|
||||
// 清除选中的已删除模型
|
||||
|
||||
@@ -48,9 +48,6 @@
|
||||
<SelectItem value="online">
|
||||
在线
|
||||
</SelectItem>
|
||||
<SelectItem value="unhealthy">
|
||||
异常
|
||||
</SelectItem>
|
||||
<SelectItem value="offline">
|
||||
离线
|
||||
</SelectItem>
|
||||
@@ -86,9 +83,6 @@
|
||||
<SelectItem value="online">
|
||||
在线
|
||||
</SelectItem>
|
||||
<SelectItem value="unhealthy">
|
||||
异常
|
||||
</SelectItem>
|
||||
<SelectItem value="offline">
|
||||
离线
|
||||
</SelectItem>
|
||||
@@ -237,6 +231,16 @@
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="连接事件"
|
||||
@click="handleViewEvents(node)"
|
||||
>
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -548,6 +552,77 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 连接事件对话框 -->
|
||||
<Dialog
|
||||
:open="showEventsDialog"
|
||||
title="连接事件"
|
||||
:description="eventsNode ? `${eventsNode.name} 的连接历史` : ''"
|
||||
size="lg"
|
||||
@update:open="(v: boolean) => { if (!v) { showEventsDialog = false; eventsNode = null; nodeEvents = [] } }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- 可靠性指标摘要 -->
|
||||
<div
|
||||
v-if="eventsNode"
|
||||
class="grid grid-cols-3 gap-3 text-sm"
|
||||
>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">失败请求</span>
|
||||
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.failed_requests || 0) }}</span>
|
||||
</div>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">DNS 失败</span>
|
||||
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.dns_failures || 0) }}</span>
|
||||
</div>
|
||||
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
||||
<span class="block text-foreground/60 text-xs">流错误</span>
|
||||
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.stream_errors || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件列表 -->
|
||||
<div
|
||||
v-if="loadingEvents"
|
||||
class="py-8 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="nodeEvents.length === 0"
|
||||
class="py-8 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
暂无连接事件记录
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-80 overflow-y-auto space-y-1.5"
|
||||
>
|
||||
<div
|
||||
v-for="event in nodeEvents"
|
||||
:key="event.id"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 text-sm"
|
||||
>
|
||||
<Badge
|
||||
:variant="eventTypeVariant(event.event_type)"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
>
|
||||
{{ eventTypeLabel(event.event_type) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground truncate flex-1">{{ event.detail || '-' }}</span>
|
||||
<span class="text-xs text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="showEventsDialog = false; eventsNode = null; nodeEvents = []"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -556,7 +631,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { proxyNodesApi, type ProxyNode, type ProxyNodeRemoteConfig } from '@/api/proxy-nodes'
|
||||
import { proxyNodesApi, type ProxyNode, type ProxyNodeRemoteConfig, type ProxyNodeEvent } from '@/api/proxy-nodes'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -580,7 +655,7 @@ import {
|
||||
Dialog,
|
||||
} from '@/components/ui'
|
||||
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History } from 'lucide-vue-next'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatRegion } from '@/utils/region'
|
||||
import HardwareTooltip from './components/HardwareTooltip.vue'
|
||||
@@ -616,6 +691,12 @@ const configForm = ref({
|
||||
heartbeat_interval: '30',
|
||||
})
|
||||
|
||||
// 连接事件对话框
|
||||
const showEventsDialog = ref(false)
|
||||
const eventsNode = ref<ProxyNode | null>(null)
|
||||
const nodeEvents = ref<ProxyNodeEvent[]>([])
|
||||
const loadingEvents = ref(false)
|
||||
|
||||
// 测试连通性
|
||||
const testingNodes = ref(new Set<string>())
|
||||
const testingUrl = ref(false)
|
||||
@@ -834,10 +915,41 @@ async function handleTest(node: ProxyNode) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewEvents(node: ProxyNode) {
|
||||
eventsNode.value = node
|
||||
showEventsDialog.value = true
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res = await proxyNodesApi.listNodeEvents(node.id, 50)
|
||||
nodeEvents.value = res.items
|
||||
} catch (err: unknown) {
|
||||
toastError(parseApiError(err, '加载事件失败'))
|
||||
} finally {
|
||||
loadingEvents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function eventTypeLabel(type: string) {
|
||||
switch (type) {
|
||||
case 'connected': return '连接'
|
||||
case 'disconnected': return '断开'
|
||||
case 'error': return '错误'
|
||||
default: return type
|
||||
}
|
||||
}
|
||||
|
||||
function eventTypeVariant(type: string) {
|
||||
switch (type) {
|
||||
case 'connected': return 'success' as const
|
||||
case 'disconnected': return 'destructive' as const
|
||||
case 'error': return 'destructive' as const
|
||||
default: return 'secondary' as const
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'online': return 'success' as const
|
||||
case 'unhealthy': return 'secondary' as const
|
||||
case 'offline': return 'destructive' as const
|
||||
default: return 'secondary' as const
|
||||
}
|
||||
@@ -846,7 +958,6 @@ function statusVariant(status: string) {
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'online': return '在线'
|
||||
case 'unhealthy': return '异常'
|
||||
case 'offline': return '离线'
|
||||
default: return status
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
v-if="getFirst1hCachePrice(model.default_tiered_pricing) !== '-'"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border bg-muted/20"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存创建</Label>
|
||||
<Label class="text-xs text-muted-foreground whitespace-nowrap">1h 缓存</Label>
|
||||
<span class="text-sm font-mono">{{ getFirst1hCachePrice(model.default_tiered_pricing) }}</span>
|
||||
</div>
|
||||
<!-- 按次计费 -->
|
||||
|
||||
Reference in New Issue
Block a user