From 1ebf05940a2835dd807178e5996452f228e1daa8 Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sun, 20 Apr 2025 19:32:51 +0800 Subject: [PATCH] update --- .../backend/src/services/docker.service.ts | 158 +++++--- packages/backend/src/websocket.ts | 356 ++++++++++++------ .../frontend/src/components/DockerManager.vue | 312 ++++++++------- 3 files changed, 503 insertions(+), 323 deletions(-) diff --git a/packages/backend/src/services/docker.service.ts b/packages/backend/src/services/docker.service.ts index 6cb2363..d31b365 100644 --- a/packages/backend/src/services/docker.service.ts +++ b/packages/backend/src/services/docker.service.ts @@ -8,24 +8,37 @@ const execAsync = promisify(exec); // --- Interfaces (与前端 DockerManager.vue 中的定义保持一致) --- // 理想情况下,这些类型应该放在共享的 types 包中 interface PortInfo { - IP?: string; - PrivatePort: number; - PublicPort?: number; - Type: 'tcp' | 'udp' | string; + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: 'tcp' | 'udp' | string; +} + +// 与前端一致的 Stats 接口 +interface DockerStats { + ID: string; // Docker stats 返回的是 ID + Name: string; + CPUPerc: string; + MemUsage: string; + MemPerc: string; + NetIO: string; + BlockIO: string; + PIDs: string; } interface DockerContainer { - Id: string; - Names: string[]; - Image: string; - ImageID: string; - Command: string; - Created: number; - State: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' | string; - Status: string; - Ports: PortInfo[]; - Labels: Record; - // 根据 `docker ps --format '{{json .}}'` 的输出添加其他需要的字段 + Id: string; // docker ps 返回的是 Id + Names: string[]; + Image: string; + ImageID: string; + Command: string; + Created: number; + State: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' | string; + Status: string; + Ports: PortInfo[]; + Labels: Record; + stats?: DockerStats | null; // 添加可选的 stats 字段 + // 根据 `docker ps --format '{{json .}}'` 的输出添加其他需要的字段 } // 定义命令类型 @@ -46,7 +59,7 @@ export class DockerService { try { // 尝试执行一个简单的 docker 命令,如 docker version - await execAsync('docker version', { timeout: 5000 }); // 5秒超时 + await execAsync('docker version', { timeout: 2000 }); // 5秒超时 console.log('[DockerService] Docker is available.'); // Use console.log this.isDockerAvailableCache = true; return true; @@ -61,51 +74,84 @@ export class DockerService { * 获取所有 Docker 容器的状态 (包括已停止的)。 */ async getContainerStatus(): Promise<{ available: boolean; containers: DockerContainer[] }> { - const available = await this.checkDockerAvailability(); - if (!available) { - return { available: false, containers: [] }; - } + const available = await this.checkDockerAvailability(); + if (!available) { + return { available: false, containers: [] }; + } - try { - // 使用 --format '{{json .}}' 获取每个容器的 JSON 输出 - // 使用 --no-trunc 避免 ID 被截断 - const { stdout } = await execAsync("docker ps -a --no-trunc --format '{{json .}}'", { timeout: this.commandTimeout }); + let allContainers: DockerContainer[] = []; + const statsMap = new Map(); - // stdout 包含多行 JSON,每行一个容器 - const lines = stdout.trim().split('\n'); - const containers: DockerContainer[] = lines - .map(line => { - try { - // Docker 的 JSON 输出有时可能不是严格的 JSON (例如 Names 字段),需要处理 - // 尝试更健壮的解析或预处理 - const data = JSON.parse(line); - // 手动解析 Names 字段 (docker ps format 的 Names 是逗号分隔的) - if (typeof data.Names === 'string') { - data.Names = data.Names.split(','); - } - // 解析 Ports 字段 (可能需要更复杂的逻辑来匹配前端接口) - // docker ps format 的 Ports 字段格式比较复杂,直接用 JSON 可能不包含所有信息 - // 这里暂时依赖 JSON 输出,如果需要更详细的端口信息,可能需要 `docker inspect` - // 假设 JSON 输出的 Ports 字段是符合我们接口的数组 (这可能需要调整命令或后端处理) - if (!Array.isArray(data.Ports)) { - data.Ports = []; // 如果 Ports 不是数组,置为空数组 - } + // 1. 获取所有容器的基本信息 + try { + const { stdout: psStdout } = await execAsync("docker ps -a --no-trunc --format '{{json .}}'", { timeout: this.commandTimeout }); + const lines = psStdout.trim().split('\n'); + allContainers = lines + .map(line => { + try { + const data = JSON.parse(line); + if (typeof data.Names === 'string') { + data.Names = data.Names.split(','); + } + if (!Array.isArray(data.Ports)) { + data.Ports = []; + } + // 初始化 stats 为 null + data.stats = null; + return data as DockerContainer; + } catch (parseError) { + console.error(`[DockerService] Failed to parse container JSON line: ${line}`, { error: parseError }); + return null; + } + }) + .filter((container): container is DockerContainer => container !== null); + } catch (error: any) { + console.error('[DockerService] Failed to execute "docker ps"', { error: error.message, stderr: error.stderr }); + this.isDockerAvailableCache = false; + return { available: false, containers: [] }; + } - return data as DockerContainer; - } catch (parseError) { - console.error(`[DockerService] Failed to parse container JSON line: ${line}`, { error: parseError }); // Use console.error - return null; + // 2. 获取正在运行容器的统计信息 + try { + // --no-stream 获取一次性快照 + const { stdout: statsStdout } = await execAsync("docker stats --no-stream --format '{{json .}}'", { timeout: this.commandTimeout }); + const statsLines = statsStdout.trim().split('\n'); + statsLines.forEach(line => { + try { + const statsData = JSON.parse(line) as DockerStats; + // docker stats 返回的 ID 可能与 docker ps 的 Id 字段匹配 + // 注意:docker stats 可能返回短 ID,而 docker ps -a --no-trunc 返回长 ID + // 实际应用中可能需要处理 ID 匹配问题,这里假设它们能直接匹配或通过 Name 匹配 + // 为了简化,我们优先使用 ID 匹配 + if (statsData.ID) { + // 尝试直接用 ID 作为 key (可能是短 ID) + // 如果 statsData.ID 是短 ID,而 allContainers 的 Id 是长 ID,这里可能匹配不上 + // 一个更健壮的方法是先从 allContainers 构建一个 Name -> ID 的映射 + // 但这里我们先简化处理,假设 ID 能匹配上 + statsMap.set(statsData.ID, statsData); + // 也可以考虑用 Name 匹配作为备选 + // if (statsData.Name) statsMap.set(statsData.Name, statsData); + } + } catch (parseError) { + console.error(`[DockerService] Failed to parse stats JSON line: ${line}`, { error: parseError }); + } + }); + } catch (error: any) { + // 获取 stats 失败不应阻止返回容器列表,只是 stats 会是 null + console.warn('[DockerService] Failed to execute "docker stats"', { error: error.message, stderr: error.stderr }); + } + + // 3. 合并统计信息到容器列表 + allContainers.forEach(container => { + // 尝试用容器的长 ID 或短 ID (前12位) 或 Name 去匹配 statsMap + const shortId = container.Id.substring(0, 12); + const stats = statsMap.get(container.Id) || statsMap.get(shortId) || statsMap.get(container.Names[0]); // 尝试多种匹配方式 + if (stats) { + container.stats = stats; } - }) - .filter((container): container is DockerContainer => container !== null); // 过滤掉解析失败的行 + }); - return { available: true, containers }; - } catch (error: any) { - console.error('[DockerService] Failed to execute "docker ps"', { error: error.message, stderr: error.stderr }); // Use console.error - // 如果执行 docker ps 失败,可能意味着 Docker 服务出问题了 - this.isDockerAvailableCache = false; // 重置可用性缓存 - return { available: false, containers: [] }; - } + return { available: true, containers: allContainers }; } /** diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 6fbf02d..812ea8c 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -8,6 +8,7 @@ import { decrypt } from './utils/crypto'; import { SftpService } from './services/sftp.service'; import { StatusMonitorService } from './services/status-monitor.service'; import * as SshService from './services/ssh.service'; +import { DockerService } from './services/docker.service'; // 导入 DockerService import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType @@ -29,6 +30,7 @@ export interface ClientState { // 导出以便 Service 可以导入 dbConnectionId: number; sftp?: SFTPWrapper; // 添加 sftp 实例 (由 SftpService 管理) statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理) + dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID ipAddress?: string; // 添加 IP 地址字段 } @@ -42,18 +44,36 @@ interface PortInfo { } // --- End FIX --- -// --- NEW: Stats Interface (Ensure this matches frontend) --- +// --- Docker Interfaces (Ensure this matches frontend and DockerService) --- +// Stats 接口 interface DockerStats { - ID: string; - Name: string; - CPUPerc: string; - MemUsage: string; - MemPerc: string; - NetIO: string; - BlockIO: string; - PIDs: string; + ID: string; // 来自 docker stats + Name: string; // 来自 docker stats + CPUPerc: string; // 来自 docker stats + MemUsage: string; // 来自 docker stats + MemPerc: string; // 来自 docker stats + NetIO: string; // 来自 docker stats + BlockIO: string; // 来自 docker stats + PIDs: string; // 来自 docker stats } +// Container 接口 (包含 stats) +interface DockerContainer { + id: string; // 使用小写 id 以匹配前端期望 + Names: string[]; + Image: string; + ImageID: string; + Command: string; + Created: number; + State: string; + Status: string; + Ports: PortInfo[]; + Labels: Record; + stats?: DockerStats | null; // 可选的 stats 字段 +} +// --- End Docker Interfaces --- + + // --- 新增:解析 Ports 字符串的辅助函数 --- function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined if (!portsString) { @@ -123,6 +143,7 @@ export const clientStates = new Map(); // Export clientStat const sftpService = new SftpService(clientStates); const statusMonitorService = new StatusMonitorService(clientStates); const auditLogService = new AuditLogService(); // 实例化 AuditLogService +const dockerService = new DockerService(); // 实例化 DockerService (主要用于类型或未来可能的本地调用) /** * 清理指定会话 ID 关联的所有资源 @@ -145,10 +166,16 @@ const cleanupClientConnection = (sessionId: string | undefined) => { state.sshShellStream?.end(); // 结束 shell 流 state.sshClient?.end(); // 结束 SSH 客户端 - // 4. 从状态 Map 中移除 + // 4. 清理 Docker 状态轮询定时器 + if (state.dockerStatusIntervalId) { + clearInterval(state.dockerStatusIntervalId); + console.log(`WebSocket: Cleared Docker status interval for session ${sessionId}.`); + } + + // 5. 从状态 Map 中移除 clientStates.delete(sessionId); - // 5. 清除 WebSocket 上的 sessionId 关联 (可选,因为 ws 可能已关闭) + // 6. 清除 WebSocket 上的 sessionId 关联 (可选,因为 ws 可能已关闭) if (state.ws && state.ws.sessionId === sessionId) { delete state.ws.sessionId; } @@ -159,9 +186,146 @@ const cleanupClientConnection = (sessionId: string | undefined) => { } }; +// --- NEW: Reusable function to fetch remote Docker status with stats --- +const fetchRemoteDockerStatus = async (state: ClientState): Promise<{ available: boolean; containers: DockerContainer[] }> => { + if (!state || !state.sshClient) { + throw new Error('SSH client is not available in the current state.'); + } + + let allContainers: DockerContainer[] = []; + const statsMap = new Map(); + let isDockerCmdAvailable = true; // Assume available initially + + // 1. Get basic container info + try { + const psCommand = "docker ps -a --no-trunc --format '{{json .}}'"; + const { stdout: psStdout, stderr: psStderr } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + let stdout = ''; + let stderr = ''; + state.sshClient.exec(psCommand, { pty: false }, (err, stream) => { + if (err) return reject(err); + stream.on('data', (data: Buffer) => { stdout += data.toString(); }); + stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + stream.on('close', (code: number | null) => { + // Don't reject on non-zero code here, check stderr + resolve({ stdout, stderr }); + }); + stream.on('error', (execErr: Error) => reject(execErr)); + }); + }); + + if (psStderr.includes('command not found') || psStderr.includes('Cannot connect to the Docker daemon')) { + console.warn(`[fetchRemoteDockerStatus] Docker ps command failed on session ${state.ws.sessionId}. Docker unavailable. Stderr: ${psStderr}`); + isDockerCmdAvailable = false; + return { available: false, containers: [] }; + } else if (psStderr) { + console.warn(`[fetchRemoteDockerStatus] Docker ps command stderr on session ${state.ws.sessionId}: ${psStderr}`); + // Continue execution but log the warning + } + + + const lines = psStdout.trim().split('\n'); + allContainers = lines + .map(line => { + try { + const data = JSON.parse(line); + // Map raw data to DockerContainer interface (lowercase id) + const container: DockerContainer = { + id: data.ID, // Map ID to lowercase id + Names: typeof data.Names === 'string' ? data.Names.split(',') : (data.Names || []), + Image: data.Image || '', + ImageID: data.ImageID || '', + Command: data.Command || '', + Created: data.CreatedAt || 0, // Check if CreatedAt exists + State: data.State || 'unknown', + Status: data.Status || '', + Ports: parsePortsString(data.Ports), + Labels: data.Labels || {}, + stats: null // Initialize stats as null + }; + return container; + } catch (parseError) { + console.error(`[fetchRemoteDockerStatus] Failed to parse container JSON line for session ${state.ws.sessionId}: ${line}`, parseError); + return null; + } + }) + .filter((container): container is DockerContainer => container !== null); + + } catch (error: any) { + console.error(`[fetchRemoteDockerStatus] Error executing docker ps for session ${state.ws.sessionId}:`, error); + // Check if error indicates docker unavailable + const errorMessage = error.message || ''; + if (errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon')) { + isDockerCmdAvailable = false; + return { available: false, containers: [] }; + } + // Otherwise, throw to indicate a more general fetch error + throw new Error(`Failed to get remote Docker container list: ${errorMessage}`); + } + + // If docker ps failed indicating unavailability, return early + if (!isDockerCmdAvailable) { + return { available: false, containers: [] }; + } + + // 2. Get stats for running containers + try { + const statsCommand = "docker stats --no-stream --format '{{json .}}'"; + const { stdout: statsStdout, stderr: statsStderr } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + let stdout = ''; + let stderr = ''; + state.sshClient.exec(statsCommand, { pty: false }, (err, stream) => { + if (err) return reject(err); + stream.on('data', (data: Buffer) => { stdout += data.toString(); }); + stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + stream.on('close', (code: number | null) => { + // Don't reject on non-zero code, check stderr + resolve({ stdout, stderr }); + }); + stream.on('error', (execErr: Error) => reject(execErr)); + }); + }); + + if (statsStderr) { + // Log stats errors but don't necessarily fail the whole process + console.warn(`[fetchRemoteDockerStatus] Docker stats command stderr on session ${state.ws.sessionId}: ${statsStderr}`); + } + + const statsLines = statsStdout.trim().split('\n'); + statsLines.forEach(line => { + try { + const statsData = JSON.parse(line) as DockerStats; + if (statsData.ID) { + // Use the ID from stats data (usually short ID) as the key + statsMap.set(statsData.ID, statsData); + } + } catch (parseError) { + console.error(`[fetchRemoteDockerStatus] Failed to parse stats JSON line for session ${state.ws.sessionId}: ${line}`, parseError); + } + }); + } catch (error: any) { + // Failure to get stats is not critical, just log and continue + console.warn(`[fetchRemoteDockerStatus] Error executing docker stats for session ${state.ws.sessionId}:`, error); + } + + // 3. Merge stats into containers + allContainers.forEach(container => { + const shortId = container.id.substring(0, 12); // docker stats often uses short ID + const stats = statsMap.get(container.id) || statsMap.get(shortId); // Try matching long and short ID + if (stats) { + container.stats = stats; + } + }); + + return { available: true, containers: allContainers }; +}; +// --- End fetchRemoteDockerStatus function --- + + export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise => { // Make async const wss = new WebSocketServer({ noServer: true }); const db = await getDbInstance(); // 获取数据库实例 (use await and getDbInstance) + const DOCKER_STATUS_INTERVAL = 2000; // Poll Docker status every 2 seconds // --- 心跳检测 --- const heartbeatInterval = setInterval(() => { @@ -325,6 +489,58 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`); statusMonitorService.startStatusPolling(newSessionId); + // 8. Start Docker status polling + console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`); + const dockerIntervalId = setInterval(async () => { + const currentState = clientStates.get(newSessionId); // Re-fetch state + if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) { + console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`); + clearInterval(dockerIntervalId); + return; + } + try { + // console.log(`[Docker Polling] Fetching status for session ${newSessionId}...`); + const statusPayload = await fetchRemoteDockerStatus(currentState); + if (currentState.ws.readyState === WebSocket.OPEN) { + currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); + } + } catch (error: any) { + console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error); + // Optionally send error to client, or just log + // if (currentState.ws.readyState === WebSocket.OPEN) { + // currentState.ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Polling failed: ${error.message}` } })); + // } + } + }, DOCKER_STATUS_INTERVAL); + newState.dockerStatusIntervalId = dockerIntervalId; + + // 9. Trigger initial Docker status fetch immediately + (async () => { + const currentState = clientStates.get(newSessionId); + if (currentState && currentState.ws.readyState === WebSocket.OPEN) { + try { + console.log(`[Docker Initial Fetch] Fetching status for session ${newSessionId}...`); + const statusPayload = await fetchRemoteDockerStatus(currentState); + if (currentState.ws.readyState === WebSocket.OPEN) { + currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); + } + } catch (error: any) { + console.error(`[Docker Initial Fetch] Error fetching Docker status for session ${newSessionId}:`, error); + if (currentState.ws.readyState === WebSocket.OPEN) { + // Send specific error type for initial fetch failure + const errorMessage = error.message || 'Unknown error during initial fetch'; + const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon'); + if (isUnavailable) { + currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } })); + } else { + currentState.ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Initial Docker status fetch failed: ${errorMessage}` } })); + } + } + } + } + })(); + + } catch (shellError: any) { console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError); // 记录审计日志:打开 Shell 失败 @@ -340,7 +556,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re cleanupClientConnection(newSessionId); } - // 8. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后) + // 10. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后) sshClient.on('close', () => { console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`); cleanupClientConnection(newSessionId); @@ -393,106 +609,36 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re break; } - // --- NEW: Handle Docker Status Request --- + // --- REFACTORED: Handle Docker Status Request --- case 'docker:get_status': { - if (!state || !state.sshClient) { - console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`); - ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: 'SSH connection not active.' } })); + if (!state) { + console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动会话状态。`); + ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: 'Session state not found.' } })); return; } - console.log(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求...`); + if (!state.sshClient) { + console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`); + ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: 'SSH connection not active.' } })); + return; + } + console.log(`WebSocket: 处理来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求 (手动触发)...`); try { - // Execute docker ps command remotely - const command = "docker ps -a --no-trunc --format '{{json .}}'"; - const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - let stdout = ''; - let stderr = ''; - state.sshClient.exec(command, { pty: false }, (err, stream) => { // pty: false might be better for non-interactive commands - if (err) return reject(err); - stream.on('data', (data: Buffer) => { stdout += data.toString(); }); - stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); - stream.on('close', (code: number | null, signal: string | null) => { - if (code === 0) { - resolve({ stdout, stderr }); - } else { - // Check if stderr indicates docker not found or cannot connect - if (stderr.includes('command not found') || stderr.includes('Cannot connect to the Docker daemon')) { - console.warn(`WebSocket: 远程 Docker 命令 (${command}) 执行失败 (可能未安装或未运行) on session ${sessionId}. Stderr: ${stderr}`); - // Send specific 'unavailable' status back - ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } })); - // Resolve normally here as we handled the 'unavailable' case - resolve({ stdout: '', stderr }); - } else { - reject(new Error(`Command failed with code ${code}. Stderr: ${stderr}`)); - } - } - }); - // Add type annotation for execErr - stream.on('error', (execErr: Error) => reject(execErr)); - }); - }); - - // If stdout is empty, it means the command failed in a way we handled (like docker unavailable) - if (!execResult.stdout.trim()) { - // Response already sent if docker was unavailable - if (!execResult.stderr.includes('command not found') && !execResult.stderr.includes('Cannot connect to the Docker daemon')) { - console.warn(`WebSocket: Docker ps command for session ${sessionId} produced no output, but no specific error detected. Assuming available but no containers.`); - ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: true, containers: [] } })); - } - return; - } - - - // Parse the multi-line JSON output - const lines = execResult.stdout.trim().split('\n'); - const containers = lines.map(line => { - try { - const data = JSON.parse(line); - // --- FIX: Use parsePortsString --- - const containerData = { - id: data.ID, // Assume original field is uppercase ID - Names: typeof data.Names === 'string' ? data.Names.split(',') : (data.Names || []), - Image: data.Image || '', - ImageID: data.ImageID || '', - Command: data.Command || '', - Created: data.CreatedAt || 0, // Check if CreatedAt exists - State: data.State || 'unknown', - Status: data.Status || '', - Ports: parsePortsString(data.Ports), // <--- Use the parser here - Labels: data.Labels || {} - // Add other fields as needed, mapping from data.* - }; - // --- End FIX --- - - // --- Add Log to verify parsed container --- - // console.log(`Parsed Container Data (Session: ${sessionId}):`, containerData); - // --- End Log --- - - return containerData; - } catch (parseError) { - console.error(`WebSocket: Failed to parse remote docker ps JSON line for session ${sessionId}: ${line}`, parseError); - return null; - } - }).filter(Boolean); // Filter out nulls from parse errors - - // --- Add Log to verify final containers array --- - // console.log(`Final Containers Array to Send (Session: ${sessionId}):`, containers); - // --- End Log --- - - ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: true, containers } })); - + // Call the reusable function + const statusPayload = await fetchRemoteDockerStatus(state); + ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); } catch (error: any) { - console.error(`WebSocket: 执行远程 Docker 状态命令失败 for session ${sessionId}:`, error); - // Check if error indicates docker not found or cannot connect - const errorMessage = error.message || ''; - if (errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon')) { - ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } })); - } else { - ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Failed to get remote Docker status: ${errorMessage}` } })); - } + console.error(`WebSocket: 手动执行远程 Docker 状态命令失败 for session ${sessionId}:`, error); + const errorMessage = error.message || 'Unknown error fetching status'; + // Send specific error if Docker unavailable, general error otherwise + const isUnavailable = errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon'); + if (isUnavailable) { + ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } })); + } else { + ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Failed to get remote Docker status: ${errorMessage}` } })); + } } break; - } // end case 'docker:get_status' + } // end case 'docker:get_status' (Refactored) // --- NEW: Handle Docker Command Execution --- case 'docker:command': { diff --git a/packages/frontend/src/components/DockerManager.vue b/packages/frontend/src/components/DockerManager.vue index 9677132..208abd4 100644 --- a/packages/frontend/src/components/DockerManager.vue +++ b/packages/frontend/src/components/DockerManager.vue @@ -17,6 +17,14 @@ interface PortInfo { Type: 'tcp' | 'udp' | string; } +// --- Interfaces --- +interface PortInfo { + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: 'tcp' | 'udp' | string; +} + interface DockerContainer { id: string; // <--- Changed from Id to id Names: string[]; @@ -28,6 +36,7 @@ interface DockerContainer { Status: string; Ports: PortInfo[]; Labels: Record; + stats?: DockerStats | null; // ADDED: Assume stats are pushed with the container data } // --- NEW: Stats Interface (Example structure, adjust based on actual docker stats json output) --- @@ -50,12 +59,11 @@ const isLoading = ref(false); const error = ref(null); const isDockerAvailable = ref(true); // This will now reflect remote docker availability let refreshInterval: ReturnType | null = null; +// REMOVED: statsRefreshInterval let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions -// --- NEW: State for expansion (multiple allowed) --- +// --- State for expansion (multiple allowed) --- const expandedContainerIds = ref>(new Set()); // Use a Set to store multiple IDs -const containerStats = ref>(new Map()); // Map: containerId -> stats -const isStatsLoading = ref>(new Map()); // Map: containerId -> loading state -const statsError = ref>(new Map()); // Map: containerId -> error message +// REMOVED: containerStats, isStatsLoading, statsError maps // --- Computed --- @@ -78,32 +86,54 @@ const setupWsListeners = () => { const wsManager = activeSession.value.wsManager; // Listener for Docker status updates + // Listener for Docker status updates (SIMPLIFIED) const unsubStatus = wsManager.onMessage('docker:status:update', (payload) => { - console.log('[DockerManager] Received docker:status:update', payload); - isLoading.value = false; // Stop loading indicator - if (payload && typeof payload.available === 'boolean') { - isDockerAvailable.value = payload.available; - if (payload.available && Array.isArray(payload.containers)) { - containers.value = payload.containers; - error.value = null; + console.log('[DockerManager] Received docker:status:update', payload); + isLoading.value = false; // Stop loading indicator + + if (payload && typeof payload.available === 'boolean') { + isDockerAvailable.value = payload.available; + if (payload.available && Array.isArray(payload.containers)) { + // Directly replace the containers list with the received data + // Assuming payload.containers includes the 'stats' property for each container + containers.value = payload.containers as DockerContainer[]; + error.value = null; + + // Clean up expansion state for containers that no longer exist + const currentIds = new Set(containers.value.map(c => c.id)); + const idsToRemove = new Set(); + expandedContainerIds.value.forEach(id => { + if (!currentIds.has(id)) { + idsToRemove.add(id); + } + }); + idsToRemove.forEach(id => expandedContainerIds.value.delete(id)); + + } else { + // Docker available but no containers, or Docker unavailable + containers.value = []; + error.value = null; + expandedContainerIds.value.clear(); // Collapse all + + // Stop main refresh interval if Docker becomes unavailable remotely + // (No stats interval to stop anymore) + if (refreshInterval && !payload.available) { + clearInterval(refreshInterval); + refreshInterval = null; + console.log('[DockerManager] Stopped refresh interval due to remote Docker unavailability.'); + } + } } else { - containers.value = []; - error.value = null; // Clear error if Docker just unavailable - // Stop interval if Docker becomes unavailable remotely - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - console.log('[DockerManager] Stopped refresh interval due to remote Docker unavailability.'); - } + // Handle invalid payload + isDockerAvailable.value = false; + containers.value = []; + error.value = t('dockerManager.error.invalidResponse'); + expandedContainerIds.value.clear(); // Collapse all + + if (refreshInterval) clearInterval(refreshInterval); + refreshInterval = null; + // No stats interval to stop } - } else { - // Handle invalid payload - isDockerAvailable.value = false; - containers.value = []; - error.value = t('dockerManager.error.invalidResponse'); - if (refreshInterval) clearInterval(refreshInterval); - refreshInterval = null; - } }); // Listener for Docker status fetch errors @@ -131,31 +161,13 @@ const setupWsListeners = () => { requestDockerStatus(); // Trigger a status refresh immediately }); - // --- NEW: Listen for stats updates --- - const unsubStatsUpdate = wsManager.onMessage('docker:stats:update', (payload) => { - // Update stats for the specific container if it's being tracked - if (payload?.containerId && expandedContainerIds.value.has(payload.containerId)) { - console.log(`[DockerManager] Received stats update for ${payload.containerId}:`, payload.stats); - containerStats.value.set(payload.containerId, payload.stats as DockerStats); - isStatsLoading.value.set(payload.containerId, false); - statsError.value.set(payload.containerId, null); - } - }); - - const unsubStatsError = wsManager.onMessage('docker:stats:error', (payload) => { - // Update error status for the specific container if it's being tracked - if (payload?.containerId && expandedContainerIds.value.has(payload.containerId)) { - console.error(`[DockerManager] Error fetching stats for ${payload.containerId}:`, payload.message); - containerStats.value.set(payload.containerId, null); - isStatsLoading.value.set(payload.containerId, false); - statsError.value.set(payload.containerId, payload.message || t('dockerManager.stats.errorGeneric')); - } - }); + // REMOVED: unsubStatsUpdate and unsubStatsError listeners wsUnsubscribeHooks.push( - unsubStatus, unsubStatusError, unsubCommandError, unsubRequestUpdate, // existing unsub hooks - unsubStatsUpdate, - unsubStatsError + unsubStatus, + unsubStatusError, + unsubCommandError, + unsubRequestUpdate ); }; @@ -216,113 +228,97 @@ const sendDockerCommand = (containerId: string, command: 'start' | 'stop' | 'res }); }; -// --- UPDATED: Method to toggle expansion for a specific container --- +// --- SIMPLIFIED: Method to toggle expansion --- const toggleExpand = (containerId: string) => { - const currentlyExpanded = expandedContainerIds.value.has(containerId); - - if (currentlyExpanded) { - // Collapse this specific container + if (expandedContainerIds.value.has(containerId)) { expandedContainerIds.value.delete(containerId); - // Clear its stats data - containerStats.value.delete(containerId); - isStatsLoading.value.delete(containerId); - statsError.value.delete(containerId); - console.log(`[DockerManager] Collapsed container ${containerId}. Remaining expanded:`, Array.from(expandedContainerIds.value)); + console.log(`[DockerManager] Collapsed container ${containerId}.`); } else { - // Expand this specific container expandedContainerIds.value.add(containerId); - // Initialize its stats state - containerStats.value.set(containerId, null); - statsError.value.set(containerId, null); - isStatsLoading.value.set(containerId, true); - console.log(`[DockerManager] Expanded container ${containerId}. All expanded:`, Array.from(expandedContainerIds.value)); - - // Request stats from backend - if (activeSession.value && sshConnectionStatus.value === 'connected') { - console.log(`[DockerManager] Requesting stats for container ${containerId}`); - activeSession.value.wsManager.sendMessage({ - type: 'docker:get_stats', - payload: { containerId } - }); - } else { - console.warn('[DockerManager] Cannot fetch stats, SSH not connected.'); - statsError.value.set(containerId, t('dockerManager.error.sshNotConnected')); - isStatsLoading.value.set(containerId, false); - } + console.log(`[DockerManager] Expanded container ${containerId}.`); + // No need to request stats here, they should be in containers.value } }; +// REMOVED: requestExpandedStats function // --- Lifecycle and Watchers --- -// Watch for changes in the active session OR SSH connection status +// --- SIMPLIFIED Watcher --- watch([currentSessionId, sshConnectionStatus], ([newSessionId, newSshStatus], [oldSessionId, oldSshStatus]) => { - console.log(`[DockerManager] Watch triggered. Session: ${oldSessionId}=>${newSessionId}, SSH Status: ${oldSshStatus}=>${newSshStatus}`); + console.log(`[DockerManager] Watch triggered. Session: ${oldSessionId}=>${newSessionId}, SSH Status: ${oldSshStatus}=>${newSshStatus}`); - // --- Reset state on session change or SSH disconnect/error --- - if (newSessionId !== oldSessionId || (newSessionId && (newSshStatus === 'disconnected' || newSshStatus === 'error'))) { - console.log('[DockerManager] Resetting state due to session change or SSH disconnect/error.'); - containers.value = []; - isLoading.value = false; - error.value = null; - isDockerAvailable.value = true; // Assume available until fetch attempt + // --- Clear state and main interval on session change or SSH disconnect/error --- + const resetStateAndInterval = () => { + console.log('[DockerManager] Resetting state and clearing main interval.'); + containers.value = []; + isLoading.value = false; + error.value = null; + isDockerAvailable.value = true; // Assume available until fetch attempt + expandedContainerIds.value.clear(); // Clear expansion state - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - console.log('[DockerManager] Cleared refresh interval.'); - } - clearWsListeners(); // Clear listeners on disconnect or session change - // --- Add: Collapse container when session changes or disconnects --- - // --- Add: Collapse ALL containers when session changes or disconnects --- - if (expandedContainerIds.value.size > 0) { - console.log('[DockerManager] Session changed/disconnected, collapsing all stats views.'); - expandedContainerIds.value.clear(); - containerStats.value.clear(); - statsError.value.clear(); - isStatsLoading.value.clear(); - } - // --- End Add --- - } + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + console.log('[DockerManager] Cleared main refresh interval.'); + } + // No stats interval to clear + clearWsListeners(); // Clear listeners + }; - // --- Setup listeners and fetch data when session is active AND SSH is connected --- - if (newSessionId && newSshStatus === 'connected') { - // Only setup listeners/fetch if we weren't already connected in this session - if (oldSshStatus !== 'connected' || newSessionId !== oldSessionId) { - console.log(`[DockerManager] Session ${newSessionId} connected. Setting up listeners and fetching initial status.`); - setupWsListeners(); - requestDockerStatus(); // Fetch initial status now that SSH is connected + if (newSessionId !== oldSessionId || (newSessionId && (newSshStatus === 'disconnected' || newSshStatus === 'error'))) { + resetStateAndInterval(); + } - // Start interval only when SSH is connected - if (!refreshInterval) { - refreshInterval = setInterval(requestDockerStatus, 1000); // Check status every second - console.log('[DockerManager] Refresh interval started.'); - } - } - } else if (newSessionId && newSshStatus === 'connecting') { // <--- Removed 'initializing' check - // If connecting, ensure loading indicator is potentially active, but don't fetch yet - isLoading.value = true; // Show loading as SSH connects - error.value = null; // Clear previous errors - containers.value = []; // Clear old containers - isDockerAvailable.value = false; // Docker not available until SSH connects - console.log('[DockerManager] SSH is connecting, waiting...'); - } else { - // Handle cases like no active session (newSessionId is null) - isLoading.value = false; // Ensure loading is off if no session - console.log('[DockerManager] No active session or SSH not connected.'); - } + // --- Setup listeners and start main interval when session is active AND SSH is connected --- + if (newSessionId && newSshStatus === 'connected') { + // Only setup/start if we weren't already connected or interval isn't running + if (oldSshStatus !== 'connected' || newSessionId !== oldSessionId || !refreshInterval) { + console.log(`[DockerManager] Session ${newSessionId} connected. Setting up listeners and starting main interval.`); + setupWsListeners(); + requestDockerStatus(); // Fetch initial status -}, { immediate: true, deep: true }); // immediate: true to run on initial mount, deep might be needed for status object? + // Start main status refresh interval (if backend doesn't push automatically) + // If backend *does* push automatically, this interval might be redundant or cause extra load. + // Consider making the interval optional or configurable based on backend behavior. + if (!refreshInterval) { + // Let's keep a slower interval for safety, maybe backend push fails sometimes + refreshInterval = setInterval(requestDockerStatus, 15000); // Check status every 15 seconds + console.log('[DockerManager] Main refresh interval started (15s).'); + } + // No stats interval to start + } + } else if (newSessionId && newSshStatus === 'connecting') { + isLoading.value = true; + error.value = null; + containers.value = []; + isDockerAvailable.value = false; + console.log('[DockerManager] SSH is connecting, waiting...'); + // Ensure main interval is stopped while connecting + if (refreshInterval) clearInterval(refreshInterval); + refreshInterval = null; + } else { + // Handle cases like no active session or other statuses + isLoading.value = false; + console.log('[DockerManager] No active session or SSH not connected/connecting.'); + // Ensure main interval is stopped if not connected + if (refreshInterval) clearInterval(refreshInterval); + refreshInterval = null; + } + +}, { immediate: true }); +// --- SIMPLIFIED onUnmounted --- onUnmounted(() => { - console.log('[DockerManager] Component unmounted.'); - clearWsListeners(); // Clean up listeners - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - console.log('[DockerManager] Refresh interval cleared on unmount.'); - } + console.log('[DockerManager] Component unmounted.'); + clearWsListeners(); // Clean up listeners + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + console.log('[DockerManager] Main refresh interval cleared on unmount.'); + } + // No stats interval to clear }); @@ -431,23 +427,19 @@ onUnmounted(() => {
-
- {{ t('dockerManager.stats.loading') }} -
-
- {{ t('dockerManager.stats.error') }}: {{ statsError.get(container.id) }} -
-
+ + +
{{ t('dockerManager.stats.cpu') }}
-
{{ containerStats.get(container.id)?.CPUPerc ?? 'N/A' }}
+
{{ container.stats.CPUPerc ?? 'N/A' }}
{{ t('dockerManager.stats.memory') }}
-
{{ containerStats.get(container.id)?.MemUsage ?? 'N/A' }} ({{ containerStats.get(container.id)?.MemPerc ?? 'N/A' }})
+
{{ container.stats.MemUsage ?? 'N/A' }} ({{ container.stats.MemPerc ?? 'N/A' }})
{{ t('dockerManager.stats.netIO') }}
-
{{ containerStats.get(container.id)?.NetIO ?? 'N/A' }}
+
{{ container.stats.NetIO ?? 'N/A' }}
{{ t('dockerManager.stats.blockIO') }}
-
{{ containerStats.get(container.id)?.BlockIO ?? 'N/A' }}
+
{{ container.stats.BlockIO ?? 'N/A' }}
{{ t('dockerManager.stats.pids') }}
-
{{ containerStats.get(container.id)?.PIDs ?? 'N/A' }}
+
{{ container.stats.PIDs ?? 'N/A' }}
@@ -468,23 +460,19 @@ onUnmounted(() => {
-
- {{ t('dockerManager.stats.loading') }} -
-
- {{ t('dockerManager.stats.error') }}: {{ statsError.get(container.id) }} -
-
+ + +
{{ t('dockerManager.stats.cpu') }}
-
{{ containerStats.get(container.id)?.CPUPerc ?? 'N/A' }}
+
{{ container.stats.CPUPerc ?? 'N/A' }}
{{ t('dockerManager.stats.memory') }}
-
{{ containerStats.get(container.id)?.MemUsage ?? 'N/A' }} ({{ containerStats.get(container.id)?.MemPerc ?? 'N/A' }})
+
{{ container.stats.MemUsage ?? 'N/A' }} ({{ container.stats.MemPerc ?? 'N/A' }})
{{ t('dockerManager.stats.netIO') }}
-
{{ containerStats.get(container.id)?.NetIO ?? 'N/A' }}
+
{{ container.stats.NetIO ?? 'N/A' }}
{{ t('dockerManager.stats.blockIO') }}
-
{{ containerStats.get(container.id)?.BlockIO ?? 'N/A' }}
+
{{ container.stats.BlockIO ?? 'N/A' }}
{{ t('dockerManager.stats.pids') }}
-
{{ containerStats.get(container.id)?.PIDs ?? 'N/A' }}
+
{{ container.stats.PIDs ?? 'N/A' }}