From fc968830c50e12fd617f459a6b372ecc1c399596 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 13:57:26 +0800 Subject: [PATCH] fix: correct ps auxww column parsing + robust remote SSH script --- .../admin-servers/admin-servers.service.ts | 197 ++++++++---------- 1 file changed, 86 insertions(+), 111 deletions(-) diff --git a/src/modules/admin-servers/admin-servers.service.ts b/src/modules/admin-servers/admin-servers.service.ts index 4094a78..fd3c9f1 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); -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 DiskInfo { mount: string; total: string; used: string; free: string; percent: number } +export 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 }; @@ -24,58 +24,38 @@ 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' }, + 'nginx': { name: 'Nginx', desc: '反向代理' }, '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: '容器运行时' }, + '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', 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: '云监控数据上报' }, + 'systemd-journald': { name: '日志服务', desc: '系统日志' }, + 'snapd': { name: 'Snapd', desc: '包管理' }, + 'multipathd': { name: '存储多路径', desc: '磁盘管理' }, + 'YDEyes': { name: '腾讯云监控', desc: '安全监控' }, + 'barad_agent': { name: '云监控上报', desc: '指标采集' }, }; -function processInfo(cmd: string): { name: string; desc: string } { - for (const [pattern, info] of Object.entries(PROCESS_ALIASES)) { +function friendlyProcess(cmd: string): { name: string; desc: string } { + for (const [pattern, info] of Object.entries(PROCESS_ALIASES)) if (cmd.includes(pattern)) return info; - } - // Shorten common paths - const short = cmd.replace(/^\/usr\/bin\//, '').replace(/^\/opt\/.*?\//, '').replace(/^\/usr\/lib\//, '').replace(/^\/lib\//, '').replace(/^\/sbin\//, '').replace(/^\/snap\//, 'snap/').slice(0, 25); + const short = cmd.split('/').pop()?.slice(0, 20) || cmd.slice(0, 20); return { name: short, desc: '' }; } -async function getDisks(mounts: string[]): Promise { - const results: DiskInfo[] = []; - for (const m of mounts) { - try { - const { stdout } = await execAsync(`df -h ${m} | tail -1 | awk '{print $2,$3,$4,$5}'`); - const parts = stdout.trim().split(/\s+/); - 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; -} - -async function getProcesses(): Promise { - try { - 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 [] } +// Parse "ps auxww" output: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND... +function parsePsLine(line: string): ProcessInfo { + const parts = line.trim().split(/\s+/); + // columns: 0=USER 1=PID 2=%CPU 3=%MEM 4=VSZ 5=RSS 6=TTY 7=STAT 8=START 9=TIME 10...=COMMAND + const pid = parseInt(parts[1]) || 0; + const cpu = (parts[2] || '0') + '%'; + const mem = (parts[3] || '0') + '%'; + const cmd = parts.slice(10).join(' '); + const info = friendlyProcess(cmd); + return { pid, cpu, mem, name: info.name, desc: info.desc, command: cmd.slice(0, 80) }; } @Injectable() @@ -88,92 +68,87 @@ 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()]); + + // Disks + const diskResults: DiskInfo[] = []; + for (const m of ['/', '/data']) { + try { + const { stdout } = await execAsync(`df -h ${m} | tail -1 | awk '{print $2","$3","$4","$5}'`); + const parts = stdout.trim().split(','); + if (parts.length >= 3) diskResults.push({ mount: m, total: parts[0], used: parts[1], free: parts[2], percent: parseInt(parts[4]) || parseInt(parts[3]) || 0 }); + } catch { diskResults.push({ mount: m, total: '-', used: '-', free: '-', percent: 0 }) } + } + + // Processes — use auxww for full command line + let processes: ProcessInfo[] = []; + try { + const { stdout } = await execAsync("ps auxww --sort=-%mem --no-headers 2>/dev/null | head -10"); + processes = stdout.trim().split('\n').filter(Boolean).map(parsePsLine); + } catch {} + 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); + const u = os.uptime(); + const d = Math.floor(u / 86400), h = Math.floor((u % 86400) / 3600), m = Math.floor((u % 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}天${h}时${m}分`, processes, + disks: diskResults, uptime: `${d}天${h}时${m}分`, processes, network: { publicIp: '120.53.227.155', privateIp, domains: ['api.longde.cloud', 'admin.longde.cloud'] }, }; } async getRemoteMetrics(): Promise { try { - 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(() => ''); + // Single SSH to collect all metrics + const script = `echo "==HOST==" && hostname +echo "==IP==" && hostname -I | awk '{print \$1}' +echo "==LOAD==" && cat /proc/loadavg | awk '{print \$1}' +echo "==CORES==" && cat /proc/cpuinfo | grep processor | wc -l +echo "==MEM==" && free -m | awk '/Mem/{printf \"%.1fG,%.1fG,%.1fG,%d\",\$2/1024,\$3/1024,\$4/1024,int(\$3/\$2*100)}' +echo "==DISKROOT==" && df -h / | awk 'NR==2{print \$2\",\"\$3\",\"\$4\",\"int(\$5)}' +echo "==DISKDATA==" && df -h /data 2>/dev/null | awk 'NR==2{print \$2\",\"\$3\",\"\$4\",\"int(\$5)}' +echo "==UPTIME==" && uptime -p | sed 's/up //' +echo "==PROCS==" && ps auxww --sort=-%mem --no-headers 2>/dev/null | head -8`; - // 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 { stdout } = await execAsync( + `ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 -i ${SSH_KEY_PATH} ubuntu@${REMOTE_HOST} '${script}'`, + { timeout: 12000 }, + ); - 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 }, - ]; + // Parse sections + const lines = stdout.split('\n'); + const get = (tag: string) => lines.find(l => l.startsWith(`==${tag}==`))?.replace(`==${tag}==`, '').trim() || ''; + + const hostname = get('HOST') || 'remote'; + const privateIp = get('IP') || '10.2.0.7'; + const load1 = parseFloat(get('LOAD')) || 0; + const cores = parseInt(get('CORES')) || 4; + const cpuUsage = Math.min(100, Math.round((load1 / Math.max(cores, 1)) * 100)); + + const memParts = get('MEM').split(','); + const memPercent = parseInt(memParts[3]) || 0; + + const diskRootParts = get('DISKROOT').split(','); + const diskDataParts = get('DISKDATA').split(','); + const disks: DiskInfo[] = []; + if (diskRootParts.length >= 3) disks.push({ mount: '/', total: diskRootParts[0], used: diskRootParts[1], free: diskRootParts[2], percent: parseInt(diskRootParts[3]) || 0 }); + else disks.push({ mount: '/', total: '-', used: '-', free: '-', percent: 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) }; - }); + const procLines = lines.filter(l => !l.startsWith('==') && !l.startsWith('USER') && /^\S+\s+\d+\s/.test(l)); + const processes: ProcessInfo[] = procLines.map(parsePsLine); - // 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分'); + let up = get('UPTIME'); + up = up.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, 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, + memory: { total: memParts[0] || '-', used: memParts[1] || '-', free: memParts[2] || '-', percent: memPercent }, + disks, uptime: up || '-', processes, network: { publicIp: '81.70.187.179', privateIp, domains: ['longde.cloud', 'git.longde.cloud'] }, }; } catch (err: any) {