feat(default): add model performance badges

Add a batched performance summary API for model square cards and show compact latency, throughput, and status metrics without increasing card size. Also fix OTP verification form submission.
This commit is contained in:
CaIon
2026-05-06 22:20:43 +08:00
parent d98f0e8ac3
commit e8cfb546fa
16 changed files with 316 additions and 34 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
)}
/>
<Button className='mt-2 w-full' disabled={!isFormValid || isLoading}>
<Button type='submit' className='mt-2 w-full' disabled={!isFormValid || isLoading}>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')}
</Button>
+23
View File
@@ -38,6 +38,29 @@ export type PerformanceMetricsData = {
}
}
export type PerfModelSummary = {
model_name: string
avg_latency_ms: number
success_rate: number
avg_tps: number
request_count: number
}
export type PerfSummaryAllData = {
success: boolean
message?: string
data: {
models: PerfModelSummary[]
}
}
export async function getPerfMetricsSummary(
hours = 24
): Promise<PerfSummaryAllData> {
const res = await api.get(`/api/perf-metrics/summary?hours=${hours}`)
return res.data
}
export async function getPerfMetrics(
modelName: string,
hours = 24
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { getPerfMetricsSummary } from '../api'
import { DEFAULT_PRICING_PAGE_SIZE, DEFAULT_TOKEN_UNIT } from '../constants'
import type { PricingModel, TokenUnit } from '../types'
import { ModelCard } from './model-card'
import type { ModelPerfBadgeData } from './model-perf-badge'
export interface ModelCardGridProps {
models: PricingModel[]
@@ -22,6 +25,13 @@ export function ModelCardGrid(props: ModelCardGridProps) {
const tokenUnit = props.tokenUnit ?? DEFAULT_TOKEN_UNIT
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
const perfQuery = useQuery({
queryKey: ['perf-metrics-summary'],
queryFn: () => getPerfMetricsSummary(24),
staleTime: 60 * 1000,
retry: false,
})
useEffect(() => {
setPage(1)
}, [props.models])
@@ -31,6 +41,16 @@ export function ModelCardGrid(props: ModelCardGridProps) {
return props.models.slice(start, start + pageSize)
}, [page, pageSize, props.models])
const perfMap = useMemo(() => {
const map = new Map<string, ModelPerfBadgeData>()
for (const model of perfQuery.data?.data?.models ?? []) {
if (model.request_count > 0) {
map.set(model.model_name, model)
}
}
return map
}, [perfQuery.data])
if (props.models.length === 0) {
return null
}
@@ -46,6 +66,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
showRechargePrice={props.showRechargePrice}
perf={perfMap.get(model.model_name || '')}
onClick={() => props.onModelClick(model.model_name || '')}
/>
))}
+37 -32
View File
@@ -14,6 +14,8 @@ import { parseTags } from '../lib/filters'
import { isTokenBasedModel } from '../lib/model-helpers'
import { formatPrice, formatRequestPrice } from '../lib/price'
import type { PricingModel, TokenUnit } from '../types'
import { ModelPerfBadge } from './model-perf-badge'
import type { ModelPerfBadgeData } from './model-perf-badge'
export interface ModelCardProps {
model: PricingModel
@@ -22,6 +24,7 @@ export interface ModelCardProps {
usdExchangeRate?: number
tokenUnit?: TokenUnit
showRechargePrice?: boolean
perf?: ModelPerfBadgeData
}
export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
@@ -69,7 +72,7 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
return (
<div
className={cn(
'group flex flex-col rounded-xl border p-3 transition-colors sm:p-5',
'group relative flex flex-col rounded-xl border p-3 transition-colors sm:p-5',
'hover:bg-muted/20'
)}
>
@@ -206,41 +209,43 @@ export const ModelCard = memo(function ModelCard(props: ModelCardProps) {
{props.model.description || t('No description available.')}
</p>
{/* Footer row 1: group + billing type */}
<div className='mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 sm:mt-4'>
{primaryGroup && (
{/* Footer: left metadata and right performance summary share row alignment */}
<div className='mt-2 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 gap-y-1 sm:mt-4'>
<div className='flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1'>
{primaryGroup && (
<span className='text-muted-foreground text-xs font-medium'>
{primaryGroup} {t('Groups')}
</span>
)}
<span className='text-muted-foreground text-xs font-medium'>
{primaryGroup} {t('Groups')}
{isTokenBased ? t('Token-based') : t('Per Request')}
</span>
)}
<span className='text-muted-foreground text-xs font-medium'>
{isTokenBased ? t('Token-based') : t('Per Request')}
</span>
{isDynamicPricing && (
<StatusBadge
label={t('Dynamic Pricing')}
variant='warning'
copyable={false}
size='sm'
/>
)}
</div>
{isDynamicPricing && (
<StatusBadge
label={t('Dynamic Pricing')}
variant='warning'
copyable={false}
size='sm'
/>
)}
</div>
<ModelPerfBadge perf={props.perf} className='row-span-2 self-start' />
{/* Footer row 2: endpoint + tag chips */}
<div className='mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:mt-2 sm:gap-x-3 sm:gap-y-1'>
{bottomTags.map((item) => (
<span key={item} className='text-muted-foreground/70 text-xs'>
{item}
<div className='flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-0.5 sm:gap-x-3 sm:gap-y-1'>
{bottomTags.map((item) => (
<span key={item} className='text-muted-foreground/70 text-xs'>
{item}
</span>
))}
<span className='text-muted-foreground/50 text-xs'>
{tokenUnitLabel}
</span>
))}
<span className='text-muted-foreground/50 text-xs'>
{tokenUnitLabel}
</span>
{hiddenCount > 0 && (
<span className='text-muted-foreground/40 text-xs'>
+{hiddenCount}
</span>
)}
{hiddenCount > 0 && (
<span className='text-muted-foreground/40 text-xs'>
+{hiddenCount}
</span>
)}
</div>
</div>
</div>
)
@@ -0,0 +1,77 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { formatLatency, formatThroughput } from '../lib/mock-stats'
export type ModelPerfBadgeData = {
avg_latency_ms: number
success_rate: number
avg_tps: number
}
export interface ModelPerfBadgeProps
extends React.HTMLAttributes<HTMLDivElement> {
perf: ModelPerfBadgeData | undefined
}
function formatCompactThroughput(tps: number): string {
return formatThroughput(tps).replace(' t/s', 'tps')
}
export const ModelPerfBadge = memo(function ModelPerfBadge(
props: ModelPerfBadgeProps
) {
const { t } = useTranslation()
if (!props.perf) {
return null
}
const { avg_latency_ms, avg_tps, success_rate } = props.perf
let statusColor = 'bg-emerald-500'
if (success_rate < 99) {
statusColor = 'bg-red-500'
} else if (success_rate < 99.9) {
statusColor = 'bg-amber-500'
}
return (
<div
className={cn(
'hidden w-[132px] grid-cols-[38px_48px_30px] gap-x-2 text-right tabular-nums min-[460px]:grid',
props.className
)}
>
<div title={t('Average latency')} className='min-w-0'>
<div className='text-muted-foreground/55 text-[10px] leading-4'>
{t('Latency short')}
</div>
<div className='text-muted-foreground/80 whitespace-nowrap font-mono text-xs leading-4'>
{avg_latency_ms > 0 ? formatLatency(avg_latency_ms) : '—'}
</div>
</div>
<div title={t('Throughput')} className='min-w-0'>
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
{t('Throughput short')}
</div>
<div className='text-muted-foreground/80 whitespace-nowrap font-mono text-xs leading-4'>
{formatCompactThroughput(avg_tps)}
</div>
</div>
<div
title={`${t('Success rate')}: ${success_rate.toFixed(1)}%`}
className='min-w-0'
>
<div className='text-muted-foreground/55 truncate text-[10px] leading-4'>
{t('Status short')}
</div>
<div className='flex h-4 items-center justify-end gap-0.5'>
<span className='bg-muted-foreground/10 h-2 w-1 rounded-full' />
<span className='bg-muted-foreground/15 h-2.5 w-1 rounded-full' />
<span className={cn('h-3 w-1 rounded-full', statusColor)} />
</div>
</div>
</div>
)
})