fix(providers): correct endpoint health aggregation and display

This commit is contained in:
elky
2026-09-06 18:24:27 +08:00
parent dba5e6e9e9
commit d1b5eb08ee
9 changed files with 425 additions and 38 deletions
+2 -2
View File
@@ -581,7 +581,7 @@ export interface EndpointAPIKeyUpdate {
export interface EndpointHealthDetail {
api_format: string
health_score: number
health_score: number | null
is_active: boolean
total_keys?: number
active_keys?: number
@@ -914,7 +914,7 @@ export interface ProviderWithEndpointsSummary {
total_models: number
active_models: number
global_model_ids: string[]
avg_health_score: number
avg_health_score: number | null
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
@@ -203,7 +203,7 @@
{{ formatApiFormatShort(endpoint.api_format) }}
</span>
<span class="font-medium text-muted-foreground/80">
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
{{ getEndpointHealthLabel(endpoint) }}
</span>
</div>
@@ -212,7 +212,7 @@
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
:style="{ width: getEndpointHealthBarWidth(endpoint) }"
/>
</div>
</div>
@@ -238,7 +238,13 @@ import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { formatBillingType } from '@/utils/format'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import {
sortEndpoints,
getEndpointHealthLabel,
getEndpointHealthBarWidth,
getEndpointDotColor,
getEndpointTooltip,
} from '@/features/providers/composables/useEndpointStatus'
import { isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { useI18n } from '@/i18n'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
@@ -109,7 +109,7 @@
{{ formatApiFormatShort(endpoint.api_format) }}
</span>
<span class="font-medium text-muted-foreground/80">
{{ isEndpointAvailable(endpoint) ? `${(endpoint.health_score * 100).toFixed(0)}%` : '-' }}
{{ getEndpointHealthLabel(endpoint) }}
</span>
</div>
@@ -118,7 +118,7 @@
<div
class="h-full rounded-full transition-all duration-300"
:class="getEndpointDotColor(endpoint)"
:style="{ width: isEndpointAvailable(endpoint) ? `${Math.max(endpoint.health_score * 100, 5)}%` : '100%' }"
:style="{ width: getEndpointHealthBarWidth(endpoint) }"
/>
</div>
</div>
@@ -210,7 +210,13 @@ import TableRow from '@/components/ui/table-row.vue'
import TableCell from '@/components/ui/table-cell.vue'
import ProviderBalanceCell from './ProviderBalanceCell.vue'
import { type ProviderWithEndpointsSummary, formatApiFormatShort } from '@/api/endpoints'
import { sortEndpoints, isEndpointAvailable, getEndpointDotColor, getEndpointTooltip } from '@/features/providers/composables/useEndpointStatus'
import {
sortEndpoints,
getEndpointHealthLabel,
getEndpointHealthBarWidth,
getEndpointDotColor,
getEndpointTooltip,
} from '@/features/providers/composables/useEndpointStatus'
import type { BalanceExtraItem } from '@/features/providers/auth-templates'
import { useI18n } from '@/i18n'
import { safeExternalWebUrl } from '@/utils/navigationSecurity'
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, type App, type Component } from 'vue'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import { createI18n } from '@/i18n'
import ProviderTableRow from '../ProviderTableRow.vue'
import ProviderMobileCard from '../ProviderMobileCard.vue'
vi.mock('../ProviderBalanceCell.vue', () => ({
default: { render: () => null },
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
function mountProvider(component: Component, healthScore: number | null) {
const provider: ProviderWithEndpointsSummary = {
id: 'provider-1',
name: 'Provider One',
provider_type: 'custom',
provider_priority: 100,
keep_priority_on_conversion: false,
enable_format_conversion: true,
is_active: true,
total_endpoints: 1,
active_endpoints: 1,
total_keys: 1,
active_keys: 1,
total_models: 0,
active_models: 0,
global_model_ids: [],
avg_health_score: healthScore,
unhealthy_endpoints: healthScore === 0 ? 1 : 0,
api_formats: ['openai:chat'],
endpoint_health_details: [{
api_format: 'openai:chat',
health_score: healthScore,
is_active: true,
total_keys: 1,
active_keys: 1,
}],
ops_configured: false,
created_at: '2026-09-06T00:00:00Z',
updated_at: '2026-09-06T00:00:00Z',
}
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(component, {
provider,
editingDescriptionId: null,
isBalanceLoading: () => false,
getProviderBalance: () => null,
getProviderBalanceBreakdown: () => null,
getProviderBalanceError: () => null,
getProviderCheckin: () => null,
getProviderCookieExpired: () => null,
getProviderBalanceExtra: () => [],
formatBalanceDisplay: () => '-',
formatResetCountdown: () => '',
getQuotaUsedColorClass: () => '',
})
app.use(createI18n())
app.mount(root)
mountedApps.push({ app, root })
return root
}
describe.each([
['desktop provider row', ProviderTableRow],
['mobile provider card', ProviderMobileCard],
] as const)('%s endpoint health', (_name, component) => {
it.each([
{ score: null, label: '-', width: '100%', color: 'bg-muted-foreground/40' },
{ score: 0, label: '0%', width: '5%', color: 'bg-red-500' },
{ score: 0.8, label: '80%', width: '80%', color: 'bg-green-500' },
])('renders $score without confusing unknown health with zero', ({ score, label, width, color }) => {
const root = mountProvider(component, score)
const health = root.querySelector('[title*="健康"]')
const bar = health?.querySelector<HTMLElement>('.transition-all')
expect(health?.querySelectorAll('span')[1]?.textContent?.trim()).toBe(label)
expect(bar?.style.width).toBe(width)
expect(bar?.classList.contains(color)).toBe(true)
})
})
@@ -1,6 +1,63 @@
import { describe, expect, it } from 'vitest'
import { getEndpointTooltip } from '../useEndpointStatus'
import type { EndpointHealthDetail } from '@/api/endpoints'
import {
getEndpointDotColor,
getEndpointHealthLabel,
getEndpointHealthBarWidth,
getEndpointTooltip,
} from '../useEndpointStatus'
const availableEndpoint: EndpointHealthDetail = {
api_format: 'openai:chat',
health_score: 1,
is_active: true,
total_keys: 1,
active_keys: 1,
}
describe('endpoint health display', () => {
it.each([null, undefined, Number.NaN, Number.POSITIVE_INFINITY])(
'renders missing or invalid health %s as unknown rather than a percentage',
(healthScore) => {
const endpoint = { ...availableEndpoint, health_score: healthScore } as EndpointHealthDetail
expect(getEndpointHealthLabel(endpoint)).toBe('-')
expect(getEndpointHealthBarWidth(endpoint)).toBe('100%')
expect(getEndpointDotColor(endpoint)).toBe('bg-muted-foreground/40')
expect(getEndpointTooltip(endpoint, 'zh-CN')).toContain('暂无健康数据')
},
)
it.each([
{ is_active: false },
{ active_keys: 0 },
{ active_keys: 0, total_keys: 0 },
])('does not show a health percentage for unavailable endpoint %s', (overrides) => {
const endpoint = { ...availableEndpoint, ...overrides }
expect(getEndpointHealthLabel(endpoint)).toBe('-')
expect(getEndpointHealthBarWidth(endpoint)).toBe('100%')
expect(getEndpointDotColor(endpoint)).toBe('bg-muted-foreground/40')
})
it.each([
[0, '0%', '5%', 'bg-red-500'],
[0.2, '20%', '20%', 'bg-red-500'],
[0.5, '50%', '50%', 'bg-amber-500'],
[0.8, '80%', '80%', 'bg-green-500'],
[1, '100%', '100%', 'bg-green-500'],
[-0.2, '0%', '5%', 'bg-red-500'],
[1.2, '100%', '100%', 'bg-green-500'],
] as const)('renders score %s consistently across label, bar and tooltip', (score, label, width, color) => {
const endpoint = { ...availableEndpoint, health_score: score }
expect(getEndpointHealthLabel(endpoint)).toBe(label)
expect(getEndpointHealthBarWidth(endpoint)).toBe(width)
expect(getEndpointDotColor(endpoint)).toBe(color)
expect(getEndpointTooltip(endpoint, 'zh-CN')).toContain(`健康度 ${label}`)
})
})
describe('getEndpointTooltip', () => {
it('formats the internal Codex Live id at the user-visible tooltip boundary', () => {
@@ -32,11 +32,29 @@ export function isEndpointAvailable(endpoint: EndpointHealthDetail): boolean {
return getEndpointStatus(endpoint) === 'available'
}
function getEndpointHealthScore(endpoint: EndpointHealthDetail): number | null {
const score = endpoint.health_score
if (!isEndpointAvailable(endpoint) || typeof score !== 'number' || !Number.isFinite(score)) {
return null
}
return Math.min(1, Math.max(0, score))
}
export function getEndpointHealthLabel(endpoint: EndpointHealthDetail): string {
const score = getEndpointHealthScore(endpoint)
return score === null ? '-' : `${(score * 100).toFixed(0)}%`
}
export function getEndpointHealthBarWidth(endpoint: EndpointHealthDetail): string {
const score = getEndpointHealthScore(endpoint)
return score === null ? '100%' : `${Math.max(score * 100, 5)}%`
}
/**
* 根据健康分数获取颜色
*/
export function getHealthScoreColor(score: number | undefined | null): string {
if (score === undefined || score === null) {
if (score === undefined || score === null || !Number.isFinite(score)) {
return 'bg-muted-foreground/40'
}
if (score >= 0.8) return 'bg-green-500'
@@ -48,10 +66,7 @@ export function getHealthScoreColor(score: number | undefined | null): string {
* 端点不可用时进度条颜色
*/
export function getEndpointDotColor(endpoint: EndpointHealthDetail): string {
if (!isEndpointAvailable(endpoint)) {
return 'bg-muted-foreground/40'
}
return getHealthScoreColor(endpoint.health_score)
return getHealthScoreColor(getEndpointHealthScore(endpoint))
}
/**
@@ -70,8 +85,8 @@ export function getEndpointTooltip(endpoint: EndpointHealthDetail, locale: Local
case 'keys_disabled':
return `${format}: ${t('无可用密钥')}`
case 'available': {
const score = endpoint.health_score
if (score === undefined || score === null) {
const score = getEndpointHealthScore(endpoint)
if (score === null) {
return `${format}: ${t('暂无健康数据')}`
}
return `${format}: ${t('健康度')} ${(score * 100).toFixed(0)}%`