Files
Termix/plugins/docker/frontend/components/ConsoleTerminal.tsx
T
LukeGus a2479c19b0 feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
2026-09-21 09:55:37 -05:00

637 lines
19 KiB
TypeScript

import { getErrorMessage } from "@/lib/error-message.js";
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import { Select2 } from "@/components/select2";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import { useTheme } from "@/components/theme-provider";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
interface ConsoleTerminalProps {
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
}
export function ConsoleTerminal(
props: ConsoleTerminalProps,
): React.ReactElement {
return (
<ConnectionLogProvider>
<ConsoleTerminalInner {...props} />
</ConnectionLogProvider>
);
}
function ConsoleTerminalInner({
containerId,
containerName,
containerState,
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const { addLog, clearLogs } = useConnectionLog();
const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
[hostConfig.terminalConfig],
);
const themeColors = React.useMemo(() => {
const activeTheme = terminalConfig.theme;
return resolveTermixThemeColors(activeTheme, appTheme);
}, [terminalConfig.theme, appTheme]);
const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === terminalConfig.fontFamily,
);
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.cursorBlink = terminalConfig.cursorBlink;
terminal.options.cursorStyle = terminalConfig.cursorStyle;
terminal.options.fontSize = terminalConfig.fontSize;
terminal.options.fontFamily = fontFamily;
terminal.options.scrollback = terminalConfig.scrollback;
terminal.options.letterSpacing = terminalConfig.letterSpacing;
terminal.options.lineHeight = terminalConfig.lineHeight;
const readTextFromClipboard = async (): Promise<string> => {
return readFromClipboard();
};
const writeTextToClipboard = async (text: string): Promise<void> => {
await copyToClipboard(text);
};
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
if (
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
terminal.clearSelection();
}
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
}
return false;
}
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
return true;
});
terminal.options.theme = {
background: themeColors.background,
foreground: themeColors.foreground,
cursor: themeColors.cursor,
cursorAccent: themeColors.cursorAccent,
selectionBackground: themeColors.selectionBackground,
selectionForeground: themeColors.selectionForeground,
black: themeColors.black,
red: themeColors.red,
green: themeColors.green,
yellow: themeColors.yellow,
blue: themeColors.blue,
magenta: themeColors.magenta,
cyan: themeColors.cyan,
white: themeColors.white,
brightBlack: themeColors.brightBlack,
brightRed: themeColors.brightRed,
brightGreen: themeColors.brightGreen,
brightYellow: themeColors.brightYellow,
brightBlue: themeColors.brightBlue,
brightMagenta: themeColors.brightMagenta,
brightCyan: themeColors.brightCyan,
brightWhite: themeColors.brightWhite,
};
setTimeout(() => {
fitAddon.fit();
}, 100);
const resizeHandler = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const { rows, cols } = terminal;
wsRef.current.send(
JSON.stringify({
type: "resize",
data: { rows, cols },
}),
);
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
clipboardProvider.dispose();
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal, t, terminalConfig, themeColors]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect.
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
retryRef.current.reset();
clearLogs();
if (terminal) {
try {
terminal.clear();
} catch {
// Terminal clear can fail after disposal.
}
}
}, [terminal, clearLogs]);
const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
}
setIsConnecting(true);
addLog({
type: "info",
stage: "docker_connecting",
message: t("docker.connectingTo", { containerName }),
});
try {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
let baseWsUrl: string;
let wsProtocols: string[] = [];
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
} else if (isElectronApp) {
const origin = await resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin,
});
const resolvedUrl = await buildOriginWsUrl({
origin,
localPort: 30009,
localPath: "/docker/console/",
remotePath: "/docker/console/",
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl.url;
wsProtocols = resolvedUrl.protocols;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
}
const ws = new WebSocket(baseWsUrl, wsProtocols);
ws.onopen = () => {
const cols = terminal.cols || 80;
const rows = terminal.rows || 24;
ws.send(
JSON.stringify({
type: "connect",
data: {
hostConfig,
containerId,
shell: selectedShell,
cols,
rows,
},
}),
);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "output":
terminal.write(msg.data);
break;
case "connected":
setIsConnected(true);
setIsConnecting(false);
retryRef.current.markConnected();
if (msg.data?.shellChanged) {
toast.warning(
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
);
} else {
toast.success(t("docker.connectedTo", { containerName }));
}
setTimeout(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
data: { rows: terminal.rows, cols: terminal.cols },
}),
);
}
}, 100);
break;
case "disconnected":
setIsConnected(false);
setIsConnecting(false);
retryRef.current.reset();
terminal.write(
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
break;
case "error":
setIsConnecting(false);
toast.error(msg.message || t("docker.consoleError"));
terminal.write(
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
);
addLog({
type: "error",
stage: "error",
message: msg.message || t("docker.consoleError"),
});
retryRef.current.markFailed();
break;
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
setIsConnecting(false);
setIsConnected(false);
toast.error(t("docker.failedToConnect"));
addLog({
type: "error",
stage: "error",
message: t("docker.failedToConnect"),
});
retryRef.current.markFailed();
};
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
ws.onclose = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
if (wsRef.current === ws) {
wsRef.current = null;
}
};
wsRef.current = ws;
terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "input",
data,
}),
);
}
});
} catch (error) {
setIsConnecting(false);
const message = `Failed to connect: ${getErrorMessage(error)}`;
toast.error(message);
addLog({ type: "error", stage: "error", message });
retryRef.current.markFailed();
}
}, [
terminal,
containerState,
hostConfig,
containerId,
selectedShell,
containerName,
t,
addLog,
]);
const retry = useConnectionRetry({
connect,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, []);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToAccess")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">
{t("docker.console")}
</span>
</div>
<Select2
value={selectedShell}
onChange={(event) => setSelectedShell(event.target.value)}
disabled={isConnected}
placeholder={t("docker.selectShell")}
className="w-[120px]"
>
<option value="bash">{t("docker.bash")}</option>
<option value="sh">{t("docker.sh")}</option>
<option value="ash">{t("docker.ash")}</option>
</Select2>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
{t("docker.connecting")}
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
{t("docker.connect")}
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
{t("docker.disconnect")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card
className="flex-1 overflow-hidden pt-1 pb-0"
style={{ background: themeColors.background }}
>
<CardContent className="p-0 h-full relative">
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{!isConnected &&
!isConnecting &&
retry.status !== "error" &&
retry.status !== "disconnected" && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
</div>
)}
{!isConnected &&
(isConnecting ||
retry.status === "error" ||
retry.status === "disconnected") && (
<ConnectionScreen
status={isConnecting ? "connecting" : retry.status}
message={t("docker.connectingTo", { containerName })}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={() => {
clearLogs();
retry.retryNow();
}}
retryLabel={t("docker.connect")}
/>
)}
</CardContent>
</Card>
</div>
);
}