mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,898 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { systemLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../../src/types/index.js";
|
||||
import { applyAgentAuth } from "../../../../src/backend/hosts/terminal-auth-helpers.js";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../../../../src/backend/hosts/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
|
||||
import {
|
||||
asObject,
|
||||
asString,
|
||||
MAX_WS_MESSAGE_BYTES,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../../../src/backend/utils/ws-message.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: import("ssh2").ClientChannel | null;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
hostId?: number;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
const CONSOLE_PORT = 30009;
|
||||
|
||||
/**
|
||||
* Created by startConsoleServer(), not at module load.
|
||||
*
|
||||
* Importing this file used to bind the port as a side effect, which meant the
|
||||
* plugin could not be disabled and re-enabled: the module stays in the ESM
|
||||
* cache, so the second activate() got a server that had already been closed.
|
||||
*/
|
||||
let wss: WebSocketServer | null = null;
|
||||
|
||||
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
|
||||
void onConsoleConnection(ws, req);
|
||||
}
|
||||
|
||||
/** Starts the console WebSocket server. Call from the plugin's activate(). */
|
||||
export function startConsoleServer(): Promise<void> {
|
||||
if (wss) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = new WebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: CONSOLE_PORT,
|
||||
maxPayload: MAX_WS_MESSAGE_BYTES,
|
||||
});
|
||||
|
||||
const onStartupError = (error: Error) => {
|
||||
server.off("listening", onListening);
|
||||
wss = null;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const onListening = () => {
|
||||
server.off("error", onStartupError);
|
||||
// Past startup an error is logged rather than fatal: one bad socket
|
||||
// must not take the server down.
|
||||
server.on("error", (error) => {
|
||||
sshLogger.error("Docker console WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
|
||||
server.once("error", onStartupError);
|
||||
server.once("listening", onListening);
|
||||
server.on("connection", handleConnection);
|
||||
|
||||
wss = server;
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
containerCommand(
|
||||
session.containerRuntime,
|
||||
`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", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const jumpHostId = jumpHosts[i].hostId;
|
||||
|
||||
const resolvedJumpHost = await repository.findHostById(jumpHostId, userId);
|
||||
if (!resolvedJumpHost || resolvedJumpHost.userId !== userId) {
|
||||
throw new Error(`Jump host ${jumpHostId} not found`);
|
||||
}
|
||||
|
||||
const jumpHost = resolvedJumpHost as unknown as SSHHost;
|
||||
if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) {
|
||||
try {
|
||||
jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts);
|
||||
} catch (e) {
|
||||
sshLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: jumpHost.id,
|
||||
});
|
||||
jumpHost.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: jumpHost.password,
|
||||
sshKey: jumpHost.key,
|
||||
keyPassword: jumpHost.keyPassword,
|
||||
authType: jumpHost.authType,
|
||||
};
|
||||
|
||||
if (jumpHost.credentialId) {
|
||||
const credential = await repository.findCredentialByIdForUser(
|
||||
jumpHost.credentialId as number,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credential) {
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.key || credential.privateKey) as
|
||||
string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpHost.username,
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
jumpHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
currentClient = client;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
}
|
||||
|
||||
async function onConsoleConnection(
|
||||
ws: WebSocket,
|
||||
req: import("http").IncomingMessage,
|
||||
) {
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
let payload;
|
||||
try {
|
||||
payload = await authManagerInstance.verifyJWTToken(token);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Docker console JWT verification failed", {
|
||||
operation: "docker_console_auth_error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(wsPingInterval);
|
||||
if (!sshSession) return;
|
||||
sshSession.stream?.end();
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
sshSession = null;
|
||||
};
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = parseWsMessage(data);
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const connectData = asObject(message.data);
|
||||
const hostConfig = asObject(
|
||||
connectData.hostConfig,
|
||||
) as unknown as SSHHost;
|
||||
const containerId = asString(connectData.containerId);
|
||||
const shell = asString(connectData.shell) || undefined;
|
||||
const cols = toTerminalDimension(connectData.cols) || 80;
|
||||
const rows = toTerminalDimension(connectData.rows) || 24;
|
||||
|
||||
const hostId = hostConfig?.id;
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../../../../src/backend/hosts/host-resolver.js");
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const hostSyncId = hostConfig?.syncId;
|
||||
const resolvedHost = hostSyncId
|
||||
? await resolveHostBySyncId(hostSyncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: hostSyncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The connection below dials resolvedHost.ip outright, so if this
|
||||
// server has a different machine under the id the client sent, the
|
||||
// console opens on that machine's Docker daemon instead.
|
||||
if (
|
||||
!hostSyncId &&
|
||||
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
|
||||
) {
|
||||
sshLogger.error(
|
||||
"Refusing Docker console: host id resolves to a different address here",
|
||||
undefined,
|
||||
{
|
||||
operation: "docker_console_host_id_mismatch",
|
||||
hostId,
|
||||
userId,
|
||||
clientIp: hostConfig?.ip,
|
||||
resolvedIp: resolvedHost.ip,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
|
||||
port: resolvedHost.port || 22,
|
||||
username: resolvedHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 120,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (resolvedHost.authType === "password" && resolvedHost.password) {
|
||||
config.password = resolvedHost.password;
|
||||
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
|
||||
const cleanKey = resolvedHost.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedHost.keyPassword) {
|
||||
config.passphrase = resolvedHost.keyPassword;
|
||||
}
|
||||
} else if (resolvedHost.authType === "agent") {
|
||||
const result = await applyAgentAuth(
|
||||
config,
|
||||
resolvedHost.terminalConfig as unknown as
|
||||
Record<string, unknown> | undefined,
|
||||
);
|
||||
if ("error" in result) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: result.error,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
resolvedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
const stream = await new Promise<import("ssh2").ClientChannel>(
|
||||
(resolve, reject) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
resolvedHost.ip,
|
||||
resolvedHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
config.sock = stream;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.sock) {
|
||||
await resolveSshConnectConfigHost(config);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.on("ready", () => resolve());
|
||||
client.on("error", reject);
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
const { runtime: containerRuntime } = getContainerRuntimeConfig(
|
||||
resolvedHost.dockerConfig,
|
||||
);
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: resolvedHost.id,
|
||||
containerRuntime,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
let shellToUse = shell || "bash";
|
||||
|
||||
if (shell) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.exec(
|
||||
containerCommand(
|
||||
containerRuntime,
|
||||
`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 available`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
sshLogger.warn(
|
||||
`Requested shell ${shell} not found, detecting available shell`,
|
||||
{
|
||||
operation: "shell_validation",
|
||||
sessionId,
|
||||
containerId,
|
||||
requestedShell: shell,
|
||||
},
|
||||
);
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
} else {
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
|
||||
sshSession.shell = shellToUse;
|
||||
|
||||
const execCommand = containerCommand(
|
||||
containerRuntime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
);
|
||||
sshLogger.info("Attaching to Docker container", {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.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;
|
||||
sshLogger.success("Docker container attached", {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
// Buffers incomplete multi-byte UTF-8 sequences across chunk
|
||||
// boundaries so box-drawing/special characters don't get
|
||||
// corrupted when a character is split across TCP packets.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const text = decoder.write(data);
|
||||
if (!text) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Console session ended",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (sshSession) {
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: shell && shell !== shellToUse,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to connect to container", error, {
|
||||
operation: "console_connect",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Failed to connect to container",
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const input = asString(message.data);
|
||||
if (input) sshSession.stream.write(input);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const dimensions = asObject(message.data);
|
||||
const cols = toTerminalDimension(dimensions.cols);
|
||||
const rows = toTerminalDimension(dimensions.rows);
|
||||
if (cols && rows)
|
||||
sshSession.stream.setWindow(rows, cols, rows, cols);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ping": {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket message error", error, {
|
||||
operation: "ws_message",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "An error occurred"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
sshLogger.info("Docker console disconnected", {
|
||||
operation: "docker_console_disconnect",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: sshSession?.hostId,
|
||||
containerId: sshSession?.containerId,
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes every live console session and the WebSocket server, freeing port
|
||||
* 30009. Called from the plugin's deactivate().
|
||||
*
|
||||
* There is deliberately no process signal handler here. Core owns shutdown:
|
||||
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
|
||||
* runs every plugin's deactivate. A plugin calling process.exit() itself
|
||||
* skipped the rest of that sequence, including other plugins and the
|
||||
* database flush.
|
||||
*/
|
||||
export function closeConsoleServer(): Promise<void> {
|
||||
activeSessions.forEach((session) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
const server = wss;
|
||||
wss = null;
|
||||
if (!server) return Promise.resolve();
|
||||
|
||||
server.off("connection", handleConnection);
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
export type ContainerRuntime = "docker" | "podman";
|
||||
|
||||
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||
return value === "podman" ? "podman" : "docker";
|
||||
}
|
||||
|
||||
export function getContainerRuntimeConfig(raw: unknown): {
|
||||
runtime: ContainerRuntime;
|
||||
} {
|
||||
if (!raw) {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
let config: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!config || typeof config !== "object") {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: normalizeContainerRuntime(
|
||||
(config as Record<string, unknown>).runtime,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
* Extend PATH with their common install locations before invoking the CLI.
|
||||
*/
|
||||
const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
export function containerCommand(
|
||||
runtime: ContainerRuntime | undefined,
|
||||
args: string,
|
||||
): string {
|
||||
return `PATH="${EXTRA_PATH_DIRS}:$PATH" ${normalizeContainerRuntime(runtime)} ${args}`;
|
||||
}
|
||||
|
||||
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
||||
return runtime === "podman" ? "Podman" : "Docker";
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||
import {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
executeDockerCommand,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
getRequestUserId,
|
||||
registerDockerSshRoutes,
|
||||
} from "./routes.js";
|
||||
import { startConsoleServer, closeConsoleServer } from "./console.js";
|
||||
|
||||
const PORT = 30007;
|
||||
|
||||
let httpServer: Server | null = null;
|
||||
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
||||
null;
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
app.use(authManagerInstance.createAuthMiddleware());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
app.use(express.urlencoded({ limit: "100mb", extended: true }));
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
httpServer = server;
|
||||
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
// The console server is started here rather than on import so disabling the
|
||||
// plugin frees port 30009 and re-enabling can take it again.
|
||||
await startConsoleServer();
|
||||
|
||||
ctx.log.info(`Docker plugin listening on ${PORT} (console on 30009)`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await closeConsoleServer();
|
||||
|
||||
if (httpServer) {
|
||||
const server = httpServer;
|
||||
httpServer = null;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
authManagerInstance = null;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
isWindows?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export const sshSessions: Record<string, SSHSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
|
||||
const session = pendingTOTPSessions[sessionId];
|
||||
if (now - session.createdAt > 180000) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
}
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
export function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
sshLogger.warn(
|
||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
session.timeout = setTimeout(() => {
|
||||
cleanupSession(sessionId);
|
||||
}, SESSION_IDLE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDockerCommand(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sessionId?: string,
|
||||
userId?: string,
|
||||
hostId?: number,
|
||||
): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
sshLogger.info("Executing Docker command", {
|
||||
operation: "docker_command_exec",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Docker command execution error", err, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
sshLogger.error("Docker command failed", undefined, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
exitCode: code,
|
||||
stderr,
|
||||
});
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
sshLogger.success("Docker command completed", {
|
||||
operation: "docker_command_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
sshLogger.error("Docker command stream error", streamErr, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
reject(streamErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerApp;
|
||||
@@ -0,0 +1,883 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
List as ListIcon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
import {
|
||||
connectDockerSession,
|
||||
disconnectDockerSession,
|
||||
listDockerContainers,
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
verifyDockerWarpgate,
|
||||
logActivity,
|
||||
getSSHHosts,
|
||||
} from "@/main-axios.ts";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerTable } from "./components/ContainerTable.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import {
|
||||
useAreaPreferences,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
interface DockerConnectionError {
|
||||
message?: string;
|
||||
connectionLogs?: ConnectionLogInput[];
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const uiPrefsCtx = useUiPreferencesContext();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
const [selectedContainer, setSelectedContainer] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [detailInitialTab, setDetailInitialTab] = React.useState<
|
||||
"logs" | "stats" | "console"
|
||||
>("logs");
|
||||
const [containerLayout, setContainerLayout] = React.useState<
|
||||
"card" | "table"
|
||||
>(dockerPrefs.containerLayout);
|
||||
|
||||
const handleSetContainerLayout = React.useCallback(
|
||||
(layout: "card" | "table") => {
|
||||
setContainerLayout(layout);
|
||||
uiPrefsCtx?.setOverride("docker", "containerLayout", layout);
|
||||
},
|
||||
[uiPrefsCtx],
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
// Initial view follows the interface preset; switching it afterwards is a
|
||||
// per-session choice, same as before.
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
|
||||
dockerPrefs.viewMode,
|
||||
);
|
||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
|
||||
React.useState(false);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
|
||||
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] =
|
||||
React.useState<string>("");
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const logDockerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("docker", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log docker activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 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 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 initializingRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initSession = async () => {
|
||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializingRef.current) return;
|
||||
initializingRef.current = true;
|
||||
|
||||
if (sessionId) {
|
||||
initializingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
clearLogs();
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
const dockerError = error as DockerConnectionError;
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
|
||||
if (Array.isArray(dockerError.connectionLogs)) {
|
||||
setLogs(dockerError.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: dockerError.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
initSession();
|
||||
|
||||
return () => {
|
||||
initializingRef.current = false;
|
||||
if (sessionId) {
|
||||
disconnectDockerSession(sessionId).catch(() => {
|
||||
// Silently handle disconnect errors
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
keepaliveDockerSession(sessionId).catch(() => {
|
||||
// Silently handle keepalive errors
|
||||
});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
setContainers(data);
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasLoadedContainersOnce(false);
|
||||
}, [sessionId]);
|
||||
|
||||
useAdaptivePolling(
|
||||
async () => {
|
||||
if (!sessionId) return;
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
setContainers(data);
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: 5000,
|
||||
maxIntervalMs: 30000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
!!sessionId && isVisible && !!dockerValidation?.available,
|
||||
{
|
||||
onError: () => {
|
||||
setIsLoadingContainers(false);
|
||||
setHasLoadedContainersOnce(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setViewMode("list");
|
||||
setSelectedContainer(null);
|
||||
}, []);
|
||||
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!totpSessionId || !code) return;
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerTOTP(totpSessionId, code);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setSessionId(totpSessionId);
|
||||
setTotpSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(totpSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotpCancel = () => {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpPrompt("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateContinue = async () => {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerWarpgate(warpgateSessionId);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(warpgateSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateCancel = () => {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateSessionId(null);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateOpenUrl = () => {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(true);
|
||||
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason("auth_failed");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthCancel = () => {
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
initializingRef.current = false;
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
clearLogs();
|
||||
setRetryCount((c) => c + 1);
|
||||
};
|
||||
|
||||
const dockerConnectRetry = useConnectionRetry({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled:
|
||||
!!currentHostConfig?.enableDocker &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
|
||||
dockerConnectRetryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 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-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("docker.manager")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={
|
||||
isValidating ? t("docker.validating") : t("docker.connecting")
|
||||
}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connectionFailed")}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
currentHostConfig ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHostConfig}
|
||||
onBack={handleBack}
|
||||
initialTab={detailInitialTab}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-accent-brand" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{dockerValidation?.version
|
||||
? t("docker.version", {
|
||||
runtime:
|
||||
dockerValidation.runtime === "podman"
|
||||
? "Podman"
|
||||
: "Docker",
|
||||
version: dockerValidation.version,
|
||||
})
|
||||
: t("docker.manager")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docker.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
<Select2
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="h-8 px-2 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="all">{t("docker.allStatuses")}</option>
|
||||
<option value="running">{t("docker.stateRunning")}</option>
|
||||
<option value="paused">{t("docker.statePaused")}</option>
|
||||
<option value="exited">{t("docker.stateExited")}</option>
|
||||
<option value="restarting">
|
||||
{t("docker.stateRestarting")}
|
||||
</option>
|
||||
</Select2>
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<div className="flex items-center border border-border overflow-hidden">
|
||||
<Button
|
||||
variant={containerLayout === "card" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => handleSetContainerLayout("card")}
|
||||
className={`size-8 rounded-none ${containerLayout === "card" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
title={t("docker.cardView")}
|
||||
>
|
||||
<Grid3X3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
containerLayout === "table" ? "secondary" : "ghost"
|
||||
}
|
||||
size="icon"
|
||||
onClick={() => handleSetContainerLayout("table")}
|
||||
className={`size-8 rounded-none border-l border-border ${containerLayout === "table" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
title={t("docker.listView")}
|
||||
>
|
||||
<ListIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={refreshContainers}
|
||||
disabled={isLoadingContainers}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/docker"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-center size-9 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("hosts.docsLink")}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{sessionId ? (
|
||||
!hasLoadedContainersOnce ? (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<RefreshCw className="size-8 animate-spin mb-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.loadingContainers")}
|
||||
</span>
|
||||
</div>
|
||||
) : containerLayout === "table" ? (
|
||||
<ContainerTable
|
||||
containers={containers}
|
||||
sessionId={sessionId}
|
||||
onSelectContainer={(id, tab) => {
|
||||
setSelectedContainer(id);
|
||||
setDetailInitialTab(tab ?? "logs");
|
||||
setViewMode("detail");
|
||||
}}
|
||||
selectedContainerId={selectedContainer}
|
||||
onRefresh={refreshContainers}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
) : (
|
||||
<ContainerList
|
||||
containers={containers}
|
||||
sessionId={sessionId}
|
||||
onSelectContainer={(id) => {
|
||||
setSelectedContainer(id);
|
||||
setDetailInitialTab("logs");
|
||||
setViewMode("detail");
|
||||
}}
|
||||
selectedContainerId={selectedContainer}
|
||||
onRefresh={refreshContainers}
|
||||
search={search}
|
||||
statusFilter={statusFilter}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHostConfig && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHostConfig.ip,
|
||||
port: currentHostConfig.port,
|
||||
username: currentHostConfig.username,
|
||||
name: currentHostConfig.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
attempt={dockerConnectRetry.attempt}
|
||||
maxAttempts={dockerConnectRetry.maxAttempts}
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
retryLabel={t("terminal.retry")}
|
||||
logPosition="top"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DockerManager(props: DockerManagerProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<DockerManagerInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { Card, CardContent } from "@/components/card.tsx";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: SSHHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal(
|
||||
props: ConsoleTerminalProps,
|
||||
): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ConsoleTerminalInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsoleTerminalInner({
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
hostConfig,
|
||||
}: ConsoleTerminalProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
[hostConfig.terminalConfig],
|
||||
);
|
||||
|
||||
const themeColors = React.useMemo(() => {
|
||||
const activeTheme = terminalConfig.theme;
|
||||
return resolveTermixThemeColors(activeTheme, appTheme);
|
||||
}, [terminalConfig.theme, appTheme]);
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardProvider = new RobustClipboardProvider();
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === terminalConfig.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
|
||||
|
||||
terminal.options.cursorBlink = terminalConfig.cursorBlink;
|
||||
terminal.options.cursorStyle = terminalConfig.cursorStyle;
|
||||
terminal.options.fontSize = terminalConfig.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.scrollback = terminalConfig.scrollback;
|
||||
terminal.options.letterSpacing = terminalConfig.letterSpacing;
|
||||
terminal.options.lineHeight = terminalConfig.lineHeight;
|
||||
|
||||
const readTextFromClipboard = async (): Promise<string> => {
|
||||
return readFromClipboard();
|
||||
};
|
||||
|
||||
const writeTextToClipboard = async (text: string): Promise<void> => {
|
||||
await copyToClipboard(text);
|
||||
};
|
||||
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") return true;
|
||||
|
||||
if (
|
||||
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
|
||||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "c" &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
terminal.clearSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert" &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.shiftKey &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
terminal.options.theme = {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
selectionBackground: themeColors.selectionBackground,
|
||||
selectionForeground: themeColors.selectionForeground,
|
||||
black: themeColors.black,
|
||||
red: themeColors.red,
|
||||
green: themeColors.green,
|
||||
yellow: themeColors.yellow,
|
||||
blue: themeColors.blue,
|
||||
magenta: themeColors.magenta,
|
||||
cyan: themeColors.cyan,
|
||||
white: themeColors.white,
|
||||
brightBlack: themeColors.brightBlack,
|
||||
brightRed: themeColors.brightRed,
|
||||
brightGreen: themeColors.brightGreen,
|
||||
brightYellow: themeColors.brightYellow,
|
||||
brightBlue: themeColors.brightBlue,
|
||||
brightMagenta: themeColors.brightMagenta,
|
||||
brightCyan: themeColors.brightCyan,
|
||||
brightWhite: themeColors.brightWhite,
|
||||
};
|
||||
|
||||
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);
|
||||
clipboardProvider.dispose();
|
||||
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect during cleanup.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [terminal, t, terminalConfig, themeColors]);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
setIsConnected(false);
|
||||
retryRef.current.reset();
|
||||
clearLogs();
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
} catch {
|
||||
// Terminal clear can fail after disposal.
|
||||
}
|
||||
}
|
||||
}, [terminal, clearLogs]);
|
||||
|
||||
const connect = React.useCallback(async () => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
toast.error(t("docker.containerMustBeRunning"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "docker_connecting",
|
||||
message: t("docker.connectingTo", { containerName }),
|
||||
});
|
||||
|
||||
try {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
const isDev =
|
||||
!isElectronApp &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
let baseWsUrl: string;
|
||||
let wsProtocols: string[] = [];
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
||||
} else if (isElectronApp) {
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
});
|
||||
const resolvedUrl = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/docker/console/",
|
||||
});
|
||||
if (!resolvedUrl) {
|
||||
setIsConnecting(false);
|
||||
toast.error(t("errors.remoteServerRequired"));
|
||||
return;
|
||||
}
|
||||
baseWsUrl = resolvedUrl.url;
|
||||
wsProtocols = resolvedUrl.protocols;
|
||||
} else {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(baseWsUrl, wsProtocols);
|
||||
|
||||
ws.onopen = () => {
|
||||
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);
|
||||
retryRef.current.markConnected();
|
||||
|
||||
if (msg.data?.shellChanged) {
|
||||
toast.warning(
|
||||
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
|
||||
);
|
||||
} else {
|
||||
toast.success(t("docker.connectedTo", { containerName }));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
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);
|
||||
retryRef.current.reset();
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
|
||||
);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
setIsConnecting(false);
|
||||
toast.error(msg.message || t("docker.consoleError"));
|
||||
terminal.write(
|
||||
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: msg.message || t("docker.consoleError"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
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(t("docker.failedToConnect"));
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message: t("docker.failedToConnect"),
|
||||
});
|
||||
retryRef.current.markFailed();
|
||||
};
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
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;
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
const message = `Failed to connect: ${getErrorMessage(error)}`;
|
||||
toast.error(message);
|
||||
addLog({ type: "error", stage: "error", message });
|
||||
retryRef.current.markFailed();
|
||||
}
|
||||
}, [
|
||||
terminal,
|
||||
containerState,
|
||||
hostConfig,
|
||||
containerId,
|
||||
selectedShell,
|
||||
containerName,
|
||||
t,
|
||||
addLog,
|
||||
]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect,
|
||||
autoStart: false,
|
||||
});
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch {
|
||||
// Best-effort disconnect 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-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.containerNotRunning")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.startContainerToAccess")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<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">
|
||||
{t("docker.console")}
|
||||
</span>
|
||||
</div>
|
||||
<Select2
|
||||
value={selectedShell}
|
||||
onChange={(event) => setSelectedShell(event.target.value)}
|
||||
disabled={isConnected}
|
||||
placeholder={t("docker.selectShell")}
|
||||
className="w-[120px]"
|
||||
>
|
||||
<option value="bash">{t("docker.bash")}</option>
|
||||
<option value="sh">{t("docker.sh")}</option>
|
||||
<option value="ash">{t("docker.ash")}</option>
|
||||
</Select2>
|
||||
<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" />
|
||||
{t("docker.connecting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="h-4 w-4 mr-2" />
|
||||
{t("docker.connect")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={disconnect}
|
||||
variant="destructive"
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<PowerOff className="h-4 w-4 mr-2" />
|
||||
{t("docker.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="flex-1 overflow-hidden pt-1 pb-0"
|
||||
style={{ background: themeColors.background }}
|
||||
>
|
||||
<CardContent className="p-0 h-full relative">
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isConnected ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{!isConnected &&
|
||||
!isConnecting &&
|
||||
retry.status !== "error" &&
|
||||
retry.status !== "disconnected" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.notConnected")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("docker.clickToConnect")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isConnected &&
|
||||
(isConnecting ||
|
||||
retry.status === "error" ||
|
||||
retry.status === "disconnected") && (
|
||||
<ConnectionScreen
|
||||
status={isConnecting ? "connecting" : retry.status}
|
||||
message={t("docker.connectingTo", { containerName })}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={() => {
|
||||
clearLogs();
|
||||
retry.retryNow();
|
||||
}}
|
||||
retryLabel={t("docker.connect")}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Pause,
|
||||
PlayCircle,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import {
|
||||
startDockerContainer,
|
||||
stopDockerContainer,
|
||||
restartDockerContainer,
|
||||
pauseDockerContainer,
|
||||
unpauseDockerContainer,
|
||||
removeDockerContainer,
|
||||
} from "@/main-axios.ts";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
sessionId: string;
|
||||
onSelect?: () => void;
|
||||
isSelected?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function DockerBadge({ state }: { state: DockerContainer["state"] }) {
|
||||
let colorClass = "border-border text-muted-foreground";
|
||||
if (state === "running")
|
||||
colorClass = "border-accent-brand/40 text-accent-brand bg-accent-brand/10";
|
||||
if (state === "paused")
|
||||
colorClass = "border-yellow-500/40 text-yellow-500 bg-yellow-500/10";
|
||||
if (state === "exited")
|
||||
colorClass = "border-destructive/40 text-destructive bg-destructive/5";
|
||||
if (state === "restarting")
|
||||
colorClass = "border-blue-400/40 text-blue-400 bg-blue-400/10";
|
||||
return (
|
||||
<span
|
||||
className={`text-[10px] font-bold px-1.5 py-0.5 border uppercase tracking-wider ${colorClass}`}
|
||||
>
|
||||
{state}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainerCard({
|
||||
container,
|
||||
sessionId,
|
||||
onSelect,
|
||||
isSelected = false,
|
||||
onRefresh,
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
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 isLoading =
|
||||
isStarting || isStopping || isRestarting || isPausing || isRemoving;
|
||||
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
const portsList = (container.ports ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const handleStart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToStopContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsStopping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await restartDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerRestarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRestartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} 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(t("docker.containerUnpaused", { name: containerName }));
|
||||
} else {
|
||||
await pauseDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerPaused", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToTogglePauseContainer", {
|
||||
action: container.state === "paused" ? "unpause" : "pause",
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsPausing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
confirmWithToast(
|
||||
t("docker.confirmRemoveContainer", { name: containerName }) +
|
||||
(container.state === "running"
|
||||
? " " + t("docker.runningContainerWarning")
|
||||
: ""),
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await removeDockerContainer(
|
||||
sessionId,
|
||||
container.id,
|
||||
container.state === "running",
|
||||
);
|
||||
toast.success(t("docker.containerRemoved", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("docker.failedToRemoveContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
},
|
||||
t("common.remove"),
|
||||
t("common.cancel"),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden p-0 gap-0 group hover:border-accent-brand/40 transition-colors cursor-pointer ${isSelected ? "border-accent-brand/60 bg-accent-brand/5" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Box
|
||||
className={`size-3.5 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold truncate">{containerName}</span>
|
||||
</div>
|
||||
<DockerBadge state={container.state} />
|
||||
</div>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{t("docker.image")}
|
||||
</span>
|
||||
<span className="text-xs font-mono truncate text-foreground/80">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{t("docker.ports")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{t("docker.noPorts")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-border bg-muted/5 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{container.state !== "running" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-accent-brand"
|
||||
disabled={isLoading}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{isStarting ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{container.state === "running" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleStop}
|
||||
>
|
||||
{isStopping ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Square className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(container.state === "running" || container.state === "paused") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isLoading}
|
||||
onClick={handlePause}
|
||||
>
|
||||
{isPausing ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : container.state === "paused" ? (
|
||||
<PlayCircle className="size-3" />
|
||||
) : (
|
||||
<Pause className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={isLoading || container.state === "exited"}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-destructive"
|
||||
disabled={isLoading}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
Box,
|
||||
List,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer, SSHHost } from "@/types";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
|
||||
interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: SSHHost;
|
||||
onBack: () => void;
|
||||
initialTab?: DetailTab;
|
||||
}
|
||||
|
||||
export function ContainerDetail({
|
||||
sessionId,
|
||||
containerId,
|
||||
containers,
|
||||
hostConfig,
|
||||
onBack,
|
||||
initialTab = "logs",
|
||||
}: ContainerDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = React.useState<DetailTab>(initialTab);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
}, [containerId, initialTab]);
|
||||
|
||||
const container = containers.find((c) => c.id === containerId);
|
||||
const containerName = container
|
||||
? container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name
|
||||
: "";
|
||||
|
||||
if (!container) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center flex flex-col gap-3">
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.containerNotFound")}
|
||||
</p>
|
||||
<Button onClick={onBack} variant="outline" size="sm">
|
||||
<ArrowLeft className="size-4 mr-2" />
|
||||
{t("docker.backToList")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: "logs",
|
||||
label: t("docker.logs"),
|
||||
icon: <List className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
label: t("docker.stats"),
|
||||
icon: <Activity className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
id: "console",
|
||||
label: t("docker.consoleTab"),
|
||||
icon: <Terminal className="size-3.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-3 py-3 gap-3">
|
||||
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="size-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Box className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{containerName}</h1>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DockerBadge state={container.state} />
|
||||
<Separator orientation="vertical" className="h-8 mx-2" />
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="flex gap-1 border-b border-border shrink-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-b-accent-brand text-foreground bg-accent-brand/5"
|
||||
: "border-b-transparent text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === "logs" && (
|
||||
<LogViewer
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "stats" && (
|
||||
<ContainerStats
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
containerState={container.state}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "console" && (
|
||||
<ConsoleTerminal
|
||||
containerId={containerId}
|
||||
containerName={containerName}
|
||||
containerState={container.state}
|
||||
hostConfig={hostConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
containers: DockerContainer[];
|
||||
sessionId: string;
|
||||
onSelectContainer: (containerId: string) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onRefresh?: () => void;
|
||||
search?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
export function ContainerList({
|
||||
containers,
|
||||
sessionId,
|
||||
onSelectContainer,
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
search = "",
|
||||
statusFilter = "all",
|
||||
}: ContainerListProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
return containers.filter((c) => {
|
||||
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
|
||||
const matchesSearch =
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.image.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.id.toLowerCase().includes(search.toLowerCase());
|
||||
return (
|
||||
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
|
||||
);
|
||||
});
|
||||
}, [containers, search, statusFilter]);
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersFound")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersFoundHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersMatchFiltersHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{filtered.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Info,
|
||||
MemoryStick,
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { DockerStats } from "@/types";
|
||||
import { getContainerStats } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
}
|
||||
|
||||
export function ContainerStats({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const statsRef = React.useRef(stats);
|
||||
statsRef.current = stats;
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (containerState !== "running") return false;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
Math.abs(parseFloat(data.cpu) - parseFloat(previous.cpu)) >= 1.5 ||
|
||||
Math.abs(
|
||||
parseFloat(data.memoryPercent) - parseFloat(previous.memoryPercent),
|
||||
) >= 1 ||
|
||||
data.pids !== previous.pids;
|
||||
statsRef.current = data;
|
||||
setStats(data);
|
||||
return changed;
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, t("docker.failedToFetchStats")));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
{
|
||||
minIntervalMs: 2_000,
|
||||
maxIntervalMs: 12_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
containerState === "running",
|
||||
);
|
||||
|
||||
if (containerState !== "running") {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Activity className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.containerNotRunning")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.startContainerToViewStats")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<RefreshCw className="size-8 animate-spin mb-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.loadingStats")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
|
||||
<Activity className="size-8 mb-4" />
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
{t("docker.errorLoadingStats")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground mt-1">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("docker.noStatsAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cpuPercent = parseFloat(stats.cpu) || 0;
|
||||
const memPercent = parseFloat(stats.memoryPercent) || 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<SectionCard
|
||||
title={t("docker.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-bold text-accent-brand">
|
||||
{stats.cpu}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("docker.current")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-3xl font-bold text-accent-brand">
|
||||
{stats.memoryPercent}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{stats.memoryUsed} / {stats.memoryLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${Math.min(memPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.networkIo")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.input")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">
|
||||
{stats.netInput}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.output")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold text-accent-brand">
|
||||
{stats.netOutput}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.blockIo")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.read")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">
|
||||
{stats.blockRead}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.write")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold text-accent-brand">
|
||||
{stats.blockWrite}
|
||||
</span>
|
||||
</div>
|
||||
{stats.pids && (
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{t("docker.pids")}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-bold">{stats.pids}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("docker.containerInformation")}
|
||||
icon={<Info className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.name")}
|
||||
</span>
|
||||
<span className="font-mono">{containerName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.id")}
|
||||
</span>
|
||||
<span className="font-mono">{containerId.substring(0, 12)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs py-1">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("docker.state")}
|
||||
</span>
|
||||
<DockerBadge
|
||||
state={
|
||||
containerState as import("@/types").DockerContainer["state"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { startDockerContainer, stopDockerContainer } from "@/main-axios.ts";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
|
||||
interface ContainerTableProps {
|
||||
containers: DockerContainer[];
|
||||
sessionId: string;
|
||||
onSelectContainer: (containerId: string, tab?: DetailTab) => void;
|
||||
selectedContainerId?: string | null;
|
||||
onRefresh?: () => void;
|
||||
search?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
export function ContainerTable({
|
||||
containers,
|
||||
sessionId,
|
||||
onSelectContainer,
|
||||
selectedContainerId = null,
|
||||
onRefresh,
|
||||
search = "",
|
||||
statusFilter = "all",
|
||||
}: ContainerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [pendingId, setPendingId] = React.useState<string | null>(null);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
return containers.filter((c) => {
|
||||
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
|
||||
const matchesSearch =
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.image.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.id.toLowerCase().includes(search.toLowerCase());
|
||||
return (
|
||||
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
|
||||
);
|
||||
});
|
||||
}, [containers, search, statusFilter]);
|
||||
|
||||
const handleToggleRun = async (
|
||||
e: React.MouseEvent,
|
||||
container: DockerContainer,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
setPendingId(container.id);
|
||||
try {
|
||||
if (container.state === "running") {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
} else {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
container.state === "running"
|
||||
? t("docker.failedToStopContainer", { error: getErrorMessage(err) })
|
||||
: t("docker.failedToStartContainer", {
|
||||
error: getErrorMessage(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersFound")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersFoundHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
|
||||
<Box className="size-16 mb-4" />
|
||||
<span className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{t("docker.noContainersMatchFiltersHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.name")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.image")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.state")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.ports")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("docker.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">
|
||||
{t("docker.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((container) => {
|
||||
const containerName = container.name.startsWith("/")
|
||||
? container.name.slice(1)
|
||||
: container.name;
|
||||
const portsList = (container.ports ?? "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const isSelected = selectedContainerId === container.id;
|
||||
const isPending = pendingId === container.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={container.id}
|
||||
onClick={() => onSelectContainer(container.id, "logs")}
|
||||
className={`cursor-pointer transition-colors hover:bg-muted/40 ${
|
||||
isSelected ? "bg-accent-brand/5" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Box
|
||||
className={`size-3.5 shrink-0 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{containerName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[220px]">
|
||||
<span className="block truncate font-mono text-muted-foreground">
|
||||
{container.image}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<DockerBadge state={container.state} />
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[200px]">
|
||||
{portsList.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{portsList.slice(0, 2).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{portsList.length > 2 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{portsList.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground italic">
|
||||
{t("docker.noPorts")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{container.status}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div
|
||||
className="flex items-center justify-end gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.logs")}
|
||||
onClick={() => onSelectContainer(container.id, "logs")}
|
||||
>
|
||||
<List className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.stats")}
|
||||
onClick={() => onSelectContainer(container.id, "stats")}
|
||||
>
|
||||
<Box className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title={t("docker.consoleTab")}
|
||||
disabled={container.state !== "running"}
|
||||
onClick={() =>
|
||||
onSelectContainer(container.id, "console")
|
||||
}
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={
|
||||
container.state === "running"
|
||||
? "text-destructive"
|
||||
: "text-accent-brand"
|
||||
}
|
||||
disabled={isPending}
|
||||
title={
|
||||
container.state === "running"
|
||||
? t("docker.stop")
|
||||
: t("docker.start")
|
||||
}
|
||||
onClick={(e) => handleToggleRun(e, container)}
|
||||
>
|
||||
{isPending ? (
|
||||
<RefreshCw className="size-3 animate-spin" />
|
||||
) : container.state === "running" ? (
|
||||
<Square className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DockerLogOptions } from "@/types";
|
||||
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
function AdminToggle({
|
||||
on,
|
||||
onToggle,
|
||||
label,
|
||||
}: {
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getLogColor(line: string): string {
|
||||
if (line.includes(" WARN") || line.includes(" warn"))
|
||||
return "text-yellow-400/90";
|
||||
if (line.includes(" ERROR") || line.includes(" error"))
|
||||
return "text-destructive";
|
||||
if (line.includes(" DEBUG") || line.includes(" debug"))
|
||||
return "text-muted-foreground/60";
|
||||
return "text-foreground/90";
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
const [tailLines, setTailLines] = React.useState("100");
|
||||
const [showTimestamps, setShowTimestamps] = React.useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = React.useState(false);
|
||||
const [logSearch, setLogSearch] = React.useState("");
|
||||
const logsEndRef = React.useRef<HTMLDivElement>(null);
|
||||
const rawLogsRef = React.useRef(rawLogs);
|
||||
rawLogsRef.current = rawLogs;
|
||||
|
||||
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);
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
next.some((line, index) => line !== rawLogsRef.current[index]);
|
||||
if (changed) {
|
||||
rawLogsRef.current = next;
|
||||
setRawLogs(next);
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchLogs,
|
||||
{
|
||||
minIntervalMs: 3_000,
|
||||
maxIntervalMs: 18_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
autoRefresh,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoRefresh && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [rawLogs, autoRefresh]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const blob = await downloadContainerLogs(sessionId, containerId, {
|
||||
timestamps: showTimestamps,
|
||||
});
|
||||
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(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = logSearch
|
||||
? rawLogs.filter((l) => l.toLowerCase().includes(logSearch.toLowerCase()))
|
||||
: rawLogs;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="flex items-center justify-between bg-card border border-border px-3 py-2 gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 shrink-0 flex-wrap">
|
||||
<AdminToggle
|
||||
on={autoRefresh}
|
||||
onToggle={() => setAutoRefresh(!autoRefresh)}
|
||||
label={t("docker.autoRefresh")}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<AdminToggle
|
||||
on={showTimestamps}
|
||||
onToggle={() => setShowTimestamps(!showTimestamps)}
|
||||
label={t("docker.timestamps")}
|
||||
/>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Select2
|
||||
value={tailLines}
|
||||
onChange={(e) => setTailLines(e.target.value)}
|
||||
className="h-7 px-2 text-[10px] bg-background border border-border text-foreground outline-none uppercase font-bold"
|
||||
>
|
||||
<option value="50">{t("docker.last50")}</option>
|
||||
<option value="100">{t("docker.last100")}</option>
|
||||
<option value="500">{t("docker.last500")}</option>
|
||||
<option value="1000">{t("docker.last1000")}</option>
|
||||
<option value="all">{t("docker.allLogs")}</option>
|
||||
</Select2>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
|
||||
{filteredLogs.length}/{rawLogs.length} {t("docker.lines")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-64">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("docker.filterLogs")}
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
className="pl-7 h-7 text-xs bg-background border-border rounded-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${isLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("docker.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<Download className="size-3" />
|
||||
{t("docker.download")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5"
|
||||
onClick={() => setRawLogs([])}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{t("docker.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-muted border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((line, i) => {
|
||||
const tsEnd = line.indexOf(" ", 1);
|
||||
const maybeTs = tsEnd > 10 ? line.substring(0, tsEnd) : null;
|
||||
const rest = maybeTs ? line.substring(tsEnd) : line;
|
||||
return (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{maybeTs && (
|
||||
<span className="text-accent-brand/50">{maybeTs}</span>
|
||||
)}
|
||||
<span className={getLogColor(rest)}>{rest}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">
|
||||
{logSearch
|
||||
? t("docker.noLogsMatching", { query: logSearch })
|
||||
: t("docker.noLogsAvailable")}
|
||||
</span>
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Frontend half of the docker plugin.
|
||||
*
|
||||
* Registers the container-management tab through the existing extension
|
||||
* seams (registerRailItem / registerTabComponent) the same way
|
||||
* ssh-terminal's frontend does, and the host-editor Docker tab through the
|
||||
* new registerHostEditorTab seam built for this plugin.
|
||||
*
|
||||
* HostDockerTab itself stays in src/ui/sidebar/HostEditorFeatureTabs.tsx
|
||||
* rather than moving into this plugin: it takes the same {form, setField}
|
||||
* pair every other host editor tab does, and those types (HostEditorForm,
|
||||
* the setField signature) are core types HostEditor.tsx owns, the same
|
||||
* reason ssh-terminal leaves session-manager.ts in core rather than copying
|
||||
* it here.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled. As of this change neither is actually invoked anywhere yet --
|
||||
* see plugins/ssh-terminal/frontend/index.mjs and
|
||||
* src/ui/tests/sidebar/plugin-extension-seam.test.tsx, which says outright
|
||||
* "there is no plugin loader yet" for the frontend half. This file follows
|
||||
* the same shape so it is ready the moment that loader exists.
|
||||
*/
|
||||
|
||||
export const id = "docker";
|
||||
export const tabId = "docker";
|
||||
export const hostEditorTabId = "docker";
|
||||
|
||||
export async function register({
|
||||
registerRailItem,
|
||||
registerTabComponent,
|
||||
registerHostEditorTab,
|
||||
icons,
|
||||
}) {
|
||||
registerTabComponent(tabId, () =>
|
||||
import("./DockerManager.tsx").then((m) => ({
|
||||
default: m.DockerManager,
|
||||
})),
|
||||
);
|
||||
|
||||
registerRailItem({
|
||||
id: tabId,
|
||||
icon: icons.Box,
|
||||
labelKey: "nav.docker",
|
||||
kind: "tab",
|
||||
});
|
||||
|
||||
if (registerHostEditorTab) {
|
||||
const { HostDockerTab } =
|
||||
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
|
||||
registerHostEditorTab({
|
||||
id: hostEditorTabId,
|
||||
labelKey: "hosts.tabDocker",
|
||||
icon: icons.Box,
|
||||
component: HostDockerTab,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregister({
|
||||
unregisterRailItem,
|
||||
unregisterTabComponent,
|
||||
unregisterHostEditorTab,
|
||||
}) {
|
||||
unregisterRailItem(tabId);
|
||||
unregisterTabComponent(tabId);
|
||||
unregisterHostEditorTab?.(hostEditorTabId);
|
||||
}
|
||||
Reference in new issue
Block a user