码桶
发现社区成员的开源项目
SystemStats.tsx17.9 KB
import { useEffect, useState, useCallback } from 'react'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Server, MessageSquare, Brain, Coins, Cpu, MemoryStick, Clock, Activity, BarChart3, Bot } from 'lucide-react'
interface StatsResp {
ok: boolean
system: {
instances: number
running: number
instanceList: { id: number; name: string; login_status: string }[]
totalMessages: number
todayModelCalls: number
todayTokens: number
cpu: number
systemCpu: number
processMemoryMB: number
systemMemoryTotalGB: number
systemMemoryFreeGB: number
uptimeSec: number
startedAt: number
}
messages: {
ranges: Record<string, { in: number; out: number; total: number }>
trend: { label: string; in: number; out: number; total: number }[]
byBot: { bot_id: number; name: string; in: number; out: number; total: number }[]
}
models: {
todayCalls: number
todayTokens: number
totalCalls: number
ttftAvg: number
latencyAvg: number
tpmAvg: number
successRate: number
callTrend: { label: string; calls: number; tokens: number }[]
byModelCalls: { model: string; calls: number }[]
byModelTokens: { model: string; tokens: number }[]
topSessions: { peer_id: string; tokens: number; calls: number }[]
}
}
const RANGES = [
{ v: 1, label: '1 天' },
{ v: 3, label: '3 天' },
{ v: 7, label: '1 周' },
]
function fmtNum(n: number) {
return (n || 0).toLocaleString('en-US')
}
function fmtUptime(sec: number) {
const s = Math.floor(sec)
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
if (d > 0) return `${d}天 ${h}时 ${m}分`
return `${h}时 ${m}分 ${ss}秒`
}
function fmtDate(epochSec: number) {
const d = new Date(epochSec * 1000)
const p = (x: number) => String(x).padStart(2, '0')
return `${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function fmtMem(mb: number) {
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb} MB`
}
// 轻量 SVG 面积图(无第三方依赖)
function AreaChart({
series,
height = 200,
stroke = '#3b82f6',
fill = 'rgba(59,130,246,0.14)',
}: {
series: { label: string; value: number }[]
height?: number
stroke?: string
fill?: string
}) {
const W = 760
const H = height
const padX = 36
const padY = 24
const max = Math.max(1, ...series.map((s) => s.value))
const n = series.length
const innerW = W - padX * 2
const innerH = H - padY * 2
const x = (i: number) => padX + (n <= 1 ? innerW / 2 : (innerW * i) / (n - 1))
const y = (v: number) => padY + innerH - (innerH * v) / max
const linePts = series.map((s, i) => `${x(i)},${y(s.value)}`)
const linePath = linePts.join(' ')
const areaPath = `${x(0)},${H - padY} ${linePath} ${x(n - 1)},${H - padY}`
const labelStep = Math.max(1, Math.ceil(n / 6))
const gridY = [0, 0.25, 0.5, 0.75, 1].map((f) => padY + innerH - innerH * f)
return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ height }} preserveAspectRatio="xMidYMid meet">
{gridY.map((gy, i) => (
<line key={i} x1={padX} x2={W - padX} y1={gy} y2={gy} stroke="rgba(148,163,184,0.15)" strokeWidth={1} />
))}
<polygon points={areaPath} fill={fill} />
<polyline points={linePath} fill="none" stroke={stroke} strokeWidth={2} strokeLinejoin="round" />
{series.map((s, i) => (
<circle key={i} cx={x(i)} cy={y(s.value)} r={2.5} fill={stroke}>
<title>{`${s.label}: ${fmtNum(s.value)}`}</title>
</circle>
))}
{series.map((s, i) =>
i % labelStep === 0 || i === n - 1 ? (
<text
key={i}
x={x(i)}
y={H - 6}
fontSize={10}
fill="rgba(148,163,184,0.85)"
textAnchor={i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle'}
>
{s.label}
</text>
) : null
)}
</svg>
)
}
function MetricCard({
icon,
label,
value,
sub,
}: {
icon: React.ReactNode
label: string
value: string
sub?: string
}) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/40 text-primary">
{icon}
</div>
<div className="min-w-0">
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-2xl font-semibold leading-tight truncate">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
</div>
</CardContent>
</Card>
)
}
function MiniStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
)
}
function Empty({ text }: { text: string }) {
return <div className="py-8 text-center text-sm text-muted-foreground">{text}</div>
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<CardTitle className="flex items-center gap-2 text-base">
{icon}
{children}
</CardTitle>
)
}
export default function SystemStats() {
const [range, setRange] = useState(1)
const [data, setData] = useState<StatsResp | null>(null)
const [loading, setLoading] = useState(false)
const [auto, setAuto] = useState(true)
const [updatedAt, setUpdatedAt] = useState<number>(0)
const load = useCallback(async () => {
setLoading(true)
try {
const d = await api.post('/api/stats', { range })
if (d && d.ok) {
setData(d)
setUpdatedAt(Date.now())
}
} catch (e) {
console.error('[SystemStats] 加载失败', e)
} finally {
setLoading(false)
}
}, [range])
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (!auto) return
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [auto, load])
if (!data) {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
{loading ? '加载中…' : '暂无数据'}
</div>
)
}
const s = data.system
const m = data.models
const r = data.messages
const cur = r.ranges[String(range)] || { in: 0, out: 0, total: 0 }
return (
<div className="flex flex-col gap-3 p-3 md:p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<h1 className="text-lg font-semibold">系统统计</h1>
<span className="text-sm text-muted-foreground">平台、消息与模型调用的统一视图</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{updatedAt ? `更新于 ${fmtDate(Math.floor(updatedAt / 1000))}` : ''}
</span>
<Button variant="outline" size="sm" className="h-9" onClick={() => setAuto((v) => !v)}>
<Activity className={`mr-1 h-4 w-4 ${auto ? 'text-green-500' : ''}`} />
{auto ? '自动' : '手动'}
</Button>
<Button size="sm" className="h-9" onClick={load} disabled={loading}>
<RefreshCw className={`mr-1 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
</div>
</div>
{/* 顶部四项指标 */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
icon={<Server className="h-5 w-5" />}
label="平台实例数"
value={fmtNum(s.instances)}
sub={`运行中 ${s.running}`}
/>
<MetricCard
icon={<MessageSquare className="h-5 w-5" />}
label="消息总数"
value={fmtNum(s.totalMessages)}
sub="累计消息量"
/>
<MetricCard
icon={<Brain className="h-5 w-5" />}
label="今日模型调用"
value={fmtNum(s.todayModelCalls)}
sub="今日累计"
/>
<MetricCard
icon={<Coins className="h-5 w-5" />}
label="词元(Tokens)"
value={fmtNum(s.todayTokens)}
sub="今日消耗"
/>
</div>
{/* 系统资源 */}
<Card>
<CardHeader>
<SectionTitle icon={<Cpu className="h-4 w-4" />}>系统资源</SectionTitle>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="grid grid-cols-2 gap-3">
<MiniStat label="程序 CPU 占用" value={`${s.cpu}%`} />
<MiniStat label="系统 CPU" value={`${s.systemCpu}%`} />
<MiniStat label="进程内存" value={fmtMem(s.processMemoryMB)} />
<MiniStat
label="系统内存"
value={`${fmtMem(Math.round((s.systemMemoryTotalGB - s.systemMemoryFreeGB) * 1024))} / ${s.systemMemoryTotalGB} GB`}
/>
<MiniStat label="运行时间" value={fmtUptime(s.uptimeSec)} />
<MiniStat label="启动于" value={fmtDate(s.startedAt)} />
</div>
<div>
<div className="mb-2 text-sm font-medium text-muted-foreground">平台实例</div>
<div className="space-y-2">
{s.instanceList.length === 0 && <Empty text="暂无平台实例" />}
{s.instanceList.map((b) => (
<div key={b.id} className="flex items-center justify-between rounded-lg border p-3">
<div>
<div className="font-medium">{b.name}</div>
<div className="text-xs text-muted-foreground">#{b.id}</div>
</div>
<Badge variant={b.login_status === 'confirmed' || b.login_status === 'online' ? 'success' : 'secondary'}>
{b.login_status === 'confirmed' || b.login_status === 'online' ? '运行中' : b.login_status || '未连接'}
</Badge>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* 消息概览 */}
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-base">消息概览</CardTitle>
<Tabs value={String(range)} onValueChange={(v) => setRange(Number(v))}>
<TabsList>
{RANGES.map((x) => (
<TabsTrigger key={x.v} value={String(x.v)}>
{x.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="接收" value={fmtNum(cur.in)} />
<MiniStat label="发送" value={fmtNum(cur.out)} />
<MiniStat label="总计" value={fmtNum(cur.total)} />
</div>
<div>
<div className="mb-1 text-sm text-muted-foreground">
消息趋势(最近 {range === 1 ? '1 天按小时' : `${range} 天按天`} 聚合)
</div>
<AreaChart series={r.trend.map((t) => ({ label: t.label, value: t.total }))} stroke="#10b981" fill="rgba(16,185,129,0.14)" />
</div>
<div>
<div className="mb-2 text-sm font-medium text-muted-foreground">平台消息排名</div>
{r.byBot.length === 0 ? (
<Empty text="当前没有平台消息统计数据。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>平台</TableHead>
<TableHead className="text-right">接收</TableHead>
<TableHead className="text-right">发送</TableHead>
<TableHead className="text-right">总计</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{r.byBot.map((b) => (
<TableRow key={b.bot_id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell className="text-right">{fmtNum(b.in)}</TableCell>
<TableCell className="text-right">{fmtNum(b.out)}</TableCell>
<TableCell className="text-right">{fmtNum(b.total)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
{/* 模型调用 */}
<Card>
<CardHeader>
<SectionTitle icon={<Brain className="h-4 w-4" />}>模型调用</SectionTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1 text-sm text-muted-foreground">模型调用趋势(调用次数)</div>
<AreaChart series={m.callTrend.map((t) => ({ label: t.label, value: t.calls }))} stroke="#3b82f6" fill="rgba(59,130,246,0.14)" />
</div>
<div>
<div className="mb-1 text-sm text-muted-foreground">模型调用词元(Tokens)趋势</div>
<AreaChart series={m.callTrend.map((t) => ({ label: t.label, value: t.tokens }))} stroke="#8b5cf6" fill="rgba(139,92,246,0.14)" />
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MiniStat label="平均首字延迟 TTFT" value={m.ttftAvg ? `${m.ttftAvg} ms` : '—'} />
<MiniStat label="平均响应时间" value={m.latencyAvg ? `${m.latencyAvg} ms` : '—'} />
<MiniStat label="平均每分钟输出 TPM" value={m.tpmAvg ? fmtNum(m.tpmAvg) : '—'} />
<MiniStat label="调用成功率" value={m.todayCalls ? `${m.successRate}%` : '—'} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-2 text-sm font-medium text-muted-foreground">模型调用排名</div>
{m.byModelCalls.length === 0 ? (
<Empty text="最近 1 天还没有模型调用数据。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>模型</TableHead>
<TableHead className="text-right">调用次数</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{m.byModelCalls.map((x) => (
<TableRow key={x.model}>
<TableCell className="font-medium">{x.model}</TableCell>
<TableCell className="text-right">{fmtNum(x.calls)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div>
<div className="mb-2 text-sm font-medium text-muted-foreground">模型调用词元排名</div>
{m.byModelTokens.length === 0 ? (
<Empty text="最近 1 天还没有模型调用数据。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>模型</TableHead>
<TableHead className="text-right">词元</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{m.byModelTokens.map((x) => (
<TableRow key={x.model}>
<TableCell className="font-medium">{x.model}</TableCell>
<TableCell className="text-right">{fmtNum(x.tokens)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium text-muted-foreground">最近 1 天会话词元(Tokens)Top 10</div>
{m.topSessions.length === 0 ? (
<Empty text="最近 1 天还没有会话调用数据。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>会话</TableHead>
<TableHead className="text-right">词元</TableHead>
<TableHead className="text-right">调用次数</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{m.topSessions.map((x) => (
<TableRow key={x.peer_id}>
<TableCell className="font-mono text-xs">{x.peer_id}</TableCell>
<TableCell className="text-right">{fmtNum(x.tokens)}</TableCell>
<TableCell className="text-right">{fmtNum(x.calls)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
)
}