mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
916 lines
35 KiB
TypeScript
916 lines
35 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Activity,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
CornerDownLeft,
|
|
GripHorizontal,
|
|
ChevronDown,
|
|
Search,
|
|
Send,
|
|
Lightbulb,
|
|
Terminal,
|
|
Trash2,
|
|
X,
|
|
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 { DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog"
|
|
import "xterm/css/xterm.css"
|
|
import { API_PORT, fetchApi } from "@/lib/api-config"
|
|
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
|
import { useT } from "@/lib/i18n/provider"
|
|
|
|
interface LxcTerminalModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
vmid: number
|
|
vmName: string
|
|
}
|
|
|
|
interface CheatSheetResult {
|
|
command: string
|
|
description: string
|
|
examples: string[]
|
|
}
|
|
|
|
const LXC_COMMANDS = [
|
|
{ cmd: "ls -la", descKey: "listFiles" },
|
|
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
|
{ cmd: "cat filename", descKey: "displayFile" },
|
|
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
|
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
|
{ cmd: "df -h", descKey: "diskUsage" },
|
|
{ cmd: "du -sh *", descKey: "directorySizes" },
|
|
{ cmd: "free -h", descKey: "memoryUsage" },
|
|
{ cmd: "top", descKey: "runningProcesses" },
|
|
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
|
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
|
{ cmd: "systemctl restart service", descKey: "restartService" },
|
|
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
|
{ cmd: "apt install package", descKey: "installPackage" },
|
|
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
|
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
|
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
|
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
|
{ cmd: "docker ps", descKey: "listContainers" },
|
|
{ cmd: "docker images", descKey: "listImages" },
|
|
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
|
{ cmd: "ping host", descKey: "testConnectivity" },
|
|
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
|
{ cmd: "history", descKey: "commandHistory" },
|
|
{ cmd: "clear", descKey: "clearScreen" },
|
|
] as const
|
|
|
|
type LocalCommand = { cmd: string; desc: 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 t = useT()
|
|
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)
|
|
const isInsideLxcRef = useRef(false)
|
|
const outputBufferRef = useRef<string>("")
|
|
|
|
const [modalHeight, setModalHeight] = useState(500)
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
const resizeBarRef = useRef<HTMLDivElement>(null)
|
|
const modalHeightRef = useRef(500)
|
|
|
|
// Search state
|
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const localCommands = useMemo<LocalCommand[]>(
|
|
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
|
|
[t],
|
|
)
|
|
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
|
const [useOnline, setUseOnline] = useState(true)
|
|
|
|
useEffect(() => {
|
|
setFilteredCommands(localCommands)
|
|
}, [localCommands])
|
|
|
|
// 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")
|
|
isInsideLxcRef.current = false
|
|
outputBufferRef.current = ""
|
|
}
|
|
}, [isOpen])
|
|
|
|
// Initialize terminal
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
|
|
// `cancelled` short-circuits the async init if the modal closes
|
|
// before the dynamic xterm import resolves. Without this, we'd
|
|
// construct a Terminal instance, attach it to a now-stale ref, and
|
|
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
|
|
// con `import("xterm")` sin cancelación.
|
|
let cancelled = false
|
|
|
|
// Small delay to ensure Dialog content is rendered
|
|
const initTimeout = setTimeout(() => {
|
|
if (cancelled || !terminalContainerRef.current) return
|
|
initTerminal()
|
|
}, 100)
|
|
|
|
const initTerminal = async () => {
|
|
const [TerminalClass, FitAddonClass] = await Promise.all([
|
|
import("xterm").then((mod) => mod.Terminal),
|
|
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
|
])
|
|
if (cancelled) 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)
|
|
fitAddon.fit()
|
|
}
|
|
|
|
termRef.current = term
|
|
fitAddonRef.current = fitAddon
|
|
|
|
// Connect WebSocket to host terminal. We append a single-use ticket
|
|
// (`?ticket=...`) which the backend consumes on handshake — see
|
|
// lib/terminal-ws.ts and AppImage/scripts/flask_terminal_routes.py.
|
|
const wsUrl = getWebSocketUrl()
|
|
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
|
wsRef.current = ws
|
|
|
|
// Reset state for new connection
|
|
isInsideLxcRef.current = false
|
|
outputBufferRef.current = ""
|
|
|
|
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 after connection is ready.
|
|
// The string is sent verbatim to the bash PTY, so a non-numeric
|
|
// `vmid` would land as shell input (e.g. `pct enter ; rm -rf /`).
|
|
// The prop is typed `number` but JSON / URL query injections can
|
|
// sneak strings in; validate as a defensive redundancy. Audit
|
|
// residual #lxc-terminal-vmid-injection.
|
|
setTimeout(() => {
|
|
if (ws.readyState !== WebSocket.OPEN) return
|
|
// Coerce + verify: must be a positive integer that round-trips
|
|
// through Number without losing fidelity.
|
|
const id = Number(vmid)
|
|
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
|
|
return
|
|
}
|
|
ws.send(`pct enter ${id}\r`)
|
|
}, 300)
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
setConnectionStatus("offline")
|
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
setConnectionStatus("offline")
|
|
if (pingIntervalRef.current) {
|
|
clearInterval(pingIntervalRef.current)
|
|
}
|
|
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
|
}
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data)
|
|
}
|
|
})
|
|
|
|
ws.onmessage = (event) => {
|
|
// Filter out pong responses
|
|
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
|
return
|
|
}
|
|
|
|
// Helper to strip ANSI escape codes for pattern matching
|
|
const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
|
|
// 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
|
|
|
|
const buffer = outputBufferRef.current
|
|
const cleanBuffer = stripAnsi(buffer)
|
|
|
|
// Look for pct enter command followed by a new prompt
|
|
const pctEnterMatch = cleanBuffer.match(/pct enter (\d+)\r?\n/)
|
|
|
|
if (pctEnterMatch) {
|
|
const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length)
|
|
|
|
// Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd").
|
|
// Charset widened to accept dotted FQDNs (`proxmox.lan`) and unicode
|
|
// letters/numbers (host names like `próxmox` or non-Latin scripts).
|
|
// The previous `[a-zA-Z0-9_-]` truncated the hostname and the
|
|
// "are we inside the LXC?" comparison then misfired.
|
|
const hostPromptMatch = cleanBuffer.match(/@([\p{L}\p{N}._-]+).*pct enter/u)
|
|
const hostName = hostPromptMatch ? hostPromptMatch[1] : null
|
|
|
|
// Look for a new prompt after pct enter that ends with # or $
|
|
// This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#)
|
|
const promptMatch = afterPctEnter.match(/[@\[]([\p{L}\p{N}._-]+)[^\r\n]*[#$]\s*$/u)
|
|
|
|
if (promptMatch) {
|
|
const lxcHostname = promptMatch[1]
|
|
|
|
// If we found a prompt with a DIFFERENT hostname than the Proxmox host,
|
|
// we're inside the LXC container
|
|
if (!hostName || lxcHostname !== hostName) {
|
|
isInsideLxcRef.current = true
|
|
|
|
// Find the original prompt with ANSI codes to display it properly
|
|
const afterPctEnterWithAnsi = buffer.substring(buffer.indexOf('pct enter') + pctEnterMatch[0].length)
|
|
|
|
// Write the LXC prompt (last line with # or $)
|
|
const lastPromptMatch = afterPctEnterWithAnsi.match(/[^\r\n]*[#$]\s*$/)
|
|
if (lastPromptMatch) {
|
|
term.write(lastPromptMatch[0])
|
|
}
|
|
|
|
// Detect if this is Alpine/ash shell by checking prompt format
|
|
// Alpine uses: [root@hostname ~]# or [root@hostname /]#
|
|
// Other distros use: root@hostname:/# or root@hostname:~#
|
|
const isAlpine = afterPctEnter.match(/\[[^\]]+@[^\]]+\s+[^\]]*\][#$]/)
|
|
|
|
if (isAlpine) {
|
|
// Send an extra Enter ONLY for Alpine containers (ash shell)
|
|
// This forces the prompt to refresh properly
|
|
setTimeout(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send('\r')
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Already inside LXC, write directly
|
|
term.write(event.data)
|
|
}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(initTimeout)
|
|
if (pingIntervalRef.current) {
|
|
clearInterval(pingIntervalRef.current)
|
|
}
|
|
if (wsRef.current) {
|
|
wsRef.current.close()
|
|
}
|
|
if (termRef.current) {
|
|
termRef.current.dispose()
|
|
}
|
|
}
|
|
}, [isOpen, vmid, t])
|
|
|
|
// 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 sendEsc = useCallback(() => sendKey("\x1b"), [sendKey])
|
|
const sendTab = useCallback(() => sendKey("\t"), [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 sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
|
|
|
|
// Mobile clipboard helpers — see lib/terminal-clipboard.ts for the rationale.
|
|
const handleCopy = useCallback(async () => {
|
|
await copyTerminalSelection(termRef.current)
|
|
}, [])
|
|
const handlePaste = useCallback(async () => {
|
|
await pasteFromClipboard(sendKey)
|
|
}, [sendKey])
|
|
|
|
// Search effect - debounced search with cheat.sh
|
|
useEffect(() => {
|
|
const searchCheatSh = async (query: string) => {
|
|
if (!query.trim()) {
|
|
setSearchResults([])
|
|
setFilteredCommands(localCommands)
|
|
return
|
|
}
|
|
|
|
try {
|
|
setIsSearching(true)
|
|
const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}`
|
|
const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(10000),
|
|
})
|
|
|
|
if (!data.success || !data.examples || data.examples.length === 0) {
|
|
throw new Error(t("terminal.noExamplesFound"))
|
|
}
|
|
|
|
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
|
command: example.command,
|
|
description: example.description || "",
|
|
examples: [example.command],
|
|
}))
|
|
|
|
setUseOnline(true)
|
|
setSearchResults(formattedResults)
|
|
} catch (error) {
|
|
const filtered = localCommands.filter(
|
|
(item) =>
|
|
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
|
item.desc.toLowerCase().includes(query.toLowerCase()),
|
|
)
|
|
setFilteredCommands(filtered)
|
|
setSearchResults([])
|
|
setUseOnline(false)
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}
|
|
|
|
const debounce = setTimeout(() => {
|
|
if (searchQuery && searchQuery.length >= 2) {
|
|
searchCheatSh(searchQuery)
|
|
} else {
|
|
setSearchResults([])
|
|
setFilteredCommands(localCommands)
|
|
}
|
|
}, 800)
|
|
|
|
return () => clearTimeout(debounce)
|
|
}, [searchQuery, localCommands, t])
|
|
|
|
const handleClear = useCallback(() => {
|
|
if (termRef.current) {
|
|
termRef.current.clear()
|
|
}
|
|
}, [])
|
|
|
|
const sendToTerminal = useCallback((command: string) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(command)
|
|
setTimeout(() => {
|
|
setSearchModalOpen(false)
|
|
}, 100)
|
|
}
|
|
}, [])
|
|
|
|
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` }}
|
|
hideClose
|
|
>
|
|
{/* 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">
|
|
<DialogTitle className="text-sm font-medium text-white">
|
|
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
|
</DialogTitle>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => setSearchModalOpen(true)}
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={connectionStatus !== "online"}
|
|
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
|
</Button>
|
|
<Button
|
|
onClick={handleClear}
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={connectionStatus !== "online"}
|
|
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
|
</Button>
|
|
</div>
|
|
</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 && (
|
|
<div className="px-2 py-2 bg-zinc-900 border-t border-zinc-800">
|
|
<div className="flex items-center justify-center gap-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendEsc}
|
|
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
|
>
|
|
ESC
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendTab}
|
|
className="h-8 px-2 text-xs bg-zinc-800 border-zinc-700 text-zinc-300"
|
|
>
|
|
TAB
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendArrowUp}
|
|
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
|
>
|
|
<ArrowUp className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendArrowDown}
|
|
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
|
>
|
|
<ArrowDown className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendArrowLeft}
|
|
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendArrowRight}
|
|
className="h-8 w-8 p-0 bg-zinc-800 border-zinc-700"
|
|
>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendEnter}
|
|
className="h-8 px-2 text-xs bg-blue-600/20 border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
|
|
>
|
|
<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 border-zinc-700 text-zinc-300 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={() => sendKey("\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={() => sendKey("\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={() => sendKey("\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>
|
|
)}
|
|
|
|
{/* 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">{t(`scriptTerminal.${connectionStatus}`)}</span>
|
|
</div>
|
|
<Button
|
|
onClick={onClose}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("actions.close")}</span>
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
{/* Search Commands Modal */}
|
|
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
|
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
|
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
|
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
|
/>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
|
|
|
<div className="space-y-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
|
<Input
|
|
placeholder={t("terminal.searchPlaceholder")}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
|
autoCapitalize="none"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
|
|
{isSearching && (
|
|
<div className="text-center py-4 text-zinc-400">
|
|
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
|
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-2 pr-2 max-h-[50vh]">
|
|
{searchResults.length > 0 ? (
|
|
<>
|
|
{searchResults.map((result, index) => (
|
|
<div
|
|
key={index}
|
|
className="p-4 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:border-zinc-600 transition-colors"
|
|
>
|
|
{result.description && (
|
|
<p className="text-xs text-zinc-400 mb-2 leading-relaxed"># {result.description}</p>
|
|
)}
|
|
<div
|
|
onClick={() => sendToTerminal(result.command)}
|
|
className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2"
|
|
>
|
|
<code className="text-sm text-blue-400 font-mono break-all flex-1">{result.command}</code>
|
|
<Send className="h-4 w-4 text-zinc-600 group-hover:text-blue-400 flex-shrink-0 mt-0.5 transition-colors" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="text-center py-2">
|
|
<p className="text-xs text-zinc-500">
|
|
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
|
{t("terminal.poweredByCheatSh")}
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : filteredCommands.length > 0 && !useOnline ? (
|
|
filteredCommands.map((item, index) => (
|
|
<div
|
|
key={index}
|
|
onClick={() => sendToTerminal(item.cmd)}
|
|
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
|
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
|
</div>
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
sendToTerminal(item.cmd)
|
|
}}
|
|
size="sm"
|
|
variant="ghost"
|
|
className="shrink-0 h-7 px-2 text-xs"
|
|
>
|
|
<Send className="h-3 w-3 mr-1" />
|
|
{t("terminal.send")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : !isSearching && !searchQuery && !useOnline ? (
|
|
localCommands.map((item, index) => (
|
|
<div
|
|
key={index}
|
|
onClick={() => sendToTerminal(item.cmd)}
|
|
className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<code className="text-sm text-blue-400 font-mono break-all">{item.cmd}</code>
|
|
<p className="text-xs text-zinc-400 mt-1">{item.desc}</p>
|
|
</div>
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
sendToTerminal(item.cmd)
|
|
}}
|
|
size="sm"
|
|
variant="ghost"
|
|
className="shrink-0 h-7 px-2 text-xs"
|
|
>
|
|
<Send className="h-3 w-3 mr-1" />
|
|
{t("terminal.send")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : !isSearching ? (
|
|
<div className="text-center py-12 space-y-4">
|
|
{searchQuery ? (
|
|
<>
|
|
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
|
<div>
|
|
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
|
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
|
<div>
|
|
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
|
<div className="text-sm text-zinc-500 space-y-1">
|
|
<p>{t("terminal.trySearchingFor")}</p>
|
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
|
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
|
<code
|
|
key={cmd}
|
|
onClick={() => setSearchQuery(cmd)}
|
|
className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700"
|
|
>
|
|
{cmd}
|
|
</code>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{useOnline && (
|
|
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
|
<Lightbulb className="w-3 h-3" />
|
|
<span>{t("terminal.poweredByCheatSh")}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
|
<div className="flex items-center gap-2">
|
|
<Lightbulb className="w-3 h-3" />
|
|
<span>{t("terminal.searchTip")}</span>
|
|
</div>
|
|
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
|
|
</div>
|
|
</div>
|
|
</SearchDialogContent>
|
|
</SearchDialog>
|
|
</Dialog>
|
|
)
|
|
}
|