2026-05-22 13:30:44 +08:00
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
|
|
|
import * as os from 'os';
|
|
|
|
|
import { exec } from 'child_process';
|
|
|
|
|
import { promisify } from 'util';
|
|
|
|
|
|
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
|
|
2026-05-22 13:51:19 +08:00
|
|
|
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 }
|
2026-05-22 13:42:42 +08:00
|
|
|
export interface ServerInfo {
|
|
|
|
|
name: string; role: string; hostname: string;
|
2026-05-22 13:34:08 +08:00
|
|
|
cpu: { model: string; cores: number; usagePercent: number };
|
2026-05-22 13:30:44 +08:00
|
|
|
memory: { total: string; used: string; free: string; percent: number };
|
2026-05-22 13:42:42 +08:00
|
|
|
disks: DiskInfo[];
|
2026-05-22 13:30:44 +08:00
|
|
|
uptime: string;
|
2026-05-22 13:42:42 +08:00
|
|
|
processes: ProcessInfo[];
|
|
|
|
|
network: { publicIp: string; privateIp: string; domains: string[] };
|
2026-05-22 13:30:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 13:31:45 +08:00
|
|
|
const SSH_KEY_PATH = process.env.SSH_KEY_PATH || '/home/ubuntu/.ssh/wangdl.pem';
|
2026-05-22 13:34:08 +08:00
|
|
|
const REMOTE_HOST = '10.2.0.7';
|
2026-05-22 13:30:44 +08:00
|
|
|
|
2026-05-22 13:51:19 +08:00
|
|
|
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: '反向代理/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: '云监控数据上报' },
|
2026-05-22 13:42:42 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-22 13:51:19 +08:00
|
|
|
function processInfo(cmd: string): { name: string; desc: string } {
|
|
|
|
|
for (const [pattern, info] of Object.entries(PROCESS_ALIASES)) {
|
|
|
|
|
if (cmd.includes(pattern)) return info;
|
2026-05-22 13:42:42 +08:00
|
|
|
}
|
2026-05-22 13:51:19 +08:00
|
|
|
// 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: '' };
|
2026-05-22 13:42:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getDisks(mounts: string[]): Promise<DiskInfo[]> {
|
|
|
|
|
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+/);
|
2026-05-22 13:51:19 +08:00
|
|
|
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 });
|
2026-05-22 13:42:42 +08:00
|
|
|
} catch { results.push({ mount: m, total: '-', used: '-', free: '-', percent: 0 }) }
|
|
|
|
|
}
|
|
|
|
|
return results;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getProcesses(): Promise<ProcessInfo[]> {
|
|
|
|
|
try {
|
2026-05-22 13:51:19 +08:00
|
|
|
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) };
|
2026-05-22 13:42:42 +08:00
|
|
|
});
|
|
|
|
|
} catch { return [] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 13:30:44 +08:00
|
|
|
@Injectable()
|
|
|
|
|
export class AdminServersService {
|
|
|
|
|
private readonly logger = new Logger(AdminServersService.name);
|
|
|
|
|
|
2026-05-22 13:42:42 +08:00
|
|
|
async getLocalMetrics(): Promise<ServerInfo> {
|
2026-05-22 13:30:44 +08:00
|
|
|
const cpus = os.cpus();
|
|
|
|
|
const totalMem = os.totalmem();
|
|
|
|
|
const freeMem = os.freemem();
|
|
|
|
|
const usedMem = totalMem - freeMem;
|
2026-05-22 13:42:42 +08:00
|
|
|
const cpuUsage = Math.min(100, Math.round((os.loadavg()[0] / cpus.length) * 100));
|
2026-05-22 13:51:19 +08:00
|
|
|
const [disks, processes] = await Promise.all([getDisks(['/', '/data']), getProcesses()]);
|
2026-05-22 13:30:44 +08:00
|
|
|
const nets = os.networkInterfaces();
|
2026-05-22 13:42:42 +08:00
|
|
|
const privateIp = Object.values(nets).flat().find(n => n?.family === 'IPv4' && !n.internal)?.address || '172.21.0.4';
|
2026-05-22 13:34:08 +08:00
|
|
|
const d = Math.floor(os.uptime() / 86400);
|
|
|
|
|
const h = Math.floor((os.uptime() % 86400) / 3600);
|
|
|
|
|
const m = Math.floor((os.uptime() % 3600) / 60);
|
2026-05-22 13:30:44 +08:00
|
|
|
return {
|
2026-05-22 13:42:42 +08:00
|
|
|
name: '蜂驰云 8核32G', role: '生产核心', hostname: os.hostname(),
|
2026-05-22 13:34:08 +08:00
|
|
|
cpu: { model: cpus[0]?.model || '', cores: cpus.length, usagePercent: cpuUsage },
|
2026-05-22 13:30:44 +08:00
|
|
|
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) },
|
2026-05-22 13:51:19 +08:00
|
|
|
disks, uptime: `${d}天${h}时${m}分`, processes,
|
2026-05-22 13:42:42 +08:00
|
|
|
network: { publicIp: '120.53.227.155', privateIp, domains: ['api.longde.cloud', 'admin.longde.cloud'] },
|
2026-05-22 13:30:44 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 13:42:42 +08:00
|
|
|
async getRemoteMetrics(): Promise<ServerInfo | null> {
|
2026-05-22 13:30:44 +08:00
|
|
|
try {
|
2026-05-22 13:51:19 +08:00
|
|
|
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(() => '');
|
|
|
|
|
|
|
|
|
|
// 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<string, string> = {};
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-22 13:42:42 +08:00
|
|
|
|
2026-05-22 13:51:19 +08:00
|
|
|
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(',');
|
2026-05-22 13:42:42 +08:00
|
|
|
const disks: DiskInfo[] = [
|
2026-05-22 13:51:19 +08:00
|
|
|
{ mount: '/', total: diskRootParts[0] || '-', used: diskRootParts[1] || '-', free: diskRootParts[2] || '-', percent: parseInt(diskRootParts[3]) || 0 },
|
2026-05-22 13:42:42 +08:00
|
|
|
];
|
2026-05-22 13:51:19 +08:00
|
|
|
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分');
|
2026-05-22 13:30:44 +08:00
|
|
|
|
|
|
|
|
return {
|
2026-05-22 13:51:19 +08:00
|
|
|
name: '轻量云 4核4G', role: '工具/辅助', hostname,
|
|
|
|
|
cpu: { model: 'Intel Xeon (Lighthouse)', cores, usagePercent: cpuUsage },
|
2026-05-22 13:42:42 +08:00
|
|
|
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,
|
2026-05-22 13:51:19 +08:00
|
|
|
network: { publicIp: '81.70.187.179', privateIp, domains: ['longde.cloud', 'git.longde.cloud'] },
|
2026-05-22 13:30:44 +08:00
|
|
|
};
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
this.logger.warn('Remote metrics failed: ' + err.message);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getAllMetrics() {
|
2026-05-22 13:34:08 +08:00
|
|
|
const [local, remote] = await Promise.all([this.getLocalMetrics(), this.getRemoteMetrics()]);
|
2026-05-22 13:42:42 +08:00
|
|
|
const servers = [local];
|
|
|
|
|
if (remote) servers.push(remote);
|
2026-05-22 13:34:08 +08:00
|
|
|
return { servers };
|
2026-05-22 13:30:44 +08:00
|
|
|
}
|
|
|
|
|
}
|