From ee64df23761ef3d153320a48cfdc186f0dfcf329 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 22 Nov 2025 23:34:09 +0100 Subject: [PATCH] Update AppImage --- AppImage/components/proxmox-dashboard.tsx | 35 +- AppImage/components/terminal-panel.tsx | 398 ++++++++++++++++------ 2 files changed, 316 insertions(+), 117 deletions(-) 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 = ({
-
+ ) }