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

376 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 { Activity, BarChart3, Bot, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AdminUnauthorizedError, API_BASE, adminFetchJson } from '@/lib/admin-api'
interface Provider {
id: number
name: string
model_id: string
base_url: string | null
is_active: boolean
is_default: boolean
}
interface HourlyStat {
hour: number
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
}
interface DailyStats {
date: string
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
hourly: HourlyStat[]
}
interface RealtimeStats {
rpm: number
tpm: number
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value)
}
function formatPercent(value: number) {
return `${(value * 100).toFixed(1)}%`
}
export default function AdminPage() {
const router = useRouter()
const [providers, setProviders] = useState<Provider[]>([])
const [providerId, setProviderId] = useState<string>('')
const [daily, setDaily] = useState<DailyStats | null>(null)
const [realtime, setRealtime] = useState<RealtimeStats>({ rpm: 0, tpm: 0 })
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const selectedProvider = useMemo(
() => providers.find((p) => String(p.id) === providerId) || null,
[providers, providerId]
)
const cacheHitRate = useMemo(() => {
if (!daily || daily.request_count <= 0) return 0
return daily.cached_count / daily.request_count
}, [daily])
const errorRate = useMemo(() => {
if (!daily || daily.request_count <= 0) return 0
return daily.error_count / daily.request_count
}, [daily])
const totalTokens = useMemo(() => {
if (!daily) return 0
return daily.input_tokens + daily.output_tokens
}, [daily])
const fetchProviders = async () => {
setIsLoading(true)
setError('')
try {
const data = await adminFetchJson<Provider[]>('/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 || '加载失败')
} finally {
setIsLoading(false)
}
}
const fetchDaily = async (id: string) => {
try {
const data = await adminFetchJson<DailyStats>(`/api/v1/admin/stats/daily/${id}`)
setDaily(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载统计失败')
}
}
const fetchRealtime = async (id: string) => {
try {
const data = await adminFetchJson<RealtimeStats>(`/api/v1/admin/stats/realtime/${id}`)
setRealtime(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
}
}
const fetchHealth = async () => {
try {
const res = await fetch(`${API_BASE}/health`)
setHealthOk(res.ok)
} catch {
setHealthOk(false)
}
}
useEffect(() => {
fetchProviders()
fetchHealth()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!providerId) return
fetchDaily(providerId)
fetchRealtime(providerId)
const timer = setInterval(() => fetchRealtime(providerId), 5000)
return () => clearInterval(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerId])
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">
Provider
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => fetchHealth()}>
<Activity className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={fetchProviders} disabled={isLoading}>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
<Button asChild>
<Link href="/admin/providers">
<Bot className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button variant="secondary" asChild>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-4 w-4" />
</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> Provider</CardTitle>
<CardDescription> Provider </CardDescription>
</div>
<div className="w-full sm:w-[320px]">
<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>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>API </span>
{healthOk === null ? (
<Badge variant="outline"></Badge>
) : healthOk ? (
<Badge variant="success">OK</Badge>
) : (
<Badge variant="destructive"></Badge>
)}
{selectedProvider && (
<>
<span className="ml-4"></span>
{selectedProvider.is_active ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
{selectedProvider.is_default && <Badge variant="success"></Badge>}
</>
)}
</div>
<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">{formatNumber(realtime.rpm)}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>TPM Token</CardDescription>
<CardTitle className="text-3xl">{formatNumber(realtime.tpm)}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(daily.request_count) : '--'}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(totalTokens) : '--'}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatPercent(cacheHitRate) : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{daily ? formatNumber(daily.cached_count) : '--'}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatPercent(errorRate) : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{daily ? formatNumber(daily.error_count) : '--'}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>/ Token</CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(daily.input_tokens) : '--'} / {daily ? formatNumber(daily.output_tokens) : '--'}
</CardTitle>
</CardHeader>
</Card>
</div>
</CardContent>
</Card>
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle>Provider </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
ProviderAI
</TableCell>
</TableRow>
) : (
providers.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{p.name}
{p.is_default && <Badge variant="success"></Badge>}
</div>
</TableCell>
<TableCell className="font-mono text-xs">{p.model_id}</TableCell>
<TableCell>
{p.is_active ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}