feat(workspace): enhance status cards and terminal groups

add memory and disk monitoring cards with richer server metrics
and localized labels in the workspace status panel

group ssh terminal tabs by server with per-group add actions to
make multi-terminal relationships clearer

sync helloagents archive and module documentation for the
completed workspace updates
This commit is contained in:
yinjianm
2026-03-25 22:58:27 +08:00
parent d730d06c5e
commit 7af8812e26
23 changed files with 1644 additions and 839 deletions
@@ -3,7 +3,6 @@ import { WebSocket } from 'ws';
import { ClientState } from '../websocket';
import { settingsService } from '../settings/settings.service';
interface ServerStatus {
cpuPercent?: number;
memPercent?: number;
@@ -30,39 +29,398 @@ interface ServerStatus {
netTxTotalBytes?: number; // Bytes since boot
netInterface?: string;
osName?: string;
loadAvg?: number[]; // 系统平均负载 [1min, 5min, 15min]
timestamp: number; // 状态获取时间戳
loadAvg?: number[];
timestamp: number;
}
interface NetworkStats {
[interfaceName: string]: {
rx_bytes: number;
tx_bytes: number;
}
};
}
interface DiskIoStats {
[deviceName: string]: {
readBytes: number;
writeBytes: number;
}
};
}
// 用于存储上一次的网络统计信息以计算速率
const previousNetStats = new Map<string, { rx: number, tx: number, timestamp: number }>();
const previousDiskStats = new Map<string, { device: string, readBytes: number, writeBytes: number, timestamp: number }>();
const previousNetStats = new Map<string, { rx: number; tx: number; timestamp: number }>();
const previousDiskStats = new Map<string, { device: string; readBytes: number; writeBytes: number; timestamp: number }>();
export class StatusMonitorService {
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
// 用于存储上一次的 CPU 统计信息以计算使用率
private previousCpuStats = new Map<string, { total: number, idle: number, timestamp: number }>();
private clientStates: Map<string, ClientState>;
private previousCpuStats = new Map<string, { total: number; idle: number; timestamp: number }>();
constructor(clientStates: Map<string, ClientState>) {
this.clientStates = clientStates;
}
async startStatusPolling(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient || state.statusIntervalId) {
return;
}
let intervalMs = 3000;
try {
const intervalSeconds = await settingsService.getStatusMonitorIntervalSeconds();
intervalMs = intervalSeconds * 1000;
console.log(`[StatusMonitor ${sessionId}] 使用配置的轮询间隔: ${intervalSeconds} 秒 (${intervalMs}ms)`);
} catch (error) {
console.error(`[StatusMonitor ${sessionId}] 获取轮询间隔设置失败,将使用默认值 3000ms:`, error);
}
state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId);
}, intervalMs);
}
stopStatusPolling(sessionId: string): void {
const state = this.clientStates.get(sessionId);
if (!state?.statusIntervalId) {
return;
}
clearInterval(state.statusIntervalId);
state.statusIntervalId = undefined;
previousNetStats.delete(sessionId);
previousDiskStats.delete(sessionId);
this.previousCpuStats.delete(sessionId);
}
private async fetchAndSendServerStatus(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) {
this.stopStatusPolling(sessionId);
return;
}
try {
const status = await this.fetchServerStatus(state.sshClient, sessionId);
state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } }));
} catch (error: any) {
state.ws.send(JSON.stringify({
type: 'status_error',
payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` },
}));
}
}
private async fetchServerStatus(sshClient: Client, sessionId: string): Promise<ServerStatus> {
const timestamp = Date.now();
const status: Partial<ServerStatus> = { timestamp };
try {
try {
const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release');
const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m);
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
} catch (err) { /* noop */ }
try {
let cpuModelOutput = '';
try {
cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1");
status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
} catch (procErr) {
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
}
if (!status.cpuModel) {
status.cpuModel = 'Unknown';
}
} catch (err) {
status.cpuModel = 'Unknown';
}
await this.collectMemoryStatus(sshClient, status);
await this.collectDiskStatus(sshClient, sessionId, timestamp, status);
try {
const procStatOutput = await this.executeSshCommand(sshClient, 'cat /proc/stat');
const currentCpuTimes = this.parseProcStat(procStatOutput);
const now = Date.now();
if (currentCpuTimes) {
const prevCpuStats = this.previousCpuStats.get(sessionId);
if (prevCpuStats && prevCpuStats.timestamp < now) {
const totalDiff = currentCpuTimes.total - prevCpuStats.total;
const idleDiff = currentCpuTimes.idle - prevCpuStats.idle;
const timeDiffMs = now - prevCpuStats.timestamp;
if (totalDiff > 0 && timeDiffMs > 100) {
const usageRatio = 1.0 - (idleDiff / totalDiff);
status.cpuPercent = parseFloat((Math.max(0, Math.min(100, usageRatio * 100))).toFixed(1));
} else {
status.cpuPercent = prevCpuStats.total > 0 ? status.cpuPercent : 0;
}
} else {
status.cpuPercent = 0;
}
this.previousCpuStats.set(sessionId, { ...currentCpuTimes, timestamp: now });
}
} catch (err) {
status.cpuPercent = undefined;
}
try {
const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime');
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/);
if (match) {
status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])];
}
} catch (err) { /* noop */ }
await this.collectNetworkStatus(sshClient, sessionId, timestamp, status);
} catch (error) {
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error);
}
return status as ServerStatus;
}
private async collectMemoryStatus(sshClient: Client, status: Partial<ServerStatus>): Promise<void> {
try {
let freeCommand = 'free -m';
let isBusyBox = false;
try {
const busyboxCheck = await this.executeSshCommand(sshClient, 'busybox --help');
if (busyboxCheck.includes('BusyBox')) {
freeCommand = 'free';
isBusyBox = true;
}
} catch (err) { /* noop */ }
const normalizeMemory = (value: number): number => isBusyBox ? Math.round(value / 1024) : value;
const freeOutput = await this.executeSshCommand(sshClient, freeCommand);
const lines = freeOutput.split('\n');
const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used'));
const memLine = lines.find(line => line.startsWith('Mem:'));
const swapLine = lines.find(line => line.startsWith('Swap:'));
if (memLine && headerLine) {
const headers = headerLine.trim().split(/\s+/);
const values = memLine.trim().split(/\s+/).slice(1);
const memoryFields: Record<string, number> = {};
headers.forEach((header, index) => {
const rawValue = parseInt(values[index], 10);
if (!isNaN(rawValue)) {
memoryFields[header.toLowerCase()] = normalizeMemory(rawValue);
}
});
const totalVal = memoryFields.total;
const usedVal = memoryFields.used;
const freeVal = memoryFields.free;
const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0));
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal;
status.memUsed = usedVal;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0);
if (cachedVal > 0) {
status.memCached = cachedVal;
}
}
} else if (memLine) {
const parts = memLine.split(/\s+/);
if (parts.length >= 4) {
const totalVal = normalizeMemory(parseInt(parts[1], 10));
const usedVal = normalizeMemory(parseInt(parts[2], 10));
const freeVal = normalizeMemory(parseInt(parts[3], 10));
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal;
status.memUsed = usedVal;
status.memFree = !isNaN(freeVal) ? freeVal : undefined;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
}
}
}
if (swapLine) {
const parts = swapLine.split(/\s+/);
if (parts.length >= 3) {
const totalVal = normalizeMemory(parseInt(parts[1], 10));
const usedVal = normalizeMemory(parseInt(parts[2], 10));
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.swapTotal = totalVal;
status.swapUsed = usedVal;
status.swapPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
}
}
} else {
status.swapTotal = 0;
status.swapUsed = 0;
status.swapPercent = 0;
}
} catch (err) { /* noop */ }
}
private async collectDiskStatus(
sshClient: Client,
sessionId: string,
timestamp: number,
status: Partial<ServerStatus>,
): Promise<void> {
try {
let dfOutput = '';
try {
dfOutput = await this.executeSshCommand(sshClient, 'df -kPT /');
} catch (err) {
dfOutput = await this.executeSshCommand(sshClient, 'df -kP /');
}
let rawDiskDevice: string | undefined;
if (dfOutput) {
const lines = dfOutput.split('\n');
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.endsWith(' /')) {
continue;
}
const parts = line.split(/\s+/);
const hasTypeColumn = parts.length >= 7;
const totalIndex = hasTypeColumn ? 2 : 1;
const usedIndex = hasTypeColumn ? 3 : 2;
const availableIndex = hasTypeColumn ? 4 : 3;
const percentIndex = hasTypeColumn ? 5 : 4;
const mountIndex = hasTypeColumn ? 6 : 5;
const total = parseInt(parts[totalIndex], 10);
const used = parseInt(parts[usedIndex], 10);
const available = parseInt(parts[availableIndex], 10);
const percentMatch = parts[percentIndex]?.match(/(\d+)%/);
if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) {
rawDiskDevice = parts[0];
status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType;
status.diskTotal = total;
status.diskUsed = used;
status.diskAvailable = available;
status.diskPercent = parseFloat(percentMatch[1]);
status.diskMountPoint = parts[mountIndex] || '/';
break;
}
}
}
if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) {
try {
const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /');
const findmntParts = findmntOutput.trim().split(/\s+/);
rawDiskDevice = rawDiskDevice || findmntParts[0];
status.diskFsType = status.diskFsType || findmntParts[1];
status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/';
} catch (err) { /* noop */ }
}
status.diskDevice = this.normalizeDiskDevice(rawDiskDevice);
if (!status.diskDevice) {
return;
}
const currentDiskStats = await this.parseProcDiskStats(sshClient);
const deviceStats = currentDiskStats?.[status.diskDevice];
if (!deviceStats) {
return;
}
const previousStats = previousDiskStats.get(sessionId);
if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) {
status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds));
status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds));
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
previousDiskStats.set(sessionId, {
device: status.diskDevice,
readBytes: deviceStats.readBytes,
writeBytes: deviceStats.writeBytes,
timestamp,
});
} catch (err) { /* noop */ }
}
private async collectNetworkStatus(
sshClient: Client,
sessionId: string,
timestamp: number,
status: Partial<ServerStatus>,
): Promise<void> {
try {
const currentStats = await this.parseProcNetDev(sshClient);
if (!currentStats) {
return;
}
const defaultInterface = await this.getDefaultInterface(sshClient) || Object.keys(currentStats).find(iface => iface !== 'lo');
if (!defaultInterface || !currentStats[defaultInterface]) {
return;
}
status.netInterface = defaultInterface;
const currentRx = currentStats[defaultInterface].rx_bytes;
const currentTx = currentStats[defaultInterface].tx_bytes;
status.netRxTotalBytes = currentRx;
status.netTxTotalBytes = currentTx;
const prevStats = previousNetStats.get(sessionId);
if (prevStats && prevStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) {
status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds));
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds));
} else {
status.netRxRate = 0;
status.netTxRate = 0;
}
} else {
status.netRxRate = 0;
status.netTxRate = 0;
}
previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp });
} catch (err) { /* noop */ }
}
private async parseProcNetDev(sshClient: Client): Promise<NetworkStats | null> {
try {
const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
const stats: NetworkStats = {};
for (const line of output.split('\n').slice(2)) {
const parts = line.trim().split(/:\s+|\s+/);
if (parts.length < 17) continue;
const interfaceName = parts[0];
const rx_bytes = parseInt(parts[1], 10);
const tx_bytes = parseInt(parts[9], 10);
if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) {
stats[interfaceName] = { rx_bytes, tx_bytes };
}
}
return Object.keys(stats).length > 0 ? stats : null;
} catch (error) {
return null;
}
}
private async parseProcDiskStats(sshClient: Client): Promise<DiskIoStats | null> {
try {
const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats');
@@ -89,464 +447,25 @@ export class StatusMonitorService {
}
}
/**
* 启动指定会话的状态轮询
* @param sessionId 会话 ID
*/
async startStatusPolling(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient) {
return;
}
if (state.statusIntervalId) {
return;
}
// +++ 从 settingsService 获取轮询间隔 +++
let intervalMs: number;
try {
const intervalSeconds = await settingsService.getStatusMonitorIntervalSeconds();
intervalMs = intervalSeconds * 1000;
console.log(`[StatusMonitor ${sessionId}] 使用配置的轮询间隔: ${intervalSeconds} 秒 (${intervalMs}ms)`);
} catch (error) {
console.error(`[StatusMonitor ${sessionId}] 获取轮询间隔设置失败,将使用默认值 3000ms:`, error);
intervalMs = 3000; // 出错时回退到 3 秒
}
// 移除立即执行,让 setInterval 负责第一次调用,给连接更多准备时间
state.statusIntervalId = setInterval(() => {
this.fetchAndSendServerStatus(sessionId);
}, intervalMs); // --- 使用获取到的间隔 ---
}
/**
* 停止指定会话的状态轮询
* @param sessionId 会话 ID
*/
stopStatusPolling(sessionId: string): void {
const state = this.clientStates.get(sessionId);
if (state?.statusIntervalId) {
//console.warn(`[StatusMonitor] 停止会话 ${sessionId} 的状态轮询。`);
clearInterval(state.statusIntervalId);
state.statusIntervalId = undefined;
previousNetStats.delete(sessionId); // 清理网络统计缓存
previousDiskStats.delete(sessionId);
this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存
}
}
/**
* 获取并发送服务器状态给客户端
* @param sessionId 会话 ID
*/
private async fetchAndSendServerStatus(sessionId: string): Promise<void> {
const state = this.clientStates.get(sessionId);
if (!state || !state.sshClient || state.ws.readyState !== WebSocket.OPEN) {
//console.warn(`[StatusMonitor] 无法获取会话 ${sessionId} 的状态,停止轮询。原因:状态无效、SSH断开或WS关闭。`);
this.stopStatusPolling(sessionId);
return;
}
try {
// 传递 sessionId 给 fetchServerStatus 以便查找 previousNetStats
const status = await this.fetchServerStatus(state.sshClient, sessionId);
state.ws.send(JSON.stringify({ type: 'status_update', payload: { connectionId: state.dbConnectionId, status } }));
} catch (error: any) {
// --- 移除 console.warn ---
// console.warn(`[StatusMonitor] 获取会话 ${sessionId} 服务器状态失败:`, error);
state.ws.send(JSON.stringify({ type: 'status_error', payload: { connectionId: state.dbConnectionId, message: `获取状态失败: ${error.message}` } }));
}
}
/**
* 通过 SSH 执行命令获取服务器状态信息
* @param sshClient SSH 客户端实例
* @param sessionId 当前会话 ID,用于网络速率计算
* @returns Promise<ServerStatus> 服务器状态信息
*/
private async fetchServerStatus(sshClient: Client, sessionId: string): Promise<ServerStatus> {
// console.debug(`[StatusMonitor ${sessionId}] Fetching server status...`);
const timestamp = Date.now();
let status: Partial<ServerStatus> = { timestamp };
try {
// --- OS Name ---
try {
const osReleaseOutput = await this.executeSshCommand(sshClient, 'cat /etc/os-release');
const nameMatch = osReleaseOutput.match(/^PRETTY_NAME="?([^"]+)"?/m);
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
} catch (err) { }
try {
let cpuModelOutput = '';
try {
cpuModelOutput = await this.executeSshCommand(sshClient, "cat /proc/cpuinfo | grep 'model name' | head -n 1");
status.cpuModel = cpuModelOutput.match(/model name\s*:\s*(.*)/i)?.[1].trim();
} catch (procErr) {
try {
cpuModelOutput = await this.executeSshCommand(sshClient, "lscpu | grep 'Model name:'");
status.cpuModel = cpuModelOutput.match(/Model name:\s+(.*)/)?.[1].trim();
} catch (lscpuErr) {
}
}
if (!status.cpuModel) {
status.cpuModel = 'Unknown';
}
} catch (err) {
status.cpuModel = 'Unknown';
}
try {
let freeCommand = 'free -m';
let isBusyBox = false;
try {
const busyboxCheck = await this.executeSshCommand(sshClient, 'busybox --help');
if (busyboxCheck.includes('BusyBox')) {
freeCommand = 'free';
isBusyBox = true;
}
} catch (err) {
// 如果检查失败,默认使用 free -m
}
const freeOutput = await this.executeSshCommand(sshClient, freeCommand);
const lines = freeOutput.split('\n');
const headerLine = lines.find(line => line.toLowerCase().includes('total') && line.toLowerCase().includes('used'));
const memLine = lines.find(line => line.startsWith('Mem:'));
const swapLine = lines.find(line => line.startsWith('Swap:'));
if (memLine && headerLine) {
const headers = headerLine.trim().split(/\s+/);
const values = memLine.trim().split(/\s+/).slice(1);
const memoryFields: Record<string, number> = {};
headers.forEach((header, index) => {
const rawValue = parseInt(values[index], 10);
if (!isNaN(rawValue)) {
memoryFields[header.toLowerCase()] = isBusyBox ? Math.round(rawValue / 1024) : rawValue;
}
});
const totalVal = memoryFields.total;
const usedVal = memoryFields.used;
const freeVal = memoryFields.free;
const cachedVal = memoryFields['buff/cache'] ?? ((memoryFields.buffers ?? 0) + (memoryFields.cached ?? 0));
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal;
status.memUsed = usedVal;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
status.memFree = !isNaN(freeVal) ? freeVal : Math.max(totalVal - usedVal - (cachedVal || 0), 0);
if (cachedVal > 0) {
status.memCached = cachedVal;
}
}
} else if (memLine) {
const parts = memLine.split(/\s+/);
if (parts.length >= 4) {
let totalVal = parseInt(parts[1], 10);
let usedVal = parseInt(parts[2], 10);
let freeVal = parseInt(parts[3], 10);
if (isBusyBox) {
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024);
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024);
if (!isNaN(freeVal)) freeVal = Math.round(freeVal / 1024);
}
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.memTotal = totalVal;
status.memUsed = usedVal;
status.memFree = !isNaN(freeVal) ? freeVal : undefined;
status.memPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
}
}
}
if (swapLine) {
const parts = swapLine.split(/\s+/);
if (parts.length >= 3) {
let totalVal = parseInt(parts[1], 10);
let usedVal = parseInt(parts[2], 10);
if (isBusyBox) {
if (!isNaN(totalVal)) totalVal = Math.round(totalVal / 1024);
if (!isNaN(usedVal)) usedVal = Math.round(usedVal / 1024);
}
if (!isNaN(totalVal) && !isNaN(usedVal)) {
status.swapTotal = totalVal;
status.swapUsed = usedVal;
status.swapPercent = totalVal > 0 ? parseFloat(((usedVal / totalVal) * 100).toFixed(1)) : 0;
}
}
} else {
status.swapTotal = 0;
status.swapUsed = 0;
status.swapPercent = 0;
}
} catch (err) { /* 静默处理 */ }
try {
let dfCommand = "df -kPT /";
let dfOutput: string;
try {
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
} catch (errP) {
dfCommand = "df -kP /";
try {
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
} catch (errK) {
dfOutput = "";
}
}
if (dfOutput) {
const lines = dfOutput.split('\n');
let rawDiskDevice: string | undefined;
let parsedDiskInfo = false;
// 从第二行开始查找根挂载点信息 (跳过表头)
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
// 确保是根挂载点,通常以 " /" 结尾
if (!line.endsWith(" /")) {
continue;
}
const parts = line.split(/\s+/);
const hasTypeColumn = parts.length >= 7;
const totalIndex = hasTypeColumn ? 2 : 1;
const usedIndex = hasTypeColumn ? 3 : 2;
const availableIndex = hasTypeColumn ? 4 : 3;
const percentIndex = hasTypeColumn ? 5 : 4;
const mountIndex = hasTypeColumn ? 6 : 5;
const total = parseInt(parts[totalIndex], 10);
const used = parseInt(parts[usedIndex], 10);
const available = parseInt(parts[availableIndex], 10);
const percentMatch = parts[percentIndex]?.match(/(\d+)%/);
if (!isNaN(total) && !isNaN(used) && !isNaN(available) && percentMatch?.[1]) {
rawDiskDevice = parts[0];
status.diskFsType = hasTypeColumn ? parts[1] : status.diskFsType;
status.diskTotal = total;
status.diskUsed = used;
status.diskAvailable = available;
status.diskPercent = parseFloat(percentMatch[1]);
status.diskMountPoint = parts[mountIndex] || '/';
break;
}
// 预期 parts 至少包含: 文件系统, 总量(KB), 已用(KB), 可用(KB), 百分比%, 挂载点
// 例如: /dev/sda1 10307920 3841884 5941800 40% /
if (parts.length >= 5) {
const total = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const percentStr = parts.find(p => p.endsWith('%')); // 查找百分比字符串
if (percentStr) {
const percentMatch = percentStr.match(/(\d+)%/);
if (!isNaN(total) && !isNaN(used) && percentMatch && percentMatch[1]) {
status.diskTotal = total; // KB
status.diskUsed = used; // KB
status.diskPercent = parseFloat(percentMatch[1]);
parsedDiskInfo = true;
break;
}
}
}
}
}
if (!rawDiskDevice || !status.diskFsType || !status.diskMountPoint) {
try {
const findmntOutput = await this.executeSshCommand(sshClient, 'findmnt -n -o SOURCE,FSTYPE,TARGET /');
const findmntParts = findmntOutput.trim().split(/\s+/);
rawDiskDevice = rawDiskDevice || findmntParts[0];
status.diskFsType = status.diskFsType || findmntParts[1];
status.diskMountPoint = status.diskMountPoint || findmntParts[2] || '/';
} catch (findmntErr) { /* 静默处理 */ }
}
status.diskDevice = this.normalizeDiskDevice(rawDiskDevice);
if (status.diskDevice) {
const currentDiskStats = await this.parseProcDiskStats(sshClient);
const deviceStats = currentDiskStats?.[status.diskDevice];
if (deviceStats) {
const previousStats = previousDiskStats.get(sessionId);
if (previousStats && previousStats.device === status.diskDevice && previousStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - previousStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) {
status.diskReadRate = Math.max(0, Math.round((deviceStats.readBytes - previousStats.readBytes) / timeDiffSeconds));
status.diskWriteRate = Math.max(0, Math.round((deviceStats.writeBytes - previousStats.writeBytes) / timeDiffSeconds));
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
} else {
status.diskReadRate = 0;
status.diskWriteRate = 0;
}
previousDiskStats.set(sessionId, {
device: status.diskDevice,
readBytes: deviceStats.readBytes,
writeBytes: deviceStats.writeBytes,
timestamp,
});
}
}
}
} catch (err) {
// 如果捕获到错误 (例如 executeSshCommand 内部的 Promise reject), disk* 字段将保持 undefined
}
try {
const procStatOutput = await this.executeSshCommand(sshClient, 'cat /proc/stat');
const currentCpuTimes = this.parseProcStat(procStatOutput);
const now = Date.now(); // Use a consistent timestamp
if (currentCpuTimes) {
const prevCpuStats = this.previousCpuStats.get(sessionId);
if (prevCpuStats && prevCpuStats.timestamp < now) {
const totalDiff = currentCpuTimes.total - prevCpuStats.total;
const idleDiff = currentCpuTimes.idle - prevCpuStats.idle;
const timeDiffMs = now - prevCpuStats.timestamp; // Time difference in ms
// Ensure positive difference and minimal time gap (e.g., > 100ms) to avoid division by zero or erratic results
if (totalDiff > 0 && timeDiffMs > 100) {
const usageRatio = 1.0 - (idleDiff / totalDiff);
// Clamp value between 0 and 100, format to 1 decimal place
status.cpuPercent = parseFloat((Math.max(0, Math.min(100, usageRatio * 100))).toFixed(1));
} else {
// If totalDiff is not positive or time gap too small, report 0 or keep previous value?
// Reporting 0 might be misleading if the system is actually busy but no change was detected in the short interval.
// Let's keep the previous value if available, otherwise 0.
status.cpuPercent = prevCpuStats?.total > 0 ? status.cpuPercent : 0; // Keep existing status.cpuPercent if valid prev exists, else 0
}
} else {
// First run or timestamp issue, report 0 as we can't calculate a rate
status.cpuPercent = 0;
}
// Store current stats for the next iteration
this.previousCpuStats.set(sessionId, { ...currentCpuTimes, timestamp: now });
} else {
// Failed to parse /proc/stat, set to undefined or keep previous? Let's use undefined.
status.cpuPercent = undefined;
}
} catch (err) {
// Failed to execute cat /proc/stat
status.cpuPercent = undefined;
// console.warn(`[StatusMonitor ${sessionId}] Failed to get CPU stats via /proc/stat:`, err);
}
try {
const uptimeOutput = await this.executeSshCommand(sshClient, 'uptime');
const match = uptimeOutput.match(/load average(?:s)?:\s*([\d.]+)[, ]?\s*([\d.]+)[, ]?\s*([\d.]+)/);
if (match) status.loadAvg = [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3])];
} catch (err) { /* 静默处理 */ }
try {
const currentStats = await this.parseProcNetDev(sshClient);
if (currentStats) {
const defaultInterface = await this.getDefaultInterface(sshClient) || Object.keys(currentStats).find(iface => iface !== 'lo'); // Detect or fallback excluding loopback
if (defaultInterface && currentStats[defaultInterface]) {
status.netInterface = defaultInterface;
const currentRx = currentStats[defaultInterface].rx_bytes;
const currentTx = currentStats[defaultInterface].tx_bytes;
status.netRxTotalBytes = currentRx;
status.netTxTotalBytes = currentTx;
const prevStats = previousNetStats.get(sessionId);
if (prevStats && prevStats.timestamp < timestamp) {
const timeDiffSeconds = (timestamp - prevStats.timestamp) / 1000;
if (timeDiffSeconds > 0.1) {
status.netRxRate = Math.max(0, Math.round((currentRx - prevStats.rx) / timeDiffSeconds));
status.netTxRate = Math.max(0, Math.round((currentTx - prevStats.tx) / timeDiffSeconds));
} else { status.netRxRate = 0; status.netTxRate = 0; }
} else { status.netRxRate = 0; status.netTxRate = 0; }
previousNetStats.set(sessionId, { rx: currentRx, tx: currentTx, timestamp });
} else { /* 静默处理 */ }
}
} catch (err) { /* 静默处理 */ }
} catch (error) {
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error);
}
return status as ServerStatus;
}
/**
* 解析 /proc/net/dev 的输出
* @param sshClient SSH 客户端实例
* @returns Promise<NetworkStats | null> 解析后的网络统计信息或 null
*/
private async parseProcNetDev(sshClient: Client): Promise<NetworkStats | null> {
let output: string;
try {
// 将命令执行放入 try...catch
output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
} catch (error) {
// 如果命令失败,记录警告并返回 null
return null;
}
// 如果命令成功,继续解析
try {
const lines = output.split('\n').slice(2); // Skip header lines
const stats: NetworkStats = {};
for (const line of lines) {
const parts = line.trim().split(/:\s+|\s+/);
if (parts.length < 17) continue;
const interfaceName = parts[0];
const rx_bytes = parseInt(parts[1], 10);
const tx_bytes = parseInt(parts[9], 10);
if (!isNaN(rx_bytes) && !isNaN(tx_bytes)) {
stats[interfaceName] = { rx_bytes, tx_bytes };
}
}
return Object.keys(stats).length > 0 ? stats : null;
} catch (parseError) {
return null;
}
}
/**
* 获取默认网络接口名称 (Linux specific)
* @param sshClient SSH 客户端实例
* @returns Promise<string | null> 默认接口名称或 null
*/
private async getDefaultInterface(sshClient: Client): Promise<string | null> {
try {
// 使用 ip route 命令查找默认路由对应的接口
const output = await this.executeSshCommand(sshClient, "ip route get 1.1.1.1 | grep -oP 'dev\\s+\\K\\S+'");
const interfaceName = output.trim();
if (interfaceName) return interfaceName;
// 如果 ip route 没返回有效接口名,也尝试 fallback
} catch (error) {
try {
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
const lines = netDevOutput.split('\n').slice(2);
for (const line of lines) {
const iface = line.trim().split(':')[0];
if (iface && iface !== 'lo') {
return iface;
}
}
} catch (fallbackError) {
if (interfaceName) {
return interfaceName;
}
} catch (error) {
try {
const netDevOutput = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
for (const line of netDevOutput.split('\n').slice(2)) {
const iface = line.trim().split(':')[0];
if (iface && iface !== 'lo') {
return iface;
}
}
} catch (fallbackError) {
return null;
}
return null;
}
return null;
@@ -577,13 +496,6 @@ export class StatusMonitorService {
return normalized;
}
/**
* 在 SSH 连接上执行单个命令
* @param sshClient SSH 客户端实例
* @param command 要执行的命令
* @returns Promise<string> 命令的标准输出
* @throws Error 如果命令执行失败
*/
private executeSshCommand(sshClient: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
let output = '';
@@ -591,70 +503,39 @@ export class StatusMonitorService {
if (err) {
return reject(new Error(`执行命令 '${command}' 失败: ${err.message}`));
}
stream.on('close', (code: number, signal?: string) => {
resolve(output.trim());
}).on('data', (data: Buffer) => {
output += data.toString('utf8');
}).stderr.on('data', (data: Buffer) => {
});
stream
.on('close', () => resolve(output.trim()))
.on('data', (data: Buffer) => {
output += data.toString('utf8');
})
.stderr.on('data', () => {
return;
});
});
});
}
/**
* 查找与给定 SSH 客户端关联的会话 ID (辅助函数)
* @param sshClientToFind 要查找的 SSH 客户端实例
* @returns string | undefined 找到的会话 ID 或 undefined
*/
private findSessionIdForClient(sshClientToFind: Client): string | undefined {
for (const [sessionId, state] of this.clientStates.entries()) {
if (state.sshClient === sshClientToFind) {
return sessionId;
}
}
return undefined;
}
/**
* Parses the output of /proc/stat to get total and idle CPU times.
* @param output The string output from `cat /proc/stat`.
* @returns An object with total and idle times, or null if parsing fails.
*/
private parseProcStat(output: string): { total: number, idle: number } | null {
private parseProcStat(output: string): { total: number; idle: number } | null {
try {
const lines = output.split('\n');
// Find the line starting with "cpu " (aggregate of all cores)
const cpuLine = lines.find(line => line.startsWith('cpu '));
const cpuLine = output.split('\n').find(line => line.startsWith('cpu '));
if (!cpuLine) {
// console.warn("Could not find 'cpu ' line in /proc/stat");
return null;
}
// Fields documented in `man proc`: cpu user nice system idle iowait irq softirq steal guest guest_nice
// We need to handle potential missing fields at the end (guest times are not always present)
const fieldsStr = cpuLine.trim().split(/\s+/).slice(1); // Remove 'cpu' prefix
const fields = fieldsStr.map(Number); // Convert remaining fields to numbers
// We need at least the first 4 fields (user, nice, system, idle)
const fields = cpuLine.trim().split(/\s+/).slice(1).map(Number);
if (fields.length < 4 || fields.slice(0, 4).some(isNaN)) {
// console.warn("Invalid format or missing required fields in 'cpu ' line:", cpuLine);
return null;
}
const idle = fields[3]; // The 4th field (index 3) is idle time
// Total time is the sum of all fields. Filter out NaN values just in case.
const idle = fields[3];
const total = fields.reduce((sum, value) => sum + (isNaN(value) ? 0 : value), 0);
// Final check for NaN just to be safe
if (isNaN(total) || isNaN(idle)) {
// console.warn("NaN detected after parsing /proc/stat fields:", fields);
return null;
}
return { total, idle };
} catch (e) {
// console.error("Error parsing /proc/stat:", e);
return null;
}
}