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:
fawney19
2026-02-28 13:52:32 +08:00
parent ecb16d345a
commit 54530faf03
25 changed files with 586 additions and 225 deletions

View File

@@ -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 添加关联提供商
*/

View File

@@ -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
},
}

View File

@@ -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

View File

@@ -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>
<!-- 按次计费 -->

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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} 个模型删除失败`, '部分失败')
}
// 清除选中的已删除模型

View File

@@ -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
}

View File

@@ -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>
<!-- 按次计费 -->