Merge branch 'dev-1.10.0' into fix/rbac-improvements
This commit is contained in:
@@ -201,12 +201,14 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
enable_tunnel INTEGER NOT NULL DEFAULT 1,
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
enable_docker INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -561,6 +563,12 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"enable_docker",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
|
||||
@@ -86,6 +86,9 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
enableDocker: integer("enable_docker", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
|
||||
@@ -237,6 +237,7 @@ router.post(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
enableDocker,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
@@ -282,6 +283,7 @@ router.post(
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
@@ -343,6 +345,7 @@ router.post(
|
||||
? JSON.parse(createdHost.jumpHosts as string)
|
||||
: [],
|
||||
enableFileManager: !!createdHost.enableFileManager,
|
||||
enableDocker: !!createdHost.enableDocker,
|
||||
statsConfig: createdHost.statsConfig
|
||||
? JSON.parse(createdHost.statsConfig as string)
|
||||
: undefined,
|
||||
@@ -459,6 +462,7 @@ router.put(
|
||||
enableTerminal,
|
||||
enableTunnel,
|
||||
enableFileManager,
|
||||
enableDocker,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
jumpHosts,
|
||||
@@ -505,6 +509,7 @@ router.put(
|
||||
? JSON.stringify(quickActions)
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
enableDocker: enableDocker ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
@@ -584,9 +589,13 @@ router.put(
|
||||
? JSON.parse(updatedHost.jumpHosts as string)
|
||||
: [],
|
||||
enableFileManager: !!updatedHost.enableFileManager,
|
||||
enableDocker: !!updatedHost.enableDocker,
|
||||
statsConfig: updatedHost.statsConfig
|
||||
? JSON.parse(updatedHost.statsConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: updatedHost.dockerConfig
|
||||
? JSON.parse(updatedHost.dockerConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -775,6 +784,7 @@ router.get(
|
||||
? JSON.parse(row.quickActions as string)
|
||||
: [],
|
||||
enableFileManager: !!row.enableFileManager,
|
||||
enableDocker: !!row.enableDocker,
|
||||
statsConfig: row.statsConfig
|
||||
? JSON.parse(row.statsConfig as string)
|
||||
: undefined,
|
||||
|
||||
687
src/backend/ssh/docker-console.ts
Normal file
687
src/backend/ssh/docker-console.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { parse as parseUrl } from "url";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { systemLogger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const dockerConsoleLogger = systemLogger;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: any;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
// WebSocket server on port 30008
|
||||
const wss = new WebSocketServer({
|
||||
port: 30008,
|
||||
verifyClient: async (info, callback) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
dockerConsoleLogger.warn("WebSocket connection rejected: No token", {
|
||||
operation: "ws_verify",
|
||||
});
|
||||
return callback(false, 401, "Authentication required");
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!decoded || !decoded.userId) {
|
||||
dockerConsoleLogger.warn(
|
||||
"WebSocket connection rejected: Invalid token",
|
||||
{
|
||||
operation: "ws_verify",
|
||||
},
|
||||
);
|
||||
return callback(false, 401, "Invalid token");
|
||||
}
|
||||
|
||||
// Store userId in the request for later use
|
||||
(info.req as any).userId = decoded.userId;
|
||||
|
||||
dockerConsoleLogger.info("WebSocket connection verified", {
|
||||
operation: "ws_verify",
|
||||
userId: decoded.userId,
|
||||
});
|
||||
|
||||
callback(true);
|
||||
} catch (error) {
|
||||
dockerConsoleLogger.error("WebSocket verification error", error, {
|
||||
operation: "ws_verify",
|
||||
});
|
||||
callback(false, 500, "Authentication failed");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to detect available shell in container
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
containerId: string,
|
||||
): Promise<string> {
|
||||
const shells = ["bash", "sh", "ash"];
|
||||
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.client.exec(
|
||||
`docker exec ${containerId} which ${shell}`,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// If we get here, the shell was found
|
||||
return shell;
|
||||
} catch {
|
||||
// Try next shell
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to sh if nothing else works
|
||||
return "sh";
|
||||
}
|
||||
|
||||
// Helper function to create jump host chain
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: any[],
|
||||
userId: string,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
// Fetch jump host from database
|
||||
const jumpHostData = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (jumpHostData.length === 0) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = jumpHostData[0] as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
} catch (e) {
|
||||
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: jumpHost.id,
|
||||
});
|
||||
jumpHost.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credentials for jump host
|
||||
let resolvedCredentials: any = {
|
||||
password: jumpHost.password,
|
||||
sshKey: jumpHost.key,
|
||||
keyPassword: jumpHost.keyPassword,
|
||||
authType: jumpHost.authType,
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, jumpHost.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: any = {
|
||||
host: jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
// Set authentication
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a previous client, use it as the sock
|
||||
if (currentClient) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
currentClient!.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHost.ip,
|
||||
jumpHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
config.sock = stream;
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
currentClient = client;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
}
|
||||
|
||||
// Handle WebSocket connections
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const userId = (req as any).userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
|
||||
dockerConsoleLogger.info("Docker console WebSocket connected", {
|
||||
operation: "ws_connect",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const { hostConfig, containerId, shell, cols, rows } =
|
||||
message.data as {
|
||||
hostConfig: SSHHost;
|
||||
containerId: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof hostConfig.jumpHosts === "string" &&
|
||||
hostConfig.jumpHosts
|
||||
) {
|
||||
try {
|
||||
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
|
||||
} catch (e) {
|
||||
dockerConsoleLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
hostConfig.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!hostConfig || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Docker is enabled for this host
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve credentials
|
||||
let resolvedCredentials: any = {
|
||||
password: hostConfig.password,
|
||||
sshKey: hostConfig.key,
|
||||
keyPassword: hostConfig.keyPassword,
|
||||
authType: hostConfig.authType,
|
||||
};
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key,
|
||||
keyPassword:
|
||||
credential.key_password || credential.keyPassword,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create SSH client
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: any = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
// Set authentication
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle jump hosts if configured
|
||||
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
hostConfig.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
const stream = await new Promise<any>((resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostConfig.ip,
|
||||
hostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
});
|
||||
config.sock = stream;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to SSH
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
// Detect or use provided shell
|
||||
const detectedShell =
|
||||
shell || (await detectShell(sshSession, containerId));
|
||||
sshSession.shell = detectedShell;
|
||||
|
||||
// Create docker exec PTY
|
||||
const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`;
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
dockerConsoleLogger.error(
|
||||
"Failed to create docker exec",
|
||||
err,
|
||||
{
|
||||
operation: "docker_exec",
|
||||
sessionId,
|
||||
containerId,
|
||||
},
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshSession!.stream = stream;
|
||||
|
||||
// Forward stream output to WebSocket
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: data.toString("utf8"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: data.toString("utf8"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Console session ended",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (sshSession) {
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: { shell: detectedShell },
|
||||
}),
|
||||
);
|
||||
|
||||
dockerConsoleLogger.info("Docker console session started", {
|
||||
operation: "console_start",
|
||||
sessionId,
|
||||
containerId,
|
||||
shell: detectedShell,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
dockerConsoleLogger.error("Failed to connect to container", error, {
|
||||
operation: "console_connect",
|
||||
sessionId,
|
||||
containerId: message.data.containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to connect to container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
sshSession.stream.write(message.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const { cols, rows } = message.data;
|
||||
sshSession.stream.setWindow(rows, cols);
|
||||
|
||||
dockerConsoleLogger.debug("Console resized", {
|
||||
operation: "console_resize",
|
||||
sessionId,
|
||||
cols,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
dockerConsoleLogger.info("Docker console disconnected", {
|
||||
operation: "console_disconnect",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ping": {
|
||||
// Respond with pong to acknowledge keepalive
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
dockerConsoleLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dockerConsoleLogger.error("WebSocket message error", error, {
|
||||
operation: "ws_message",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "An error occurred",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
dockerConsoleLogger.info("WebSocket connection closed", {
|
||||
operation: "ws_close",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
// Cleanup SSH session if still active
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
dockerConsoleLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dockerConsoleLogger.info(
|
||||
"Docker console WebSocket server started on port 30008",
|
||||
{
|
||||
operation: "startup",
|
||||
},
|
||||
);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
dockerConsoleLogger.info("Shutting down Docker console server...", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
|
||||
// Close all active sessions
|
||||
activeSessions.forEach((session, sessionId) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
dockerConsoleLogger.info("Closed session during shutdown", {
|
||||
operation: "shutdown",
|
||||
sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
wss.close(() => {
|
||||
dockerConsoleLogger.info("Docker console server closed", {
|
||||
operation: "shutdown",
|
||||
});
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
1464
src/backend/ssh/docker.ts
Normal file
1464
src/backend/ssh/docker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -316,7 +316,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let isKeyboardInteractive = false;
|
||||
@@ -802,8 +801,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
});
|
||||
|
||||
setupPingInterval();
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
@@ -1279,11 +1276,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
@@ -1320,24 +1312,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
pingInterval = setInterval(() => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write("\x00");
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
"SSH keepalive failed: " +
|
||||
(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
cleanupSSH();
|
||||
}
|
||||
} else if (!sshConn || !sshStream) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
|
||||
// It was causing ^@ characters to appear in terminals with echoctl enabled.
|
||||
// SSH-level keepalive is configured via connectConfig (keepaliveInterval,
|
||||
// keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring
|
||||
// without producing visible output on the terminal.
|
||||
//
|
||||
// See: https://github.com/Termix-SSH/Support/issues/232
|
||||
// See: https://github.com/Termix-SSH/Support/issues/309
|
||||
});
|
||||
|
||||
@@ -102,6 +102,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./ssh/docker.js");
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
|
||||
@@ -36,7 +36,7 @@ const SENSITIVE_FIELDS = [
|
||||
|
||||
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
|
||||
|
||||
class Logger {
|
||||
export class Logger {
|
||||
private serviceName: string;
|
||||
private serviceIcon: string;
|
||||
private serviceColor: string;
|
||||
|
||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -40,6 +40,7 @@ export interface SSHHost {
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
enableDocker: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
jumpHosts?: JumpHost[];
|
||||
@@ -82,6 +83,7 @@ export interface SSHHostData {
|
||||
enableTerminal?: boolean;
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
enableDocker?: boolean;
|
||||
defaultPath?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
@@ -344,13 +346,14 @@ export interface TerminalConfig {
|
||||
export interface TabContextTab {
|
||||
id: number;
|
||||
type:
|
||||
| "home"
|
||||
| "terminal"
|
||||
| "ssh_manager"
|
||||
| "server"
|
||||
| "admin"
|
||||
| "file_manager"
|
||||
| "user_profile";
|
||||
| "home"
|
||||
| "terminal"
|
||||
| "ssh_manager"
|
||||
| "server"
|
||||
| "admin"
|
||||
| "file_manager"
|
||||
| "user_profile"
|
||||
| "docker";
|
||||
title: string;
|
||||
hostConfig?: SSHHost;
|
||||
terminalRef?: any;
|
||||
@@ -676,3 +679,55 @@ export interface RestoreRequestBody {
|
||||
backupPath: string;
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state:
|
||||
| "created"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "restarting"
|
||||
| "removing"
|
||||
| "exited"
|
||||
| "dead";
|
||||
ports: string;
|
||||
created: string;
|
||||
command?: string;
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
mounts?: string[];
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids?: string;
|
||||
}
|
||||
|
||||
export interface DockerLogOptions {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,9 @@ function AppContent() {
|
||||
const showTerminalView =
|
||||
currentTabData?.type === "terminal" ||
|
||||
currentTabData?.type === "server" ||
|
||||
currentTabData?.type === "file_manager";
|
||||
currentTabData?.type === "file_manager" ||
|
||||
currentTabData?.type === "tunnel" ||
|
||||
currentTabData?.type === "docker";
|
||||
const showHome = currentTabData?.type === "home";
|
||||
const showSshManager = currentTabData?.type === "ssh_manager";
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
|
||||
390
src/ui/desktop/apps/docker/DockerManager.tsx
Normal file
390
src/ui/desktop/apps/docker/DockerManager.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
SSHHost,
|
||||
DockerContainer,
|
||||
DockerValidation,
|
||||
} from "@/types/index.js";
|
||||
import {
|
||||
connectDockerSession,
|
||||
disconnectDockerSession,
|
||||
listDockerContainers,
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { LogViewer } from "./components/LogViewer.tsx";
|
||||
import { ContainerStats } from "./components/ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function DockerManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const [selectedContainer, setSelectedContainer] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState("containers");
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
// SSH session lifecycle
|
||||
React.useEffect(() => {
|
||||
const initSession = async () => {
|
||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
await connectDockerSession(sid, currentHostConfig.id);
|
||||
setSessionId(sid);
|
||||
|
||||
// Validate Docker availability
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
toast.error(
|
||||
validation.error || "Docker is not available on this host",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to connect to host",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
initSession();
|
||||
|
||||
return () => {
|
||||
if (sessionId) {
|
||||
disconnectDockerSession(sessionId).catch(() => {
|
||||
// Silently handle disconnect errors
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
|
||||
|
||||
// Keepalive interval
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
keepaliveDockerSession(sessionId).catch(() => {
|
||||
// Silently handle keepalive errors
|
||||
});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // Every 10 minutes
|
||||
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
// Refresh containers function
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
setContainers(data);
|
||||
} catch (error) {
|
||||
// Silently handle polling errors
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Poll containers
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible || !dockerValidation?.available) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const pollContainers = async () => {
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
if (!cancelled) {
|
||||
setContainers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle polling errors
|
||||
}
|
||||
};
|
||||
|
||||
pollContainers(); // Initial fetch
|
||||
const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [sessionId, isVisible, dockerValidation?.available]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setViewMode("list");
|
||||
setSelectedContainer(null);
|
||||
}, []);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
// Check if Docker is enabled
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Docker is not enabled for this host. Enable it in Host Settings
|
||||
to use Docker features.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">
|
||||
{isValidating
|
||||
? "Validating Docker..."
|
||||
: "Connecting to host..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Docker not available
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold mb-2">Docker Error</div>
|
||||
<div>{dockerValidation.error}</div>
|
||||
{dockerValidation.code && (
|
||||
<div className="mt-2 text-xs opacity-70">
|
||||
Error code: {dockerValidation.code}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
{dockerValidation?.version && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Docker v{dockerValidation.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{viewMode === "list" ? (
|
||||
<div className="h-full px-4 py-4">
|
||||
{sessionId ? (
|
||||
<ContainerList
|
||||
containers={containers}
|
||||
sessionId={sessionId}
|
||||
onSelectContainer={(id) => {
|
||||
setSelectedContainer(id);
|
||||
setViewMode("detail");
|
||||
}}
|
||||
selectedContainerId={selectedContainer}
|
||||
onRefresh={refreshContainers}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">No session available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : sessionId && selectedContainer && currentHostConfig ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHostConfig}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-400">
|
||||
Select a container to view details
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
448
src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
Normal file
448
src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types/index.js";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: SSHHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
hostConfig,
|
||||
}: ConsoleTerminalProps): React.ReactElement {
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const [isConnected, setIsConnected] = React.useState(false);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
|
||||
const wsRef = React.useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
||||
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getWebSocketBaseUrl = React.useCallback(() => {
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
// Development mode check (similar to Terminal.tsx)
|
||||
const isDev =
|
||||
!isElectronApp &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
if (isDev) {
|
||||
// Development: connect directly to port 30008
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//localhost:30008`;
|
||||
}
|
||||
|
||||
if (isElectronApp) {
|
||||
// Electron: construct URL from configured server
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string }).configuredServerUrl ||
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
// Use nginx path routing, not direct port
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
}
|
||||
|
||||
// Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/)
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/docker/console/`;
|
||||
}, []);
|
||||
|
||||
// Initialize terminal
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.options.cursorBlink = true;
|
||||
terminal.options.fontSize = 14;
|
||||
terminal.options.fontFamily = "monospace";
|
||||
terminal.options.theme = {
|
||||
background: "#18181b",
|
||||
foreground: "#c9d1d9",
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 100);
|
||||
|
||||
const resizeHandler = () => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const { rows, cols } = terminal;
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
data: { rows, cols },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
|
||||
// Clean up WebSocket before disposing terminal
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [terminal]);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// WebSocket might already be closed
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
terminal.write("Disconnected from container console.\r\n");
|
||||
} catch (error) {
|
||||
// Terminal might be disposed
|
||||
}
|
||||
}
|
||||
}, [terminal]);
|
||||
|
||||
const connect = React.useCallback(() => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
toast.error("Container must be running to connect to console");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const token = isElectron()
|
||||
? localStorage.getItem("jwt")
|
||||
: getCookie("jwt");
|
||||
if (!token) {
|
||||
toast.error("Authentication required");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure terminal is fitted before connecting
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Double-check terminal dimensions
|
||||
const cols = terminal.cols || 80;
|
||||
const rows = terminal.rows || 24;
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig,
|
||||
containerId,
|
||||
shell: selectedShell,
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case "output":
|
||||
terminal.write(msg.data);
|
||||
break;
|
||||
|
||||
case "connected":
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
toast.success(`Connected to ${containerName}`);
|
||||
|
||||
// Fit terminal and send resize to ensure correct dimensions
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
// Send resize message with correct dimensions
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
data: { rows: terminal.rows, cols: terminal.cols },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
|
||||
case "disconnected":
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;33m${msg.message || "Disconnected"}\x1b[0m\r\n`,
|
||||
);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
setIsConnecting(false);
|
||||
toast.error(msg.message || "Console error");
|
||||
terminal.write(`\r\n\x1b[1;31mError: ${msg.message}\x1b[0m\r\n`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
setIsConnecting(false);
|
||||
setIsConnected(false);
|
||||
toast.error("Failed to connect to console");
|
||||
};
|
||||
|
||||
// Set up periodic ping to keep connection alive
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
ws.onclose = () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
if (wsRef.current === ws) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
toast.error(
|
||||
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
terminal,
|
||||
containerState,
|
||||
getWebSocketBaseUrl,
|
||||
hostConfig,
|
||||
containerId,
|
||||
selectedShell,
|
||||
containerName,
|
||||
]);
|
||||
|
||||
// Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect)
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400 text-lg">Container is not running</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Start the container to access the console
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls */}
|
||||
<Card className="py-3">
|
||||
<CardContent className="px-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
<span className="text-base font-medium">Console</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedShell}
|
||||
onValueChange={setSelectedShell}
|
||||
disabled={isConnected}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Select shell" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bash">Bash</SelectItem>
|
||||
<SelectItem value="sh">Sh</SelectItem>
|
||||
<SelectItem value="ash">Ash</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2 sm:gap-2">
|
||||
{!isConnected ? (
|
||||
<Button
|
||||
onClick={connect}
|
||||
disabled={isConnecting}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="h-4 w-4 mr-2" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={disconnect}
|
||||
variant="destructive"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<PowerOff className="h-4 w-4 mr-2" />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal */}
|
||||
<Card className="flex-1 overflow-hidden pt-1 pb-0">
|
||||
<CardContent className="p-0 h-full relative">
|
||||
{/* Terminal container - always rendered */}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isConnected ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{/* Not connected message */}
|
||||
{!isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400">Not connected</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Click Connect to start an interactive shell
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting message */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">
|
||||
Connecting to {containerName}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
446
src/ui/desktop/apps/docker/components/ContainerCard.tsx
Normal file
446
src/ui/desktop/apps/docker/components/ContainerCard.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Pause,
|
||||
Trash2,
|
||||
PlayCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { DockerContainer } from "@/types/index.js";
|
||||
import {
|
||||
startDockerContainer,
|
||||
stopDockerContainer,
|
||||
restartDockerContainer,
|
||||
pauseDockerContainer,
|
||||
unpauseDockerContainer,
|
||||
removeDockerContainer,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip.tsx";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog.tsx";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
sessionId: string;
|
||||
onSelect?: () => void;
|
||||
isSelected?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ContainerCard({
|
||||
container,
|
||||
sessionId,
|
||||
onSelect,
|
||||
isSelected = false,
|
||||
onRefresh,
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [isPausing, setIsPausing] = React.useState(false);
|
||||
const [isRemoving, setIsRemoving] = React.useState(false);
|
||||
const [showRemoveDialog, setShowRemoveDialog] = React.useState(false);
|
||||
|
||||
const statusColors = {
|
||||
running: {
|
||||
bg: "bg-green-500/10",
|
||||
border: "border-green-500/20",
|
||||
text: "text-green-400",
|
||||
badge: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
},
|
||||
exited: {
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
text: "text-red-400",
|
||||
badge: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
},
|
||||
paused: {
|
||||
bg: "bg-yellow-500/10",
|
||||
border: "border-yellow-500/20",
|
||||
text: "text-yellow-400",
|
||||
badge: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
},
|
||||
created: {
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/20",
|
||||
text: "text-blue-400",
|
||||
badge: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
},
|
||||
restarting: {
|
||||
bg: "bg-orange-500/10",
|
||||
border: "border-orange-500/20",
|
||||
text: "text-orange-400",
|
||||
badge: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
},
|
||||
removing: {
|
||||
bg: "bg-purple-500/10",
|
||||
border: "border-purple-500/20",
|
||||
text: "text-purple-400",
|
||||
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
},
|
||||
dead: {
|
||||
bg: "bg-gray-500/10",
|
||||
border: "border-gray-500/20",
|
||||
text: "text-gray-400",
|
||||
badge: "bg-gray-500/20 text-gray-300 border-gray-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
const colors = statusColors[container.state] || statusColors.created;
|
||||
|
||||
const handleStart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
toast.success(`Container ${container.name} started`);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to start container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
toast.success(`Container ${container.name} stopped`);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to stop container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await restartDockerContainer(sessionId, container.id);
|
||||
toast.success(`Container ${container.name} restarted`);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to restart container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsPausing(true);
|
||||
try {
|
||||
if (container.state === "paused") {
|
||||
await unpauseDockerContainer(sessionId, container.id);
|
||||
toast.success(`Container ${container.name} unpaused`);
|
||||
} else {
|
||||
await pauseDockerContainer(sessionId, container.id);
|
||||
toast.success(`Container ${container.name} paused`);
|
||||
}
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to ${container.state === "paused" ? "unpause" : "pause"} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsPausing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
const force = container.state === "running";
|
||||
await removeDockerContainer(sessionId, container.id, force);
|
||||
toast.success(`Container ${container.name} removed`);
|
||||
setShowRemoveDialog(false);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to remove container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
isStarting || isStopping || isRestarting || isPausing || isRemoving;
|
||||
|
||||
// Format the created date to be more readable
|
||||
const formatCreatedDate = (dateStr: string): string => {
|
||||
try {
|
||||
// Remove the timezone suffix like "+0000 UTC"
|
||||
const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
|
||||
return cleanDate;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ports into array of port mappings
|
||||
const parsePorts = (portsStr: string | undefined): string[] => {
|
||||
if (!portsStr || portsStr.trim() === "") return [];
|
||||
|
||||
// Split by comma and clean up
|
||||
return portsStr
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
};
|
||||
|
||||
const portsList = parsePorts(container.ports);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-lg ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary border-primary"
|
||||
: `border-2 ${colors.border}`
|
||||
} ${colors.bg} pt-3 pb-0`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base font-semibold truncate flex-1">
|
||||
{container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name}
|
||||
</CardTitle>
|
||||
<Badge className={`${colors.badge} border shrink-0`}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">Image:</span>
|
||||
<span className="truncate text-gray-200 text-xs">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">ID:</span>
|
||||
<span className="font-mono text-xs text-gray-200">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">
|
||||
Ports:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.map((port, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
|
||||
>
|
||||
{port}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
|
||||
>
|
||||
None
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 min-w-[50px] text-xs">
|
||||
Created:
|
||||
</span>
|
||||
<span className="text-gray-200 text-xs">
|
||||
{formatCreatedDate(container.created)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-gray-700/50">
|
||||
<TooltipProvider>
|
||||
{container.state !== "running" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isStarting ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Start</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{container.state === "running" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={handleStop}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isStopping ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Stop</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{(container.state === "running" ||
|
||||
container.state === "paused") && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={handlePause}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isPausing ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : container.state === "paused" ? (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{container.state === "paused" ? "Unpause" : "Pause"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
onClick={handleRestart}
|
||||
disabled={isLoading || container.state === "exited"}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
<RotateCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restart</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRemoveDialog(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Container</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove container{" "}
|
||||
<span className="font-semibold">
|
||||
{container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name}
|
||||
</span>
|
||||
?
|
||||
{container.state === "running" && (
|
||||
<div className="mt-2 text-yellow-400">
|
||||
Warning: This container is currently running and will be
|
||||
force-removed.
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove();
|
||||
}}
|
||||
disabled={isRemoving}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
src/ui/desktop/apps/docker/components/ContainerDetail.tsx
Normal file
118
src/ui/desktop/apps/docker/components/ContainerDetail.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { DockerContainer, SSHHost } from "@/types/index.js";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
|
||||
interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: SSHHost;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ContainerDetail({
|
||||
sessionId,
|
||||
containerId,
|
||||
containers,
|
||||
hostConfig,
|
||||
onBack,
|
||||
}: ContainerDetailProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = React.useState("logs");
|
||||
|
||||
const container = containers.find((c) => c.id === containerId);
|
||||
|
||||
if (!container) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400 text-lg">Container not found</p>
|
||||
<Button onClick={onBack} variant="outline">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to list
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
|
||||
<Button variant="ghost" onClick={onBack} size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-bold text-lg truncate">{container.name}</h2>
|
||||
<p className="text-sm text-gray-400 truncate">{container.image}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Tabs for Logs, Stats, Console */}
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<div className="px-4 pt-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="stats">Stats</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 overflow-auto px-3 pb-3 mt-3"
|
||||
>
|
||||
<LogViewer
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={container.name}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="stats"
|
||||
className="flex-1 overflow-auto px-3 pb-3 mt-3"
|
||||
>
|
||||
<ContainerStats
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={container.name}
|
||||
containerState={container.state}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="console"
|
||||
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
|
||||
>
|
||||
<ConsoleTerminal
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={container.name}
|
||||
containerState={container.state}
|
||||
hostConfig={hostConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/ui/desktop/apps/docker/components/ContainerList.tsx
Normal file
124
src/ui/desktop/apps/docker/components/ContainerList.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Search, Filter } from "lucide-react";
|
||||
import type { DockerContainer } from "@/types/index.js";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
containers: DockerContainer[];
|
||||
sessionId: string;
|
||||
onSelectContainer: (containerId: string) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function ContainerList({
|
||||
containers,
|
||||
sessionId,
|
||||
onSelectContainer,
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
}: ContainerListProps): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
||||
|
||||
const filteredContainers = React.useMemo(() => {
|
||||
return containers.filter((container) => {
|
||||
const matchesSearch =
|
||||
container.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
container.image.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
container.id.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === "all" || container.state === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [containers, searchQuery, statusFilter]);
|
||||
|
||||
const statusCounts = React.useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
containers.forEach((c) => {
|
||||
counts[c.state] = (counts[c.state] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [containers]);
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400 text-lg">No containers found</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Start by creating containers on your server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search by name, image, or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:min-w-[200px]">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All ({containers.length})</SelectItem>
|
||||
{Object.entries(statusCounts).map(([status, count]) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)} ({count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Grid */}
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-400">No containers match your filters</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Try adjusting your search or filter
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
src/ui/desktop/apps/docker/components/ContainerStats.tsx
Normal file
242
src/ui/desktop/apps/docker/components/ContainerStats.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
|
||||
import type { DockerStats } from "@/types/index.js";
|
||||
import { getContainerStats } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
}
|
||||
|
||||
export function ContainerStats({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (containerState !== "running") {
|
||||
setError("Container must be running to view stats");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch stats");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
|
||||
// Poll stats every 2 seconds
|
||||
const interval = setInterval(fetchStats, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStats]);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
|
||||
<p className="text-gray-400 text-lg">Container is not running</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Start the container to view statistics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<SimpleLoader size="lg" />
|
||||
<p className="text-gray-400 mt-4">Loading stats...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-red-400 text-lg">Error loading stats</p>
|
||||
<p className="text-gray-500 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-gray-400">No stats available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cpuPercent = parseFloat(stats.cpu) || 0;
|
||||
const memPercent = parseFloat(stats.memoryPercent) || 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto">
|
||||
{/* CPU Usage */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
CPU Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Current</span>
|
||||
<span className="font-mono font-semibold text-blue-300">
|
||||
{stats.cpu}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(cpuPercent, 100)} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MemoryStick className="h-5 w-5 text-purple-400" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Used / Limit</span>
|
||||
<span className="font-mono font-semibold text-purple-300">
|
||||
{stats.memoryUsed} / {stats.memoryLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Percentage</span>
|
||||
<span className="font-mono text-purple-300">
|
||||
{stats.memoryPercent}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(memPercent, 100)} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network I/O */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-green-400" />
|
||||
Network I/O
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Input</span>
|
||||
<span className="font-mono text-green-300">{stats.netInput}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Output</span>
|
||||
<span className="font-mono text-green-300">
|
||||
{stats.netOutput}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Block I/O */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
Block I/O
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Read</span>
|
||||
<span className="font-mono text-orange-300">
|
||||
{stats.blockRead}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">Write</span>
|
||||
<span className="font-mono text-orange-300">
|
||||
{stats.blockWrite}
|
||||
</span>
|
||||
</div>
|
||||
{stats.pids && (
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-400">PIDs</span>
|
||||
<span className="font-mono text-orange-300">{stats.pids}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Info */}
|
||||
<Card className="md:col-span-2 py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-cyan-400" />
|
||||
Container Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">Name:</span>
|
||||
<span className="font-mono text-gray-200">{containerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">ID:</span>
|
||||
<span className="font-mono text-sm text-gray-300">
|
||||
{containerId.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400">State:</span>
|
||||
<span className="font-semibold text-green-400 capitalize">
|
||||
{containerState}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
src/ui/desktop/apps/docker/components/LogViewer.tsx
Normal file
246
src/ui/desktop/apps/docker/components/LogViewer.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Card, CardContent } from "@/components/ui/card.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Download, RefreshCw, Filter } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { DockerLogOptions } from "@/types/index.js";
|
||||
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const [logs, setLogs] = React.useState<string>("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
const [tailLines, setTailLines] = React.useState<string>("100");
|
||||
const [showTimestamps, setShowTimestamps] = React.useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = React.useState(false);
|
||||
const [searchFilter, setSearchFilter] = React.useState("");
|
||||
const logsEndRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchLogs = React.useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const options: DockerLogOptions = {
|
||||
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
|
||||
const data = await getContainerLogs(sessionId, containerId, options);
|
||||
setLogs(data.logs);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchLogs();
|
||||
}, 3000); // Refresh every 3 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
React.useEffect(() => {
|
||||
if (autoRefresh && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [logs, autoRefresh]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const options: DockerLogOptions = {
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
|
||||
const blob = await downloadContainerLogs(sessionId, containerId, options);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success("Logs downloaded successfully");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = React.useMemo(() => {
|
||||
if (!searchFilter.trim()) return logs;
|
||||
|
||||
return logs
|
||||
.split("\n")
|
||||
.filter((line) => line.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
.join("\n");
|
||||
}, [logs, searchFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls */}
|
||||
<Card className="py-3">
|
||||
<CardContent className="px-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{/* Tail Lines */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="tail-lines" className="mb-1">
|
||||
Lines to show
|
||||
</Label>
|
||||
<Select value={tailLines} onValueChange={setTailLines}>
|
||||
<SelectTrigger id="tail-lines">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">Last 50 lines</SelectItem>
|
||||
<SelectItem value="100">Last 100 lines</SelectItem>
|
||||
<SelectItem value="500">Last 500 lines</SelectItem>
|
||||
<SelectItem value="1000">Last 1000 lines</SelectItem>
|
||||
<SelectItem value="all">All logs</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="timestamps" className="mb-1">
|
||||
Show Timestamps
|
||||
</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md">
|
||||
<Switch
|
||||
id="timestamps"
|
||||
checked={showTimestamps}
|
||||
onCheckedChange={setShowTimestamps}
|
||||
/>
|
||||
<span className="ml-2 text-sm">
|
||||
{showTimestamps ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto Refresh */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="auto-refresh" className="mb-1">
|
||||
Auto Refresh
|
||||
</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<span className="ml-2 text-sm">
|
||||
{autoRefresh ? "On" : "Off"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Actions</Label>
|
||||
<div className="flex gap-2 h-10">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
className="flex-1 h-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex-1 h-full"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="mt-2">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter logs..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logs Display */}
|
||||
<Card className="flex-1 overflow-hidden py-0">
|
||||
<CardContent className="p-0 h-full">
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<SimpleLoader size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
|
||||
{filteredLogs || (
|
||||
<span className="text-gray-500">No logs available</span>
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -567,6 +567,7 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
enableDocker: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -659,6 +660,7 @@ export function HostManagerEditor({
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
enableDocker: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -754,6 +756,7 @@ export function HostManagerEditor({
|
||||
: [],
|
||||
},
|
||||
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
};
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
@@ -805,6 +808,7 @@ export function HostManagerEditor({
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
enableDocker: false,
|
||||
};
|
||||
|
||||
form.reset(defaultFormData);
|
||||
@@ -862,6 +866,7 @@ export function HostManagerEditor({
|
||||
authType: data.authType,
|
||||
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
|
||||
enableTerminal: Boolean(data.enableTerminal),
|
||||
enableDocker: Boolean(data.enableDocker),
|
||||
enableTunnel: Boolean(data.enableTunnel),
|
||||
enableFileManager: Boolean(data.enableFileManager),
|
||||
defaultPath: data.defaultPath || "/",
|
||||
@@ -949,9 +954,8 @@ export function HostManagerEditor({
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
if (savedHost?.id) {
|
||||
const { notifyHostCreatedOrUpdated } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
const { notifyHostCreatedOrUpdated } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
notifyHostCreatedOrUpdated(savedHost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -984,6 +988,8 @@ export function HostManagerEditor({
|
||||
setActiveTab("general");
|
||||
} else if (errors.enableTerminal || errors.terminalConfig) {
|
||||
setActiveTab("terminal");
|
||||
} else if (errors.enableDocker) {
|
||||
setActiveTab("docker");
|
||||
} else if (errors.enableTunnel || errors.tunnelConnections) {
|
||||
setActiveTab("tunnel");
|
||||
} else if (errors.enableFileManager || errors.defaultPath) {
|
||||
@@ -1176,6 +1182,7 @@ export function HostManagerEditor({
|
||||
<TabsTrigger value="terminal">
|
||||
{t("hosts.terminal")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">
|
||||
{t("hosts.fileManager")}
|
||||
@@ -2551,6 +2558,26 @@ export function HostManagerEditor({
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableDocker"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Docker</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable Docker integration for this host
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="tunnel">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
@@ -64,7 +63,7 @@ interface ServerProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
export function ServerStats({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
@@ -462,7 +461,7 @@ export function Server({
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
|
||||
<div className="rounded-lg border-dark-border m-3 p-1 overflow-y-auto relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
@@ -600,20 +599,6 @@ export function Server({
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<UserCheck className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const interfaces = network?.interfaces || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-indigo-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const topProcesses = processes?.top || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<List className="h-5 w-5 text-yellow-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
|
||||
const system = metricsWithSystem?.system;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Server className="h-5 w-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
||||
const uptime = metricsWithUptime?.uptime;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Clock className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
143
src/ui/desktop/apps/tunnel/TunnelManager.tsx
Normal file
143
src/ui/desktop/apps/tunnel/TunnelManager.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TunnelManagerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TunnelManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: TunnelManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-1">
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 ? (
|
||||
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400 text-lg">
|
||||
{t("tunnel.noTunnelsConfigured")}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
{t("tunnel.configureTunnelsInHostSettings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,11 +43,6 @@ export function TunnelViewer({
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{t("tunnels.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
|
||||
import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx";
|
||||
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
|
||||
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
|
||||
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
@@ -58,7 +60,9 @@ export function AppView({
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "file_manager",
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "tunnel" ||
|
||||
tab.type === "docker",
|
||||
),
|
||||
[tabs],
|
||||
);
|
||||
@@ -210,7 +214,10 @@ export function AppView({
|
||||
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
const isFileManagerTab = mainTab.type === "file_manager";
|
||||
const isFileManagerTab =
|
||||
mainTab.type === "file_manager" ||
|
||||
mainTab.type === "tunnel" ||
|
||||
mainTab.type === "docker";
|
||||
const newStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
@@ -257,9 +264,14 @@ export function AppView({
|
||||
const isVisible =
|
||||
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
|
||||
|
||||
const effectiveVisible = isVisible;
|
||||
|
||||
const previousStyle = previousStylesRef.current[t.id];
|
||||
|
||||
const isFileManagerTab = t.type === "file_manager";
|
||||
const isFileManagerTab =
|
||||
t.type === "file_manager" ||
|
||||
t.type === "tunnel" ||
|
||||
t.type === "docker";
|
||||
const standardStyle = {
|
||||
position: "absolute" as const,
|
||||
top: isFileManagerTab ? 0 : 4,
|
||||
@@ -270,16 +282,24 @@ export function AppView({
|
||||
|
||||
const finalStyle: React.CSSProperties = hasStyle
|
||||
? { ...styles[t.id], overflow: "hidden" }
|
||||
: ({
|
||||
...(previousStyle || standardStyle),
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
overflow: "hidden",
|
||||
} as React.CSSProperties);
|
||||
|
||||
const effectiveVisible = isVisible;
|
||||
: effectiveVisible
|
||||
? {
|
||||
...(previousStyle || standardStyle),
|
||||
opacity: 1,
|
||||
pointerEvents: "auto",
|
||||
zIndex: 20,
|
||||
display: "block",
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
overflow: "hidden",
|
||||
}
|
||||
: ({
|
||||
...(previousStyle || standardStyle),
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
transition: "opacity 150ms ease-in-out",
|
||||
overflow: "hidden",
|
||||
} as React.CSSProperties);
|
||||
|
||||
const isTerminal = t.type === "terminal";
|
||||
const terminalConfig = {
|
||||
@@ -317,6 +337,22 @@ export function AppView({
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "docker" ? (
|
||||
<DockerManager
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
<FileManager
|
||||
embedded
|
||||
@@ -636,6 +672,8 @@ export function AppView({
|
||||
|
||||
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const isFileManager = currentTabData?.type === "file_manager";
|
||||
const isTunnel = currentTabData?.type === "tunnel";
|
||||
const isDocker = currentTabData?.type === "docker";
|
||||
const isTerminal = currentTabData?.type === "terminal";
|
||||
const isSplitScreen = allSplitScreenTab.length > 0;
|
||||
|
||||
@@ -653,7 +691,7 @@ export function AppView({
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
let containerBackground = "var(--color-dark-bg)";
|
||||
if (isFileManager && !isSplitScreen) {
|
||||
if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) {
|
||||
containerBackground = "var(--color-dark-bg-darkest)";
|
||||
} else if (isTerminal) {
|
||||
containerBackground = terminalBackgroundColor;
|
||||
|
||||
@@ -369,10 +369,13 @@ export function TopNavbar({
|
||||
const isTerminal = tab.type === "terminal";
|
||||
const isServer = tab.type === "server";
|
||||
const isFileManager = tab.type === "file_manager";
|
||||
const isTunnel = tab.type === "tunnel";
|
||||
const isDocker = tab.type === "docker";
|
||||
const isSshManager = tab.type === "ssh_manager";
|
||||
const isAdmin = tab.type === "admin";
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isSplittable = isTerminal || isServer || isFileManager;
|
||||
const isSplittable =
|
||||
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
||||
const disableSplit = !isSplittable;
|
||||
const disableActivate =
|
||||
isSplit ||
|
||||
@@ -484,6 +487,8 @@ export function TopNavbar({
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isTunnel ||
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
@@ -498,6 +503,8 @@ export function TopNavbar({
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isTunnel ||
|
||||
isDocker ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Server,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
}, [host.statsConfig]);
|
||||
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowStatus) {
|
||||
@@ -151,24 +154,50 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">Open Server Details</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">Open File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
{shouldShowMetrics && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">Open Server Stats</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">Open File Manager</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">Open Tunnels</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.enableDocker && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">Open Docker</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
User as UserIcon,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TabProps {
|
||||
@@ -119,10 +121,14 @@ export function Tab({
|
||||
tabType === "terminal" ||
|
||||
tabType === "server" ||
|
||||
tabType === "file_manager" ||
|
||||
tabType === "tunnel" ||
|
||||
tabType === "docker" ||
|
||||
tabType === "user_profile"
|
||||
) {
|
||||
const isServer = tabType === "server";
|
||||
const isFileManager = tabType === "file_manager";
|
||||
const isTunnel = tabType === "tunnel";
|
||||
const isDocker = tabType === "docker";
|
||||
const isUserProfile = tabType === "user_profile";
|
||||
|
||||
const displayTitle =
|
||||
@@ -131,9 +137,13 @@ export function Tab({
|
||||
? t("nav.serverStats")
|
||||
: isFileManager
|
||||
? t("nav.fileManager")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"));
|
||||
: isTunnel
|
||||
? t("nav.tunnels")
|
||||
: isDocker
|
||||
? t("nav.docker")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"));
|
||||
|
||||
const { base, suffix } = splitTitle(displayTitle);
|
||||
|
||||
@@ -151,6 +161,10 @@ export function Tab({
|
||||
<ServerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isFileManager ? (
|
||||
<FolderIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isTunnel ? (
|
||||
<TunnelIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isDocker ? (
|
||||
<DockerIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : isUserProfile ? (
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
|
||||
@@ -76,7 +76,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
? t("nav.serverStats")
|
||||
: tabType === "file_manager"
|
||||
? t("nav.fileManager")
|
||||
: t("nav.terminal");
|
||||
: tabType === "tunnel"
|
||||
? t("nav.tunnels")
|
||||
: tabType === "docker"
|
||||
? t("nav.docker")
|
||||
: t("nav.terminal");
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
@@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const needsUniqueTitle =
|
||||
tabData.type === "terminal" ||
|
||||
tabData.type === "server" ||
|
||||
tabData.type === "file_manager";
|
||||
tabData.type === "file_manager" ||
|
||||
tabData.type === "tunnel" ||
|
||||
tabData.type === "docker";
|
||||
const effectiveTitle = needsUniqueTitle
|
||||
? computeUniqueTitle(tabData.type, tabData.title)
|
||||
: tabData.title || "";
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
ArrowDownUp as TunnelIcon,
|
||||
Container as DockerIcon,
|
||||
Shield as AdminIcon,
|
||||
Network as SshManagerIcon,
|
||||
User as UserIcon,
|
||||
@@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement {
|
||||
return <ServerIcon className="h-4 w-4" />;
|
||||
case "file_manager":
|
||||
return <FolderIcon className="h-4 w-4" />;
|
||||
case "tunnel":
|
||||
return <TunnelIcon className="h-4 w-4" />;
|
||||
case "docker":
|
||||
return <DockerIcon className="h-4 w-4" />;
|
||||
case "user_profile":
|
||||
return <UserIcon className="h-4 w-4" />;
|
||||
case "ssh_manager":
|
||||
@@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement {
|
||||
return tab.title || t("nav.serverStats");
|
||||
case "file_manager":
|
||||
return tab.title || t("nav.fileManager");
|
||||
case "tunnel":
|
||||
return tab.title || t("nav.tunnels");
|
||||
case "docker":
|
||||
return tab.title || t("nav.docker");
|
||||
case "user_profile":
|
||||
return tab.title || t("nav.userProfile");
|
||||
case "ssh_manager":
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
TunnelStatus,
|
||||
FileManagerFile,
|
||||
FileManagerShortcut,
|
||||
DockerContainer,
|
||||
DockerStats,
|
||||
DockerLogOptions,
|
||||
DockerValidation,
|
||||
} from "../types/index.js";
|
||||
|
||||
// ============================================================================
|
||||
@@ -639,6 +643,9 @@ function initializeApiInstances() {
|
||||
|
||||
// RBAC API (port 30001)
|
||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||
|
||||
// Docker Management API (port 30007)
|
||||
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
||||
}
|
||||
|
||||
// SSH Host Management API (port 30001)
|
||||
@@ -662,6 +669,9 @@ export let homepageApi: AxiosInstance;
|
||||
// RBAC API (port 30001)
|
||||
export let rbacApi: AxiosInstance;
|
||||
|
||||
// Docker Management API (port 30007)
|
||||
export let dockerApi: AxiosInstance;
|
||||
|
||||
function initializeApp() {
|
||||
if (isElectron()) {
|
||||
getServerConfig()
|
||||
@@ -904,6 +914,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
enableTerminal: Boolean(hostData.enableTerminal),
|
||||
enableTunnel: Boolean(hostData.enableTunnel),
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
enableDocker: Boolean(hostData.enableDocker),
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
@@ -913,6 +924,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
? hostData.statsConfig
|
||||
: JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
dockerConfig: hostData.dockerConfig
|
||||
? typeof hostData.dockerConfig === "string"
|
||||
? hostData.dockerConfig
|
||||
: JSON.stringify(hostData.dockerConfig)
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
};
|
||||
@@ -970,6 +986,7 @@ export async function updateSSHHost(
|
||||
enableTerminal: Boolean(hostData.enableTerminal),
|
||||
enableTunnel: Boolean(hostData.enableTunnel),
|
||||
enableFileManager: Boolean(hostData.enableFileManager),
|
||||
enableDocker: Boolean(hostData.enableDocker),
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
@@ -979,6 +996,11 @@ export async function updateSSHHost(
|
||||
? hostData.statsConfig
|
||||
: JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
dockerConfig: hostData.dockerConfig
|
||||
? typeof hostData.dockerConfig === "string"
|
||||
? hostData.dockerConfig
|
||||
: JSON.stringify(hostData.dockerConfig)
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig || null,
|
||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||
};
|
||||
@@ -3280,5 +3302,239 @@ export async function revokeHostAccess(
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke host access");
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER MANAGEMENT API
|
||||
// ============================================================================
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user