Files
ProxMenux/AppImage/components/script-terminal-modal.tsx
T
MacRimi 89fdfd0952 fix: notification history, Ctrl+D in web terminals, repository and VFIO rule fixes, translation corrections
- Notification settings show the last 20 notifications with the result of each send, the error when one failed and every daily digest run, also on days with nothing to send; the history is read without the flag the API never returned, and the page does not offer to erase it, as Audit & Report reads it.
- The Ctrl menu of the three web terminals sends Ctrl+D, which ends a text pasted in the terminal, also from a phone or a tablet.
- The repository check lists every Proxmox VE or Enterprise Ceph repository whose suite does not match the installed version, with its channel and file, and says where to disable it; repository addresses are never shown.
- Adding an NVIDIA GPU to a VM writes its VFIO udev rule again when it is missing or was edited, and the pre-check counts a missing rule as a pending host change.
- New texts in English and Spanish, modelled in the translation steady-state test, and the notifications docs page describes the history section.
- Corrected the automatic translations of the new LXC update and power control texts, and the start, shutdown, reboot and stop buttons of VMs and LXCs in German, French, Portuguese and Swedish.
- Tests for the history panel, the terminal Ctrl+D, the repository message and the VFIO rule.
2026-10-15 18:30:53 +02:00

1108 lines
40 KiB
TypeScript

"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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Loader2,
Activity,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
CornerDownLeft,
GripHorizontal,
ChevronDown,
Copy,
Clipboard,
} from "lucide-react"
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu"
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
id: string
title: string
message: string
options?: Array<{ label: string; value: string }>
default?: string
}
interface ScriptTerminalModalProps {
open: boolean
onClose: () => void
scriptPath: string
title: string
description: string
scriptName?: string
params?: Record<string, string>
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
// flow so "Press Enter to close" in the bash script actually
// closes the modal without an extra click. Other callers ignore.
onComplete?: (exitCode?: number) => void
}
export function ScriptTerminalModal({
open: isOpen,
onClose,
scriptPath,
title,
description,
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
const termRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
// after dynamic imports resolve — captures the latest value without
// re-binding the closure.
const isOpenRef = useRef<boolean>(false)
const fitAddonRef = useRef<any>(null)
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
const [isComplete, setIsComplete] = useState(false)
const [currentInteraction, setCurrentInteraction] = useState<WebInteraction | null>(null)
const [interactionInput, setInteractionInput] = useState("")
const checkConnectionInterval = useRef<NodeJS.Timeout | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef(0)
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
const completionReceivedRef = useRef(false)
const [isMobile, setIsMobile] = useState(false)
const [isTablet, setIsTablet] = useState(false)
const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [modalHeight, setModalHeight] = useState(600)
const [isResizing, setIsResizing] = useState(false)
const resizeBarRef = useRef<HTMLDivElement>(null)
const modalHeightRef = useRef(600)
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
const paramsRef = useRef(params)
// Keep paramsRef updated with latest params
useEffect(() => {
paramsRef.current = params
}, [params])
// Same trick for onComplete — we want the latest callback inside
// the ws.onclose handler without re-running the connection effect.
const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined)
useEffect(() => {
onCompleteRef.current = onComplete
}, [onComplete])
const attemptReconnect = useCallback(() => {
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
return
}
reconnectAttemptsRef.current++
setConnectionStatus("connecting")
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
wsRef.current.close()
}
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
wsRef.current = ws
ws.onopen = () => {
setConnectionStatus("online")
reconnectAttemptsRef.current = 0
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
}
keepAliveIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }))
}
}, 30000)
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
const cols = termRef.current.cols
const rows = termRef.current.rows
ws.send(JSON.stringify({ type: "resize", cols, rows }))
}
}, 100)
}
ws.onmessage = (event) => {
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
// The PTY worker always emits this final line. Treat it as a
// completion fallback because some WebSocket servers tear down the
// connection before the following structured message is flushed.
const exitMatch = typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
try {
const msg = JSON.parse(event.data)
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
setCurrentInteraction({
type: msg.interaction.type,
id: msg.interaction.id,
title: msg.interaction.title || "",
message: msg.interaction.message || "",
options: msg.interaction.options,
default: msg.interaction.default,
})
return
}
if (msg.type === "error") {
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
if (msg.type === "script_complete") {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
} catch {}
termRef.current?.write(event.data)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
}
ws.onerror = () => {
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
}
}
ws.onclose = (event) => {
setConnectionStatus("offline")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (completionReceivedRef.current) {
return
}
if (!isComplete && reconnectAttemptsRef.current < 3) {
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
} else {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
const keyMap: Record<string, string> = {
escape: "\x1b",
tab: "\t",
up: "\x1bOA",
down: "\x1bOB",
left: "\x1bOD",
right: "\x1bOC",
enter: "\r",
ctrlc: "\x03",
}
const sequence = keyMap[key]
if (sequence && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(sequence)
}
}, [])
const initializeTerminal = async () => {
// Snapshot the open-state at call time. After the dynamic xterm
// imports resolve, bail out if the modal has since been closed —
// otherwise we attach a Terminal to a stale ref and open a WS that
// nobody reads.
const wasOpenAtCall = isOpenRef.current
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
import("xterm/css/xterm.css"),
])
if (!wasOpenAtCall || !isOpenRef.current) return
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "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)
}
termRef.current = term
fitAddonRef.current = fitAddon
setTimeout(() => {
if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit()
// Send the keyboard to the xterm instance so any --yesno /
// --menu the script opens receives Enter / arrow keys
// directly. Without this the modal's Close button (or any
// other focusable descendant) intercepts the keystrokes.
termRef.current.focus()
}
}, 100)
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
wsRef.current = ws
ws.onopen = () => {
setConnectionStatus("online")
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
}
keepAliveIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }))
}
}, 30000)
const initMessage = {
script_path: scriptPath,
params: paramsRef.current,
}
ws.send(JSON.stringify(initMessage))
setTimeout(() => {
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
const cols = termRef.current.cols
const rows = termRef.current.rows
ws.send(
JSON.stringify({
type: "resize",
cols: cols,
rows: rows,
}),
)
}
}, 100)
}
ws.onmessage = (event) => {
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
}
// See the reconnect handler above. The exit line is guaranteed to be
// sent with the PTY output and is therefore a robust fallback when a
// final JSON frame is lost during server-side socket teardown.
const exitMatch = typeof event.data === "string"
? event.data.match(/\[Script exited with code (-?\d+)\]/)
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
try {
const msg = JSON.parse(event.data)
if (msg.type === "web_interaction" && msg.interaction) {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
setCurrentInteraction({
type: msg.interaction.type,
id: msg.interaction.id,
title: msg.interaction.title || "",
message: msg.interaction.message || "",
options: msg.interaction.options,
default: msg.interaction.default,
})
return
}
if (msg.type === "error") {
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
return
}
if (msg.type === "script_complete") {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
}
} catch {
// Not JSON, es output normal de terminal
}
term.write(event.data)
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
}
ws.onerror = (error) => {
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
}
}
ws.onclose = (event) => {
setConnectionStatus("offline")
if (!completionReceivedRef.current) {
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
}
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
if (!completionReceivedRef.current && !isComplete) {
setIsComplete(true)
onCompleteRef.current?.(-1)
}
}
// Read `wsRef.current` inside the handler so reconnect (which swaps
// `wsRef.current` to a fresh WebSocket) doesn't leave us writing to the
// dead closure-captured `ws`. Without this fix, after reconnect the
// user's stdin disappears into the void. Audit residual #8.
term.onData((data) => {
const live = wsRef.current
if (live && live.readyState === WebSocket.OPEN) {
live.send(data)
}
})
checkConnectionInterval.current = setInterval(() => {
if (wsRef.current) {
setConnectionStatus(
wsRef.current.readyState === WebSocket.OPEN
? "online"
: wsRef.current.readyState === WebSocket.CONNECTING
? "connecting"
: "offline",
)
}
}, 500)
let resizeTimeout: NodeJS.Timeout | null = null
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout) clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(() => {
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
fitAddonRef.current.fit()
wsRef.current.send(
JSON.stringify({
type: "resize",
cols: termRef.current.cols,
rows: termRef.current.rows,
}),
)
}
}, 100)
})
if (terminalContainerRef.current) {
resizeObserver.observe(terminalContainerRef.current)
}
}
useEffect(() => {
isOpenRef.current = isOpen
const savedHeight = localStorage.getItem("scriptModalHeight")
if (savedHeight) {
const height = Number.parseInt(savedHeight, 10)
setModalHeight(height)
modalHeightRef.current = height
}
if (isOpen) {
initializeTerminal()
} else {
if (checkConnectionInterval.current) {
clearInterval(checkConnectionInterval.current)
}
if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current)
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
}
if (keepAliveIntervalRef.current) {
clearInterval(keepAliveIntervalRef.current)
keepAliveIntervalRef.current = null
}
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
reconnectAttemptsRef.current = 0
completionReceivedRef.current = false
setIsComplete(false)
setInteractionInput("")
setCurrentInteraction(null)
setIsWaitingNextInteraction(false)
setConnectionStatus("connecting")
}
}, [isOpen])
useEffect(() => {
const updateDeviceType = () => {
const width = window.innerWidth
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
const isTabletSize = width >= 768 && width <= 1366
setIsMobile(width < 768)
setIsTablet(isTouchDevice && isTabletSize)
}
updateDeviceType()
const handleResize = () => updateDeviceType()
window.addEventListener("resize", handleResize)
const handleVisibilityChange = () => {
if (!document.hidden && isOpen) {
if (wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
attemptReconnect()
}
}
}
const handleFocus = () => {
if (isOpen && wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
attemptReconnect()
}
}
let wakeLock: any = null
const requestWakeLock = async () => {
if ("wakeLock" in navigator && isOpen) {
try {
wakeLock = await (navigator as any).wakeLock.request("screen")
} catch (err) {
// Wake Lock no soportado o denegado, continuar sin él
}
}
}
requestWakeLock()
document.addEventListener("visibilitychange", handleVisibilityChange)
window.addEventListener("focus", handleFocus)
return () => {
window.removeEventListener("resize", handleResize)
document.removeEventListener("visibilitychange", handleVisibilityChange)
window.removeEventListener("focus", handleFocus)
if (wakeLock) {
wakeLock.release().catch(() => {})
}
}
}, [isOpen, isComplete, attemptReconnect])
const getScriptWebSocketUrl = (sid: string): string => {
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}/ws/script/${sid}`
}
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/script/${sid}`
} else {
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
}
}
const handleInteractionResponse = (value: string) => {
if (!wsRef.current || !currentInteraction) {
return
}
if (value === "cancel" || value === "") {
setCurrentInteraction(null)
setInteractionInput("")
handleCloseModal()
return
}
const response = JSON.stringify({
type: "interaction_response",
id: currentInteraction.id,
value: value,
})
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(response)
}
setCurrentInteraction(null)
setInteractionInput("")
waitingTimeoutRef.current = setTimeout(() => {
setIsWaitingNextInteraction(true)
}, 50)
}
const handleCloseModal = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close()
}
if (checkConnectionInterval.current) {
clearInterval(checkConnectionInterval.current)
}
if (termRef.current) {
termRef.current.dispose()
}
onClose()
}
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault()
e.stopPropagation()
setIsResizing(true)
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
const startY = clientY
const startHeight = modalHeight
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
const deltaY = currentY - startY
const newHeight = Math.max(300, Math.min(window.innerHeight - 50, startHeight + deltaY))
modalHeightRef.current = newHeight
setModalHeight(newHeight)
}
const handleEnd = () => {
const finalHeight = modalHeightRef.current
setIsResizing(false)
document.removeEventListener("mousemove", handleMove as any)
document.removeEventListener("mouseup", handleEnd)
document.removeEventListener("touchmove", handleMove as any)
document.removeEventListener("touchend", handleEnd)
document.removeEventListener("touchcancel", handleEnd)
localStorage.setItem("scriptModalHeight", finalHeight.toString())
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
setTimeout(() => {
fitAddonRef.current?.fit()
wsRef.current?.send(
JSON.stringify({
type: "resize",
cols: termRef.current.cols,
rows: termRef.current.rows,
}),
)
}, 100)
}
}
document.addEventListener("mousemove", handleMove as any)
document.addEventListener("mouseup", handleEnd)
document.addEventListener("touchmove", handleMove as any, { passive: false })
document.addEventListener("touchend", handleEnd)
document.addEventListener("touchcancel", handleEnd)
}
const sendCommand = (command: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(command)
}
}
// Mobile clipboard helpers — see lib/terminal-clipboard.ts.
const handleCopy = async () => {
await copyTerminalSelection(termRef.current)
}
const handlePaste = async () => {
await pasteFromClipboard(sendCommand)
}
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
style={{
height: isMobile ? "80vh" : `${modalHeight}px`,
maxHeight: "none",
}}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
// Radix defaults to focusing the first focusable descendant
// when a Dialog opens — that used to land on the Close button
// and every Enter/Space keystroke intended for the shell
// dialogs INSIDE the terminal would close the modal instead.
// Preempting the auto-focus lets the xterm focus() call in
// initializeTerminal below own the keyboard from the start.
onOpenAutoFocus={(e) => e.preventDefault()}
hideClose
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="flex items-center gap-2 p-4 border-b">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
</div>
<div className="overflow-hidden relative flex-1">
<div ref={terminalContainerRef} className="w-full h-full" />
{isWaitingNextInteraction && !currentInteraction && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-muted-foreground">{t("scriptTerminal.processing")}</p>
</div>
</div>
)}
</div>
{!isMobile && (
<div
ref={resizeBarRef}
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
className={`h-4 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
}`}
style={{ touchAction: "none" }}
>
<GripHorizontal
className={`h-5 w-5 transition-colors pointer-events-none ${
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
}`}
/>
</div>
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1b")
}}
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
>
ESC
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\t")
}}
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
>
TAB
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOA")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOB")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOD")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\x1bOC")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
>
<ArrowRight className="h-4 w-4" />
</Button>
<Button
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
sendCommand("\r")
}}
variant="outline"
size="sm"
className="h-8 px-2.5 text-xs bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400"
>
<CornerDownLeft className="h-4 w-4 mr-1" />
Enter
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px] gap-1"
>
Ctrl
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
<span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">{t("scriptTerminal.paste")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<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-blue-500"
: "bg-red-500"
}`}
title={
connectionStatus === "online"
? t("scriptTerminal.connected")
: connectionStatus === "connecting"
? t("scriptTerminal.connecting")
: t("scriptTerminal.disconnected")
}
></div>
<span className="text-xs text-muted-foreground">
{connectionStatus === "online"
? t("scriptTerminal.online")
: connectionStatus === "connecting"
? t("scriptTerminal.connectingStatus")
: t("scriptTerminal.offline")}
</span>
</div>
<Button
onClick={handleCloseModal}
variant="outline"
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
>
{t("actions.close")}
</Button>
</div>
</DialogContent>
</Dialog>
{currentInteraction && (
<Dialog open={true}>
<DialogContent
className="max-w-4xl max-h-[80vh] overflow-y-auto animate-in fade-in-0 zoom-in-95 duration-100"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
hideClose
>
<DialogTitle>{currentInteraction.title}</DialogTitle>
<div className="space-y-4">
{/*
Render the interaction message as plain text. The message
comes through the WebSocket from a script running as root —
a script bug or compromised author could embed `<script>` or
`<img onerror=...>` and run JS in the admin's browser, leaking
the JWT and any keys held in React state. `whitespace-pre-wrap`
already preserves the `\n` formatting we previously emulated
via `<br/>`, so we don't need any HTML conversion. See audit
Tier 2 #17b.
*/}
<p className="whitespace-pre-wrap break-words">
{currentInteraction.message.replace(/\\n/g, "\n")}
</p>
{currentInteraction.type === "yesno" && (
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse("yes")}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
>
{t("scriptTerminal.yes")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
{t("actions.cancel")}
</Button>
</div>
)}
{currentInteraction.type === "menu" && currentInteraction.options && (
<div className="space-y-2">
{currentInteraction.options.map((option, index) => (
<Button
key={option.value}
onClick={() => handleInteractionResponse(option.value)}
variant="outline"
className="w-full justify-start hover:bg-blue-600 hover:text-white transition-all duration-100 animate-in fade-in-0 slide-in-from-left-2"
style={{ animationDelay: `${index * 30}ms` }}
>
{option.label}
</Button>
))}
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
{t("actions.cancel")}
</Button>
</div>
)}
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
<div className="space-y-2">
<Label>{t("scriptTerminal.yourInput")}</Label>
<Input
value={interactionInput}
onChange={(e) => setInteractionInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleInteractionResponse(interactionInput)
}
}}
placeholder={currentInteraction.default || ""}
className="transition-all duration-150"
/>
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse(interactionInput)}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
{t("scriptTerminal.submit")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
{t("actions.cancel")}
</Button>
</div>
</div>
)}
{currentInteraction.type === "msgbox" && (
<div className="flex gap-2">
<Button
onClick={() => handleInteractionResponse("ok")}
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
>
{t("scriptTerminal.ok")}
</Button>
<Button
onClick={() => handleInteractionResponse("cancel")}
variant="outline"
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
>
{t("actions.cancel")}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)}
</>
)
}