Files
HeXi/hexi/web/src/pages/home/index.tsx
T

271 lines
10 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from 'react'
import { Avatar, Button, Card } from '@heroui/react'
import { hubDashboard } from '../../api/client'
interface BotInfo {
self_id: string
adapter: string
nick: string
avatar: string
ws_connected: boolean
online: boolean | null
msg_recv: number | null
msg_sent: number | null
}
interface SystemInfo {
hostname: string
os: string
python: string
nonebot: string
boot_time: number
system_uptime_seconds: number
bot_uptime_seconds: number
}
interface CpuInfo {
percent: number
per_core: number[]
logical: number | null
physical: number | null
brand: string
load_avg: number[] | null
}
interface MemInfo {
percent: number
used: number
total: number
available: number
process_rss: number
process_percent: number
}
interface ProcessInfo {
pid: number
name: string
rss: number
cpu_percent: number
memory_percent: number
create_time: number
}
interface DiskInfo {
mount: string
device: string
percent: number
used: number
total: number
}
interface DashboardData {
ok: boolean
bots: BotInfo[]
system: SystemInfo
cpu: CpuInfo
memory: MemInfo
process: ProcessInfo
disk: DiskInfo[]
}
function fmtBytes(n: number): string {
if (!Number.isFinite(n)) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return v.toFixed(1) + ' ' + units[i]
}
function fmtUptime(sec: number): string {
if (!Number.isFinite(sec)) return '—'
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
const parts: string[] = []
if (d) parts.push(d + ' 天')
if (h) parts.push(h + ' 小时')
parts.push(m + ' 分')
return parts.join(' ')
}
function Bar({ value, color }: { value: number; color?: string }) {
const v = Math.max(0, Math.min(100, value))
const c = color || (v >= 80 ? 'bg-rose-500' : v >= 60 ? 'bg-amber-500' : 'bg-indigo-500')
return (
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
<div className={'h-full rounded-full transition-all ' + c} style={{ width: v + '%' }} />
</div>
)
}
function coreColor(v: number): string {
if (v >= 80) return 'bg-rose-500'
if (v >= 60) return 'bg-amber-500'
if (v >= 30) return 'bg-indigo-500'
return 'bg-emerald-500'
}
export default function HomePage() {
const [data, setData] = useState<DashboardData | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const refreshingRef = useRef(false)
const refresh = useCallback(async (silent = false) => {
if (refreshingRef.current) return
refreshingRef.current = true
if (!silent) setLoading(true)
try {
const d = await hubDashboard()
if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '采集失败')
setData(d)
setError('')
} catch (e: any) {
setError(e.message || '加载失败')
} finally {
refreshingRef.current = false
if (!silent) setLoading(false)
}
}, [])
useEffect(() => {
refresh(true) // 初始静默加载
const timer = setInterval(() => {
if (document.visibilityState !== 'hidden') refresh(true)
}, 15000)
const onVisible = () => {
if (document.visibilityState === 'visible') refresh(true)
}
document.addEventListener('visibilitychange', onVisible)
return () => {
clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible)
}
}, [refresh])
const cpu = data?.cpu
const mem = data?.memory
const proc = data?.process
const sys = data?.system
const hostSubtitle = [
cpu?.brand,
cpu?.logical && cpu.logical + ' 核',
(data ? sys?.python : ''),
data ? 'NoneBot ' + sys?.nonebot : '',
].filter(Boolean).join(' · ')
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与服务状态</p>
</div>
<Button size="sm" isDisabled={loading} onPress={() => refresh(false)}>{loading ? '刷新中…' : '刷新'}</Button>
</div>
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{/* 主机资源 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<div className="flex w-full items-center justify-between">
<div>
<Card.Title>主机资源</Card.Title>
<p className="text-xs text-slate-400">{hostSubtitle || '加载中…'}</p>
</div>
</div>
</Card.Header>
<Card.Content>
<div className="grid gap-4 md:grid-cols-3">
{/* CPU */}
<div className="rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">CPU 使用率{cpu?.logical ? ' ' + cpu.logical + ' 核' : ''}</span>
<span className="text-sm font-semibold text-slate-800">{cpu ? cpu.percent + '%' : '—'}</span>
</div>
<div className="mt-3"><Bar value={cpu?.percent || 0} /></div>
<p className="mt-2 text-xs text-slate-400">
负载: {cpu?.load_avg ? cpu.load_avg.join(' / ') : '—'}
</p>
{cpu?.per_core?.length ? (
<div className="mt-3 grid grid-cols-4 gap-2">
{cpu.per_core.map((v, i) => (
<div key={i} className="flex flex-col items-center rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5">
<span className="text-xs text-slate-400">{i}</span>
<div className={'mt-1 h-1.5 w-full rounded-full ' + coreColor(v)} />
<span className="mt-1 text-[10px] text-slate-500">{v.toFixed(0)}%</span>
</div>
))}
</div>
) : null}
</div>
{/* 内存 */}
<div className="rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">内存使用</span>
<span className="text-sm font-semibold text-slate-800">{mem ? mem.percent + '%' : '—'}</span>
</div>
<div className="mt-3"><Bar value={mem?.percent || 0} /></div>
<p className="mt-2 text-xs text-slate-400">
本进程 {mem ? fmtBytes(mem.process_rss) : '—'} · 其他已用 {mem ? fmtBytes(Math.max(0, mem.used - mem.process_rss)) : '—'} · 空间 {mem ? fmtBytes(Math.max(0, mem.total - mem.used)) : '—'}
</p>
<p className="mt-1 text-xs text-slate-400">共 {mem ? fmtBytes(mem.total) : '—'}</p>
</div>
{/* 进程 */}
<div className="rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">运行进程</span>
<span className="text-xs text-slate-500">PID {proc?.pid ?? '—'}</span>
</div>
<dl className="mt-3 space-y-2 text-xs">
<div className="flex justify-between"><dt className="text-slate-400">RSS</dt><dd className="text-slate-700">{proc ? fmtBytes(proc.rss) : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">进程</dt><dd className="text-slate-700">{proc?.name || '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">CPU</dt><dd className="text-slate-700">{proc?.cpu_percent != null ? proc.cpu_percent + '%' : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">内存占用</dt><dd className="text-slate-700">{proc?.memory_percent != null ? proc.memory_percent + '%' : '—'}</dd></div>
<div className="flex justify-between"><dt className="text-slate-400">启动时长</dt><dd className="text-slate-700">{sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</dd></div>
</dl>
</div>
</div>
</Card.Content>
</Card>
{/* 在线会话 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<Card.Title>在线会话</Card.Title>
<p className="text-xs text-slate-400">当前已接入并完成登录的 QQ 账号</p>
</Card.Header>
<Card.Content>
{!data ? (
<p className="text-sm text-slate-400">加载中…</p>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
</div>
<div className="flex items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
</span>
</div>
</div>
))}
</div>
)}
</Card.Content>
</Card>
</div>
)
}