feat(default): add real rankings data

This commit is contained in:
CaIon
2026-05-06 18:20:02 +08:00
parent 0f9f094a48
commit f8cf9c57c4
41 changed files with 1498 additions and 1912 deletions
+2 -6
View File
@@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = {
avg_ttft_ms: number
avg_latency_ms: number
success_rate: number
count: number
success_count: number
ttft_count: number
avg_tps: number
}
export type PerformanceGroup = {
@@ -26,9 +24,7 @@ export type PerformanceGroup = {
avg_ttft_ms: number
avg_latency_ms: number
success_rate: number
request_count: number
success_count: number
ttft_count: number
avg_tps: number
series: PerformanceSeriesPoint[]
}
@@ -28,7 +28,7 @@ function formatDayLabel(date: string): string {
}
// ---------------------------------------------------------------------------
// Latency trend chart (24h, multi-group line chart)
// Latency trend chart (24h, multi-group point-line chart)
// ---------------------------------------------------------------------------
export function LatencyTrendChart(props: {
@@ -52,14 +52,20 @@ export function LatencyTrendChart(props: {
yField: 'ttft',
seriesField: 'group',
smooth: true,
point: { visible: false },
legends: { visible: true, orient: 'top', position: 'start' },
point: {
visible: true,
style: { size: 5, stroke: '#ffffff', lineWidth: 1.5 },
},
line: {
style: { lineWidth: 2 },
},
legends: { visible: false },
tooltip: {
mark: {
title: { value: (d: { time: string }) => d.time },
content: [
{
key: (d: { group: string }) => d.group,
key: t('Average TTFT'),
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
},
],
@@ -83,7 +89,7 @@ export function LatencyTrendChart(props: {
},
],
}
}, [props.series])
}, [props.series, t])
if (props.series.length === 0) {
return (
@@ -116,10 +122,10 @@ export function LatencyTrendChart(props: {
}
// ---------------------------------------------------------------------------
// Uptime bar chart (30 days)
// Uptime trend chart (24h, point-line chart)
// ---------------------------------------------------------------------------
export function UptimeBarChart(props: {
export function UptimeTrendChart(props: {
series: UptimeDayPoint[]
className?: string
}) {
@@ -137,18 +143,25 @@ export function UptimeBarChart(props: {
}))
return {
type: 'bar' as const,
type: 'line' as const,
data: [{ id: 'uptime', values: data }],
xField: 'date',
yField: 'uptime',
bar: {
smooth: true,
line: {
style: { stroke: '#10b981', lineWidth: 2 },
},
point: {
visible: true,
style: {
size: 5,
stroke: '#ffffff',
lineWidth: 1.5,
fill: (datum: { uptime: number }) => {
if (datum.uptime >= 99.9) return '#10b981'
if (datum.uptime >= 99.0) return '#f59e0b'
return '#ef4444'
},
cornerRadius: 2,
},
},
tooltip: {
@@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
<div className={cn('h-56 sm:h-64', props.className)}>
{themeReady && spec && (
<VChart
key={`uptime-${resolvedTheme}`}
key={`uptime-trend-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
@@ -1,12 +1,6 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Activity,
AlertTriangle,
HeartPulse,
Timer,
TrendingUp,
} from 'lucide-react'
import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
@@ -21,18 +15,14 @@ import { GroupBadge } from '@/components/group-badge'
import { getPerfMetrics, type PerformanceGroup } from '../api'
import {
formatLatency,
formatThroughput,
formatUptimePct,
type UptimeDayPoint,
} from '../lib/mock-stats'
import type { PricingModel } from '../types'
import { LatencyTrendChart, UptimeBarChart } from './model-details-charts'
import { LatencyTrendChart, UptimeTrendChart } from './model-details-charts'
import { UptimeSparkline } from './model-details-uptime-sparkline'
const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
notation: 'compact',
maximumFractionDigits: 1,
})
function StatCard(props: {
icon: React.ComponentType<{ className?: string }>
label: string
@@ -71,39 +61,55 @@ type PerformanceRow = {
avg_ttft_ms: number
avg_latency_ms: number
success_rate: number
request_count: number
avg_tps: number
}
function toLatencySeries(groups: PerformanceGroup[]) {
return groups.flatMap((group) =>
group.series
.filter((point) => point.ttft_count > 0 && point.avg_ttft_ms > 0)
.map((point) => ({
timestamp: new Date(point.ts * 1000).toISOString(),
group: group.group,
ttft_ms: point.avg_ttft_ms,
}))
)
const byTs = new Map<number, number[]>()
for (const group of groups) {
for (const point of group.series) {
if (point.avg_ttft_ms <= 0) continue
const current = byTs.get(point.ts) ?? []
current.push(point.avg_ttft_ms)
byTs.set(point.ts, current)
}
}
return Array.from(byTs.entries())
.sort(([a], [b]) => a - b)
.map(([ts, values]) => ({
timestamp: new Date(ts * 1000).toISOString(),
group: 'latency',
ttft_ms: Math.round(
values.reduce((sum, value) => sum + value, 0) / values.length
),
}))
}
function toUptimeSeries(groups: PerformanceGroup[]): UptimeDayPoint[] {
const byTs = new Map<number, { count: number; success: number }>()
const byTs = new Map<number, { rates: number[]; incidents: number }>()
for (const group of groups) {
for (const point of group.series) {
const current = byTs.get(point.ts) ?? { count: 0, success: 0 }
current.count += point.count
current.success += point.success_count
const current = byTs.get(point.ts) ?? { rates: [], incidents: 0 }
if (Number.isFinite(point.success_rate)) {
current.rates.push(point.success_rate)
if (point.success_rate < 100) current.incidents += 1
}
byTs.set(point.ts, current)
}
}
return Array.from(byTs.entries())
.sort(([a], [b]) => a - b)
.map(([ts, value]) => {
const uptime = value.count > 0 ? (value.success / value.count) * 100 : 0
const uptime =
value.rates.length > 0
? value.rates.reduce((sum, rate) => sum + rate, 0) /
value.rates.length
: 0
return {
date: new Date(ts * 1000).toISOString(),
uptime_pct: Math.round(uptime * 100) / 100,
incidents: value.success < value.count ? 1 : 0,
incidents: value.incidents,
outage_minutes: 0,
}
})
@@ -113,23 +119,20 @@ function toGroupUptimeSeries(group: PerformanceGroup): UptimeDayPoint[] {
return group.series.map((point) => ({
date: new Date(point.ts * 1000).toISOString(),
uptime_pct: Math.round(point.success_rate * 100) / 100,
incidents: point.success_count < point.count ? 1 : 0,
incidents: point.success_rate < 100 ? 1 : 0,
outage_minutes: 0,
}))
}
function weightedAverage(
function average(
rows: PerformanceRow[],
field: 'avg_ttft_ms' | 'avg_latency_ms'
): number {
let total = 0
let count = 0
for (const row of rows) {
if (row[field] <= 0 || row.request_count <= 0) continue
total += row[field] * row.request_count
count += row.request_count
}
return count > 0 ? Math.round(total / count) : 0
) {
const values = rows.map((row) => row[field]).filter((value) => value > 0)
if (values.length === 0) return 0
return Math.round(
values.reduce((sum, value) => sum + value, 0) / values.length
)
}
export function ModelDetailsPerformance(props: { model: PricingModel }) {
@@ -147,7 +150,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
avg_ttft_ms: group.avg_ttft_ms,
avg_latency_ms: group.avg_latency_ms,
success_rate: group.success_rate,
request_count: group.request_count,
avg_tps: group.avg_tps,
})),
[groups]
)
@@ -169,15 +172,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
)
}
const ttftValues = performances
.map((p) => p.avg_ttft_ms)
const tpsValues = performances
.map((p) => p.avg_tps)
.filter((value) => value > 0)
const bestTtft = ttftValues.length > 0 ? Math.min(...ttftValues) : 0
const avgLatency = weightedAverage(performances, 'avg_latency_ms')
const totalRequests = performances.reduce((s, p) => s + p.request_count, 0)
const totalSuccess = groups.reduce((s, p) => s + p.success_count, 0)
const avgTps =
tpsValues.length > 0
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
: 0
const avgLatency = average(performances, 'avg_latency_ms')
const successRates = performances
.map((perf) => perf.success_rate)
.filter((value) => Number.isFinite(value))
const successRate =
totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0
successRates.length > 0
? successRates.reduce((sum, value) => sum + value, 0) /
successRates.length
: 0
const incidentCount = uptimeSeries.reduce((s, p) => s + p.incidents, 0)
let intent: 'default' | 'warning' | 'success' = 'warning'
if (successRate >= 99.9) {
@@ -191,18 +201,17 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
return (
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-2 lg:grid-cols-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<StatCard
icon={Timer}
label={t('Best TTFT')}
value={formatLatency(bestTtft)}
hint={t('Lowest median first-token latency')}
label='TPS'
value={formatThroughput(avgTps)}
hint={t('Sustained tokens per second')}
/>
<StatCard
icon={Timer}
label={t('Average latency')}
value={formatLatency(avgLatency)}
hint={t('Across all groups')}
/>
<StatCard
icon={HeartPulse}
@@ -217,25 +226,22 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
}
intent={intent}
/>
<StatCard
icon={TrendingUp}
label={t('Requests (24h)')}
value={COMPACT_NUMBER.format(totalRequests)}
hint={t('Aggregated across enabled groups')}
/>
</div>
<section>
<SectionHeader
icon={Activity}
icon={HeartPulse}
title={t('Per-group performance')}
description={t('Average latency, TTFT, and success rate by group')}
description={t('Average latency, TTFT, TPS, and success rate')}
/>
<div className='overflow-x-auto rounded-lg border'>
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
TPS
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Average TTFT')}
</TableHead>
@@ -243,46 +249,35 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
{t('Average latency')}
</TableHead>
<TableHead
className={`${headerCellClass} min-w-[160px] text-left`}
className={`${headerCellClass} min-w-[180px] text-left`}
>
{t('Success rate')}
</TableHead>
<TableHead className={`${headerCellClass} text-right`}>
{t('Request Count')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{performances.map((perf) => {
const isBestTtft = perf.avg_ttft_ms === bestTtft
return (
<TableRow key={perf.group}>
<TableCell className='py-2.5'>
<GroupBadge group={perf.group} size='sm' />
</TableCell>
<TableCell
className={cn(
'py-2.5 text-right font-mono',
isBestTtft && 'text-emerald-600 dark:text-emerald-400'
)}
>
{formatLatency(perf.avg_ttft_ms)}
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{formatLatency(perf.avg_latency_ms)}
</TableCell>
<TableCell className='py-2.5'>
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{COMPACT_NUMBER.format(perf.request_count)}
</TableCell>
</TableRow>
)
})}
{performances.map((perf) => (
<TableRow key={perf.group}>
<TableCell className='py-2.5'>
<GroupBadge group={perf.group} size='sm' />
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatThroughput(perf.avg_tps)}
</TableCell>
<TableCell className='py-2.5 text-right font-mono'>
{formatLatency(perf.avg_ttft_ms)}
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
{formatLatency(perf.avg_latency_ms)}
</TableCell>
<TableCell className='py-2.5'>
<UptimeSparkline
size='sm'
series={uptimeByGroup[perf.group] ?? []}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
@@ -292,7 +287,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
<SectionHeader
icon={Timer}
title={t('Latency trend (last 24h)')}
description={t('Average time-to-first-token (TTFT) by group')}
description={t('Average TTFT')}
/>
<LatencyTrendChart series={latencySeries} />
</section>
@@ -322,7 +317,7 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
) : null
}
/>
<UptimeBarChart series={uptimeSeries} />
<UptimeTrendChart series={uptimeSeries} />
</section>
</div>
)
+223 -134
View File
@@ -1,16 +1,10 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
import {
ArrowLeft,
Boxes,
Code2,
HeartPulse,
Info,
ReceiptText,
Rocket,
} from 'lucide-react'
import { ArrowLeft, Code2, HeartPulse, Info, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Sheet,
@@ -32,6 +26,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button'
import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout'
import { getPerfMetrics } from '../api'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import { usePricingData } from '../hooks/use-pricing-data'
import {
@@ -42,18 +37,23 @@ import {
} from '../lib/dynamic-price'
import { parseTags } from '../lib/filters'
import {
getAvailableGroups,
isTokenBasedModel,
replaceModelInPath,
} from '../lib/model-helpers'
formatLatency,
formatThroughput,
formatUptimePct,
} from '../lib/mock-stats'
import { getAvailableGroups, isTokenBasedModel } from '../lib/model-helpers'
import { inferModelMetadata } from '../lib/model-metadata'
import { formatFixedPrice, formatGroupPrice } from '../lib/price'
import type { PriceType, PricingModel, TokenUnit } from '../types'
import type {
Modality,
ModelCapability,
PriceType,
PricingModel,
TokenUnit,
} from '../types'
import { DynamicPricingBreakdown } from './dynamic-pricing-breakdown'
import { ModelDetailsApi, ModelDetailsProviderInfo } from './model-details-api'
import { ModelDetailsApps } from './model-details-apps'
import { ModelDetailsCapabilities } from './model-details-capabilities'
import { ModalitiesMatrix } from './model-details-modalities'
import { ModalityIcons } from './model-details-modalities'
import { ModelDetailsPerformance } from './model-details-performance'
import { ModelDetailsQuickStats } from './model-details-quick-stats'
@@ -69,8 +69,181 @@ function SectionTitle(props: { children: React.ReactNode }) {
)
}
const CAPABILITY_LABEL_KEYS: Record<ModelCapability, string> = {
function_calling: 'Function calling',
streaming: 'Streaming',
vision: 'Vision',
json_mode: 'JSON mode',
structured_output: 'Structured output',
reasoning: 'Reasoning',
tools: 'Tools',
system_prompt: 'System prompt',
web_search: 'Web search',
code_interpreter: 'Code interpreter',
caching: 'Prompt caching',
embeddings: 'Embeddings',
}
function CompactCapabilityList(props: { capabilities: ModelCapability[] }) {
const { t } = useTranslation()
if (props.capabilities.length === 0) {
return (
<span className='text-muted-foreground text-xs'>
{t('No capabilities reported for this model.')}
</span>
)
}
return (
<div className='flex flex-wrap gap-1.5'>
{props.capabilities.map((capability) => (
<span
key={capability}
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
>
{t(CAPABILITY_LABEL_KEYS[capability] ?? capability)}
</span>
))}
</div>
)
}
function CompactModalities(props: { input: Modality[]; output: Modality[] }) {
const { t } = useTranslation()
return (
<div className='grid gap-2 sm:grid-cols-2'>
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Input')}
</span>
<ModalityIcons modalities={props.input} />
</div>
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Output')}
</span>
<ModalityIcons modalities={props.output} />
</div>
</div>
)
}
function ModelSignalsSection(props: {
capabilities: ModelCapability[]
input: Modality[]
output: Modality[]
}) {
const { t } = useTranslation()
return (
<section>
<SectionTitle>
{t('Capabilities')} / {t('Supported modalities')}
</SectionTitle>
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
<CompactCapabilityList capabilities={props.capabilities} />
<CompactModalities input={props.input} output={props.output} />
</div>
</section>
)
}
function OverviewMetric(props: {
icon: React.ComponentType<{ className?: string }>
label: string
value: React.ReactNode
intent?: 'default' | 'warning' | 'success'
}) {
const Icon = props.icon
const intent = props.intent ?? 'default'
return (
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate text-[10px] font-medium tracking-wider uppercase'>
{props.label}
</div>
<div
className={cn(
'text-foreground truncate font-mono text-sm font-semibold tabular-nums',
intent === 'warning' && 'text-amber-600 dark:text-amber-400',
intent === 'success' && 'text-emerald-600 dark:text-emerald-400'
)}
>
{props.value}
</div>
</div>
</div>
)
}
function OverviewSummaryGrid(props: { model: PricingModel }) {
const { t } = useTranslation()
const metricsQuery = useQuery({
queryKey: ['perf-metrics', props.model.model_name],
queryFn: () => getPerfMetrics(props.model.model_name, 24),
staleTime: 60 * 1000,
})
const groups = metricsQuery.data?.data.groups ?? []
const successRates = groups
.map((group) => group.success_rate)
.filter((rate) => Number.isFinite(rate))
const successRate =
successRates.length > 0
? successRates.reduce((sum, rate) => sum + rate, 0) / successRates.length
: Number.NaN
let successIntent: 'default' | 'warning' | 'success' = 'warning'
if (successRate >= 99.9) {
successIntent = 'success'
} else if (successRate >= 99) {
successIntent = 'default'
}
const tpsValues = groups
.map((group) => group.avg_tps)
.filter((value) => value > 0)
const avgTps =
tpsValues.length > 0
? tpsValues.reduce((sum, value) => sum + value, 0) / tpsValues.length
: 0
const latencyValues = groups
.map((group) => group.avg_latency_ms)
.filter((value) => value > 0)
const avgLatency =
latencyValues.length > 0
? Math.round(
latencyValues.reduce((sum, value) => sum + value, 0) /
latencyValues.length
)
: 0
return (
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
<OverviewMetric
icon={Timer}
label='TPS'
value={formatThroughput(avgTps)}
/>
<OverviewMetric
icon={Timer}
label={t('Average latency')}
value={formatLatency(avgLatency)}
/>
<OverviewMetric
icon={HeartPulse}
label={t('Success rate')}
value={formatUptimePct(successRate)}
intent={successIntent}
/>
</div>
)
}
// ----------------------------------------------------------------------------
// Model header (always visible above the tabs)
// Model header (always visible above the detail sections)
// ----------------------------------------------------------------------------
function ModelHeader(props: { model: PricingModel }) {
@@ -362,55 +535,6 @@ function PriceSection(props: {
)
}
// ----------------------------------------------------------------------------
// API endpoints list
// ----------------------------------------------------------------------------
function EndpointsSection(props: {
model: PricingModel
endpointMap: Record<string, { path?: string; method?: string }>
}) {
const { t } = useTranslation()
const endpoints = useMemo(() => {
const types = props.model.supported_endpoint_types || []
return types.map((type) => {
const info = props.endpointMap[type] || {}
let path = info.path || ''
if (path.includes('{model}')) {
path = replaceModelInPath(path, props.model.model_name || '')
}
return { type, path, method: info.method || 'POST' }
})
}, [props.model, props.endpointMap])
if (endpoints.length === 0) return null
return (
<section>
<SectionTitle>{t('API Endpoints')}</SectionTitle>
<div className='space-y-1'>
{endpoints.map(({ type, path, method }) => (
<div key={type} className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{type}</span>
{path && (
<code className='text-muted-foreground/60 text-xs break-all'>
{path}
</code>
)}
</div>
{path && (
<span className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase'>
{method}
</span>
)}
</div>
))}
</div>
</section>
)
}
// ----------------------------------------------------------------------------
// Auto group chain (used inside group pricing section)
// ----------------------------------------------------------------------------
@@ -740,17 +864,7 @@ function GroupPricingSection(props: {
)
}
// ----------------------------------------------------------------------------
// Tabbed details content
// ----------------------------------------------------------------------------
const TAB_VALUES = [
'overview',
'pricing',
'performance',
'api',
'apps',
] as const
const TAB_VALUES = ['overview', 'performance', 'api'] as const
type TabValue = (typeof TAB_VALUES)[number]
const TAB_META: Record<
@@ -758,10 +872,8 @@ const TAB_META: Record<
{ icon: React.ComponentType<{ className?: string }>; labelKey: string }
> = {
overview: { icon: Info, labelKey: 'Overview' },
pricing: { icon: ReceiptText, labelKey: 'Pricing' },
performance: { icon: HeartPulse, labelKey: 'Performance' },
api: { icon: Code2, labelKey: 'API' },
apps: { icon: Rocket, labelKey: 'Apps' },
}
export interface ModelDetailsContentProps {
@@ -789,8 +901,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
<div className='@container/details space-y-4'>
<ModelHeader model={props.model} />
<ModelDetailsQuickStats metadata={metadata} />
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='bg-muted/60 h-auto w-full justify-start gap-1 overflow-x-auto rounded-lg p-1'>
{TAB_VALUES.map((value) => {
@@ -808,59 +918,42 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
})}
</TabsList>
<TabsContent value='overview' className='space-y-5 outline-none'>
<section>
<div className='mb-3 flex items-center gap-2'>
<Boxes className='text-muted-foreground/70 size-3.5' />
<h3 className='text-foreground text-sm font-semibold'>
{t('Capabilities')}
</h3>
</div>
<ModelDetailsCapabilities capabilities={metadata.capabilities} />
</section>
<TabsContent value='overview' className='space-y-6 outline-none'>
<OverviewSummaryGrid model={props.model} />
<section>
<div className='mb-3 flex items-center gap-2'>
<h3 className='text-foreground text-sm font-semibold'>
{t('Supported modalities')}
</h3>
</div>
<ModalitiesMatrix
input={metadata.input_modalities}
output={metadata.output_modalities}
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
<SectionTitle>{t('Pricing')}</SectionTitle>
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
</section>
<ModelDetailsQuickStats metadata={metadata} />
<ModelSignalsSection
capabilities={metadata.capabilities}
input={metadata.input_modalities}
output={metadata.output_modalities}
/>
<ModelDetailsProviderInfo model={props.model} />
<PriceSection
model={props.model}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
<EndpointsSection
model={props.model}
endpointMap={props.endpointMap}
/>
</TabsContent>
<TabsContent value='pricing' className='space-y-5 outline-none'>
{isDynamic && (
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
)}
<GroupPricingSection
model={props.model}
groupRatio={props.groupRatio}
usableGroup={props.usableGroup}
autoGroups={props.autoGroups}
priceRate={props.priceRate}
usdExchangeRate={props.usdExchangeRate}
tokenUnit={props.tokenUnit}
showRechargePrice={showRechargePrice}
/>
</TabsContent>
<TabsContent value='performance' className='outline-none'>
@@ -873,10 +966,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
endpointMap={props.endpointMap}
/>
</TabsContent>
<TabsContent value='apps' className='outline-none'>
<ModelDetailsApps model={props.model} />
</TabsContent>
</Tabs>
</div>
)
+15
View File
@@ -0,0 +1,15 @@
import { api } from '@/lib/api'
import type { RankingPeriod, RankingsSnapshot } from './types'
type RankingsResponse = {
success: boolean
message?: string
data: RankingsSnapshot
}
export async function getRankings(
period: RankingPeriod
): Promise<RankingsResponse> {
const res = await api.get('/api/rankings', { params: { period } })
return res.data
}
@@ -1,97 +0,0 @@
import { ExternalLink, Rocket } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { formatTokens } from '../lib/format'
import type { AppListing } from '../types'
import { GrowthText } from './growth-text'
type AppsSectionProps = {
rows: AppListing[]
}
/**
* "Top Apps" card — clean two-column listing of the apps consuming the
* most tokens through new-api in the active period. Apps don't get a
* dedicated chart (each app has too much variance to plot meaningfully);
* instead we keep the focus on the leaderboard itself.
*/
export function AppsSection(props: AppsSectionProps) {
const { t } = useTranslation()
const half = Math.ceil(props.rows.length / 2)
const left = props.rows.slice(0, half)
const right = props.rows.slice(half)
return (
<section className='bg-card overflow-hidden rounded-lg border'>
<header className='px-5 py-4'>
<h2 className='text-foreground inline-flex items-center gap-2 text-base font-semibold'>
<Rocket className='text-primary size-4' />
{t('Top Apps')}
</h2>
<p className='text-muted-foreground mt-1 text-sm'>
{t('Apps using the most tokens through new-api')}
</p>
</header>
{props.rows.length === 0 ? (
<div className='text-muted-foreground/80 border-t px-5 py-8 text-center text-sm'>
{t('No apps match the selected filters')}
</div>
) : (
<div className='grid grid-cols-1 gap-x-8 border-t px-5 pt-3 pb-4 md:grid-cols-2'>
<AppList rows={left} />
{right.length > 0 && <AppList rows={right} />}
</div>
)}
</section>
)
}
function AppList(props: { rows: AppListing[] }) {
return (
<ul>
{props.rows.map((row) => (
<li key={row.name} className='flex items-center gap-3 py-2.5'>
<span className='text-muted-foreground/80 w-6 shrink-0 text-right font-mono text-xs tabular-nums'>
{row.rank}.
</span>
<span className='bg-muted text-muted-foreground inline-flex size-9 shrink-0 items-center justify-center rounded-md text-sm font-bold uppercase'>
{row.initial}
</span>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 text-sm font-semibold'>
{row.url ? (
<a
href={row.url}
target='_blank'
rel='noopener noreferrer'
className='text-foreground hover:text-primary inline-flex items-center gap-1 truncate transition-colors'
>
<span className='truncate'>{row.name}</span>
<ExternalLink className='text-muted-foreground/60 size-3 shrink-0' />
</a>
) : (
<span className='text-foreground truncate'>{row.name}</span>
)}
<Badge
variant='outline'
className='h-4 shrink-0 rounded-sm px-1 text-[10px] font-normal'
>
{row.category}
</Badge>
</div>
<p className='text-muted-foreground/80 truncate text-xs'>
{row.description}
</p>
</div>
<div className='shrink-0 text-right'>
<div className='text-foreground font-mono text-sm font-semibold tabular-nums'>
{formatTokens(row.total_tokens)}
</div>
<GrowthText value={row.growth_pct} className='text-[11px]' />
</div>
</li>
))}
</ul>
)
}
@@ -1,205 +0,0 @@
import { useMemo } from 'react'
import { VChart } from '@visactor/react-vchart'
import { useTranslation } from 'react-i18next'
import { useChartTheme } from '@/lib/use-chart-theme'
import { VCHART_OPTION } from '@/lib/vchart'
import { formatTokens } from '../lib/format'
import type { CategorySection as CategorySectionData } from '../types'
import { ModelLeaderboard } from './model-leaderboard'
const TOOLTIP_MAX_ROWS = 8
const MAX_LEADERBOARD_ROWS = 8
type CategorySectionProps = {
section: CategorySectionData
}
/**
* Per-category ranking unit: a compact stacked-bar chart of token usage
* over time paired with a 2-column leaderboard of the top models in that
* category. Renders as a self-contained card; the rankings page stacks
* one of these per category for quick browsing.
*/
export function CategorySection(props: CategorySectionProps) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const orderedPoints = useMemo(() => {
const order = new Map(
props.section.models_history.models.map(
(m, idx) => [m.name, idx] as const
)
)
return [...props.section.models_history.points].sort((a, b) => {
const tsCmp = a.ts.localeCompare(b.ts)
if (tsCmp !== 0) return tsCmp
return (order.get(a.model) ?? 999) - (order.get(b.model) ?? 999)
})
}, [props.section.models_history])
const spec = useMemo(() => {
if (orderedPoints.length === 0) return null
return {
type: 'bar' as const,
data: [{ id: 'category-history', values: orderedPoints }],
xField: 'label',
yField: 'tokens',
seriesField: 'model',
stack: true,
bar: { style: { cornerRadius: 1 } },
legends: { visible: false },
axes: [
{
orient: 'bottom',
label: {
style: { fill: 'currentColor', fontSize: 9 },
autoHide: true,
autoLimit: true,
},
tick: { visible: false },
},
{
orient: 'left',
label: {
formatMethod: (val: number | string) => formatTokens(Number(val)),
style: { fill: 'currentColor', fontSize: 9 },
},
grid: { visible: true, style: { lineDash: [3, 3] } },
},
],
tooltip: {
mark: {
content: [
{
key: (datum: Record<string, unknown>) =>
String(datum?.model ?? ''),
value: (datum: Record<string, unknown>) =>
formatTokens(Number(datum?.tokens) || 0),
},
],
},
dimension: {
title: {
value: (datum: Record<string, unknown>) =>
String(datum?.label ?? ''),
},
content: [
{
key: (datum: Record<string, unknown>) =>
String(datum?.model ?? ''),
value: (datum: Record<string, unknown>) =>
Number(datum?.tokens) || 0,
},
],
updateContent: (
array: Array<{ key: string; value: string | number }>
) => {
array.sort((a, b) => Number(b.value) - Number(a.value))
const visible = array.slice(0, TOOLTIP_MAX_ROWS)
return visible.map((item) => ({
key: item.key,
value: formatTokens(Number(item.value) || 0),
}))
},
},
},
animationAppear: { duration: 400 },
}
}, [orderedPoints])
return (
<article
id={`category-${props.section.category}`}
className='bg-card scroll-mt-20 overflow-hidden rounded-lg border'
>
<header className='flex items-start justify-between gap-4 px-5 py-3.5'>
<div className='min-w-0 flex-1'>
<h3 className='text-foreground text-base font-semibold'>
{t(props.section.label)}
</h3>
<p className='text-muted-foreground/80 mt-0.5 truncate text-xs'>
{t(props.section.description)}
</p>
</div>
<div className='shrink-0 text-right'>
<div className='text-foreground font-mono text-base font-semibold tabular-nums'>
{formatTokens(props.section.total_tokens)}
</div>
<div className='text-muted-foreground/80 text-[10px] tracking-widest uppercase'>
{t('tokens')}
</div>
</div>
</header>
<div className='px-5 pb-4'>
<div className='h-44 sm:h-48'>
{themeReady && spec ? (
<VChart
key={`category-history-${props.section.category}-${resolvedTheme}`}
spec={{
...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light',
background: 'transparent',
}}
option={VCHART_OPTION}
/>
) : (
<div className='text-muted-foreground/80 flex h-full items-center justify-center text-xs'>
{t('No history data available')}
</div>
)}
</div>
</div>
{props.section.models.length === 0 ? (
<div className='text-muted-foreground/80 border-t px-5 py-6 text-center text-sm'>
{t('No models match the selected filters')}
</div>
) : (
<div className='border-t px-5 pt-2 pb-4'>
<ModelLeaderboard
rows={props.section.models}
limit={MAX_LEADERBOARD_ROWS}
variant='compact'
/>
</div>
)}
</article>
)
}
type CategorySectionsProps = {
sections: CategorySectionData[]
}
/**
* Renders the per-category rankings strip (one card per category).
* Includes a strip header so users understand the page structure shifts
* from the global view to category drill-downs.
*/
export function CategorySections(props: CategorySectionsProps) {
const { t } = useTranslation()
if (props.sections.length === 0) return null
return (
<section className='space-y-5'>
<header className='space-y-1'>
<p className='text-muted-foreground text-[11px] font-medium tracking-widest uppercase'>
{t('By category')}
</p>
<h2 className='text-foreground text-xl font-semibold tracking-tight'>
{t('Browse rankings by category')}
</h2>
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
{t('Discover the leading models in each domain')}
</p>
</header>
<div className='grid grid-cols-1 gap-5 lg:grid-cols-2'>
{props.sections.map((section) => (
<CategorySection key={section.category} section={section} />
))}
</div>
</section>
)
}
-2
View File
@@ -1,5 +1,3 @@
export * from './apps-section'
export * from './category-section'
export * from './entity-links'
export * from './growth-text'
export * from './market-share-section'
@@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
all: 'Token share by model author since launch',
}
/** Stable colour palette for vendors, used in both the area chart and the
/** Stable colour palette for vendors, used in both the share chart and the
* legend dots. Falls back to a neutral palette for unknown vendors so that
* future additions still render. */
const VENDOR_COLOURS: Record<string, string> = {
@@ -77,7 +77,7 @@ type MarketShareSectionProps = {
}
/**
* Combined "Market Share" card: a 100%-stacked area chart showing each
* Combined "Market Share" card: a 100%-stacked bar chart showing each
* vendor's slice of total token volume, paired below with a two-column
* vendor list.
*/
@@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
const spec = useMemo(() => {
if (orderedPoints.length === 0) return null
return {
type: 'area' as const,
type: 'bar' as const,
data: [{ id: 'vendor-share', values: orderedPoints }],
xField: 'label',
yField: 'share',
seriesField: 'vendor',
stack: true,
paddingInner: 0.12,
legends: { visible: false },
area: {
style: { fillOpacity: 0.85, curveType: 'monotone' },
},
line: { style: { lineWidth: 0, curveType: 'monotone' } },
point: { visible: false },
bar: { style: { cornerRadius: 1 } },
color: { specified: colourMap },
axes: [
{
@@ -1,33 +1,28 @@
import {
ArrowDownRight,
ArrowUpRight,
Sparkles,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
import { formatReleaseDate, formatTokens } from '../lib/format'
import type { NewModelEntry, RankingMover } from '../types'
import type { RankingMover } from '../types'
import { ModelLink, VendorLink } from './entity-links'
type PulseSectionProps = {
movers: RankingMover[]
droppers: RankingMover[]
newModels: NewModelEntry[]
}
/**
* Three-up "Pulse" panel: rank gainers, rank losers, and recently released
* models — the "what's changing" footer of the rankings page. Each card
* is intentionally compact so the trio fits in one row on desktop.
* Rank movement panel: gainers and losers calculated from the previous period.
*/
export function PulseSection(props: PulseSectionProps) {
const { t } = useTranslation()
return (
<section className='grid grid-cols-1 gap-4 lg:grid-cols-3'>
<section className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<PulseCard
title={t('Trending up')}
description={t('Models climbing the leaderboard')}
@@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
</ul>
)}
</PulseCard>
<PulseCard
title={t('Newly released')}
description={t('Recently launched models')}
icon={<Sparkles className='size-4 text-amber-500' />}
>
{props.newModels.length === 0 ? (
<PulseEmpty label={t('No new models yet')} />
) : (
<ul>
{props.newModels.slice(0, 6).map((row) => (
<NewModelRow key={row.model_name} row={row} />
))}
</ul>
)}
</PulseCard>
</section>
)
}
@@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
</li>
)
}
function NewModelRow(props: { row: NewModelEntry }) {
return (
<li className='flex items-center gap-3 px-4 py-2'>
<span className='shrink-0'>{getLobeIcon(props.row.vendor_icon, 20)}</span>
<div className='min-w-0 flex-1'>
<ModelLink
modelName={props.row.model_name}
className='text-foreground block truncate font-mono text-xs font-medium'
>
{props.row.model_name}
</ModelLink>
<p className='text-muted-foreground/80 truncate text-[11px]'>
{formatReleaseDate(props.row.release_date)} ·{' '}
<VendorLink vendor={props.row.vendor}>
{props.row.vendor.toLowerCase()}
</VendorLink>
</p>
</div>
<span className='text-foreground shrink-0 font-mono text-xs font-semibold tabular-nums'>
{formatTokens(props.row.total_tokens)}
</span>
</li>
)
}
@@ -17,9 +17,7 @@ type RankingsHeroProps = {
/**
* Hero strip for the rankings page. Intentionally minimal — title +
* subtitle + period tabs only. Category filtering is no longer needed
* because every category is rendered inline as its own section further
* down the page.
* subtitle + period tabs only.
*/
export function RankingsHero(props: RankingsHeroProps) {
const { t } = useTranslation()
@@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
</h1>
<p className='text-muted-foreground/80 max-w-2xl text-sm'>
{t(
'Discover the most-used models, top apps, and rising vendors on the platform updated continuously across every category.'
'Discover the most-used models and rising vendors on the platform, updated from live usage data.'
)}
</p>
</div>
+9 -13
View File
@@ -1,15 +1,11 @@
import { useMemo } from 'react'
import { buildRankingsSnapshot } from '../lib/mock-rankings'
import type { RankingPeriod, RankingsSnapshot } from '../types'
import { useQuery } from '@tanstack/react-query'
import { getRankings } from '../api'
import type { RankingPeriod } from '../types'
/**
* Memoised rankings snapshot for a period.
*
* Currently this synchronously builds deterministic mock data. When the
* backend ships real analytics endpoints, swap the body to a
* `useQuery`-based fetch — the consuming components don't care which side
* produced the data as long as it conforms to {@link RankingsSnapshot}.
*/
export function useRankings(period: RankingPeriod): RankingsSnapshot {
return useMemo(() => buildRankingsSnapshot(period), [period])
export function useRankings(period: RankingPeriod) {
return useQuery({
queryKey: ['rankings', period],
queryFn: () => getRankings(period),
staleTime: 5 * 60 * 1000,
})
}
+55 -30
View File
@@ -1,10 +1,9 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import {
AppsSection,
CategorySections,
MarketShareSection,
ModelsSection,
PulseSection,
@@ -26,7 +25,8 @@ export function Rankings() {
? (search.period as RankingPeriod)
: 'week'
const snapshot = useRankings(period)
const rankingsQuery = useRankings(period)
const snapshot = rankingsQuery.data?.data
const handlePeriodChange = (next: RankingPeriod) => {
navigate({
@@ -56,37 +56,62 @@ export function Rankings() {
<PageTransition className='relative mx-auto w-full max-w-[1280px] space-y-8 px-3 pt-16 pb-10 sm:px-6 sm:pt-20 sm:pb-12 xl:px-8'>
<RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{/* Overall (all-categories) view ----------------------------- */}
<ModelsSection
history={snapshot.models_history}
rows={snapshot.models}
period={period}
/>
{rankingsQuery.isLoading ? (
<RankingsLoading />
) : !snapshot ? (
<RankingsError
message={
rankingsQuery.error instanceof Error
? rankingsQuery.error.message
: t('Unable to load rankings data')
}
/>
) : (
<>
<ModelsSection
history={snapshot.models_history}
rows={snapshot.models}
period={period}
/>
<MarketShareSection
history={snapshot.vendor_share_history}
rows={snapshot.vendors}
period={period}
/>
<MarketShareSection
history={snapshot.vendor_share_history}
rows={snapshot.vendors}
period={period}
/>
<AppsSection rows={snapshot.apps} />
<PulseSection
movers={snapshot.top_movers}
droppers={snapshot.top_droppers}
newModels={snapshot.new_models}
/>
{/* Per-category drill-downs --------------------------------- */}
<CategorySections sections={snapshot.category_sections} />
<p className='text-muted-foreground/60 mx-auto max-w-3xl text-center text-[11px] leading-relaxed'>
{t(
'Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.'
)}
</p>
<PulseSection
movers={snapshot.top_movers}
droppers={snapshot.top_droppers}
/>
</>
)}
</PageTransition>
</div>
</PublicLayout>
)
}
function RankingsLoading() {
return (
<div className='space-y-6'>
<Skeleton className='h-[420px] w-full rounded-xl' />
<Skeleton className='h-[360px] w-full rounded-xl' />
<Skeleton className='h-[180px] w-full rounded-xl' />
</div>
)
}
function RankingsError(props: { message: string }) {
const { t } = useTranslation()
return (
<div className='bg-card rounded-xl border border-dashed px-6 py-12 text-center'>
<h2 className='text-foreground text-base font-semibold'>
{t('Unable to load rankings')}
</h2>
<p className='text-muted-foreground mx-auto mt-2 max-w-md text-sm'>
{props.message}
</p>
</div>
)
}
-1
View File
@@ -1,2 +1 @@
export * from './format'
export * from './mock-rankings'
File diff suppressed because it is too large Load Diff
+1 -80
View File
@@ -2,11 +2,7 @@
// Rankings types
// ----------------------------------------------------------------------------
//
// Shape of the data shown on the /rankings page. The backend has not yet
// implemented these analytics endpoints, so the helpers in
// `lib/mock-rankings.ts` produce deterministic mock values seeded from the
// (period, category) tuple. When the real APIs land, these types double as
// the response shape the UI expects.
// Shape of the real data shown on the /rankings page.
export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
@@ -24,13 +20,6 @@ export type RankingCategoryId =
| 'productivity'
| 'multimodal'
export type RankingCategory = {
id: RankingCategoryId
/** Default English label, fed through i18n at render time. */
label: string
description: string
}
export type ModelRanking = {
rank: number
/** Previous rank in the same period; undefined means "new". */
@@ -47,37 +36,6 @@ export type ModelRanking = {
growth_pct: number
}
export type AppCategory =
| 'Coding'
| 'Chat'
| 'Productivity'
| 'Education'
| 'Creative'
| 'Roleplay'
| 'Translation'
| 'Marketing'
| 'Health'
| 'Finance'
| 'Research'
| 'Other'
export type AppListing = {
rank: number
previous_rank?: number
name: string
description: string
category: AppCategory
url?: string
/** Total tokens this app sent through new-api in the period. */
total_tokens: number
/** Period-over-period change. */
growth_pct: number
/** Top model used by this app (model_name). */
top_model: string
/** Logo letter / initial. */
initial: string
}
export type VendorRanking = {
rank: number
vendor: string
@@ -102,17 +60,6 @@ export type RankingMover = {
growth_pct: number
}
export type NewModelEntry = {
model_name: string
vendor: string
vendor_icon?: string
category: RankingCategoryId
release_date: string
total_tokens: number
/** % growth since the model launched. */
growth_pct: number
}
/**
* One sample of a model's token usage at a given timestamp.
* Flat shape ready to feed VChart's stacked-bar spec.
@@ -158,42 +105,16 @@ export type VendorShareSeries = {
buckets: number
}
/**
* Self-contained ranking unit for a single category. Pairs the small
* stacked-bar chart with the leaderboard data it summarises so
* `<CategorySection>` can render both halves from one prop. Every
* category gets one of these rendered inline on the rankings page.
*/
export type CategorySection = {
category: RankingCategoryId
/** English source label, fed through i18n at render time. */
label: string
/** English source description, fed through i18n at render time. */
description: string
/** Top models in this category, ordered by total tokens desc. */
models: ModelRanking[]
/** Stacked-bar history of token usage by model in this category. */
models_history: ModelHistorySeries
/** Sum of all `models[].total_tokens` (cached for the section header). */
total_tokens: number
}
export type RankingsSnapshot = {
// Overall (all categories) ------------------------------------------------
models: ModelRanking[]
apps: AppListing[]
vendors: VendorRanking[]
/** Largest rank gainers in this period. */
top_movers: RankingMover[]
/** Largest rank losers in this period. */
top_droppers: RankingMover[]
/** Newly launched / recently added models. */
new_models: NewModelEntry[]
/** Stacked-bar history of token usage by model over the period. */
models_history: ModelHistorySeries
/** 100%-stacked area history of token share by vendor over the period. */
vendor_share_history: VendorShareSeries
// Per-category sections ---------------------------------------------------
/** Independent ranking sections, one per non-`all` category. */
category_sections: CategorySection[]
}
@@ -1,4 +1,4 @@
export type HeaderNavPricingConfig = {
export type HeaderNavAccessConfig = {
enabled: boolean
requireAuth: boolean
}
@@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = {
export type HeaderNavModulesConfig = {
home: boolean
console: boolean
pricing: HeaderNavPricingConfig
pricing: HeaderNavAccessConfig
rankings: HeaderNavAccessConfig
docs: boolean
about: boolean
[key: string]: boolean | HeaderNavPricingConfig
[key: string]: boolean | HeaderNavAccessConfig
}
export type SidebarSectionConfig = {
@@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
enabled: true,
requireAuth: false,
},
rankings: {
enabled: true,
requireAuth: false,
},
docs: true,
about: true,
}
@@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => {
const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
...HEADER_NAV_DEFAULT,
pricing: { ...HEADER_NAV_DEFAULT.pricing },
rankings: { ...HEADER_NAV_DEFAULT.rankings },
})
const parseAccessModule = (
raw: unknown,
fallback: HeaderNavAccessConfig
): HeaderNavAccessConfig => {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: toBoolean(raw, fallback.enabled),
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled: toBoolean(record.enabled, fallback.enabled),
requireAuth: toBoolean(record.requireAuth, fallback.requireAuth),
}
}
return { ...fallback }
}
const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
(acc, [section, config]) => {
@@ -97,23 +127,16 @@ export function parseHeaderNavModules(
const result: HeaderNavModulesConfig = {
...base,
pricing: { ...base.pricing },
rankings: { ...base.rankings },
}
Object.entries(parsed).forEach(([key, raw]) => {
if (key === 'pricing') {
if (raw && typeof raw === 'object') {
const rawPricing = raw as Record<string, unknown>
result.pricing = {
enabled: toBoolean(
rawPricing.enabled,
base.pricing?.enabled ?? true
),
requireAuth: toBoolean(
rawPricing.requireAuth,
base.pricing?.requireAuth ?? false
),
}
}
result.pricing = parseAccessModule(raw, base.pricing)
return
}
if (key === 'rankings') {
result.rankings = parseAccessModule(raw, base.rankings)
return
}
@@ -27,6 +27,8 @@ const headerNavSchema = z.object({
console: z.boolean(),
pricingEnabled: z.boolean(),
pricingRequireAuth: z.boolean(),
rankingsEnabled: z.boolean(),
rankingsRequireAuth: z.boolean(),
docs: z.boolean(),
about: z.boolean(),
})
@@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
config.pricing?.requireAuth === undefined
? HEADER_NAV_DEFAULT.pricing.requireAuth
: Boolean(config.pricing.requireAuth),
rankingsEnabled:
config.rankings?.enabled === undefined
? HEADER_NAV_DEFAULT.rankings.enabled
: Boolean(config.rankings.enabled),
rankingsRequireAuth:
config.rankings?.requireAuth === undefined
? HEADER_NAV_DEFAULT.rankings.requireAuth
: Boolean(config.rankings.requireAuth),
docs:
config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
about:
@@ -90,6 +100,11 @@ export function HeaderNavigationSection({
enabled: values.pricingEnabled,
requireAuth: values.pricingRequireAuth,
},
rankings: {
...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
enabled: values.rankingsEnabled,
requireAuth: values.rankingsRequireAuth,
},
}
const serialized = serializeHeaderNavModules(payload)
@@ -107,7 +122,7 @@ export function HeaderNavigationSection({
form.reset(toFormValues(HEADER_NAV_DEFAULT))
}
const modules: Array<{
const simpleModules: Array<{
key: keyof HeaderNavFormValues
title: string
description: string
@@ -134,6 +149,39 @@ export function HeaderNavigationSection({
},
]
const accessModules: Array<{
enabledKey: keyof HeaderNavFormValues
requireAuthKey: keyof HeaderNavFormValues
requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled'
title: string
description: string
requireAuthTitle: string
requireAuthDescription: string
}> = [
{
enabledKey: 'pricingEnabled',
requireAuthKey: 'pricingRequireAuth',
requireAuthDependsOn: 'pricingEnabled',
title: t('Model Square'),
description: t('Public model catalog and pricing page.'),
requireAuthTitle: t('Require login to view models'),
requireAuthDescription: t(
'Visitors must authenticate before accessing the pricing directory.'
),
},
{
enabledKey: 'rankingsEnabled',
requireAuthKey: 'rankingsRequireAuth',
requireAuthDependsOn: 'rankingsEnabled',
title: t('Rankings'),
description: t('Public rankings page based on live usage data.'),
requireAuthTitle: t('Require login to view rankings'),
requireAuthDescription: t(
'Visitors must authenticate before accessing the rankings page.'
),
},
]
return (
<SettingsSection
title={t('Header navigation')}
@@ -142,7 +190,7 @@ export function HeaderNavigationSection({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'>
{modules.map((module) => (
{simpleModules.map((module) => (
<FormField
key={module.key}
control={form.control}
@@ -168,59 +216,57 @@ export function HeaderNavigationSection({
))}
</div>
<div className='rounded-lg border p-4'>
<FormField
control={form.control}
name='pricingEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{t('Models directory')}
</FormLabel>
<FormDescription>
{t(
'Exposes the pricing/models catalog in the top navigation.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-4 lg:grid-cols-2'>
{accessModules.map((module) => (
<div key={module.enabledKey} className='rounded-lg border p-4'>
<FormField
control={form.control}
name={module.enabledKey}
render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{module.title}
</FormLabel>
<FormDescription>{module.description}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='pricingRequireAuth'
render={({ field }) => (
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{t('Require login to view models')}
</FormLabel>
<FormDescription>
{t(
'Visitors must authenticate before accessing the pricing directory.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!form.watch('pricingEnabled')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={module.requireAuthKey}
render={({ field }) => (
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'>
<div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'>
{module.requireAuthTitle}
</FormLabel>
<FormDescription>
{module.requireAuthDescription}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!form.watch(module.requireAuthDependsOn)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
</div>
<div className='flex flex-wrap gap-3'>
@@ -269,11 +269,6 @@ function getModeBadgeVariant(
return 'outline'
}
function truncateExpr(value: string) {
if (!value) return ''
return value.length > 110 ? `${value.slice(0, 110)}...` : value
}
function buildPreviewRows(
values: ModelPricingFormValues,
mode: PricingMode,
@@ -2,9 +2,16 @@ import { useState, useEffect, useCallback } from 'react'
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Select,
SelectContent,
@@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { CommonLogFilters } from '../types'
import { CommonLogsStats } from './common-logs-stats'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider'
@@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>(
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const isAdmin = useIsAdmin()
const { sensitiveVisible } = useUsageLogsContext()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<CommonLogFilters>(() => {
@@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>(
const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
const sensitiveType = sensitiveVisible ? 'text' : 'password'
const statsBar = (
<div className='flex flex-wrap items-center gap-2'>
<CommonLogsStats />
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-7'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
</div>
)
return (
<DataTableToolbar
table={props.table}
leftActions={statsBar}
customSearch={
<CompactDateTimeRangePicker
start={filters.startTime}
-6
View File
@@ -6,7 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout'
import type { NavGroup } from '@/components/layout/types'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { CommonLogsHeaderActions } from './components/common-logs-header-actions'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import {
UsageLogsProvider,
@@ -106,11 +105,6 @@ function UsageLogsContent() {
<SectionPageLayout.Description>
{t(pageMeta.descriptionKey)}
</SectionPageLayout.Description>
{activeCategory === 'common' && (
<SectionPageLayout.Actions>
<CommonLogsHeaderActions />
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content>
<div className='space-y-4'>
{showTaskSwitcher && (