mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-12-14 08:06:22 +00:00
1058 lines
37 KiB
TypeScript
1058 lines
37 KiB
TypeScript
"use client"
|
||
|
||
import type React from "react"
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { API_PORT } from "../lib/api-config"
|
||
import { fetchApi } from "@/lib/api-config" // Cambiando import para usar fetchApi directamente
|
||
import { X, Search, Send, Lightbulb, Terminal, Plus, GripHorizontal } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
||
|
||
type TerminalPanelProps = {
|
||
websocketUrl?: string
|
||
onClose?: () => void
|
||
initMessage?: Record<string, any>
|
||
onWebInteraction?: (interaction: any) => void
|
||
onWebSocketCreated?: (ws: WebSocket) => void
|
||
onTerminalOutput?: () => void
|
||
isScriptModal?: boolean
|
||
}
|
||
|
||
interface TerminalInstance {
|
||
id: string
|
||
title: string
|
||
term: any
|
||
ws: WebSocket | null
|
||
isConnected: boolean
|
||
fitAddon: any // Added fitAddon to TerminalInstance
|
||
}
|
||
|
||
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`
|
||
}
|
||
}
|
||
|
||
function getApiUrl(endpoint?: string): string {
|
||
if (typeof window === "undefined") {
|
||
return "http://localhost:8008"
|
||
}
|
||
|
||
const { protocol, hostname } = window.location
|
||
const apiProtocol = protocol === "https:" ? "https:" : "http:"
|
||
return `${apiProtocol}//${hostname}:${API_PORT}${endpoint || ""}`
|
||
}
|
||
|
||
const proxmoxCommands = [
|
||
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
|
||
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
|
||
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
|
||
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
|
||
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
|
||
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
|
||
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
|
||
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
|
||
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
|
||
{ cmd: "qm status <vmid>", desc: "Show VM status" },
|
||
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
|
||
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
|
||
{ cmd: "pct list", desc: "List all LXC containers" },
|
||
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
|
||
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
|
||
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
|
||
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
|
||
{ cmd: "pvesm status", desc: "Show storage status" },
|
||
{ cmd: "pvesm list <storage>", desc: "List storage content" },
|
||
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
|
||
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
|
||
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
|
||
{ cmd: "pvecm status", desc: "Show cluster status" },
|
||
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
|
||
{ cmd: "zpool status", desc: "Show ZFS pool status" },
|
||
{ cmd: "zpool list", desc: "List all ZFS pools" },
|
||
{ cmd: "zfs list", desc: "List all ZFS datasets" },
|
||
{ cmd: "ls -la", desc: "List all files with details" },
|
||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||
{ cmd: "mkdir dirname", desc: "Create new directory" },
|
||
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
|
||
{ cmd: "cp source dest", desc: "Copy files or directories" },
|
||
{ cmd: "mv source dest", desc: "Move or rename files" },
|
||
{ cmd: "cat filename", desc: "Display file contents" },
|
||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
||
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
|
||
{ cmd: "df -h", desc: "Show disk usage" },
|
||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
||
{ cmd: "free -h", desc: "Show memory usage" },
|
||
{ cmd: "top", desc: "Show running processes" },
|
||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
||
{ cmd: "kill -9 PID", desc: "Force kill process" },
|
||
{ cmd: "systemctl status service", desc: "Check service status" },
|
||
{ cmd: "systemctl start service", desc: "Start a service" },
|
||
{ cmd: "systemctl stop service", desc: "Stop a service" },
|
||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
|
||
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
|
||
{ cmd: "apt remove package", desc: "Remove package" },
|
||
{ cmd: "docker ps", desc: "List running containers" },
|
||
{ cmd: "docker images", desc: "List Docker images" },
|
||
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
|
||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||
{ cmd: "wget url", desc: "Download file from URL" },
|
||
{ cmd: "ssh user@host", desc: "Connect via SSH" },
|
||
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
|
||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
|
||
{ cmd: "history", desc: "Show command history" },
|
||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||
]
|
||
|
||
export function TerminalPanel({
|
||
websocketUrl,
|
||
onClose,
|
||
initMessage,
|
||
onWebInteraction,
|
||
onWebSocketCreated,
|
||
onTerminalOutput,
|
||
isScriptModal = false,
|
||
}: TerminalPanelProps) {
|
||
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
||
const [isMobile, setIsMobile] = useState(false)
|
||
const [isTablet, setIsTablet] = useState(false)
|
||
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||
const [searchQuery, setSearchQuery] = useState("")
|
||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||
const [useOnline, setUseOnline] = useState(true)
|
||
|
||
const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
|
||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
const updateDeviceType = () => {
|
||
const width = window.innerWidth
|
||
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
|
||
const isTabletSize = width >= 768 && width <= 1366 // iPads Pro pueden llegar a 1366px
|
||
|
||
setIsMobile(width < 768)
|
||
setIsTablet(isTouchDevice && isTabletSize)
|
||
}
|
||
|
||
updateDeviceType()
|
||
const handleResize = () => updateDeviceType()
|
||
window.addEventListener("resize", handleResize)
|
||
|
||
const savedHeight = localStorage.getItem("terminalHeight")
|
||
if (savedHeight) {
|
||
setTerminalHeight(Number.parseInt(savedHeight, 10))
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleResize)
|
||
}
|
||
}, [])
|
||
|
||
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||
// Bloquear solo en pantallas muy pequeñas (móviles)
|
||
if (window.innerWidth < 640 && !isTablet) {
|
||
return
|
||
}
|
||
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
|
||
// Detectar si es touch o mouse
|
||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
|
||
const startY = clientY
|
||
const startHeight = terminalHeight
|
||
|
||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
|
||
const deltaY = currentY - startY
|
||
const newHeight = Math.max(200, Math.min(2400, startHeight + deltaY))
|
||
|
||
setTerminalHeight(newHeight)
|
||
}
|
||
|
||
const handleEnd = () => {
|
||
document.removeEventListener("mousemove", handleMove as any)
|
||
document.removeEventListener("mouseup", handleEnd)
|
||
document.removeEventListener("touchmove", handleMove as any)
|
||
document.removeEventListener("touchend", handleEnd)
|
||
|
||
localStorage.setItem("terminalHeight", terminalHeight.toString())
|
||
}
|
||
|
||
document.addEventListener("mousemove", handleMove as any)
|
||
document.addEventListener("mouseup", handleEnd)
|
||
document.addEventListener("touchmove", handleMove as any, { passive: false })
|
||
document.addEventListener("touchend", handleEnd)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (terminals.length === 0) {
|
||
addNewTerminal()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const searchCheatSh = async (query: string) => {
|
||
if (!query.trim()) {
|
||
setSearchResults([])
|
||
setFilteredCommands(proxmoxCommands)
|
||
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("No examples found")
|
||
}
|
||
|
||
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 = proxmoxCommands.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(proxmoxCommands)
|
||
}
|
||
}, 800)
|
||
|
||
return () => clearTimeout(debounce)
|
||
}, [searchQuery])
|
||
|
||
const addNewTerminal = () => {
|
||
if (terminals.length >= 4) return
|
||
|
||
const newId = `terminal-${Date.now()}`
|
||
setTerminals((prev) => [
|
||
...prev,
|
||
{
|
||
id: newId,
|
||
title: `Terminal ${prev.length + 1}`,
|
||
term: null,
|
||
ws: null,
|
||
isConnected: false,
|
||
fitAddon: null, // Added fitAddon initialization
|
||
},
|
||
])
|
||
setActiveTerminalId(newId)
|
||
}
|
||
|
||
const closeTerminal = (id: string) => {
|
||
const terminal = terminals.find((t) => t.id === id)
|
||
if (terminal) {
|
||
if (terminal.ws) {
|
||
terminal.ws.close()
|
||
}
|
||
if (terminal.term) {
|
||
terminal.term.dispose()
|
||
}
|
||
}
|
||
|
||
setTerminals((prev) => {
|
||
const filtered = prev.filter((t) => t.id !== id)
|
||
if (filtered.length > 0 && activeTerminalId === id) {
|
||
setActiveTerminalId(filtered[0].id)
|
||
}
|
||
return filtered
|
||
})
|
||
|
||
delete containerRefs.current[id]
|
||
}
|
||
|
||
const handleCloseTab = (e: React.MouseEvent, id: string) => {
|
||
e.stopPropagation()
|
||
closeTerminal(id)
|
||
}
|
||
|
||
useEffect(() => {
|
||
terminals.forEach((terminal) => {
|
||
const container = containerRefs.current[terminal.id]
|
||
if (!terminal.term && container) {
|
||
initializeTerminal(terminal, container)
|
||
}
|
||
})
|
||
}, [terminals, isMobile])
|
||
|
||
useEffect(() => {
|
||
if (window.innerWidth < 640) return
|
||
|
||
terminals.forEach((terminal) => {
|
||
if (terminal.term && terminal.fitAddon && terminal.isConnected) {
|
||
try {
|
||
setTimeout(() => {
|
||
terminal.fitAddon?.fit()
|
||
if (terminal.ws?.readyState === WebSocket.OPEN) {
|
||
const cols = terminal.term?.cols || 80
|
||
const rows = terminal.term?.rows || 24
|
||
terminal.ws.send(
|
||
JSON.stringify({
|
||
type: "resize",
|
||
cols,
|
||
rows,
|
||
}),
|
||
)
|
||
}
|
||
}, 100)
|
||
} catch (err) {
|
||
console.warn("[Terminal] resize on height change failed:", err)
|
||
}
|
||
}
|
||
})
|
||
}, [terminalHeight, layout, terminals, isMobile])
|
||
|
||
useEffect(() => {
|
||
if (!isScriptModal) return
|
||
|
||
const mainContainer = containerRefs.current["main"]
|
||
if (!mainContainer) return
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
terminals.forEach((terminal) => {
|
||
if (terminal.term && terminal.fitAddon && terminal.isConnected) {
|
||
try {
|
||
setTimeout(() => {
|
||
terminal.fitAddon?.fit()
|
||
if (terminal.ws?.readyState === WebSocket.OPEN) {
|
||
const cols = terminal.term?.cols || 80
|
||
const rows = terminal.term?.rows || 24
|
||
terminal.ws.send(
|
||
JSON.stringify({
|
||
type: "resize",
|
||
cols,
|
||
rows,
|
||
}),
|
||
)
|
||
}
|
||
}, 50)
|
||
} catch (err) {
|
||
// Silently handle resize errors
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
resizeObserver.observe(mainContainer)
|
||
|
||
return () => {
|
||
resizeObserver.disconnect()
|
||
}
|
||
}, [terminals, isScriptModal])
|
||
|
||
const initializeTerminal = async (terminal: TerminalInstance, container: HTMLDivElement) => {
|
||
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"),
|
||
]).then(([Terminal, FitAddon]) => [Terminal, 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)
|
||
|
||
term.open(container)
|
||
|
||
fitAddon.fit()
|
||
|
||
const wsUrl = websocketUrl || getWebSocketUrl()
|
||
const ws = new WebSocket(wsUrl)
|
||
|
||
const syncSizeWithBackend = () => {
|
||
try {
|
||
fitAddon.fit()
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
const cols = term.cols
|
||
const rows = term.rows
|
||
ws.send(
|
||
JSON.stringify({
|
||
type: "resize",
|
||
cols,
|
||
rows,
|
||
}),
|
||
)
|
||
}
|
||
} catch (err) {
|
||
console.warn("[Terminal] resize failed:", err)
|
||
}
|
||
}
|
||
|
||
ws.onopen = () => {
|
||
setTerminals((prev) =>
|
||
prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon } : t)),
|
||
)
|
||
|
||
if (onWebSocketCreated) {
|
||
onWebSocketCreated(ws)
|
||
}
|
||
|
||
if (initMessage) {
|
||
ws.send(JSON.stringify(initMessage))
|
||
} else {
|
||
term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m")
|
||
}
|
||
|
||
syncSizeWithBackend()
|
||
}
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data)
|
||
if (data.type === "web_interaction" && data.interaction && onWebInteraction) {
|
||
onWebInteraction(data.interaction)
|
||
return // Don't write to terminal
|
||
}
|
||
} catch (e) {
|
||
// Not JSON, it's regular terminal output
|
||
}
|
||
|
||
if (onTerminalOutput) {
|
||
onTerminalOutput()
|
||
}
|
||
|
||
term.write(event.data)
|
||
}
|
||
|
||
ws.onerror = () => {
|
||
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
|
||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||
}
|
||
|
||
ws.onclose = () => {
|
||
setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t)))
|
||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||
}
|
||
|
||
term.onData((data) => {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(data)
|
||
}
|
||
})
|
||
|
||
let resizeTimeout: any = null
|
||
|
||
const handleResize = () => {
|
||
clearTimeout(resizeTimeout)
|
||
resizeTimeout = setTimeout(() => {
|
||
syncSizeWithBackend()
|
||
}, 150)
|
||
}
|
||
|
||
window.addEventListener("resize", handleResize)
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleResize)
|
||
ws.close()
|
||
term.dispose()
|
||
}
|
||
}
|
||
|
||
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
|
||
// Prevenir comportamientos por defecto del navegador
|
||
if (e) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}
|
||
|
||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||
if (!activeTerminal || !activeTerminal.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return
|
||
|
||
let seq = ""
|
||
switch (key) {
|
||
case "UP":
|
||
seq = "\x1bOA"
|
||
break
|
||
case "DOWN":
|
||
seq = "\x1bOB"
|
||
break
|
||
case "RIGHT":
|
||
seq = "\x1bOC"
|
||
break
|
||
case "LEFT":
|
||
seq = "\x1bOD"
|
||
break
|
||
case "ESC":
|
||
seq = "\x1b"
|
||
break
|
||
case "TAB":
|
||
seq = "\t"
|
||
break
|
||
case "CTRL_C":
|
||
seq = "\x03"
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
|
||
activeTerminal.ws.send(seq)
|
||
}
|
||
|
||
const handleClear = () => {
|
||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||
if (activeTerminal?.term) {
|
||
activeTerminal.term.clear()
|
||
}
|
||
}
|
||
|
||
const handleClose = () => {
|
||
terminals.forEach((terminal) => {
|
||
if (terminal.ws) terminal.ws.close()
|
||
if (terminal.term) terminal.term.dispose()
|
||
})
|
||
onClose?.()
|
||
}
|
||
|
||
const sendToActiveTerminal = (command: string) => {
|
||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||
|
||
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
|
||
activeTerminal.ws.send(command)
|
||
|
||
setTimeout(() => {
|
||
setSearchModalOpen(false)
|
||
}, 100)
|
||
}
|
||
}
|
||
|
||
const sendSequence = (seq: string, e?: React.MouseEvent | React.TouchEvent) => {
|
||
if (e) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
}
|
||
|
||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
|
||
activeTerminal.ws.send(seq)
|
||
}
|
||
}
|
||
|
||
const getLayoutClass = () => {
|
||
const count = terminals.length
|
||
if (isMobile || count === 1) return "grid grid-cols-1"
|
||
|
||
// Vista de cuadrícula 2x2
|
||
if (layout === "grid") {
|
||
if (count === 2) return "grid grid-cols-2"
|
||
if (count === 3) return "grid grid-cols-2 grid-rows-2"
|
||
if (count === 4) return "grid grid-cols-2 grid-rows-2"
|
||
}
|
||
|
||
if (count === 2) return "grid grid-cols-1 grid-rows-2"
|
||
if (count === 3) return "grid grid-cols-1 grid-rows-3"
|
||
if (count === 4) return "grid grid-cols-1 grid-rows-4"
|
||
|
||
// Vista de filas apiladas (single) - una terminal debajo de otra
|
||
return "grid grid-cols-1"
|
||
}
|
||
|
||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||
|
||
return (
|
||
<div
|
||
ref={panelRef}
|
||
className="flex flex-col bg-background"
|
||
style={isScriptModal ? { height: "100%" } : { height: `${terminalHeight}px` }}
|
||
>
|
||
{!isScriptModal && (
|
||
<div className="border-b border-border flex-none relative">
|
||
<div className="flex items-center justify-between px-2 pt-1">
|
||
<div className="flex gap-1 overflow-x-auto scrollbar-hide">
|
||
{terminals.map((terminal) => (
|
||
<button
|
||
key={terminal.id}
|
||
onClick={() => setActiveTerminalId(terminal.id)}
|
||
className={`px-3 py-1 text-sm rounded-t-md transition-colors whitespace-nowrap ${
|
||
terminal.id === activeTerminalId
|
||
? "bg-background text-foreground font-medium"
|
||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||
}`}
|
||
>
|
||
{terminal.title}
|
||
{terminals.length > 1 && (
|
||
<span onClick={(e) => handleCloseTab(e, terminal.id)} className="ml-2 hover:text-destructive">
|
||
×
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={addNewTerminal}
|
||
className="text-muted-foreground hover:text-foreground transition-colors p-1"
|
||
title="New Terminal"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Terminal Tabs */}
|
||
{!isScriptModal && (
|
||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar">
|
||
{terminals.map((terminal) => (
|
||
<button
|
||
key={terminal.id}
|
||
onClick={() => setActiveTerminalId(terminal.id)}
|
||
className={`px-3 py-1 text-xs rounded-t transition-colors ${
|
||
terminal.id === activeTerminalId ? "bg-zinc-800 text-white" : "text-zinc-500 hover:text-white"
|
||
}`}
|
||
>
|
||
{terminal.title}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{isScriptModal && (
|
||
<div className="sr-only" data-connection-status={activeTerminal?.isConnected ? "connected" : "disconnected"}>
|
||
Connection Status
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
data-terminal-container
|
||
ref={(el) => {
|
||
containerRefs.current["main"] = el
|
||
}}
|
||
className={`overflow-hidden flex flex-col ${isMobile ? "flex-1 h-[60vh]" : "overflow-hidden"} w-full max-w-full`}
|
||
style={
|
||
isScriptModal
|
||
? { height: "100%", flexShrink: 0 }
|
||
: !isMobile || isTablet
|
||
? { height: `${terminalHeight}px`, flexShrink: 0 }
|
||
: undefined
|
||
}
|
||
>
|
||
{isMobile ? (
|
||
<Tabs value={activeTerminalId} onValueChange={setActiveTerminalId} className="h-full flex flex-col">
|
||
<TabsList className="w-full justify-start bg-zinc-900 rounded-none border-b border-zinc-800 overflow-x-auto">
|
||
{terminals.map((terminal) => (
|
||
<TabsTrigger key={terminal.id} value={terminal.id} className="relative">
|
||
{terminal.title}
|
||
{terminals.length > 1 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
closeTerminal(terminal.id)
|
||
}}
|
||
className="ml-2 hover:bg-zinc-700 rounded p-0.5"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
{terminals.map((terminal) => (
|
||
<TabsContent
|
||
key={terminal.id}
|
||
value={terminal.id}
|
||
forceMount
|
||
className={`flex-1 h-full mt-0 ${activeTerminalId === terminal.id ? "block" : "hidden"}`}
|
||
>
|
||
<div
|
||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||
className="w-full h-full flex-1 bg-black overflow-hidden"
|
||
/>
|
||
</TabsContent>
|
||
))}
|
||
</Tabs>
|
||
) : (
|
||
<div className={`${getLayoutClass()} h-full gap-0.5 bg-zinc-800 p-0.5 w-full overflow-hidden`}>
|
||
{terminals.map((terminal) => (
|
||
<div
|
||
key={terminal.id}
|
||
className={`relative bg-zinc-900 overflow-hidden flex flex-col min-h-0 w-full ${
|
||
terminals.length > 1 && activeTerminalId === terminal.id ? "ring-2 ring-blue-500" : ""
|
||
}`}
|
||
>
|
||
<div className="flex-shrink-0 flex items-center justify-between px-2 py-1 bg-zinc-900/95 border-b border-zinc-800">
|
||
<button
|
||
onClick={() => setActiveTerminalId(terminal.id)}
|
||
className={`text-xs font-medium ${
|
||
activeTerminalId === terminal.id ? "text-blue-400" : "text-zinc-500"
|
||
} ${isScriptModal ? "hidden" : ""}`}
|
||
>
|
||
{terminal.title}
|
||
</button>
|
||
{terminals.length > 1 && (
|
||
<button onClick={() => closeTerminal(terminal.id)} className="hover:bg-zinc-700 rounded p-0.5">
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div
|
||
ref={(el) => (containerRefs.current[terminal.id] = el)}
|
||
onClick={() => setActiveTerminalId(terminal.id)}
|
||
className="flex-1 w-full max-w-full bg-black overflow-hidden cursor-pointer"
|
||
data-terminal-container
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{!isScriptModal && (isTablet || (!isMobile && !isTablet)) && terminals.length > 0 && (
|
||
<div
|
||
onMouseDown={handleResizeStart}
|
||
onTouchStart={handleResizeStart}
|
||
className="h-2 w-full cursor-row-resize bg-zinc-800 hover:bg-blue-600 transition-colors flex items-center justify-center group relative"
|
||
style={{ touchAction: "none" }}
|
||
>
|
||
<GripHorizontal className="h-4 w-4 text-zinc-600 group-hover:text-white pointer-events-none" />
|
||
</div>
|
||
)}
|
||
|
||
{(isMobile || isTablet) && (
|
||
<div className="flex flex-wrap gap-2 justify-center items-center px-2 bg-zinc-900 text-sm rounded-b-md border-t border-zinc-700 py-1.5">
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
sendSequence("\x1b", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
ESC
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
sendSequence("\t", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
TAB
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleKeyButton("UP", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
↑
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleKeyButton("DOWN", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
↓
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleKeyButton("LEFT", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
←
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
handleKeyButton("RIGHT", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
→
|
||
</Button>
|
||
<Button
|
||
onPointerDown={(e) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
sendSequence("\x03", e)
|
||
}}
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 px-3 text-xs"
|
||
>
|
||
CTRL+C
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||
<DialogContent 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">
|
||
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
||
/>
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</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="Search commands... (e.g., tar, docker, qm, systemctl)"
|
||
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">Searching cheat.sh...</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={() => sendToActiveTerminal(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" />
|
||
Powered by cheat.sh
|
||
</p>
|
||
</div>
|
||
</>
|
||
) : filteredCommands.length > 0 && !useOnline ? (
|
||
filteredCommands.map((item, index) => (
|
||
<div
|
||
key={index}
|
||
onClick={() => sendToActiveTerminal(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()
|
||
sendToActiveTerminal(item.cmd)
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 h-7 px-2 text-xs"
|
||
>
|
||
<Send className="h-3 w-3 mr-1" />
|
||
Send
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : !isSearching && !searchQuery && !useOnline ? (
|
||
proxmoxCommands.map((item, index) => (
|
||
<div
|
||
key={index}
|
||
onClick={() => sendToActiveTerminal(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()
|
||
sendToActiveTerminal(item.cmd)
|
||
}}
|
||
size="sm"
|
||
variant="ghost"
|
||
className="shrink-0 h-7 px-2 text-xs"
|
||
>
|
||
<Send className="h-3 w-3 mr-1" />
|
||
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">No results found for "{searchQuery}"</p>
|
||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||
<div>
|
||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
||
<div className="text-sm text-zinc-500 space-y-1">
|
||
<p>Try searching for:</p>
|
||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||
{["tar", "grep", "docker", "qm", "systemctl"].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>Powered by cheat.sh</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>Tip: Search for any Linux command or Proxmox commands (qm, pct, zpool)</span>
|
||
</div>
|
||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|