mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
fix(providers): correct endpoint health aggregation and display
This commit is contained in:
@@ -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)}%`
|
||||
|
||||
Reference in New Issue
Block a user