Files
ProxMenux/AppImage/components/lxc-terminal-modal.tsx

516 lines
17 KiB
TypeScript
Raw Normal View History

2026-01-31 16:17:36 +01:00
"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,
2026-02-01 01:13:13 +01:00
ChevronDown,
2026-01-31 16:17:36 +01:00
} from "lucide-react"
2026-02-01 01:13:13 +01:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
2026-01-31 16:17:36 +01:00
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
interface LxcTerminalModalProps {
open: boolean
onClose: () => void
vmid: number
vmName: string
}
2026-01-31 18:10:55 +01:00
function getWebSocketUrl(): string {
2026-01-31 16:17:36 +01:00
if (typeof window === "undefined") {
2026-01-31 18:10:55 +01:00
return "ws://localhost:8008/ws/terminal"
2026-01-31 16:17:36 +01:00
}
const { protocol, hostname, port } = window.location
const isStandardPort = port === "" || port === "80" || port === "443"
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
if (isStandardPort) {
2026-01-31 18:10:55 +01:00
return `${wsProtocol}//${hostname}/ws/terminal`
2026-01-31 16:17:36 +01:00
} else {
2026-01-31 18:10:55 +01:00
return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal`
2026-01-31 16:17:36 +01:00
}
}
export function LxcTerminalModal({
open: isOpen,
onClose,
vmid,
vmName,
}: LxcTerminalModalProps) {
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
const fitAddonRef = useRef<any>(null)
const terminalContainerRef = useRef<HTMLDivElement>(null)
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
2026-02-01 00:50:53 +01:00
const isInsideLxcRef = useRef(false)
2026-01-31 18:10:55 +01:00
const outputBufferRef = useRef<string>("")
2026-01-31 16:17:36 +01:00
const [modalHeight, setModalHeight] = useState(500)
const [isResizing, setIsResizing] = useState(false)
const resizeBarRef = useRef<HTMLDivElement>(null)
const modalHeightRef = useRef(500)
2026-02-01 01:13:13 +01:00
2026-02-01 00:50:53 +01:00
2026-01-31 16:17:36 +01:00
// 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")
2026-02-01 00:50:53 +01:00
isInsideLxcRef.current = false
2026-01-31 18:10:55 +01:00
outputBufferRef.current = ""
2026-01-31 16:17:36 +01:00
}
}, [isOpen])
// Initialize terminal
useEffect(() => {
2026-01-31 17:02:11 +01:00
if (!isOpen) return
// Small delay to ensure Dialog content is rendered
const initTimeout = setTimeout(() => {
2026-01-31 17:19:50 +01:00
if (!terminalContainerRef.current) return
2026-01-31 17:02:11 +01:00
initTerminal()
}, 100)
2026-01-31 16:17:36 +01:00
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
2026-01-31 18:10:55 +01:00
// Connect WebSocket to host terminal
const wsUrl = getWebSocketUrl()
2026-01-31 16:17:36 +01:00
const ws = new WebSocket(wsUrl)
wsRef.current = ws
2026-01-31 18:10:55 +01:00
2026-02-01 01:13:13 +01:00
// Reset state for new connection
isInsideLxcRef.current = false
outputBufferRef.current = ""
2026-01-31 16:17:36 +01:00
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)
2026-01-31 17:19:50 +01:00
// Sync terminal size
2026-01-31 16:17:36 +01:00
fitAddon.fit()
ws.send(JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}))
2026-01-31 18:10:55 +01:00
// Auto-execute pct enter after connection is ready
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`pct enter ${vmid}\r`)
}
}, 300)
2026-01-31 16:17:36 +01:00
}
2026-01-31 17:19:50 +01:00
ws.onerror = () => {
2026-01-31 16:17:36 +01:00
setConnectionStatus("offline")
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
}
2026-01-31 17:19:50 +01:00
ws.onclose = () => {
2026-01-31 16:17:36 +01:00
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)
}
})
2026-01-31 18:10:55 +01:00
ws.onmessage = (event) => {
// Filter out pong responses
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
2026-02-01 00:50:53 +01:00
// Buffer output until we detect we're inside the LXC
// pct enter always enters directly without login prompt when run as root
if (!isInsideLxcRef.current) {
outputBufferRef.current += event.data
2026-01-31 18:17:50 +01:00
2026-02-01 00:50:53 +01:00
// Detect when we're inside the LXC container
// The LXC prompt will NOT contain "constructor" (the host name)
// It will be something like "root@plex:/#" or "user@containername:~$"
const buffer = outputBufferRef.current
2026-01-31 18:29:19 +01:00
2026-02-01 00:50:53 +01:00
// Look for a prompt that:
// 1. Comes after pct enter command
// 2. Has @ followed by container name (not host name)
// 3. Ends with # or $
const pctEnterMatch = buffer.match(/pct enter \d+\r?\n/)
if (pctEnterMatch) {
const afterPctEnter = buffer.substring(buffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length)
// Find the LXC prompt - it should be a line ending with :~# :~$ :/# or similar
// and NOT containing the host name "constructor"
const lxcPromptMatch = afterPctEnter.match(/\r?\n?([^\r\n]*@(?!constructor)[^\r\n]*[#$]\s*)$/)
if (lxcPromptMatch) {
// Successfully inside LXC - only show from the LXC prompt onwards
isInsideLxcRef.current = true
// Find where the LXC prompt line starts
const promptStart = afterPctEnter.lastIndexOf(lxcPromptMatch[1])
if (promptStart !== -1) {
// Only show the LXC prompt itself
term.write(lxcPromptMatch[1])
2026-02-01 00:42:51 +01:00
}
2026-02-01 00:50:53 +01:00
return
2026-01-31 18:10:55 +01:00
}
}
2026-02-01 00:50:53 +01:00
} else {
// Already inside LXC, write directly
term.write(event.data)
2026-02-01 00:42:51 +01:00
}
2026-01-31 18:10:55 +01:00
}
2026-01-31 16:17:36 +01:00
}
return () => {
2026-01-31 17:02:11 +01:00
clearTimeout(initTimeout)
2026-01-31 16:17:36 +01:00
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)
}
}, [])
2026-01-31 16:48:22 +01:00
const sendEsc = useCallback(() => sendKey("\x1b"), [sendKey])
const sendTab = useCallback(() => sendKey("\t"), [sendKey])
2026-01-31 16:17:36 +01:00
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])
2026-01-31 16:48:22 +01:00
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
2026-01-31 16:17:36 +01:00
const showMobileControls = isMobile || isTablet
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="max-w-4xl w-[95vw] p-0 gap-0 bg-black border-border overflow-hidden flex flex-col"
style={{ height: `${modalHeight}px` }}
2026-01-31 16:48:22 +01:00
hideClose
2026-01-31 16:17:36 +01:00
>
{/* Resize bar */}
<div
ref={resizeBarRef}
className="h-3 w-full cursor-ns-resize flex items-center justify-center bg-zinc-900 hover:bg-zinc-800 transition-colors touch-none"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
>
<GripHorizontal className="h-4 w-4 text-zinc-500" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
2026-01-31 17:40:08 +01:00
<DialogTitle className="text-sm font-medium text-white">
Terminal: {vmName} (ID: {vmid})
</DialogTitle>
2026-01-31 16:17:36 +01:00
</div>
{/* Terminal container */}
<div className="flex-1 overflow-hidden bg-black p-1">
<div
ref={terminalContainerRef}
className="w-full h-full"
style={{ minHeight: "200px" }}
/>
</div>
{/* Mobile/Tablet control buttons */}
{showMobileControls && (
2026-01-31 17:19:50 +01:00
<div className="px-2 py-2 bg-zinc-900 border-t border-zinc-800">
<div className="flex items-center justify-center gap-1.5">
2026-01-31 16:17:36 +01:00
<Button
variant="outline"
size="sm"
2026-01-31 16:48:22 +01:00
onClick={sendEsc}
2026-01-31 17:19:50 +01:00
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
2026-01-31 16:48:22 +01:00
>
ESC
</Button>
<Button
variant="outline"
size="sm"
onClick={sendTab}
2026-01-31 17:19:50 +01:00
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
2026-01-31 16:17:36 +01:00
>
2026-01-31 16:48:22 +01:00
TAB
2026-01-31 16:17:36 +01:00
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowUp}
2026-01-31 17:19:50 +01:00
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
2026-01-31 16:17:36 +01:00
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowDown}
2026-01-31 17:19:50 +01:00
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
2026-01-31 16:17:36 +01:00
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowLeft}
2026-01-31 17:19:50 +01:00
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
2026-01-31 16:17:36 +01:00
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={sendArrowRight}
2026-01-31 17:19:50 +01:00
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
2026-01-31 16:17:36 +01:00
>
<ArrowRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
2026-01-31 16:48:22 +01:00
onClick={sendEnter}
2026-01-31 17:19:50 +01:00
className="h-8 px-2 text-xs bg-blue-600/20 border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
2026-01-31 16:17:36 +01:00
>
2026-01-31 16:48:22 +01:00
<CornerDownLeft className="h-4 w-4 mr-1" />
Enter
2026-01-31 16:17:36 +01:00
</Button>
2026-02-01 01:13:13 +01:00
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300 gap-1"
>
Ctrl
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">Exit (nano)</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
2026-01-31 16:17:36 +01:00
</div>
</div>
)}
2026-01-31 17:40:08 +01:00
{/* Status bar at bottom */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-t border-zinc-800">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-500" />
<div
className={`w-2 h-2 rounded-full ${
connectionStatus === "online"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500 animate-pulse"
: "bg-red-500"
}`}
/>
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
</div>
<Button
onClick={onClose}
variant="outline"
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
Close
</Button>
</div>
2026-01-31 16:17:36 +01:00
</DialogContent>
</Dialog>
)
}