This commit is contained in:
Baobhan Sith
2025-04-20 17:05:30 +08:00
parent 80a461d337
commit e2fbdb3bd9
10 changed files with 1055 additions and 7 deletions
@@ -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<void> {
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<void> {
// 从请求体中获取参数
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)。
}
}
}
@@ -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;
+3 -1
View File
@@ -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: '后端服务运行中!' });
});
@@ -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<string, string>;
// 根据 `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<boolean> {
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<void> {
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}`);
}
}
}
+256 -1
View File
@@ -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<string, ClientState>(); // 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<void>((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':
@@ -0,0 +1,467 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
// import apiClient from '../utils/apiClient'; // Removed apiClient
import { useI18n } from 'vue-i18n';
import { useSessionStore } from '../stores/session.store'; // Import session store
import { storeToRefs } from 'pinia';
const { t } = useI18n();
const sessionStore = useSessionStore();
const { activeSession } = storeToRefs(sessionStore); // Get reactive active session
// --- Interfaces (Keep these) ---
interface PortInfo {
IP?: string;
PrivatePort: number;
PublicPort?: number;
Type: 'tcp' | 'udp' | string;
}
interface DockerContainer {
id: string; // <--- Changed from Id to 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<string, string>;
}
// --- State ---
const containers = ref<DockerContainer[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const isDockerAvailable = ref(true); // This will now reflect remote docker availability
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions
// --- Computed ---
const currentSessionId = computed(() => activeSession.value?.sessionId);
// Add computed property for SSH connection status
const sshConnectionStatus = computed(() => activeSession.value?.wsManager.connectionStatus.value ?? 'disconnected');
// --- Methods ---
// Clear existing WebSocket listeners
const clearWsListeners = () => {
wsUnsubscribeHooks.forEach(unsub => unsub());
wsUnsubscribeHooks = [];
};
// Setup WebSocket listeners for the current active session
const setupWsListeners = () => {
clearWsListeners(); // Clear previous listeners first
if (!activeSession.value) return;
const wsManager = activeSession.value.wsManager;
// Listener for Docker status updates
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;
} 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.');
}
}
} 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
const unsubStatusError = wsManager.onMessage('docker:status:error', (payload) => {
console.error('[DockerManager] Received docker:status:error', payload);
isLoading.value = false;
error.value = payload?.message || t('dockerManager.error.fetchFailed');
isDockerAvailable.value = false; // Assume unavailable on error
containers.value = [];
if (refreshInterval) clearInterval(refreshInterval); // Stop interval on error
refreshInterval = null;
});
// Listener for Docker command execution errors (optional, could use notifications)
const unsubCommandError = wsManager.onMessage('docker:command:error', (payload) => {
console.error('[DockerManager] Received docker:command:error', payload);
// Display error to user (e.g., using a notification system)
alert(`${t('dockerManager.error.commandFailed', { command: payload?.command || '?' })}: ${payload?.message || 'Unknown error'}`);
});
// --- NEW: Listener for backend requesting a status update ---
const unsubRequestUpdate = wsManager.onMessage('request_docker_status_update', () => {
console.log('[DockerManager] Received request_docker_status_update from backend.');
// Debounce or add slight delay? Maybe not needed if backend delay is sufficient.
requestDockerStatus(); // Trigger a status refresh immediately
});
wsUnsubscribeHooks.push(unsubStatus, unsubStatusError, unsubCommandError, unsubRequestUpdate); // Add new unsubscribe hook
};
// Request Docker status via WebSocket - NOW CHECKS SSH STATUS
const requestDockerStatus = () => {
// Only request if SSH is connected
if (sshConnectionStatus.value !== 'connected') {
console.log(`[DockerManager] SSH not connected (status: ${sshConnectionStatus.value}), skipping Docker status request.`);
// No need to set loading=false here, as it should only be set true when connected
return;
}
if (!activeSession.value) { // Should not happen if ssh is connected, but for safety
console.warn('[DockerManager] requestDockerStatus called without active session.');
return;
}
console.log(`[DockerManager] Requesting Docker status for session ${activeSession.value.sessionId}`);
isLoading.value = true; // Show loading indicator
error.value = null; // Clear previous error
activeSession.value.wsManager.sendMessage({ type: 'docker:get_status' });
};
// Send command for a specific container via WebSocket
const sendDockerCommand = (containerId: string, command: 'start' | 'stop' | 'restart' | 'remove') => {
// Check SSH status first
if (sshConnectionStatus.value !== 'connected') {
console.warn('[DockerManager] Cannot send command, SSH not connected.');
alert(t('dockerManager.error.sshNotConnected')); // Inform user
return;
}
if (!activeSession.value) { // Safety check
console.warn('[DockerManager] Cannot send command, no active session.');
return;
}
if (!isDockerAvailable.value) {
console.warn('[DockerManager] Cannot send command, remote Docker is not available.');
alert(t('dockerManager.notAvailable'));
return;
}
console.log(`[DockerManager] Sending command '${command}' for container ${containerId} via session ${activeSession.value.sessionId}`);
activeSession.value.wsManager.sendMessage({
type: 'docker:command',
payload: { containerId, command }
});
// Optionally trigger a status refresh sooner after a command
// setTimeout(requestDockerStatus, 500); // e.g., refresh after 0.5s
// --- 添加日志,检查传入的 containerId ---
console.log(`[DockerManager] Preparing to send command. containerId: "${containerId}" (Type: ${typeof containerId}), command: "${command}"`);
// --- 结束日志 ---
console.log(`[DockerManager] Sending command '${command}' for container ${containerId} via session ${activeSession.value.sessionId}`);
activeSession.value.wsManager.sendMessage({
type: 'docker:command',
payload: { containerId, command }
});
};
// --- Lifecycle and Watchers ---
// Watch for changes in the active session OR SSH connection status
watch([currentSessionId, sshConnectionStatus], ([newSessionId, newSshStatus], [oldSessionId, oldSshStatus]) => {
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
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
console.log('[DockerManager] Cleared refresh interval.');
}
clearWsListeners(); // Clear listeners on disconnect or session change
}
// --- 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
// 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.');
}
}, { immediate: true, deep: true }); // immediate: true to run on initial mount, deep might be needed for status object?
onUnmounted(() => {
console.log('[DockerManager] Component unmounted.');
clearWsListeners(); // Clean up listeners
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
console.log('[DockerManager] Refresh interval cleared on unmount.');
}
});
</script>
<template>
<div class="docker-manager pane-content">
<!-- Case 1: No active session -->
<div v-if="!currentSessionId" class="unavailable-placeholder">
<i class="fas fa-plug"></i>
<p>{{ t('dockerManager.error.noActiveSession') }}</p>
<small>{{ t('dockerManager.error.connectFirst') }}</small>
</div>
<!-- Case 2: Active session, SSH connecting -->
<div v-else-if="sshConnectionStatus === 'connecting'" class="loading-placeholder"> <!-- <--- Removed 'initializing' check -->
<i class="fas fa-spinner fa-spin"></i>
<p>{{ t('dockerManager.waitingForSsh') }}</p>
<small>{{ activeSession?.wsManager.statusMessage.value || '...' }}</small>
</div>
<!-- Case 3: Active session, SSH disconnected -->
<div v-else-if="sshConnectionStatus === 'disconnected'" class="unavailable-placeholder">
<i class="fas fa-unlink"></i>
<p>{{ t('dockerManager.error.sshDisconnected') }}</p>
<small>{{ activeSession?.wsManager.statusMessage.value || '...' }}</small>
</div>
<!-- Case 4: Active session, SSH error -->
<div v-else-if="sshConnectionStatus === 'error'" class="error-placeholder">
<i class="fas fa-exclamation-circle error-icon"></i>
<p>{{ t('dockerManager.error.sshError') }}</p>
<small>{{ activeSession?.wsManager.statusMessage.value || 'Unknown SSH error' }}</small>
</div>
<!-- Case 5: Active session, SSH connected, Docker loading -->
<div v-else-if="isLoading && containers.length === 0" class="loading-placeholder">
<i class="fas fa-spinner fa-spin"></i> {{ t('dockerManager.loading') }}
</div>
<!-- Case 6: Active session, SSH connected, Docker unavailable -->
<div v-else-if="!isDockerAvailable" class="unavailable-placeholder">
<i class="fab fa-docker error-icon"></i>
<p>{{ t('dockerManager.notAvailable') }}</p>
<small>{{ t('dockerManager.installHintRemote') }}</small> <!-- Use a remote-specific hint -->
</div>
<!-- Case 7: Active session, SSH connected, Fetch error -->
<div v-else-if="error" class="error-placeholder">
<i class="fas fa-exclamation-triangle error-icon"></i>
<p>{{ t('dockerManager.error.fetchFailed') }}</p>
<small>{{ error }}</small>
</div>
<!-- Case 8: Active session, SSH connected, Docker available, show list -->
<div v-else class="container-list">
<div v-if="containers.length === 0 && !isLoading" class="empty-placeholder">
{{ t('dockerManager.noContainers') }}
</div>
<table v-else>
<thead>
<tr>
<th>{{ t('dockerManager.header.name') }}</th>
<th>{{ t('dockerManager.header.image') }}</th>
<th>{{ t('dockerManager.header.status') }}</th>
<th>{{ t('dockerManager.header.ports') }}</th>
<th>{{ t('dockerManager.header.actions') }}</th>
</tr>
</thead>
<tbody>
<!-- Key change: Use container.id -->
<tr v-for="container in containers" :key="container.id">
<td>{{ container.Names?.join(', ') || 'N/A' }}</td>
<td>{{ container.Image }}</td>
<td>
<span :class="['status-badge', `status-${container.State?.toLowerCase()}`]">
{{ container.Status }}
</span>
</td>
<!-- Corrected Port mapping display logic -->
<td>{{ container.Ports?.map(p => `${p.IP ? p.IP + ':' : ''}${p.PublicPort ? p.PublicPort + '->' : ''}${p.PrivatePort}/${p.Type}`).join(', ') || 'N/A' }}</td>
<td class="action-buttons">
<!-- Pass container.id instead of container.Id -->
<button @click="sendDockerCommand(container.id, 'start')" :title="t('dockerManager.action.start')" class="action-btn start" :disabled="container.State === 'running'">
<i class="fas fa-play"></i>
</button>
<button @click="sendDockerCommand(container.id, 'stop')" :title="t('dockerManager.action.stop')" class="action-btn stop" :disabled="container.State !== 'running'">
<i class="fas fa-stop"></i>
</button>
<button @click="sendDockerCommand(container.id, 'restart')" :title="t('dockerManager.action.restart')" class="action-btn restart" :disabled="container.State !== 'running'">
<i class="fas fa-sync-alt"></i>
</button>
<button @click="sendDockerCommand(container.id, 'remove')" :title="t('dockerManager.action.remove')" class="action-btn remove" :disabled="container.State === 'running'">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
/* Styles remain largely the same */
.docker-manager {
padding: var(--base-padding, 1rem);
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background-color: var(--app-bg-color);
color: var(--text-color);
}
.loading-placeholder,
.error-placeholder,
.unavailable-placeholder,
.empty-placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
flex-grow: 1;
color: var(--text-color-secondary);
height: 100%;
}
.unavailable-placeholder i:first-child, .loading-placeholder i:first-child { /* Target the icon */
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.unavailable-placeholder p, .loading-placeholder p {
margin-top: 0.5rem;
margin-bottom: 0.3rem;
font-weight: 500;
}
.unavailable-placeholder small, .loading-placeholder small {
font-size: 0.8em;
max-width: 80%;
color: var(--text-color-disabled); /* Lighter color for subtext */
}
.error-placeholder p {
margin-top: 0.5rem;
margin-bottom: 0.3rem;
font-weight: 500;
}
.error-placeholder small {
font-size: 0.8em;
max-width: 80%;
}
.error-icon {
font-size: 2rem;
color: var(--color-danger, #dc3545);
margin-bottom: 0.5rem;
}
.unavailable-placeholder .error-icon { /* Style for docker icon when unavailable */
color: var(--text-color-secondary);
}
.container-list {
flex-grow: 1;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
th, td {
padding: 0.6rem 0.8rem;
text-align: left;
border-bottom: 1px solid var(--border-color-light, #eee);
white-space: nowrap;
}
td:first-child, th:first-child {
white-space: normal;
}
th {
background-color: var(--header-bg-color);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
tbody tr:hover {
background-color: var(--hover-bg-color, #f5f5f5);
}
.status-badge {
padding: 0.2em 0.6em;
border-radius: 10px;
font-size: 0.8em;
font-weight: 500;
color: #fff;
background-color: var(--text-color-secondary);
}
.status-running { background-color: var(--color-success, #28a745); }
.status-exited { background-color: var(--color-danger, #dc3545); }
.status-paused { background-color: var(--color-warning, #ffc107); color: #333; }
.status-restarting { background-color: var(--color-info, #17a2b8); }
.status-created { background-color: var(--text-color-secondary); }
.action-buttons {
display: flex;
gap: 0.5rem;
}
.action-btn {
background: none;
border: none;
color: var(--text-color-secondary);
cursor: pointer;
padding: 0.3rem;
font-size: 1rem;
transition: color 0.2s ease;
}
.action-btn[disabled] {
color: var(--text-color-disabled);
cursor: not-allowed;
}
.action-btn:not([disabled]):hover {
color: var(--text-color);
}
.action-btn.start:not([disabled]):hover { color: var(--color-success, #28a745); }
.action-btn.stop:not([disabled]):hover { color: var(--color-warning, #ffc107); }
.action-btn.restart:not([disabled]):hover { color: var(--color-info, #17a2b8); }
.action-btn.remove:not([disabled]):hover { color: var(--color-danger, #dc3545); }
</style>
@@ -72,6 +72,7 @@ const componentMap: Record<PaneName, Component> = {
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 ---
+33
View File
@@ -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..."
}
}
+33
View File
@@ -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 连接..."
}
}
+3 -2
View File
@@ -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<PaneName[]> = ref([
'connections', 'terminal', 'commandBar', 'fileManager',
'editor', 'statusMonitor', 'commandHistory', 'quickCommands'
'editor', 'statusMonitor', 'commandHistory', 'quickCommands',
'dockerManager' // <--- 在这里添加 'dockerManager'
]);
// 新增:控制布局(Header/Footer)可见性的状态
const isLayoutVisible: Ref<boolean> = ref(true); // 控制整体布局(Header/Footer)可见性