Skip to content

Commit a0bf702

Browse files
author
谭策
committed
chore(deploy): add server metrics collection feature
- 新增服务器性能指标采集功能,支持采集CPU/内存/磁盘/网络等实时指标 - 后端新增 collectServerMetrics 和 collectAllServerMetrics 方法 - 新增定时任务,每5分钟自动采集所有已启用服务器的性能指标 - 前端服务器管理页面新增单个和批量采集性能指标按钮 - 修复 CircularProgress 组件 Canvas 渲染时非有限值导致的错误 - 修复 Dashboard 页面 servers?.slice 非数组类型报错
1 parent 3e2669e commit a0bf702

7 files changed

Lines changed: 377 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
企业级 IT 运维多 Agent 自动化平台 — 基于大语言模型的智能运维解决方案。
1717

18-
[项目愿景与社区共建](docs/项目愿景与社区共建.md) [项目入门学习文档](docs/book/README.md)
18+
[项目愿景与社区共建](docs/项目愿景与社区共建.md) [项目入门学习文档](docs/book/README.md)
1919

2020
[![CI](https://github.com/qinshihu/itops-agent-platform/actions/workflows/ci.yml/badge.svg)](https://github.com/qinshihu/itops-agent-platform/actions/workflows/ci.yml)
2121
[![Release](https://github.com/qinshihu/itops-agent-platform/actions/workflows/release.yml/badge.svg)](https://github.com/qinshihu/itops-agent-platform/actions/workflows/release.yml)

backend/src/routes/serverManagementRoutes.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ router.post('/collect-all', async (_req, res) => {
2626
}
2727
});
2828

29+
router.post('/:id/collect-metrics', async (req, res) => {
30+
try {
31+
const result = await serverInfoCollector.collectServerMetrics(req.params.id);
32+
if (result.success) {
33+
res.json({ success: true, data: result.data });
34+
} else {
35+
res.status(400).json({ success: false, error: result.error });
36+
}
37+
} catch (err) {
38+
res.status(500).json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' });
39+
}
40+
});
41+
42+
router.post('/collect-all-metrics', async (_req, res) => {
43+
try {
44+
const result = await serverInfoCollector.collectAllServerMetrics();
45+
res.json({ success: true, data: result });
46+
} catch (err) {
47+
res.status(500).json({ success: false, error: err instanceof Error ? err.message : 'Unknown error' });
48+
}
49+
});
50+
2951
router.post('/import', async (req, res) => {
3052
try {
3153
const { servers, test_connection } = req.body as {

backend/src/services/schedulerService.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import db, { performMaintenance } from '../models/database';
44
import { logger } from '../utils/logger';
55
import { executeWorkflow } from './workflowExecutor';
66
import { WorkflowParsed, WorkflowNode, WorkflowEdge } from '../types';
7+
import { serverInfoCollector } from './serverInfoCollector';
78

89
interface ScheduledTaskRecord {
910
id: string;
@@ -37,6 +38,9 @@ class SchedulerService {
3738
// 启动数据库定期维护任务
3839
this.initDatabaseMaintenance();
3940

41+
// 启动服务器性能指标定期采集任务(每5分钟)
42+
this.initMetricsCollection();
43+
4044
this.initialized = true;
4145
logger.info(`✅ Scheduler initialized with ${tasks.length} tasks`);
4246
} catch (e) {
@@ -76,6 +80,28 @@ class SchedulerService {
7680
logger.info('✅ Database maintenance scheduled: daily at 3:00 AM');
7781
}
7882

83+
/**
84+
* 初始化服务器性能指标定期采集任务(每 5 分钟)
85+
*/
86+
private initMetricsCollection() {
87+
const metricsJob = scheduleJob('*/5 * * * *', async () => {
88+
logger.info('📊 Starting scheduled server metrics collection...');
89+
90+
try {
91+
const result = await serverInfoCollector.collectAllServerMetrics();
92+
logger.info(`✅ Scheduled metrics collection completed: ${result.success} success, ${result.failed} failed`);
93+
if (result.failed > 0) {
94+
logger.warn(`️ Failed metrics collection: ${JSON.stringify(result.errors)}`);
95+
}
96+
} catch (error) {
97+
logger.error('❌ Scheduled metrics collection failed', error as Error);
98+
}
99+
});
100+
101+
this.jobs.set('metrics-collection', metricsJob);
102+
logger.info('✅ Server metrics collection scheduled: every 5 minutes');
103+
}
104+
79105
scheduleTask(task: ScheduledTaskRecord) {
80106
// 先取消已存在的任务
81107
this.cancelTask(task.id);

backend/src/services/serverInfoCollector.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Client } from 'ssh2';
22
import db from '../models/database';
33
import { sshPool } from './sshService';
44
import { logger } from '../utils/logger';
5+
import { v4 as uuidv4 } from 'uuid';
56

67
interface ServerInfo {
78
id: string;
@@ -28,6 +29,26 @@ interface ServerInfoResult {
2829
};
2930
}
3031

32+
interface ServerMetricsResult {
33+
success: boolean;
34+
error?: string;
35+
data?: {
36+
cpu_usage: number;
37+
memory_usage: number;
38+
memory_total_gb: number;
39+
memory_used_gb: number;
40+
disk_usage: number;
41+
disk_total_gb: number;
42+
disk_used_gb: number;
43+
network_in_mbps: number;
44+
network_out_mbps: number;
45+
load_1min: number;
46+
load_5min: number;
47+
load_15min: number;
48+
uptime_seconds: number;
49+
};
50+
}
51+
3152
class ServerInfoCollector {
3253
private static readonly CONNECT_TIMEOUT = 10000;
3354

@@ -146,6 +167,224 @@ class ServerInfoCollector {
146167

147168
return { success, failed: errors.length, errors };
148169
}
170+
171+
async collectServerMetrics(serverId: string): Promise<ServerMetricsResult> {
172+
const server = db.prepare('SELECT * FROM servers WHERE id = ?').get(serverId) as ServerInfo | undefined;
173+
174+
if (!server || !server.enabled) {
175+
return { success: false, error: 'Server not found or disabled' };
176+
}
177+
178+
let conn: Client | null = null;
179+
180+
return new Promise((resolve) => {
181+
sshPool.acquire(serverId).then((connection) => {
182+
conn = connection;
183+
}).catch((error) => {
184+
resolve({ success: false, error: error instanceof Error ? error.message : 'Failed to acquire SSH connection' });
185+
return;
186+
}).then(() => {
187+
if (!conn) return;
188+
189+
let isResolved = false;
190+
191+
const safeResolve = (result: ServerMetricsResult) => {
192+
if (!isResolved) {
193+
isResolved = true;
194+
if (conn) {
195+
sshPool.release(conn, result.success);
196+
}
197+
resolve(result);
198+
}
199+
};
200+
201+
const commands = {
202+
cpu_usage: `top -bn1 | grep "Cpu(s)" | awk '{print 100 - $8}' || cat /proc/stat | awk '/^cpu / {print ($2+$4)*100/($2+$4+$5)}'`,
203+
memory: `free -m | awk '/^Mem:/{printf "%.1f %.1f %.1f", $2/1024, $3/1024, $3*100/$2}'`,
204+
disk: `df -m --output=source,size,used,pcent / 2>/dev/null | tail -1 | awk '{print $2/1024, $3/1024, $4}' || df -BM / | tail -1 | awk '{print $2, $3, $5}'`,
205+
network: `cat /proc/net/dev 2>/dev/null | grep -v lo: | awk 'NR>2 {rx+=$2; tx+=$10} END {printf "%.2f %.2f", rx/1024/1024, tx/1024/1024}' || echo "0 0"`,
206+
load: `cat /proc/loadavg 2>/dev/null | awk '{print $1, $2, $3}' || uptime | awk -F'load average:' '{print $2}'`,
207+
uptime: `cat /proc/uptime 2>/dev/null | awk '{print int($1)}' || echo "0"`
208+
};
209+
210+
const results: Record<string, string> = {};
211+
let completed = 0;
212+
const total = Object.keys(commands).length;
213+
214+
const checkComplete = () => {
215+
completed++;
216+
if (completed === total) {
217+
try {
218+
const data = this.parseMetricsResults(results);
219+
220+
db.prepare(`
221+
INSERT INTO server_metrics (
222+
id, server_id, cpu_usage, memory_usage, memory_total_gb, memory_used_gb,
223+
disk_usage, disk_total_gb, disk_used_gb, network_in_mbps, network_out_mbps,
224+
load_1min, load_5min, load_15min, uptime_seconds, collected_at
225+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
226+
`).run(
227+
uuidv4(),
228+
serverId,
229+
data.cpu_usage,
230+
data.memory_usage,
231+
data.memory_total_gb,
232+
data.memory_used_gb,
233+
data.disk_usage,
234+
data.disk_total_gb,
235+
data.disk_used_gb,
236+
data.network_in_mbps,
237+
data.network_out_mbps,
238+
data.load_1min,
239+
data.load_5min,
240+
data.load_15min,
241+
data.uptime_seconds
242+
);
243+
244+
logger.info(`Server metrics collected for ${server.name} (${serverId}): CPU=${data.cpu_usage?.toFixed(1)}%`);
245+
safeResolve({ success: true, data });
246+
} catch (error) {
247+
logger.error(`Failed to save metrics for ${serverId}:`, error);
248+
safeResolve({ success: false, error: error instanceof Error ? error.message : 'Failed to save metrics' });
249+
}
250+
}
251+
};
252+
253+
for (const [key, cmd] of Object.entries(commands)) {
254+
conn!.exec(cmd, (err, stream) => {
255+
if (err) {
256+
results[key] = '';
257+
checkComplete();
258+
return;
259+
}
260+
261+
let output = '';
262+
stream.on('data', (data: Buffer) => {
263+
output += data.toString('utf-8');
264+
});
265+
266+
stream.on('close', () => {
267+
results[key] = output.trim();
268+
checkComplete();
269+
});
270+
271+
stream.stderr.on('data', () => { /* ignore stderr */ });
272+
});
273+
}
274+
});
275+
});
276+
}
277+
278+
private parseMetricsResults(results: Record<string, string>): ServerMetricsResult['data'] {
279+
let cpu_usage = 0;
280+
try {
281+
const cpuStr = results.cpu_usage.trim();
282+
if (cpuStr) {
283+
cpu_usage = parseFloat(cpuStr);
284+
if (isNaN(cpu_usage) || cpu_usage < 0 || cpu_usage > 100) {
285+
cpu_usage = 0;
286+
}
287+
}
288+
} catch {
289+
cpu_usage = 0;
290+
}
291+
292+
let memory_usage = 0, memory_total_gb = 0, memory_used_gb = 0;
293+
try {
294+
const memParts = results.memory.trim().split(/\s+/);
295+
if (memParts.length >= 3) {
296+
memory_total_gb = parseFloat(memParts[0]);
297+
memory_used_gb = parseFloat(memParts[1]);
298+
memory_usage = parseFloat(memParts[2]);
299+
}
300+
} catch {
301+
// defaults
302+
}
303+
304+
let disk_usage = 0, disk_total_gb = 0, disk_used_gb = 0;
305+
try {
306+
const diskParts = results.disk.trim().split(/\s+/);
307+
if (diskParts.length >= 3) {
308+
const sizeStr = diskParts[0].replace(/[A-Za-z]/g, '');
309+
const usedStr = diskParts[1].replace(/[A-Za-z]/g, '');
310+
const pcentStr = diskParts[2].replace('%', '');
311+
disk_total_gb = parseFloat(sizeStr);
312+
disk_used_gb = parseFloat(usedStr);
313+
disk_usage = parseFloat(pcentStr);
314+
}
315+
} catch {
316+
// defaults
317+
}
318+
319+
let network_in_mbps = 0, network_out_mbps = 0;
320+
try {
321+
const netParts = results.network.trim().split(/\s+/);
322+
if (netParts.length >= 2) {
323+
network_in_mbps = parseFloat(netParts[0]);
324+
network_out_mbps = parseFloat(netParts[1]);
325+
}
326+
} catch {
327+
// defaults
328+
}
329+
330+
let load_1min = 0, load_5min = 0, load_15min = 0;
331+
try {
332+
const loadParts = results.load.trim().split(/[\s,]+/).filter(s => s);
333+
if (loadParts.length >= 3) {
334+
load_1min = parseFloat(loadParts[0]);
335+
load_5min = parseFloat(loadParts[1]);
336+
load_15min = parseFloat(loadParts[2]);
337+
}
338+
} catch {
339+
// defaults
340+
}
341+
342+
let uptime_seconds = 0;
343+
try {
344+
uptime_seconds = parseInt(results.uptime.trim(), 10) || 0;
345+
} catch {
346+
// defaults
347+
}
348+
349+
return {
350+
cpu_usage: isNaN(cpu_usage) ? 0 : cpu_usage,
351+
memory_usage: isNaN(memory_usage) ? 0 : memory_usage,
352+
memory_total_gb: isNaN(memory_total_gb) ? 0 : memory_total_gb,
353+
memory_used_gb: isNaN(memory_used_gb) ? 0 : memory_used_gb,
354+
disk_usage: isNaN(disk_usage) ? 0 : disk_usage,
355+
disk_total_gb: isNaN(disk_total_gb) ? 0 : disk_total_gb,
356+
disk_used_gb: isNaN(disk_used_gb) ? 0 : disk_used_gb,
357+
network_in_mbps: isNaN(network_in_mbps) ? 0 : network_in_mbps,
358+
network_out_mbps: isNaN(network_out_mbps) ? 0 : network_out_mbps,
359+
load_1min: isNaN(load_1min) ? 0 : load_1min,
360+
load_5min: isNaN(load_5min) ? 0 : load_5min,
361+
load_15min: isNaN(load_15min) ? 0 : load_15min,
362+
uptime_seconds: uptime_seconds
363+
};
364+
}
365+
366+
async collectAllServerMetrics(): Promise<{
367+
success: number;
368+
failed: number;
369+
errors: Array<{ serverId: string; serverName: string; error: string }>;
370+
}> {
371+
const servers = db.prepare('SELECT id, name FROM servers WHERE enabled = 1').all() as { id: string; name: string }[];
372+
373+
const errors: Array<{ serverId: string; serverName: string; error: string }> = [];
374+
let success = 0;
375+
376+
for (const server of servers) {
377+
const result = await this.collectServerMetrics(server.id);
378+
if (result.success) {
379+
success++;
380+
} else {
381+
errors.push({ serverId: server.id, serverName: server.name, error: result.error || 'Unknown error' });
382+
}
383+
}
384+
385+
logger.info(`Metrics collection completed: ${success} success, ${errors.length} failed`);
386+
return { success, failed: errors.length, errors };
387+
}
149388
}
150389

151390
export const serverInfoCollector = new ServerInfoCollector();

0 commit comments

Comments
 (0)