mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat(health): 增强健康监控面板,支持按 Key 查看详情与摘要统计
- 后端 health monitor 新增 summary 接口,按 api_format 聚合健康摘要 - 新增 GroupedFormatKey 类型与 key 分组查询接口 - HealthMonitorCard 重构为卡片+详情对话框,展示成功率、响应时间、Key 状态 - 抽取 HealthMonitorDetailDialog 独立组件 - 新增 useRouteQuery composable 用于 URL query 参数双向绑定 - PoolManagement/ProviderManagement 集成健康监控入口 - 新增 health monitor summary 单元测试 Closes #256 Co-authored-by: AAEE86 <[email protected]>
This commit is contained in:
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
|
|||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type { EndpointAPIKey, AllowedModels }
|
export type { EndpointAPIKey, AllowedModels }
|
||||||
|
|
||||||
|
export interface GroupedFormatKey {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
name: string
|
||||||
|
auth_type?: string
|
||||||
|
api_key_masked: string
|
||||||
|
internal_priority: number
|
||||||
|
global_priority_by_format: Record<string, number> | null
|
||||||
|
format_priority: number | null
|
||||||
|
rate_multipliers: Record<string, number> | null
|
||||||
|
is_active: boolean
|
||||||
|
provider_active: boolean
|
||||||
|
pool_enabled: boolean
|
||||||
|
circuit_breaker_open: boolean
|
||||||
|
provider_name: string
|
||||||
|
api_format: string
|
||||||
|
api_formats: string[]
|
||||||
|
capabilities: string[]
|
||||||
|
health_score: number | null
|
||||||
|
success_rate: number | null
|
||||||
|
avg_response_time_ms: number | null
|
||||||
|
request_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 能力定义类型
|
* 能力定义类型
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
|
|||||||
return allKeys
|
return allKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取按 API 格式分组的 Key 列表
|
||||||
|
*/
|
||||||
|
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
|
||||||
|
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||||
|
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
|
||||||
|
const result: Record<string, GroupedFormatKey[]> = {}
|
||||||
|
|
||||||
|
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
|
||||||
|
if (!Array.isArray(keys)) continue
|
||||||
|
|
||||||
|
result[apiFormat] = keys.map((key) => ({
|
||||||
|
id: String(key.id || ''),
|
||||||
|
provider_id: String(key.provider_id || ''),
|
||||||
|
name: String(key.name || 'Unnamed Key'),
|
||||||
|
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
|
||||||
|
api_key_masked: String(key.api_key_masked || '***'),
|
||||||
|
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
|
||||||
|
global_priority_by_format:
|
||||||
|
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
|
||||||
|
? (key.global_priority_by_format as Record<string, number>)
|
||||||
|
: null,
|
||||||
|
format_priority: toNumberOrNull(key.format_priority),
|
||||||
|
rate_multipliers:
|
||||||
|
key.rate_multipliers && typeof key.rate_multipliers === 'object'
|
||||||
|
? (key.rate_multipliers as Record<string, number>)
|
||||||
|
: null,
|
||||||
|
is_active: key.is_active !== false,
|
||||||
|
provider_active: key.provider_active !== false,
|
||||||
|
pool_enabled: key.pool_enabled === true,
|
||||||
|
circuit_breaker_open: key.circuit_breaker_open === true,
|
||||||
|
provider_name: String(key.provider_name || 'Unknown Provider'),
|
||||||
|
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
|
||||||
|
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
|
||||||
|
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
|
||||||
|
health_score: toNumberOrNull(key.health_score),
|
||||||
|
success_rate: toNumberOrNull(key.success_rate),
|
||||||
|
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
|
||||||
|
request_count: toNumberOrNull(key.request_count) ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为 Provider 添加 Key
|
* 为 Provider 添加 Key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -594,6 +594,7 @@ export interface HealthSummary {
|
|||||||
total: number
|
total: number
|
||||||
active: number
|
active: number
|
||||||
unhealthy: number
|
unhealthy: number
|
||||||
|
circuit_open: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useRoute, useRouter, type LocationQueryValue, type LocationQuery } from 'vue-router'
|
||||||
|
|
||||||
|
type QueryValue = LocationQueryValue | LocationQueryValue[]
|
||||||
|
|
||||||
|
function normalizeQueryValue(value: QueryValue): string | undefined {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
|
||||||
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
||||||
|
for (const key of keys) {
|
||||||
|
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouteQuery() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function getQueryValue(key: string): string | undefined {
|
||||||
|
return normalizeQueryValue(route.query[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchQuery(patch: Record<string, string | undefined | null>) {
|
||||||
|
const next: LocationQuery = { ...route.query }
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value == null || value.trim() === '') {
|
||||||
|
delete next[key]
|
||||||
|
} else {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queriesEqual(route.query, next)) return
|
||||||
|
void router.replace({ query: next }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route, router, getQueryValue, patchQuery }
|
||||||
|
}
|
||||||
@@ -3,50 +3,231 @@
|
|||||||
variant="default"
|
variant="default"
|
||||||
class="overflow-hidden"
|
class="overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- 标题和筛选器 -->
|
<div class="border-b border-border/60 bg-muted/20 px-6 py-5">
|
||||||
<div class="px-6 py-3.5 border-b border-border/60">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||||
<h3 class="text-base font-semibold">
|
<div class="space-y-1">
|
||||||
{{ title }}
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
</h3>
|
<h3 class="text-base font-semibold">
|
||||||
<div class="flex items-center gap-3">
|
{{ title }}
|
||||||
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
|
</h3>
|
||||||
<Select
|
<Badge
|
||||||
v-model="lookbackHours"
|
variant="outline"
|
||||||
|
class="text-[10px] uppercase tracking-[0.25em]"
|
||||||
|
>
|
||||||
|
{{ isAdmin ? 'Admin' : 'Public' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
异常优先排序,支持状态筛选、详情诊断与自动刷新,首屏先看出问题的格式。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
最后刷新 {{ generatedAt ? formatRelativeTime(generatedAt) : '未加载' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-xl border border-border/60 bg-card/70 px-3 py-1.5">
|
||||||
|
<Switch v-model="autoRefreshEnabled" />
|
||||||
|
<span class="text-xs font-medium">
|
||||||
|
{{ autoRefreshEnabled ? '自动刷新 15s' : '自动刷新已关闭' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="isAdmin"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-9 px-3 text-xs"
|
||||||
|
:disabled="recoveringAllKeys || (healthSummary?.keys.circuit_open ?? 0) <= 0"
|
||||||
|
@click="recoverAllConfirmOpen = true"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="recoveringAllKeys"
|
||||||
|
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||||
|
/>
|
||||||
|
恢复全部熔断 Key
|
||||||
|
</Button>
|
||||||
|
<RefreshButton
|
||||||
|
:loading="loadingMonitors"
|
||||||
|
@click="refreshData({ silent: false })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 xl:grid-cols-6">
|
||||||
|
<Card
|
||||||
|
v-for="item in summaryCards"
|
||||||
|
:key="item.label"
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<SelectValue />
|
<div class="min-w-0">
|
||||||
</SelectTrigger>
|
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
<SelectContent>
|
{{ item.label }}
|
||||||
<SelectItem value="1">
|
</div>
|
||||||
1 小时
|
<div
|
||||||
</SelectItem>
|
class="mt-2 text-xl font-semibold"
|
||||||
<SelectItem value="6">
|
:class="item.valueClass"
|
||||||
6 小时
|
>
|
||||||
</SelectItem>
|
{{ item.value }}
|
||||||
<SelectItem value="12">
|
</div>
|
||||||
12 小时
|
<div class="mt-1 text-[11px] text-muted-foreground">
|
||||||
</SelectItem>
|
{{ item.help }}
|
||||||
<SelectItem value="24">
|
</div>
|
||||||
24 小时
|
</div>
|
||||||
</SelectItem>
|
<div
|
||||||
<SelectItem value="48">
|
class="rounded-xl border border-border/60 p-2"
|
||||||
48 小时
|
:class="item.iconClass"
|
||||||
</SelectItem>
|
>
|
||||||
</SelectContent>
|
<component
|
||||||
</Select>
|
:is="item.icon"
|
||||||
<RefreshButton
|
class="h-4 w-4"
|
||||||
:loading="loading"
|
/>
|
||||||
@click="refreshData"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div class="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs text-muted-foreground">回溯时间</Label>
|
||||||
|
<Select v-model="lookbackHours">
|
||||||
|
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">
|
||||||
|
1 小时
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="6">
|
||||||
|
6 小时
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="12">
|
||||||
|
12 小时
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="24">
|
||||||
|
24 小时
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="48">
|
||||||
|
48 小时
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs text-muted-foreground">状态筛选</Label>
|
||||||
|
<Select v-model="statusFilter">
|
||||||
|
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
全部状态
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="unhealthy">
|
||||||
|
仅看异常
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="warning">
|
||||||
|
仅看预警
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="healthy">
|
||||||
|
仅看健康
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="unknown">
|
||||||
|
仅看无流量
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs text-muted-foreground">格式族</Label>
|
||||||
|
<Select v-model="familyFilter">
|
||||||
|
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
全部格式
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="claude">
|
||||||
|
Claude
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="openai">
|
||||||
|
OpenAI
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="gemini">
|
||||||
|
Gemini
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="other">
|
||||||
|
其他
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs text-muted-foreground">显示范围</Label>
|
||||||
|
<div class="flex h-9 items-center justify-between rounded-xl border border-border/60 bg-card/70 px-3">
|
||||||
|
<span class="text-xs font-medium">仅看有流量</span>
|
||||||
|
<Switch v-model="onlyTraffic" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>显示 {{ filteredMonitors.length }} / {{ monitors.length }} 个格式</span>
|
||||||
|
<Button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2 text-xs"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Badge
|
||||||
|
variant="success"
|
||||||
|
class="gap-1"
|
||||||
|
>
|
||||||
|
健康
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground">成功率 ≥ 95%</span>
|
||||||
|
<Badge
|
||||||
|
variant="warning"
|
||||||
|
class="gap-1"
|
||||||
|
>
|
||||||
|
预警
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground">70% - 95%</span>
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
class="gap-1"
|
||||||
|
>
|
||||||
|
异常
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground">成功率 < 70%</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="gap-1"
|
||||||
|
>
|
||||||
|
无流量
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground">窗口内没有请求</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div
|
<div
|
||||||
v-if="loadingMonitors"
|
v-if="showInitialLoading"
|
||||||
class="flex items-center justify-center py-12"
|
class="flex items-center justify-center py-12"
|
||||||
>
|
>
|
||||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
@@ -57,81 +238,149 @@
|
|||||||
v-else-if="monitors.length === 0"
|
v-else-if="monitors.length === 0"
|
||||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Activity class="w-12 h-12 mb-3 opacity-30" />
|
<Activity class="mb-3 h-12 w-12 opacity-30" />
|
||||||
<p>暂无健康监控数据</p>
|
<p>暂无健康监控数据</p>
|
||||||
<p class="text-xs mt-1">
|
<p class="mt-1 text-xs">
|
||||||
端点尚未产生请求记录
|
端点尚未产生请求记录
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="filteredMonitors.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<AlertTriangle class="mb-3 h-12 w-12 opacity-30" />
|
||||||
|
<p>当前筛选条件下没有匹配项</p>
|
||||||
|
<p class="mt-1 text-xs">
|
||||||
|
可以放宽状态、格式族或流量条件
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="mt-4"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="space-y-3"
|
class="space-y-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="monitor in monitors"
|
v-for="monitor in filteredMonitors"
|
||||||
:key="monitor.api_format"
|
:key="monitor.api_format"
|
||||||
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
|
class="rounded-xl border p-4 transition-colors"
|
||||||
|
:class="getMonitorCardClass(monitor)"
|
||||||
>
|
>
|
||||||
<!-- 响应式布局:窄屏上下两行,宽屏左右结构 -->
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-6">
|
||||||
<div class="flex flex-col sm:flex-row sm:gap-6 sm:items-center">
|
<div class="space-y-1.5 xl:w-64 xl:flex-shrink-0">
|
||||||
<!-- 第一行/左侧:信息区域 -->
|
|
||||||
<div class="sm:w-52 flex-shrink-0 space-y-1.5 mb-3 sm:mb-0">
|
|
||||||
<!-- API 格式标签和成功率 -->
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="font-mono text-xs whitespace-nowrap"
|
class="whitespace-nowrap font-mono text-xs"
|
||||||
>
|
>
|
||||||
{{ formatApiFormat(monitor.api_format) }}
|
{{ formatApiFormat(monitor.api_format) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
:variant="getMonitorStatusVariant(monitor)"
|
||||||
|
class="text-xs whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ getMonitorStatusLabel(monitor) }}
|
||||||
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="monitor.total_attempts > 0"
|
v-if="monitor.total_attempts > 0"
|
||||||
:variant="getSuccessRateVariant(monitor.success_rate)"
|
:variant="getSuccessRateVariant(monitor.success_rate)"
|
||||||
class="text-xs whitespace-nowrap"
|
class="text-xs whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{{ (monitor.success_rate * 100).toFixed(0) }}%
|
{{ formatPercent(monitor.success_rate) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<!-- 提供商信息(仅管理员可见)- 窄屏时显示在同一行 -->
|
|
||||||
<span
|
<span
|
||||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||||
class="text-xs text-muted-foreground sm:hidden"
|
class="text-xs text-muted-foreground xl:hidden"
|
||||||
>
|
>
|
||||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提供商信息(仅管理员可见)- 宽屏时显示在下方 -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||||
class="text-xs text-muted-foreground hidden sm:block"
|
class="hidden text-xs text-muted-foreground xl:block"
|
||||||
>
|
>
|
||||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ monitor.total_attempts }} 次请求
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
失败 {{ monitor.failed_count }}
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
跳过 {{ monitor.skipped_count }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
最近事件 {{ monitor.last_event_at ? formatRelativeTime(monitor.last_event_at) : '暂无' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-3 text-xs"
|
||||||
|
@click="openMonitorDetail(monitor)"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第二行/右侧:时间线区域 -->
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex-1 min-w-0 sm:flex sm:justify-end">
|
<EndpointHealthTimeline
|
||||||
<div class="w-full sm:max-w-5xl">
|
:monitor="monitor"
|
||||||
<EndpointHealthTimeline
|
:lookback-hours="parseInt(lookbackHours)"
|
||||||
:monitor="monitor"
|
/>
|
||||||
:lookback-hours="parseInt(lookbackHours)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<HealthMonitorDetailDialog
|
||||||
|
v-model="detailDialogOpen"
|
||||||
|
:monitor="selectedMonitor"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
:format-keys="selectedFormatKeys"
|
||||||
|
:loading-format-keys="loadingFormatKeys"
|
||||||
|
:recovering-key-id="recoveringKeyId"
|
||||||
|
@navigate-provider="goToProviderManagement"
|
||||||
|
@navigate-pool="goToPoolManagement"
|
||||||
|
@recover-key="recoverFormatKey"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
v-model="recoverAllConfirmOpen"
|
||||||
|
title="恢复全部熔断 Key"
|
||||||
|
:description="recoverAllDialogDescription"
|
||||||
|
type="warning"
|
||||||
|
confirm-text="立即恢复"
|
||||||
|
:loading="recoveringAllKeys"
|
||||||
|
@confirm="confirmRecoverAllKeys"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||||
import { Activity, Loader2 } from 'lucide-vue-next'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Activity, AlertTriangle, Gauge, KeyRound, Loader2, Radio, ShieldAlert } from 'lucide-vue-next'
|
||||||
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
|
import HealthMonitorDetailDialog from './HealthMonitorDetailDialog.vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
import Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
|
import Switch from '@/components/ui/switch.vue'
|
||||||
import Select from '@/components/ui/select.vue'
|
import Select from '@/components/ui/select.vue'
|
||||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||||
import SelectValue from '@/components/ui/select-value.vue'
|
import SelectValue from '@/components/ui/select-value.vue'
|
||||||
@@ -139,12 +388,24 @@ import SelectContent from '@/components/ui/select-content.vue'
|
|||||||
import SelectItem from '@/components/ui/select-item.vue'
|
import SelectItem from '@/components/ui/select-item.vue'
|
||||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||||
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
|
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
|
||||||
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
|
import { getKeysGroupedByFormat, type GroupedFormatKey } from '@/api/endpoints/keys'
|
||||||
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
import { getEndpointStatusMonitor, getHealthSummary, getPublicEndpointStatusMonitor, recoverAllKeysHealth, recoverKeyHealth } from '@/api/endpoints/health'
|
||||||
|
import type { EndpointStatusMonitor, HealthSummary, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
|
|
||||||
|
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||||
|
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||||
|
type SummaryCardItem = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
help: string
|
||||||
|
icon: Component
|
||||||
|
valueClass: string
|
||||||
|
iconClass: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
title?: string
|
title?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
@@ -155,55 +416,510 @@ const props = withDefaults(defineProps<{
|
|||||||
showProviderInfo: false
|
showProviderInfo: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const { error: showError } = useToast()
|
const AUTO_REFRESH_INTERVAL_MS = 15_000
|
||||||
|
const STATUS_SORT_ORDER: Record<MonitorStatus, number> = {
|
||||||
|
unhealthy: 0,
|
||||||
|
warning: 1,
|
||||||
|
unknown: 2,
|
||||||
|
healthy: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMonitors = ref(false)
|
const loadingMonitors = ref(false)
|
||||||
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
|
const monitors = ref<MonitorItem[]>([])
|
||||||
|
const generatedAt = ref<string | null>(null)
|
||||||
|
const healthSummary = ref<HealthSummary | null>(null)
|
||||||
const lookbackHours = ref('6')
|
const lookbackHours = ref('6')
|
||||||
|
const statusFilter = ref<'all' | MonitorStatus>('all')
|
||||||
|
const familyFilter = ref<'all' | 'claude' | 'openai' | 'gemini' | 'other'>('all')
|
||||||
|
const onlyTraffic = ref(false)
|
||||||
|
const autoRefreshEnabled = ref(true)
|
||||||
|
const detailDialogOpen = ref(false)
|
||||||
|
const selectedMonitorFormat = ref<string | null>(null)
|
||||||
|
const recoverAllConfirmOpen = ref(false)
|
||||||
|
const recoveringAllKeys = ref(false)
|
||||||
|
const keysByFormat = ref<Record<string, GroupedFormatKey[]>>({})
|
||||||
|
const loadingFormatKeys = ref(false)
|
||||||
|
const recoveringKeyId = ref<string | null>(null)
|
||||||
|
|
||||||
async function loadMonitors() {
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let loadRequestId = 0
|
||||||
|
|
||||||
|
const isAdmin = computed(() => props.isAdmin)
|
||||||
|
const showInitialLoading = computed(() => loadingMonitors.value && monitors.value.length === 0)
|
||||||
|
|
||||||
|
const unhealthyCount = computed(() =>
|
||||||
|
monitors.value.filter(item => getMonitorStatus(item) === 'unhealthy').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const warningCount = computed(() =>
|
||||||
|
monitors.value.filter(item => getMonitorStatus(item) === 'warning').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const trafficCount = computed(() =>
|
||||||
|
monitors.value.filter(item => item.total_attempts > 0).length
|
||||||
|
)
|
||||||
|
|
||||||
|
const overallSuccessRate = computed(() => {
|
||||||
|
const totals = monitors.value.reduce((acc, item) => {
|
||||||
|
acc.success += item.success_count
|
||||||
|
acc.failed += item.failed_count
|
||||||
|
return acc
|
||||||
|
}, { success: 0, failed: 0 })
|
||||||
|
|
||||||
|
const denominator = totals.success + totals.failed
|
||||||
|
if (denominator <= 0) return null
|
||||||
|
return totals.success / denominator
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards = computed<SummaryCardItem[]>(() => {
|
||||||
|
const items: SummaryCardItem[] = [
|
||||||
|
{
|
||||||
|
label: '异常格式',
|
||||||
|
value: `${unhealthyCount.value}`,
|
||||||
|
help: `共 ${monitors.value.length} 个格式`,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
valueClass: unhealthyCount.value > 0 ? 'text-destructive' : 'text-foreground',
|
||||||
|
iconClass: unhealthyCount.value > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '预警格式',
|
||||||
|
value: `${warningCount.value}`,
|
||||||
|
help: '建议优先观察',
|
||||||
|
icon: Radio,
|
||||||
|
valueClass: warningCount.value > 0 ? 'text-yellow-600 dark:text-yellow-400' : 'text-foreground',
|
||||||
|
iconClass: warningCount.value > 0 ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' : 'bg-muted/60 text-muted-foreground'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '有流量格式',
|
||||||
|
value: `${trafficCount.value}`,
|
||||||
|
help: `最近 ${lookbackHours.value} 小时`,
|
||||||
|
icon: Activity,
|
||||||
|
valueClass: 'text-foreground',
|
||||||
|
iconClass: 'bg-primary/10 text-primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '整体成功率',
|
||||||
|
value: overallSuccessRate.value == null ? '--' : formatPercent(overallSuccessRate.value),
|
||||||
|
help: '按 success / (success + failed)',
|
||||||
|
icon: Gauge,
|
||||||
|
valueClass: overallSuccessRate.value != null && overallSuccessRate.value < 0.95
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-foreground',
|
||||||
|
iconClass: 'bg-primary/10 text-primary'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (props.isAdmin) {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: '熔断 Key',
|
||||||
|
value: `${healthSummary.value?.keys.circuit_open ?? 0}`,
|
||||||
|
help: '已打开熔断器',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
valueClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'text-destructive' : 'text-foreground',
|
||||||
|
iconClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '活跃 Key',
|
||||||
|
value: `${healthSummary.value?.keys.active ?? 0}`,
|
||||||
|
help: '当前可调度密钥',
|
||||||
|
icon: KeyRound,
|
||||||
|
valueClass: 'text-foreground',
|
||||||
|
iconClass: 'bg-primary/10 text-primary'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredMonitors = computed(() => {
|
||||||
|
const filtered = monitors.value.filter((monitor) => {
|
||||||
|
const status = getMonitorStatus(monitor)
|
||||||
|
const family = getMonitorFamily(monitor.api_format)
|
||||||
|
|
||||||
|
if (statusFilter.value !== 'all' && status !== statusFilter.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (familyFilter.value !== 'all' && family !== familyFilter.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyTraffic.value && monitor.total_attempts <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered.sort((left, right) => {
|
||||||
|
const statusGap = STATUS_SORT_ORDER[getMonitorStatus(left)] - STATUS_SORT_ORDER[getMonitorStatus(right)]
|
||||||
|
if (statusGap !== 0) return statusGap
|
||||||
|
|
||||||
|
const leftTime = left.last_event_at ? new Date(left.last_event_at).getTime() : 0
|
||||||
|
const rightTime = right.last_event_at ? new Date(right.last_event_at).getTime() : 0
|
||||||
|
if (leftTime !== rightTime) return rightTime - leftTime
|
||||||
|
|
||||||
|
if (left.total_attempts !== right.total_attempts) {
|
||||||
|
return right.total_attempts - left.total_attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.api_format.localeCompare(right.api_format)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
statusFilter.value !== 'all' || familyFilter.value !== 'all' || onlyTraffic.value
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedMonitor = computed<MonitorItem | null>(() => {
|
||||||
|
if (!selectedMonitorFormat.value) return null
|
||||||
|
return monitors.value.find(item => item.api_format === selectedMonitorFormat.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const selectedFormatKeys = computed<GroupedFormatKey[]>(() => {
|
||||||
|
if (!selectedMonitorFormat.value) return []
|
||||||
|
const keys = keysByFormat.value[selectedMonitorFormat.value] ?? []
|
||||||
|
return [...keys].sort((left, right) => {
|
||||||
|
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||||
|
if (circuitGap !== 0) return circuitGap
|
||||||
|
|
||||||
|
const leftHealth = left.health_score ?? 1
|
||||||
|
const rightHealth = right.health_score ?? 1
|
||||||
|
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||||
|
|
||||||
|
if (left.request_count !== right.request_count) {
|
||||||
|
return right.request_count - left.request_count
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.name.localeCompare(right.name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const recoverAllDialogDescription = computed(() => {
|
||||||
|
const circuitOpen = healthSummary.value?.keys.circuit_open ?? 0
|
||||||
|
return [
|
||||||
|
'这会重置所有已熔断 Key 的健康状态并关闭熔断器。',
|
||||||
|
`当前检测到 **${circuitOpen}** 个熔断 Key。`,
|
||||||
|
'建议在确认上游服务恢复后执行。'
|
||||||
|
].join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadMonitors(silent = false) {
|
||||||
|
const currentRequestId = ++loadRequestId
|
||||||
loadingMonitors.value = true
|
loadingMonitors.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
lookback_hours: parseInt(lookbackHours.value),
|
lookback_hours: parseInt(lookbackHours.value),
|
||||||
per_format_limit: 100
|
per_format_limit: 60
|
||||||
|
}
|
||||||
|
|
||||||
|
const monitorPromise = props.isAdmin
|
||||||
|
? getEndpointStatusMonitor(params)
|
||||||
|
: getPublicEndpointStatusMonitor(params)
|
||||||
|
|
||||||
|
const [monitorResult, summaryResult] = await Promise.allSettled([
|
||||||
|
monitorPromise,
|
||||||
|
props.isAdmin ? getHealthSummary() : Promise.resolve(null)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (currentRequestId !== loadRequestId) return
|
||||||
|
|
||||||
|
if (monitorResult.status === 'fulfilled') {
|
||||||
|
monitors.value = monitorResult.value.formats || []
|
||||||
|
generatedAt.value = monitorResult.value.generated_at ?? null
|
||||||
|
} else if (!silent) {
|
||||||
|
showError(parseApiError(monitorResult.reason, '加载健康监控数据失败'), '错误')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.isAdmin) {
|
if (props.isAdmin) {
|
||||||
const data = await getEndpointStatusMonitor(params)
|
if (summaryResult.status === 'fulfilled') {
|
||||||
monitors.value = data.formats || []
|
healthSummary.value = summaryResult.value
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await getPublicEndpointStatusMonitor(params)
|
healthSummary.value = null
|
||||||
monitors.value = data.formats || []
|
}
|
||||||
|
|
||||||
|
if (selectedMonitorFormat.value && !monitors.value.some(item => item.api_format === selectedMonitorFormat.value)) {
|
||||||
|
detailDialogOpen.value = false
|
||||||
|
selectedMonitorFormat.value = null
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
if (!silent) {
|
||||||
|
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingMonitors.value = false
|
if (currentRequestId === loadRequestId) {
|
||||||
|
loadingMonitors.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData(options: { silent?: boolean } = {}) {
|
||||||
loading.value = true
|
await loadMonitors(Boolean(options.silent))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeysByFormat(force = false) {
|
||||||
|
if (!props.isAdmin) return
|
||||||
|
if (!force && Object.keys(keysByFormat.value).length > 0) return
|
||||||
|
|
||||||
|
loadingFormatKeys.value = true
|
||||||
try {
|
try {
|
||||||
await loadMonitors()
|
keysByFormat.value = await getKeysGroupedByFormat()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showError(parseApiError(err, '加载 Key 诊断数据失败'), '错误')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loadingFormatKeys.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
function resetFilters() {
|
||||||
if (rate >= 0.95) return 'default'
|
statusFilter.value = 'all'
|
||||||
if (rate >= 0.8) return 'secondary'
|
familyFilter.value = 'all'
|
||||||
return 'destructive'
|
onlyTraffic.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function stopAutoRefresh() {
|
||||||
|
if (!autoRefreshTimer) return
|
||||||
|
clearInterval(autoRefreshTimer)
|
||||||
|
autoRefreshTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoRefresh() {
|
||||||
|
stopAutoRefresh()
|
||||||
|
if (!autoRefreshEnabled.value) return
|
||||||
|
|
||||||
|
autoRefreshTimer = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'hidden') return
|
||||||
|
void refreshData({ silent: true })
|
||||||
|
}, AUTO_REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible' && autoRefreshEnabled.value) {
|
||||||
|
void refreshData({ silent: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMonitorDetail(monitor: MonitorItem) {
|
||||||
|
selectedMonitorFormat.value = monitor.api_format
|
||||||
|
detailDialogOpen.value = true
|
||||||
|
if (props.isAdmin) {
|
||||||
|
await loadKeysByFormat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRecoverAllKeys() {
|
||||||
|
if (recoveringAllKeys.value) return
|
||||||
|
|
||||||
|
recoveringAllKeys.value = true
|
||||||
|
try {
|
||||||
|
const result = await recoverAllKeysHealth()
|
||||||
|
recoverAllConfirmOpen.value = false
|
||||||
|
|
||||||
|
if (result.recovered_count > 0) {
|
||||||
|
showSuccess(result.message || `已恢复 ${result.recovered_count} 个熔断 Key`)
|
||||||
|
} else {
|
||||||
|
showWarning(result.message || '当前没有需要恢复的熔断 Key')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshData({ silent: false })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showError(parseApiError(err, '批量恢复熔断 Key 失败'), '错误')
|
||||||
|
} finally {
|
||||||
|
recoveringAllKeys.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverFormatKey(key: GroupedFormatKey) {
|
||||||
|
if (recoveringKeyId.value || !selectedMonitorFormat.value) return
|
||||||
|
|
||||||
|
recoveringKeyId.value = key.id
|
||||||
|
try {
|
||||||
|
const result = await recoverKeyHealth(key.id, selectedMonitorFormat.value)
|
||||||
|
showSuccess(result.message || '当前格式已恢复')
|
||||||
|
await Promise.all([
|
||||||
|
refreshData({ silent: true }),
|
||||||
|
loadKeysByFormat(true)
|
||||||
|
])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
showError(parseApiError(err, '恢复 Key 健康状态失败'), '错误')
|
||||||
|
} finally {
|
||||||
|
recoveringKeyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function goToProviderManagement(providerId: string) {
|
||||||
|
void router.push({
|
||||||
|
name: 'ProviderManagement',
|
||||||
|
query: { providerId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPoolManagement(providerId: string, search?: string) {
|
||||||
|
const query: Record<string, string> = { providerId }
|
||||||
|
const normalizedSearch = search?.trim()
|
||||||
|
if (normalizedSearch) {
|
||||||
|
query.search = normalizedSearch
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({
|
||||||
|
name: 'PoolManagement',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorFamily(apiFormat: string): 'claude' | 'openai' | 'gemini' | 'other' {
|
||||||
|
if (apiFormat.startsWith('claude:')) return 'claude'
|
||||||
|
if (apiFormat.startsWith('openai:')) return 'openai'
|
||||||
|
if (apiFormat.startsWith('gemini:')) return 'gemini'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||||
|
const timeline = monitor.timeline ?? []
|
||||||
|
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||||
|
const status = timeline[index]
|
||||||
|
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||||
|
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||||
|
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||||
|
if (timelineStatus === 'warning') return 'warning'
|
||||||
|
|
||||||
|
if (monitor.total_attempts <= 0) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||||
|
if (monitor.success_rate < 0.95) return 'warning'
|
||||||
|
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||||
|
switch (getMonitorStatus(monitor)) {
|
||||||
|
case 'unhealthy':
|
||||||
|
return '异常'
|
||||||
|
case 'warning':
|
||||||
|
return '预警'
|
||||||
|
case 'healthy':
|
||||||
|
return '健康'
|
||||||
|
default:
|
||||||
|
return '无流量'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||||
|
switch (getMonitorStatus(monitor)) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'success'
|
||||||
|
case 'warning':
|
||||||
|
return 'warning'
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorCardClass(monitor: MonitorItem): string {
|
||||||
|
switch (getMonitorStatus(monitor)) {
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'border-red-300/70 bg-red-500/5 hover:border-red-400/80'
|
||||||
|
case 'warning':
|
||||||
|
return 'border-yellow-300/70 bg-yellow-500/5 hover:border-yellow-400/80'
|
||||||
|
case 'healthy':
|
||||||
|
return 'border-border/60 hover:border-primary/50'
|
||||||
|
default:
|
||||||
|
return 'border-border/40 bg-muted/20 hover:border-border/70'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSuccessRateVariant(rate: number): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||||
|
if (rate >= 0.95) return 'success'
|
||||||
|
if (rate >= 0.8) return 'warning'
|
||||||
|
if (rate >= 0) return 'destructive'
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatPercent(rate: number): string {
|
||||||
|
return `${(rate * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateString).getTime()
|
||||||
|
if (Number.isNaN(diff)) return '未知'
|
||||||
|
if (diff < 60_000) return '刚刚'
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60_000)
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days} 天前`
|
||||||
|
|
||||||
|
return formatAbsoluteTime(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAbsoluteTime(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
watch(lookbackHours, () => {
|
watch(lookbackHours, () => {
|
||||||
loadMonitors()
|
void refreshData({ silent: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => props.isAdmin, () => {
|
||||||
|
keysByFormat.value = {}
|
||||||
|
void refreshData({ silent: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(autoRefreshEnabled, () => {
|
||||||
|
startAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(detailDialogOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
selectedMonitorFormat.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshData()
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
startAutoRefresh()
|
||||||
|
void refreshData({ silent: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
stopAutoRefresh()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,936 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="open"
|
||||||
|
size="5xl"
|
||||||
|
close-on-backdrop
|
||||||
|
:title="monitor ? `${formatApiFormat(monitor.api_format)} 详情` : '监控详情'"
|
||||||
|
:description="dialogDescription"
|
||||||
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
aria-label="关闭详情"
|
||||||
|
@click="open = false"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="monitor"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
当前状态
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<Badge :variant="getMonitorStatusVariant(monitor)">
|
||||||
|
{{ getMonitorStatusLabel(monitor) }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
成功率
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xl font-semibold">
|
||||||
|
{{ formatPercent(monitor.success_rate) }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
请求总数
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xl font-semibold">
|
||||||
|
{{ monitor.total_attempts }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
最近失败
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xl font-semibold">
|
||||||
|
{{ failureEvents.length }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-3"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
最近事件
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm font-medium">
|
||||||
|
{{ monitor.last_event_at ? formatAbsoluteTime(monitor.last_event_at) : '暂无' }}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="monitorApiPath"
|
||||||
|
class="rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
请求入口
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 font-mono text-foreground">
|
||||||
|
{{ monitorApiPath }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isAdmin"
|
||||||
|
class="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
受影响 Provider
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ providerSummaries.length }} 个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loadingFormatKeys"
|
||||||
|
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
正在加载 Key 诊断数据...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="providerSummaries.length === 0"
|
||||||
|
class="py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前格式下没有可诊断的 provider/key 数据。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="provider in providerSummaries"
|
||||||
|
:key="provider.provider_id"
|
||||||
|
class="rounded-xl border p-3 transition-colors"
|
||||||
|
:class="providerFilter === provider.provider_id
|
||||||
|
? 'border-primary/60 bg-primary/5'
|
||||||
|
: 'border-border/50 bg-card/70'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ provider.provider_name }}
|
||||||
|
</span>
|
||||||
|
<Badge :variant="provider.circuitOpenKeys > 0 ? 'destructive' : provider.providerActive ? 'success' : 'outline'">
|
||||||
|
{{ provider.circuitOpenKeys > 0 ? `${provider.circuitOpenKeys} 熔断` : provider.providerActive ? '活跃' : '停用' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ provider.totalKeys }} 个 Key
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
活跃 {{ provider.activeKeys }}
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
平均健康 {{ formatScore(provider.avgHealthScore) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right text-xs text-muted-foreground">
|
||||||
|
<div>{{ provider.requestCount }} 请求</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="FILTER_BUTTON_CLASS"
|
||||||
|
@click="toggleProviderFilter(provider.provider_id)"
|
||||||
|
>
|
||||||
|
{{ providerFilter === provider.provider_id ? '取消筛选' : '只看该 Provider' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="FILTER_BUTTON_CLASS"
|
||||||
|
@click="$emit('navigateProvider', provider.provider_id)"
|
||||||
|
>
|
||||||
|
前往 Provider
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="provider.poolEnabled"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="FILTER_BUTTON_CLASS"
|
||||||
|
@click="$emit('navigatePool', provider.provider_id)"
|
||||||
|
>
|
||||||
|
前往号池
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
Key 诊断
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ filteredFormatKeys.length }} / {{ sortedFormatKeys.length }} 个 Key
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'all' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||||
|
@click="keyScopeFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'circuit-open' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||||
|
@click="keyScopeFilter = 'circuit-open'"
|
||||||
|
>
|
||||||
|
仅看熔断
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'recoverable' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||||
|
@click="keyScopeFilter = 'recoverable'"
|
||||||
|
>
|
||||||
|
仅看可恢复项
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="FILTER_BUTTON_CLASS"
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
清空局部筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span v-if="providerFilter !== 'all'">
|
||||||
|
Provider:{{ providerFilterName }}
|
||||||
|
</span>
|
||||||
|
<span v-if="providerFilter !== 'all' && keyScopeFilter !== 'all'"> · </span>
|
||||||
|
<span v-if="keyScopeFilter === 'circuit-open'">范围:仅熔断项</span>
|
||||||
|
<span v-else-if="keyScopeFilter === 'recoverable'">范围:仅可恢复项</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loadingFormatKeys"
|
||||||
|
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
正在加载 Key 诊断数据...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="sortedFormatKeys.length === 0"
|
||||||
|
class="py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前格式下没有 Key 样本。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="filteredFormatKeys.length === 0"
|
||||||
|
class="py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前局部筛选下没有匹配的 Key。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="key in filteredFormatKeys"
|
||||||
|
:key="key.id"
|
||||||
|
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ key.name }}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{{ key.provider_name }}
|
||||||
|
</Badge>
|
||||||
|
<Badge :variant="key.circuit_breaker_open ? 'destructive' : key.is_active && key.provider_active ? 'success' : 'outline'">
|
||||||
|
{{ key.circuit_breaker_open ? '已熔断' : key.is_active && key.provider_active ? '可用' : '停用' }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="shouldShowRecoverAction(key)"
|
||||||
|
variant="warning"
|
||||||
|
>
|
||||||
|
建议恢复
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-mono text-xs text-muted-foreground">
|
||||||
|
{{ key.api_key_masked }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>健康 {{ formatScore(key.health_score) }}</span>
|
||||||
|
<span v-if="key.success_rate != null">{{ formatPercent(key.success_rate) }}</span>
|
||||||
|
<span>{{ key.request_count }} 请求</span>
|
||||||
|
<span v-if="key.avg_response_time_ms != null">均延迟 {{ formatLatency(key.avg_response_time_ms) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
v-if="key.pool_enabled"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="ACTION_BUTTON_CLASS"
|
||||||
|
@click="$emit('navigatePool', key.provider_id, key.name)"
|
||||||
|
>
|
||||||
|
号池内查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="shouldShowRecoverAction(key)"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:class="ACTION_BUTTON_CLASS"
|
||||||
|
:disabled="recoveringKeyId !== null"
|
||||||
|
@click="$emit('recoverKey', key)"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="recoveringKeyId === key.id"
|
||||||
|
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||||
|
/>
|
||||||
|
恢复健康度
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
最近失败
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
最近 {{ Math.min(failureEvents.length, 8) }} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="failureEvents.length === 0"
|
||||||
|
class="py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前窗口内没有失败事件。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="event in failureEvents.slice(0, 8)"
|
||||||
|
:key="buildEventKey(event)"
|
||||||
|
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="destructive">
|
||||||
|
失败
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="event.status_code"
|
||||||
|
class="text-xs font-medium text-foreground"
|
||||||
|
>
|
||||||
|
HTTP {{ event.status_code }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatAbsoluteTime(event.timestamp) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm text-foreground">
|
||||||
|
{{ event.error_type || event.error_message || '未提供错误类型' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{{ event.latency_ms != null ? `延迟 ${formatLatency(event.latency_ms)}` : '延迟未知' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
错误分布
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Error Type
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="errorTypes.length === 0"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有可归类的错误类型。
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-for="item in errorTypes"
|
||||||
|
:key="item.label"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ item.label }} × {{ item.count }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Status Code
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="statusCodes.length === 0"
|
||||||
|
class="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有状态码样本。
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-for="item in statusCodes"
|
||||||
|
:key="item.label"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{{ item.label }} × {{ item.count }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
延迟样本
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
P50
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-semibold">
|
||||||
|
{{ latencyStats.p50 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
P95
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-semibold">
|
||||||
|
{{ latencyStats.p95 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
Max
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 font-semibold">
|
||||||
|
{{ latencyStats.max }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
variant="inset"
|
||||||
|
class="p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
最近活动
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
最近 {{ Math.min(sortedEvents.length, 12) }} 条事件
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sortedEvents.length === 0"
|
||||||
|
class="py-6 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
当前没有事件样本。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="event in sortedEvents.slice(0, 12)"
|
||||||
|
:key="buildEventKey(event)"
|
||||||
|
class="flex flex-col gap-2 rounded-xl border border-border/50 bg-card/70 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2 flex-wrap">
|
||||||
|
<Badge :variant="getEventStatusVariant(event.status)">
|
||||||
|
{{ getEventStatusLabel(event.status) }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="event.status_code"
|
||||||
|
class="text-xs text-foreground"
|
||||||
|
>
|
||||||
|
HTTP {{ event.status_code }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="event.latency_ms != null"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ formatLatency(event.latency_ms) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="event.error_type"
|
||||||
|
class="truncate text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ event.error_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{{ formatAbsoluteTime(event.timestamp) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { Loader2, X } from 'lucide-vue-next'
|
||||||
|
import Card from '@/components/ui/card.vue'
|
||||||
|
import Badge from '@/components/ui/badge.vue'
|
||||||
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||||
|
import type { GroupedFormatKey } from '@/api/endpoints/keys'
|
||||||
|
import type { EndpointHealthEvent, EndpointStatusMonitor, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints/types'
|
||||||
|
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||||
|
|
||||||
|
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||||
|
type MonitorEvent = EndpointHealthEvent | PublicHealthEvent
|
||||||
|
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||||
|
type CountItem = { label: string; count: number }
|
||||||
|
type KeyScopeFilter = 'all' | 'circuit-open' | 'recoverable'
|
||||||
|
type ProviderImpactSummary = {
|
||||||
|
provider_id: string
|
||||||
|
provider_name: string
|
||||||
|
providerActive: boolean
|
||||||
|
poolEnabled: boolean
|
||||||
|
totalKeys: number
|
||||||
|
activeKeys: number
|
||||||
|
circuitOpenKeys: number
|
||||||
|
avgHealthScore: number | null
|
||||||
|
requestCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
monitor: MonitorItem | null
|
||||||
|
isAdmin: boolean
|
||||||
|
formatKeys: GroupedFormatKey[]
|
||||||
|
loadingFormatKeys: boolean
|
||||||
|
recoveringKeyId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
navigateProvider: [providerId: string]
|
||||||
|
navigatePool: [providerId: string, search?: string]
|
||||||
|
recoverKey: [key: GroupedFormatKey]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const FILTER_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||||
|
const ACTION_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||||
|
|
||||||
|
const providerFilter = ref<string>('all')
|
||||||
|
const keyScopeFilter = ref<KeyScopeFilter>('all')
|
||||||
|
|
||||||
|
watch(open, (value) => {
|
||||||
|
if (!value) {
|
||||||
|
providerFilter.value = 'all'
|
||||||
|
keyScopeFilter.value = 'all'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const monitorApiPath = computed(() =>
|
||||||
|
props.monitor && 'api_path' in props.monitor ? props.monitor.api_path : null
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedEvents = computed<MonitorEvent[]>(() => {
|
||||||
|
const events = props.monitor?.events ?? []
|
||||||
|
return [...events].sort((left, right) =>
|
||||||
|
new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failureEvents = computed<MonitorEvent[]>(() =>
|
||||||
|
sortedEvents.value.filter(event => event.status === 'failed')
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||||
|
[...props.formatKeys].sort((left, right) => {
|
||||||
|
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||||
|
if (circuitGap !== 0) return circuitGap
|
||||||
|
|
||||||
|
const leftHealth = left.health_score ?? 1
|
||||||
|
const rightHealth = right.health_score ?? 1
|
||||||
|
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||||
|
|
||||||
|
if (left.request_count !== right.request_count) {
|
||||||
|
return right.request_count - left.request_count
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.name.localeCompare(right.name)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const providerSummaries = computed<ProviderImpactSummary[]>(() => {
|
||||||
|
const grouped = new Map<string, ProviderImpactSummary & { healthValues: number[] }>()
|
||||||
|
|
||||||
|
for (const key of sortedFormatKeys.value) {
|
||||||
|
const current = grouped.get(key.provider_id) ?? {
|
||||||
|
provider_id: key.provider_id,
|
||||||
|
provider_name: key.provider_name,
|
||||||
|
providerActive: key.provider_active,
|
||||||
|
poolEnabled: key.pool_enabled,
|
||||||
|
totalKeys: 0,
|
||||||
|
activeKeys: 0,
|
||||||
|
circuitOpenKeys: 0,
|
||||||
|
avgHealthScore: null,
|
||||||
|
requestCount: 0,
|
||||||
|
healthValues: []
|
||||||
|
}
|
||||||
|
|
||||||
|
current.totalKeys += 1
|
||||||
|
current.requestCount += key.request_count
|
||||||
|
current.providerActive = current.providerActive || key.provider_active
|
||||||
|
current.poolEnabled = current.poolEnabled || key.pool_enabled
|
||||||
|
|
||||||
|
if (key.is_active) current.activeKeys += 1
|
||||||
|
if (key.circuit_breaker_open) current.circuitOpenKeys += 1
|
||||||
|
if (key.health_score != null) current.healthValues.push(key.health_score)
|
||||||
|
|
||||||
|
grouped.set(key.provider_id, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(grouped.values())
|
||||||
|
.map((provider) => ({
|
||||||
|
provider_id: provider.provider_id,
|
||||||
|
provider_name: provider.provider_name,
|
||||||
|
providerActive: provider.providerActive,
|
||||||
|
poolEnabled: provider.poolEnabled,
|
||||||
|
totalKeys: provider.totalKeys,
|
||||||
|
activeKeys: provider.activeKeys,
|
||||||
|
circuitOpenKeys: provider.circuitOpenKeys,
|
||||||
|
avgHealthScore: provider.healthValues.length > 0
|
||||||
|
? provider.healthValues.reduce((sum, value) => sum + value, 0) / provider.healthValues.length
|
||||||
|
: null,
|
||||||
|
requestCount: provider.requestCount,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.circuitOpenKeys !== right.circuitOpenKeys) {
|
||||||
|
return right.circuitOpenKeys - left.circuitOpenKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftHealth = left.avgHealthScore ?? 1
|
||||||
|
const rightHealth = right.avgHealthScore ?? 1
|
||||||
|
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||||
|
|
||||||
|
return right.requestCount - left.requestCount
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const providerFilterName = computed(() =>
|
||||||
|
providerSummaries.value.find(p => p.provider_id === providerFilter.value)?.provider_name ?? '当前 Provider'
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||||
|
sortedFormatKeys.value.filter((key) => {
|
||||||
|
if (providerFilter.value !== 'all' && key.provider_id !== providerFilter.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyScopeFilter.value === 'circuit-open') {
|
||||||
|
return key.circuit_breaker_open
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyScopeFilter.value === 'recoverable') {
|
||||||
|
return shouldShowRecoverAction(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const dialogDescription = computed(() => {
|
||||||
|
if (!props.monitor) return '查看最近失败、错误分布与事件样本'
|
||||||
|
const timeText = props.monitor.last_event_at
|
||||||
|
? `最近事件 ${formatRelativeTime(props.monitor.last_event_at)}`
|
||||||
|
: '当前窗口没有事件'
|
||||||
|
return `${getMonitorStatusLabel(props.monitor)} · ${props.monitor.total_attempts} 次请求 · ${timeText}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorTypes = computed<CountItem[]>(() =>
|
||||||
|
buildCountItems(
|
||||||
|
failureEvents.value
|
||||||
|
.map(event => event.error_type?.trim())
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusCodes = computed<CountItem[]>(() =>
|
||||||
|
buildCountItems(
|
||||||
|
failureEvents.value
|
||||||
|
.map(event => event.status_code != null ? String(event.status_code) : null)
|
||||||
|
.filter((value): value is string => Boolean(value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const latencyStats = computed(() => {
|
||||||
|
const values = sortedEvents.value
|
||||||
|
.map(event => event.latency_ms)
|
||||||
|
.filter((value): value is number => value != null)
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return { p50: '--', p95: '--', max: '--' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
p50: formatLatency(pickPercentile(values, 0.5)),
|
||||||
|
p95: formatLatency(pickPercentile(values, 0.95)),
|
||||||
|
max: formatLatency(values[values.length - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sortedFormatKeys, (keys) => {
|
||||||
|
if (providerFilter.value === 'all') return
|
||||||
|
if (!keys.some(key => key.provider_id === providerFilter.value)) {
|
||||||
|
providerFilter.value = 'all'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
providerFilter.value = 'all'
|
||||||
|
keyScopeFilter.value = 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProviderFilter(providerId: string) {
|
||||||
|
providerFilter.value = providerFilter.value === providerId ? 'all' : providerId
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowRecoverAction(key: GroupedFormatKey): boolean {
|
||||||
|
if (key.health_score == null) return false
|
||||||
|
return key.health_score < 0.999
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||||
|
const timeline = monitor.timeline ?? []
|
||||||
|
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||||
|
const status = timeline[index]
|
||||||
|
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||||
|
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||||
|
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||||
|
if (timelineStatus === 'warning') return 'warning'
|
||||||
|
|
||||||
|
if (monitor.total_attempts <= 0) {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||||
|
if (monitor.success_rate < 0.95) return 'warning'
|
||||||
|
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||||
|
switch (getMonitorStatus(monitor)) {
|
||||||
|
case 'unhealthy':
|
||||||
|
return '异常'
|
||||||
|
case 'warning':
|
||||||
|
return '预警'
|
||||||
|
case 'healthy':
|
||||||
|
return '健康'
|
||||||
|
default:
|
||||||
|
return '无流量'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||||
|
switch (getMonitorStatus(monitor)) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'success'
|
||||||
|
case 'warning':
|
||||||
|
return 'warning'
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'destructive'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'success'
|
||||||
|
case 'failed':
|
||||||
|
return 'destructive'
|
||||||
|
case 'skipped':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return '成功'
|
||||||
|
case 'failed':
|
||||||
|
return '失败'
|
||||||
|
case 'skipped':
|
||||||
|
return '跳过'
|
||||||
|
case 'started':
|
||||||
|
return '执行中'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCountItems(values: string[]): CountItem[] {
|
||||||
|
const counter = new Map<string, number>()
|
||||||
|
for (const value of values) {
|
||||||
|
counter.set(value, (counter.get(value) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return Array.from(counter.entries())
|
||||||
|
.map(([label, count]) => ({ label, count }))
|
||||||
|
.sort((left, right) => right.count - left.count)
|
||||||
|
.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPercentile(sortedValues: number[], percentile: number): number {
|
||||||
|
if (sortedValues.length === 0) return 0
|
||||||
|
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * percentile) - 1))
|
||||||
|
return sortedValues[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(rate: number): string {
|
||||||
|
return `${(rate * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(score: number | null | undefined): string {
|
||||||
|
if (score == null) return '--'
|
||||||
|
return `${(score * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLatency(latencyMs: number): string {
|
||||||
|
if (latencyMs < 1000) return `${latencyMs}ms`
|
||||||
|
return `${(latencyMs / 1000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateString).getTime()
|
||||||
|
if (Number.isNaN(diff)) return '未知'
|
||||||
|
if (diff < 60_000) return '刚刚'
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60_000)
|
||||||
|
if (minutes < 60) return `${minutes} 分钟前`
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours} 小时前`
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days} 天前`
|
||||||
|
|
||||||
|
return formatAbsoluteTime(dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAbsoluteTime(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventKey(event: MonitorEvent): string {
|
||||||
|
return `${event.timestamp}-${event.status}-${event.status_code ?? 'na'}-${event.error_type ?? 'none'}-${event.latency_ms ?? 'na'}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1825,8 +1825,6 @@ mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
|
|||||||
|
|
||||||
const grouped: Record<string, Record<string, unknown>[]> = {}
|
const grouped: Record<string, Record<string, unknown>[]> = {}
|
||||||
for (const provider of MOCK_PROVIDERS) {
|
for (const provider of MOCK_PROVIDERS) {
|
||||||
const endpoints = generateMockEndpointsForProvider(provider.id)
|
|
||||||
const baseUrlByFormat = Object.fromEntries(endpoints.map(e => [e.api_format, e.base_url]))
|
|
||||||
const keys = PROVIDER_KEYS_CACHE[provider.id] || []
|
const keys = PROVIDER_KEYS_CACHE[provider.id] || []
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const formats: string[] = key.api_formats || []
|
const formats: string[] = key.api_formats || []
|
||||||
@@ -1836,7 +1834,7 @@ mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
|
|||||||
...key,
|
...key,
|
||||||
api_format: fmt,
|
api_format: fmt,
|
||||||
provider_name: provider.name,
|
provider_name: provider.name,
|
||||||
endpoint_base_url: baseUrlByFormat[fmt],
|
pool_enabled: Boolean((provider.config as Record<string, unknown> | undefined)?.pool_advanced),
|
||||||
global_priority: key.global_priority ?? null,
|
global_priority: key.global_priority ?? null,
|
||||||
circuit_breaker_open: false,
|
circuit_breaker_open: false,
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
|
|||||||
@@ -1155,6 +1155,7 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { useRouteQuery } from '@/composables/useRouteQuery'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
getPoolOverview,
|
getPoolOverview,
|
||||||
@@ -1209,6 +1210,7 @@ const { confirm } = useConfirm()
|
|||||||
const { copyToClipboard } = useClipboard()
|
const { copyToClipboard } = useClipboard()
|
||||||
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
|
||||||
const proxyNodesStore = useProxyNodesStore()
|
const proxyNodesStore = useProxyNodesStore()
|
||||||
|
const { getQueryValue, patchQuery } = useRouteQuery()
|
||||||
|
|
||||||
// --- Overview ---
|
// --- Overview ---
|
||||||
const poolProviders = ref<PoolOverviewItem[]>([])
|
const poolProviders = ref<PoolOverviewItem[]>([])
|
||||||
@@ -1430,7 +1432,7 @@ const desktopColumnWidths = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function selectProvider(id: string) {
|
async function selectProvider(id: string, options: { preserveSearch?: boolean } = {}) {
|
||||||
const requestId = ++selectProviderRequestId
|
const requestId = ++selectProviderRequestId
|
||||||
selectedProviderId.value = id
|
selectedProviderId.value = id
|
||||||
selectedProviderData.value = null
|
selectedProviderData.value = null
|
||||||
@@ -1444,7 +1446,9 @@ async function selectProvider(id: string) {
|
|||||||
proxyMobilePopoverOpenKeyId.value = null
|
proxyMobilePopoverOpenKeyId.value = null
|
||||||
suppressFiltersWatch = true
|
suppressFiltersWatch = true
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
searchQuery.value = ''
|
if (!options.preserveSearch) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
statusFilter.value = 'all'
|
statusFilter.value = 'all'
|
||||||
suppressFiltersWatch = false
|
suppressFiltersWatch = false
|
||||||
if (keysSearchDebounceTimer !== null) {
|
if (keysSearchDebounceTimer !== null) {
|
||||||
@@ -1504,6 +1508,32 @@ const keyFormDialogOpen = ref(false)
|
|||||||
const oauthKeyEditDialogOpen = ref(false)
|
const oauthKeyEditDialogOpen = ref(false)
|
||||||
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getQueryValue('search') ?? '',
|
||||||
|
(value) => {
|
||||||
|
if (searchQuery.value === value) return
|
||||||
|
searchQuery.value = value
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchQuery, (value) => {
|
||||||
|
patchQuery({ search: value.trim() || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getQueryValue('providerId'),
|
||||||
|
(value) => {
|
||||||
|
if (!value || value === selectedProviderId.value) return
|
||||||
|
void selectProvider(value, { preserveSearch: true })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(selectedProviderId, (value) => {
|
||||||
|
patchQuery({ providerId: value || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
interface QuotaProgressItem {
|
interface QuotaProgressItem {
|
||||||
label: string
|
label: string
|
||||||
remainingPercent: number
|
remainingPercent: number
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ import {
|
|||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
|
import { useRouteQuery } from '@/composables/useRouteQuery'
|
||||||
|
|
||||||
interface ProviderDeleteProgressState {
|
interface ProviderDeleteProgressState {
|
||||||
providerId: string
|
providerId: string
|
||||||
@@ -302,6 +303,7 @@ interface ProviderDeleteProgressState {
|
|||||||
|
|
||||||
const { error: showError, success: showSuccess, info: showInfo } = useToast()
|
const { error: showError, success: showSuccess, info: showInfo } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
|
const { getQueryValue, patchQuery } = useRouteQuery()
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -460,6 +462,46 @@ const {
|
|||||||
stopTick,
|
stopTick,
|
||||||
} = useProviderBalance()
|
} = useProviderBalance()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getQueryValue('search') ?? '',
|
||||||
|
(value) => {
|
||||||
|
if (searchQuery.value === value) return
|
||||||
|
searchQuery.value = value
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchQuery, (value) => {
|
||||||
|
patchQuery({ search: value.trim() || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getQueryValue('providerId'),
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
if (selectedProviderId.value === value) {
|
||||||
|
if (!providerDrawerOpen.value) providerDrawerOpen.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openProviderDrawer(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedProviderId.value) selectedProviderId.value = null
|
||||||
|
if (providerDrawerOpen.value) providerDrawerOpen.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(selectedProviderId, (value) => {
|
||||||
|
patchQuery({ providerId: value || undefined })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(providerDrawerOpen, (open) => {
|
||||||
|
if (!open && selectedProviderId.value) {
|
||||||
|
selectedProviderId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 扩展操作配置对话框
|
// 扩展操作配置对话框
|
||||||
const opsConfigDialogOpen = ref(false)
|
const opsConfigDialogOpen = ref(false)
|
||||||
const opsConfigProviderId = ref('')
|
const opsConfigProviderId = ref('')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 pb-8">
|
<div class="space-y-6 px-4 sm:px-6 lg:px-0 pb-8">
|
||||||
<HealthMonitorCard
|
<HealthMonitorCard
|
||||||
title="健康监控"
|
title="健康监控"
|
||||||
:is-admin="isAdminPage"
|
:is-admin="isAdminPage"
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
|
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isAdminPage = computed(() => route.path.startsWith('/admin'))
|
const authStore = useAuthStore()
|
||||||
|
const isAdminPage = computed(() => authStore.isAdmin || route.path.startsWith('/admin'))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ async def get_health_summary(
|
|||||||
- `total_endpoints`: Endpoint 总数
|
- `total_endpoints`: Endpoint 总数
|
||||||
- `active_endpoints`: 活跃 Endpoint 数量
|
- `active_endpoints`: 活跃 Endpoint 数量
|
||||||
- `total_keys`: Key 总数
|
- `total_keys`: Key 总数
|
||||||
- `active_keys`: 活跃 Key 数量
|
- `active_keys`: 当前可调度 Key 数量(Provider 活跃 + Key 启用 + 存在活跃 Endpoint + 非熔断)
|
||||||
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
||||||
"""
|
"""
|
||||||
adapter = AdminHealthSummaryAdapter()
|
adapter = AdminHealthSummaryAdapter()
|
||||||
|
|||||||
@@ -1006,7 +1006,7 @@ class HealthSummaryResponse(BaseModel):
|
|||||||
"""健康状态摘要"""
|
"""健康状态摘要"""
|
||||||
|
|
||||||
endpoints: dict[str, int] = Field(..., description="Endpoint 统计 (total, active, unhealthy)")
|
endpoints: dict[str, int] = Field(..., description="Endpoint 统计 (total, active, unhealthy)")
|
||||||
keys: dict[str, int] = Field(..., description="Key 统计 (total, active, unhealthy)")
|
keys: dict[str, int] = Field(..., description="Key 统计 (total, active=当前可调度, unhealthy)")
|
||||||
|
|
||||||
|
|
||||||
# ========== RPM 控制相关 ==========
|
# ========== RPM 控制相关 ==========
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from src.config.constants import CircuitBreakerDefaults
|
|||||||
from src.core.batch_committer import get_batch_committer
|
from src.core.batch_committer import get_batch_committer
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.core.metrics import health_open_circuits
|
from src.core.metrics import health_open_circuits
|
||||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
|
|
||||||
|
|
||||||
class CircuitState:
|
class CircuitState:
|
||||||
@@ -849,28 +849,78 @@ class HealthMonitor:
|
|||||||
endpoint_stats = db.query(
|
endpoint_stats = db.query(
|
||||||
func.count(ProviderEndpoint.id).label("total"),
|
func.count(ProviderEndpoint.id).label("total"),
|
||||||
func.sum(case((ProviderEndpoint.is_active.is_(True), 1), else_=0)).label("active"),
|
func.sum(case((ProviderEndpoint.is_active.is_(True), 1), else_=0)).label("active"),
|
||||||
func.sum(case((ProviderEndpoint.health_score < 0.5, 1), else_=0)).label(
|
|
||||||
"unhealthy"
|
|
||||||
),
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
active_endpoint_rows_raw = (
|
||||||
|
db.query(ProviderEndpoint.provider_id, ProviderEndpoint.api_format)
|
||||||
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
ProviderEndpoint.is_active.is_(True),
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active_endpoint_rows: list[tuple[str, str]] = []
|
||||||
|
active_provider_formats: set[tuple[str, str]] = set()
|
||||||
|
for provider_id, api_format in active_endpoint_rows_raw:
|
||||||
|
format_text = (
|
||||||
|
api_format.value if hasattr(api_format, "value") else str(api_format or "")
|
||||||
|
)
|
||||||
|
if not format_text:
|
||||||
|
continue
|
||||||
|
normalized = (str(provider_id), format_text)
|
||||||
|
active_endpoint_rows.append(normalized)
|
||||||
|
active_provider_formats.add(normalized)
|
||||||
|
|
||||||
# 统计 Key(只加载必要列,避免全字段全表扫描)
|
# 统计 Key(只加载必要列,避免全字段全表扫描)
|
||||||
key_rows = db.query(
|
key_rows = (
|
||||||
ProviderAPIKey.is_active,
|
db.query(
|
||||||
ProviderAPIKey.health_by_format,
|
ProviderAPIKey.provider_id,
|
||||||
ProviderAPIKey.circuit_breaker_by_format,
|
ProviderAPIKey.is_active,
|
||||||
).all()
|
ProviderAPIKey.api_formats,
|
||||||
|
ProviderAPIKey.health_by_format,
|
||||||
|
ProviderAPIKey.circuit_breaker_by_format,
|
||||||
|
Provider.is_active,
|
||||||
|
)
|
||||||
|
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
total_keys = len(key_rows)
|
total_keys = len(key_rows)
|
||||||
active_keys = 0
|
active_keys = 0
|
||||||
unhealthy_keys = 0
|
unhealthy_keys = 0
|
||||||
circuit_open_keys = 0
|
circuit_open_keys = 0
|
||||||
|
schedulable_provider_formats: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for is_active, health_by_format, circuit_by_format in key_rows:
|
for (
|
||||||
if is_active:
|
provider_id,
|
||||||
active_keys += 1
|
is_active,
|
||||||
|
api_formats,
|
||||||
|
health_by_format,
|
||||||
|
circuit_by_format,
|
||||||
|
provider_active,
|
||||||
|
) in key_rows:
|
||||||
health_by_format = health_by_format or {}
|
health_by_format = health_by_format or {}
|
||||||
circuit_by_format = circuit_by_format or {}
|
circuit_by_format = circuit_by_format or {}
|
||||||
|
|
||||||
|
key_schedulable = False
|
||||||
|
if provider_active and is_active:
|
||||||
|
for raw_format in api_formats or []:
|
||||||
|
format_text = (
|
||||||
|
raw_format.value
|
||||||
|
if hasattr(raw_format, "value")
|
||||||
|
else str(raw_format or "")
|
||||||
|
)
|
||||||
|
if not format_text:
|
||||||
|
continue
|
||||||
|
if (str(provider_id), format_text) not in active_provider_formats:
|
||||||
|
continue
|
||||||
|
format_circuit = circuit_by_format.get(format_text, {})
|
||||||
|
if not format_circuit.get("open"):
|
||||||
|
key_schedulable = True
|
||||||
|
schedulable_provider_formats.add((str(provider_id), format_text))
|
||||||
|
if key_schedulable:
|
||||||
|
active_keys += 1
|
||||||
|
|
||||||
# 检查是否有任何格式健康度低于 0.5
|
# 检查是否有任何格式健康度低于 0.5
|
||||||
for fmt, health_data in health_by_format.items():
|
for fmt, health_data in health_by_format.items():
|
||||||
if float(health_data.get("health_score") or 1.0) < 0.5:
|
if float(health_data.get("health_score") or 1.0) < 0.5:
|
||||||
@@ -883,11 +933,17 @@ class HealthMonitor:
|
|||||||
circuit_open_keys += 1
|
circuit_open_keys += 1
|
||||||
break
|
break
|
||||||
|
|
||||||
|
unhealthy_endpoints = sum(
|
||||||
|
1
|
||||||
|
for provider_id, format_text in active_endpoint_rows
|
||||||
|
if (provider_id, format_text) not in schedulable_provider_formats
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"total": endpoint_stats.total or 0 if endpoint_stats else 0,
|
"total": endpoint_stats.total or 0 if endpoint_stats else 0,
|
||||||
"active": int(endpoint_stats.active or 0) if endpoint_stats else 0,
|
"active": int(endpoint_stats.active or 0) if endpoint_stats else 0,
|
||||||
"unhealthy": int(endpoint_stats.unhealthy or 0) if endpoint_stats else 0,
|
"unhealthy": unhealthy_endpoints,
|
||||||
},
|
},
|
||||||
"keys": {
|
"keys": {
|
||||||
"total": total_keys,
|
"total": total_keys,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.core.key_capabilities import get_capability
|
|||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
from src.models.endpoint_models import EndpointAPIKeyResponse
|
from src.models.endpoint_models import EndpointAPIKeyResponse
|
||||||
|
from src.services.provider.pool.config import parse_pool_config
|
||||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||||
from src.services.provider_keys.response_builder import build_key_response
|
from src.services.provider_keys.response_builder import build_key_response
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
|||||||
|
|
||||||
grouped: dict[str, list[dict]] = {}
|
grouped: dict[str, list[dict]] = {}
|
||||||
for key, provider in keys:
|
for key, provider in keys:
|
||||||
|
pool_enabled = parse_pool_config(getattr(provider, "config", None)) is not None
|
||||||
raw_api_formats = key.api_formats or []
|
raw_api_formats = key.api_formats or []
|
||||||
api_formats: list[str] = []
|
api_formats: list[str] = []
|
||||||
seen_formats: set[str] = set()
|
seen_formats: set[str] = set()
|
||||||
@@ -155,6 +157,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
|
|||||||
"is_active": key.is_active,
|
"is_active": key.is_active,
|
||||||
"provider_active": provider.is_active,
|
"provider_active": provider.is_active,
|
||||||
"provider_name": provider.name,
|
"provider_name": provider.name,
|
||||||
|
"pool_enabled": pool_enabled,
|
||||||
"api_formats": api_formats,
|
"api_formats": api_formats,
|
||||||
"capabilities": caps_list,
|
"capabilities": caps_list,
|
||||||
"success_rate": success_rate,
|
"success_rate": success_rate,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.services.health.monitor import HealthMonitor
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
first_result: Any = None,
|
||||||
|
all_result: list[Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._first_result = first_result
|
||||||
|
self._all_result = all_result or []
|
||||||
|
|
||||||
|
def join(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def filter(self, *args: Any, **kwargs: Any) -> _FakeQuery:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self) -> Any:
|
||||||
|
return self._first_result
|
||||||
|
|
||||||
|
def all(self) -> list[Any]:
|
||||||
|
return self._all_result
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHealthSummaryDB:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
endpoint_stats: Any,
|
||||||
|
active_endpoint_rows: list[tuple[str, str]],
|
||||||
|
key_rows: list[tuple[Any, ...]],
|
||||||
|
) -> None:
|
||||||
|
self._endpoint_stats = endpoint_stats
|
||||||
|
self._active_endpoint_rows = active_endpoint_rows
|
||||||
|
self._key_rows = key_rows
|
||||||
|
self._query_count = 0
|
||||||
|
|
||||||
|
def query(self, *models: Any) -> _FakeQuery:
|
||||||
|
self._query_count += 1
|
||||||
|
if self._query_count == 1:
|
||||||
|
return _FakeQuery(first_result=self._endpoint_stats)
|
||||||
|
if self._query_count == 2:
|
||||||
|
return _FakeQuery(all_result=self._active_endpoint_rows)
|
||||||
|
if self._query_count == 3:
|
||||||
|
return _FakeQuery(all_result=self._key_rows)
|
||||||
|
raise AssertionError(f"unexpected query #{self._query_count}: {models}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_health_status_counts_only_current_schedulable_keys() -> None:
|
||||||
|
endpoint_stats = type("EndpointStats", (), {"total": 3, "active": 3})()
|
||||||
|
db = _FakeHealthSummaryDB(
|
||||||
|
endpoint_stats=endpoint_stats,
|
||||||
|
active_endpoint_rows=[
|
||||||
|
("provider-a", "openai:chat"),
|
||||||
|
("provider-a", "openai:cli"),
|
||||||
|
("provider-b", "claude:chat"),
|
||||||
|
],
|
||||||
|
key_rows=[
|
||||||
|
(
|
||||||
|
"provider-a",
|
||||||
|
True,
|
||||||
|
["openai:chat", "openai:cli"],
|
||||||
|
{"openai:chat": {"health_score": 0.4}},
|
||||||
|
{"openai:chat": {"open": True}, "openai:cli": {"open": False}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider-a",
|
||||||
|
True,
|
||||||
|
["openai:chat"],
|
||||||
|
{"openai:chat": {"health_score": 0.8}},
|
||||||
|
{"openai:chat": {"open": True}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider-b",
|
||||||
|
True,
|
||||||
|
["claude:chat"],
|
||||||
|
{"claude:chat": {"health_score": 0.9}},
|
||||||
|
{"claude:chat": {"open": False}},
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider-c",
|
||||||
|
True,
|
||||||
|
["gemini:chat"],
|
||||||
|
{"gemini:chat": {"health_score": 1.0}},
|
||||||
|
{"gemini:chat": {"open": False}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider-a",
|
||||||
|
False,
|
||||||
|
["openai:chat"],
|
||||||
|
{"openai:chat": {"health_score": 1.0}},
|
||||||
|
{"openai:chat": {"open": False}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = HealthMonitor.get_all_health_status(db)
|
||||||
|
|
||||||
|
assert summary["endpoints"] == {"total": 3, "active": 3, "unhealthy": 2}
|
||||||
|
assert summary["keys"]["total"] == 5
|
||||||
|
assert summary["keys"]["active"] == 1
|
||||||
|
assert summary["keys"]["unhealthy"] == 1
|
||||||
|
assert summary["keys"]["circuit_open"] == 2
|
||||||
Reference in New Issue
Block a user