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:
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user