api-server/src/modules/admin-servers/admin-servers.service.ts

167 lines
8.0 KiB
TypeScript
Raw Normal View History

import { Injectable, Logger } from '@nestjs/common';
import * as os from 'os';
import { exec } from 'child_process';
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; desc: string; command: string }
export interface ServerInfo {
name: string; role: string; hostname: string;
cpu: { model: string; cores: number; usagePercent: number };
memory: { total: string; used: string; free: string; percent: number };
disks: DiskInfo[];
uptime: string;
processes: ProcessInfo[];
network: { publicIp: string; privateIp: string; domains: string[] };
}
2026-05-22 13:31:45 +08:00
const SSH_KEY_PATH = process.env.SSH_KEY_PATH || '/home/ubuntu/.ssh/wangdl.pem';
const REMOTE_HOST = '10.2.0.7';
const PROCESS_ALIASES: Record<string, { name: string; desc: string }> = {
'mysqld': { name: 'MySQL 8.0', desc: '业务数据库' },
'redis-server': { name: 'Redis 7', desc: '缓存/队列' },
'qdrant': { name: 'Qdrant', desc: '向量索引库' },
'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', desc: '容器引擎' },
'containerd': { name: 'containerd', desc: '容器管理' },
'systemd-journald': { name: '日志服务', desc: '系统日志' },
'snapd': { name: 'Snapd', desc: '包管理' },
'multipathd': { name: '存储多路径', desc: '磁盘管理' },
'YDEyes': { name: '腾讯云监控', desc: '安全监控' },
'barad_agent': { name: '云监控上报', desc: '指标采集' },
};
function friendlyProcess(cmd: string): { name: string; desc: string } {
for (const [pattern, info] of Object.entries(PROCESS_ALIASES))
if (cmd.includes(pattern)) return info;
const short = cmd.split('/').pop()?.slice(0, 20) || cmd.slice(0, 20);
return { name: short, desc: '' };
}
// 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()
export class AdminServersService {
private readonly logger = new Logger(AdminServersService.name);
async getLocalMetrics(): Promise<ServerInfo> {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuUsage = Math.min(100, Math.round((os.loadavg()[0] / cpus.length) * 100));
// 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 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: diskResults, uptime: `${d}${h}${m}`, processes,
network: { publicIp: '120.53.227.155', privateIp, domains: ['api.longde.cloud', 'admin.longde.cloud'] },
};
}
async getRemoteMetrics(): Promise<ServerInfo | null> {
try {
// 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\n",\$2/1024,\$3/1024,\$4/1024,int(\$3/\$2*100)}'
echo "==DISKROOT==" && df -h / | awk 'NR==2{printf "%s,%s,%s,%d\n",\$2,\$3,\$4,int(\$5)}'
echo "==DISKDATA==" && df -h /data 2>/dev/null | awk 'NR==2{printf "%s,%s,%s,%d\n",\$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`;
const { stdout } = await execAsync(
`ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 -i ${SSH_KEY_PATH} ubuntu@${REMOTE_HOST} '${script}'`,
{ timeout: 12000 },
);
// 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 = lines.filter(l => !l.startsWith('==') && !l.startsWith('USER') && /^\S+\s+\d+\s/.test(l));
const processes: ProcessInfo[] = procLines.map(parsePsLine);
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] || '-', 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) {
this.logger.warn('Remote metrics failed: ' + err.message);
return null;
}
}
async getAllMetrics() {
const [local, remote] = await Promise.all([this.getLocalMetrics(), this.getRemoteMetrics()]);
const servers = [local];
if (remote) servers.push(remote);
return { servers };
}
}