mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
220 lines
7.0 KiB
TypeScript
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`
|
|
}
|