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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +1,190 @@
|
||||
<template>
|
||||
<!-- 根元素,包含内边距、背景、边框和文本样式 -->
|
||||
<div class="status-monitor p-4 bg-background text-foreground h-full overflow-y-auto text-sm" :class="{ 'bg-header': !activeSessionId }">
|
||||
<h4 v-if="activeSessionId" class="mt-0 mb-4 border-b border-border pb-2 text-base font-medium">
|
||||
{{ t('statusMonitor.title') }}
|
||||
</h4>
|
||||
<h4 v-if="activeSessionId" class="mt-0 mb-4 border-b border-border pb-2 text-base font-medium">
|
||||
{{ t('statusMonitor.title') }}
|
||||
</h4>
|
||||
|
||||
<!-- 无活动会话状态 -->
|
||||
<div v-if="!activeSessionId" class="no-session-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-plug text-4xl mb-3 text-text-secondary"></i>
|
||||
<span class="text-lg font-medium mb-2">{{ t('layout.noActiveSession.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="currentStatusError" class="status-error flex flex-col items-center justify-center text-center text-red-500 mt-4 h-full">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.errorPrefix') }} {{ currentStatusError }}</span>
|
||||
<div v-if="!activeSessionId" class="no-session-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-plug text-4xl mb-3 text-text-secondary"></i>
|
||||
<span class="text-lg font-medium mb-2">{{ t('layout.noActiveSession.title') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentStatusError" class="status-error flex flex-col items-center justify-center text-center text-red-500 mt-4 h-full">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.errorPrefix') }} {{ currentStatusError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="!currentServerStatus" class="loading-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态网格 -->
|
||||
<div v-else class="status-grid grid gap-3">
|
||||
<!-- IP 地址 (如果启用) -->
|
||||
<div v-if="statusMonitorShowIpBoolean && activeSessionId && sessionIpAddress" class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">IP:</label>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="ip-address-value truncate text-left cursor-pointer hover:text-primary transition-colors"
|
||||
:title="sessionIpAddress"
|
||||
@click="copyIpToClipboard(sessionIpAddress)">
|
||||
{{ sessionIpAddress }}
|
||||
<template v-else>
|
||||
<div class="status-grid grid gap-3">
|
||||
<div v-if="statusMonitorShowIpBoolean && activeSessionId && sessionIpAddress" class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">IP:</label>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="ip-address-value truncate text-left cursor-pointer hover:text-primary transition-colors"
|
||||
:title="sessionIpAddress"
|
||||
@click="copyIpToClipboard(sessionIpAddress)"
|
||||
>
|
||||
{{ sessionIpAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuModelLabel') }}</label>
|
||||
<span class="cpu-model-value truncate text-left" :title="displayCpuModel">{{ displayCpuModel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.osLabel') }}</label>
|
||||
<span class="os-name-value truncate text-left" :title="displayOsName">{{ displayOsName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="resource-monitor-group grid gap-3">
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="16"
|
||||
color="#3b82f6"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.swapLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="16"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#6b7280'"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="font-mono text-xs whitespace-nowrap text-left text-text-secondary">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-cards grid gap-3">
|
||||
<section class="status-card">
|
||||
<div class="status-card__header">
|
||||
<div class="status-card__title-group">
|
||||
<span class="status-card__icon status-card__icon--memory">
|
||||
<i class="fas fa-memory"></i>
|
||||
</span>
|
||||
<h5 class="status-card__title">{{ t('statusMonitor.memoryCardTitle') }}</h5>
|
||||
</div>
|
||||
<span class="status-card__badge">{{ memoryTotalDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="memory-card__content">
|
||||
<div class="memory-ring" :style="memoryRingStyle">
|
||||
<div class="memory-ring__center">{{ memoryPercentDisplay }}</div>
|
||||
</div>
|
||||
|
||||
<div class="memory-stats-grid">
|
||||
<div
|
||||
v-for="item in memoryStatItems"
|
||||
:key="item.key"
|
||||
class="memory-stat"
|
||||
>
|
||||
<div class="memory-stat__label">
|
||||
<span class="memory-stat__dot" :class="`memory-stat__dot--${item.key}`"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="memory-stat__value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="status-card__header">
|
||||
<div class="status-card__title-group">
|
||||
<span class="status-card__icon status-card__icon--disk">
|
||||
<i class="fas fa-hdd"></i>
|
||||
</span>
|
||||
<h5 class="status-card__title">{{ t('statusMonitor.diskCardTitle') }}</h5>
|
||||
</div>
|
||||
<span class="status-card__badge">{{ diskUsageDisplay }}</span>
|
||||
</div>
|
||||
|
||||
<div class="disk-meta-row">
|
||||
<span class="disk-device">
|
||||
<span class="memory-stat__dot memory-stat__dot--free"></span>
|
||||
<span>{{ diskDeviceDisplay }}</span>
|
||||
</span>
|
||||
<span class="disk-type">
|
||||
<span class="disk-type__label">{{ t('statusMonitor.diskTypeLabel') }}</span>
|
||||
<span class="disk-type__value">{{ diskFsTypeDisplay }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="disk-card__body">
|
||||
<div class="disk-usage-tube">
|
||||
<div class="disk-usage-tube__inner">
|
||||
<div class="disk-usage-tube__fill" :style="diskUsageFillStyle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="disk-rate-grid">
|
||||
<div class="disk-rate-card">
|
||||
<span class="disk-rate-card__label">{{ t('statusMonitor.diskReadRateLabel') }}</span>
|
||||
<span class="disk-rate-card__value">{{ diskReadRateDisplay }}</span>
|
||||
</div>
|
||||
<div class="disk-rate-card">
|
||||
<span class="disk-rate-card__label">{{ t('statusMonitor.diskWriteRateLabel') }}</span>
|
||||
<span class="disk-rate-card__value">{{ diskWriteRateDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="disk-table">
|
||||
<div class="disk-table__header">
|
||||
<span>{{ t('statusMonitor.diskMountLabel') }}</span>
|
||||
<span>{{ t('statusMonitor.diskSizeLabel') }}</span>
|
||||
<span>{{ t('statusMonitor.diskAvailableLabel') }}</span>
|
||||
<span>{{ t('statusMonitor.diskUsedPercentLabel') }}</span>
|
||||
</div>
|
||||
<div class="disk-table__row">
|
||||
<span class="disk-mount-pill">{{ diskMountPointDisplay }}</span>
|
||||
<span>{{ diskSizeDisplay }}</span>
|
||||
<span>{{ diskAvailableDisplay }}</span>
|
||||
<span class="disk-percent-pill">{{ diskPercentDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-center gap-3 mt-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.networkLabel') }} ({{ currentServerStatus?.netInterface || '...' }}):</label>
|
||||
<div class="network-values flex items-center justify-start gap-4">
|
||||
<span class="rate down inline-flex items-center gap-1 text-green-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-down w-3 text-center"></i>
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</span>
|
||||
</span>
|
||||
<span class="rate up inline-flex items-center gap-1 text-orange-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-up w-3 text-center"></i>
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPU 型号 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuModelLabel') }}</label>
|
||||
<span class="cpu-model-value truncate text-left" :title="displayCpuModel">{{ displayCpuModel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作系统名称 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.osLabel') }}</label>
|
||||
<span class="os-name-value truncate text-left" :title="displayOsName">{{ displayOsName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 资源使用率分组 -->
|
||||
<div class="resource-monitor-group grid gap-3 mb-3">
|
||||
<!-- CPU 使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="16"
|
||||
color="#3b82f6"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<!-- 移除 w-12 和 text-right 以实现左对齐 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内存使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.memoryLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayMemPercent"
|
||||
:stroke-width="16"
|
||||
color="#22c55e"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ memDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- swap -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.swapLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="16"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#6b7280'"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.diskLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayDiskPercent"
|
||||
:stroke-width="16"
|
||||
color="#a855f7"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 网络速率,仅在有活动会话且有数据时显示 -->
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-center gap-3 mt-2">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.networkLabel') }} ({{ currentServerStatus?.netInterface || '...' }}):</label>
|
||||
<div class="network-values flex items-center justify-start gap-4"> <!-- 减小间距 -->
|
||||
<span class="rate down inline-flex items-center gap-1 text-green-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-down w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</span>
|
||||
</span>
|
||||
<span class="rate up inline-flex items-center gap-1 text-orange-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-up w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</span>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-start gap-3 mt-2">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.totalTrafficLabel') }}:</label>
|
||||
<div class="flex flex-col gap-1.5 text-xs">
|
||||
@@ -158,216 +200,518 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 图表组件 -->
|
||||
<!-- 仅当有活动会话且有数据时渲染图表 -->
|
||||
|
||||
<StatusCharts v-if="activeSessionId && currentServerStatus" :server-status="currentServerStatus" :active-session-id="activeSessionId" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref, computed, watch, type PropType, nextTick } from 'vue';
|
||||
import { computed, ref, watch, type CSSProperties, type PropType, nextTick } from 'vue';
|
||||
import { ElProgress } from 'element-plus';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import StatusCharts from './StatusCharts.vue';
|
||||
import { useSessionStore } from '../stores/session.store'; // 注入 sessionStore
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
|
||||
import { useConnectionsStore } from '../stores/connections.store'; // 导入连接 store
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // + 导入通知 store
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import type { ServerStatus } from '../types/server.types';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessionStore = useSessionStore();
|
||||
const settingsStore = useSettingsStore(); // 实例化设置 store
|
||||
const connectionsStore = useConnectionsStore(); // 实例化连接 store
|
||||
const uiNotificationsStore = useUiNotificationsStore(); // + 实例化通知 store
|
||||
const { sessions } = storeToRefs(sessionStore); // 获取响应式的 sessions
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore); // 获取 IP 显示设置
|
||||
const settingsStore = useSettingsStore();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
const { sessions } = storeToRefs(sessionStore);
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore);
|
||||
const isSwitchingSession = ref(false);
|
||||
|
||||
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps({
|
||||
activeSessionId: {
|
||||
type: String as PropType<string | null>,
|
||||
required: false, // 允许为 null
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// --- Computed properties to get current session data ---
|
||||
const currentSessionState = computed(() => {
|
||||
return props.activeSessionId ? sessions.value.get(props.activeSessionId) : null;
|
||||
});
|
||||
const currentSessionState = computed(() => (props.activeSessionId ? sessions.value.get(props.activeSessionId) : null));
|
||||
const currentServerStatus = computed<ServerStatus | null>(() => currentSessionState.value?.statusMonitorManager?.serverStatus?.value ?? null);
|
||||
|
||||
const currentServerStatus = computed<ServerStatus | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.serverStatus?.value ?? null;
|
||||
});
|
||||
const displayCpuPercent = computed(() => currentServerStatus.value?.cpuPercent ?? 0);
|
||||
const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent ?? 0);
|
||||
const currentStatusError = computed<string | null>(() => currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null);
|
||||
|
||||
// --- 计算属性,用于绑定到进度条宽度 ---
|
||||
// 始终返回当前状态的百分比。动画由 CSS 类控制。
|
||||
const displayCpuPercent = computed(() => {
|
||||
return currentServerStatus.value?.cpuPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayMemPercent = computed(() => {
|
||||
return currentServerStatus.value?.memPercent ?? 0;
|
||||
});
|
||||
|
||||
const displaySwapPercent = computed(() => {
|
||||
return currentServerStatus.value?.swapPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayDiskPercent = computed(() => {
|
||||
return currentServerStatus.value?.diskPercent ?? 0;
|
||||
});
|
||||
|
||||
const currentStatusError = computed<string | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null;
|
||||
});
|
||||
|
||||
// --- 缓存逻辑保持不变 ---
|
||||
const cachedCpuModel = ref<string | null>(null);
|
||||
const cachedOsName = ref<string | null>(null);
|
||||
|
||||
// --- Watcher for caching CPU Model and OS Name ---
|
||||
// 现在监听 currentServerStatus
|
||||
watch(currentServerStatus, (newData) => {
|
||||
if (newData) {
|
||||
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
|
||||
cachedCpuModel.value = newData.cpuModel;
|
||||
}
|
||||
if (newData.osName !== undefined && newData.osName !== null && newData.osName !== '') {
|
||||
cachedOsName.value = newData.osName;
|
||||
}
|
||||
watch(currentServerStatus, newData => {
|
||||
if (!newData) return;
|
||||
if (newData.cpuModel) {
|
||||
cachedCpuModel.value = newData.cpuModel;
|
||||
}
|
||||
if (newData.osName) {
|
||||
cachedOsName.value = newData.osName;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// --- 监听 activeSessionId 变化以处理会话切换状态 ---
|
||||
watch(() => props.activeSessionId, async (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
isSwitchingSession.value = true;
|
||||
await nextTick(); // 等待DOM更新(currentServerStatus已改变,displayPercent们会返回0)
|
||||
await nextTick();
|
||||
isSwitchingSession.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Computed properties for display ---
|
||||
const displayCpuModel = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
|
||||
const displayOsName = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
const displayCpuModel = computed(() => (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable'));
|
||||
const displayOsName = computed(() => (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable'));
|
||||
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
|
||||
};
|
||||
|
||||
const formatKbToGb = (kb?: number): string => {
|
||||
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
|
||||
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
const formatCompactBytes = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes.toFixed(1)} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
const formatStorageSizeFromKb = (kb?: number, compact = false): string => {
|
||||
if (kb === undefined || kb === null || isNaN(kb)) return t('statusMonitor.notAvailable');
|
||||
const units = compact ? ['KB', 'M', 'G', 'T'] : ['KB', t('statusMonitor.megaBytes'), t('statusMonitor.gigaBytes'), 'TB'];
|
||||
let value = kb;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
// 辅助函数,用于在需要时将 MB 格式化为 GB
|
||||
const formatMemorySize = (mb?: number): string => {
|
||||
if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
|
||||
return `${value} ${t('statusMonitor.megaBytes')}`;
|
||||
} else {
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
}
|
||||
if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
return `${mb.toFixed(1)} ${t('statusMonitor.megaBytes')}`;
|
||||
}
|
||||
return `${(mb / 1024).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
};
|
||||
|
||||
const memDisplay = computed(() => {
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
|
||||
});
|
||||
|
||||
const diskDisplay = computed(() => {
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
|
||||
});
|
||||
|
||||
const swapDisplay = computed(() => {
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
// 仅当交换空间总量 > 0 时显示详细信息
|
||||
if (total === 0) {
|
||||
return t('statusMonitor.swapNotAvailable'); // 或更具体的消息
|
||||
}
|
||||
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
const total = currentServerStatus.value?.swapTotal ?? 0;
|
||||
const used = currentServerStatus.value?.swapUsed ?? 0;
|
||||
if (total === 0) {
|
||||
return t('statusMonitor.swapNotAvailable');
|
||||
}
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
});
|
||||
|
||||
const memoryTotalValue = computed(() => currentServerStatus.value?.memTotal ?? 0);
|
||||
const memoryUsedValue = computed(() => currentServerStatus.value?.memUsed ?? 0);
|
||||
const memoryCachedValue = computed(() => currentServerStatus.value?.memCached ?? 0);
|
||||
const memoryFreeValue = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (data?.memFree !== undefined) {
|
||||
return data.memFree;
|
||||
}
|
||||
if (data?.memTotal !== undefined && data?.memUsed !== undefined) {
|
||||
return Math.max(data.memTotal - data.memUsed - (data.memCached ?? 0), 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const memoryTotalDisplay = computed(() => formatMemorySize(currentServerStatus.value?.memTotal));
|
||||
const memoryPercentDisplay = computed(() => `${Math.round(currentServerStatus.value?.memPercent ?? 0)}%`);
|
||||
|
||||
const memoryRingStyle = computed<CSSProperties>(() => {
|
||||
const total = memoryTotalValue.value;
|
||||
if (total <= 0) {
|
||||
return { background: 'conic-gradient(#2d3748 0% 100%)' };
|
||||
}
|
||||
|
||||
const usedPercent = Math.min(100, (memoryUsedValue.value / total) * 100);
|
||||
const cachedPercent = Math.min(100 - usedPercent, (memoryCachedValue.value / total) * 100);
|
||||
const usedEnd = usedPercent;
|
||||
const cacheEnd = usedPercent + cachedPercent;
|
||||
|
||||
return {
|
||||
background: `conic-gradient(#df5a5a 0 ${usedEnd}%, #8f96a3 ${usedEnd}% ${cacheEnd}%, #35b36f ${cacheEnd}% 100%)`,
|
||||
};
|
||||
});
|
||||
|
||||
const memoryStatItems = computed(() => [
|
||||
{ key: 'used', label: t('statusMonitor.memoryUsedStat'), value: formatMemorySize(memoryUsedValue.value) },
|
||||
{ key: 'cached', label: t('statusMonitor.memoryCachedStat'), value: formatMemorySize(memoryCachedValue.value) },
|
||||
{ key: 'free', label: t('statusMonitor.memoryFreeStat'), value: formatMemorySize(memoryFreeValue.value) },
|
||||
]);
|
||||
|
||||
const diskUsageDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) {
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
return `${formatStorageSizeFromKb(data.diskUsed, true)} / ${formatStorageSizeFromKb(data.diskTotal, true)}`;
|
||||
});
|
||||
|
||||
const diskUsageFillStyle = computed<CSSProperties>(() => ({
|
||||
height: `${Math.max(6, Math.min(100, currentServerStatus.value?.diskPercent ?? 0))}%`,
|
||||
}));
|
||||
|
||||
const diskDeviceDisplay = computed(() => currentServerStatus.value?.diskDevice || t('statusMonitor.notAvailable'));
|
||||
const diskFsTypeDisplay = computed(() => currentServerStatus.value?.diskFsType || t('statusMonitor.notAvailable'));
|
||||
const diskReadRateDisplay = computed(() => formatCompactBytes(currentServerStatus.value?.diskReadRate));
|
||||
const diskWriteRateDisplay = computed(() => formatCompactBytes(currentServerStatus.value?.diskWriteRate));
|
||||
const diskMountPointDisplay = computed(() => currentServerStatus.value?.diskMountPoint || t('statusMonitor.notAvailable'));
|
||||
const diskSizeDisplay = computed(() => formatStorageSizeFromKb(currentServerStatus.value?.diskTotal, true));
|
||||
const diskAvailableDisplay = computed(() => formatStorageSizeFromKb(currentServerStatus.value?.diskAvailable, true));
|
||||
const diskPercentDisplay = computed(() => `${Math.round(currentServerStatus.value?.diskPercent ?? 0)}%`);
|
||||
|
||||
const sessionIpAddress = computed(() => {
|
||||
const sessionState = currentSessionState.value;
|
||||
if (sessionState && sessionState.connectionId) {
|
||||
// 直接从 connectionsStore 的 connections 数组中查找
|
||||
const connectionIdAsNumber = parseInt(sessionState.connectionId, 10);
|
||||
if (isNaN(connectionIdAsNumber)) {
|
||||
return null; // 如果 connectionId 不是有效的数字,则返回 null
|
||||
}
|
||||
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber);
|
||||
return connectionInfo?.host || null;
|
||||
if (!sessionState?.connectionId) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
const connectionIdAsNumber = parseInt(sessionState.connectionId, 10);
|
||||
if (isNaN(connectionIdAsNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber);
|
||||
return connectionInfo?.host || null;
|
||||
});
|
||||
|
||||
const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
if (!ipAddress) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ipAddress);
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制!'));
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制'));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy IP address: ', err);
|
||||
uiNotificationsStore.showError(t('statusMonitor.copyIpError', '复制 IP 失败'));
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
::v-deep(.el-progress-bar__outer) {
|
||||
background-color: var(--header-bg-color) !important;
|
||||
background-color: var(--header-bg-color) !important;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress .el-progress-bar__inner) {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress.no-transition .el-progress-bar__inner) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
::v-deep(.el-progress-bar__innerText) {
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
top: -0.5px;
|
||||
top: -0.5px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: linear-gradient(180deg, rgba(36, 39, 43, 0.96), rgba(28, 30, 34, 0.96));
|
||||
border: 1px solid rgba(103, 232, 149, 0.12);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.status-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-card__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-card__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
color: #4ade80;
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.status-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(74, 222, 128, 0.28);
|
||||
background: rgba(24, 70, 46, 0.35);
|
||||
color: #d1fae5;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memory-card__content {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memory-ring {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.memory-ring::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 12px;
|
||||
border-radius: 999px;
|
||||
background: #16181c;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.memory-ring__center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #f5f7fa;
|
||||
}
|
||||
|
||||
.memory-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-stat,
|
||||
.disk-rate-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.memory-stat__label,
|
||||
.disk-rate-card__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary-color, #9ca3af);
|
||||
}
|
||||
|
||||
.memory-stat__value,
|
||||
.disk-rate-card__value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.memory-stat__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.memory-stat__dot--used {
|
||||
background: #df5a5a;
|
||||
}
|
||||
|
||||
.memory-stat__dot--cached {
|
||||
background: #8f96a3;
|
||||
}
|
||||
|
||||
.memory-stat__dot--free {
|
||||
background: #35b36f;
|
||||
}
|
||||
|
||||
.disk-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-secondary-color, #9ca3af);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.disk-device {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e5e7eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.disk-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.disk-type__value {
|
||||
color: #f59e0b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.disk-card__body {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.disk-usage-tube {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.disk-usage-tube__inner {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 68px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(210, 214, 219, 0.95));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.disk-usage-tube__fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(120, 187, 117, 0.88), rgba(98, 161, 95, 0.98));
|
||||
}
|
||||
|
||||
.disk-rate-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.disk-table {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.disk-table__header,
|
||||
.disk-table__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr 1fr 0.9fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.disk-table__header {
|
||||
color: var(--text-secondary-color, #9ca3af);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.disk-table__row {
|
||||
color: #f8fafc;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.disk-mount-pill,
|
||||
.disk-percent-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.disk-mount-pill {
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
color: #d1fae5;
|
||||
border: 1px solid rgba(74, 222, 128, 0.16);
|
||||
}
|
||||
|
||||
.disk-percent-pill {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
color: #dcfce7;
|
||||
border: 1px solid rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.memory-card__content,
|
||||
.disk-card__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.memory-ring,
|
||||
.disk-usage-tube {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.memory-stats-grid,
|
||||
.disk-rate-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.disk-meta-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.disk-table__header,
|
||||
.disk-table__row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,37 +64,38 @@ const showConnectionListPopup = ref(false); // 连接列表弹出状态
|
||||
const draggableSessions = ref<SessionTabInfoWithStatus[]>([]); // + Local state for draggable
|
||||
const showTransferProgressModal = ref(false); // 控制传输进度模态框的显示状态
|
||||
|
||||
const activeSessionState = computed(() => {
|
||||
if (!props.activeSessionId) {
|
||||
return null;
|
||||
}
|
||||
const openConnectionPicker = () => {
|
||||
showConnectionListPopup.value = true;
|
||||
};
|
||||
|
||||
return sessionStore.sessions.get(props.activeSessionId) ?? null;
|
||||
});
|
||||
const getSessionAtIndex = (index: number) => draggableSessions.value[index] ?? null;
|
||||
|
||||
const activeConnectionInfo = computed(() => {
|
||||
const activeSession = activeSessionState.value;
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
const isGroupStart = (index: number) => {
|
||||
const currentSession = getSessionAtIndex(index);
|
||||
const previousSession = getSessionAtIndex(index - 1);
|
||||
|
||||
return connectionsStore.connections.find((connection) => connection.id === Number(activeSession.connectionId)) ?? null;
|
||||
});
|
||||
return Boolean(currentSession && (!previousSession || previousSession.connectionId !== currentSession.connectionId));
|
||||
};
|
||||
|
||||
const canAddTerminalToActiveConnection = computed(() => activeConnectionInfo.value?.type === 'SSH');
|
||||
const isGroupEnd = (index: number) => {
|
||||
const currentSession = getSessionAtIndex(index);
|
||||
const nextSession = getSessionAtIndex(index + 1);
|
||||
|
||||
const openNewTerminalForActiveConnection = () => {
|
||||
const activeConnection = activeConnectionInfo.value;
|
||||
if (!activeConnection || activeConnection.type !== 'SSH') {
|
||||
showConnectionListPopup.value = true;
|
||||
return Boolean(currentSession && (!nextSession || nextSession.connectionId !== currentSession.connectionId));
|
||||
};
|
||||
|
||||
const getConnectionInfoById = (connectionId: string) =>
|
||||
connectionsStore.connections.find((connection) => connection.id === Number(connectionId)) ?? null;
|
||||
|
||||
const canOpenSiblingTerminal = (connectionId: string) => getConnectionInfoById(connectionId)?.type === 'SSH';
|
||||
|
||||
const openNewTerminalForConnection = (connectionId: string) => {
|
||||
const connectionInfo = getConnectionInfoById(connectionId);
|
||||
if (!connectionInfo || connectionInfo.type !== 'SSH') {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStore.handleOpenNewSession(activeConnection.id);
|
||||
};
|
||||
|
||||
const openConnectionPicker = () => {
|
||||
showConnectionListPopup.value = true;
|
||||
sessionStore.handleOpenNewSession(connectionInfo.id);
|
||||
};
|
||||
|
||||
// + Watch prop changes to update local state
|
||||
@@ -477,52 +478,74 @@ onBeforeUnmount(() => {
|
||||
animation="150"
|
||||
:disabled="props.isMobile"
|
||||
>
|
||||
<template #item="{ element: session }">
|
||||
<template #item="{ element: session, index }">
|
||||
<li
|
||||
:key="session.sessionId"
|
||||
:class="['flex items-center px-3 h-full cursor-pointer border-r border-border transition-colors duration-150 relative group',
|
||||
session.sessionId === activeSessionId ? 'bg-background text-foreground' : 'bg-header text-text-secondary hover:bg-border']"
|
||||
@click="activateSession(session.sessionId)"
|
||||
@contextmenu.prevent="showContextMenu($event, session.sessionId)"
|
||||
@touchstart="handleTouchStart($event, session.sessionId)"
|
||||
@touchend="handleTouchEnd($event)"
|
||||
class="flex h-full flex-shrink-0 items-stretch py-1 pl-1"
|
||||
@dragstart="handleDragStart"
|
||||
:title="session.connectionName"
|
||||
>
|
||||
<!-- Status dot -->
|
||||
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
|
||||
session.isMarkedForSuspend ? 'bg-blue-500' : // +++ 如果已标记待挂起,则为蓝色 +++
|
||||
session.status === 'connected' ? 'bg-green-500' :
|
||||
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
|
||||
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
|
||||
<span class="truncate text-sm" style="transform: translateY(-1px);">{{ session.connectionName }}</span>
|
||||
<span
|
||||
class="ml-2 inline-flex flex-shrink-0 items-center rounded-full border border-border px-1.5 py-0.5 text-[11px] leading-none text-text-secondary"
|
||||
:title="t('terminalTabBar.terminalBadge', { index: session.terminalIndex })"
|
||||
>
|
||||
{{ session.terminalIndex }}
|
||||
</span>
|
||||
<button class="ml-2 p-0.5 rounded-full text-text-secondary hover:bg-border hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
:class="{'text-foreground hover:bg-header': session.sessionId === activeSessionId}"
|
||||
@click="closeSession($event, session.sessionId)" :title="$t('tabs.closeTabTooltip')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex h-full items-stretch overflow-hidden rounded-md border border-border/70 bg-header/80 shadow-[0_0_0_1px_rgba(0,0,0,0.08)]">
|
||||
<div
|
||||
v-if="isGroupStart(index)"
|
||||
class="flex max-w-[160px] items-center border-r border-border/70 bg-black/15 px-2.5 text-xs font-semibold tracking-wide text-text-secondary"
|
||||
:title="session.connectionName"
|
||||
>
|
||||
<i class="fas fa-server mr-1.5 text-[10px] text-primary/80"></i>
|
||||
<span class="truncate">{{ session.connectionName }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'group flex h-full items-center px-3 transition-colors duration-150 relative',
|
||||
session.sessionId === activeSessionId
|
||||
? 'bg-background text-foreground'
|
||||
: 'bg-header text-text-secondary hover:bg-border',
|
||||
!isGroupStart(index) ? 'border-l border-border/60' : '',
|
||||
]"
|
||||
@click="activateSession(session.sessionId)"
|
||||
@contextmenu.prevent="showContextMenu($event, session.sessionId)"
|
||||
@touchstart="handleTouchStart($event, session.sessionId)"
|
||||
@touchend="handleTouchEnd($event)"
|
||||
:title="`${session.connectionName} / ${t('terminalTabBar.terminalBadge', { index: session.terminalIndex })}`"
|
||||
>
|
||||
<span :class="['w-2 h-2 rounded-full mr-2 flex-shrink-0',
|
||||
session.isMarkedForSuspend ? 'bg-blue-500' :
|
||||
session.status === 'connected' ? 'bg-green-500' :
|
||||
session.status === 'connecting' ? 'bg-yellow-500 animate-pulse' :
|
||||
session.status === 'disconnected' ? 'bg-red-500' : 'bg-gray-400']"></span>
|
||||
<span class="whitespace-nowrap text-xs font-medium">
|
||||
{{ t('terminalTabBar.terminalBadge', { index: session.terminalIndex }) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-border hover:text-foreground group-hover:opacity-100"
|
||||
:class="{ 'text-foreground hover:bg-header': session.sessionId === activeSessionId }"
|
||||
@click="closeSession($event, session.sessionId)"
|
||||
:title="$t('tabs.closeTabTooltip')"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isGroupEnd(index) && canOpenSiblingTerminal(session.connectionId)"
|
||||
type="button"
|
||||
class="flex h-full items-center border-l border-border/60 bg-black/10 px-2.5 text-text-secondary transition-colors duration-150 hover:bg-border hover:text-foreground"
|
||||
@click.stop="openNewTerminalForConnection(session.connectionId)"
|
||||
:title="t('terminalTabBar.newTerminalTooltip')"
|
||||
>
|
||||
<i class="fas fa-plus text-[11px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
<!-- Add Terminal Button -->
|
||||
<button
|
||||
class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
|
||||
@click="openNewTerminalForActiveConnection"
|
||||
:title="canAddTerminalToActiveConnection ? t('terminalTabBar.newTerminalTooltip') : t('tabs.newTabTooltip')">
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
<!-- Open Connection Picker Button -->
|
||||
<button class="flex items-center justify-center px-3 h-full border-border text-text-secondary hover:bg-border hover:text-foreground transition-colors duration-150 flex-shrink-0"
|
||||
@click="openConnectionPicker" :title="t('terminalTabBar.openConnectionPickerTooltip')">
|
||||
<i class="fas fa-server text-sm"></i>
|
||||
<i class="fas fa-plus text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
|
||||
@@ -594,6 +594,18 @@
|
||||
"megaBytes": "MB",
|
||||
"gigaBytes": "GB",
|
||||
"swapNotAvailable": "Swap Unavailable",
|
||||
"memoryCardTitle": "Memory",
|
||||
"memoryUsedStat": "Used",
|
||||
"memoryCachedStat": "Cached",
|
||||
"memoryFreeStat": "Free",
|
||||
"diskCardTitle": "Disk",
|
||||
"diskTypeLabel": "Type",
|
||||
"diskReadRateLabel": "Read/s",
|
||||
"diskWriteRateLabel": "Write/s",
|
||||
"diskMountLabel": "Mount",
|
||||
"diskSizeLabel": "Size",
|
||||
"diskAvailableLabel": "Available",
|
||||
"diskUsedPercentLabel": "Used%",
|
||||
"cpuUsageTitle": "CPU Usage",
|
||||
"memoryUsageTitleUnit": "Memory Usage ({unit})",
|
||||
"networkSpeedTitleUnit": "Network Speed ({unit})",
|
||||
|
||||
@@ -1228,6 +1228,18 @@
|
||||
"osLabel": "OS:",
|
||||
"swapLabel": "スワップ:",
|
||||
"swapNotAvailable": "スワップは利用できません",
|
||||
"memoryCardTitle": "メモリ",
|
||||
"memoryUsedStat": "使用中",
|
||||
"memoryCachedStat": "キャッシュ",
|
||||
"memoryFreeStat": "空き",
|
||||
"diskCardTitle": "ディスク",
|
||||
"diskTypeLabel": "種類",
|
||||
"diskReadRateLabel": "読込/s",
|
||||
"diskWriteRateLabel": "書込/s",
|
||||
"diskMountLabel": "マウント",
|
||||
"diskSizeLabel": "サイズ",
|
||||
"diskAvailableLabel": "利用可能",
|
||||
"diskUsedPercentLabel": "使用率",
|
||||
"title": "サーバー状態",
|
||||
"cpuUsageTitle": "CPU使用率",
|
||||
"memoryUsageTitleUnit": "メモリ使用状況 ({unit})",
|
||||
|
||||
@@ -594,6 +594,18 @@
|
||||
"megaBytes": "MB",
|
||||
"gigaBytes": "GB",
|
||||
"swapNotAvailable": "Swap 不可用",
|
||||
"memoryCardTitle": "内存",
|
||||
"memoryUsedStat": "已用",
|
||||
"memoryCachedStat": "缓存",
|
||||
"memoryFreeStat": "空闲",
|
||||
"diskCardTitle": "磁盘",
|
||||
"diskTypeLabel": "类型",
|
||||
"diskReadRateLabel": "读/s",
|
||||
"diskWriteRateLabel": "写/s",
|
||||
"diskMountLabel": "挂载",
|
||||
"diskSizeLabel": "大小",
|
||||
"diskAvailableLabel": "可用",
|
||||
"diskUsedPercentLabel": "已用%",
|
||||
"cpuUsageTitle": "CPU 使用率",
|
||||
"memoryUsageTitleUnit": "内存使用情况 ({unit})",
|
||||
"networkSpeedTitleUnit": "网络速度 ({unit})",
|
||||
|
||||
@@ -4,9 +4,17 @@ export interface ServerStatus {
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
memFree?: number; // MB
|
||||
memCached?: number; // MB
|
||||
diskPercent?: number;
|
||||
diskUsed?: number; // KB
|
||||
diskTotal?: number; // KB
|
||||
diskAvailable?: number; // KB
|
||||
diskMountPoint?: string;
|
||||
diskFsType?: string;
|
||||
diskDevice?: string;
|
||||
diskReadRate?: number; // Bytes/sec
|
||||
diskWriteRate?: number; // Bytes/sec
|
||||
cpuModel?: string;
|
||||
swapPercent?: number;
|
||||
swapUsed?: number; // MB
|
||||
|
||||
Reference in New Issue
Block a user