Update AppImage

This commit is contained in:
MacRimi
2025-11-22 23:34:09 +01:00
parent b13f03eb97
commit ee64df2376
2 changed files with 316 additions and 117 deletions

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-b from-zinc-900 to-black text-white flex flex-col">
<div className="min-h-screen bg-background flex flex-col">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
@@ -609,7 +610,7 @@ export function ProxmoxDashboard() {
</div>
</div>
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6 flex-1 flex flex-col min-h-0">
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6 flex-1 flex flex-col">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
@@ -635,7 +636,7 @@ export function ProxmoxDashboard() {
<Hardware key={`hardware-${componentKey}`} />
</TabsContent>
<TabsContent value="terminal" className="h-[calc(100vh-280px)] flex flex-col overflow-hidden mt-0">
<TabsContent value="terminal" className="flex-1 flex flex-col overflow-hidden mt-0 min-h-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
@@ -647,21 +648,23 @@ export function ProxmoxDashboard() {
<Settings />
</TabsContent>
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground flex-shrink-0">
<p className="font-medium mb-2">ProxMenux Monitor v1.0.1</p>
<p>
<a
href="https://ko-fi.com/macrimi"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
>
Support and contribute to the project
</a>
</p>
</footer>
</div>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground flex-shrink-0">
<p className="font-medium mb-2">ProxMenux Monitor v1.0.1</p>
<p>
<a
href="https://ko-fi.com/macrimi"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
>
Support and contribute to the project
</a>
</p>
</footer>
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
</div>
)
}

View File

@@ -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<TerminalPanelProps> = ({
terminals,
onAddTerminal,
onRemoveTerminal,
onClearTerminal,
}) => {
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
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<TerminalPanelProps> = ({
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
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<TerminalPanelProps> = ({
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<TerminalPanelProps> = ({
return () => clearTimeout(debounce)
}, [searchQuery])
const addNewTerminal = () => {
if (terminals.length >= 4) return
const newId = `terminal-${Date.now()}`
// containerRefs.current[newId] = useRef<HTMLDivElement>(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<TerminalPanelProps> = ({
}
}
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<TerminalPanelProps> = ({
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 (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>No terminals open. Click "New" to create one.</p>
</div>
)
}
if (terminals.length === 1 || isMobile) {
// Single terminal mode - fill entire container
return (
<div className="w-full h-full flex flex-col bg-black">
<div className="flex items-center justify-between border-b border-border px-4 py-2 bg-muted/50 flex-shrink-0">
<span className="text-sm font-medium">Terminal {terminals[0].id}</span>
<Button variant="ghost" size="sm" onClick={() => onRemoveTerminal(terminals[0].id)} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<div ref={(el) => (terminalRefs.current[terminals[0].id] = el)} className="flex-1 w-full bg-black" />
</div>
)
}
if (terminals.length === 2) {
// Split view - two terminals side by side
return (
<div className="w-full h-full flex gap-2">
{terminals.map((terminal) => (
<div key={terminal.id} className="flex-1 flex flex-col border border-border rounded bg-black min-w-0">
<div className="flex items-center justify-between border-b border-border px-4 py-2 bg-muted/50 flex-shrink-0">
<span className="text-sm font-medium">Terminal {terminal.id}</span>
<Button variant="ghost" size="sm" onClick={() => onRemoveTerminal(terminal.id)} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<div ref={(el) => (terminalRefs.current[terminal.id] = el)} className="flex-1 w-full bg-black" />
</div>
))}
</div>
)
}
// Grid view for 3+ terminals
return (
<div className="w-full h-full grid grid-cols-2 gap-2">
{terminals.map((terminal) => (
<div key={terminal.id} className="flex flex-col border border-border rounded bg-black min-h-0">
<div className="flex items-center justify-between border-b border-border px-4 py-2 bg-muted/50 flex-shrink-0">
<span className="text-sm font-medium">Terminal {terminal.id}</span>
<Button variant="ghost" size="sm" onClick={() => onRemoveTerminal(terminal.id)} className="h-6 w-6 p-0">
<X className="h-4 w-4" />
</Button>
</div>
<div ref={(el) => (terminalRefs.current[terminal.id] = el)} className="flex-1 w-full bg-black" />
</div>
))}
</div>
)
}
return (
<div className="flex flex-col h-full">
<>
<style jsx>{`
:global(.xterm .xterm-viewport) {
padding: 0 !important;
@@ -387,30 +507,51 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({
`}</style>
<div className="h-full flex flex-col bg-zinc-900 rounded-md overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800 flex-shrink-0">
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b 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 ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"}
></div>
<span className="text-xs text-zinc-500">{terminals.length} terminals</span>
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span>
</div>
<div className="flex gap-2">
{!isMobile && terminals.length > 1 && (
<>
<Button
onClick={() => setLayout("vertical")}
variant="outline"
size="sm"
className={`h-8 px-2 ${layout === "vertical" ? "bg-blue-500/20 border-blue-500" : ""}`}
>
<Split className="h-4 w-4 rotate-90" />
</Button>
<Button
onClick={() => setLayout("horizontal")}
variant="outline"
size="sm"
className={`h-8 px-2 ${layout === "horizontal" ? "bg-blue-500/20 border-blue-500" : ""}`}
>
<Split className="h-4 w-4" />
</Button>
<Button
onClick={() => setLayout("grid")}
variant="outline"
size="sm"
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
>
<Grid2X2 className="h-4 w-4" />
</Button>
</>
)}
<Button
onClick={() =>
onAddTerminal({
id: `terminal-${Date.now()}`,
title: `Terminal ${terminals.length + 1}`,
term: null,
ws: null,
isConnected: false,
})
}
onClick={addNewTerminal}
variant="outline"
size="sm"
className="h-8 gap-2 bg-green-600 hover:bg-green-700 border-green-500 text-white"
disabled={terminals.length >= 4}
className="h-8 gap-2 bg-green-600 hover:bg-green-700 border-green-500 text-white disabled:opacity-50"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New</span>
@@ -426,7 +567,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({
<span className="hidden sm:inline">Search</span>
</Button>
<Button
onClick={() => onClearTerminal(activeTerminalId)}
onClick={handleClear}
variant="outline"
size="sm"
disabled={!activeTerminal?.isConnected}
@@ -447,7 +588,62 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({
</div>
</div>
<div className="h-[calc(100%-60px)] overflow-hidden">{renderTerminals()}</div>
<div className="flex-1 overflow-hidden">
{isMobile ? (
<Tabs value={activeTerminalId} onValueChange={setActiveTerminalId} className="h-full flex flex-col">
<TabsList className="bg-zinc-900 border-b border-zinc-800 rounded-none justify-start overflow-x-auto flex-shrink-0">
{terminals.map((terminal) => (
<TabsTrigger
key={terminal.id}
value={terminal.id}
className="data-[state=active]:bg-blue-500/20 data-[state=active]:border-b-2 data-[state=active]:border-blue-500 rounded-none"
>
{terminal.title}
</TabsTrigger>
))}
</TabsList>
{terminals.map((terminal) => (
<TabsContent
key={terminal.id}
value={terminal.id}
className="flex-1 m-0 data-[state=active]:flex data-[state=inactive]:hidden"
>
<div ref={setContainerRef(terminal.id)} className="w-full h-full bg-black" />
</TabsContent>
))}
</Tabs>
) : (
<div className={`${getLayoutClass()} gap-2 h-full p-2`}>
{terminals.map((terminal) => (
<div
key={terminal.id}
className={`relative bg-black rounded flex flex-col overflow-hidden border ${
terminal.id === activeTerminalId ? "border-blue-500" : "border-zinc-700"
}`}
onClick={() => setActiveTerminalId(terminal.id)}
>
<div className="flex items-center justify-between px-2 py-1 bg-zinc-800 border-b border-zinc-700">
<span className="text-xs font-medium text-white">{terminal.title}</span>
{terminals.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={(e) => {
e.stopPropagation()
closeTerminal(terminal.id)
}}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div ref={setContainerRef(terminal.id)} className="flex-1 w-full overflow-hidden bg-black" />
</div>
))}
</div>
)}
</div>
{isMobile && (
<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">
@@ -654,6 +850,6 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({
</DialogContent>
</Dialog>
</div>
</div>
</>
)
}