update
This commit is contained in:
@@ -8,24 +8,37 @@ const execAsync = promisify(exec);
|
|||||||
// --- Interfaces (与前端 DockerManager.vue 中的定义保持一致) ---
|
// --- Interfaces (与前端 DockerManager.vue 中的定义保持一致) ---
|
||||||
// 理想情况下,这些类型应该放在共享的 types 包中
|
// 理想情况下,这些类型应该放在共享的 types 包中
|
||||||
interface PortInfo {
|
interface PortInfo {
|
||||||
IP?: string;
|
IP?: string;
|
||||||
PrivatePort: number;
|
PrivatePort: number;
|
||||||
PublicPort?: number;
|
PublicPort?: number;
|
||||||
Type: 'tcp' | 'udp' | string;
|
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 {
|
interface DockerContainer {
|
||||||
Id: string;
|
Id: string; // docker ps 返回的是 Id
|
||||||
Names: string[];
|
Names: string[];
|
||||||
Image: string;
|
Image: string;
|
||||||
ImageID: string;
|
ImageID: string;
|
||||||
Command: string;
|
Command: string;
|
||||||
Created: number;
|
Created: number;
|
||||||
State: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' | string;
|
State: 'created' | 'restarting' | 'running' | 'removing' | 'paused' | 'exited' | 'dead' | string;
|
||||||
Status: string;
|
Status: string;
|
||||||
Ports: PortInfo[];
|
Ports: PortInfo[];
|
||||||
Labels: Record<string, string>;
|
Labels: Record<string, string>;
|
||||||
// 根据 `docker ps --format '{{json .}}'` 的输出添加其他需要的字段
|
stats?: DockerStats | null; // 添加可选的 stats 字段
|
||||||
|
// 根据 `docker ps --format '{{json .}}'` 的输出添加其他需要的字段
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义命令类型
|
// 定义命令类型
|
||||||
@@ -46,7 +59,7 @@ export class DockerService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 尝试执行一个简单的 docker 命令,如 docker version
|
// 尝试执行一个简单的 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
|
console.log('[DockerService] Docker is available.'); // Use console.log
|
||||||
this.isDockerAvailableCache = true;
|
this.isDockerAvailableCache = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -61,51 +74,84 @@ export class DockerService {
|
|||||||
* 获取所有 Docker 容器的状态 (包括已停止的)。
|
* 获取所有 Docker 容器的状态 (包括已停止的)。
|
||||||
*/
|
*/
|
||||||
async getContainerStatus(): Promise<{ available: boolean; containers: DockerContainer[] }> {
|
async getContainerStatus(): Promise<{ available: boolean; containers: DockerContainer[] }> {
|
||||||
const available = await this.checkDockerAvailability();
|
const available = await this.checkDockerAvailability();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
return { available: false, containers: [] };
|
return { available: false, containers: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let allContainers: DockerContainer[] = [];
|
||||||
// 使用 --format '{{json .}}' 获取每个容器的 JSON 输出
|
const statsMap = new Map<string, DockerStats>();
|
||||||
// 使用 --no-trunc 避免 ID 被截断
|
|
||||||
const { stdout } = await execAsync("docker ps -a --no-trunc --format '{{json .}}'", { timeout: this.commandTimeout });
|
|
||||||
|
|
||||||
// stdout 包含多行 JSON,每行一个容器
|
// 1. 获取所有容器的基本信息
|
||||||
const lines = stdout.trim().split('\n');
|
try {
|
||||||
const containers: DockerContainer[] = lines
|
const { stdout: psStdout } = await execAsync("docker ps -a --no-trunc --format '{{json .}}'", { timeout: this.commandTimeout });
|
||||||
.map(line => {
|
const lines = psStdout.trim().split('\n');
|
||||||
try {
|
allContainers = lines
|
||||||
// Docker 的 JSON 输出有时可能不是严格的 JSON (例如 Names 字段),需要处理
|
.map(line => {
|
||||||
// 尝试更健壮的解析或预处理
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(line);
|
||||||
// 手动解析 Names 字段 (docker ps format 的 Names 是逗号分隔的)
|
if (typeof data.Names === 'string') {
|
||||||
if (typeof data.Names === 'string') {
|
data.Names = data.Names.split(',');
|
||||||
data.Names = data.Names.split(',');
|
}
|
||||||
}
|
if (!Array.isArray(data.Ports)) {
|
||||||
// 解析 Ports 字段 (可能需要更复杂的逻辑来匹配前端接口)
|
data.Ports = [];
|
||||||
// docker ps format 的 Ports 字段格式比较复杂,直接用 JSON 可能不包含所有信息
|
}
|
||||||
// 这里暂时依赖 JSON 输出,如果需要更详细的端口信息,可能需要 `docker inspect`
|
// 初始化 stats 为 null
|
||||||
// 假设 JSON 输出的 Ports 字段是符合我们接口的数组 (这可能需要调整命令或后端处理)
|
data.stats = null;
|
||||||
if (!Array.isArray(data.Ports)) {
|
return data as DockerContainer;
|
||||||
data.Ports = []; // 如果 Ports 不是数组,置为空数组
|
} 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;
|
// 2. 获取正在运行容器的统计信息
|
||||||
} catch (parseError) {
|
try {
|
||||||
console.error(`[DockerService] Failed to parse container JSON line: ${line}`, { error: parseError }); // Use console.error
|
// --no-stream 获取一次性快照
|
||||||
return null;
|
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 };
|
return { available: true, containers: allContainers };
|
||||||
} 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: [] };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+251
-105
@@ -8,6 +8,7 @@ import { decrypt } from './utils/crypto';
|
|||||||
import { SftpService } from './services/sftp.service';
|
import { SftpService } from './services/sftp.service';
|
||||||
import { StatusMonitorService } from './services/status-monitor.service';
|
import { StatusMonitorService } from './services/status-monitor.service';
|
||||||
import * as SshService from './services/ssh.service';
|
import * as SshService from './services/ssh.service';
|
||||||
|
import { DockerService } from './services/docker.service'; // 导入 DockerService
|
||||||
import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService
|
import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService
|
||||||
import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType
|
import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export interface ClientState { // 导出以便 Service 可以导入
|
|||||||
dbConnectionId: number;
|
dbConnectionId: number;
|
||||||
sftp?: SFTPWrapper; // 添加 sftp 实例 (由 SftpService 管理)
|
sftp?: SFTPWrapper; // 添加 sftp 实例 (由 SftpService 管理)
|
||||||
statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理)
|
statusIntervalId?: NodeJS.Timeout; // 添加状态轮询 ID (由 StatusMonitorService 管理)
|
||||||
|
dockerStatusIntervalId?: NodeJS.Timeout; // NEW: Docker 状态轮询 ID
|
||||||
ipAddress?: string; // 添加 IP 地址字段
|
ipAddress?: string; // 添加 IP 地址字段
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +44,36 @@ interface PortInfo {
|
|||||||
}
|
}
|
||||||
// --- End FIX ---
|
// --- End FIX ---
|
||||||
|
|
||||||
// --- NEW: Stats Interface (Ensure this matches frontend) ---
|
// --- Docker Interfaces (Ensure this matches frontend and DockerService) ---
|
||||||
|
// Stats 接口
|
||||||
interface DockerStats {
|
interface DockerStats {
|
||||||
ID: string;
|
ID: string; // 来自 docker stats
|
||||||
Name: string;
|
Name: string; // 来自 docker stats
|
||||||
CPUPerc: string;
|
CPUPerc: string; // 来自 docker stats
|
||||||
MemUsage: string;
|
MemUsage: string; // 来自 docker stats
|
||||||
MemPerc: string;
|
MemPerc: string; // 来自 docker stats
|
||||||
NetIO: string;
|
NetIO: string; // 来自 docker stats
|
||||||
BlockIO: string;
|
BlockIO: string; // 来自 docker stats
|
||||||
PIDs: string;
|
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<string, string>;
|
||||||
|
stats?: DockerStats | null; // 可选的 stats 字段
|
||||||
|
}
|
||||||
|
// --- End Docker Interfaces ---
|
||||||
|
|
||||||
|
|
||||||
// --- 新增:解析 Ports 字符串的辅助函数 ---
|
// --- 新增:解析 Ports 字符串的辅助函数 ---
|
||||||
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined
|
function parsePortsString(portsString: string | undefined | null): PortInfo[] { // Now PortInfo is defined
|
||||||
if (!portsString) {
|
if (!portsString) {
|
||||||
@@ -123,6 +143,7 @@ export const clientStates = new Map<string, ClientState>(); // Export clientStat
|
|||||||
const sftpService = new SftpService(clientStates);
|
const sftpService = new SftpService(clientStates);
|
||||||
const statusMonitorService = new StatusMonitorService(clientStates);
|
const statusMonitorService = new StatusMonitorService(clientStates);
|
||||||
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
const auditLogService = new AuditLogService(); // 实例化 AuditLogService
|
||||||
|
const dockerService = new DockerService(); // 实例化 DockerService (主要用于类型或未来可能的本地调用)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理指定会话 ID 关联的所有资源
|
* 清理指定会话 ID 关联的所有资源
|
||||||
@@ -145,10 +166,16 @@ const cleanupClientConnection = (sessionId: string | undefined) => {
|
|||||||
state.sshShellStream?.end(); // 结束 shell 流
|
state.sshShellStream?.end(); // 结束 shell 流
|
||||||
state.sshClient?.end(); // 结束 SSH 客户端
|
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);
|
clientStates.delete(sessionId);
|
||||||
|
|
||||||
// 5. 清除 WebSocket 上的 sessionId 关联 (可选,因为 ws 可能已关闭)
|
// 6. 清除 WebSocket 上的 sessionId 关联 (可选,因为 ws 可能已关闭)
|
||||||
if (state.ws && state.ws.sessionId === sessionId) {
|
if (state.ws && state.ws.sessionId === sessionId) {
|
||||||
delete state.ws.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<string, DockerStats>();
|
||||||
|
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<WebSocketServer> => { // Make async
|
export const initializeWebSocket = async (server: http.Server, sessionParser: RequestHandler): Promise<WebSocketServer> => { // Make async
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
const db = await getDbInstance(); // 获取数据库实例 (use await and getDbInstance)
|
const db = await getDbInstance(); // 获取数据库实例 (use await and getDbInstance)
|
||||||
|
const DOCKER_STATUS_INTERVAL = 2000; // Poll Docker status every 2 seconds
|
||||||
|
|
||||||
// --- 心跳检测 ---
|
// --- 心跳检测 ---
|
||||||
const heartbeatInterval = setInterval(() => {
|
const heartbeatInterval = setInterval(() => {
|
||||||
@@ -325,6 +489,58 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
|
console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`);
|
||||||
statusMonitorService.startStatusPolling(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) {
|
} catch (shellError: any) {
|
||||||
console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError);
|
console.error(`SSH: 会话 ${newSessionId} 打开 Shell 失败:`, shellError);
|
||||||
// 记录审计日志:打开 Shell 失败
|
// 记录审计日志:打开 Shell 失败
|
||||||
@@ -340,7 +556,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
cleanupClientConnection(newSessionId);
|
cleanupClientConnection(newSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后)
|
// 10. 设置 SSH Client 的关闭和错误处理 (移到 Shell 成功打开之后)
|
||||||
sshClient.on('close', () => {
|
sshClient.on('close', () => {
|
||||||
console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`);
|
console.log(`SSH: 会话 ${newSessionId} 的客户端连接已关闭。`);
|
||||||
cleanupClientConnection(newSessionId);
|
cleanupClientConnection(newSessionId);
|
||||||
@@ -393,106 +609,36 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: Handle Docker Status Request ---
|
// --- REFACTORED: Handle Docker Status Request ---
|
||||||
case 'docker:get_status': {
|
case 'docker:get_status': {
|
||||||
if (!state || !state.sshClient) {
|
if (!state) {
|
||||||
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动 SSH 连接。`);
|
console.warn(`WebSocket: 收到来自 ${ws.username} (会话: ${sessionId}) 的 ${type} 请求,但无活动会话状态。`);
|
||||||
ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: 'SSH connection not active.' } }));
|
ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: 'Session state not found.' } }));
|
||||||
return;
|
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 {
|
try {
|
||||||
// Execute docker ps command remotely
|
// Call the reusable function
|
||||||
const command = "docker ps -a --no-trunc --format '{{json .}}'";
|
const statusPayload = await fetchRemoteDockerStatus(state);
|
||||||
const execResult = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload }));
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.error(`WebSocket: 执行远程 Docker 状态命令失败 for session ${sessionId}:`, error);
|
console.error(`WebSocket: 手动执行远程 Docker 状态命令失败 for session ${sessionId}:`, error);
|
||||||
// Check if error indicates docker not found or cannot connect
|
const errorMessage = error.message || 'Unknown error fetching status';
|
||||||
const errorMessage = error.message || '';
|
// Send specific error if Docker unavailable, general error otherwise
|
||||||
if (errorMessage.includes('command not found') || errorMessage.includes('Cannot connect to the Docker daemon')) {
|
const isUnavailable = 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: [] } }));
|
if (isUnavailable) {
|
||||||
} else {
|
ws.send(JSON.stringify({ type: 'docker:status:update', payload: { available: false, containers: [] } }));
|
||||||
ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Failed to get remote Docker status: ${errorMessage}` } }));
|
} else {
|
||||||
}
|
ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Failed to get remote Docker status: ${errorMessage}` } }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
} // end case 'docker:get_status'
|
} // end case 'docker:get_status' (Refactored)
|
||||||
|
|
||||||
// --- NEW: Handle Docker Command Execution ---
|
// --- NEW: Handle Docker Command Execution ---
|
||||||
case 'docker:command': {
|
case 'docker:command': {
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ interface PortInfo {
|
|||||||
Type: 'tcp' | 'udp' | string;
|
Type: 'tcp' | 'udp' | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
|
interface PortInfo {
|
||||||
|
IP?: string;
|
||||||
|
PrivatePort: number;
|
||||||
|
PublicPort?: number;
|
||||||
|
Type: 'tcp' | 'udp' | string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DockerContainer {
|
interface DockerContainer {
|
||||||
id: string; // <--- Changed from Id to id
|
id: string; // <--- Changed from Id to id
|
||||||
Names: string[];
|
Names: string[];
|
||||||
@@ -28,6 +36,7 @@ interface DockerContainer {
|
|||||||
Status: string;
|
Status: string;
|
||||||
Ports: PortInfo[];
|
Ports: PortInfo[];
|
||||||
Labels: Record<string, string>;
|
Labels: Record<string, string>;
|
||||||
|
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) ---
|
// --- NEW: Stats Interface (Example structure, adjust based on actual docker stats json output) ---
|
||||||
@@ -50,12 +59,11 @@ const isLoading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const isDockerAvailable = ref(true); // This will now reflect remote docker availability
|
const isDockerAvailable = ref(true); // This will now reflect remote docker availability
|
||||||
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// REMOVED: statsRefreshInterval
|
||||||
let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions
|
let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions
|
||||||
// --- NEW: State for expansion (multiple allowed) ---
|
// --- State for expansion (multiple allowed) ---
|
||||||
const expandedContainerIds = ref<Set<string>>(new Set()); // Use a Set to store multiple IDs
|
const expandedContainerIds = ref<Set<string>>(new Set()); // Use a Set to store multiple IDs
|
||||||
const containerStats = ref<Map<string, DockerStats | null>>(new Map()); // Map: containerId -> stats
|
// REMOVED: containerStats, isStatsLoading, statsError maps
|
||||||
const isStatsLoading = ref<Map<string, boolean>>(new Map()); // Map: containerId -> loading state
|
|
||||||
const statsError = ref<Map<string, string | null>>(new Map()); // Map: containerId -> error message
|
|
||||||
|
|
||||||
|
|
||||||
// --- Computed ---
|
// --- Computed ---
|
||||||
@@ -78,32 +86,54 @@ const setupWsListeners = () => {
|
|||||||
const wsManager = activeSession.value.wsManager;
|
const wsManager = activeSession.value.wsManager;
|
||||||
|
|
||||||
// Listener for Docker status updates
|
// Listener for Docker status updates
|
||||||
|
// Listener for Docker status updates (SIMPLIFIED)
|
||||||
const unsubStatus = wsManager.onMessage('docker:status:update', (payload) => {
|
const unsubStatus = wsManager.onMessage('docker:status:update', (payload) => {
|
||||||
console.log('[DockerManager] Received docker:status:update', payload);
|
console.log('[DockerManager] Received docker:status:update', payload);
|
||||||
isLoading.value = false; // Stop loading indicator
|
isLoading.value = false; // Stop loading indicator
|
||||||
if (payload && typeof payload.available === 'boolean') {
|
|
||||||
isDockerAvailable.value = payload.available;
|
if (payload && typeof payload.available === 'boolean') {
|
||||||
if (payload.available && Array.isArray(payload.containers)) {
|
isDockerAvailable.value = payload.available;
|
||||||
containers.value = payload.containers;
|
if (payload.available && Array.isArray(payload.containers)) {
|
||||||
error.value = null;
|
// 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<string>();
|
||||||
|
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 {
|
} else {
|
||||||
containers.value = [];
|
// Handle invalid payload
|
||||||
error.value = null; // Clear error if Docker just unavailable
|
isDockerAvailable.value = false;
|
||||||
// Stop interval if Docker becomes unavailable remotely
|
containers.value = [];
|
||||||
if (refreshInterval) {
|
error.value = t('dockerManager.error.invalidResponse');
|
||||||
clearInterval(refreshInterval);
|
expandedContainerIds.value.clear(); // Collapse all
|
||||||
refreshInterval = null;
|
|
||||||
console.log('[DockerManager] Stopped refresh interval due to remote Docker unavailability.');
|
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
|
// Listener for Docker status fetch errors
|
||||||
@@ -131,31 +161,13 @@ const setupWsListeners = () => {
|
|||||||
requestDockerStatus(); // Trigger a status refresh immediately
|
requestDockerStatus(); // Trigger a status refresh immediately
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- NEW: Listen for stats updates ---
|
// REMOVED: unsubStatsUpdate and unsubStatsError listeners
|
||||||
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'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wsUnsubscribeHooks.push(
|
wsUnsubscribeHooks.push(
|
||||||
unsubStatus, unsubStatusError, unsubCommandError, unsubRequestUpdate, // existing unsub hooks
|
unsubStatus,
|
||||||
unsubStatsUpdate,
|
unsubStatusError,
|
||||||
unsubStatsError
|
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 toggleExpand = (containerId: string) => {
|
||||||
const currentlyExpanded = expandedContainerIds.value.has(containerId);
|
if (expandedContainerIds.value.has(containerId)) {
|
||||||
|
|
||||||
if (currentlyExpanded) {
|
|
||||||
// Collapse this specific container
|
|
||||||
expandedContainerIds.value.delete(containerId);
|
expandedContainerIds.value.delete(containerId);
|
||||||
// Clear its stats data
|
console.log(`[DockerManager] Collapsed container ${containerId}.`);
|
||||||
containerStats.value.delete(containerId);
|
|
||||||
isStatsLoading.value.delete(containerId);
|
|
||||||
statsError.value.delete(containerId);
|
|
||||||
console.log(`[DockerManager] Collapsed container ${containerId}. Remaining expanded:`, Array.from(expandedContainerIds.value));
|
|
||||||
} else {
|
} else {
|
||||||
// Expand this specific container
|
|
||||||
expandedContainerIds.value.add(containerId);
|
expandedContainerIds.value.add(containerId);
|
||||||
// Initialize its stats state
|
console.log(`[DockerManager] Expanded container ${containerId}.`);
|
||||||
containerStats.value.set(containerId, null);
|
// No need to request stats here, they should be in containers.value
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// REMOVED: requestExpandedStats function
|
||||||
|
|
||||||
// --- Lifecycle and Watchers ---
|
// --- Lifecycle and Watchers ---
|
||||||
|
|
||||||
// Watch for changes in the active session OR SSH connection status
|
// --- SIMPLIFIED Watcher ---
|
||||||
watch([currentSessionId, sshConnectionStatus], ([newSessionId, newSshStatus], [oldSessionId, oldSshStatus]) => {
|
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 ---
|
// --- Clear state and main interval on session change or SSH disconnect/error ---
|
||||||
if (newSessionId !== oldSessionId || (newSessionId && (newSshStatus === 'disconnected' || newSshStatus === 'error'))) {
|
const resetStateAndInterval = () => {
|
||||||
console.log('[DockerManager] Resetting state due to session change or SSH disconnect/error.');
|
console.log('[DockerManager] Resetting state and clearing main interval.');
|
||||||
containers.value = [];
|
containers.value = [];
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
isDockerAvailable.value = true; // Assume available until fetch attempt
|
isDockerAvailable.value = true; // Assume available until fetch attempt
|
||||||
|
expandedContainerIds.value.clear(); // Clear expansion state
|
||||||
|
|
||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(refreshInterval);
|
clearInterval(refreshInterval);
|
||||||
refreshInterval = null;
|
refreshInterval = null;
|
||||||
console.log('[DockerManager] Cleared refresh interval.');
|
console.log('[DockerManager] Cleared main refresh interval.');
|
||||||
}
|
}
|
||||||
clearWsListeners(); // Clear listeners on disconnect or session change
|
// No stats interval to clear
|
||||||
// --- Add: Collapse container when session changes or disconnects ---
|
clearWsListeners(); // Clear listeners
|
||||||
// --- 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 ---
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Setup listeners and fetch data when session is active AND SSH is connected ---
|
if (newSessionId !== oldSessionId || (newSessionId && (newSshStatus === 'disconnected' || newSshStatus === 'error'))) {
|
||||||
if (newSessionId && newSshStatus === 'connected') {
|
resetStateAndInterval();
|
||||||
// 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
|
// --- Setup listeners and start main interval when session is active AND SSH is connected ---
|
||||||
if (!refreshInterval) {
|
if (newSessionId && newSshStatus === 'connected') {
|
||||||
refreshInterval = setInterval(requestDockerStatus, 1000); // Check status every second
|
// Only setup/start if we weren't already connected or interval isn't running
|
||||||
console.log('[DockerManager] Refresh interval started.');
|
if (oldSshStatus !== 'connected' || newSessionId !== oldSessionId || !refreshInterval) {
|
||||||
}
|
console.log(`[DockerManager] Session ${newSessionId} connected. Setting up listeners and starting main interval.`);
|
||||||
}
|
setupWsListeners();
|
||||||
} else if (newSessionId && newSshStatus === 'connecting') { // <--- Removed 'initializing' check
|
requestDockerStatus(); // Fetch initial status
|
||||||
// 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?
|
// 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(() => {
|
onUnmounted(() => {
|
||||||
console.log('[DockerManager] Component unmounted.');
|
console.log('[DockerManager] Component unmounted.');
|
||||||
clearWsListeners(); // Clean up listeners
|
clearWsListeners(); // Clean up listeners
|
||||||
if (refreshInterval) {
|
if (refreshInterval) {
|
||||||
clearInterval(refreshInterval);
|
clearInterval(refreshInterval);
|
||||||
refreshInterval = null;
|
refreshInterval = null;
|
||||||
console.log('[DockerManager] Refresh interval cleared on unmount.');
|
console.log('[DockerManager] Main refresh interval cleared on unmount.');
|
||||||
}
|
}
|
||||||
|
// No stats interval to clear
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -431,23 +427,19 @@ onUnmounted(() => {
|
|||||||
<div class="expansion-card-content" v-if="expandedContainerIds.has(container.id)">
|
<div class="expansion-card-content" v-if="expandedContainerIds.has(container.id)">
|
||||||
<div class="stats-container card-stats-container">
|
<div class="stats-container card-stats-container">
|
||||||
<!-- Stats content (loading, error, data) for this specific container -->
|
<!-- Stats content (loading, error, data) for this specific container -->
|
||||||
<div v-if="isStatsLoading.get(container.id)" class="stats-loading">
|
<!-- SIMPLIFIED: Display stats directly from container object -->
|
||||||
<i class="fas fa-spinner fa-spin"></i> {{ t('dockerManager.stats.loading') }}
|
<!-- REMOVED: v-if="isStatsLoading..." and v-else-if="statsError..." -->
|
||||||
</div>
|
<dl v-if="container.stats" class="stats-dl">
|
||||||
<div v-else-if="statsError.get(container.id)" class="stats-error">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i> {{ t('dockerManager.stats.error') }}: {{ statsError.get(container.id) }}
|
|
||||||
</div>
|
|
||||||
<dl v-else-if="containerStats.get(container.id)" class="stats-dl">
|
|
||||||
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.CPUPerc ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.CPUPerc ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.MemUsage ?? 'N/A' }} ({{ containerStats.get(container.id)?.MemPerc ?? 'N/A' }})</dd>
|
<dd>{{ container.stats.MemUsage ?? 'N/A' }} ({{ container.stats.MemPerc ?? 'N/A' }})</dd>
|
||||||
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.NetIO ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.NetIO ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.BlockIO ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.BlockIO ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.PIDs ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.PIDs ?? 'N/A' }}</dd>
|
||||||
<!-- Add more stats if available -->
|
<!-- Add more stats if available -->
|
||||||
</dl>
|
</dl>
|
||||||
<div v-else class="stats-nodata">
|
<div v-else class="stats-nodata">
|
||||||
@@ -468,23 +460,19 @@ onUnmounted(() => {
|
|||||||
<td :colspan="6">
|
<td :colspan="6">
|
||||||
<div class="stats-container">
|
<div class="stats-container">
|
||||||
<!-- Desktop stats content for this specific container -->
|
<!-- Desktop stats content for this specific container -->
|
||||||
<div v-if="isStatsLoading.get(container.id)" class="stats-loading">
|
<!-- SIMPLIFIED: Display stats directly from container object -->
|
||||||
<i class="fas fa-spinner fa-spin"></i> {{ t('dockerManager.stats.loading') }}
|
<!-- REMOVED: v-if="isStatsLoading..." and v-else-if="statsError..." -->
|
||||||
</div>
|
<dl v-if="container.stats" class="stats-dl">
|
||||||
<div v-else-if="statsError.get(container.id)" class="stats-error">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i> {{ t('dockerManager.stats.error') }}: {{ statsError.get(container.id) }}
|
|
||||||
</div>
|
|
||||||
<dl v-else-if="containerStats.get(container.id)" class="stats-dl">
|
|
||||||
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
<dt>{{ t('dockerManager.stats.cpu') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.CPUPerc ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.CPUPerc ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
<dt>{{ t('dockerManager.stats.memory') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.MemUsage ?? 'N/A' }} ({{ containerStats.get(container.id)?.MemPerc ?? 'N/A' }})</dd>
|
<dd>{{ container.stats.MemUsage ?? 'N/A' }} ({{ container.stats.MemPerc ?? 'N/A' }})</dd>
|
||||||
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
<dt>{{ t('dockerManager.stats.netIO') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.NetIO ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.NetIO ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
<dt>{{ t('dockerManager.stats.blockIO') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.BlockIO ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.BlockIO ?? 'N/A' }}</dd>
|
||||||
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
<dt>{{ t('dockerManager.stats.pids') }}</dt>
|
||||||
<dd>{{ containerStats.get(container.id)?.PIDs ?? 'N/A' }}</dd>
|
<dd>{{ container.stats.PIDs ?? 'N/A' }}</dd>
|
||||||
<!-- Add more stats if available -->
|
<!-- Add more stats if available -->
|
||||||
</dl>
|
</dl>
|
||||||
<div v-else class="stats-nodata">
|
<div v-else class="stats-nodata">
|
||||||
|
|||||||
Reference in New Issue
Block a user