mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* The Docker panel's interactive connect.
|
||||
*
|
||||
* Core's pipeline does the handshake; this only bridges its prompt channel to
|
||||
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a
|
||||
* password the host does not store) answers the request that is waiting with
|
||||
* what to ask, and the next request (connect-totp, connect-warpgate) carries
|
||||
* the answer back and waits for whatever comes after it: the session, another
|
||||
* prompt, or an error.
|
||||
*/
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshHost,
|
||||
PluginSshPromptRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerSessions } from "./sessions.js";
|
||||
import {
|
||||
connectionLog,
|
||||
getErrorMessage,
|
||||
type ConnectionLogLine,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
/** What one HTTP request answers with. */
|
||||
export interface ConnectStep {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOTP_WAIT_MS = 3 * 60 * 1000;
|
||||
const WARPGATE_WAIT_MS = 5 * 60 * 1000;
|
||||
const STEP_WAIT_MS = 90 * 1000;
|
||||
// Covers the longest a person may take on a prompt, plus the handshake.
|
||||
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000;
|
||||
|
||||
const PASSWORD_PROMPT = /password/i;
|
||||
|
||||
/** Hands each step to whichever request is waiting for one. */
|
||||
export class PendingConnect {
|
||||
constructor(
|
||||
readonly userId: string,
|
||||
readonly hostId: number,
|
||||
) {}
|
||||
|
||||
expiresAt = Date.now() + TOTP_WAIT_MS;
|
||||
/** What the open prompt expects, or null when nothing is being asked. */
|
||||
awaiting: "totp" | "warpgate" | null = null;
|
||||
abandoned = false;
|
||||
private waiter: ((step: ConnectStep) => void) | null = null;
|
||||
private queued: ConnectStep | null = null;
|
||||
private answer: ((value: string | null) => void) | null = null;
|
||||
|
||||
emit(step: ConnectStep): void {
|
||||
if (this.waiter) {
|
||||
const waiter = this.waiter;
|
||||
this.waiter = null;
|
||||
waiter(step);
|
||||
} else {
|
||||
this.queued = step;
|
||||
}
|
||||
}
|
||||
|
||||
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
|
||||
if (this.queued) {
|
||||
const step = this.queued;
|
||||
this.queued = null;
|
||||
return Promise.resolve(step);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.waiter !== settle) return;
|
||||
this.waiter = null;
|
||||
this.cancel();
|
||||
resolve({
|
||||
status: 408,
|
||||
body: { error: "Connection timed out. Please reconnect." },
|
||||
});
|
||||
}, timeoutMs);
|
||||
const settle = (step: ConnectStep) => {
|
||||
clearTimeout(timer);
|
||||
resolve(step);
|
||||
};
|
||||
this.waiter = settle;
|
||||
});
|
||||
}
|
||||
|
||||
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> {
|
||||
this.awaiting = kind;
|
||||
this.expiresAt = Date.now() + waitMs;
|
||||
return new Promise((resolve) => {
|
||||
this.answer = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
respond(value: string | null): void {
|
||||
const answer = this.answer;
|
||||
this.answer = null;
|
||||
this.awaiting = null;
|
||||
answer?.(value);
|
||||
}
|
||||
|
||||
/** Nobody will read the result: give up the prompt and drop the client. */
|
||||
cancel(): void {
|
||||
this.abandoned = true;
|
||||
this.respond(null);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyConnectError(
|
||||
message: string,
|
||||
logs: ConnectionLogLine[],
|
||||
): { hostKeyChanged: boolean; authFailed: boolean } {
|
||||
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||
logs.push(
|
||||
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
|
||||
);
|
||||
} else if (
|
||||
message.includes("ECONNREFUSED") ||
|
||||
message.includes("ETIMEDOUT")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
|
||||
);
|
||||
} else if (message.includes("verification failed")) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"handshake",
|
||||
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||
),
|
||||
);
|
||||
return { hostKeyChanged: true, authFailed: false };
|
||||
} else if (
|
||||
message.includes("handshake") ||
|
||||
message.includes("key exchange")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
|
||||
);
|
||||
} else if (/authentication/i.test(message)) {
|
||||
logs.push(
|
||||
connectionLog("error", "auth", `Authentication failed: ${message}`),
|
||||
);
|
||||
return { hostKeyChanged: false, authFailed: true };
|
||||
} else {
|
||||
logs.push(
|
||||
connectionLog("error", "error", `SSH connection failed: ${message}`),
|
||||
);
|
||||
}
|
||||
return { hostKeyChanged: false, authFailed: false };
|
||||
}
|
||||
|
||||
export interface StartConnectInput {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
host: PluginSshHost;
|
||||
runtime: ContainerRuntime;
|
||||
logs: ConnectionLogLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a connect and returns the first step for the request that asked.
|
||||
* The pending entry lives in `sessions` until the connect settles, so the
|
||||
* answer routes can find it.
|
||||
*/
|
||||
export async function startConnect(
|
||||
ctx: PluginContext,
|
||||
sessions: DockerSessions,
|
||||
log: DockerLogger,
|
||||
input: StartConnectInput,
|
||||
): Promise<ConnectStep> {
|
||||
const { sessionId, userId, host, runtime, logs } = input;
|
||||
const hostId = host.id;
|
||||
const authType = (host.authType as string) || "none";
|
||||
const credentialless = !ctx.ssh.requiresSecret(authType);
|
||||
|
||||
const pending = new PendingConnect(userId, hostId);
|
||||
sessions.setPending(sessionId, pending);
|
||||
|
||||
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
|
||||
logs.push(connectionLog("info", "docker_auth", "Verification required"));
|
||||
const answer = pending.ask("totp", TOTP_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt,
|
||||
connectionLogs: logs,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
};
|
||||
|
||||
const ask = async (
|
||||
request: PluginSshPromptRequest,
|
||||
): Promise<string | null> => {
|
||||
if (pending.abandoned) return null;
|
||||
switch (request.kind) {
|
||||
case "totp":
|
||||
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
|
||||
case "warpgate": {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"docker_auth",
|
||||
"Warpgate authentication required",
|
||||
),
|
||||
);
|
||||
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_warpgate: true,
|
||||
sessionId,
|
||||
url: request.url,
|
||||
securityKey: request.securityKey,
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
}
|
||||
case "input": {
|
||||
const isPassword = PASSWORD_PROMPT.test(request.prompt);
|
||||
if (isPassword && credentialless) {
|
||||
pending.abandoned = true;
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { status: "auth_required", reason: "no_keyboard" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!isPassword && !request.isPush) return "";
|
||||
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
|
||||
logs.push(
|
||||
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||
);
|
||||
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
|
||||
|
||||
ctx.ssh
|
||||
.connect<Client>(host, {
|
||||
purpose: "docker",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
prompt: { ask },
|
||||
})
|
||||
.then(
|
||||
(connection) => {
|
||||
sessions.clearPending(sessionId);
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: true });
|
||||
if (pending.abandoned) {
|
||||
connection.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
client: connection.client,
|
||||
dispose: connection.dispose,
|
||||
hostId,
|
||||
userId,
|
||||
runtime,
|
||||
isWindows: false,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
};
|
||||
sessions.add(session);
|
||||
|
||||
connection.client.exec("ver", (err, stream) => {
|
||||
if (err || !stream) return;
|
||||
let output = "";
|
||||
stream.on("data", (d: Buffer) => {
|
||||
output += d.toString();
|
||||
});
|
||||
stream.on("close", () => {
|
||||
if (output.toLowerCase().includes("windows")) {
|
||||
session.isWindows = true;
|
||||
}
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
});
|
||||
|
||||
void ctx.audit.record({
|
||||
action: "docker_connect",
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: (host.name as string) || undefined,
|
||||
success: true,
|
||||
});
|
||||
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"connected",
|
||||
"SSH connection established successfully",
|
||||
),
|
||||
);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { success: true, status: "success", connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
(error: unknown) => {
|
||||
sessions.clearPending(sessionId);
|
||||
const message = getErrorMessage(error, "SSH connection failed");
|
||||
if (pending.abandoned) return;
|
||||
|
||||
const outcome = (error as { outcome?: Record<string, unknown> })
|
||||
.outcome;
|
||||
if (outcome?.status === "interaction-required") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 401,
|
||||
body: {
|
||||
error: message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(typeof outcome.flag === "string"
|
||||
? { [outcome.flag]: true }
|
||||
: {}),
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome?.status === "error") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 400,
|
||||
body: { error: message, connectionLogs: logs },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostKeyChanged, authFailed } = classifyConnectError(
|
||||
message,
|
||||
logs,
|
||||
);
|
||||
if (hostKeyChanged || authFailed) {
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
|
||||
}
|
||||
log.error("Docker SSH connection failed", {
|
||||
hostId,
|
||||
userId,
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (credentialless && authFailed) {
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
pending.emit({
|
||||
status: 500,
|
||||
body: { success: false, message, connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return pending.next();
|
||||
}
|
||||
|
||||
/** Carries a person's answer to a parked connect and waits for what's next. */
|
||||
export async function answerConnect(
|
||||
sessions: DockerSessions,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
kind: "totp" | "warpgate",
|
||||
value: string,
|
||||
): Promise<ConnectStep> {
|
||||
const pending = sessions.getPending(sessionId);
|
||||
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: "Authentication session expired. Please reconnect." },
|
||||
};
|
||||
}
|
||||
if (pending.awaiting !== kind) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error:
|
||||
kind === "warpgate"
|
||||
? "Session is not a Warpgate session"
|
||||
: "Session is not waiting for a code",
|
||||
},
|
||||
};
|
||||
}
|
||||
pending.respond(value);
|
||||
return pending.next();
|
||||
}
|
||||
@@ -1,652 +1,343 @@
|
||||
// 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 type { Client as SSHClient } from "ssh2";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { connectSsh } from "./ssh.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { systemLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../../src/types/index.js";
|
||||
import { AsyncResource } from "node:async_hooks";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginWebSocketConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
CONTAINER_ID_RE,
|
||||
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,
|
||||
getErrorMessage,
|
||||
hostAddressMismatch,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../../../src/backend/utils/ws-message.js";
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
const SHELLS = ["bash", "sh", "ash", "zsh"];
|
||||
const PING_MS = 30_000;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: import("ssh2").ClientChannel | null;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
hostId?: number;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
interface ConsoleSession {
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
stream: ClientChannel | null;
|
||||
hostId: number;
|
||||
containerId: string;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
/**
|
||||
* Created by startConsoleServer(), not at module load.
|
||||
*
|
||||
* Importing this file used to bind a 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.
|
||||
* There is no port now -- core routes /plugin-ws/docker/console here -- but
|
||||
* the same lifecycle applies to the server object itself.
|
||||
*/
|
||||
let wss: WebSocketServer | null = null;
|
||||
|
||||
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
|
||||
void onConsoleConnection(ws, req);
|
||||
}
|
||||
|
||||
/** Builds the console WebSocket server. Call from the plugin's activate(). */
|
||||
export function startConsoleServer(): Promise<void> {
|
||||
if (wss) return Promise.resolve();
|
||||
|
||||
const server = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_WS_MESSAGE_BYTES,
|
||||
});
|
||||
|
||||
// One bad socket must not take the server down.
|
||||
server.on("error", (error) => {
|
||||
sshLogger.error("Docker console WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
server.on("connection", handleConnection);
|
||||
|
||||
wss = server;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one upgrade to the console server.
|
||||
*
|
||||
* Registered as a public ctx.ws route because onConsoleConnection does its own
|
||||
* per-message authentication, which is where the container and host checks
|
||||
* live too.
|
||||
*/
|
||||
export function handleConsoleUpgrade(
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
const server = wss;
|
||||
if (!server) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
server.handleUpgrade(request, socket, head, (ws) => {
|
||||
server.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
function shellExists(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
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);
|
||||
});
|
||||
},
|
||||
shell: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
client.exec(
|
||||
containerCommand(runtime, `exec ${containerId} which ${shell}`),
|
||||
(err, stream) => {
|
||||
if (err) return resolve(false);
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
stream.on("close", (code: number) =>
|
||||
resolve(code === 0 && output.trim().length > 0),
|
||||
);
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", () => resolve(false));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
async function pickShell(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
containerId: string,
|
||||
requested: string | undefined,
|
||||
): Promise<string> {
|
||||
if (requested && (await shellExists(client, runtime, containerId, requested)))
|
||||
return requested;
|
||||
for (const shell of ["bash", "sh", "ash"]) {
|
||||
if (await shellExists(client, runtime, containerId, shell)) return shell;
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
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,
|
||||
/**
|
||||
* The container console at /plugin-ws/docker/console.
|
||||
*
|
||||
* Public with optionalAuth: core still resolves the caller's token, and the
|
||||
* handler refuses a socket without a user or without docker.use. Every live
|
||||
* console is closed when the plugin deactivates.
|
||||
*/
|
||||
export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
|
||||
const live = new Set<() => void>();
|
||||
ctx.disposables.add(() => {
|
||||
for (const end of [...live]) end();
|
||||
live.clear();
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
const onConnection = async (connection: PluginWebSocketConnection) => {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const userId = connection.userId;
|
||||
if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(wsPingInterval);
|
||||
if (!sshSession) return;
|
||||
sshSession.stream?.end();
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
sshSession = null;
|
||||
};
|
||||
let session: ConsoleSession | null = null;
|
||||
const send = (message: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = parseWsMessage(data);
|
||||
const endSession = () => {
|
||||
if (!session) return;
|
||||
const current = session;
|
||||
session = null;
|
||||
current.stream?.end();
|
||||
current.dispose();
|
||||
};
|
||||
|
||||
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 pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
}, PING_MS);
|
||||
|
||||
const hostId = hostConfig?.id;
|
||||
const cleanup = () => {
|
||||
clearInterval(pingTimer);
|
||||
endSession();
|
||||
live.delete(shutdown);
|
||||
};
|
||||
const shutdown = () => {
|
||||
cleanup();
|
||||
try {
|
||||
ws.close(1001, "Docker plugin disabled");
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
};
|
||||
live.add(shutdown);
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connect = async (data: unknown) => {
|
||||
if (!connection.isDataUnlocked()) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Session expired - please log in again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = asObject(data);
|
||||
const hostConfig = asObject(payload.hostConfig);
|
||||
const hostId = Number(hostConfig.id);
|
||||
const syncId =
|
||||
typeof hostConfig.syncId === "string" ? hostConfig.syncId : null;
|
||||
const containerId = asString(payload.containerId);
|
||||
const shell = asString(payload.shell) || undefined;
|
||||
const cols = toTerminalDimension(payload.cols) || 80;
|
||||
const rows = toTerminalDimension(payload.rows) || 24;
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hostId || !containerId) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!CONTAINER_ID_RE.test(containerId)) {
|
||||
send({ type: "error", message: "Invalid container ID" });
|
||||
return;
|
||||
}
|
||||
if (shell && !SHELLS.includes(shell)) {
|
||||
send({ type: "error", message: "Invalid shell" });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
endSession();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// The console socket is public and authenticates itself, so the
|
||||
// acting user is carried on the host rather than the request.
|
||||
const { client } = await connectSsh(
|
||||
{
|
||||
...(resolvedHost as unknown as PluginSshHost),
|
||||
userId,
|
||||
} as PluginSshHost,
|
||||
{ purpose: "docker-console", timeoutMs: 65000 },
|
||||
);
|
||||
|
||||
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;
|
||||
try {
|
||||
// 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 host = await ctx.ssh.resolveHost(hostId, { syncId });
|
||||
if (!host) {
|
||||
send({
|
||||
type: "error",
|
||||
message: syncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
|
||||
log.error("Refusing Docker console: host id resolves elsewhere", {
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const input = asString(message.data);
|
||||
if (input) sshSession.stream.write(input);
|
||||
}
|
||||
break;
|
||||
const settings = await readDockerHostSettings(ctx, host.id);
|
||||
if (!settings.enabled) {
|
||||
send({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const dimensions = asObject(message.data);
|
||||
const cols = toTerminalDimension(dimensions.cols);
|
||||
const rows = toTerminalDimension(dimensions.rows);
|
||||
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
|
||||
purpose: "docker-console",
|
||||
timeoutMs: 65_000,
|
||||
});
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
const current: ConsoleSession = {
|
||||
client,
|
||||
dispose,
|
||||
stream: null,
|
||||
hostId: host.id,
|
||||
containerId,
|
||||
runtime: settings.runtime,
|
||||
};
|
||||
session = current;
|
||||
|
||||
const shellToUse = await pickShell(
|
||||
client,
|
||||
settings.runtime,
|
||||
containerId,
|
||||
shell,
|
||||
);
|
||||
if (session !== current) return;
|
||||
|
||||
client.exec(
|
||||
containerCommand(
|
||||
settings.runtime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
),
|
||||
{ pty: { term: "xterm-256color", cols, rows } },
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session !== current) {
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
current.stream = stream;
|
||||
|
||||
// Buffers split multi-byte UTF-8 sequences across chunks.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
const text = decoder.write(chunk);
|
||||
if (text) send({ type: "output", data: text });
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
});
|
||||
});
|
||||
stream.on("close", () => {
|
||||
send({ type: "disconnected", message: "Console session ended" });
|
||||
if (session === current) endSession();
|
||||
});
|
||||
|
||||
send({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: !!shell && shell !== shellToUse,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to connect to container", error, {
|
||||
hostId,
|
||||
containerId,
|
||||
});
|
||||
send({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "Failed to connect to container"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.on(
|
||||
"message",
|
||||
AsyncResource.bind(async (raw: RawData) => {
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch (error) {
|
||||
send({ type: "error", message: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "connect":
|
||||
await connect(data);
|
||||
break;
|
||||
case "input": {
|
||||
const input = asString(data);
|
||||
if (input) session?.stream?.write(input);
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
const size = asObject(data);
|
||||
const cols = toTerminalDimension(size.cols);
|
||||
const rows = toTerminalDimension(size.rows);
|
||||
if (cols && rows)
|
||||
sshSession.stream.setWindow(rows, cols, rows, cols);
|
||||
session?.stream?.setWindow(rows, cols, rows, cols);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
case "disconnect":
|
||||
if (session) {
|
||||
endSession();
|
||||
send({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
break;
|
||||
default:
|
||||
log.warn("Unknown docker console message", { type });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
ws.on("close", cleanup);
|
||||
ws.on("error", (error: Error) => {
|
||||
log.error("Docker console socket error", error);
|
||||
cleanup();
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
};
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes every live console session and the WebSocket server. 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);
|
||||
for (const client of server.clients) {
|
||||
try {
|
||||
client.close(1001, "Docker plugin disabled");
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,33 +4,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Container state snapshots and the events two of them imply, for the
|
||||
* docker.events service.
|
||||
*
|
||||
* Events come from diffing successive `ps -a` snapshots, so the poll interval
|
||||
* is the resolution: a container that stops and starts between two polls is
|
||||
* not reported. That is the tradeoff for not holding a `docker events` stream
|
||||
* open against every watched host.
|
||||
*/
|
||||
|
||||
export interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a pair of snapshots implies. A container missing from the
|
||||
* previous snapshot counts as newly seen rather than started, so the first
|
||||
* poll after a restart does not replay every running container.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting")
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
|
||||
// Health is independent of state: a container can go unhealthy while it
|
||||
// stays up, which is exactly the case worth alerting on.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { RawData } from "ws";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface DockerLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
/** An error, or structured context, or both. */
|
||||
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old core logger carried. */
|
||||
export function createLogger(log: PluginLogger): DockerLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, errorOrMeta, meta) => {
|
||||
if (errorOrMeta instanceof Error) {
|
||||
log.error(withMeta(message, meta), errorOrMeta);
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, {
|
||||
...((errorOrMeta as Meta | undefined) ?? {}),
|
||||
...meta,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type LogLevel = "info" | "success" | "warning" | "error";
|
||||
|
||||
/** One line of the connection log the panel shows while connecting. */
|
||||
export interface ConnectionLogLine {
|
||||
type: LogLevel;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function connectionLog(
|
||||
type: LogLevel,
|
||||
stage: string,
|
||||
message: string,
|
||||
): ConnectionLogLine {
|
||||
return { type, stage, message };
|
||||
}
|
||||
|
||||
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
// Cap on a single decoded text frame. Control messages are tiny, and console
|
||||
// input is bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a frame into `{ type, data }`, throwing on anything else. */
|
||||
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
|
||||
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Message must be a JSON object");
|
||||
}
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new Error("Message type must be a string");
|
||||
}
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||
|
||||
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeContainerRuntime,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
|
||||
export const PLUGIN_ID = "docker";
|
||||
|
||||
export interface DockerHostSettings {
|
||||
enabled: boolean;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
export async function readDockerHostSettings(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
): Promise<DockerHostSettings> {
|
||||
const values = await ctx.settings.getAll("host", hostId);
|
||||
return {
|
||||
enabled: values.enableDocker === true,
|
||||
runtime: normalizeContainerRuntime(values.containerRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === 0 || value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The docker host settings in an imported or created host row: the nested
|
||||
* `pluginSettings.docker` of a Termix export first, then the flat
|
||||
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
|
||||
* exports, proxmox guests marked as Docker hosts).
|
||||
*/
|
||||
export function normalizeImportedHost(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enabled = asBool(
|
||||
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
|
||||
);
|
||||
if (enabled !== undefined) out.enableDocker = enabled;
|
||||
|
||||
const runtime =
|
||||
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
|
||||
if (runtime === "docker" || runtime === "podman") {
|
||||
out.containerRuntime = runtime;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
@@ -1,82 +1,33 @@
|
||||
import express, { type Router } from "express";
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.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 {
|
||||
closeConsoleServer,
|
||||
handleConsoleUpgrade,
|
||||
startConsoleServer,
|
||||
} from "./console.js";
|
||||
import { createLogger } from "./helpers.js";
|
||||
import { createDockerSessions } from "./sessions.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { registerConsole } from "./console.js";
|
||||
import { registerServices } from "./services.js";
|
||||
import { normalizeImportedHost } from "./host-settings.js";
|
||||
|
||||
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
||||
null;
|
||||
export type {
|
||||
DockerContainerAction,
|
||||
DockerContainerSummary,
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "./services.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
// An express app rather than the router directly, so the existing
|
||||
// register*Routes helpers keep the Application they expect. Core mounts it
|
||||
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
|
||||
// parsing in front of it.
|
||||
const app = express();
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
const log = createLogger(ctx.log);
|
||||
const sessions = createDockerSessions(ctx.schedule, log);
|
||||
ctx.disposables.add(() => sessions.closeAll());
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
|
||||
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
await startConsoleServer();
|
||||
|
||||
// Public because the console authenticates each session over the socket
|
||||
// itself, where the container and host checks also live.
|
||||
ctx.ws.upgrade(
|
||||
"/console",
|
||||
(request, socket, head) =>
|
||||
handleConsoleUpgrade(
|
||||
request as Parameters<typeof handleConsoleUpgrade>[0],
|
||||
socket as Parameters<typeof handleConsoleUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
|
||||
registerConsole(ctx, log);
|
||||
registerServices(ctx, log);
|
||||
ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
|
||||
|
||||
ctx.log.info(
|
||||
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await closeConsoleServer();
|
||||
authManagerInstance = null;
|
||||
}
|
||||
export async function deactivate() {}
|
||||
+781
-1103
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { containerCommand } from "./container-runtime.js";
|
||||
import {
|
||||
PS_STATE_FORMAT,
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
type ContainerState,
|
||||
type DockerEventName,
|
||||
} from "./container-state.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import { execOnClient } from "./sessions.js";
|
||||
import { listFormat, parseContainerList } from "./routes.js";
|
||||
import {
|
||||
CONTAINER_ID_RE,
|
||||
getErrorMessage,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
export const POOL = "docker";
|
||||
const TICK_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const HOST_CACHE_MS = 5 * 60_000;
|
||||
|
||||
export interface DockerContainerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type DockerContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
|
||||
|
||||
const ACTIONS: DockerContainerAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
];
|
||||
|
||||
/** The "docker.containers" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerServiceV1 {
|
||||
listContainers: (
|
||||
hostId: number,
|
||||
options?: { all?: boolean },
|
||||
) => Promise<DockerContainerSummary[]>;
|
||||
action: (
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: DockerContainerAction,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: DockerEventName;
|
||||
}
|
||||
|
||||
/** The "docker.events" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerEventsV1 {
|
||||
/**
|
||||
* Calls `listener` for container state changes on a host, polled about
|
||||
* once a minute as the subscribing user. Subscribing the same listener
|
||||
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
|
||||
* a restarted docker plugin. Resolves to an unsubscribe function.
|
||||
*/
|
||||
subscribe: (
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
) => Promise<() => void>;
|
||||
}
|
||||
|
||||
function actorOf(ctx: PluginContext): string {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("The docker service needs a calling user");
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
|
||||
const runOnHost = async <T>(
|
||||
hostId: number,
|
||||
command: (runtime: "docker" | "podman") => string,
|
||||
handle: (output: string) => T,
|
||||
): Promise<T> => {
|
||||
const { runtime } = await readDockerHostSettings(ctx, hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
hostId,
|
||||
{ pool: POOL, purpose: "docker" },
|
||||
(client) => execOnClient(client, command(runtime)),
|
||||
);
|
||||
return handle(output);
|
||||
};
|
||||
|
||||
ctx.services.provide<DockerServiceV1>("docker.containers", {
|
||||
listContainers: async (hostId, options) => {
|
||||
actorOf(ctx);
|
||||
const all = options?.all !== false;
|
||||
return runOnHost(
|
||||
hostId,
|
||||
(runtime) =>
|
||||
containerCommand(
|
||||
runtime,
|
||||
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
|
||||
),
|
||||
(output) =>
|
||||
parseContainerList(output) as unknown as DockerContainerSummary[],
|
||||
);
|
||||
},
|
||||
action: async (hostId, container, action) => {
|
||||
actorOf(ctx);
|
||||
if (!CONTAINER_ID_RE.test(container)) {
|
||||
throw new Error("Invalid container name");
|
||||
}
|
||||
if (!ACTIONS.includes(action)) {
|
||||
throw new Error(`Unknown container action: ${action}`);
|
||||
}
|
||||
const args =
|
||||
action === "remove" ? `rm ${container}` : `${action} ${container}`;
|
||||
await runOnHost(
|
||||
hostId,
|
||||
(runtime) => containerCommand(runtime, args),
|
||||
() => undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
interface Watch {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
listeners: Set<(event: DockerEvent) => void>;
|
||||
snapshot?: Map<string, ContainerState>;
|
||||
host?: { value: PluginSshHost; at: number };
|
||||
lastPolledAt: number;
|
||||
failing: boolean;
|
||||
}
|
||||
const watches = new Map<string, Watch>();
|
||||
ctx.disposables.add(() => watches.clear());
|
||||
|
||||
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
|
||||
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
|
||||
return watch.host.value;
|
||||
}
|
||||
const host = await ctx.asUser(watch.userId, () =>
|
||||
ctx.ssh.resolveHost(watch.hostId),
|
||||
);
|
||||
watch.host = host ? { value: host, at: Date.now() } : undefined;
|
||||
return host;
|
||||
};
|
||||
|
||||
const pollOne = async (watch: Watch) => {
|
||||
try {
|
||||
const host = await hostFor(watch);
|
||||
if (!host) throw new Error("Host not found");
|
||||
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
host,
|
||||
{ pool: POOL, purpose: "docker" },
|
||||
(client) =>
|
||||
execOnClient(
|
||||
client,
|
||||
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
|
||||
),
|
||||
);
|
||||
const current = parseContainerStates(output);
|
||||
const previous = watch.snapshot;
|
||||
watch.snapshot = current;
|
||||
watch.failing = false;
|
||||
if (!previous) return;
|
||||
for (const { container, event } of diffContainerStates(
|
||||
previous,
|
||||
current,
|
||||
)) {
|
||||
for (const listener of [...watch.listeners]) {
|
||||
try {
|
||||
listener({ hostId: watch.hostId, container, event });
|
||||
} catch (error) {
|
||||
log.warn("Docker event listener failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Docker missing, the host down or the owner's data locked. A later
|
||||
// success is a first observation again rather than a diff.
|
||||
watch.snapshot = undefined;
|
||||
watch.host = undefined;
|
||||
if (!watch.failing) {
|
||||
log.warn("Docker event poll failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
watch.failing = true;
|
||||
}
|
||||
};
|
||||
|
||||
ctx.schedule.every(TICK_MS, async () => {
|
||||
const now = Date.now();
|
||||
for (const watch of [...watches.values()]) {
|
||||
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
|
||||
watch.lastPolledAt = now;
|
||||
await pollOne(watch);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
ctx.ssh.dropPooled(POOL, hostId);
|
||||
for (const watch of watches.values()) {
|
||||
if (watch.hostId === hostId) watch.host = undefined;
|
||||
}
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
for (const [key, watch] of watches) {
|
||||
if (watch.hostId === hostId) watches.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.services.provide<DockerEventsV1>("docker.events", {
|
||||
subscribe: async (hostId, listener) => {
|
||||
const userId = actorOf(ctx);
|
||||
const key = `${userId}:${hostId}`;
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
userId,
|
||||
hostId,
|
||||
listeners: new Set(),
|
||||
lastPolledAt: 0,
|
||||
failing: false,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
watch.listeners.add(listener);
|
||||
const current = watch;
|
||||
return () => {
|
||||
current.listeners.delete(listener);
|
||||
if (current.listeners.size === 0 && watches.get(key) === current) {
|
||||
watches.delete(key);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// 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,183 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerLogger } from "./helpers.js";
|
||||
|
||||
const SESSION_IDLE_MS = 60 * 60 * 1000;
|
||||
const PENDING_SWEEP_MS = 60 * 1000;
|
||||
|
||||
/** A live SSH connection behind the Docker panel, owned by one user. */
|
||||
export interface DockerSession {
|
||||
id: string;
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
runtime: ContainerRuntime;
|
||||
isWindows: boolean;
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
cancelIdle?: () => void;
|
||||
}
|
||||
|
||||
/** A connect still waiting on a person (TOTP, Warpgate). */
|
||||
export interface PendingEntry {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
expiresAt: number;
|
||||
/** Gives up: answers the open prompt with nothing. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface DockerSessions {
|
||||
add: (session: DockerSession) => void;
|
||||
/** The session when `userId` owns it. */
|
||||
get: (id: string, userId: string) => DockerSession | undefined;
|
||||
close: (id: string) => void;
|
||||
touch: (session: DockerSession) => void;
|
||||
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
|
||||
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
|
||||
exec: (session: DockerSession, command: string) => Promise<string>;
|
||||
setPending: (id: string, entry: PendingEntry) => void;
|
||||
getPending: (id: string) => PendingEntry | undefined;
|
||||
clearPending: (id: string) => void;
|
||||
closeAll: () => void;
|
||||
}
|
||||
|
||||
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
|
||||
export function execOnClient(client: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && code !== null && code !== undefined) {
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
stream.on("error", (streamErr: Error) => reject(streamErr));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createDockerSessions(
|
||||
schedule: PluginSchedule,
|
||||
log: DockerLogger,
|
||||
): DockerSessions {
|
||||
const sessions = new Map<string, DockerSession>();
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
|
||||
const end = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
session.dispose();
|
||||
};
|
||||
|
||||
const scheduleIdle = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
|
||||
if (sessions.get(session.id) !== session) return;
|
||||
if (session.activeOperations > 0) {
|
||||
scheduleIdle(session);
|
||||
return;
|
||||
}
|
||||
end(session);
|
||||
});
|
||||
};
|
||||
|
||||
schedule.every(PENDING_SWEEP_MS, () => {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of pending) {
|
||||
if (now < entry.expiresAt) continue;
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
add: (session) => {
|
||||
const existing = sessions.get(session.id);
|
||||
if (existing) end(existing);
|
||||
sessions.set(session.id, session);
|
||||
session.client.once("close", () => {
|
||||
if (sessions.get(session.id) === session) {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
}
|
||||
});
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
get: (id, userId) => {
|
||||
const session = sessions.get(id);
|
||||
return session && session.userId === userId ? session : undefined;
|
||||
},
|
||||
|
||||
close: (id) => {
|
||||
const session = sessions.get(id);
|
||||
if (session) end(session);
|
||||
const entry = pending.get(id);
|
||||
if (entry) {
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
touch: (session) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
run: async (session, fn) => {
|
||||
session.lastActive = Date.now();
|
||||
session.activeOperations++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
session.activeOperations--;
|
||||
}
|
||||
},
|
||||
|
||||
exec: async (session, command) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
return await execOnClient(session.client, command);
|
||||
} catch (error) {
|
||||
log.warn("Docker command failed", {
|
||||
hostId: session.hostId,
|
||||
command: command.split(" ")[2],
|
||||
durationMs: Date.now() - started,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setPending: (id, entry) => {
|
||||
const existing = pending.get(id);
|
||||
if (existing) existing.cancel();
|
||||
pending.set(id, entry);
|
||||
},
|
||||
|
||||
getPending: (id) => pending.get(id),
|
||||
|
||||
clearPending: (id) => {
|
||||
pending.delete(id);
|
||||
},
|
||||
|
||||
closeAll: () => {
|
||||
for (const session of [...sessions.values()]) end(session);
|
||||
for (const entry of pending.values()) entry.cancel();
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { toDockerHost } from "./types";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostConfig}
|
||||
host={toDockerHost(
|
||||
hostConfig as unknown as Record<string, unknown>,
|
||||
)}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/* 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,
|
||||
@@ -14,44 +9,46 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
import { logActivity, getSSHHosts } from "@/main-axios";
|
||||
import {
|
||||
connectDockerSession,
|
||||
disconnectDockerSession,
|
||||
listDockerContainers,
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
verifyDockerWarpgate,
|
||||
} from "./docker-api";
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Input,
|
||||
SSHAuthDialog,
|
||||
Select2,
|
||||
Separator,
|
||||
TOTPDialog,
|
||||
WarpgateDialog,
|
||||
logActivity,
|
||||
useAdaptivePolling,
|
||||
useAreaPreferences,
|
||||
useConnectionLog,
|
||||
useTabsSafe,
|
||||
useUiPreferencesContext,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||
import {
|
||||
dockerEnabled,
|
||||
hostTitle,
|
||||
toDockerHost,
|
||||
type DockerContainer,
|
||||
type DockerHost,
|
||||
type DockerValidation,
|
||||
} from "./types";
|
||||
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 { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
host?: DockerHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
@@ -59,15 +56,21 @@ interface DockerManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
|
||||
interface PromptState {
|
||||
kind: "totp" | "warpgate";
|
||||
sessionId: string;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}
|
||||
|
||||
interface DockerConnectionError {
|
||||
message?: string;
|
||||
connectionLogs?: ConnectionLogInput[];
|
||||
function newSessionId(): string {
|
||||
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
hostConfig,
|
||||
host,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
@@ -75,11 +78,24 @@ function DockerManagerInner({
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const uiPrefsCtx = useUiPreferencesContext();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
// The shell's host list keeps the host current after an edit; a
|
||||
// standalone window has no shell, so it keeps the host it was given.
|
||||
const liveRecord = useHost(host?.id);
|
||||
const currentHost = React.useMemo(
|
||||
() =>
|
||||
liveRecord
|
||||
? toDockerHost(liveRecord as unknown as Record<string, unknown>)
|
||||
: host,
|
||||
[liveRecord, host],
|
||||
);
|
||||
const enabled = dockerEnabled(currentHost);
|
||||
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
@@ -105,23 +121,14 @@ function DockerManagerInner({
|
||||
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.
|
||||
// per-session choice.
|
||||
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 [prompt, setPrompt] = React.useState<PromptState | null>(null);
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
@@ -131,214 +138,168 @@ function DockerManagerInner({
|
||||
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;
|
||||
const logDockerActivity = () => {
|
||||
if (!currentHost?.id || activityLoggedRef.current) return;
|
||||
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);
|
||||
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
|
||||
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");
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
}, [host?.id]);
|
||||
|
||||
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const validate = async (sid: string, clearAfter: boolean) => {
|
||||
setSessionId(sid);
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const validation = await docker.validate(sid);
|
||||
setDockerValidation(validation);
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
retryRef.current?.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
if (clearAfter) setTimeout(() => clearLogs(), 1000);
|
||||
retryRef.current?.markConnected();
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [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
|
||||
}
|
||||
/** Acts on one step of a connect, whichever request it answered. */
|
||||
const applyConnectResult = async (
|
||||
sid: string,
|
||||
result: ConnectResult,
|
||||
clearAfter: boolean,
|
||||
) => {
|
||||
if (result.connectionLogs) setLogs(result.connectionLogs);
|
||||
|
||||
if (result.requires_warpgate) {
|
||||
setPrompt({
|
||||
kind: "warpgate",
|
||||
sessionId: sid,
|
||||
url: result.url || "",
|
||||
securityKey: result.securityKey || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.requires_totp) {
|
||||
if (result.retry) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
setPrompt({
|
||||
kind: "totp",
|
||||
sessionId: sid,
|
||||
prompt: result.prompt || t("docker.verificationCodePrompt"),
|
||||
isPassword: result.isPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "auth_required") {
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setShowAuthDialog(true);
|
||||
return;
|
||||
}
|
||||
setPrompt(null);
|
||||
await validate(sid, clearAfter);
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
const reportFailure = (error: unknown, fallback: string) => {
|
||||
if (error instanceof DockerApiError && error.connectionLogs?.length) {
|
||||
setLogs(error.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
});
|
||||
}
|
||||
retryRef.current?.markFailed();
|
||||
};
|
||||
|
||||
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 connect = async (credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHost?.id) return;
|
||||
const sid = newSessionId();
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const result = await docker.connect(sid, currentHost.id, credentials);
|
||||
await applyConnectResult(sid, result, !credentials);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.connectionFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
if (!currentHost?.id || !enabled) return;
|
||||
if (initializingRef.current || sessionId) return;
|
||||
initializingRef.current = true;
|
||||
clearLogs();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
initializingRef.current = false;
|
||||
if (sessionId) {
|
||||
disconnectDockerSession(sessionId).catch(() => {
|
||||
// Silently handle disconnect errors
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
|
||||
}, [currentHost?.id, enabled, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return () => {
|
||||
docker.disconnect(sessionId).catch(() => {});
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
keepaliveDockerSession(sessionId).catch(() => {
|
||||
// Silently handle keepalive errors
|
||||
});
|
||||
docker.keepalive(sessionId).catch(() => {});
|
||||
},
|
||||
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);
|
||||
setContainers(await docker.listContainers(sessionId, true));
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
// the next poll tries again
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [sessionId, docker]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasLoadedContainersOnce(false);
|
||||
@@ -350,7 +311,7 @@ function DockerManagerInner({
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
const data = await docker.listContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
@@ -378,124 +339,48 @@ function DockerManagerInner({
|
||||
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 closeTab = () => {
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
const handleTotpCancel = () => {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpPrompt("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!prompt || prompt.kind !== "totp" || !code) return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.totpVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateContinue = async () => {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
if (!prompt || prompt.kind !== "warpgate") return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
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();
|
||||
}
|
||||
}
|
||||
await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
reportFailure(error, t("docker.warpgateVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateCancel = () => {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateSessionId(null);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
const handlePromptCancel = () => {
|
||||
const sid = prompt?.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
if (sid) docker.disconnect(sid).catch(() => {});
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const handleWarpgateOpenUrl = () => {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
@@ -503,75 +388,12 @@ function DockerManagerInner({
|
||||
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);
|
||||
}
|
||||
await connect({
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthCancel = () => {
|
||||
@@ -592,15 +414,10 @@ function DockerManagerInner({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled:
|
||||
!!currentHostConfig?.enableDocker &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog,
|
||||
enabled: enabled && !prompt && !showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
|
||||
dockerConnectRetryRef.current = dockerConnectRetry;
|
||||
retryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
@@ -619,9 +436,9 @@ function DockerManagerInner({
|
||||
|
||||
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";
|
||||
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
|
||||
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
@@ -649,6 +466,40 @@ function DockerManagerInner({
|
||||
);
|
||||
}
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<TOTPDialog
|
||||
isOpen={prompt?.kind === "totp"}
|
||||
prompt={prompt?.prompt ?? ""}
|
||||
mode={prompt?.isPassword ? "password" : "totp"}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handlePromptCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={prompt?.kind === "warpgate"}
|
||||
url={prompt?.url ?? ""}
|
||||
securityKey={prompt?.securityKey ?? ""}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handlePromptCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username ?? "",
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
@@ -662,6 +513,7 @@ function DockerManagerInner({
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -689,12 +541,12 @@ function DockerManagerInner({
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
currentHostConfig ? (
|
||||
currentHost ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHostConfig}
|
||||
hostConfig={currentHost}
|
||||
onBack={handleBack}
|
||||
initialTab={detailInitialTab}
|
||||
/>
|
||||
@@ -833,34 +685,7 @@ function DockerManagerInner({
|
||||
</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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialogs}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
WidgetTypeDefinition,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type DockerWidgetConfig,
|
||||
type WidgetComponentProps,
|
||||
type WidgetDefinition,
|
||||
} from "./homepage";
|
||||
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const record = useHost(config.hostId || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
if (!config.hostId || !record || !dockerEnabled(record)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
@@ -47,22 +27,7 @@ function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const host = toDockerHost(record as unknown as Record<string, unknown>);
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
@@ -71,8 +36,8 @@ function DockerWidget({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
host={host}
|
||||
title={hostTitle(host)}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
@@ -83,7 +48,7 @@ function DockerWidget({
|
||||
}
|
||||
|
||||
/** Registered by the plugin while it runs. */
|
||||
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
|
||||
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
|
||||
import { dockerEnabled } from "./types";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && dockerEnabled(host),
|
||||
);
|
||||
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableDocker)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{options.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name || host.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Box } from "lucide-react";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostDockerTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
|
||||
const runtime =
|
||||
dockerConfig.runtime === "podman"
|
||||
? ("podman" as const)
|
||||
: ("docker" as const);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.dockerIntegration")}
|
||||
icon={<Box className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableDockerMonitor")}
|
||||
description={t("hosts.enableDockerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableDocker}
|
||||
onChange={(v) => setField("enableDocker", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.enableDocker && (
|
||||
<SettingRow
|
||||
label={t("hosts.containerRuntime")}
|
||||
description={t("hosts.containerRuntimeDesc")}
|
||||
>
|
||||
<Select2
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
setField("dockerConfig", {
|
||||
...dockerConfig,
|
||||
runtime: e.target.value as "docker" | "podman",
|
||||
})
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="docker">
|
||||
{t("hosts.containerRuntimeDocker")}
|
||||
</option>
|
||||
<option value="podman">
|
||||
{t("hosts.containerRuntimePodman")}
|
||||
</option>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,42 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
RobustClipboardProvider,
|
||||
Select2,
|
||||
TERMINAL_FONTS,
|
||||
copyToClipboard,
|
||||
ensureTerminalFontsLoaded,
|
||||
isElectron,
|
||||
pluginWsUrl,
|
||||
readFromClipboard,
|
||||
resolveConnectionOrigin,
|
||||
resolveTermixThemeColors,
|
||||
useAppTheme as useTheme,
|
||||
useConnectionLog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerHost } from "../types";
|
||||
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 { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||
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,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/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";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal(
|
||||
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
() => ({
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as Partial<
|
||||
typeof DEFAULT_TERMINAL_CONFIG
|
||||
> | null),
|
||||
}),
|
||||
[hostConfig.terminalConfig],
|
||||
);
|
||||
|
||||
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
|
||||
JSON.stringify({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig,
|
||||
hostConfig: {
|
||||
id: hostConfig.id,
|
||||
syncId: hostConfig.syncId ?? null,
|
||||
ip: hostConfig.ip,
|
||||
},
|
||||
containerId,
|
||||
shell: selectedShell,
|
||||
cols,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
@@ -14,16 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import {
|
||||
startDockerContainer,
|
||||
stopDockerContainer,
|
||||
restartDockerContainer,
|
||||
pauseDockerContainer,
|
||||
unpauseDockerContainer,
|
||||
removeDockerContainer,
|
||||
} from "../docker-api";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
@@ -61,6 +52,7 @@ export function ContainerCard({
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const docker = useDockerApi();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
@@ -82,7 +74,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -100,7 +92,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -118,7 +110,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await restartDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "restart");
|
||||
toast.success(t("docker.containerRestarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -137,10 +129,10 @@ export function ContainerCard({
|
||||
setIsPausing(true);
|
||||
try {
|
||||
if (container.state === "paused") {
|
||||
await unpauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "unpause");
|
||||
toast.success(t("docker.containerUnpaused", { name: containerName }));
|
||||
} else {
|
||||
await pauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "pause");
|
||||
toast.success(t("docker.containerPaused", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
@@ -166,7 +158,7 @@ export function ContainerCard({
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await removeDockerContainer(
|
||||
await docker.removeContainer(
|
||||
sessionId,
|
||||
container.id,
|
||||
container.state === "running",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer, DockerHost } from "../types";
|
||||
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,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer, SSHHost } from "@/types";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
onBack: () => void;
|
||||
initialTab?: DetailTab;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerStats } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -9,12 +11,9 @@ import {
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { DockerStats } from "@/types";
|
||||
import { getContainerStats } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -30,6 +29,7 @@ export function ContainerStats({
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -41,7 +41,7 @@ export function ContainerStats({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
const data = await docker.stats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
@@ -58,7 +58,7 @@ export function ContainerStats({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
}, [sessionId, containerId, containerState, t, docker]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
@@ -246,7 +246,7 @@ export function ContainerStats({
|
||||
</span>
|
||||
<DockerBadge
|
||||
state={
|
||||
containerState as import("@/types").DockerContainer["state"]
|
||||
containerState as import("../types").DockerContainer["state"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { startDockerContainer, stopDockerContainer } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
@@ -30,6 +30,7 @@ export function ContainerTable({
|
||||
statusFilter = "all",
|
||||
}: ContainerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [pendingId, setPendingId] = React.useState<string | null>(null);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
@@ -56,10 +57,10 @@ export function ContainerTable({
|
||||
setPendingId(container.id);
|
||||
try {
|
||||
if (container.state === "running") {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
} else {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select2,
|
||||
Separator,
|
||||
useAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerLogOptions } from "../types";
|
||||
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 "@termix/plugin-sdk/frontend";
|
||||
import type { DockerLogOptions } from "@/types";
|
||||
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
@@ -60,6 +62,7 @@ export function LogViewer({
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
@@ -78,7 +81,7 @@ export function LogViewer({
|
||||
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
const data = await getContainerLogs(sessionId, containerId, options);
|
||||
const data = await docker.logs(sessionId, containerId, options);
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
@@ -89,11 +92,13 @@ export function LogViewer({
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps]);
|
||||
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
@@ -120,9 +125,10 @@ export function LogViewer({
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const blob = await downloadContainerLogs(sessionId, containerId, {
|
||||
const data = await docker.logs(sessionId, containerId, {
|
||||
timestamps: showTimestamps,
|
||||
});
|
||||
const blob = new Blob([data.logs], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -133,7 +139,9 @@ export function LogViewer({
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
|
||||
@@ -1,345 +1,170 @@
|
||||
import axios from "axios";
|
||||
import { dockerApi, handleApiError } from "@/main-axios";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "@/types/index";
|
||||
} from "./types";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
export interface ApiConnectionLog {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
stage: ConnectionStage;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
details?: string;
|
||||
}
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
/** One step of a connect: connected, a prompt to answer, or auth needed. */
|
||||
export interface ConnectResult {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
config?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
retry?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
url?: string | null;
|
||||
securityKey?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}
|
||||
|
||||
/** An API failure carrying the server's message and connection logs. */
|
||||
export class DockerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly connectionLogs?: ApiConnectionLog[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DockerApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): DockerApiError {
|
||||
const response = (
|
||||
error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
};
|
||||
};
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
)?.response;
|
||||
const data = response?.data;
|
||||
const message =
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`);
|
||||
return new DockerApiError(message, response?.status, data?.connectionLogs);
|
||||
}
|
||||
|
||||
export async function verifyDockerTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker TOTP");
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker Warpgate");
|
||||
}
|
||||
}
|
||||
export type ContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause";
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
export function createDockerApi(api: PluginApiClient) {
|
||||
return {
|
||||
connect: (
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
) =>
|
||||
call<ConnectResult>("connect to Docker", () =>
|
||||
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
|
||||
),
|
||||
|
||||
answerTotp: (sessionId: string, totpCode: string) =>
|
||||
call<ConnectResult>("verify the code", () =>
|
||||
api.post("/ssh/connect-totp", { sessionId, totpCode }),
|
||||
),
|
||||
|
||||
continueWarpgate: (sessionId: string) =>
|
||||
call<ConnectResult>("complete Warpgate authentication", () =>
|
||||
api.post("/ssh/connect-warpgate", { sessionId }),
|
||||
),
|
||||
|
||||
disconnect: (sessionId: string) =>
|
||||
call("disconnect from Docker", () =>
|
||||
api.post("/ssh/disconnect", { sessionId }),
|
||||
),
|
||||
|
||||
keepalive: (sessionId: string) =>
|
||||
call("keep the Docker session alive", () =>
|
||||
api.post("/ssh/keepalive", { sessionId }),
|
||||
),
|
||||
|
||||
validate: (sessionId: string) =>
|
||||
call<DockerValidation>("validate Docker", () =>
|
||||
api.get(`/validate/${sessionId}`),
|
||||
),
|
||||
|
||||
listContainers: (sessionId: string, all = true) =>
|
||||
call<DockerContainer[]>("list containers", () =>
|
||||
api.get(`/containers/${sessionId}`, { params: { all } }),
|
||||
),
|
||||
|
||||
containerAction: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
action: ContainerAction,
|
||||
) =>
|
||||
call<{ success: boolean; message: string }>(
|
||||
`${action} the container`,
|
||||
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
|
||||
),
|
||||
|
||||
removeContainer: (sessionId: string, containerId: string, force = false) =>
|
||||
call<{ success: boolean; message: string }>("remove the container", () =>
|
||||
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
|
||||
params: { force },
|
||||
}),
|
||||
),
|
||||
|
||||
logs: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
) =>
|
||||
call<{ logs: string }>("load container logs", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/logs`, {
|
||||
params: options,
|
||||
}),
|
||||
),
|
||||
|
||||
stats: (sessionId: string, containerId: string) =>
|
||||
call<DockerStats>("load container stats", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/stats`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
export type DockerApi = ReturnType<typeof createDockerApi>;
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
/** The Docker API on this plugin's own client. */
|
||||
export function useDockerApi(): DockerApi {
|
||||
const api = usePluginApi();
|
||||
return useMemo(() => createDockerApi(api), [api]);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** The homepage's grid step, in pixels. */
|
||||
export const GRID_SIZE = 30;
|
||||
|
||||
export interface DockerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** What the homepage hands a widget; only the fields this plugin reads. */
|
||||
export interface WidgetComponentProps<C> {
|
||||
widget: { title?: string | null };
|
||||
config: C;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
export type WidgetDefinition<C> = Omit<
|
||||
HomepageWidgetContribution<C>,
|
||||
"defaultConfig"
|
||||
> & { defaultConfig: C };
|
||||
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import type {
|
||||
HomepageWidgetContribution,
|
||||
HostEditorSectionProps,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import DockerApp from "./DockerApp";
|
||||
import { HostDockerTab } from "./HostDockerTab";
|
||||
import { dockerWidget } from "./DockerWidget";
|
||||
import { dockerEnabled, toDockerHost } from "./types";
|
||||
|
||||
function DockerTab({ sshHost, label, isVisible }: TabProps) {
|
||||
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
|
||||
const record = (host ?? sshHost) as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={sshHost as unknown as SSHHost}
|
||||
host={record ? toDockerHost(record) : undefined}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
|
||||
return <DockerApp hostId={hostId} />;
|
||||
}
|
||||
|
||||
function DockerHostSection({ form, setField }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostDockerTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostDockerTab>[0]["setField"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// Host actions filter synchronously, so the permission is read once here.
|
||||
let canUse = true;
|
||||
void app.hasPermission("use").then((allowed) => {
|
||||
canUse = allowed;
|
||||
});
|
||||
|
||||
app.registerTab("docker", DockerTab, {
|
||||
icon: Box,
|
||||
titleKey: "nav.docker",
|
||||
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
|
||||
order: 30,
|
||||
tabType: "docker",
|
||||
copyUrlView: "docker",
|
||||
when: (host) => !!host.enableSsh && !!host.enableDocker,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "docker",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabDocker",
|
||||
icon: Box,
|
||||
order: 30,
|
||||
component: DockerHostSection,
|
||||
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
|
||||
});
|
||||
|
||||
app.registerHomepageWidget(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state:
|
||||
| "created"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "restarting"
|
||||
| "removing"
|
||||
| "exited"
|
||||
| "dead";
|
||||
ports: string;
|
||||
created: string;
|
||||
command?: string;
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
mounts?: string[];
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids?: string;
|
||||
}
|
||||
|
||||
export interface DockerLogOptions {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
runtime?: "docker" | "podman";
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** What the Docker views read from a host. */
|
||||
export interface DockerHost {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
}
|
||||
|
||||
/** Any host record the shell or an API hands over, as a DockerHost. */
|
||||
export function toDockerHost(record: Record<string, unknown>): DockerHost {
|
||||
return {
|
||||
...(record as unknown as DockerHost),
|
||||
id: Number(record.id),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerSettings(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
const all = (host as { pluginSettings?: unknown } | null | undefined)
|
||||
?.pluginSettings as
|
||||
Record<string, Record<string, unknown> | undefined> | undefined;
|
||||
return all?.docker ?? {};
|
||||
}
|
||||
|
||||
export function dockerEnabled(host: object | null | undefined): boolean {
|
||||
return dockerSettings(host).enableDocker === true;
|
||||
}
|
||||
|
||||
export function hostTitle(host: DockerHost): string {
|
||||
return host.name || `${host.username ?? ""}@${host.ip}`;
|
||||
}
|
||||
Reference in new issue
Block a user