ECharts 实时监控大屏实战
📋 项目概述
构建一个服务器监控系统,实时展示CPU、内存、网络等关键指标,支持告警通知和历史数据回溯。
核心功能
- ✅ 实时监控:秒级数据更新
- ✅ 多维指标:CPU、内存、磁盘、网络
- ✅ 告警系统:阈值触发通知
- ✅ 历史查询:时间范围筛选
- ✅ 多集群管理:同时监控多个服务器
🎯 技术选型
typescript
const stack = {
frontend: 'React 18 + TypeScript',
charts: 'ECharts 5.4 + echarts-gl',
realtime: 'WebSocket + Server-Sent Events',
state: 'Recoil',
backend: 'Node.js + Prometheus',
database: 'InfluxDB (时序数据库)'
};1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
💻 核心实现
1. 监控指标卡片
typescript
// components/Monitor/MetricCard.tsx
import React from 'react';
import { useRecoilValue } from 'recoil';
import { serverMetricsState } from '@/atoms/metrics';
import Sparkline from './Sparkline';
import styles from './MetricCard.module.css';
interface MetricCardProps {
serverId: string;
metricType: 'cpu' | 'memory' | 'disk' | 'network';
}
const MetricCard: React.FC<MetricCardProps> = ({ serverId, metricType }) => {
const metrics = useRecoilValue(serverMetricsState(serverId));
const metricData = metrics[metricType];
// 计算状态
const getStatus = (value: number) => {
if (value > 90) return 'critical';
if (value > 70) return 'warning';
return 'normal';
};
const status = getStatus(metricData.current);
return (
<div className={`${styles.metricCard} ${styles[status]}`}>
<div className={styles.header}>
<span className={styles.label}>
{metricType.toUpperCase()}
</span>
<span className={styles.status}>
{status === 'critical' ? '🔴' : status === 'warning' ? '🟡' : '🟢'}
</span>
</div>
<div className={styles.value}>
{metricData.current.toFixed(1)}%
</div>
<div className={styles.details}>
<div>最高: {metricData.max.toFixed(1)}%</div>
<div>最低: {metricData.min.toFixed(1)}%</div>
<div>平均: {metricData.avg.toFixed(1)}%</div>
</div>
{/* 迷你趋势图 */}
<Sparkline
data={metricData.history}
width={200}
height={60}
color={status === 'critical' ? '#ff4d4f' : '#52c41a'}
/>
</div>
);
};
export default MetricCard;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2. 实时数据流
typescript
// hooks/useRealtimeMetrics.ts
import { useEffect, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { serverMetricsState } from '@/atoms/metrics';
interface MetricData {
timestamp: number;
cpu: number;
memory: number;
disk: number;
networkIn: number;
networkOut: number;
}
export function useRealtimeMetrics(serverId: string, enabled: boolean = true) {
const setMetrics = useSetRecoilState(serverMetricsState(serverId));
const wsRef = useRef<WebSocket | null>(null);
const connect = useCallback(() => {
if (!enabled) return;
const ws = new WebSocket(`ws://localhost:8080/metrics/${serverId}`);
ws.onopen = () => {
console.log('Connected to metrics stream');
};
ws.onmessage = (event) => {
const data: MetricData = JSON.parse(event.data);
// 更新指标
setMetrics(prev => ({
...prev,
cpu: {
current: data.cpu,
history: [...prev.cpu.history.slice(-59), data.cpu],
max: Math.max(prev.cpu.max, data.cpu),
min: Math.min(prev.cpu.min, data.cpu),
avg: calculateAverage([...prev.cpu.history.slice(-59), data.cpu])
},
memory: {
current: data.memory,
history: [...prev.memory.history.slice(-59), data.memory],
max: Math.max(prev.memory.max, data.memory),
min: Math.min(prev.memory.min, data.memory),
avg: calculateAverage([...prev.memory.history.slice(-59), data.memory])
},
// ... 其他指标
}));
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected, reconnecting...');
setTimeout(connect, 3000);
};
wsRef.current = ws;
}, [serverId, enabled, setMetrics]);
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
return {
disconnect: () => wsRef.current?.close()
};
}
function calculateAverage(data: number[]): number {
return data.reduce((sum, val) => sum + val, 0) / data.length;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
3. CPU使用率趋势图
typescript
// components/Monitor/CPUTrendChart.tsx
import React, { useMemo } from 'react';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useRecoilValue } from 'recoil';
import { serverMetricsState } from '@/atoms/metrics';
import BaseChart from '@/components/Chart/BaseChart';
import styles from './CPUTrendChart.module.css';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent,
CanvasRenderer
]);
interface CPUTrendChartProps {
serverId: string;
timeRange: '1h' | '6h' | '24h' | '7d';
}
const CPUTrendChart: React.FC<CPUTrendChartProps> = ({ serverId, timeRange }) => {
const metrics = useRecoilValue(serverMetricsState(serverId));
// 生成图表配置
const option = useMemo(() => {
const historyData = metrics.cpu.history;
const timestamps = historyData.map((_, i) => {
const date = new Date(Date.now() - (historyData.length - i) * 60000);
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
});
return {
title: {
text: 'CPU使用率趋势',
subtext: `时间范围: ${timeRange}`,
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
},
formatter: (params: any) => {
const data = params[0];
return `
<div style="padding: 8px;">
<div style="margin-bottom: 4px; font-weight: bold;">${data.axisValue}</div>
<div>CPU: ${data.value}%</div>
</div>
`;
}
},
legend: {
data: ['CPU使用率'],
top: 30
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: timestamps,
axisLine: {
lineStyle: {
color: '#ddd'
}
}
},
yAxis: {
type: 'value',
max: 100,
axisLabel: {
formatter: '{value}%'
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
},
{
start: 0,
end: 100,
bottom: 10
}
],
visualMap: {
show: false,
dimension: 1,
pieces: [
{ lte: 70, color: '#52c41a' },
{ gt: 70, lte: 90, color: '#faad14' },
{ gt: 90, color: '#ff4d4f' }
]
},
series: [
{
name: 'CPU使用率',
type: 'line',
smooth: true,
symbol: 'none',
sampling: 'average',
data: historyData,
// 标记线
markLine: {
silent: true,
data: [
{
yAxis: 70,
label: {
formatter: '警告阈值 (70%)',
position: 'end'
},
lineStyle: {
color: '#faad14',
type: 'dashed'
}
},
{
yAxis: 90,
label: {
formatter: '严重阈值 (90%)',
position: 'end'
},
lineStyle: {
color: '#ff4d4f',
type: 'dashed'
}
}
]
},
// 面积图
areaStyle: {
opacity: 0.3
}
}
]
};
}, [metrics.cpu.history, timeRange]);
return (
<div className={styles.chartContainer}>
<BaseChart
option={option}
height={400}
type="line"
/>
</div>
);
};
export default CPUTrendChart;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
4. 告警系统
typescript
// hooks/useAlertSystem.ts
import { useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { serverMetricsState } from '@/atoms/metrics';
import { alertRulesState } from '@/atoms/alerts';
interface AlertRule {
id: string;
metric: 'cpu' | 'memory' | 'disk' | 'network';
operator: '>' | '<' | '>=' | '<=';
threshold: number;
duration: number; // 持续时间(毫秒)
level: 'warning' | 'critical';
notification: 'email' | 'sms' | 'webhook';
}
export function useAlertSystem(serverId: string) {
const metrics = useRecoilValue(serverMetricsState(serverId));
const alertRules = useRecoilValue(alertRulesState);
const triggeredAlerts = useRef<Map<string, number>>(new Map());
// 检查告警规则
const checkAlerts = useCallback(() => {
alertRules.forEach(rule => {
const currentValue = metrics[rule.metric].current;
let triggered = false;
switch (rule.operator) {
case '>':
triggered = currentValue > rule.threshold;
break;
case '<':
triggered = currentValue < rule.threshold;
break;
case '>=':
triggered = currentValue >= rule.threshold;
break;
case '<=':
triggered = currentValue <= rule.threshold;
break;
}
if (triggered) {
// 记录触发时间
if (!triggeredAlerts.current.has(rule.id)) {
triggeredAlerts.current.set(rule.id, Date.now());
}
// 检查是否持续足够时间
const triggerTime = triggeredAlerts.current.get(rule.id)!;
if (Date.now() - triggerTime >= rule.duration) {
sendNotification(rule, currentValue);
}
} else {
// 清除触发状态
triggeredAlerts.current.delete(rule.id);
}
});
}, [metrics, alertRules]);
// 发送通知
const sendNotification = useCallback((rule: AlertRule, value: number) => {
const notification = {
ruleId: rule.id,
metric: rule.metric,
value,
threshold: rule.threshold,
level: rule.level,
timestamp: Date.now(),
serverId
};
// 根据配置发送不同类型的通知
switch (rule.notification) {
case 'email':
sendEmail(notification);
break;
case 'sms':
sendSMS(notification);
break;
case 'webhook':
sendWebhook(notification);
break;
}
}, [serverId]);
// 定期检查
useEffect(() => {
const interval = setInterval(checkAlerts, 5000);
return () => clearInterval(interval);
}, [checkAlerts]);
return {
triggeredAlerts: Array.from(triggeredAlerts.current.entries())
};
}
async function sendEmail(data: any) {
await fetch('/api/notifications/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
async function sendSMS(data: any) {
await fetch('/api/notifications/sms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
async function sendWebhook(data: any) {
await fetch('/api/notifications/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
5. 多服务器管理
typescript
// components/Monitor/ServerList.tsx
import React from 'react';
import { useRecoilState } from 'recoil';
import { serversState } from '@/atoms/servers';
import ServerCard from './ServerCard';
import styles from './ServerList.module.css';
const ServerList: React.FC = () => {
const [servers, setServers] = useRecoilState(serversState);
const addServer = () => {
const newServer = {
id: `server_${Date.now()}`,
name: '新服务器',
host: '',
port: 9090,
status: 'offline'
};
setServers([...servers, newServer]);
};
const removeServer = (serverId: string) => {
setServers(servers.filter(s => s.id !== serverId));
};
return (
<div className={styles.serverList}>
<div className={styles.header}>
<h2>服务器列表</h2>
<button onClick={addServer}>添加服务器</button>
</div>
<div className={styles.grid}>
{servers.map(server => (
<ServerCard
key={server.id}
server={server}
onRemove={() => removeServer(server.id)}
/>
))}
</div>
</div>
);
};
export default ServerList;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
📊 性能优化
1. 数据降采样
typescript
/**
* LTTB降采样算法
*/
export function lttbDownsample(
data: Array<{ time: number; value: number }>,
threshold: number
): Array<{ time: number; value: number }> {
if (threshold >= data.length || threshold === 0) {
return data;
}
const sampled: Array<{ time: number; value: number }> = [];
let sampledIndex = 0;
sampled[sampledIndex++] = data[0];
const bucketSize = (data.length - 2) / (threshold - 2);
let prevAvgIndex = 0;
let prevAvgX = data[0].time;
let prevAvgY = data[0].value;
for (let i = 0; i < threshold - 2; i++) {
const bucketStart = Math.floor((i + 0) * bucketSize) + 1;
const bucketEnd = Math.floor((i + 1) * bucketSize) + 1;
const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1;
const avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1;
const avgRangeValidCount = Math.min(avgRangeEnd, data.length) - avgRangeStart;
let avgX = 0;
let avgY = 0;
for (let j = avgRangeStart; j < avgRangeStart + avgRangeValidCount; j++) {
avgX += data[j].time;
avgY += data[j].value;
}
avgX /= avgRangeValidCount;
avgY /= avgRangeValidCount;
let maxArea = -1;
let maxAreaIndex = bucketStart;
for (let k = bucketStart; k < bucketEnd; k++) {
const area = Math.abs(
(prevAvgX - data[k].time) * (avgY - data[k].value) -
(prevAvgX - data[k].time) * (avgY - prevAvgY)
) * 0.5;
if (area > maxArea) {
maxArea = area;
maxAreaIndex = k;
}
}
sampled[sampledIndex++] = data[maxAreaIndex];
prevAvgX = data[maxAreaIndex].time;
prevAvgY = data[maxAreaIndex].value;
}
sampled[sampledIndex] = data[data.length - 1];
return sampled;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
💎 总结
本实战案例展示了如何构建实时监控系统,关键点:
- WebSocket实时推送:秒级数据更新
- 告警引擎:阈值检测与通知
- 性能优化:降采样、虚拟滚动
- 可视化设计:颜色编码、趋势图
适用于运维监控、IoT设备等场景。
