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:
fawney19
2026-03-24 00:00:08 +08:00
co-authored by AAEE86
parent 4d5c591654
commit dfb95f09e1
14 changed files with 2122 additions and 107 deletions
+73
View File
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience
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
}
/**
* 获取按 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
*/
@@ -594,6 +594,7 @@ export interface HealthSummary {
total: number
active: number
unhealthy: number
circuit_open: number
}
}
+44
View File
@@ -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"
class="overflow-hidden"
>
<!-- 标题和筛选器 -->
<div class="px-6 py-3.5 border-b border-border/60">
<div class="flex items-center justify-between gap-4">
<h3 class="text-base font-semibold">
{{ title }}
</h3>
<div class="flex items-center gap-3">
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
<Select
v-model="lookbackHours"
<div class="border-b border-border/60 bg-muted/20 px-6 py-5">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-base font-semibold">
{{ title }}
</h3>
<Badge
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">
<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>
<RefreshButton
:loading="loading"
@click="refreshData"
/>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{{ item.label }}
</div>
<div
class="mt-2 text-xl font-semibold"
:class="item.valueClass"
>
{{ item.value }}
</div>
<div class="mt-1 text-[11px] text-muted-foreground">
{{ item.help }}
</div>
</div>
<div
class="rounded-xl border border-border/60 p-2"
:class="item.iconClass"
>
<component
:is="item.icon"
class="h-4 w-4"
/>
</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">成功率 &lt; 70%</span>
<Badge
variant="outline"
class="gap-1"
>
无流量
</Badge>
<span class="text-muted-foreground">窗口内没有请求</span>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="p-6">
<div
v-if="loadingMonitors"
v-if="showInitialLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
@@ -57,81 +238,149 @@
v-else-if="monitors.length === 0"
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 class="text-xs mt-1">
<p class="mt-1 text-xs">
端点尚未产生请求记录
</p>
</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
v-else
class="space-y-3"
>
<div
v-for="monitor in monitors"
v-for="monitor in filteredMonitors"
: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 sm:flex-row sm:gap-6 sm:items-center">
<!-- 第一行/左侧:信息区域 -->
<div class="sm:w-52 flex-shrink-0 space-y-1.5 mb-3 sm:mb-0">
<!-- API 格式标签和成功率 -->
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-6">
<div class="space-y-1.5 xl:w-64 xl:flex-shrink-0">
<div class="flex items-center gap-2 flex-wrap">
<Badge
variant="outline"
class="font-mono text-xs whitespace-nowrap"
class="whitespace-nowrap font-mono text-xs"
>
{{ formatApiFormat(monitor.api_format) }}
</Badge>
<Badge
:variant="getMonitorStatusVariant(monitor)"
class="text-xs whitespace-nowrap"
>
{{ getMonitorStatusLabel(monitor) }}
</Badge>
<Badge
v-if="monitor.total_attempts > 0"
:variant="getSuccessRateVariant(monitor.success_rate)"
class="text-xs whitespace-nowrap"
>
{{ (monitor.success_rate * 100).toFixed(0) }}%
{{ formatPercent(monitor.success_rate) }}
</Badge>
<!-- 提供商信息(仅管理员可见)- 窄屏时显示在同一行 -->
<span
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 }} 个密钥
</span>
</div>
<!-- 提供商信息(仅管理员可见)- 宽屏时显示在下方 -->
<div
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 }} 个密钥
</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 class="flex-1 min-w-0 sm:flex sm:justify-end">
<div class="w-full sm:max-w-5xl">
<EndpointHealthTimeline
:monitor="monitor"
:lookback-hours="parseInt(lookbackHours)"
/>
</div>
<div class="min-w-0 flex-1">
<EndpointHealthTimeline
:monitor="monitor"
:lookback-hours="parseInt(lookbackHours)"
/>
</div>
</div>
</div>
</div>
</div>
</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>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { Activity, Loader2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
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 Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
import Label from '@/components/ui/label.vue'
import Switch from '@/components/ui/switch.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.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 RefreshButton from '@/components/ui/refresh-button.vue'
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
import { getKeysGroupedByFormat, type GroupedFormatKey } from '@/api/endpoints/keys'
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 { parseApiError } from '@/utils/errorParser'
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<{
title?: string
isAdmin?: boolean
@@ -155,55 +416,510 @@ const props = withDefaults(defineProps<{
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 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 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
try {
const params = {
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) {
const data = await getEndpointStatusMonitor(params)
monitors.value = data.formats || []
if (summaryResult.status === 'fulfilled') {
healthSummary.value = summaryResult.value
}
} else {
const data = await getPublicEndpointStatusMonitor(params)
monitors.value = data.formats || []
healthSummary.value = null
}
if (selectedMonitorFormat.value && !monitors.value.some(item => item.api_format === selectedMonitorFormat.value)) {
detailDialogOpen.value = false
selectedMonitorFormat.value = null
}
} catch (err: unknown) {
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
if (!silent) {
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
}
} finally {
loadingMonitors.value = false
if (currentRequestId === loadRequestId) {
loadingMonitors.value = false
}
}
}
async function refreshData() {
loading.value = true
async function refreshData(options: { silent?: boolean } = {}) {
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 {
await loadMonitors()
keysByFormat.value = await getKeysGroupedByFormat()
} catch (err: unknown) {
showError(parseApiError(err, '加载 Key 诊断数据失败'), '错误')
} finally {
loading.value = false
loadingFormatKeys.value = false
}
}
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (rate >= 0.95) return 'default'
if (rate >= 0.8) return 'secondary'
return 'destructive'
function resetFilters() {
statusFilter.value = 'all'
familyFilter.value = 'all'
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, () => {
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(() => {
refreshData()
document.addEventListener('visibilitychange', handleVisibilityChange)
startAutoRefresh()
void refreshData({ silent: false })
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh()
})
</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>
+1 -3
View File
@@ -1825,8 +1825,6 @@ mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
const grouped: Record<string, Record<string, unknown>[]> = {}
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] || []
for (const key of keys) {
const formats: string[] = key.api_formats || []
@@ -1836,7 +1834,7 @@ mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
...key,
api_format: fmt,
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,
circuit_breaker_open: false,
capabilities: [],
+32 -2
View File
@@ -1155,6 +1155,7 @@ import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useCountdownTimer, getCodexResetCountdown } from '@/composables/useCountdownTimer'
import { useConfirm } from '@/composables/useConfirm'
import { useRouteQuery } from '@/composables/useRouteQuery'
import { parseApiError } from '@/utils/errorParser'
import {
getPoolOverview,
@@ -1209,6 +1210,7 @@ const { confirm } = useConfirm()
const { copyToClipboard } = useClipboard()
const { tick: countdownTick, start: startCountdownTimer } = useCountdownTimer()
const proxyNodesStore = useProxyNodesStore()
const { getQueryValue, patchQuery } = useRouteQuery()
// --- Overview ---
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
selectedProviderId.value = id
selectedProviderData.value = null
@@ -1444,7 +1446,9 @@ async function selectProvider(id: string) {
proxyMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true
currentPage.value = 1
searchQuery.value = ''
if (!options.preserveSearch) {
searchQuery.value = ''
}
statusFilter.value = 'all'
suppressFiltersWatch = false
if (keysSearchDebounceTimer !== null) {
@@ -1504,6 +1508,32 @@ const keyFormDialogOpen = ref(false)
const oauthKeyEditDialogOpen = ref(false)
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 {
label: string
remainingPercent: number
@@ -286,6 +286,7 @@ import {
} from '@/api/endpoints'
import { adminApi } from '@/api/admin'
import { parseApiError } from '@/utils/errorParser'
import { useRouteQuery } from '@/composables/useRouteQuery'
interface ProviderDeleteProgressState {
providerId: string
@@ -302,6 +303,7 @@ interface ProviderDeleteProgressState {
const { error: showError, success: showSuccess, info: showInfo } = useToast()
const { confirmDanger } = useConfirm()
const { getQueryValue, patchQuery } = useRouteQuery()
// 状态
const loading = ref(false)
@@ -460,6 +462,46 @@ const {
stopTick,
} = 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 opsConfigProviderId = ref('')
+4 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="space-y-6 pb-8">
<div class="space-y-6 px-4 sm:px-6 lg:px-0 pb-8">
<HealthMonitorCard
title="健康监控"
:is-admin="isAdminPage"
@@ -11,8 +11,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import HealthMonitorCard from '@/features/providers/components/HealthMonitorCard.vue'
const route = useRoute()
const isAdminPage = computed(() => route.path.startsWith('/admin'))
const authStore = useAuthStore()
const isAdminPage = computed(() => authStore.isAdmin || route.path.startsWith('/admin'))
</script>
+1 -1
View File
@@ -138,7 +138,7 @@ async def get_health_summary(
- `total_endpoints`: Endpoint 总数
- `active_endpoints`: 活跃 Endpoint 数量
- `total_keys`: Key 总数
- `active_keys`: 活跃 Key 数量
- `active_keys`: 当前可调度 Key 数量(Provider 活跃 + Key 启用 + 存在活跃 Endpoint + 非熔断)
- `circuit_breaker_open_keys`: 熔断的 Key 数量
"""
adapter = AdminHealthSummaryAdapter()
+1 -1
View File
@@ -1006,7 +1006,7 @@ class HealthSummaryResponse(BaseModel):
"""健康状态摘要"""
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 控制相关 ==========
+69 -13
View File
@@ -27,7 +27,7 @@ from src.config.constants import CircuitBreakerDefaults
from src.core.batch_committer import get_batch_committer
from src.core.logger import logger
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:
@@ -849,28 +849,78 @@ class HealthMonitor:
endpoint_stats = db.query(
func.count(ProviderEndpoint.id).label("total"),
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()
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_rows = db.query(
ProviderAPIKey.is_active,
ProviderAPIKey.health_by_format,
ProviderAPIKey.circuit_breaker_by_format,
).all()
key_rows = (
db.query(
ProviderAPIKey.provider_id,
ProviderAPIKey.is_active,
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)
active_keys = 0
unhealthy_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:
if is_active:
active_keys += 1
for (
provider_id,
is_active,
api_formats,
health_by_format,
circuit_by_format,
provider_active,
) in key_rows:
health_by_format = health_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
for fmt, health_data in health_by_format.items():
if float(health_data.get("health_score") or 1.0) < 0.5:
@@ -883,11 +933,17 @@ class HealthMonitor:
circuit_open_keys += 1
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 {
"endpoints": {
"total": endpoint_stats.total 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": {
"total": total_keys,
@@ -17,6 +17,7 @@ from src.core.key_capabilities import get_capability
from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
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.response_builder import build_key_response
@@ -98,6 +99,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
grouped: dict[str, list[dict]] = {}
for key, provider in keys:
pool_enabled = parse_pool_config(getattr(provider, "config", None)) is not None
raw_api_formats = key.api_formats or []
api_formats: list[str] = []
seen_formats: set[str] = set()
@@ -155,6 +157,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
"is_active": key.is_active,
"provider_active": provider.is_active,
"provider_name": provider.name,
"pool_enabled": pool_enabled,
"api_formats": api_formats,
"capabilities": caps_list,
"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