diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx
index 82ff978..8ce19ea 100644
--- a/AppImage/components/proxmox-dashboard.tsx
+++ b/AppImage/components/proxmox-dashboard.tsx
@@ -12,6 +12,7 @@ import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
import { OnboardingCarousel } from "./onboarding-carousel"
+import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
import { getApiUrl, fetchApi } from "../lib/api-config"
import { TerminalPanel } from "./terminal-panel"
@@ -272,7 +273,7 @@ export function ProxmoxDashboard() {
}
return (
-
+
setShowReleaseNotes(false)} />
@@ -609,7 +610,7 @@ export function ProxmoxDashboard() {
-
+
-
+
@@ -647,21 +648,23 @@ export function ProxmoxDashboard() {
+
+
-
+
)
}
diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx
index 8485ece..3717260 100644
--- a/AppImage/components/terminal-panel.tsx
+++ b/AppImage/components/terminal-panel.tsx
@@ -3,17 +3,16 @@
import type React from "react"
import { useEffect, useRef, useState, useCallback } from "react"
import { API_PORT } from "@/lib/api-config"
-import { Activity, Trash2, X, Search, Send, Lightbulb, Terminal, Plus } from "lucide-react"
+import { Activity, Trash2, X, Search, Send, Lightbulb, Terminal, Plus, Split, Grid2X2 } 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, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
type TerminalPanelProps = {
- terminals: TerminalInstance[]
- onAddTerminal: (terminal: TerminalInstance) => void
- onRemoveTerminal: (id: string) => void
- onClearTerminal: (id: string) => void
+ websocketUrl?: string
+ onClose?: () => void
}
interface TerminalInstance {
@@ -120,13 +119,10 @@ const proxmoxCommands = [
{ cmd: "clear", desc: "Clear terminal screen" },
]
-export const TerminalPanel: React.FC = ({
- terminals,
- onAddTerminal,
- onRemoveTerminal,
- onClearTerminal,
-}) => {
+export const TerminalPanel: React.FC = ({ websocketUrl, onClose }) => {
+ const [terminals, setTerminals] = useState([])
const [activeTerminalId, setActiveTerminalId] = useState("")
+ const [layout, setLayout] = useState<"single" | "vertical" | "horizontal" | "grid">("single")
const [isMobile, setIsMobile] = useState(false)
const [searchModalOpen, setSearchModalOpen] = useState(false)
@@ -137,11 +133,11 @@ export const TerminalPanel: React.FC = ({
const [searchResults, setSearchResults] = useState([])
const [useOnline, setUseOnline] = useState(true)
- const terminalRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
+ const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
- const setTerminalRef = useCallback(
+ const setContainerRef = useCallback(
(id: string) => (el: HTMLDivElement | null) => {
- terminalRefs.current[id] = el
+ containerRefs.current[id] = el
},
[],
)
@@ -155,13 +151,7 @@ export const TerminalPanel: React.FC = ({
useEffect(() => {
if (terminals.length === 0) {
- onAddTerminal({
- id: `terminal-${Date.now()}`,
- title: `Terminal 1`,
- term: null,
- ws: null,
- isConnected: false,
- })
+ addNewTerminal()
}
}, [])
@@ -234,6 +224,189 @@ export const TerminalPanel: React.FC = ({
return () => clearTimeout(debounce)
}, [searchQuery])
+ const addNewTerminal = () => {
+ if (terminals.length >= 4) return
+
+ const newId = `terminal-${Date.now()}`
+ // containerRefs.current[newId] = useRef(null) // No longer needed
+
+ setTerminals((prev) => [
+ ...prev,
+ {
+ id: newId,
+ title: `Terminal ${prev.length + 1}`,
+ term: null,
+ ws: null,
+ isConnected: false,
+ // containerRef: containerRefs.current[newId], // No longer needed
+ },
+ ])
+ 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] // Clean up the ref
+ }
+
+ useEffect(() => {
+ terminals.forEach((terminal) => {
+ const container = containerRefs.current[terminal.id]
+ if (!terminal.term && container) {
+ initializeTerminal(terminal, container)
+ }
+ })
+ }, [terminals, isMobile])
+
+ const initializeTerminal = async (terminal: TerminalInstance, container: HTMLDivElement) => {
+ const [Terminal, FitAddon] = 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 term = new Terminal({
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace",
+ fontSize: isMobile ? 11 : 13,
+ cursorBlink: true,
+ scrollback: 2000,
+ disableStdin: false,
+ 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 FitAddon()
+ term.loadAddon(fitAddon)
+
+ term.open(container)
+
+ const performResize = () => {
+ // Ensure xterm viewport has no extra padding
+ const xtermViewport = container.querySelector(".xterm-viewport") as HTMLElement
+ const xtermScreen = container.querySelector(".xterm-screen") as HTMLElement
+ if (xtermViewport) xtermViewport.style.padding = "0"
+ if (xtermScreen) xtermScreen.style.padding = "0"
+
+ // Get actual container dimensions
+ const containerRect = container.getBoundingClientRect()
+ console.log(`[v0] Container dimensions: ${containerRect.width}x${containerRect.height}`)
+
+ // Only resize if container has valid dimensions
+ if (containerRect.width > 0 && containerRect.height > 0) {
+ fitAddon.fit()
+ const cols = term.cols
+ const rows = term.rows
+ console.log(`[v0] Terminal fitted to: ${cols}x${rows}`)
+
+ // Send resize to backend via HTTP
+ const apiUrl = getApiUrl()
+ fetch(`${apiUrl}/api/terminal/${terminal.id}/resize`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cols, rows }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ console.log(`[v0] Backend PTY resized to: ${data.cols}x${data.rows}`)
+ })
+ .catch((err) => {
+ console.error(`[v0] Error resizing backend PTY:`, err)
+ })
+ } else {
+ console.log(`[v0] Container not ready yet, dimensions: ${containerRect.width}x${containerRect.height}`)
+ }
+ }
+
+ setTimeout(() => performResize(), 150)
+ setTimeout(() => performResize(), 400)
+ setTimeout(() => performResize(), 800)
+
+ const wsUrl = websocketUrl || getWebSocketUrl()
+ const ws = new WebSocket(wsUrl)
+
+ ws.onopen = () => {
+ setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws } : t)))
+ term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m")
+
+ setTimeout(() => performResize(), 250)
+ setTimeout(() => performResize(), 600)
+ }
+
+ ws.onmessage = (event) => {
+ term.write(event.data)
+ }
+
+ ws.onerror = (error) => {
+ console.error("[v0] TerminalPanel: WebSocket error:", error)
+ 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)
+ }
+ })
+
+ const handleResize = () => {
+ try {
+ performResize()
+ } catch {
+ // Ignore resize errors
+ }
+ }
+
+ window.addEventListener("resize", handleResize)
+
+ return () => {
+ window.removeEventListener("resize", handleResize)
+ ws.close()
+ term.dispose()
+ }
+ }
+
const handleKeyButton = (key: string) => {
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (!activeTerminal || !activeTerminal.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return
@@ -270,12 +443,19 @@ export const TerminalPanel: React.FC = ({
}
}
+ 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()
})
- onRemoveTerminal(activeTerminalId)
+ onClose?.()
}
const sendToActiveTerminal = (command: string) => {
@@ -307,76 +487,16 @@ export const TerminalPanel: React.FC = ({
const getLayoutClass = () => {
const count = terminals.length
if (isMobile || count === 1) return "grid grid-cols-1"
- if (count === 2) return "grid grid-cols-2"
- if (count >= 3) return "grid grid-cols-2 grid-rows-2"
+ if (layout === "vertical" || count === 2) return "grid grid-cols-2"
+ if (layout === "horizontal") return "grid grid-rows-2"
+ if (layout === "grid" || count >= 3) return "grid grid-cols-2 grid-rows-2"
return "grid grid-cols-1"
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
- const renderTerminals = () => {
- if (terminals.length === 0) {
- return (
-
-
No terminals open. Click "New" to create one.
-
- )
- }
-
- if (terminals.length === 1 || isMobile) {
- // Single terminal mode - fill entire container
- return (
-
-
- Terminal {terminals[0].id}
-
-
-
(terminalRefs.current[terminals[0].id] = el)} className="flex-1 w-full bg-black" />
-
- )
- }
-
- if (terminals.length === 2) {
- // Split view - two terminals side by side
- return (
-
- {terminals.map((terminal) => (
-
-
- Terminal {terminal.id}
-
-
-
(terminalRefs.current[terminal.id] = el)} className="flex-1 w-full bg-black" />
-
- ))}
-
- )
- }
-
- // Grid view for 3+ terminals
- return (
-
- {terminals.map((terminal) => (
-
-
- Terminal {terminal.id}
-
-
-
(terminalRefs.current[terminal.id] = el)} className="flex-1 w-full bg-black" />
-
- ))}
-
- )
- }
-
return (
-
+ <>
-
+
-
{terminals.length} terminals
+
{terminals.length} / 4 terminals
+ {!isMobile && terminals.length > 1 && (
+ <>
+
+
+
+ >
+ )}
-
{renderTerminals()}
+
+ {isMobile ? (
+
+
+ {terminals.map((terminal) => (
+
+ {terminal.title}
+
+ ))}
+
+ {terminals.map((terminal) => (
+
+
+
+ ))}
+
+ ) : (
+
+ {terminals.map((terminal) => (
+
setActiveTerminalId(terminal.id)}
+ >
+
+ {terminal.title}
+ {terminals.length > 1 && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
{isMobile && (
@@ -654,6 +850,6 @@ export const TerminalPanel: React.FC = ({
-
+ >
)
}