Files
2025-12-26 16:03:12 +08:00

343 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { BarChart3, RefreshCcw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AdminUnauthorizedError, adminFetchJson } from '@/lib/admin-api'
interface Stats {
date: string
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
hourly: {
hour: number
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
}[]
}
interface Realtime {
rpm: number
tpm: number
}
export default function StatsPage() {
const router = useRouter()
const [providers, setProviders] = useState<{ id: number; name: string; model_id: string; is_default: boolean }[]>([])
const [providerId, setProviderId] = useState<string>('')
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
const [stats, setStats] = useState<Stats | null>(null)
const [realtime, setRealtime] = useState<Realtime>({ rpm: 0, tpm: 0 })
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const totalTokens = useMemo(() => {
if (!stats) return 0
return stats.input_tokens + stats.output_tokens
}, [stats])
const cacheHitRate = useMemo(() => {
if (!stats || stats.request_count <= 0) return 0
return stats.cached_count / stats.request_count
}, [stats])
const errorRate = useMemo(() => {
if (!stats || stats.request_count <= 0) return 0
return stats.error_count / stats.request_count
}, [stats])
const maxHourlyRequest = useMemo(() => {
if (!stats) return 0
return Math.max(0, ...stats.hourly.map(h => h.request_count))
}, [stats])
const fetchProviders = async () => {
setError('')
try {
const data = await adminFetchJson<{
id: number
name: string
model_id: string
is_default: boolean
}[]>('/api/v1/admin/providers')
setProviders(data)
const defaultOne = data.find(p => p.is_default) || data[0] || null
if (defaultOne) setProviderId(String(defaultOne.id))
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载 Provider 失败')
}
}
const fetchRealtime = async () => {
if (!providerId) return
try {
const data = await adminFetchJson<Realtime>(`/api/v1/admin/stats/realtime/${providerId}`)
setRealtime(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
}
}
const fetchStats = async () => {
if (!providerId) return
setIsLoading(true)
setError('')
try {
const data = await adminFetchJson<Stats>(`/api/v1/admin/stats/daily/${providerId}?date=${date}`)
setStats(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载统计失败')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchProviders()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!providerId) return
fetchStats()
fetchRealtime()
const interval = setInterval(fetchRealtime, 5000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerId, date])
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">使</h1>
<p className="text-sm text-muted-foreground">
RPM/TPM +
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchStats} disabled={isLoading || !providerId}>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
<Button asChild variant="secondary">
<Link href="/admin/providers">
<BarChart3 className="mr-2 h-4 w-4" />
Provider
</Link>
</Button>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle></CardTitle>
<CardDescription> Provider </CardDescription>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full sm:w-[520px]">
<Select value={providerId} onValueChange={setProviderId}>
<SelectTrigger>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
{providers.map(p => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name} ({p.model_id}){p.is_default ? ' · 默认' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>RPM</CardDescription>
<CardTitle className="text-3xl">{realtime.rpm}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>TPM</CardDescription>
<CardTitle className="text-3xl">{realtime.tpm}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{stats ? `${(cacheHitRate * 100).toFixed(1)}%` : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{stats ? `${stats.cached_count} / ${stats.request_count}` : ''}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{stats ? `${(errorRate * 100).toFixed(1)}%` : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{stats ? `${stats.error_count} / ${stats.request_count}` : ''}
</CardContent>
</Card>
</div>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">{stats.request_count}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{totalTokens}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{stats.input_tokens}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{stats.output_tokens}</CardTitle>
</CardHeader>
</Card>
</div>
)}
</CardContent>
</Card>
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>0-23 </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!stats ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<>
<div className="flex items-end gap-1 h-28 overflow-x-auto rounded-md border bg-background p-3">
{stats.hourly.map((h) => {
const pct = maxHourlyRequest ? h.request_count / maxHourlyRequest : 0
return (
<div key={h.hour} className="flex flex-col items-center gap-1 min-w-[18px]">
<div
className="w-3 rounded-sm bg-primary/70"
style={{ height: `${Math.max(2, Math.round(pct * 96))}px` }}
title={`${h.hour}:00 请求:${h.request_count} 缓存:${h.cached_count} 错误:${h.error_count}`}
/>
<div className="text-[10px] text-muted-foreground">{h.hour}</div>
</div>
)
})}
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Token</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.hourly.map((h) => (
<TableRow key={h.hour}>
<TableCell className="font-mono text-xs">{String(h.hour).padStart(2, '0')}:00</TableCell>
<TableCell>{h.request_count}</TableCell>
<TableCell>{h.input_tokens + h.output_tokens}</TableCell>
<TableCell>{h.cached_count}</TableCell>
<TableCell>{h.error_count}</TableCell>
<TableCell>
{h.error_count > 0 ? (
<Badge variant="warning"></Badge>
) : h.request_count > 0 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}