331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import useSWR from 'swr'
|
|
import { Zap, Monitor, Smartphone } from 'lucide-react'
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
BarChart,
|
|
Bar,
|
|
Legend,
|
|
} from 'recharts'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
|
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { api } from '@/lib/api-client'
|
|
import { formatNumber } from '@/lib/utils'
|
|
import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } from '@/lib/types'
|
|
|
|
export default function UsagePage() {
|
|
const [days, setDays] = useState(7)
|
|
const [activeTab, setActiveTab] = useState('relay')
|
|
const [error, setError] = useState('')
|
|
|
|
// 4 parallel SWR calls — each loads independently
|
|
const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
|
|
['usage.daily', days],
|
|
() => api.usage.daily({ days })
|
|
)
|
|
const { data: modelData = [], isLoading: modelLoading } = useSWR(
|
|
['usage.byModel', days],
|
|
() => api.usage.byModel({ days })
|
|
)
|
|
const { data: telemetryModels = [] } = useSWR(
|
|
['telemetry.modelStats'],
|
|
() => api.telemetry.modelStats()
|
|
)
|
|
const { data: telemetryDaily = [] } = useSWR(
|
|
['telemetry.dailyStats', days],
|
|
() => api.telemetry.dailyStats({ days })
|
|
)
|
|
|
|
const relayLoading = dailyLoading || modelLoading
|
|
const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
|
|
|
|
// === Relay 用量图表数据 ===
|
|
|
|
const relayLineData = dailyData.map((r) => ({
|
|
day: r.day.slice(5),
|
|
Input: r.input_tokens,
|
|
Output: r.output_tokens,
|
|
}))
|
|
|
|
const relayBarData = modelData.map((r) => ({
|
|
model: r.model_id,
|
|
请求量: r.count,
|
|
Input: r.input_tokens,
|
|
Output: r.output_tokens,
|
|
}))
|
|
|
|
const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
|
const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
|
const relayTotalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
|
|
|
// === 遥测图表数据 ===
|
|
|
|
const telemetryLineData = telemetryDaily.map((r) => ({
|
|
day: r.day.slice(5),
|
|
Input: r.input_tokens,
|
|
Output: r.output_tokens,
|
|
设备数: r.unique_devices,
|
|
}))
|
|
|
|
const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
|
|
const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
|
|
const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
|
|
|
|
// === 合计 ===
|
|
|
|
const totalInput = relayTotalInput + telemetryTotalInput
|
|
const totalOutput = relayTotalOutput + telemetryTotalOutput
|
|
const totalRequests = relayTotalRequests + telemetryTotalRequests
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
|
{/* 时间范围 */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-muted-foreground">时间范围:</span>
|
|
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="7">最近 7 天</SelectItem>
|
|
<SelectItem value="30">最近 30 天</SelectItem>
|
|
<SelectItem value="90">最近 90 天</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 汇总统计 — render immediately, use 0 while loading */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<p className="text-sm text-muted-foreground">总请求数</p>
|
|
<p className="mt-1 text-2xl font-bold text-foreground">
|
|
{formatNumber(totalRequests)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<p className="text-sm text-muted-foreground">总 Input Tokens</p>
|
|
<p className="mt-1 text-2xl font-bold text-blue-400">
|
|
{formatNumber(totalInput)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<p className="text-sm text-muted-foreground">总 Output Tokens</p>
|
|
<p className="mt-1 text-2xl font-bold text-orange-400">
|
|
{formatNumber(totalOutput)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center gap-2">
|
|
<Monitor className="h-4 w-4 text-green-400" />
|
|
<p className="text-sm text-muted-foreground">中转请求</p>
|
|
</div>
|
|
<p className="mt-1 text-2xl font-bold text-green-400">
|
|
{formatNumber(relayTotalRequests)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center gap-2">
|
|
<Smartphone className="h-4 w-4 text-purple-400" />
|
|
<p className="text-sm text-muted-foreground">桌面端调用</p>
|
|
</div>
|
|
<p className="mt-1 text-2xl font-bold text-purple-400">
|
|
{formatNumber(telemetryTotalRequests)}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tab 切换 */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList>
|
|
<TabsTrigger value="relay">
|
|
<Monitor className="h-4 w-4 mr-1" />
|
|
中转用量
|
|
</TabsTrigger>
|
|
<TabsTrigger value="telemetry">
|
|
<Smartphone className="h-4 w-4 mr-1" />
|
|
桌面端遥测
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Relay 用量 Tab */}
|
|
<TabsContent value="relay" className="space-y-6">
|
|
{relayLoading ? (
|
|
<>
|
|
<ChartSkeleton height={320} />
|
|
<ChartSkeleton height={280} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Zap className="h-4 w-4 text-primary" />
|
|
中转 Token 用量趋势
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{relayLineData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={320}>
|
|
<LineChart data={relayLineData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
|
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<EmptyState message="暂无中转数据" />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">中转按模型分布</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{relayBarData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
|
|
<BarChart data={relayBarData} layout="vertical">
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
<XAxis type="number" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
<YAxis type="category" dataKey="model" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} width={120} />
|
|
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
|
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<EmptyState />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 遥测 Tab */}
|
|
<TabsContent value="telemetry" className="space-y-6">
|
|
{telemetryLoading ? (
|
|
<>
|
|
<ChartSkeleton height={320} />
|
|
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Smartphone className="h-4 w-4 text-purple-400" />
|
|
桌面端 Token 用量趋势
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{telemetryLineData.length > 0 ? (
|
|
<ResponsiveContainer width="100%" height={320}>
|
|
<LineChart data={telemetryLineData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
|
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
|
|
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
|
|
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
|
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
|
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
) : (
|
|
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">桌面端按模型统计</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{telemetryModels.length > 0 ? (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>模型</TableHead>
|
|
<TableHead className="text-right">请求数</TableHead>
|
|
<TableHead className="text-right">Input Tokens</TableHead>
|
|
<TableHead className="text-right">Output Tokens</TableHead>
|
|
<TableHead className="text-right">平均延迟</TableHead>
|
|
<TableHead className="text-right">成功率</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{telemetryModels.map((stat) => (
|
|
<TableRow key={stat.model_id}>
|
|
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
|
|
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
|
|
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
|
|
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
|
|
{(stat.success_rate * 100).toFixed(1)}%
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
) : (
|
|
<EmptyState />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|