feat(performance): update performance metrics handling and UI components
This commit is contained in:
+135
-213
@@ -18,19 +18,10 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Activity, Gauge, HeartPulse, Timer } from 'lucide-react'
|
||||
import { Gauge, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
formatLatency,
|
||||
@@ -40,7 +31,7 @@ import {
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
|
||||
const PERFORMANCE_WINDOW_HOURS = 24
|
||||
const TOP_MODEL_LIMIT = 8
|
||||
const TOP_MODEL_LIMIT = 5
|
||||
|
||||
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
|
||||
|
||||
@@ -51,121 +42,55 @@ type PerformanceSummary = {
|
||||
successRate: number
|
||||
}
|
||||
|
||||
function weightedAverage(
|
||||
function simpleAverage(
|
||||
rows: PerfModelSummary[],
|
||||
metric: WeightedMetric,
|
||||
isValid: (value: number) => boolean
|
||||
): number {
|
||||
let total = 0
|
||||
let weight = 0
|
||||
let count = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const value = Number(row[metric])
|
||||
const requestCount = Number(row.request_count) || 0
|
||||
if (requestCount <= 0 || !isValid(value)) continue
|
||||
|
||||
total += value * requestCount
|
||||
weight += requestCount
|
||||
if (!isValid(value)) continue
|
||||
total += value
|
||||
count++
|
||||
}
|
||||
|
||||
return weight > 0 ? total / weight : 0
|
||||
return count > 0 ? total / count : NaN
|
||||
}
|
||||
|
||||
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
|
||||
const totalRequests = rows.reduce(
|
||||
(sum, row) => sum + (Number(row.request_count) || 0),
|
||||
0
|
||||
)
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
totalRequests: rows.length,
|
||||
avgLatencyMs: Math.round(
|
||||
weightedAverage(
|
||||
simpleAverage(
|
||||
rows,
|
||||
'avg_latency_ms',
|
||||
(value) => Number.isFinite(value) && value > 0
|
||||
)
|
||||
),
|
||||
avgTps: weightedAverage(
|
||||
avgTps: simpleAverage(
|
||||
rows,
|
||||
'avg_tps',
|
||||
(value) => Number.isFinite(value) && value > 0
|
||||
),
|
||||
successRate: weightedAverage(rows, 'success_rate', Number.isFinite),
|
||||
successRate: simpleAverage(rows, 'success_rate', Number.isFinite),
|
||||
}
|
||||
}
|
||||
|
||||
function successRateClassName(successRate: number): string {
|
||||
if (successRate >= 99.9) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (successRate >= 99) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-rose-600 dark:text-rose-400'
|
||||
if (!Number.isFinite(successRate)) return 'text-muted-foreground'
|
||||
if (successRate >= 99.9) return 'text-success'
|
||||
if (successRate >= 99) return 'text-warning'
|
||||
return 'text-destructive'
|
||||
}
|
||||
|
||||
function successDotClassName(successRate: number): string {
|
||||
if (successRate >= 99.9) return 'bg-emerald-500'
|
||||
if (successRate >= 99) return 'bg-amber-500'
|
||||
return 'bg-rose-500'
|
||||
}
|
||||
|
||||
function PerformanceMetricItem(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
loading?: boolean
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
|
||||
return (
|
||||
<div className='px-3 py-2.5 sm:px-5 sm:py-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon
|
||||
className='text-muted-foreground/60 size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
{props.label}
|
||||
</div>
|
||||
</div>
|
||||
{props.loading ? (
|
||||
<div className='mt-2 space-y-1.5'>
|
||||
<Skeleton className='h-7 w-20' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
|
||||
{props.hint}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PerformanceTableHeader(props: { description: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1.5 border-b px-3 py-2 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Activity className='text-muted-foreground/60 size-4' />
|
||||
<div className='text-sm font-semibold'>
|
||||
{t('Model performance metrics')}
|
||||
</div>
|
||||
</div>
|
||||
<span className='text-muted-foreground text-xs'>{props.description}</span>
|
||||
</div>
|
||||
)
|
||||
if (!Number.isFinite(successRate)) return 'bg-muted-foreground'
|
||||
if (successRate >= 99.9) return 'bg-success'
|
||||
if (successRate >= 99) return 'bg-warning'
|
||||
return 'bg-destructive'
|
||||
}
|
||||
|
||||
export function PerformanceOverview() {
|
||||
@@ -178,139 +103,136 @@ export function PerformanceOverview() {
|
||||
})
|
||||
|
||||
const models = useMemo(
|
||||
() =>
|
||||
[...(metricsQuery.data?.data.models ?? [])]
|
||||
.filter((model) => Number(model.request_count) > 0)
|
||||
.sort((a, b) => b.request_count - a.request_count),
|
||||
() => metricsQuery.data?.data.models ?? [],
|
||||
[metricsQuery.data]
|
||||
)
|
||||
const summary = useMemo(() => buildPerformanceSummary(models), [models])
|
||||
const topModels = useMemo(() => models.slice(0, TOP_MODEL_LIMIT), [models])
|
||||
const loading = metricsQuery.isLoading
|
||||
const hasData = models.length > 0
|
||||
const description = t('Performance metrics for the last 24 hours')
|
||||
|
||||
if (!loading && !hasData) {
|
||||
return (
|
||||
<div className='text-muted-foreground overflow-hidden rounded-lg border px-4 py-3 text-center text-xs'>
|
||||
{t('No performance data available')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='space-y-3 sm:space-y-4'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-4'>
|
||||
<PerformanceMetricItem
|
||||
icon={Activity}
|
||||
label={t('Requests (24h)')}
|
||||
value={formatNumber(summary.totalRequests)}
|
||||
hint={t('Monitored relay requests')}
|
||||
loading={loading}
|
||||
/>
|
||||
<PerformanceMetricItem
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(summary.avgLatencyMs)}
|
||||
hint={t('Weighted by request count')}
|
||||
loading={loading}
|
||||
/>
|
||||
<PerformanceMetricItem
|
||||
icon={Gauge}
|
||||
label={t('Throughput')}
|
||||
value={formatThroughput(summary.avgTps)}
|
||||
hint='TPS'
|
||||
loading={loading}
|
||||
/>
|
||||
<PerformanceMetricItem
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(summary.successRate)}
|
||||
hint={t('Weighted by request count')}
|
||||
loading={loading}
|
||||
valueClassName={successRateClassName(summary.successRate)}
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-2.5 px-4 py-2.5 sm:px-5 sm:py-3'>
|
||||
{/* Title */}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<HeartPulse
|
||||
className='text-muted-foreground/60 size-3.5 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-xs font-semibold whitespace-nowrap'>
|
||||
{t('Performance health')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<PerformanceTableHeader description={description} />
|
||||
{!loading && !hasData ? (
|
||||
<div className='text-muted-foreground p-6 text-center text-sm'>
|
||||
{t('No performance data available')}
|
||||
{/* Separator */}
|
||||
<div className='bg-border hidden h-4 w-px sm:block' />
|
||||
|
||||
{/* 3 KPI inline metrics */}
|
||||
{loading ? (
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-3 w-14' />
|
||||
<Skeleton className='h-4 w-16' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-x-auto'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead>{t('Model')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Requests (24h)')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Throughput')}
|
||||
</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className='h-4 w-40' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Skeleton className='ml-auto h-4 w-16' />
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Skeleton className='ml-auto h-4 w-20' />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
: topModels.map((model) => (
|
||||
<TableRow key={model.model_name}>
|
||||
<TableCell className='max-w-[220px] truncate font-mono'>
|
||||
{model.model_name}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono tabular-nums'>
|
||||
{formatNumber(model.request_count)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono tabular-nums'>
|
||||
{formatLatency(model.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right font-mono tabular-nums'>
|
||||
{formatThroughput(model.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
'text-right font-mono font-semibold tabular-nums',
|
||||
successRateClassName(model.success_rate)
|
||||
)}
|
||||
>
|
||||
<span className='inline-flex items-center justify-end gap-1.5'>
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
successDotClassName(model.success_rate)
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
|
||||
<InlineMetric
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(summary.successRate)}
|
||||
valueClassName={successRateClassName(summary.successRate)}
|
||||
/>
|
||||
<InlineMetric
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(summary.avgLatencyMs)}
|
||||
/>
|
||||
<InlineMetric
|
||||
icon={Gauge}
|
||||
label={t('Throughput')}
|
||||
value={formatThroughput(summary.avgTps)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
<div className='bg-border hidden h-4 w-px lg:block' />
|
||||
|
||||
{/* Top models inline badges */}
|
||||
{!loading && hasData && (
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
{topModels.map((model) => (
|
||||
<ModelBadge key={model.model_name} model={model} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineMetric(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Icon
|
||||
className='text-muted-foreground/50 size-3 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-muted-foreground text-[11px]'>{props.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-xs font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelBadge(props: { model: PerfModelSummary }) {
|
||||
const model = props.model
|
||||
|
||||
return (
|
||||
<span className='bg-muted/50 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1'>
|
||||
<span className='max-w-[10rem] truncate font-mono text-[11px]'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
successDotClassName(model.success_rate)
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[11px] font-semibold tabular-nums',
|
||||
successRateClassName(model.success_rate)
|
||||
)}
|
||||
>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useApiInfo } from '../../hooks/use-status-data'
|
||||
import { AnnouncementsPanel } from './announcements-panel'
|
||||
import { ApiInfoPanel } from './api-info-panel'
|
||||
import { FAQPanel } from './faq-panel'
|
||||
import { PerformanceHealthPanel } from './performance-health-panel'
|
||||
import { SummaryCards } from './summary-cards'
|
||||
import { UptimePanel } from './uptime-panel'
|
||||
|
||||
@@ -716,6 +717,11 @@ export function OverviewDashboard() {
|
||||
|
||||
<CardStaggerContainer className='grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
|
||||
<div className='grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2'>
|
||||
{isAdmin && (
|
||||
<CardStaggerItem className='lg:col-span-2'>
|
||||
<PerformanceHealthPanel />
|
||||
</CardStaggerItem>
|
||||
)}
|
||||
<CardStaggerItem>
|
||||
<ApiInfoPanel />
|
||||
</CardStaggerItem>
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Gauge, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
formatLatency,
|
||||
formatThroughput,
|
||||
formatUptimePct,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
|
||||
const PERFORMANCE_WINDOW_HOURS = 24
|
||||
const TOP_MODEL_LIMIT = 5
|
||||
|
||||
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
|
||||
|
||||
function simpleAverage(
|
||||
rows: PerfModelSummary[],
|
||||
metric: WeightedMetric,
|
||||
isValid: (value: number) => boolean
|
||||
): number {
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const row of rows) {
|
||||
const value = Number(row[metric])
|
||||
if (!isValid(value)) continue
|
||||
total += value
|
||||
count++
|
||||
}
|
||||
return count > 0 ? total / count : NaN
|
||||
}
|
||||
|
||||
function rateTextClass(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return 'text-muted-foreground'
|
||||
if (rate >= 99.9) return 'text-success'
|
||||
if (rate >= 99) return 'text-warning'
|
||||
return 'text-destructive'
|
||||
}
|
||||
|
||||
function rateDotClass(rate: number): string {
|
||||
if (!Number.isFinite(rate)) return 'bg-muted-foreground'
|
||||
if (rate >= 99.9) return 'bg-success'
|
||||
if (rate >= 99) return 'bg-warning'
|
||||
return 'bg-destructive'
|
||||
}
|
||||
|
||||
export function PerformanceHealthPanel() {
|
||||
const { t } = useTranslation()
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', PERFORMANCE_WINDOW_HOURS],
|
||||
queryFn: () => getPerfMetricsSummary(PERFORMANCE_WINDOW_HOURS),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const models = useMemo(
|
||||
() => metricsQuery.data?.data.models ?? [],
|
||||
[metricsQuery.data]
|
||||
)
|
||||
|
||||
const summary = useMemo(() => {
|
||||
return {
|
||||
avgLatencyMs: Math.round(
|
||||
simpleAverage(models, 'avg_latency_ms', (v) => Number.isFinite(v) && v > 0)
|
||||
),
|
||||
avgTps: simpleAverage(models, 'avg_tps', (v) => Number.isFinite(v) && v > 0),
|
||||
successRate: simpleAverage(models, 'success_rate', Number.isFinite),
|
||||
}
|
||||
}, [models])
|
||||
|
||||
const topModels = useMemo(() => models.slice(0, TOP_MODEL_LIMIT), [models])
|
||||
const loading = metricsQuery.isLoading
|
||||
const hasData = models.length > 0
|
||||
|
||||
return (
|
||||
<section className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
|
||||
<div className='flex items-center gap-2 border-b px-4 py-3 sm:px-5'>
|
||||
<HeartPulse className='text-muted-foreground/60 size-4 shrink-0' aria-hidden='true' />
|
||||
<h3 className='text-sm font-semibold'>{t('Performance health')}</h3>
|
||||
<span className='text-muted-foreground ml-auto text-xs'>
|
||||
{t('Performance metrics for the last 24 hours')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_14rem]'>
|
||||
{/* KPI metrics */}
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<MetricCell
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(summary.successRate)}
|
||||
loading={loading}
|
||||
valueClassName={rateTextClass(summary.successRate)}
|
||||
/>
|
||||
<MetricCell
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(summary.avgLatencyMs)}
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCell
|
||||
icon={Gauge}
|
||||
label={t('Throughput')}
|
||||
value={formatThroughput(summary.avgTps)}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top models */}
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-muted-foreground mb-1 text-[11px] font-medium'>
|
||||
{t('Top models by traffic')}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className='space-y-1'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-5 w-full rounded' />
|
||||
))}
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<span className='text-muted-foreground/60 text-xs'>
|
||||
{t('No performance data available')}
|
||||
</span>
|
||||
) : (
|
||||
<div className='space-y-0.5'>
|
||||
{topModels.map((model) => (
|
||||
<div
|
||||
key={model.model_name}
|
||||
className='flex items-center justify-between gap-2 rounded px-1.5 py-1'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate font-mono text-[11px]'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
<span className='inline-flex shrink-0 items-center gap-1'>
|
||||
<span
|
||||
className={cn('size-1.5 rounded-full', rateDotClass(model.success_rate))}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[11px] font-semibold tabular-nums',
|
||||
rateTextClass(model.success_rate)
|
||||
)}
|
||||
>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCell(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
loading: boolean
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='bg-muted/40 rounded-xl px-3 py-2.5'>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium'>
|
||||
<Icon className='size-3 shrink-0' aria-hidden='true' />
|
||||
<span className='truncate'>{props.label}</span>
|
||||
</div>
|
||||
{props.loading ? (
|
||||
<Skeleton className='mt-1.5 h-5 w-16' />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 font-mono text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+152
-34
@@ -19,12 +19,18 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowRight, CreditCard } from 'lucide-react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Flame,
|
||||
ShieldCheck,
|
||||
TrendingDown,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
|
||||
@@ -87,12 +93,62 @@ function buildSummarySparklines(
|
||||
}
|
||||
}
|
||||
|
||||
function getSummarySparkline(
|
||||
key: string,
|
||||
sparklineData: Record<SummarySparklineKey, number[]>
|
||||
): number[] | undefined {
|
||||
if (key === 'usage') return sparklineData.usage
|
||||
if (key === 'requests') return sparklineData.requests
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getRunwayDays(remainQuota: number, recentUsage: number): number | null {
|
||||
if (remainQuota <= 0 || recentUsage <= 0) return null
|
||||
const days = remainQuota / recentUsage
|
||||
if (!Number.isFinite(days)) return null
|
||||
return days
|
||||
}
|
||||
|
||||
type HealthLevel = 'healthy' | 'caution' | 'critical'
|
||||
|
||||
function getHealthLevel(
|
||||
remainQuota: number,
|
||||
recentUsage: number
|
||||
): HealthLevel {
|
||||
if (remainQuota <= 0) return 'critical'
|
||||
const days = getRunwayDays(remainQuota, recentUsage)
|
||||
if (days !== null && days < 3) return 'caution'
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
const HEALTH_CONFIG: Record<
|
||||
HealthLevel,
|
||||
{ dotClass: string; labelKey: string }
|
||||
> = {
|
||||
healthy: {
|
||||
dotClass: 'bg-success',
|
||||
labelKey: 'Healthy',
|
||||
},
|
||||
caution: {
|
||||
dotClass: 'bg-warning',
|
||||
labelKey: 'Low balance',
|
||||
},
|
||||
critical: {
|
||||
dotClass: 'bg-destructive',
|
||||
labelKey: 'Balance depleted',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
export function SummaryCards() {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((state) => state.auth.user)
|
||||
const { status, loading } = useStatus()
|
||||
|
||||
const summaryTimeRange = useMemo(() => computeTimeRange(1), [])
|
||||
const remainQuota = Number(user?.quota ?? 0)
|
||||
const usedQuota = Number(user?.used_quota ?? 0)
|
||||
const requestCount = Number(user?.request_count ?? 0)
|
||||
|
||||
const usageTrendQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -112,16 +168,11 @@ export function SummaryCards() {
|
||||
})
|
||||
|
||||
const summaryValues = useMemo(() => {
|
||||
const remainQuota = Number(user?.quota ?? 0)
|
||||
const usedQuota = Number(user?.used_quota ?? 0)
|
||||
const requestCount = Number(user?.request_count ?? 0)
|
||||
|
||||
return {
|
||||
remainDisplay: formatQuota(remainQuota),
|
||||
usedDisplay: formatQuota(usedQuota),
|
||||
requestCountDisplay: formatNumber(requestCount),
|
||||
}
|
||||
}, [user])
|
||||
}, [requestCount, usedQuota])
|
||||
|
||||
const currencyEnabledFromStore = isCurrencyDisplayEnabled()
|
||||
const statusCurrencyFlag =
|
||||
@@ -138,37 +189,53 @@ export function SummaryCards() {
|
||||
() =>
|
||||
buildSummarySparklines(
|
||||
usageTrendQuery.data?.data ?? [],
|
||||
Number(user?.quota ?? 0),
|
||||
remainQuota,
|
||||
summaryTimeRange.start_timestamp,
|
||||
summaryTimeRange.end_timestamp
|
||||
),
|
||||
[
|
||||
remainQuota,
|
||||
summaryTimeRange.end_timestamp,
|
||||
summaryTimeRange.start_timestamp,
|
||||
usageTrendQuery.data?.data,
|
||||
user?.quota,
|
||||
]
|
||||
)
|
||||
|
||||
const recentUsage = useMemo(
|
||||
() =>
|
||||
(usageTrendQuery.data?.data ?? []).reduce(
|
||||
(total, item) => total + (Number(item.quota) || 0),
|
||||
0
|
||||
),
|
||||
[usageTrendQuery.data?.data]
|
||||
)
|
||||
|
||||
const healthLevel = getHealthLevel(remainQuota, recentUsage)
|
||||
const healthCfg = HEALTH_CONFIG[healthLevel]
|
||||
const runwayDays = getRunwayDays(remainQuota, recentUsage)
|
||||
|
||||
const todayUsageDisplay = formatQuota(recentUsage)
|
||||
|
||||
const items = useSummaryCardsConfig({
|
||||
...summaryValues,
|
||||
todayUsageDisplay,
|
||||
currencyEnabled,
|
||||
currencyLabel,
|
||||
}).map((config, index) => {
|
||||
const tones = ['rose', 'teal', 'gray'] as const
|
||||
|
||||
return {
|
||||
key: config.key,
|
||||
title: config.title,
|
||||
value: config.value,
|
||||
desc: config.description,
|
||||
icon: config.icon,
|
||||
tone: tones[index] ?? 'gray',
|
||||
sparkline:
|
||||
config.key === 'balance'
|
||||
? sparklineData.balance
|
||||
: config.key === 'usage'
|
||||
? sparklineData.usage
|
||||
: sparklineData.requests,
|
||||
config.key === 'todayUsage'
|
||||
? sparklineData.usage
|
||||
: getSummarySparkline(config.key, sparklineData),
|
||||
sparklineVariant: 'line' as const,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -189,7 +256,7 @@ export function SummaryCards() {
|
||||
<StaggerContainer className='grid gap-3 md:grid-cols-3'>
|
||||
{items.map((it) => (
|
||||
<StaggerItem
|
||||
key={it.title}
|
||||
key={it.key}
|
||||
className='bg-background/60 rounded-xl border p-3'
|
||||
>
|
||||
<StatCard
|
||||
@@ -199,6 +266,7 @@ export function SummaryCards() {
|
||||
icon={it.icon}
|
||||
tone={it.tone}
|
||||
sparkline={it.sparkline}
|
||||
sparklineVariant={it.sparklineVariant}
|
||||
loading={loading}
|
||||
/>
|
||||
</StaggerItem>
|
||||
@@ -206,28 +274,78 @@ export function SummaryCards() {
|
||||
</StaggerContainer>
|
||||
</div>
|
||||
|
||||
<div className='bg-warning/10 flex flex-col justify-between gap-5 border-t p-4 sm:p-5 xl:border-t-0 xl:border-l'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Credit remaining')}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-mono text-2xl font-semibold tracking-tight'>
|
||||
{summaryValues.remainDisplay}
|
||||
<div className='bg-warning/10 flex flex-col justify-between gap-4 border-t p-4 sm:p-5 xl:border-t-0 xl:border-l'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Credit remaining')}
|
||||
</span>
|
||||
<span className='flex items-center gap-1.5'>
|
||||
<span
|
||||
className={cn('size-1.5 rounded-full', healthCfg.dotClass)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-muted-foreground text-[11px] font-medium'>
|
||||
{t(healthCfg.labelKey)}
|
||||
</span>
|
||||
</span>
|
||||
<CreditCard
|
||||
className='text-muted-foreground size-4'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
{currencyEnabled
|
||||
? `${t('Displayed in')} ${currencyLabel}`
|
||||
: t('Balance is shown in quota units')}
|
||||
</p>
|
||||
|
||||
<div className='font-mono text-2xl font-semibold tracking-tight'>
|
||||
{formatQuota(remainQuota)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div className='bg-background/60 rounded-lg px-2.5 py-2'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-[11px] leading-none font-medium'>
|
||||
<Flame className='size-3 shrink-0' aria-hidden='true' />
|
||||
<span className='truncate'>{t('Last 24h usage')}</span>
|
||||
</div>
|
||||
<div className='text-foreground mt-1.5 truncate text-xs font-semibold tabular-nums'>
|
||||
{formatQuota(recentUsage)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-background/60 rounded-lg px-2.5 py-2'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-[11px] leading-none font-medium'>
|
||||
{runwayDays !== null && runwayDays < 3 ? (
|
||||
<TrendingDown
|
||||
className='size-3 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
) : (
|
||||
<ShieldCheck
|
||||
className='size-3 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{t('Runway')}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 truncate text-xs font-semibold tabular-nums',
|
||||
healthLevel === 'critical' && 'text-destructive',
|
||||
healthLevel === 'caution' && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{runwayDays !== null
|
||||
? runwayDays < 1
|
||||
? t('Less than 1 day left')
|
||||
: runwayDays > 999
|
||||
? `999+ ${t('days')}`
|
||||
: `~${formatNumber(Math.floor(runwayDays))} ${t('days')}`
|
||||
: remainQuota <= 0
|
||||
? t('Balance depleted')
|
||||
: t('No recent usage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className='justify-between' render={<Link to='/wallet' />}>
|
||||
<span>{t('Recharge')}</span>
|
||||
|
||||
<Button
|
||||
className='justify-between'
|
||||
render={<Link to='/wallet' />}
|
||||
>
|
||||
<span>{t('Wallet')}</span>
|
||||
<ArrowRight data-icon='inline-end' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+161
-15
@@ -16,12 +16,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
type StatCardTone = 'rose' | 'teal' | 'gray'
|
||||
type StatCardSparklineVariant = 'bars' | 'line'
|
||||
type StatCardDetailTone =
|
||||
| 'default'
|
||||
| 'muted'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'destructive'
|
||||
|
||||
export interface StatCardDetail {
|
||||
label: string
|
||||
value: string
|
||||
tone?: StatCardDetailTone
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
@@ -29,6 +42,8 @@ interface StatCardProps {
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
sparkline?: number[]
|
||||
sparklineVariant?: StatCardSparklineVariant
|
||||
details?: StatCardDetail[]
|
||||
tone?: StatCardTone
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
@@ -41,6 +56,20 @@ const TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
|
||||
}
|
||||
|
||||
const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'text-warning',
|
||||
teal: 'text-primary',
|
||||
gray: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const DETAIL_TONE_CLASSES: Record<StatCardDetailTone, string> = {
|
||||
default: 'text-foreground',
|
||||
muted: 'text-muted-foreground',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function normalizeSparkline(values?: number[]): number[] {
|
||||
if (!values?.length) return []
|
||||
|
||||
@@ -51,10 +80,133 @@ function normalizeSparkline(values?: number[]): number[] {
|
||||
return sanitized.map((value) => Math.max(8, (value / max) * 100))
|
||||
}
|
||||
|
||||
function buildLineSparkline(values?: number[]) {
|
||||
if (!values?.length) return null
|
||||
|
||||
const sanitized = values.map((value) => Math.max(0, Number(value) || 0))
|
||||
const width = 160
|
||||
const height = 36
|
||||
const padding = 3
|
||||
const max = Math.max(...sanitized)
|
||||
const min = Math.min(...sanitized)
|
||||
const range = max - min
|
||||
|
||||
const points = sanitized.map((value, index) => {
|
||||
const x =
|
||||
sanitized.length === 1
|
||||
? width / 2
|
||||
: (index / (sanitized.length - 1)) * width
|
||||
const normalized =
|
||||
range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0
|
||||
const y = height - padding - normalized * (height - padding * 2)
|
||||
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const linePath = points
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' ')
|
||||
const firstPoint = points[0]
|
||||
const lastPoint = points[points.length - 1]
|
||||
const areaPath = `${linePath} L ${lastPoint.x} ${height} L ${firstPoint.x} ${height} Z`
|
||||
|
||||
return {
|
||||
areaPath,
|
||||
linePath,
|
||||
}
|
||||
}
|
||||
|
||||
function LineSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
||||
const rawGradientId = useId()
|
||||
const gradientId = `stat-card-line-${rawGradientId.replace(/:/g, '')}`
|
||||
const paths = buildLineSparkline(props.values)
|
||||
|
||||
if (!paths) return <div className='h-8' aria-hidden='true' />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-8 overflow-hidden rounded-lg',
|
||||
LINE_TONE_CLASSES[props.tone]
|
||||
)}
|
||||
aria-hidden='true'
|
||||
>
|
||||
<svg
|
||||
viewBox='0 0 160 36'
|
||||
preserveAspectRatio='none'
|
||||
className='size-full'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1='0' x2='0' y1='0' y2='1'>
|
||||
<stop offset='0%' stopColor='currentColor' stopOpacity='0.24' />
|
||||
<stop offset='100%' stopColor='currentColor' stopOpacity='0' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={paths.areaPath} fill={`url(#${gradientId})`} />
|
||||
<path
|
||||
d={paths.linePath}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2.25'
|
||||
vectorEffect='non-scaling-stroke'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
||||
const sparkline = normalizeSparkline(props.values)
|
||||
|
||||
return (
|
||||
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
|
||||
{sparkline.map((height, index) => (
|
||||
<span
|
||||
key={`spark-${index}`}
|
||||
className={cn(
|
||||
'flex-1 rounded-t-sm bg-linear-to-t',
|
||||
height <= 0 && 'opacity-20',
|
||||
TONE_CLASSES[props.tone]
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCardDetails(props: { details: StatCardDetail[] }) {
|
||||
return (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{props.details.map((detail) => (
|
||||
<div
|
||||
key={detail.label}
|
||||
className='bg-muted/40 rounded-lg border border-transparent px-2.5 py-2'
|
||||
>
|
||||
<div className='text-muted-foreground truncate text-[11px] leading-none font-medium'>
|
||||
{detail.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 truncate text-xs font-semibold tabular-nums',
|
||||
DETAIL_TONE_CLASSES[detail.tone ?? 'default']
|
||||
)}
|
||||
title={detail.value}
|
||||
>
|
||||
{detail.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatCard(props: StatCardProps) {
|
||||
const Icon = props.icon
|
||||
const tone = props.tone ?? 'gray'
|
||||
const sparkline = normalizeSparkline(props.sparkline)
|
||||
const sparklineVariant = props.sparklineVariant ?? 'bars'
|
||||
|
||||
return (
|
||||
<div className='group flex min-h-32 flex-col justify-between gap-3'>
|
||||
@@ -94,19 +246,13 @@ export function StatCard(props: StatCardProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex h-8 items-end gap-1' aria-hidden='true'>
|
||||
{sparkline.map((height, index) => (
|
||||
<span
|
||||
key={`${props.title}-spark-${index}`}
|
||||
className={cn(
|
||||
'flex-1 rounded-t-sm bg-linear-to-t',
|
||||
height <= 0 && 'opacity-20',
|
||||
TONE_CLASSES[tone]
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{props.details?.length ? (
|
||||
<StatCardDetails details={props.details} />
|
||||
) : sparklineVariant === 'line' ? (
|
||||
<LineSparkline values={props.sparkline} tone={tone} />
|
||||
) : (
|
||||
<BarSparkline values={props.sparkline} tone={tone} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Layers,
|
||||
Gauge,
|
||||
Zap,
|
||||
Wallet,
|
||||
Flame,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
type LucideIcon,
|
||||
@@ -83,7 +83,7 @@ export function useModelStatCardsConfig(): StatCardConfig[] {
|
||||
}
|
||||
|
||||
export function useSummaryCardsConfig(totals: {
|
||||
remainDisplay: string
|
||||
todayUsageDisplay: string
|
||||
usedDisplay: string
|
||||
requestCountDisplay: string
|
||||
currencyLabel: string
|
||||
@@ -93,13 +93,13 @@ export function useSummaryCardsConfig(totals: {
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'balance',
|
||||
title: t('Current Balance'),
|
||||
value: totals.remainDisplay,
|
||||
key: 'todayUsage',
|
||||
title: t('Last 24h usage'),
|
||||
value: totals.todayUsageDisplay,
|
||||
description: totals.currencyEnabled
|
||||
? `${t('Remaining quota')} (${totals.currencyLabel})`
|
||||
: t('Remaining quota units'),
|
||||
icon: Wallet,
|
||||
? `${t('Consumed in the last 24 hours')} (${totals.currencyLabel})`
|
||||
: t('Consumed in the last 24 hours'),
|
||||
icon: Flame,
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
|
||||
+22
-22
@@ -109,25 +109,23 @@ function ModelChartsFallback() {
|
||||
|
||||
function PerformanceOverviewFallback() {
|
||||
return (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-2 divide-x sm:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className='px-3 py-2.5 sm:px-5 sm:py-4'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
<Skeleton className='mt-2 h-7 w-20' />
|
||||
<Skeleton className='mt-1.5 h-3.5 w-28' />
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Skeleton className='h-4 w-24' />
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-3 w-14' />
|
||||
<Skeleton className='h-4 w-16' />
|
||||
</div>
|
||||
))}
|
||||
<div className='ml-auto flex items-center gap-2'>
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-5 w-28 rounded-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='flex items-center justify-between border-b px-4 py-3 sm:px-5'>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
<Skeleton className='h-4 w-48' />
|
||||
</div>
|
||||
<Skeleton className='h-44 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,12 +265,14 @@ export function Dashboard() {
|
||||
/>
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
{isAdmin && (
|
||||
<FadeIn delay={0.05}>
|
||||
<Suspense fallback={<PerformanceOverviewFallback />}>
|
||||
<LazyPerformanceOverview />
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
)}
|
||||
<FadeIn delay={0.1}>
|
||||
<Suspense fallback={<PerformanceOverviewFallback />}>
|
||||
<LazyPerformanceOverview />
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.15}>
|
||||
<Suspense fallback={<ModelChartsFallback />}>
|
||||
<LazyConsumptionDistributionChart
|
||||
data={modelData}
|
||||
@@ -286,7 +286,7 @@ export function Dashboard() {
|
||||
/>
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.2}>
|
||||
<FadeIn delay={0.15}>
|
||||
<Suspense fallback={<ModelChartsFallback />}>
|
||||
<LazyModelCharts
|
||||
data={modelData}
|
||||
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function formatThroughput(tps: number): string {
|
||||
if (tps <= 0) return '—'
|
||||
if (!Number.isFinite(tps) || tps <= 0) return '—'
|
||||
if (tps >= 1_000) return `${(tps / 1_000).toFixed(1)}K t/s`
|
||||
return `${tps.toFixed(tps < 10 ? 2 : 1)} t/s`
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export type PerfModelSummary = {
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
avg_tps: number
|
||||
request_count: number
|
||||
request_count?: number
|
||||
}
|
||||
|
||||
export type PerfSummaryAllData = {
|
||||
|
||||
@@ -59,9 +59,7 @@ export function ModelCardGrid(props: ModelCardGridProps) {
|
||||
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)
|
||||
}
|
||||
map.set(model.model_name, model)
|
||||
}
|
||||
return map
|
||||
}, [perfQuery.data])
|
||||
|
||||
@@ -475,14 +475,14 @@ export function RechargeFormCard({
|
||||
</div>
|
||||
{topupLink && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Need a code?')}{' '}
|
||||
{t('Need a redemption code?')}{' '}
|
||||
<a
|
||||
href={topupLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center gap-1 underline-offset-4 hover:underline'
|
||||
>
|
||||
{t('Purchase here')}
|
||||
{t('Get one here')}
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -254,7 +254,7 @@ export function SubscriptionPlansCard({
|
||||
<>
|
||||
<TitledCard
|
||||
title={t('Subscription Plans')}
|
||||
description={t('Purchase a plan to enjoy model benefits')}
|
||||
description={t('Subscribe to a plan for model access')}
|
||||
icon={<Crown className='h-4 w-4' />}
|
||||
contentClassName='space-y-4 sm:space-y-5'
|
||||
>
|
||||
@@ -499,7 +499,7 @@ export function SubscriptionPlansCard({
|
||||
|
||||
{!hasAny && (
|
||||
<p className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Purchase a plan to enjoy model benefits')}
|
||||
{t('Subscribe to a plan for model access')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user