295 lines
8.2 KiB
TypeScript
295 lines
8.2 KiB
TypeScript
/**
|
||
* 资源监控组件
|
||
*
|
||
* 显示系统资源使用情况,包括:
|
||
* - CPU 使用率(带历史图表)
|
||
* - 内存使用率(带历史图表)
|
||
* - 磁盘使用率(带历史图表)
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import React from 'react'
|
||
import { Cpu, HardDrive, MemoryStick, TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||
import Card from '@/components/ui/Card'
|
||
import { Title3, Body, Caption1 } from '@/components/ui/Typography'
|
||
import ProgressBar from '@/components/ui/ProgressBar'
|
||
|
||
export interface ResourceData {
|
||
current: number // 当前使用百分比
|
||
history: number[] // 历史数据点(最近N个)
|
||
total?: number // 总量(MB)
|
||
used?: number // 已用(MB)
|
||
free?: number // 空闲(MB)
|
||
}
|
||
|
||
export interface ResourceMonitorProps {
|
||
cpu?: ResourceData
|
||
memory?: ResourceData
|
||
disk?: ResourceData
|
||
cpuCores?: number
|
||
className?: string
|
||
showHistory?: boolean
|
||
historyPoints?: number
|
||
}
|
||
|
||
export default function ResourceMonitor({
|
||
cpu,
|
||
memory,
|
||
disk,
|
||
cpuCores,
|
||
className = '',
|
||
showHistory = true,
|
||
historyPoints = 20,
|
||
}: ResourceMonitorProps) {
|
||
return (
|
||
<div className={`space-y-4 ${className}`}>
|
||
{/* CPU 监控 */}
|
||
{cpu && (
|
||
<ResourceCard
|
||
icon={<Cpu className="w-5 h-5" />}
|
||
title="CPU 使用率"
|
||
subtitle={cpuCores ? `${cpuCores} 核心` : undefined}
|
||
data={cpu}
|
||
showHistory={showHistory}
|
||
historyPoints={historyPoints}
|
||
unit="%"
|
||
warningThreshold={70}
|
||
criticalThreshold={90}
|
||
/>
|
||
)}
|
||
|
||
{/* 内存监控 */}
|
||
{memory && (
|
||
<ResourceCard
|
||
icon={<MemoryStick className="w-5 h-5" />}
|
||
title="内存使用率"
|
||
subtitle={memory.total ? `${formatMB(memory.used || 0)} / ${formatMB(memory.total)}` : undefined}
|
||
data={memory}
|
||
showHistory={showHistory}
|
||
historyPoints={historyPoints}
|
||
unit="%"
|
||
warningThreshold={75}
|
||
criticalThreshold={90}
|
||
/>
|
||
)}
|
||
|
||
{/* 磁盘监控 */}
|
||
{disk && (
|
||
<ResourceCard
|
||
icon={<HardDrive className="w-5 h-5" />}
|
||
title="磁盘使用率"
|
||
subtitle={disk.total ? `${formatMB(disk.used || 0)} / ${formatMB(disk.total)}` : undefined}
|
||
data={disk}
|
||
showHistory={showHistory}
|
||
historyPoints={historyPoints}
|
||
unit="%"
|
||
warningThreshold={80}
|
||
criticalThreshold={95}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface ResourceCardProps {
|
||
icon: React.ReactNode
|
||
title: string
|
||
subtitle?: string
|
||
data: ResourceData
|
||
showHistory: boolean
|
||
historyPoints: number
|
||
unit: string
|
||
warningThreshold: number
|
||
criticalThreshold: number
|
||
}
|
||
|
||
function ResourceCard({
|
||
icon,
|
||
title,
|
||
subtitle,
|
||
data,
|
||
showHistory,
|
||
historyPoints,
|
||
unit,
|
||
warningThreshold,
|
||
criticalThreshold,
|
||
}: ResourceCardProps) {
|
||
const getStatusType = (value: number) => {
|
||
if (value >= criticalThreshold) return 'error'
|
||
if (value >= warningThreshold) return 'warning'
|
||
return 'success'
|
||
}
|
||
|
||
const getTrend = () => {
|
||
if (data.history.length < 2) return 'stable'
|
||
const recent = data.history.slice(-5)
|
||
const avg = recent.reduce((a, b) => a + b, 0) / recent.length
|
||
const diff = data.current - avg
|
||
if (diff > 5) return 'up'
|
||
if (diff < -5) return 'down'
|
||
return 'stable'
|
||
}
|
||
|
||
const trend = getTrend()
|
||
|
||
return (
|
||
<Card variant="subtle" className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div style={{ color: 'var(--color-label-secondary)' }}>{icon}</div>
|
||
<div>
|
||
<Title3>{title}</Title3>
|
||
{subtitle && (
|
||
<Caption1 style={{ color: 'var(--color-label-tertiary)' }}>
|
||
{subtitle}
|
||
</Caption1>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Body
|
||
className="font-semibold"
|
||
style={{
|
||
color: getStatusType(data.current) === 'error'
|
||
? 'var(--color-red)'
|
||
: getStatusType(data.current) === 'warning'
|
||
? 'var(--color-orange)'
|
||
: 'var(--color-green)'
|
||
}}
|
||
>
|
||
{data.current.toFixed(1)}{unit}
|
||
</Body>
|
||
{trend === 'up' && <TrendingUp className="w-4 h-4" style={{ color: 'var(--color-red)' }} />}
|
||
{trend === 'down' && <TrendingDown className="w-4 h-4" style={{ color: 'var(--color-green)' }} />}
|
||
{trend === 'stable' && <Minus className="w-4 h-4" style={{ color: 'var(--color-label-tertiary)' }} />}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
<ProgressBar
|
||
value={data.current}
|
||
type={getStatusType(data.current) as 'success' | 'warning' | 'error'}
|
||
animated
|
||
className="mb-2"
|
||
/>
|
||
|
||
{/* 历史图表 */}
|
||
{showHistory && data.history.length > 0 && (
|
||
<div className="mt-3">
|
||
<MiniChart
|
||
data={data.history.slice(-historyPoints)}
|
||
warningThreshold={warningThreshold}
|
||
criticalThreshold={criticalThreshold}
|
||
/>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
interface MiniChartProps {
|
||
data: number[]
|
||
warningThreshold: number
|
||
criticalThreshold: number
|
||
}
|
||
|
||
function MiniChart({ data, warningThreshold, criticalThreshold }: MiniChartProps) {
|
||
const height = 40
|
||
const width = 200
|
||
const padding = 2
|
||
|
||
if (data.length === 0) return null
|
||
|
||
const max = 100
|
||
const min = 0
|
||
const range = max - min
|
||
|
||
const points = data.map((value, index) => {
|
||
const x = padding + (index / (data.length - 1 || 1)) * (width - padding * 2)
|
||
const y = height - padding - ((value - min) / range) * (height - padding * 2)
|
||
return `${x},${y}`
|
||
}).join(' ')
|
||
|
||
const getColor = (value: number) => {
|
||
if (value >= criticalThreshold) return 'var(--color-red)'
|
||
if (value >= warningThreshold) return 'var(--color-orange)'
|
||
return 'var(--color-green)'
|
||
}
|
||
|
||
// 创建渐变区域
|
||
const areaPoints = `${padding},${height - padding} ${points} ${width - padding},${height - padding}`
|
||
|
||
return (
|
||
<div className="relative">
|
||
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||
{/* 警告线 */}
|
||
<line
|
||
x1={padding}
|
||
y1={height - padding - (warningThreshold / 100) * (height - padding * 2)}
|
||
x2={width - padding}
|
||
y2={height - padding - (warningThreshold / 100) * (height - padding * 2)}
|
||
stroke="var(--color-orange)"
|
||
strokeWidth="1"
|
||
strokeDasharray="4,4"
|
||
opacity="0.3"
|
||
/>
|
||
|
||
{/* 危险线 */}
|
||
<line
|
||
x1={padding}
|
||
y1={height - padding - (criticalThreshold / 100) * (height - padding * 2)}
|
||
x2={width - padding}
|
||
y2={height - padding - (criticalThreshold / 100) * (height - padding * 2)}
|
||
stroke="var(--color-red)"
|
||
strokeWidth="1"
|
||
strokeDasharray="4,4"
|
||
opacity="0.3"
|
||
/>
|
||
|
||
{/* 填充区域 */}
|
||
<polygon
|
||
points={areaPoints}
|
||
fill={getColor(data[data.length - 1])}
|
||
opacity="0.1"
|
||
/>
|
||
|
||
{/* 折线 */}
|
||
<polyline
|
||
points={points}
|
||
fill="none"
|
||
stroke={getColor(data[data.length - 1])}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
|
||
{/* 当前值点 */}
|
||
{data.length > 0 && (
|
||
<circle
|
||
cx={width - padding}
|
||
cy={height - padding - ((data[data.length - 1] - min) / range) * (height - padding * 2)}
|
||
r="3"
|
||
fill={getColor(data[data.length - 1])}
|
||
/>
|
||
)}
|
||
</svg>
|
||
<div className="flex justify-between mt-1">
|
||
<Caption1 style={{ color: 'var(--color-label-quaternary)' }}>
|
||
{data.length > 0 ? `最低 ${Math.min(...data).toFixed(0)}%` : ''}
|
||
</Caption1>
|
||
<Caption1 style={{ color: 'var(--color-label-quaternary)' }}>
|
||
{data.length > 0 ? `最高 ${Math.max(...data).toFixed(0)}%` : ''}
|
||
</Caption1>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatMB(mb: number): string {
|
||
if (mb >= 1024) {
|
||
return `${(mb / 1024).toFixed(1)} GB`
|
||
}
|
||
return `${mb.toFixed(0)} MB`
|
||
}
|