From e2fbdb3bd9749594a9cc4c43513454b4019dddfa Mon Sep 17 00:00:00 2001 From: Baobhan Sith <80159437+Heavrnl@users.noreply.github.com> Date: Sun, 20 Apr 2025 17:05:30 +0800 Subject: [PATCH] update --- .../backend/src/docker/docker.controller.ts | 62 +++ packages/backend/src/docker/docker.routes.ts | 18 + packages/backend/src/index.ts | 4 +- .../backend/src/services/docker.service.ts | 168 +++++++ packages/backend/src/websocket.ts | 257 +++++++++- .../frontend/src/components/DockerManager.vue | 467 ++++++++++++++++++ .../src/components/LayoutRenderer.vue | 15 +- packages/frontend/src/locales/en.json | 33 ++ packages/frontend/src/locales/zh.json | 33 ++ packages/frontend/src/stores/layout.store.ts | 5 +- 10 files changed, 1055 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/docker/docker.controller.ts create mode 100644 packages/backend/src/docker/docker.routes.ts create mode 100644 packages/backend/src/services/docker.service.ts create mode 100644 packages/frontend/src/components/DockerManager.vue diff --git a/packages/backend/src/docker/docker.controller.ts b/packages/backend/src/docker/docker.controller.ts new file mode 100644 index 0000000..4d8eeac --- /dev/null +++ b/packages/backend/src/docker/docker.controller.ts @@ -0,0 +1,62 @@ +import { Request, Response, NextFunction } from 'express'; +import { DockerService, DockerCommand } from '../services/docker.service'; // 导入服务和命令类型 + +// 由于没有 typedi,我们将手动实例化服务或通过其他方式获取实例 +// 简单起见,这里直接 new 一个实例。在实际项目中,可能需要更复杂的实例管理。 +const dockerService = new DockerService(); + +export class DockerController { + + /** + * 处理获取 Docker 容器状态的请求 (GET /docker/status) + */ + async getStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const status = await dockerService.getContainerStatus(); + res.json(status); // 直接返回 { available: boolean, containers: DockerContainer[] } + } catch (error) { + // 将错误传递给 Express 的错误处理中间件 + next(error); + } + } + + /** + * 处理执行 Docker 命令的请求 (POST /docker/command) + */ + async executeCommand(req: Request, res: Response, next: NextFunction): Promise { + // 从请求体中获取参数 + const { containerId, command } = req.body; + + // 基本的输入验证 + if (!containerId || typeof containerId !== 'string') { + res.status(400).json({ message: 'Missing or invalid containerId in request body.' }); + return; + } + // 验证 command 是否是允许的类型 + const allowedCommands: DockerCommand[] = ['start', 'stop', 'restart', 'remove']; + if (!command || !allowedCommands.includes(command)) { + res.status(400).json({ message: `Invalid command. Must be one of: ${allowedCommands.join(', ')}.` }); + return; + } + + try { + // 调用服务执行命令 + await dockerService.executeContainerCommand(containerId, command as DockerCommand); + // 成功执行,返回 200 OK 或 204 No Content + res.status(200).json({ message: `Command '${command}' executed successfully for container ${containerId}.` }); + // 或者 res.status(204).send(); + } catch (error: any) { + // 根据错误类型返回不同的状态码 + if (error.message.includes('Docker is not available')) { + res.status(503).json({ message: error.message }); // Service Unavailable + } else if (error.message.includes('Invalid container ID') || error.message.includes('Unsupported Docker command')) { + res.status(400).json({ message: error.message }); // Bad Request + } else { + // 其他执行错误,可能是 Docker 守护进程错误等 + res.status(500).json({ message: error.message || 'Failed to execute Docker command.' }); // Internal Server Error + } + // 注意:这里没有调用 next(error),因为我们已经处理了响应。 + // 如果希望使用统一的错误处理中间件,则应该调用 next(error)。 + } + } +} \ No newline at end of file diff --git a/packages/backend/src/docker/docker.routes.ts b/packages/backend/src/docker/docker.routes.ts new file mode 100644 index 0000000..b65107a --- /dev/null +++ b/packages/backend/src/docker/docker.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { DockerController } from './docker.controller'; +// 修改导入路径 +import { isAuthenticated } from '../auth/auth.middleware'; + +const router = Router(); +const dockerController = new DockerController(); // 同样,手动实例化 + +// 应用认证中间件,确保只有登录用户才能访问 Docker 相关接口 +router.use(isAuthenticated); + +// GET /api/docker/status - 获取 Docker 容器状态 +router.get('/status', (req, res, next) => dockerController.getStatus(req, res, next)); + +// POST /api/docker/command - 执行 Docker 命令 +router.post('/command', (req, res, next) => dockerController.executeCommand(req, res, next)); + +export default router; \ No newline at end of file diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index a3d904a..97f1552 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -21,6 +21,7 @@ import commandHistoryRoutes from './command-history/command-history.routes'; // import quickCommandsRoutes from './quick-commands/quick-commands.routes'; // 导入快捷指令路由 import terminalThemeRoutes from './terminal-themes/terminal-theme.routes'; // 导入终端主题路由 import appearanceRoutes from './appearance/appearance.routes'; // 导入外观设置路由 +// import dockerRouter from './docker/docker.routes'; // <--- 移除 Docker 路由导入 import { initializeWebSocket } from './websocket'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入 IP 白名单中间件 @@ -162,8 +163,9 @@ const startServer = () => { app.use('/api/v1/quick-commands', quickCommandsRoutes); app.use('/api/v1/terminal-themes', terminalThemeRoutes); app.use('/api/v1/appearance', appearanceRoutes); + // app.use('/api/v1/docker', dockerRouter); // <--- 移除 Docker 路由注册 - // 状态检查接口 (如果不需要 session 可以保留在外面,但移入更安全) + // 状态检查接口 app.get('/api/v1/status', (req: Request, res: Response) => { res.json({ status: '后端服务运行中!' }); }); diff --git a/packages/backend/src/services/docker.service.ts b/packages/backend/src/services/docker.service.ts new file mode 100644 index 0000000..6cb2363 --- /dev/null +++ b/packages/backend/src/services/docker.service.ts @@ -0,0 +1,168 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +// import { Service } from 'typedi'; // Removed typedi import +// import { logger } from '../utils/logger'; // Removed logger import + +const execAsync = promisify(exec); + +// --- Interfaces (与前端 DockerManager.vue 中的定义保持一致) --- +// 理想情况下,这些类型应该放在共享的 types 包中 +interface PortInfo { + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: 'tcp' | 'udp' | 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 .}}'` 的输出添加其他需要的字段 +} + +// 定义命令类型 +export type DockerCommand = 'start' | 'stop' | 'restart' | 'remove'; // 使用实际的 docker 命令 + +// @Service() // Removed typedi decorator +export class DockerService { + private isDockerAvailableCache: boolean | null = null; + private readonly commandTimeout = 15000; // 15 秒超时 + + /** + * 检查 Docker CLI 是否可用。包含缓存以避免重复检查。 + */ + async checkDockerAvailability(): Promise { + if (this.isDockerAvailableCache !== null) { + return this.isDockerAvailableCache; + } + + try { + // 尝试执行一个简单的 docker 命令,如 docker version + await execAsync('docker version', { timeout: 5000 }); // 5秒超时 + console.log('[DockerService] Docker is available.'); // Use console.log + this.isDockerAvailableCache = true; + return true; + } catch (error: any) { + console.warn('[DockerService] Docker check failed. Docker might not be installed or running.', { error: error.message }); // Use console.warn + this.isDockerAvailableCache = false; + return false; + } + } + + /** + * 获取所有 Docker 容器的状态 (包括已停止的)。 + */ + async getContainerStatus(): Promise<{ available: boolean; containers: DockerContainer[] }> { + 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 }); + + // 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 不是数组,置为空数组 + } + + return data as DockerContainer; + } catch (parseError) { + console.error(`[DockerService] Failed to parse container JSON line: ${line}`, { error: parseError }); // Use console.error + return null; + } + }) + .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: [] }; + } + } + + /** + * 对指定的容器执行命令。 + * @param containerId 容器 ID + * @param command 命令 ('start', 'stop', 'restart', 'remove') + */ + async executeContainerCommand(containerId: string, command: DockerCommand): Promise { + const available = await this.checkDockerAvailability(); + if (!available) { + throw new Error('Docker is not available.'); + } + + // 参数校验和清理,防止命令注入 + const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, ''); + if (!cleanContainerId) { + throw new Error('Invalid container ID format.'); + } + + let dockerCliCommand: string; + switch (command) { + case 'start': + dockerCliCommand = `docker start ${cleanContainerId}`; + break; + case 'stop': + dockerCliCommand = `docker stop ${cleanContainerId}`; + break; + case 'restart': + dockerCliCommand = `docker restart ${cleanContainerId}`; + break; + case 'remove': + // 使用 -f 强制删除正在运行的容器,对应前端的 'down' 意图 + dockerCliCommand = `docker rm -f ${cleanContainerId}`; + break; + default: + // 防止未知的命令类型 + console.error(`[DockerService] Received unknown command type: ${command}`); // Use console.error + throw new Error(`Unsupported Docker command: ${command}`); + } + + console.log(`[DockerService] Executing command: ${dockerCliCommand}`); // Use console.log + try { + const { stdout, stderr } = await execAsync(dockerCliCommand, { timeout: this.commandTimeout }); + if (stderr) { + // Docker 命令有时会将正常信息输出到 stderr (例如 rm 返回容器 ID) + // 但也可能包含错误信息 + console.warn(`[DockerService] Command "${dockerCliCommand}" produced stderr:`, { stderr }); // Use console.warn + // 可以根据 stderr 内容判断是否真的是错误 + if (stderr.toLowerCase().includes('error') || stderr.toLowerCase().includes('failed')) { + throw new Error(`Docker command failed: ${stderr}`); + } + } + console.log(`[DockerService] Command "${dockerCliCommand}" executed successfully.`, { stdout }); // Use console.log + } catch (error: any) { + console.error(`[DockerService] Failed to execute command "${dockerCliCommand}"`, { error: error.message, stderr: error.stderr }); // Use console.error + // 抛出错误,让 Controller 层处理并返回给前端 + throw new Error(`Failed to execute Docker command "${command}": ${error.stderr || error.message}`); + } + } +} \ No newline at end of file diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 68b9759..3df3ec5 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -32,6 +32,77 @@ export interface ClientState { // 导出以便 Service 可以导入 ipAddress?: string; // 添加 IP 地址字段 } +// --- Interfaces (保持与前端一致) --- +// --- FIX: Move PortInfo definition before its usage --- +interface PortInfo { + IP?: string; + PrivatePort: number; + PublicPort?: number; + Type: 'tcp' | 'udp' | string; +} +// --- End FIX --- + +// --- 新增:解析 Ports 字符串的辅助函数 --- +function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined + if (!portsString) { + return []; + } + const ports: PortInfo[] = []; // Now PortInfo is defined + // 示例格式: "0.0.0.0:8080->80/tcp, :::8080->80/tcp", "127.0.0.1:5432->5432/tcp", "6379/tcp" + const entries = portsString.split(', '); + + for (const entry of entries) { + const parts = entry.split('->'); + let publicPart = ''; + let privatePart = ''; + + if (parts.length === 2) { // Format like "IP:PublicPort->PrivatePort/Type" or "PublicPort->PrivatePort/Type" + publicPart = parts[0]; + privatePart = parts[1]; + } else if (parts.length === 1) { // Format like "PrivatePort/Type" + privatePart = parts[0]; + } else { + console.warn(`[WebSocket] Skipping unparsable port entry: ${entry}`); + continue; + } + + // Parse Private Part (e.g., "80/tcp") + const privateMatch = privatePart.match(/^(\d+)\/(tcp|udp|\w+)$/); + if (!privateMatch) { + console.warn(`[WebSocket] Skipping unparsable private port part: ${privatePart}`); + continue; + } + const privatePort = parseInt(privateMatch[1], 10); + const type = privateMatch[2]; + + let ip: string | undefined = undefined; + let publicPort: number | undefined = undefined; + + // Parse Public Part (e.g., "0.0.0.0:8080" or ":::8080" or just "8080") + if (publicPart) { + const publicMatch = publicPart.match(/^(?:([\d.:a-fA-F]+):)?(\d+)$/); // Supports IPv4, IPv6, or just port + if (publicMatch) { + ip = publicMatch[1] || undefined; // IP might be undefined if only port is specified + publicPort = parseInt(publicMatch[2], 10); + } else { + console.warn(`[WebSocket] Skipping unparsable public port part: ${publicPart}`); + // Continue processing with only private port info if public part is weird + } + } + + if (!isNaN(privatePort)) { + ports.push({ + IP: ip, + PrivatePort: privatePort, + PublicPort: publicPort, + Type: type + }); + } + } + return ports; +} +// --- 结束辅助函数 --- + // 存储所有活动客户端的状态 (key: sessionId) export const clientStates = new Map(); // Export clientStates @@ -310,7 +381,191 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re break; } - // --- SFTP 操作 (委托给 SftpService) --- + // --- NEW: 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.' } })); + 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 } })); + + } 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}` } })); + } + } + break; + } // end case 'docker:get_status' + + // --- NEW: Handle Docker Command Execution --- + case 'docker:command': { + if (!state || !state.sshClient) { + console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`); + ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command: payload?.command, message: 'SSH connection not active.' } })); + return; + } + const { containerId, command } = payload || {}; + if (!containerId || typeof containerId !== 'string' || !command || !['start', 'stop', 'restart', 'remove'].includes(command)) { + console.error(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的无效 ${type} 请求。Payload:`, payload); + ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command: command, message: 'Invalid containerId or command.' } })); + return; + } + + // --- 添加日志 --- + console.log(`WebSocket: Received docker:command. Raw Payload:`, payload); + console.log(`WebSocket: Validating containerId: "${containerId}" (Type: ${typeof containerId}), Command: "${command}" (Type: ${typeof command})`); + // --- 结束日志 --- + + // 验证逻辑: + if (!containerId || typeof containerId !== 'string' || !command || !['start', 'stop', 'restart', 'remove'].includes(command)) { + console.error(`WebSocket: Validation FAILED for docker:command. Payload:`, payload); // 增加失败日志 + ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command: command, message: 'Invalid containerId or command.' } })); + return; + } + + console.log(`WebSocket: Validation PASSED for docker:command.`); // 增加成功日志 + console.log(`WebSocket: Processing command '${command}' for container '${containerId}' on session ${sessionId}...`); + try { + // Sanitize containerId (basic) - more robust validation might be needed + const cleanContainerId = containerId.replace(/[^a-zA-Z0-9_-]/g, ''); + if (!cleanContainerId) throw new Error('Invalid container ID format after sanitization.'); + + let dockerCliCommand: string; + switch (command) { + case 'start': dockerCliCommand = `docker start ${cleanContainerId}`; break; + case 'stop': dockerCliCommand = `docker stop ${cleanContainerId}`; break; + case 'restart': dockerCliCommand = `docker restart ${cleanContainerId}`; break; + case 'remove': dockerCliCommand = `docker rm -f ${cleanContainerId}`; break; // Use -f for remove + default: throw new Error(`Unsupported command: ${command}`); // Should be caught by earlier validation + } + + // Execute command remotely + await new Promise((resolve, reject) => { + state.sshClient.exec(dockerCliCommand, { pty: false }, (err, stream) => { + if (err) return reject(err); + let stderr = ''; + stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); }); + stream.on('close', (code: number | null) => { + if (code === 0) { + console.log(`WebSocket: 远程 Docker 命令 (${dockerCliCommand}) on session ${sessionId} 执行成功。`); + resolve(); + } else { + console.error(`WebSocket: 远程 Docker 命令 (${dockerCliCommand}) on session ${sessionId} 执行失败 (Code: ${code}). Stderr: ${stderr}`); + reject(new Error(`Command failed with code ${code}. ${stderr || 'No stderr output.'}`)); + } + }); + // Add type annotation for execErr + stream.on('error', (execErr: Error) => reject(execErr)); + }); + }); + // Optionally send a success confirmation back? Not strictly needed if status updates quickly. + // ws.send(JSON.stringify({ type: 'docker:command:success', payload: { command, containerId } })); + + // Trigger a status update after command execution + // Use a small delay to allow Docker daemon to potentially update state + setTimeout(() => { + if (clientStates.has(sessionId!)) { // Check if session still exists + ws.send(JSON.stringify({ type: 'request_docker_status_update' })); // Ask frontend to re-request + // Or directly trigger backend fetch and push: + // handleDockerGetStatus(ws, state); // Need to refactor get_status logic into a reusable function + } + }, 500); + + + } catch (error: any) { + console.error(`WebSocket: 执行远程 Docker 命令 (${command} for ${containerId}) 失败 for session ${sessionId}:`, error); + ws.send(JSON.stringify({ type: 'docker:command:error', payload: { command, containerId, message: `Failed to execute remote command: ${error.message}` } })); + } + break; + } // end case 'docker:command' + + + // --- SFTP Cases --- case 'sftp:readdir': case 'sftp:stat': case 'sftp:readfile': diff --git a/packages/frontend/src/components/DockerManager.vue b/packages/frontend/src/components/DockerManager.vue new file mode 100644 index 0000000..b95a06c --- /dev/null +++ b/packages/frontend/src/components/DockerManager.vue @@ -0,0 +1,467 @@ + + + + + \ No newline at end of file diff --git a/packages/frontend/src/components/LayoutRenderer.vue b/packages/frontend/src/components/LayoutRenderer.vue index a203afb..19e23a3 100644 --- a/packages/frontend/src/components/LayoutRenderer.vue +++ b/packages/frontend/src/components/LayoutRenderer.vue @@ -72,6 +72,7 @@ const componentMap: Record = { statusMonitor: defineAsyncComponent(() => import('./StatusMonitor.vue')), commandHistory: defineAsyncComponent(() => import('../views/CommandHistoryView.vue')), quickCommands: defineAsyncComponent(() => import('../views/QuickCommandsView.vue')), + dockerManager: defineAsyncComponent(() => import('./DockerManager.vue')), // <--- 添加 dockerManager 映射 }; // --- Computed --- @@ -179,9 +180,17 @@ const componentProps = computed(() => { class: 'pane-content', onExecuteCommand: (command: string) => emit('sendCommand', command), // 复用 sendCommand 事件 }; - default: - return { class: 'pane-content' }; - } + case 'dockerManager': + // DockerManager 可能不需要 session 信息,但需要转发事件 + return { + class: 'pane-content', + // 假设 DockerManager 会发出 'docker-command' 事件 + // onDockerCommand: (payload: { containerId: string; command: 'up' | 'down' | 'restart' | 'stop' }) => emit('dockerCommand', payload), + // 暂时不添加事件转发,等组件实现后再确定 + }; + default: + return { class: 'pane-content' }; + } }); // --- Methods --- diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 63ee868..47812e3 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -781,5 +781,38 @@ "commandInput": "Enter command...", "terminalSearch": "Search terminal..." } + }, + "dockerManager": { + "loading": "Loading Docker Containers...", + "notAvailable": "Docker Not Available on Remote Host", + "installHint": "Please ensure Docker is installed and running on the remote host.", + "installHintRemote": "Please ensure Docker is installed and running on the remote host.", + "error": { + "fetchFailed": "Failed to fetch remote container status", + "commandFailed": "Failed to execute remote command '{command}'", + "invalidResponse": "Received invalid response from server", + "noActiveSession": "No active SSH session.", + "connectFirst": "Please connect to an SSH session first.", + "sshDisconnected": "SSH session disconnected.", + "sshError": "SSH Connection Error", + "sshNotConnected": "SSH session is not connected." + }, + "noContainers": "No running or stopped containers found on remote host.", + "header": { + "name": "Name", + "image": "Image", + "status": "Status", + "ports": "Ports", + "actions": "Actions" + }, + "action": { + "up": "Start/Up", + "down": "Remove/Down", + "restart": "Restart", + "stop": "Stop", + "start": "Start", + "remove": "Remove" + }, + "waitingForSsh": "Waiting for SSH connection..." } } diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index 16cb4d3..b0271e2 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -781,5 +781,38 @@ "commandInput": "输入命令...", "terminalSearch": "搜索终端..." } + }, + "dockerManager": { + "loading": "正在加载 Docker 容器...", + "notAvailable": "远程主机 Docker 不可用", + "installHint": "请确保远程主机上已安装并运行 Docker。", + "installHintRemote": "请确保远程主机上已安装并运行 Docker。", + "error": { + "fetchFailed": "获取远程容器状态失败", + "commandFailed": "执行远程命令 '{command}' 失败", + "invalidResponse": "收到无效的服务器响应", + "noActiveSession": "无活动的 SSH 会话。", + "connectFirst": "请先连接到一个 SSH 会话。", + "sshDisconnected": "SSH 会话已断开。", + "sshError": "SSH 连接错误", + "sshNotConnected": "SSH 会话未连接。" + }, + "noContainers": "在远程主机上未找到正在运行或已停止的容器。", + "header": { + "name": "名称", + "image": "镜像", + "status": "状态", + "ports": "端口", + "actions": "操作" + }, + "action": { + "up": "启动/Up", + "down": "移除/Down", + "restart": "重启", + "stop": "停止", + "start": "启动", + "remove": "移除" + }, + "waitingForSsh": "等待 SSH 连接..." } } diff --git a/packages/frontend/src/stores/layout.store.ts b/packages/frontend/src/stores/layout.store.ts index 2f7efe3..890c154 100644 --- a/packages/frontend/src/stores/layout.store.ts +++ b/packages/frontend/src/stores/layout.store.ts @@ -4,7 +4,7 @@ import { ref, computed, watch, type Ref, type ComputedRef } from 'vue'; import apiClient from '../utils/apiClient'; // 使用统一的 apiClient // 定义所有可用面板的名称 -export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands'; +export type PaneName = 'connections' | 'terminal' | 'commandBar' | 'fileManager' | 'editor' | 'statusMonitor' | 'commandHistory' | 'quickCommands' | 'dockerManager'; // 定义布局节点接口 export interface LayoutNode { @@ -92,7 +92,8 @@ export const useLayoutStore = defineStore('layout', () => { // 存储所有理论上可用的面板名称 const allPossiblePanes: Ref = ref([ 'connections', 'terminal', 'commandBar', 'fileManager', - 'editor', 'statusMonitor', 'commandHistory', 'quickCommands' + 'editor', 'statusMonitor', 'commandHistory', 'quickCommands', + 'dockerManager' // <--- 在这里添加 'dockerManager' ]); // 新增:控制布局(Header/Footer)可见性的状态 const isLayoutVisible: Ref = ref(true); // 控制整体布局(Header/Footer)可见性