import React from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Button } from "@/components/ui/button.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx"; import { Card, CardContent } from "@/components/ui/card.tsx"; import { getBasePath } from "@/lib/base-path"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { toast } from "sonner"; import type { SSHHost } from "@/types"; import { getCookie, isElectron } from "@/ui/main-axios.ts"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; import { useTranslation } from "react-i18next"; interface ConsoleTerminalProps { sessionId: string; containerId: string; containerName: string; containerState: string; hostConfig: SSHHost; } export function ConsoleTerminal({ sessionId, containerId, containerName, containerState, hostConfig, }: ConsoleTerminalProps): React.ReactElement { const { t } = useTranslation(); const { instance: terminal, ref: xtermRef } = useXTerm(); const [isConnected, setIsConnected] = React.useState(false); const [isConnecting, setIsConnecting] = React.useState(false); const [selectedShell, setSelectedShell] = React.useState("bash"); const wsRef = React.useRef(null); const fitAddonRef = React.useRef(null); const pingIntervalRef = React.useRef(null); React.useEffect(() => { if (!terminal) return; const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); const webLinksAddon = new WebLinksAddon(); fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); terminal.loadAddon(clipboardAddon); terminal.loadAddon(webLinksAddon); terminal.options.cursorBlink = true; terminal.options.fontSize = 14; terminal.options.fontFamily = "monospace"; const backgroundColor = getComputedStyle(document.documentElement) .getPropertyValue("--bg-elevated") .trim(); const foregroundColor = getComputedStyle(document.documentElement) .getPropertyValue("--foreground") .trim(); terminal.options.theme = { background: backgroundColor || "var(--bg-elevated)", foreground: foregroundColor || "var(--foreground)", }; 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); if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch (error) {} wsRef.current.close(); wsRef.current = null; } terminal.dispose(); }; }, [terminal]); const disconnect = React.useCallback(() => { if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch (error) {} wsRef.current.close(); wsRef.current = null; } setIsConnected(false); if (terminal) { try { terminal.clear(); } catch (error) {} } }, [terminal, t]); const connect = React.useCallback(() => { if (!terminal || containerState !== "running") { toast.error(t("docker.containerMustBeRunning")); return; } setIsConnecting(true); try { const token = isElectron() ? localStorage.getItem("jwt") : getCookie("jwt"); if (!token) { toast.error(t("docker.authenticationRequired")); setIsConnecting(false); return; } 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 === ""); const baseWsUrl = isDev ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008` : isElectronApp ? (() => { const baseUrl = (window as { configuredServerUrl?: string }) .configuredServerUrl || "http://127.0.0.1:30001"; const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://"; const wsHost = baseUrl.replace(/^https?:\/\//, ""); return `${wsProtocol}${wsHost}/docker/console/`; })() : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`; const ws = new WebSocket(wsUrl); 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); 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); 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`, ); 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")); }; 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); toast.error( `Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`, ); } }, [ terminal, containerState, hostConfig, containerId, selectedShell, containerName, t, ]); React.useEffect(() => { return () => { if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } if (wsRef.current) { try { wsRef.current.send(JSON.stringify({ type: "disconnect" })); } catch (error) {} wsRef.current.close(); wsRef.current = null; } setIsConnected(false); }; }, []); if (containerState !== "running") { return (

{t("docker.containerNotRunning")}

{t("docker.startContainerToAccess")}

); } return (
{t("docker.console")}
{!isConnected ? ( ) : ( )}
{!isConnected && !isConnecting && (

{t("docker.notConnected")}

{t("docker.clickToConnect")}

)} {isConnecting && (

{t("docker.connectingTo", { containerName })}

)}
); }