码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/SystemStats.tsx
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>
  )
}