diff --git a/src/modules/admin-servers/admin-servers.controller.ts b/src/modules/admin-servers/admin-servers.controller.ts index 5272316..20de1b7 100644 --- a/src/modules/admin-servers/admin-servers.controller.ts +++ b/src/modules/admin-servers/admin-servers.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { AdminServersService } from './admin-servers.service'; +import { AdminServersService, type ServerInfo } 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'; @@ -16,7 +16,7 @@ export class AdminServersController { @Get('metrics') @AdminRoles('SUPER_ADMIN' as AdminRole) @ApiOperation({ summary: '服务器实时指标(仅超级管理员)' }) - async getMetrics(): Promise<{ servers: import("./admin-servers.service").ServerInfo[] }> { + async getMetrics(): Promise<{ servers: ServerInfo[] }> { return this.serversService.getAllMetrics(); } } diff --git a/src/modules/admin-servers/admin-servers.service.ts b/src/modules/admin-servers/admin-servers.service.ts index 5320bc4..4094a78 100644 --- a/src/modules/admin-servers/admin-servers.service.ts +++ b/src/modules/admin-servers/admin-servers.service.ts @@ -5,8 +5,8 @@ import { promisify } from 'util'; const execAsync = promisify(exec); -export interface DiskInfo { mount: string; total: string; used: string; free: string; percent: number } -export interface ProcessInfo { pid: number; cpu: string; mem: string; name: string; command: string } +interface DiskInfo { mount: string; total: string; used: string; free: string; percent: number } +interface ProcessInfo { pid: number; cpu: string; mem: string; name: string; desc: string; command: string } export interface ServerInfo { name: string; role: string; hostname: string; cpu: { model: string; cores: number; usagePercent: number }; @@ -20,30 +20,33 @@ export interface ServerInfo { const SSH_KEY_PATH = process.env.SSH_KEY_PATH || '/home/ubuntu/.ssh/wangdl.pem'; const REMOTE_HOST = '10.2.0.7'; -// Map raw process commands to friendly names -const PROCESS_ALIASES: Record = { - 'mysqld': 'MySQL 8.0', - 'redis-server': 'Redis 7', - 'qdrant': 'Qdrant', - 'nginx': 'Nginx', - 'gitea': 'Gitea', - 'node dist/src/main.js': 'NestJS API', - 'node dist/main.js': 'NestJS API', - 'act_runner': 'Gitea Runner', - 'zhixi-worker': 'RAG Worker', - 'python3': 'Python Worker', - 'docker': 'Docker Daemon', - 'containerd': 'containerd', - 'hermes': 'Hermes Agent', - 'certbot': 'Certbot', +const PROCESS_ALIASES: Record = { + 'mysqld': { name: 'MySQL 8.0', desc: '业务数据库' }, + 'redis-server': { name: 'Redis 7', desc: '缓存/队列' }, + 'qdrant': { name: 'Qdrant', desc: '向量索引库' }, + 'nginx': { name: 'Nginx', desc: '反向代理/HTTPS' }, + 'gitea': { name: 'Gitea', desc: '代码仓库' }, + 'dist/src/main.js': { name: 'NestJS API', desc: '知习后端服务' }, + 'dist/main.js': { name: 'NestJS API', desc: '知习后端服务' }, + 'act_runner': { name: 'Gitea Runner', desc: 'CI/CD 执行器' }, + 'hermes': { name: 'Hermes Agent', desc: 'AI Agent 框架' }, + 'dockerd': { name: 'Docker Daemon', desc: '容器运行时' }, + 'containerd': { name: 'containerd', desc: '容器管理' }, + 'certbot': { name: 'Certbot', desc: 'SSL 证书续期' }, + 'systemd-journald': { name: 'systemd-journald', desc: '系统日志' }, + 'snapd': { name: 'Snapd', desc: 'Snap 包管理' }, + 'multipathd': { name: 'multipathd', desc: '多路径存储' }, + 'YDEyes': { name: '腾讯云监控', desc: '云主机安全监控' }, + 'barad_agent': { name: '腾讯云 Barad', desc: '云监控数据上报' }, }; -function friendlyName(cmd: string): string { - for (const [pattern, name] of Object.entries(PROCESS_ALIASES)) { - if (cmd.includes(pattern)) return name; +function processInfo(cmd: string): { name: string; desc: string } { + for (const [pattern, info] of Object.entries(PROCESS_ALIASES)) { + if (cmd.includes(pattern)) return info; } - // Trim common paths - return cmd.replace(/^\/usr\/bin\//, '').replace(/^\/opt\/.*?\//, '').replace(/^\/usr\/lib\//, '').slice(0, 30); + // Shorten common paths + const short = cmd.replace(/^\/usr\/bin\//, '').replace(/^\/opt\/.*?\//, '').replace(/^\/usr\/lib\//, '').replace(/^\/lib\//, '').replace(/^\/sbin\//, '').replace(/^\/snap\//, 'snap/').slice(0, 25); + return { name: short, desc: '' }; } async function getDisks(mounts: string[]): Promise { @@ -52,7 +55,8 @@ async function getDisks(mounts: string[]): Promise { try { const { stdout } = await execAsync(`df -h ${m} | tail -1 | awk '{print $2,$3,$4,$5}'`); const parts = stdout.trim().split(/\s+/); - results.push({ mount: m, total: parts[0] || '-', used: parts[1] || '-', free: parts[2] || '-', percent: parseInt(parts[3]) || 0 }); + if (parts.length >= 3) results.push({ mount: m, total: parts[0], used: parts[1], free: parts[2], percent: parseInt(parts[3]) || 0 }); + else results.push({ mount: m, total: '-', used: '-', free: '-', percent: 0 }); } catch { results.push({ mount: m, total: '-', used: '-', free: '-', percent: 0 }) } } return results; @@ -60,11 +64,16 @@ async function getDisks(mounts: string[]): Promise { async function getProcesses(): Promise { try { - const { stdout } = await execAsync("ps aux --sort=-%mem --no-headers | head -10 | awk '{print $2,$3,$4,$11}'"); - return stdout.trim().split('\n').filter(Boolean).map(line => { - const [pid, cpu, mem, ...cmd] = line.trim().split(/\s+/); - const command = (cmd || []).join(' '); - return { pid: parseInt(pid), cpu: cpu + '%', mem: mem + '%', name: friendlyName(command), command }; + const { stdout } = await execAsync("ps auxww --sort=-%mem --no-headers | head -10 | awk '{print $2,$3,$4}'"); + const lines = stdout.trim().split('\n').filter(Boolean); + return lines.map(line => { + const parts = line.trim().split(/\s+/); + const pid = parseInt(parts[0]); + const cpu = parts[1]; + const mem = parts[2]; + const cmd = parts.slice(3).join(' '); + const info = processInfo(cmd); + return { pid, cpu: cpu + '%', mem: mem + '%', name: info.name, desc: info.desc, command: cmd.slice(0, 80) }; }); } catch { return [] } } @@ -79,77 +88,93 @@ export class AdminServersService { const freeMem = os.freemem(); const usedMem = totalMem - freeMem; const cpuUsage = Math.min(100, Math.round((os.loadavg()[0] / cpus.length) * 100)); - - const [disks, processes] = await Promise.all([ - getDisks(['/', '/data']), - getProcesses(), - ]); - + const [disks, processes] = await Promise.all([getDisks(['/', '/data']), getProcesses()]); const nets = os.networkInterfaces(); const privateIp = Object.values(nets).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address || '172.21.0.4'; const d = Math.floor(os.uptime() / 86400); const h = Math.floor((os.uptime() % 86400) / 3600); const m = Math.floor((os.uptime() % 3600) / 60); - return { name: '蜂驰云 8核32G', role: '生产核心', hostname: os.hostname(), cpu: { model: cpus[0]?.model || '', cores: cpus.length, usagePercent: cpuUsage }, 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) }, - disks, uptime: `${d}d ${h}h ${m}m`, processes, + disks, uptime: `${d}天${h}时${m}分`, processes, network: { publicIp: '120.53.227.155', privateIp, domains: ['api.longde.cloud', 'admin.longde.cloud'] }, }; } async getRemoteMetrics(): Promise { try { - const base = `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i ${SSH_KEY_PATH} ubuntu@${REMOTE_HOST}`; + const sh = (cmd: string) => execAsync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i ${SSH_KEY_PATH} ubuntu@${REMOTE_HOST} '${cmd}'`, { timeout: 5000 }).then(r => r.stdout.trim()).catch(() => ''); - const execSSH = (cmd: string) => execAsync(`${base} "${cmd.replace(/"/g, '\\"')}"`, { timeout: 5000 }).then(r => r.stdout.trim()).catch(() => ''); - - const [hostname, privateIp, load, cores, memRaw, diskRoot, diskData, uptimeStr] = await Promise.all([ - execSSH('hostname'), - execSSH("hostname -I | awk '{print $1}'"), - execSSH("cat /proc/loadavg | awk '{print $1}'"), - execSSH('cat /proc/cpuinfo | grep processor | wc -l'), - execSSH("free -m | grep Mem | awk '{print $2,$3,$4}'"), - execSSH("df -h / | tail -1 | awk '{print $2,$3,$4,$5}'"), - execSSH("df -h /data 2>/dev/null | tail -1 | awk '{print $2,$3,$4,$5}'"), - execSSH("uptime -p | sed 's/up //'"), - ]); - - // Get remote process list - let processes: ProcessInfo[] = []; - try { - const procRaw = await execAsync(`${base} "ps aux --sort=-%mem --no-headers | head -10 | awk '{print \\$2,\\$3,\\$4,\\$11}'"`, { timeout: 5000 }); - processes = procRaw.stdout.trim().split('\n').filter(Boolean).map(line => { - const [pid, cpu, mem, ...cmd] = line.trim().split(/\s+/); - const command = (cmd || []).join(' '); - return { pid: parseInt(pid), cpu: cpu + '%', mem: mem + '%', name: friendlyName(command), command }; - }); - } catch {} - - const load1 = parseFloat(load) || 0; - const cpuCores = parseInt(cores) || 4; - const cpuUsage = Math.min(100, Math.round((load1 / cpuCores) * 100)); - - const memParts = memRaw.split(/\s+/); - const memPer = memParts[0] && memParts[1] ? Math.round((parseInt(memParts[1]) / parseInt(memParts[0])) * 100) : 0; - - const rootParts = diskRoot.split(/\s+/); - const dataParts = diskData.split(/\s+/); - const disks: DiskInfo[] = [ - { mount: '/', total: rootParts[0] || '-', used: rootParts[1] || '-', free: rootParts[2] || '-', percent: parseInt(rootParts[3]) || 0 }, - ]; - if (dataParts.length >= 3) { - disks.push({ mount: '/data', total: dataParts[0], used: dataParts[1], free: dataParts[2], percent: parseInt(dataParts[3]) || 0 }); + // Collect all metrics via a single SSH call with a script + const script = ` +H=$(hostname) +IP=$(hostname -I | awk '{print $1}') +L=$(cat /proc/loadavg | awk '{print $1}') +C=$(cat /proc/cpuinfo | grep processor | wc -l) +M=$(free -m | grep Mem | awk '{print $2","$3","$4}') +DROOT=$(df -h / | tail -1 | awk '{print $2","$3","$4","$5}') +DDATA=$(df -h /data 2>/dev/null | tail -1 | awk '{print $2","$3","$4","$5}') +U=$(uptime -p | sed 's/up //') +P=$(ps auxww --sort=-%mem --no-headers | head -8) +echo "---HOST---"; echo "$H" +echo "---IP---"; echo "$IP" +echo "---LOAD---"; echo "$L" +echo "---CORES---"; echo "$C" +echo "---MEM---"; echo "$M" +echo "---DISKROOT---"; echo "$DROOT" +echo "---DISKDATA---"; echo "$DDATA" +echo "---UPTIME---"; echo "$U" +echo "---PROCS---"; echo "$P" +`; + const { stdout } = await execAsync(`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 -i ${SSH_KEY_PATH} ubuntu@${REMOTE_HOST} '${script}'`, { timeout: 10000 }); + const sections: Record = {}; + let currentSection = ''; + for (const line of stdout.split('\n')) { + if (line.startsWith('---') && line.endsWith('---')) { + currentSection = line.replace(/---/g, ''); + sections[currentSection] = ''; + } else if (currentSection) { + sections[currentSection] += (sections[currentSection] ? '\n' : '') + line; + } } + const hostname = sections['HOST'] || 'remote'; + const privateIp = sections['IP'] || '10.2.0.7'; + const load1 = parseFloat(sections['LOAD']) || 0; + const cores = parseInt(sections['CORES']) || 4; + const cpuUsage = Math.min(100, Math.round((load1 / cores) * 100)); + const memParts = (sections['MEM'] || '').split(','); + const memPer = memParts[0] && memParts[1] ? Math.round((parseInt(memParts[1]) / parseInt(memParts[0])) * 100) : 0; + const diskRootParts = (sections['DISKROOT'] || '').split(','); + const diskDataParts = (sections['DISKDATA'] || '').split(','); + const disks: DiskInfo[] = [ + { mount: '/', total: diskRootParts[0] || '-', used: diskRootParts[1] || '-', free: diskRootParts[2] || '-', percent: parseInt(diskRootParts[3]) || 0 }, + ]; + if (diskDataParts.length >= 3) disks.push({ mount: '/data', total: diskDataParts[0], used: diskDataParts[1], free: diskDataParts[2], percent: parseInt(diskDataParts[3]) || 0 }); + + const procLines = (sections['PROCS'] || '').split('\n').filter(Boolean); + const processes: ProcessInfo[] = procLines.map(line => { + const parts = line.trim().split(/\s+/); + const pid = parseInt(parts[0]); + const cpu = parts[1]; + const mem = parts[2]; + const cmd = parts.slice(10).join(' '); + const info = processInfo(cmd); + return { pid, cpu: cpu + '%', mem: mem + '%', name: info.name, desc: info.desc, command: cmd.slice(0, 80) }; + }); + + // Convert uptime to Chinese + let uptimeStr = sections['UPTIME'] || ''; + uptimeStr = uptimeStr.replace(/(\d+)\s+weeks?,?\s*/g, '$1周').replace(/(\d+)\s+days?,?\s*/g, '$1天').replace(/(\d+)\s+hours?,?\s*/g, '$1时').replace(/(\d+)\s+minutes?/g, '$1分'); + return { - name: '轻量云 4核4G', role: '工具/辅助', hostname: hostname || 'remote', - cpu: { model: 'Intel Xeon (Lighthouse)', cores: cpuCores, usagePercent: cpuUsage }, + name: '轻量云 4核4G', role: '工具/辅助', hostname, + cpu: { model: 'Intel Xeon (Lighthouse)', cores, usagePercent: cpuUsage }, memory: { total: memParts[0] ? (parseInt(memParts[0]) / 1024).toFixed(1) + 'G' : '-', used: memParts[1] ? (parseInt(memParts[1]) / 1024).toFixed(1) + 'G' : '-', free: memParts[2] ? (parseInt(memParts[2]) / 1024).toFixed(1) + 'G' : '-', percent: memPer }, disks, uptime: uptimeStr || '-', processes, - network: { publicIp: '81.70.187.179', privateIp: privateIp || '10.2.0.7', domains: ['longde.cloud', 'git.longde.cloud'] }, + network: { publicIp: '81.70.187.179', privateIp, domains: ['longde.cloud', 'git.longde.cloud'] }, }; } catch (err: any) { this.logger.warn('Remote metrics failed: ' + err.message);