Commit f8cf9c57 by CaIon

feat(default): add real rankings data

parent 0f9f094a
package controller
import (
"net/http"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func GetRankings(c *gin.Context) {
result, err := service.GetRankingsSnapshot(c.DefaultQuery("period", "week"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result,
})
}
...@@ -242,7 +242,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -242,7 +242,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
} }
if newAPIError != nil { if newAPIError != nil {
gopool.Go(func() { gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, false) perfmetrics.RecordRelaySample(relayInfo, false, 0)
}) })
} }
} }
......
...@@ -13,11 +13,13 @@ type PerfMetric struct { ...@@ -13,11 +13,13 @@ type PerfMetric struct {
ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"` ModelName string `json:"model_name" gorm:"size:128;uniqueIndex:idx_perf_model_group_bucket,priority:1"`
Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"` Group string `json:"group" gorm:"column:group;size:64;uniqueIndex:idx_perf_model_group_bucket,priority:2"`
BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"` BucketTs int64 `json:"bucket_ts" gorm:"uniqueIndex:idx_perf_model_group_bucket,priority:3;index:idx_perf_bucket_ts"`
RequestCount int64 `json:"request_count" gorm:"default:0"` RequestCount int64 `json:"-" gorm:"default:0"`
SuccessCount int64 `json:"success_count" gorm:"default:0"` SuccessCount int64 `json:"-" gorm:"default:0"`
TotalLatencyMs int64 `json:"total_latency_ms" gorm:"default:0"` TotalLatencyMs int64 `json:"-" gorm:"default:0"`
TtftSumMs int64 `json:"ttft_sum_ms" gorm:"default:0"` TtftSumMs int64 `json:"-" gorm:"default:0"`
TtftCount int64 `json:"ttft_count" gorm:"default:0"` TtftCount int64 `json:"-" gorm:"default:0"`
OutputTokens int64 `json:"-" gorm:"default:0"`
GenerationMs int64 `json:"-" gorm:"default:0"`
} }
func (PerfMetric) TableName() string { func (PerfMetric) TableName() string {
...@@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error { ...@@ -40,6 +42,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs), "total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs), "ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount), "ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
}), }),
}).Create(metric).Error }).Create(metric).Error
} }
......
package model
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
type RankingQuotaTotal struct {
ModelName string `json:"model_name"`
TotalTokens int64 `json:"total_tokens"`
}
type RankingQuotaBucket struct {
ModelName string `json:"model_name"`
Bucket int64 `json:"bucket"`
Tokens int64 `json:"tokens"`
}
func GetRankingQuotaTotals(startTime int64, endTime int64) ([]RankingQuotaTotal, error) {
var rows []RankingQuotaTotal
query := DB.Table("quota_data").
Select("model_name, sum(token_used) as total_tokens").
Where("model_name <> ''").
Group("model_name").
Having("sum(token_used) > 0").
Order("total_tokens DESC")
query = applyRankingQuotaTimeRange(query, startTime, endTime)
err := query.Find(&rows).Error
return rows, err
}
func GetRankingQuotaBuckets(startTime int64, endTime int64, bucketSize int64) ([]RankingQuotaBucket, error) {
if bucketSize <= 0 {
bucketSize = 3600
}
bucketExpr := rankingBucketExpr(bucketSize)
var rows []RankingQuotaBucket
query := DB.Table("quota_data").
Select(fmt.Sprintf("model_name, %s as bucket, sum(token_used) as tokens", bucketExpr)).
Where("model_name <> ''").
Group(fmt.Sprintf("model_name, %s", bucketExpr)).
Having("sum(token_used) > 0").
Order("bucket ASC")
query = applyRankingQuotaTimeRange(query, startTime, endTime)
err := query.Find(&rows).Error
return rows, err
}
func rankingBucketExpr(bucketSize int64) string {
if common.UsingMySQL {
return fmt.Sprintf("FLOOR(created_at / %d) * %d", bucketSize, bucketSize)
}
return fmt.Sprintf("(created_at / %d) * %d", bucketSize, bucketSize)
}
func applyRankingQuotaTimeRange(query *gorm.DB, startTime int64, endTime int64) *gorm.DB {
if startTime > 0 {
query = query.Where("created_at >= ?", startTime)
}
if endTime > 0 {
query = query.Where("created_at <= ?", endTime)
}
return query
}
...@@ -47,6 +47,8 @@ func flushCompletedBuckets() { ...@@ -47,6 +47,8 @@ func flushCompletedBuckets() {
TotalLatencyMs: drained.totalLatencyMs, TotalLatencyMs: drained.totalLatencyMs,
TtftSumMs: drained.ttftSumMs, TtftSumMs: drained.ttftSumMs,
TtftCount: drained.ttftCount, TtftCount: drained.ttftCount,
OutputTokens: drained.outputTokens,
GenerationMs: drained.generationMs,
}) })
if err != nil { if err != nil {
bucket.addCounters(drained) bucket.addCounters(drained)
...@@ -82,6 +84,8 @@ func redisCounters(values map[string]string) counters { ...@@ -82,6 +84,8 @@ func redisCounters(values map[string]string) counters {
totalLatencyMs: parseRedisInt(values["lat"]), totalLatencyMs: parseRedisInt(values["lat"]),
ttftSumMs: parseRedisInt(values["ttft"]), ttftSumMs: parseRedisInt(values["ttft"]),
ttftCount: parseRedisInt(values["ttft_n"]), ttftCount: parseRedisInt(values["ttft_n"]),
outputTokens: parseRedisInt(values["out"]),
generationMs: parseRedisInt(values["gen_ms"]),
} }
} }
......
...@@ -15,13 +15,15 @@ import ( ...@@ -15,13 +15,15 @@ import (
var hotBuckets sync.Map var hotBuckets sync.Map
// seriesSchema is a stable client cache/schema marker. Do not change it when
// hiding fields or making response-only privacy hardening changes.
const seriesSchema = "dbcd0a3c01b55203" const seriesSchema = "dbcd0a3c01b55203"
func Init() { func Init() {
go flushLoop() go flushLoop()
} }
func RecordRelaySample(info *relaycommon.RelayInfo, success bool) { func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens int64) {
if info == nil { if info == nil {
return return
} }
...@@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) { ...@@ -31,13 +33,23 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool) {
if hasTtft { if hasTtft {
ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds() ttftMs = info.FirstResponseTime.Sub(info.StartTime).Milliseconds()
} }
latencyMs := now.Sub(info.StartTime).Milliseconds()
generationMs := latencyMs
if hasTtft {
generationMs = now.Sub(info.FirstResponseTime).Milliseconds()
}
if generationMs <= 0 {
generationMs = latencyMs
}
Record(Sample{ Record(Sample{
Model: info.OriginModelName, Model: info.OriginModelName,
Group: info.UsingGroup, Group: info.UsingGroup,
LatencyMs: now.Sub(info.StartTime).Milliseconds(), LatencyMs: latencyMs,
TtftMs: ttftMs, TtftMs: ttftMs,
HasTtft: hasTtft, HasTtft: hasTtft,
Success: success, Success: success,
OutputTokens: outputTokens,
GenerationMs: generationMs,
}) })
} }
...@@ -89,6 +101,8 @@ func Query(params QueryParams) (QueryResult, error) { ...@@ -89,6 +101,8 @@ func Query(params QueryParams) (QueryResult, error) {
totalLatencyMs: row.TotalLatencyMs, totalLatencyMs: row.TotalLatencyMs,
ttftSumMs: row.TtftSumMs, ttftSumMs: row.TtftSumMs,
ttftCount: row.TtftCount, ttftCount: row.TtftCount,
outputTokens: row.OutputTokens,
generationMs: row.GenerationMs,
}) })
} }
...@@ -125,6 +139,8 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters) ...@@ -125,6 +139,8 @@ func mergeCounters(merged map[bucketKey]counters, key bucketKey, value counters)
current.totalLatencyMs += value.totalLatencyMs current.totalLatencyMs += value.totalLatencyMs
current.ttftSumMs += value.ttftSumMs current.ttftSumMs += value.ttftSumMs
current.ttftCount += value.ttftCount current.ttftCount += value.ttftCount
current.outputTokens += value.outputTokens
current.generationMs += value.generationMs
merged[key] = current merged[key] = current
} }
...@@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu ...@@ -166,6 +182,8 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
total.totalLatencyMs += value.totalLatencyMs total.totalLatencyMs += value.totalLatencyMs
total.ttftSumMs += value.ttftSumMs total.ttftSumMs += value.ttftSumMs
total.ttftCount += value.ttftCount total.ttftCount += value.ttftCount
total.outputTokens += value.outputTokens
total.generationMs += value.generationMs
series = append(series, bucketPoint(ts, value)) series = append(series, bucketPoint(ts, value))
} }
...@@ -174,9 +192,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu ...@@ -174,9 +192,7 @@ func buildQueryResult(modelName string, merged map[bucketKey]counters) QueryResu
AvgTtftMs: avg(total.ttftSumMs, total.ttftCount), AvgTtftMs: avg(total.ttftSumMs, total.ttftCount),
AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount), AvgLatencyMs: avg(total.totalLatencyMs, total.requestCount),
SuccessRate: successRate(total), SuccessRate: successRate(total),
RequestCount: total.requestCount, AvgTps: avgTps(total),
SuccessCount: total.successCount,
TtftCount: total.ttftCount,
Series: series, Series: series,
}) })
} }
...@@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint { ...@@ -194,9 +210,7 @@ func bucketPoint(ts int64, value counters) BucketPoint {
AvgTtftMs: avg(value.ttftSumMs, value.ttftCount), AvgTtftMs: avg(value.ttftSumMs, value.ttftCount),
AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount), AvgLatencyMs: avg(value.totalLatencyMs, value.requestCount),
SuccessRate: successRate(value), SuccessRate: successRate(value),
Count: value.requestCount, AvgTps: avgTps(value),
SuccessCount: value.successCount,
TtftCount: value.ttftCount,
} }
} }
...@@ -214,6 +228,13 @@ func successRate(value counters) float64 { ...@@ -214,6 +228,13 @@ func successRate(value counters) float64 {
return float64(value.successCount) / float64(value.requestCount) * 100 return float64(value.successCount) / float64(value.requestCount) * 100
} }
func avgTps(value counters) float64 {
if value.outputTokens <= 0 || value.generationMs <= 0 {
return 0
}
return float64(value.outputTokens) / (float64(value.generationMs) / 1000)
}
func recordRedis(key bucketKey, sample Sample) { func recordRedis(key bucketKey, sample Sample) {
if !common.RedisEnabled || common.RDB == nil { if !common.RedisEnabled || common.RDB == nil {
return return
...@@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) { ...@@ -234,6 +255,10 @@ func recordRedis(key bucketKey, sample Sample) {
pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs) pipe.HIncrBy(ctx, redisKey, "ttft", sample.TtftMs)
pipe.HIncrBy(ctx, redisKey, "ttft_n", 1) pipe.HIncrBy(ctx, redisKey, "ttft_n", 1)
} }
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
pipe.HIncrBy(ctx, redisKey, "out", sample.OutputTokens)
pipe.HIncrBy(ctx, redisKey, "gen_ms", sample.GenerationMs)
}
pipe.Expire(ctx, redisKey, time.Hour) pipe.Expire(ctx, redisKey, time.Hour)
_, _ = pipe.Exec(ctx) _, _ = pipe.Exec(ctx)
} }
......
...@@ -14,6 +14,8 @@ type Sample struct { ...@@ -14,6 +14,8 @@ type Sample struct {
TtftMs int64 TtftMs int64
HasTtft bool HasTtft bool
Success bool Success bool
OutputTokens int64
GenerationMs int64
} }
type QueryParams struct { type QueryParams struct {
...@@ -27,9 +29,7 @@ type BucketPoint struct { ...@@ -27,9 +29,7 @@ type BucketPoint struct {
AvgTtftMs int64 `json:"avg_ttft_ms"` AvgTtftMs int64 `json:"avg_ttft_ms"`
AvgLatencyMs int64 `json:"avg_latency_ms"` AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"` SuccessRate float64 `json:"success_rate"`
Count int64 `json:"count"` AvgTps float64 `json:"avg_tps"`
SuccessCount int64 `json:"success_count"`
TtftCount int64 `json:"ttft_count"`
} }
type GroupResult struct { type GroupResult struct {
...@@ -37,9 +37,7 @@ type GroupResult struct { ...@@ -37,9 +37,7 @@ type GroupResult struct {
AvgTtftMs int64 `json:"avg_ttft_ms"` AvgTtftMs int64 `json:"avg_ttft_ms"`
AvgLatencyMs int64 `json:"avg_latency_ms"` AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"` SuccessRate float64 `json:"success_rate"`
RequestCount int64 `json:"request_count"` AvgTps float64 `json:"avg_tps"`
SuccessCount int64 `json:"success_count"`
TtftCount int64 `json:"ttft_count"`
Series []BucketPoint `json:"series"` Series []BucketPoint `json:"series"`
} }
...@@ -61,6 +59,8 @@ type counters struct { ...@@ -61,6 +59,8 @@ type counters struct {
totalLatencyMs int64 totalLatencyMs int64
ttftSumMs int64 ttftSumMs int64
ttftCount int64 ttftCount int64
outputTokens int64
generationMs int64
} }
type atomicBucket struct { type atomicBucket struct {
...@@ -69,6 +69,8 @@ type atomicBucket struct { ...@@ -69,6 +69,8 @@ type atomicBucket struct {
totalLatencyMs atomic.Int64 totalLatencyMs atomic.Int64
ttftSumMs atomic.Int64 ttftSumMs atomic.Int64
ttftCount atomic.Int64 ttftCount atomic.Int64
outputTokens atomic.Int64
generationMs atomic.Int64
} }
func (b *atomicBucket) add(sample Sample) { func (b *atomicBucket) add(sample Sample) {
...@@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) { ...@@ -83,6 +85,10 @@ func (b *atomicBucket) add(sample Sample) {
b.ttftSumMs.Add(sample.TtftMs) b.ttftSumMs.Add(sample.TtftMs)
b.ttftCount.Add(1) b.ttftCount.Add(1)
} }
if sample.OutputTokens > 0 && sample.GenerationMs > 0 {
b.outputTokens.Add(sample.OutputTokens)
b.generationMs.Add(sample.GenerationMs)
}
} }
func (b *atomicBucket) snapshot() counters { func (b *atomicBucket) snapshot() counters {
...@@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters { ...@@ -92,6 +98,8 @@ func (b *atomicBucket) snapshot() counters {
totalLatencyMs: b.totalLatencyMs.Load(), totalLatencyMs: b.totalLatencyMs.Load(),
ttftSumMs: b.ttftSumMs.Load(), ttftSumMs: b.ttftSumMs.Load(),
ttftCount: b.ttftCount.Load(), ttftCount: b.ttftCount.Load(),
outputTokens: b.outputTokens.Load(),
generationMs: b.generationMs.Load(),
} }
} }
...@@ -102,6 +110,8 @@ func (b *atomicBucket) drain() counters { ...@@ -102,6 +110,8 @@ func (b *atomicBucket) drain() counters {
totalLatencyMs: b.totalLatencyMs.Swap(0), totalLatencyMs: b.totalLatencyMs.Swap(0),
ttftSumMs: b.ttftSumMs.Swap(0), ttftSumMs: b.ttftSumMs.Swap(0),
ttftCount: b.ttftCount.Swap(0), ttftCount: b.ttftCount.Swap(0),
outputTokens: b.outputTokens.Swap(0),
generationMs: b.generationMs.Swap(0),
} }
} }
...@@ -121,4 +131,10 @@ func (b *atomicBucket) addCounters(c counters) { ...@@ -121,4 +131,10 @@ func (b *atomicBucket) addCounters(c counters) {
if c.ttftCount != 0 { if c.ttftCount != 0 {
b.ttftCount.Add(c.ttftCount) b.ttftCount.Add(c.ttftCount)
} }
if c.outputTokens != 0 {
b.outputTokens.Add(c.outputTokens)
}
if c.generationMs != 0 {
b.generationMs.Add(c.generationMs)
}
} }
...@@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -32,6 +32,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics) apiRouter.GET("/perf-metrics", middleware.TryUserAuth(), controller.GetPerfMetrics)
apiRouter.GET("/rankings", controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
......
...@@ -377,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u ...@@ -377,7 +377,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
Other: other, Other: other,
}) })
gopool.Go(func() { gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true) perfmetrics.RecordRelaySample(relayInfo, true, int64(usage.CompletionTokens))
}) })
} }
......
...@@ -474,6 +474,6 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us ...@@ -474,6 +474,6 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
Other: other, Other: other,
}) })
gopool.Go(func() { gopool.Go(func() {
perfmetrics.RecordRelaySample(relayInfo, true) perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens))
}) })
} }
...@@ -88,6 +88,14 @@ export type DataTableToolbarProps<TData> = { ...@@ -88,6 +88,14 @@ export type DataTableToolbarProps<TData> = {
*/ */
hideViewOptions?: boolean hideViewOptions?: boolean
/** /**
* Content rendered on the LEFT side of the secondary action row. When
* provided the toolbar splits into two visual rows:
* Row 1: search inputs / filter chips …… Expand
* Row 2: expanded filters
* Row 3: leftActions …… Reset / Search / ViewOptions
*/
leftActions?: ReactNode
/**
* Outer wrapper className override. * Outer wrapper className override.
*/ */
className?: string className?: string
...@@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) { ...@@ -216,6 +224,39 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
</Button> </Button>
) : null ) : null
const hasLeftActions = props.leftActions != null
if (hasLeftActions) {
return (
<div className={cn('flex flex-col gap-2', props.className)}>
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{expandToggle}
</div>
</div>
{expanded && hasExpandable && (
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.expandable}
</div>
)}
<div className='flex flex-wrap items-center gap-2 sm:gap-3'>
{props.leftActions}
<div className='ms-auto flex shrink-0 items-center gap-1.5 sm:gap-2'>
{props.preActions}
{resetButton}
{searchButton}
{viewOptionsNode}
</div>
</div>
</div>
)
}
return ( return (
<div <div
className={cn( className={cn(
......
...@@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = { ...@@ -16,9 +16,7 @@ export type PerformanceSeriesPoint = {
avg_ttft_ms: number avg_ttft_ms: number
avg_latency_ms: number avg_latency_ms: number
success_rate: number success_rate: number
count: number avg_tps: number
success_count: number
ttft_count: number
} }
export type PerformanceGroup = { export type PerformanceGroup = {
...@@ -26,9 +24,7 @@ export type PerformanceGroup = { ...@@ -26,9 +24,7 @@ export type PerformanceGroup = {
avg_ttft_ms: number avg_ttft_ms: number
avg_latency_ms: number avg_latency_ms: number
success_rate: number success_rate: number
request_count: number avg_tps: number
success_count: number
ttft_count: number
series: PerformanceSeriesPoint[] series: PerformanceSeriesPoint[]
} }
......
...@@ -28,7 +28,7 @@ function formatDayLabel(date: string): string { ...@@ -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: { export function LatencyTrendChart(props: {
...@@ -52,14 +52,20 @@ export function LatencyTrendChart(props: { ...@@ -52,14 +52,20 @@ export function LatencyTrendChart(props: {
yField: 'ttft', yField: 'ttft',
seriesField: 'group', seriesField: 'group',
smooth: true, smooth: true,
point: { visible: false }, point: {
legends: { visible: true, orient: 'top', position: 'start' }, visible: true,
style: { size: 5, stroke: '#ffffff', lineWidth: 1.5 },
},
line: {
style: { lineWidth: 2 },
},
legends: { visible: false },
tooltip: { tooltip: {
mark: { mark: {
title: { value: (d: { time: string }) => d.time }, title: { value: (d: { time: string }) => d.time },
content: [ content: [
{ {
key: (d: { group: string }) => d.group, key: t('Average TTFT'),
value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`, value: (d: { ttft: number }) => `${Math.round(d.ttft)} ms`,
}, },
], ],
...@@ -83,7 +89,7 @@ export function LatencyTrendChart(props: { ...@@ -83,7 +89,7 @@ export function LatencyTrendChart(props: {
}, },
], ],
} }
}, [props.series]) }, [props.series, t])
if (props.series.length === 0) { if (props.series.length === 0) {
return ( return (
...@@ -116,10 +122,10 @@ export function LatencyTrendChart(props: { ...@@ -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[] series: UptimeDayPoint[]
className?: string className?: string
}) { }) {
...@@ -137,18 +143,25 @@ export function UptimeBarChart(props: { ...@@ -137,18 +143,25 @@ export function UptimeBarChart(props: {
})) }))
return { return {
type: 'bar' as const, type: 'line' as const,
data: [{ id: 'uptime', values: data }], data: [{ id: 'uptime', values: data }],
xField: 'date', xField: 'date',
yField: 'uptime', yField: 'uptime',
bar: { smooth: true,
line: {
style: { stroke: '#10b981', lineWidth: 2 },
},
point: {
visible: true,
style: { style: {
size: 5,
stroke: '#ffffff',
lineWidth: 1.5,
fill: (datum: { uptime: number }) => { fill: (datum: { uptime: number }) => {
if (datum.uptime >= 99.9) return '#10b981' if (datum.uptime >= 99.9) return '#10b981'
if (datum.uptime >= 99.0) return '#f59e0b' if (datum.uptime >= 99.0) return '#f59e0b'
return '#ef4444' return '#ef4444'
}, },
cornerRadius: 2,
}, },
}, },
tooltip: { tooltip: {
...@@ -210,7 +223,7 @@ export function UptimeBarChart(props: { ...@@ -210,7 +223,7 @@ export function UptimeBarChart(props: {
<div className={cn('h-56 sm:h-64', props.className)}> <div className={cn('h-56 sm:h-64', props.className)}>
{themeReady && spec && ( {themeReady && spec && (
<VChart <VChart
key={`uptime-${resolvedTheme}`} key={`uptime-trend-${resolvedTheme}`}
spec={{ spec={{
...spec, ...spec,
theme: resolvedTheme === 'dark' ? 'dark' : 'light', theme: resolvedTheme === 'dark' ? 'dark' : 'light',
......
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
}
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>
)
}
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>
)
}
export * from './apps-section'
export * from './category-section'
export * from './entity-links' export * from './entity-links'
export * from './growth-text' export * from './growth-text'
export * from './market-share-section' export * from './market-share-section'
......
...@@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = { ...@@ -16,7 +16,7 @@ const PERIOD_DESCRIPTIONS: Record<RankingPeriod, string> = {
all: 'Token share by model author since launch', 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 * legend dots. Falls back to a neutral palette for unknown vendors so that
* future additions still render. */ * future additions still render. */
const VENDOR_COLOURS: Record<string, string> = { const VENDOR_COLOURS: Record<string, string> = {
...@@ -77,7 +77,7 @@ type MarketShareSectionProps = { ...@@ -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's slice of total token volume, paired below with a two-column
* vendor list. * vendor list.
*/ */
...@@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) { ...@@ -104,18 +104,15 @@ export function MarketShareSection(props: MarketShareSectionProps) {
const spec = useMemo(() => { const spec = useMemo(() => {
if (orderedPoints.length === 0) return null if (orderedPoints.length === 0) return null
return { return {
type: 'area' as const, type: 'bar' as const,
data: [{ id: 'vendor-share', values: orderedPoints }], data: [{ id: 'vendor-share', values: orderedPoints }],
xField: 'label', xField: 'label',
yField: 'share', yField: 'share',
seriesField: 'vendor', seriesField: 'vendor',
stack: true, stack: true,
paddingInner: 0.12,
legends: { visible: false }, legends: { visible: false },
area: { bar: { style: { cornerRadius: 1 } },
style: { fillOpacity: 0.85, curveType: 'monotone' },
},
line: { style: { lineWidth: 0, curveType: 'monotone' } },
point: { visible: false },
color: { specified: colourMap }, color: { specified: colourMap },
axes: [ axes: [
{ {
......
import { import {
ArrowDownRight, ArrowDownRight,
ArrowUpRight, ArrowUpRight,
Sparkles,
TrendingDown, TrendingDown,
TrendingUp, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { formatReleaseDate, formatTokens } from '../lib/format' import type { RankingMover } from '../types'
import type { NewModelEntry, RankingMover } from '../types'
import { ModelLink, VendorLink } from './entity-links' import { ModelLink, VendorLink } from './entity-links'
type PulseSectionProps = { type PulseSectionProps = {
movers: RankingMover[] movers: RankingMover[]
droppers: RankingMover[] droppers: RankingMover[]
newModels: NewModelEntry[]
} }
/** /**
* Three-up "Pulse" panel: rank gainers, rank losers, and recently released * Rank movement panel: gainers and losers calculated from the previous period.
* models — the "what's changing" footer of the rankings page. Each card
* is intentionally compact so the trio fits in one row on desktop.
*/ */
export function PulseSection(props: PulseSectionProps) { export function PulseSection(props: PulseSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( 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 <PulseCard
title={t('Trending up')} title={t('Trending up')}
description={t('Models climbing the leaderboard')} description={t('Models climbing the leaderboard')}
...@@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) { ...@@ -59,22 +54,6 @@ export function PulseSection(props: PulseSectionProps) {
</ul> </ul>
)} )}
</PulseCard> </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> </section>
) )
} }
...@@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) { ...@@ -145,28 +124,3 @@ function MoverRow(props: { row: RankingMover; intent: 'up' | 'down' }) {
</li> </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 = { ...@@ -17,9 +17,7 @@ type RankingsHeroProps = {
/** /**
* Hero strip for the rankings page. Intentionally minimal — title + * Hero strip for the rankings page. Intentionally minimal — title +
* subtitle + period tabs only. Category filtering is no longer needed * subtitle + period tabs only.
* because every category is rendered inline as its own section further
* down the page.
*/ */
export function RankingsHero(props: RankingsHeroProps) { export function RankingsHero(props: RankingsHeroProps) {
const { t } = useTranslation() const { t } = useTranslation()
...@@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) { ...@@ -35,7 +33,7 @@ export function RankingsHero(props: RankingsHeroProps) {
</h1> </h1>
<p className='text-muted-foreground/80 max-w-2xl text-sm'> <p className='text-muted-foreground/80 max-w-2xl text-sm'>
{t( {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> </p>
</div> </div>
......
import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query'
import { buildRankingsSnapshot } from '../lib/mock-rankings' import { getRankings } from '../api'
import type { RankingPeriod, RankingsSnapshot } from '../types' import type { RankingPeriod } from '../types'
/** export function useRankings(period: RankingPeriod) {
* Memoised rankings snapshot for a period. return useQuery({
* queryKey: ['rankings', period],
* Currently this synchronously builds deterministic mock data. When the queryFn: () => getRankings(period),
* backend ships real analytics endpoints, swap the body to a staleTime: 5 * 60 * 1000,
* `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])
} }
import { useNavigate, useSearch } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout' import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition' import { PageTransition } from '@/components/page-transition'
import { import {
AppsSection,
CategorySections,
MarketShareSection, MarketShareSection,
ModelsSection, ModelsSection,
PulseSection, PulseSection,
...@@ -26,7 +25,8 @@ export function Rankings() { ...@@ -26,7 +25,8 @@ export function Rankings() {
? (search.period as RankingPeriod) ? (search.period as RankingPeriod)
: 'week' : 'week'
const snapshot = useRankings(period) const rankingsQuery = useRankings(period)
const snapshot = rankingsQuery.data?.data
const handlePeriodChange = (next: RankingPeriod) => { const handlePeriodChange = (next: RankingPeriod) => {
navigate({ navigate({
...@@ -56,7 +56,18 @@ export function Rankings() { ...@@ -56,7 +56,18 @@ 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'> <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} /> <RankingsHero period={period} onPeriodChange={handlePeriodChange} />
{/* Overall (all-categories) view ----------------------------- */} {rankingsQuery.isLoading ? (
<RankingsLoading />
) : !snapshot ? (
<RankingsError
message={
rankingsQuery.error instanceof Error
? rankingsQuery.error.message
: t('Unable to load rankings data')
}
/>
) : (
<>
<ModelsSection <ModelsSection
history={snapshot.models_history} history={snapshot.models_history}
rows={snapshot.models} rows={snapshot.models}
...@@ -69,24 +80,38 @@ export function Rankings() { ...@@ -69,24 +80,38 @@ export function Rankings() {
period={period} period={period}
/> />
<AppsSection rows={snapshot.apps} />
<PulseSection <PulseSection
movers={snapshot.top_movers} movers={snapshot.top_movers}
droppers={snapshot.top_droppers} 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>
</PageTransition> </PageTransition>
</div> </div>
</PublicLayout> </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>
)
}
export * from './format' export * from './format'
export * from './mock-rankings'
...@@ -2,11 +2,7 @@ ...@@ -2,11 +2,7 @@
// Rankings types // Rankings types
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// //
// Shape of the data shown on the /rankings page. The backend has not yet // Shape of the real data shown on the /rankings page.
// 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.
export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all' export type RankingPeriod = 'today' | 'week' | 'month' | 'year' | 'all'
...@@ -24,13 +20,6 @@ export type RankingCategoryId = ...@@ -24,13 +20,6 @@ export type RankingCategoryId =
| 'productivity' | 'productivity'
| 'multimodal' | 'multimodal'
export type RankingCategory = {
id: RankingCategoryId
/** Default English label, fed through i18n at render time. */
label: string
description: string
}
export type ModelRanking = { export type ModelRanking = {
rank: number rank: number
/** Previous rank in the same period; undefined means "new". */ /** Previous rank in the same period; undefined means "new". */
...@@ -47,37 +36,6 @@ export type ModelRanking = { ...@@ -47,37 +36,6 @@ export type ModelRanking = {
growth_pct: number 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 = { export type VendorRanking = {
rank: number rank: number
vendor: string vendor: string
...@@ -102,17 +60,6 @@ export type RankingMover = { ...@@ -102,17 +60,6 @@ export type RankingMover = {
growth_pct: number 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. * One sample of a model's token usage at a given timestamp.
* Flat shape ready to feed VChart's stacked-bar spec. * Flat shape ready to feed VChart's stacked-bar spec.
...@@ -158,42 +105,16 @@ export type VendorShareSeries = { ...@@ -158,42 +105,16 @@ export type VendorShareSeries = {
buckets: number 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 = { export type RankingsSnapshot = {
// Overall (all categories) ------------------------------------------------ // Overall (all categories) ------------------------------------------------
models: ModelRanking[] models: ModelRanking[]
apps: AppListing[]
vendors: VendorRanking[] vendors: VendorRanking[]
/** Largest rank gainers in this period. */ /** Largest rank gainers in this period. */
top_movers: RankingMover[] top_movers: RankingMover[]
/** Largest rank losers in this period. */ /** Largest rank losers in this period. */
top_droppers: RankingMover[] top_droppers: RankingMover[]
/** Newly launched / recently added models. */
new_models: NewModelEntry[]
/** Stacked-bar history of token usage by model over the period. */ /** Stacked-bar history of token usage by model over the period. */
models_history: ModelHistorySeries models_history: ModelHistorySeries
/** 100%-stacked area history of token share by vendor over the period. */ /** 100%-stacked area history of token share by vendor over the period. */
vendor_share_history: VendorShareSeries vendor_share_history: VendorShareSeries
// Per-category sections ---------------------------------------------------
/** Independent ranking sections, one per non-`all` category. */
category_sections: CategorySection[]
} }
export type HeaderNavPricingConfig = { export type HeaderNavAccessConfig = {
enabled: boolean enabled: boolean
requireAuth: boolean requireAuth: boolean
} }
...@@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = { ...@@ -6,10 +6,11 @@ export type HeaderNavPricingConfig = {
export type HeaderNavModulesConfig = { export type HeaderNavModulesConfig = {
home: boolean home: boolean
console: boolean console: boolean
pricing: HeaderNavPricingConfig pricing: HeaderNavAccessConfig
rankings: HeaderNavAccessConfig
docs: boolean docs: boolean
about: boolean about: boolean
[key: string]: boolean | HeaderNavPricingConfig [key: string]: boolean | HeaderNavAccessConfig
} }
export type SidebarSectionConfig = { export type SidebarSectionConfig = {
...@@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = { ...@@ -26,6 +27,10 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
enabled: true, enabled: true,
requireAuth: false, requireAuth: false,
}, },
rankings: {
enabled: true,
requireAuth: false,
},
docs: true, docs: true,
about: true, about: true,
} }
...@@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => { ...@@ -74,8 +79,33 @@ const toBoolean = (value: unknown, fallback: boolean): boolean => {
const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({ const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
...HEADER_NAV_DEFAULT, ...HEADER_NAV_DEFAULT,
pricing: { ...HEADER_NAV_DEFAULT.pricing }, 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 => const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>( Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
(acc, [section, config]) => { (acc, [section, config]) => {
...@@ -97,23 +127,16 @@ export function parseHeaderNavModules( ...@@ -97,23 +127,16 @@ export function parseHeaderNavModules(
const result: HeaderNavModulesConfig = { const result: HeaderNavModulesConfig = {
...base, ...base,
pricing: { ...base.pricing }, pricing: { ...base.pricing },
rankings: { ...base.rankings },
} }
Object.entries(parsed).forEach(([key, raw]) => { Object.entries(parsed).forEach(([key, raw]) => {
if (key === 'pricing') { if (key === 'pricing') {
if (raw && typeof raw === 'object') { result.pricing = parseAccessModule(raw, base.pricing)
const rawPricing = raw as Record<string, unknown> return
result.pricing = {
enabled: toBoolean(
rawPricing.enabled,
base.pricing?.enabled ?? true
),
requireAuth: toBoolean(
rawPricing.requireAuth,
base.pricing?.requireAuth ?? false
),
}
} }
if (key === 'rankings') {
result.rankings = parseAccessModule(raw, base.rankings)
return return
} }
......
...@@ -27,6 +27,8 @@ const headerNavSchema = z.object({ ...@@ -27,6 +27,8 @@ const headerNavSchema = z.object({
console: z.boolean(), console: z.boolean(),
pricingEnabled: z.boolean(), pricingEnabled: z.boolean(),
pricingRequireAuth: z.boolean(), pricingRequireAuth: z.boolean(),
rankingsEnabled: z.boolean(),
rankingsRequireAuth: z.boolean(),
docs: z.boolean(), docs: z.boolean(),
about: z.boolean(), about: z.boolean(),
}) })
...@@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ ...@@ -53,6 +55,14 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
config.pricing?.requireAuth === undefined config.pricing?.requireAuth === undefined
? HEADER_NAV_DEFAULT.pricing.requireAuth ? HEADER_NAV_DEFAULT.pricing.requireAuth
: Boolean(config.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: docs:
config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
about: about:
...@@ -90,6 +100,11 @@ export function HeaderNavigationSection({ ...@@ -90,6 +100,11 @@ export function HeaderNavigationSection({
enabled: values.pricingEnabled, enabled: values.pricingEnabled,
requireAuth: values.pricingRequireAuth, requireAuth: values.pricingRequireAuth,
}, },
rankings: {
...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
enabled: values.rankingsEnabled,
requireAuth: values.rankingsRequireAuth,
},
} }
const serialized = serializeHeaderNavModules(payload) const serialized = serializeHeaderNavModules(payload)
...@@ -107,7 +122,7 @@ export function HeaderNavigationSection({ ...@@ -107,7 +122,7 @@ export function HeaderNavigationSection({
form.reset(toFormValues(HEADER_NAV_DEFAULT)) form.reset(toFormValues(HEADER_NAV_DEFAULT))
} }
const modules: Array<{ const simpleModules: Array<{
key: keyof HeaderNavFormValues key: keyof HeaderNavFormValues
title: string title: string
description: string description: string
...@@ -134,6 +149,39 @@ export function HeaderNavigationSection({ ...@@ -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 ( return (
<SettingsSection <SettingsSection
title={t('Header navigation')} title={t('Header navigation')}
...@@ -142,7 +190,7 @@ export function HeaderNavigationSection({ ...@@ -142,7 +190,7 @@ export function HeaderNavigationSection({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'> <form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
{modules.map((module) => ( {simpleModules.map((module) => (
<FormField <FormField
key={module.key} key={module.key}
control={form.control} control={form.control}
...@@ -168,21 +216,19 @@ export function HeaderNavigationSection({ ...@@ -168,21 +216,19 @@ export function HeaderNavigationSection({
))} ))}
</div> </div>
<div className='rounded-lg border p-4'> <div className='grid gap-4 lg:grid-cols-2'>
{accessModules.map((module) => (
<div key={module.enabledKey} className='rounded-lg border p-4'>
<FormField <FormField
control={form.control} control={form.control}
name='pricingEnabled' name={module.enabledKey}
render={({ field }) => ( render={({ field }) => (
<FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'> <FormItem className='flex flex-row items-start justify-between rounded-lg border p-4'>
<div className='space-y-0.5 pe-4'> <div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'> <FormLabel className='text-base'>
{t('Models directory')} {module.title}
</FormLabel> </FormLabel>
<FormDescription> <FormDescription>{module.description}</FormDescription>
{t(
'Exposes the pricing/models catalog in the top navigation.'
)}
</FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
...@@ -197,24 +243,22 @@ export function HeaderNavigationSection({ ...@@ -197,24 +243,22 @@ export function HeaderNavigationSection({
<FormField <FormField
control={form.control} control={form.control}
name='pricingRequireAuth' name={module.requireAuthKey}
render={({ field }) => ( render={({ field }) => (
<FormItem className='mt-4 flex flex-row items-start justify-between rounded-lg border border-dashed p-4'> <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'> <div className='space-y-0.5 pe-4'>
<FormLabel className='text-base'> <FormLabel className='text-base'>
{t('Require login to view models')} {module.requireAuthTitle}
</FormLabel> </FormLabel>
<FormDescription> <FormDescription>
{t( {module.requireAuthDescription}
'Visitors must authenticate before accessing the pricing directory.'
)}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
disabled={!form.watch('pricingEnabled')} disabled={!form.watch(module.requireAuthDependsOn)}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
...@@ -222,6 +266,8 @@ export function HeaderNavigationSection({ ...@@ -222,6 +266,8 @@ export function HeaderNavigationSection({
)} )}
/> />
</div> </div>
))}
</div>
<div className='flex flex-wrap gap-3'> <div className='flex flex-wrap gap-3'>
<Button type='button' variant='outline' onClick={resetToDefault}> <Button type='button' variant='outline' onClick={resetToDefault}>
......
...@@ -269,11 +269,6 @@ function getModeBadgeVariant( ...@@ -269,11 +269,6 @@ function getModeBadgeVariant(
return 'outline' return 'outline'
} }
function truncateExpr(value: string) {
if (!value) return ''
return value.length > 110 ? `${value.slice(0, 110)}...` : value
}
function buildPreviewRows( function buildPreviewRows(
values: ModelPricingFormValues, values: ModelPricingFormValues,
mode: PricingMode, mode: PricingMode,
......
...@@ -2,10 +2,17 @@ import { useState, useEffect, useCallback } from 'react' ...@@ -2,10 +2,17 @@ import { useState, useEffect, useCallback } from 'react'
import { useQueryClient, useIsFetching } from '@tanstack/react-query' import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router' import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin' import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
...@@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants' ...@@ -17,6 +24,7 @@ import { LOG_TYPES } from '../constants'
import { buildSearchParams } from '../lib/filter' import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils' import { getDefaultTimeRange } from '../lib/utils'
import type { CommonLogFilters } from '../types' import type { CommonLogFilters } from '../types'
import { CommonLogsStats } from './common-logs-stats'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider' import { useUsageLogsContext } from './usage-logs-provider'
...@@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>( ...@@ -41,7 +49,7 @@ export function CommonLogsFilterBar<TData>(
const queryClient = useQueryClient() const queryClient = useQueryClient()
const searchParams = route.useSearch() const searchParams = route.useSearch()
const isAdmin = useIsAdmin() const isAdmin = useIsAdmin()
const { sensitiveVisible } = useUsageLogsContext() const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<CommonLogFilters>(() => { const [filters, setFilters] = useState<CommonLogFilters>(() => {
...@@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>( ...@@ -142,9 +150,34 @@ export function CommonLogsFilterBar<TData>(
const inputClass = 'w-full sm:w-[140px] lg:w-[160px]' const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
const sensitiveType = sensitiveVisible ? 'text' : 'password' 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 ( return (
<DataTableToolbar <DataTableToolbar
table={props.table} table={props.table}
leftActions={statsBar}
customSearch={ customSearch={
<CompactDateTimeRangePicker <CompactDateTimeRangePicker
start={filters.startTime} start={filters.startTime}
......
...@@ -6,7 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' ...@@ -6,7 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { SectionPageLayout } from '@/components/layout' import { SectionPageLayout } from '@/components/layout'
import type { NavGroup } from '@/components/layout/types' import type { NavGroup } from '@/components/layout/types'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog' 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 { UserInfoDialog } from './components/dialogs/user-info-dialog'
import { import {
UsageLogsProvider, UsageLogsProvider,
...@@ -106,11 +105,6 @@ function UsageLogsContent() { ...@@ -106,11 +105,6 @@ function UsageLogsContent() {
<SectionPageLayout.Description> <SectionPageLayout.Description>
{t(pageMeta.descriptionKey)} {t(pageMeta.descriptionKey)}
</SectionPageLayout.Description> </SectionPageLayout.Description>
{activeCategory === 'common' && (
<SectionPageLayout.Actions>
<CommonLogsHeaderActions />
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='space-y-4'>
{showTaskSwitcher && ( {showTaskSwitcher && (
......
...@@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = { ...@@ -20,6 +20,59 @@ const DEFAULT_HEADER_NAV_MODULES = {
about: true, about: true,
} }
function parseAccessModule(
raw: unknown,
fallback: { enabled: boolean; requireAuth: boolean }
) {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: raw === true || raw === 'true' || raw === '1' || raw === 1,
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled:
typeof record.enabled === 'boolean' ? record.enabled : fallback.enabled,
requireAuth:
typeof record.requireAuth === 'boolean'
? record.requireAuth
: fallback.requireAuth,
}
}
return { ...fallback }
}
function parseHeaderNavModules(
raw: unknown
): typeof DEFAULT_HEADER_NAV_MODULES {
if (!raw || String(raw).trim() === '') {
return DEFAULT_HEADER_NAV_MODULES
}
try {
const parsed = JSON.parse(String(raw)) as Record<string, unknown>
return {
...DEFAULT_HEADER_NAV_MODULES,
...parsed,
pricing: parseAccessModule(
parsed.pricing,
DEFAULT_HEADER_NAV_MODULES.pricing
),
rankings: parseAccessModule(
parsed.rankings,
DEFAULT_HEADER_NAV_MODULES.rankings
),
}
} catch {
return DEFAULT_HEADER_NAV_MODULES
}
}
/** /**
* Generate top navigation links based on HeaderNavModules configuration from backend /api/status * Generate top navigation links based on HeaderNavModules configuration from backend /api/status
* Backend format example (stringified JSON): * Backend format example (stringified JSON):
...@@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = { ...@@ -27,6 +80,7 @@ const DEFAULT_HEADER_NAV_MODULES = {
* home: true, * home: true,
* console: true, * console: true,
* pricing: { enabled: true, requireAuth: false }, * pricing: { enabled: true, requireAuth: false },
* rankings: { enabled: true, requireAuth: false },
* docs: true, * docs: true,
* about: true * about: true
* } * }
...@@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] { ...@@ -38,17 +92,7 @@ export function useTopNavLinks(): TopNavLink[] {
// Parse HeaderNavModules // Parse HeaderNavModules
const modules = useMemo(() => { const modules = useMemo(() => {
const raw = status?.HeaderNavModules return parseHeaderNavModules(status?.HeaderNavModules)
// If empty string, null, or undefined, use default config
if (!raw || (raw as string).trim() === '') {
return DEFAULT_HEADER_NAV_MODULES
}
try {
return JSON.parse(raw as string)
} catch {
// Parse failed, use default config
return DEFAULT_HEADER_NAV_MODULES
}
}, [status?.HeaderNavModules]) }, [status?.HeaderNavModules])
// Documentation link (may be external) // Documentation link (may be external)
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "Provide Markdown, HTML, or an external URL for the user agreement", "Provide Markdown, HTML, or an external URL for the user agreement": "Provide Markdown, HTML, or an external URL for the user agreement",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Provide per-category safety overrides as JSON. Use `default` for fallback values.",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.",
"Public model catalog and pricing page.": "Public model catalog and pricing page.",
"Public rankings page based on live usage data.": "Public rankings page based on live usage data.",
"Provider": "Provider", "Provider": "Provider",
"Provider & data privacy": "Provider & data privacy", "Provider & data privacy": "Provider & data privacy",
"Provider created successfully": "Provider created successfully", "Provider created successfully": "Provider created successfully",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "Require email verification for new accounts", "Require email verification for new accounts": "Require email verification for new accounts",
"Require job success before follow-up actions": "Require job success before follow-up actions", "Require job success before follow-up actions": "Require job success before follow-up actions",
"Require login to view models": "Require login to view models", "Require login to view models": "Require login to view models",
"Require login to view rankings": "Require login to view rankings",
"required": "required", "required": "required",
"Required": "Required", "Required": "Required",
"Required events:": "Required events:", "Required events:": "Required events:",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "Vision, image / video, document chat", "Vision, image / video, document chat": "Vision, image / video, document chat",
"Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...", "Visit Settings → General and adjust quota options...": "Visit Settings → General and adjust quota options...",
"Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.", "Visitors must authenticate before accessing the pricing directory.": "Visitors must authenticate before accessing the pricing directory.",
"Visitors must authenticate before accessing the rankings page.": "Visitors must authenticate before accessing the rankings page.",
"Visual": "Visual", "Visual": "Visual",
"Visual edit": "Visual edit", "Visual edit": "Visual edit",
"Visual editor": "Visual editor", "Visual editor": "Visual editor",
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "Fournir du Markdown, du HTML ou une URL externe pour l'accord utilisateur", "Provide Markdown, HTML, or an external URL for the user agreement": "Fournir du Markdown, du HTML ou une URL externe pour l'accord utilisateur",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Fournir des remplacements de sécurité par catégorie au format JSON. Utilisez `default` pour les valeurs de secours.", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Fournir des remplacements de sécurité par catégorie au format JSON. Utilisez `default` pour les valeurs de secours.",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Fournir des remplacements d'en-tête par modèle au format JSON. Utile pour activer des fonctionnalités bêta telles que les fenêtres de contexte étendues.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Fournir des remplacements d'en-tête par modèle au format JSON. Utile pour activer des fonctionnalités bêta telles que les fenêtres de contexte étendues.",
"Public model catalog and pricing page.": "Page publique du catalogue des modèles et des tarifs.",
"Public rankings page based on live usage data.": "Page publique des classements basée sur les données d'utilisation réelles.",
"Provider": "Fournisseur", "Provider": "Fournisseur",
"Provider & data privacy": "Fournisseur & confidentialité", "Provider & data privacy": "Fournisseur & confidentialité",
"Provider created successfully": "Fournisseur créé avec succès", "Provider created successfully": "Fournisseur créé avec succès",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes", "Require email verification for new accounts": "Exiger la vérification de l'e-mail pour les nouveaux comptes",
"Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi", "Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi",
"Require login to view models": "Exiger la connexion pour voir les modèles", "Require login to view models": "Exiger la connexion pour voir les modèles",
"Require login to view rankings": "Exiger la connexion pour voir les classements",
"required": "requis", "required": "requis",
"Required": "Requis", "Required": "Requis",
"Required events:": "Événements requis :", "Required events:": "Événements requis :",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document", "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document",
"Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...", "Visit Settings → General and adjust quota options...": "Visitez Paramètres → Général et ajustez les options de quota...",
"Visitors must authenticate before accessing the pricing directory.": "Les visiteurs doivent s'authentifier avant d'accéder au répertoire des prix.", "Visitors must authenticate before accessing the pricing directory.": "Les visiteurs doivent s'authentifier avant d'accéder au répertoire des prix.",
"Visitors must authenticate before accessing the rankings page.": "Les visiteurs doivent s'authentifier avant d'accéder à la page des classements.",
"Visual": "Visuel", "Visual": "Visuel",
"Visual edit": "Édition visuelle", "Visual edit": "Édition visuelle",
"Visual editor": "Éditeur visuel", "Visual editor": "Éditeur visuel",
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "ユーザー同意書にMarkdown、HTML、または外部URLを提供する", "Provide Markdown, HTML, or an external URL for the user agreement": "ユーザー同意書にMarkdown、HTML、または外部URLを提供する",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "カテゴリごとの安全オーバーライドをJSONとして提供します。フォールバック値には`default`を使用してください。", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "カテゴリごとの安全オーバーライドをJSONとして提供します。フォールバック値には`default`を使用してください。",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "モデルごとのヘッダーオーバーライドをJSONとして提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "モデルごとのヘッダーオーバーライドをJSONとして提供します。拡張コンテキストウィンドウなどのベータ機能を有効にするのに役立ちます。",
"Public model catalog and pricing page.": "モデルカタログと料金の公開ページ。",
"Public rankings page based on live usage data.": "実際の利用データに基づく公開ランキングページ。",
"Provider": "プロバイダ", "Provider": "プロバイダ",
"Provider & data privacy": "プロバイダーとデータ保護", "Provider & data privacy": "プロバイダーとデータ保護",
"Provider created successfully": "プロバイダーの作成に成功しました", "Provider created successfully": "プロバイダーの作成に成功しました",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "新しいアカウントにメール認証を要求する", "Require email verification for new accounts": "新しいアカウントにメール認証を要求する",
"Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求", "Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求",
"Require login to view models": "モデルを表示するにはログインを要求する", "Require login to view models": "モデルを表示するにはログインを要求する",
"Require login to view rankings": "ランキングを表示するにはログインを要求する",
"required": "必須", "required": "必須",
"Required": "必須", "Required": "必須",
"Required events:": "必須イベント:", "Required events:": "必須イベント:",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット", "Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット",
"Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...", "Visit Settings → General and adjust quota options...": "「設定」→「一般」にアクセスして、クォータオプションを調整してください...",
"Visitors must authenticate before accessing the pricing directory.": "訪問者は料金ディレクトリにアクセスする前に認証を行う必要があります。", "Visitors must authenticate before accessing the pricing directory.": "訪問者は料金ディレクトリにアクセスする前に認証を行う必要があります。",
"Visitors must authenticate before accessing the rankings page.": "訪問者はランキングページにアクセスする前に認証を行う必要があります。",
"Visual": "ビジュアル", "Visual": "ビジュアル",
"Visual edit": "ビジュアル編集", "Visual edit": "ビジュアル編集",
"Visual editor": "ビジュアルエディター", "Visual editor": "ビジュアルエディター",
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "Укажите Markdown, HTML или внешний URL для пользовательского соглашения", "Provide Markdown, HTML, or an external URL for the user agreement": "Укажите Markdown, HTML или внешний URL для пользовательского соглашения",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Предоставьте переопределения безопасности по категориям в формате JSON. Используйте `default` для резервных значений.", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Предоставьте переопределения безопасности по категориям в формате JSON. Используйте `default` для резервных значений.",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Предоставьте переопределения заголовков для каждой модели в формате JSON. Полезно для включения бета-функций, таких как расширенные окна контекста.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Предоставьте переопределения заголовков для каждой модели в формате JSON. Полезно для включения бета-функций, таких как расширенные окна контекста.",
"Public model catalog and pricing page.": "Публичная страница каталога моделей и цен.",
"Public rankings page based on live usage data.": "Публичная страница рейтингов на основе реальных данных использования.",
"Provider": "Провайдер", "Provider": "Провайдер",
"Provider & data privacy": "Поставщик и конфиденциальность", "Provider & data privacy": "Поставщик и конфиденциальность",
"Provider created successfully": "Поставщик успешно создан", "Provider created successfully": "Поставщик успешно создан",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей", "Require email verification for new accounts": "Требовать подтверждение электронной почты для новых учетных записей",
"Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями", "Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями",
"Require login to view models": "Требовать вход для просмотра моделей", "Require login to view models": "Требовать вход для просмотра моделей",
"Require login to view rankings": "Требовать вход для просмотра рейтингов",
"required": "обязателен", "required": "обязателен",
"Required": "Обязательно", "Required": "Обязательно",
"Required events:": "Обязательные события:", "Required events:": "Обязательные события:",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам", "Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам",
"Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...", "Visit Settings → General and adjust quota options...": "Перейдите в Настройки → Общие и настройте параметры квоты...",
"Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.", "Visitors must authenticate before accessing the pricing directory.": "Посетители должны пройти аутентификацию перед доступом к каталогу цен.",
"Visitors must authenticate before accessing the rankings page.": "Посетители должны пройти аутентификацию перед доступом к странице рейтингов.",
"Visual": "Визуальный", "Visual": "Визуальный",
"Visual edit": "Визуальное редактирование", "Visual edit": "Визуальное редактирование",
"Visual editor": "Визуальный редактор", "Visual editor": "Визуальный редактор",
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "Cung cấp Markdown, HTML, hoặc một URL bên ngoài cho thỏa thuận người dùng", "Provide Markdown, HTML, or an external URL for the user agreement": "Cung cấp Markdown, HTML, hoặc một URL bên ngoài cho thỏa thuận người dùng",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Cung cấp các ghi đè an toàn theo từng danh mục dưới dạng JSON. Sử dụng `default` cho các giá trị dự phòng.", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "Cung cấp các ghi đè an toàn theo từng danh mục dưới dạng JSON. Sử dụng `default` cho các giá trị dự phòng.",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Cung cấp các ghi đè tiêu đề theo từng mô hình dưới dạng JSON. Hữu ích để bật các tính năng beta như cửa sổ ngữ cảnh mở rộng.", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "Cung cấp các ghi đè tiêu đề theo từng mô hình dưới dạng JSON. Hữu ích để bật các tính năng beta như cửa sổ ngữ cảnh mở rộng.",
"Public model catalog and pricing page.": "Trang công khai cho danh mục mô hình và giá.",
"Public rankings page based on live usage data.": "Trang bảng xếp hạng công khai dựa trên dữ liệu sử dụng thực.",
"Provider": "Nhà cung cấp", "Provider": "Nhà cung cấp",
"Provider & data privacy": "Nhà cung cấp & quyền riêng tư", "Provider & data privacy": "Nhà cung cấp & quyền riêng tư",
"Provider created successfully": "Đã tạo nhà cung cấp thành công", "Provider created successfully": "Đã tạo nhà cung cấp thành công",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới", "Require email verification for new accounts": "Yêu cầu xác minh email cho tài khoản mới",
"Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo", "Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo",
"Require login to view models": "Yêu cầu đăng nhập để xem các mô hình", "Require login to view models": "Yêu cầu đăng nhập để xem các mô hình",
"Require login to view rankings": "Yêu cầu đăng nhập để xem bảng xếp hạng",
"required": "bắt buộc", "required": "bắt buộc",
"Required": "Bắt buộc", "Required": "Bắt buộc",
"Required events:": "Sự kiện bắt buộc:", "Required events:": "Sự kiện bắt buộc:",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu", "Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu",
"Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...", "Visit Settings → General and adjust quota options...": "Truy cập Cài đặt → Chung và điều chỉnh tùy chọn hạn mức...",
"Visitors must authenticate before accessing the pricing directory.": "Khách truy cập phải xác thực trước khi truy cập thư mục giá.", "Visitors must authenticate before accessing the pricing directory.": "Khách truy cập phải xác thực trước khi truy cập thư mục giá.",
"Visitors must authenticate before accessing the rankings page.": "Khách truy cập phải xác thực trước khi truy cập trang bảng xếp hạng.",
"Visual": "Trực quan", "Visual": "Trực quan",
"Visual edit": "Chỉnh sửa trực quan", "Visual edit": "Chỉnh sửa trực quan",
"Visual editor": "Trình sửa trực quan", "Visual editor": "Trình sửa trực quan",
......
...@@ -2943,6 +2943,8 @@ ...@@ -2943,6 +2943,8 @@
"Provide Markdown, HTML, or an external URL for the user agreement": "提供 Markdown、HTML 或外部 URL 作为用户协议", "Provide Markdown, HTML, or an external URL for the user agreement": "提供 Markdown、HTML 或外部 URL 作为用户协议",
"Provide per-category safety overrides as JSON. Use `default` for fallback values.": "以 JSON 格式提供按类别划分的安全覆盖。使用 `default` 作为回退值。", "Provide per-category safety overrides as JSON. Use `default` for fallback values.": "以 JSON 格式提供按类别划分的安全覆盖。使用 `default` 作为回退值。",
"Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "以 JSON 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。", "Provide per-model header overrides as JSON. Useful for enabling beta features such as expanded context windows.": "以 JSON 格式提供按模型划分的标头覆盖。可用于启用测试功能,例如扩展上下文窗口。",
"Public model catalog and pricing page.": "公开模型目录和价格页面。",
"Public rankings page based on live usage data.": "基于真实用量数据的公开排行榜页面。",
"Provider": "提供商", "Provider": "提供商",
"Provider & data privacy": "厂商与数据隐私", "Provider & data privacy": "厂商与数据隐私",
"Provider created successfully": "提供商创建成功", "Provider created successfully": "提供商创建成功",
...@@ -3157,6 +3159,7 @@ ...@@ -3157,6 +3159,7 @@
"Require email verification for new accounts": "要求新账户验证邮箱", "Require email verification for new accounts": "要求新账户验证邮箱",
"Require job success before follow-up actions": "在后续操作前要求任务成功", "Require job success before follow-up actions": "在后续操作前要求任务成功",
"Require login to view models": "要求登录才能查看模型", "Require login to view models": "要求登录才能查看模型",
"Require login to view rankings": "要求登录才能查看排行榜",
"required": "必填", "required": "必填",
"Required": "必需", "Required": "必需",
"Required events:": "必需事件:", "Required events:": "必需事件:",
...@@ -4125,6 +4128,7 @@ ...@@ -4125,6 +4128,7 @@
"Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话", "Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话",
"Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...", "Visit Settings → General and adjust quota options...": "访问设置 → 通用并调整配额选项...",
"Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。", "Visitors must authenticate before accessing the pricing directory.": "访客必须先进行身份验证才能访问定价目录。",
"Visitors must authenticate before accessing the rankings page.": "访客必须先进行身份验证才能访问排行榜页面。",
"Visual": "可视", "Visual": "可视",
"Visual edit": "可视化编辑", "Visual edit": "可视化编辑",
"Visual editor": "可视化编辑器", "Visual editor": "可视化编辑器",
......
...@@ -4,7 +4,8 @@ export const STATIC_I18N_KEYS = [ ...@@ -4,7 +4,8 @@ export const STATIC_I18N_KEYS = [
// Header navigation // Header navigation
'Home', 'Home',
'Console', 'Console',
'Pricing', 'Model Square',
'Rankings',
'Docs', 'Docs',
'About', 'About',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment