From f30a446bd5c3721d6384a6d36b2d18272e06195e Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 13:30:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20server=20metrics=20API=20=E2=80=94=20lo?= =?UTF-8?q?cal=20os=20+=20remote=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 2 + .../admin-servers/admin-servers.controller.ts | 22 +++ .../admin-servers/admin-servers.module.ts | 11 ++ .../admin-servers/admin-servers.service.ts | 142 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/modules/admin-servers/admin-servers.controller.ts create mode 100644 src/modules/admin-servers/admin-servers.module.ts create mode 100644 src/modules/admin-servers/admin-servers.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 960b69c..9bb7fa0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { AuthModule } from './modules/auth/auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; import { AdminUsersModule } from './modules/admin-users/admin-users.module'; +import { AdminServersModule } from './modules/admin-servers/admin-servers.module'; import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module'; import { AdminAiChatModule } from './modules/admin-ai-chat/admin-ai-chat.module'; import { AdminAuditLogModule } from './modules/admin-audit-log/admin-audit-log.module'; @@ -89,6 +90,7 @@ import appleConfig from './config/apple.config'; AdminAuthModule, AdminDashboardModule, AdminUsersModule, + AdminServersModule, AdminConversationModule, AdminAiChatModule, AdminAuditLogModule, diff --git a/src/modules/admin-servers/admin-servers.controller.ts b/src/modules/admin-servers/admin-servers.controller.ts new file mode 100644 index 0000000..466c02d --- /dev/null +++ b/src/modules/admin-servers/admin-servers.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { AdminServersService } from './admin-servers.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import type { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-servers') +@Controller('admin-api/servers') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +@ApiBearerAuth() +export class AdminServersController { + constructor(private readonly serversService: AdminServersService) {} + + @Get('metrics') + @AdminRoles('SUPER_ADMIN' as AdminRole) + @ApiOperation({ summary: '服务器实时指标(仅超级管理员)' }) + async getMetrics() { + return this.serversService.getAllMetrics(); + } +} diff --git a/src/modules/admin-servers/admin-servers.module.ts b/src/modules/admin-servers/admin-servers.module.ts new file mode 100644 index 0000000..1cde117 --- /dev/null +++ b/src/modules/admin-servers/admin-servers.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminServersController } from './admin-servers.controller'; +import { AdminServersService } from './admin-servers.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; + +@Module({ + controllers: [AdminServersController], + providers: [AdminServersService, AdminAuthGuard, AdminRolesGuard], +}) +export class AdminServersModule {} diff --git a/src/modules/admin-servers/admin-servers.service.ts b/src/modules/admin-servers/admin-servers.service.ts new file mode 100644 index 0000000..9f5a439 --- /dev/null +++ b/src/modules/admin-servers/admin-servers.service.ts @@ -0,0 +1,142 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as os from 'os'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +interface ServerMetrics { + hostname: string; + cpu: { model: string; cores: number; usagePercent: number; loadAvg: number[] }; + memory: { total: string; used: string; free: string; percent: number }; + disk: { total: string; used: string; free: string; percent: number }; + uptime: string; + processes: { pid: number; cpu: string; mem: string; command: string }[]; + network: { ip: string }; +} + +const REMOTE_SSH = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i /home/ubuntu/.ssh/zhixi.pem ubuntu@10.2.0.7'; +const SSH_KEY_PATH = process.env.SSH_KEY_PATH || '/home/ubuntu/.ssh/zhixi.pem'; + +@Injectable() +export class AdminServersService { + private readonly logger = new Logger(AdminServersService.name); + + async getLocalMetrics(): Promise { + const cpus = os.cpus(); + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + + // CPU usage (approximate via load avg vs cores) + const loadAvg = os.loadavg(); + const cpuUsage = Math.round((loadAvg[0] / cpus.length) * 100); + + // Disk + let disk = { total: '-', used: '-', free: '-', percent: 0 }; + try { + const { stdout } = await execAsync("df -h / | tail -1 | awk '{print $2,$3,$4,$5}'"); + const [total, used, free, pct] = stdout.trim().split(/\s+/); + disk = { total, used, free, percent: parseInt(pct) || 0 }; + } catch {} + + // Top processes + let processes: ServerMetrics['processes'] = []; + try { + const { stdout } = await execAsync("ps aux --sort=-%mem --no-headers | head -8 | awk '{print $2,$3,$4,$11}'"); + processes = stdout.trim().split('\n').map(line => { + const [pid, cpu, mem, ...cmd] = line.trim().split(/\s+/); + return { pid: parseInt(pid), cpu: cpu + '%', mem: mem + '%', command: cmd.join(' ').slice(0, 60) }; + }); + } catch {} + + // Network IPs + const nets = os.networkInterfaces(); + const ip = Object.values(nets).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address || 'unknown'; + + // Uptime + const uptimeSeconds = os.uptime(); + const d = Math.floor(uptimeSeconds / 86400); + const h = Math.floor((uptimeSeconds % 86400) / 3600); + const m = Math.floor((uptimeSeconds % 3600) / 60); + const uptime = `${d}d ${h}h ${m}m`; + + return { + hostname: os.hostname(), + cpu: { model: cpus[0]?.model || '', cores: cpus.length, usagePercent: cpuUsage, loadAvg }, + memory: { total: (totalMem / 1e9).toFixed(1) + 'G', used: (usedMem / 1e9).toFixed(1) + 'G', free: (freeMem / 1e9).toFixed(1) + 'G', percent: Math.round((usedMem / totalMem) * 100) }, + disk, uptime, processes, + network: { ip }, + }; + } + + async getRemoteMetrics(): Promise { + try { + const sshKey = SSH_KEY_PATH; + const cmd = `${REMOTE_SSH} 'echo "HOST=$(hostname)"; echo "IP=$(hostname -I | awk '"'"'{print \$1}'"'"')"; echo "UPTIME=$(uptime -p)"; top -bn1 | head -1; free -h | grep Mem; df -h / | tail -1; ps aux --sort=-%mem --no-headers | head -6 | awk '"'"'{print \$2,\$3,\$4,\$11}'"'"'`; + const { stdout } = await execAsync(cmd.replace('ssh -o', `ssh -i ${sshKey} -o`), { timeout: 8000 }); + const lines = stdout.trim().split('\n'); + + const hostname = lines.find(l => l.startsWith('HOST='))?.split('=')[1] || 'remote'; + const ip = lines.find(l => l.startsWith('IP='))?.split('=')[1] || '10.2.0.7'; + const uptimeStr = lines.find(l => l.startsWith('UPTIME='))?.split('=')[1]?.replace('up ', '') || ''; + + // top output: "load average: 0.08, 0.03, 0.01" + const topLine = lines.find(l => l.includes('load average')) || ''; + const loadMatch = topLine.match(/load average: ([\d.]+), ([\d.]+), ([\d.]+)/); + const loadAvg = loadMatch ? [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])] : [0, 0, 0]; + + // memory + const memLine = lines.find(l => /Mem:/.test(l)) || ''; + const memParts = memLine.replace('Mem:', '').trim().split(/\s+/); + + // disk + const diskLine = lines.find(l => /\/$/.test(l) || l.includes('/ ')) || ''; + const diskParts = diskLine.trim().split(/\s+/); + + // processes + const procLines = lines.filter(l => /^\d+\s/.test(l)); + + const cpuUsage = Math.round((loadAvg[0] / 4) * 100); // assume 4 cores + + return { + hostname, + cpu: { model: 'Intel Xeon (Lighthouse)', cores: 4, usagePercent: cpuUsage, loadAvg }, + memory: { + total: memParts[1] || '-', + used: memParts[2] || '-', + free: memParts[3] || '-', + percent: loadAvg[0] > 0 ? Math.round(cpuUsage) : 0, + }, + disk: { + total: diskParts[1] || '-', + used: diskParts[2] || '-', + free: diskParts[3] || '-', + percent: parseInt(diskParts[4]) || 0, + }, + uptime: uptimeStr, + processes: procLines.map(line => { + const [pid, cpu, mem, ...cmd] = line.trim().split(/\s+/); + return { pid: parseInt(pid), cpu: cpu + '%', mem: mem + '%', command: (cmd || []).join(' ').slice(0, 60) }; + }), + network: { ip }, + }; + } catch (err: any) { + this.logger.warn('Remote metrics failed: ' + err.message); + return null; + } + } + + async getAllMetrics() { + const [local, remote] = await Promise.all([ + this.getLocalMetrics(), + this.getRemoteMetrics(), + ]); + return { + servers: [ + { name: '蜂驰云 8核32G', role: '生产核心', ...local }, + ...(remote ? [{ name: '轻量云 4核4G', role: '工具/辅助', ...remote }] : []), + ], + }; + } +}