From e3d10495f3160850e547fc6b3ecc62fbd22a2c67 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 31 Jan 2026 16:17:36 +0100 Subject: [PATCH] Update terminal modal --- AppImage/components/lxc-terminal-modal.tsx | 421 +++++++++++++++++++++ AppImage/components/virtual-machines.tsx | 41 +- 2 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 AppImage/components/lxc-terminal-modal.tsx diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx new file mode 100644 index 00000000..4ff93bee --- /dev/null +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -0,0 +1,421 @@ +"use client" + +import type React from "react" +import { useState, useEffect, useRef, useCallback } from "react" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Activity, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + CornerDownLeft, + GripHorizontal, + X, +} from "lucide-react" +import "xterm/css/xterm.css" +import { API_PORT } from "@/lib/api-config" + +interface LxcTerminalModalProps { + open: boolean + onClose: () => void + vmid: number + vmName: string +} + +function getWebSocketUrl(): string { + if (typeof window === "undefined") { + return "ws://localhost:8008/ws/terminal" + } + + const { protocol, hostname, port } = window.location + const isStandardPort = port === "" || port === "80" || port === "443" + const wsProtocol = protocol === "https:" ? "wss:" : "ws:" + + if (isStandardPort) { + return `${wsProtocol}//${hostname}/ws/terminal` + } else { + return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal` + } +} + +export function LxcTerminalModal({ + open: isOpen, + onClose, + vmid, + vmName, +}: LxcTerminalModalProps) { + const termRef = useRef(null) + const wsRef = useRef(null) + const fitAddonRef = useRef(null) + const terminalContainerRef = useRef(null) + const pingIntervalRef = useRef | null>(null) + + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting") + const [isMobile, setIsMobile] = useState(false) + const [isTablet, setIsTablet] = useState(false) + + const [modalHeight, setModalHeight] = useState(500) + const [isResizing, setIsResizing] = useState(false) + const resizeBarRef = useRef(null) + const modalHeightRef = useRef(500) + + // Detect mobile/tablet + useEffect(() => { + const checkDevice = () => { + const width = window.innerWidth + setIsMobile(width < 640) + setIsTablet(width >= 640 && width < 1024) + } + checkDevice() + window.addEventListener("resize", checkDevice) + return () => window.removeEventListener("resize", checkDevice) + }, []) + + // Cleanup on close + useEffect(() => { + if (!isOpen) { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + if (termRef.current) { + termRef.current.dispose() + termRef.current = null + } + setConnectionStatus("connecting") + } + }, [isOpen]) + + // Initialize terminal + useEffect(() => { + if (!isOpen || !terminalContainerRef.current) return + + const initTerminal = async () => { + const [TerminalClass, FitAddonClass] = await Promise.all([ + import("xterm").then((mod) => mod.Terminal), + import("xterm-addon-fit").then((mod) => mod.FitAddon), + ]) + + const fontSize = window.innerWidth < 768 ? 12 : 16 + + const term = new TerminalClass({ + rendererType: "dom", + fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', + fontSize: fontSize, + lineHeight: 1, + cursorBlink: true, + scrollback: 2000, + disableStdin: false, + customGlyphs: true, + fontWeight: "500", + fontWeightBold: "700", + theme: { + background: "#000000", + foreground: "#ffffff", + cursor: "#ffffff", + cursorAccent: "#000000", + black: "#2e3436", + red: "#cc0000", + green: "#4e9a06", + yellow: "#c4a000", + blue: "#3465a4", + magenta: "#75507b", + cyan: "#06989a", + white: "#d3d7cf", + brightBlack: "#555753", + brightRed: "#ef2929", + brightGreen: "#8ae234", + brightYellow: "#fce94f", + brightBlue: "#729fcf", + brightMagenta: "#ad7fa8", + brightCyan: "#34e2e2", + brightWhite: "#eeeeec", + }, + }) + + const fitAddon = new FitAddonClass() + term.loadAddon(fitAddon) + + if (terminalContainerRef.current) { + term.open(terminalContainerRef.current) + fitAddon.fit() + } + + termRef.current = term + fitAddonRef.current = fitAddon + + // Connect WebSocket + const wsUrl = getWebSocketUrl() + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onopen = () => { + setConnectionStatus("online") + + // Start heartbeat ping + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } else { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + } + }, 25000) + + // Sync terminal size + fitAddon.fit() + ws.send(JSON.stringify({ + type: "resize", + cols: term.cols, + rows: term.rows, + })) + + // Auto-execute pct enter command after a brief delay + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`pct enter ${vmid}\n`) + } + }, 500) + } + + ws.onmessage = (event) => { + // Filter out pong responses + if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { + return + } + term.write(event.data) + } + + ws.onerror = () => { + setConnectionStatus("offline") + term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") + } + + ws.onclose = () => { + setConnectionStatus("offline") + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") + } + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + } + + initTerminal() + + return () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + if (termRef.current) { + termRef.current.dispose() + } + } + }, [isOpen, vmid]) + + // Resize handling + useEffect(() => { + if (termRef.current && fitAddonRef.current && isOpen) { + setTimeout(() => { + fitAddonRef.current?.fit() + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "resize", + cols: termRef.current.cols, + rows: termRef.current.rows, + })) + } + }, 100) + } + }, [modalHeight, isOpen]) + + // Resize bar handlers + const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + setIsResizing(true) + modalHeightRef.current = modalHeight + }, [modalHeight]) + + useEffect(() => { + if (!isResizing) return + + const handleMove = (e: MouseEvent | TouchEvent) => { + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + const windowHeight = window.innerHeight + const newHeight = windowHeight - clientY - 20 + const clampedHeight = Math.max(300, Math.min(windowHeight - 100, newHeight)) + modalHeightRef.current = clampedHeight + setModalHeight(clampedHeight) + } + + const handleEnd = () => { + setIsResizing(false) + } + + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleEnd) + document.addEventListener("touchmove", handleMove) + document.addEventListener("touchend", handleEnd) + + return () => { + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleEnd) + document.removeEventListener("touchmove", handleMove) + document.removeEventListener("touchend", handleEnd) + } + }, [isResizing]) + + // Send key helpers for mobile/tablet + const sendKey = useCallback((key: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(key) + } + }, []) + + const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) + const sendArrowUp = useCallback(() => sendKey("\x1b[A"), [sendKey]) + const sendArrowDown = useCallback(() => sendKey("\x1b[B"), [sendKey]) + const sendArrowLeft = useCallback(() => sendKey("\x1b[D"), [sendKey]) + const sendArrowRight = useCallback(() => sendKey("\x1b[C"), [sendKey]) + const sendEnter = useCallback(() => sendKey("\r"), [sendKey]) + const sendTab = useCallback(() => sendKey("\t"), [sendKey]) + + const showMobileControls = isMobile || isTablet + + return ( + !open && onClose()}> + + {/* Resize bar */} +
+ +
+ + {/* Header */} +
+
+ + Terminal: {vmName} (ID: {vmid}) + +
+ + {connectionStatus} +
+
+ +
+ + {/* Terminal container */} +
+
+
+ + {/* Mobile/Tablet control buttons */} + {showMobileControls && ( +
+
+ + + + + + + +
+
+ )} + +
+ ) +} diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a94dbf20..c7e64115 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -8,9 +8,10 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal } from 'lucide-react' import useSWR from "swr" import { MetricsView } from "./metrics-dialog" +import { LxcTerminalModal } from "./lxc-terminal-modal" import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" @@ -256,6 +257,9 @@ export function VirtualMachines() { const [vmDetails, setVMDetails] = useState(null) const [controlLoading, setControlLoading] = useState(false) const [detailsLoading, setDetailsLoading] = useState(false) + const [terminalOpen, setTerminalOpen] = useState(false) + const [terminalVmid, setTerminalVmid] = useState(null) + const [terminalVmName, setTerminalVmName] = useState("") const [vmConfigs, setVmConfigs] = useState>({}) const [currentView, setCurrentView] = useState<"main" | "metrics">("main") const [showAdditionalInfo, setShowAdditionalInfo] = useState(false) @@ -380,7 +384,14 @@ export function VirtualMachines() { } } - const handleDownloadLogs = async (vmid: number, vmName: string) => { + // Open terminal for LXC container + const openLxcTerminal = (vmid: number, vmName: string) => { + setTerminalVmid(vmid) + setTerminalVmName(vmName) + setTerminalOpen(true) + } + +const handleDownloadLogs = async (vmid: number, vmName: string) => { try { const data = await fetchApi(`/api/vms/${vmid}/logs`) @@ -1762,6 +1773,18 @@ export function VirtualMachines() {
+ {/* Terminal button for LXC containers - only when running */} + {selectedVM?.type === "lxc" && selectedVM?.status === "running" && ( +
+ +
+ )}
) }