Files
CLICD/frontend/src/pages/Dashboard.tsx
T
2026-06-07 13:15:59 +08:00

220 lines
7.0 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
import RingStats from '../components/RingStats'
import ResourceStatsPanel, {
ChartPoint,
ResourceChartConfig,
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
type HostMetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
}
const hostHistoryKey = 'clicd_host_metric_history_v2'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [history, setHistory] = useState<HostMetricPoint[]>(readHostHistory)
const [range, setRange] = useState<StatsRangeKey>('30m')
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
if (dashRes.data.data) setStats(dashRes.data.data)
if (hostRes.data.data) {
const nextHost = hostRes.data.data
setHost(nextHost)
appendHostPoint(nextHost, setHistory)
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
const filtered = filterHistory(history, range)
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0)
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
const charts: ResourceChartConfig[] = [
{
title: 'CPU 使用率',
icon: <Cpu className="w-5 h-5" />,
current: host?.cpu.usage_pct || 0,
points: toChartPoints(filtered, 'cpu'),
max: 100,
formatValue: formatPercent,
detail: `${host?.cpu.cores || 0} 核`,
},
{
title: '内存使用',
icon: <MemoryStick className="w-5 h-5" />,
current: memoryPct,
points: toChartPoints(filtered, 'memory'),
max: 100,
formatValue: formatPercent,
detail: `${formatMB(host?.ram.used_mb || 0)} / ${formatMB(host?.ram.total_mb || 0)}`,
},
{
title: '网络流量',
icon: <Network className="w-5 h-5" />,
current: networkBps,
points: toChartPoints(filtered, 'network'),
formatValue: formatRate,
detail: `入 ${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
},
{
title: '磁盘IO',
icon: <HardDrive className="w-5 h-5" />,
current: diskIOBps,
points: toChartPoints(filtered, 'diskIO'),
formatValue: formatRate,
detail: `读 ${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black">控制面板</h1>
<p className="text-sm text-gray-500 mt-1">宿主机资源状态与容器概览</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryCard icon={<Server className="w-5 h-5" />} title="容器总数" value={stats?.total_containers || 0} />
<SummaryCard dot="bg-green-500" title="运行中" value={stats?.running || 0} />
<SummaryCard dot="bg-red-500" title="已停止" value={stats?.stopped || 0} muted />
</div>
{host && (
<RingStats
cpuPercent={host.cpu.usage_pct}
cpuCores={host.cpu.cores}
cpuUsed={host.cpu.usage_pct * host.cpu.cores / 100}
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb}
loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024}
/>
)}
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={fetchData} charts={charts} />
</div>
)
}
function SummaryCard({
icon,
dot,
title,
value,
muted = false,
}: {
icon?: JSX.Element
dot?: string
title: string
value: number
muted?: boolean
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{icon}
{dot && <span className={`w-2 h-2 rounded-full ${dot}`}></span>}
{title}
</div>
<div className={`text-3xl font-bold ${muted ? 'text-gray-600' : 'text-black'}`}>{value}</div>
</div>
)
}
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
const point: HostMetricPoint = {
ts: Date.now(),
cpu: clamp(host.cpu.usage_pct),
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
}
setHistory((prev) => {
const cutoff = Date.now() - statsRanges['1w']
const next = [...prev.filter((item) => item.ts >= cutoff), point]
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
return next
})
}
function readHostHistory(): HostMetricPoint[] {
try {
const raw = localStorage.getItem(hostHistoryKey)
if (!raw) return []
const parsed = JSON.parse(raw) as HostMetricPoint[]
const cutoff = Date.now() - statsRanges['1w']
return parsed.filter((item) => item.ts >= cutoff)
} catch {
return []
}
}
function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
const cutoff = Date.now() - statsRanges[range]
return history.filter((point) => point.ts >= cutoff)
}
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
}
function formatPercent(value: number) {
return `${value.toFixed(1)}%`
}
function formatMB(mb: number) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
}
function formatBytes(value: number) {
if (value < 1024) return `${value.toFixed(0)} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
return `${(value / 1024 / 1024).toFixed(2)} MB`
}
function formatRate(value: number) {
return `${formatBytes(value)}/s`
}