✨ feat(workspace): 增强连接管理与终端状态展示
- 为连接管理页补充多级标签树、列头排序和行级更多菜单
- 支持同一 SSH 连接打开多个终端并显示终端序号
- 补充状态监控的内存与磁盘详情字段
---
✨ feat(workspace): enhance connection management and terminal status visibility
- add multi-level tag tree, sortable columns, and row-level more menu
- support multiple terminals per SSH connection with terminal indices
- extend status monitor with memory and disk detail fields
This commit is contained in:
@@ -9,12 +9,20 @@ interface ServerStatus {
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
memFree?: number; // MB
|
||||
memCached?: number; // MB
|
||||
swapPercent?: number;
|
||||
swapUsed?: number; // MB
|
||||
swapTotal?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
diskAvailable?: number; // KB
|
||||
diskMountPoint?: string;
|
||||
diskFsType?: string;
|
||||
diskDevice?: string;
|
||||
diskReadRate?: number; // Bytes per second
|
||||
diskWriteRate?: number; // Bytes per second
|
||||
cpuModel?: string;
|
||||
netRxRate?: number; // Bytes per second
|
||||
netTxRate?: number; // Bytes per second
|
||||
@@ -34,9 +42,17 @@ interface NetworkStats {
|
||||
}
|
||||
}
|
||||
|
||||
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 }>();
|
||||
|
||||
export class StatusMonitorService {
|
||||
private clientStates: Map<string, ClientState>; // 使用导入的 ClientState
|
||||
@@ -47,6 +63,32 @@ export class StatusMonitorService {
|
||||
this.clientStates = clientStates;
|
||||
}
|
||||
|
||||
private async parseProcDiskStats(sshClient: Client): Promise<DiskIoStats | null> {
|
||||
try {
|
||||
const output = await this.executeSshCommand(sshClient, 'cat /proc/diskstats');
|
||||
const stats: DiskIoStats = {};
|
||||
|
||||
for (const line of output.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 10) continue;
|
||||
|
||||
const deviceName = parts[2];
|
||||
const sectorsRead = parseInt(parts[5], 10);
|
||||
const sectorsWritten = parseInt(parts[9], 10);
|
||||
if (!isNaN(sectorsRead) && !isNaN(sectorsWritten)) {
|
||||
stats[deviceName] = {
|
||||
readBytes: sectorsRead * 512,
|
||||
writeBytes: sectorsWritten * 512,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(stats).length > 0 ? stats : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动指定会话的状态轮询
|
||||
* @param sessionId 会话 ID
|
||||
@@ -88,6 +130,7 @@ export class StatusMonitorService {
|
||||
clearInterval(state.statusIntervalId);
|
||||
state.statusIntervalId = undefined;
|
||||
previousNetStats.delete(sessionId); // 清理网络统计缓存
|
||||
previousDiskStats.delete(sessionId);
|
||||
this.previousCpuStats.delete(sessionId); // 清理 CPU 统计缓存
|
||||
}
|
||||
}
|
||||
@@ -170,22 +213,52 @@ export class StatusMonitorService {
|
||||
}
|
||||
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) {
|
||||
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 >= 3) {
|
||||
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 (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;
|
||||
}
|
||||
}
|
||||
@@ -216,12 +289,12 @@ export class StatusMonitorService {
|
||||
|
||||
|
||||
try {
|
||||
let dfCommand = "df -kP /"; // 优先尝试 POSIX 标准格式
|
||||
let dfCommand = "df -kPT /";
|
||||
let dfOutput: string;
|
||||
try {
|
||||
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
|
||||
} catch (errP) {
|
||||
dfCommand = "df -k /"; // 备用方案
|
||||
dfCommand = "df -kP /";
|
||||
try {
|
||||
dfOutput = await this.executeSshCommand(sshClient, dfCommand);
|
||||
} catch (errK) {
|
||||
@@ -231,13 +304,38 @@ export class StatusMonitorService {
|
||||
|
||||
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(" /")) {
|
||||
const parts = line.split(/\s+/);
|
||||
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) {
|
||||
@@ -259,6 +357,46 @@ export class StatusMonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -414,6 +552,31 @@ export class StatusMonitorService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private normalizeDiskDevice(rawDevice?: string): string | undefined {
|
||||
if (!rawDevice) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let normalized = rawDevice.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/dev/')) {
|
||||
normalized = normalized.slice(5);
|
||||
}
|
||||
|
||||
if (/^(sd[a-z]+|vd[a-z]+|xvd[a-z]+|hd[a-z]+)\d+$/.test(normalized)) {
|
||||
return normalized.replace(/\d+$/, '');
|
||||
}
|
||||
|
||||
if (/^(nvme\d+n\d+|mmcblk\d+)p\d+$/.test(normalized)) {
|
||||
return normalized.replace(/p\d+$/, '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSH 连接上执行单个命令
|
||||
* @param sshClient SSH 客户端实例
|
||||
|
||||
Reference in New Issue
Block a user