Update AppImage

This commit is contained in:
MacRimi
2025-11-22 23:59:55 +01:00
parent d154cab054
commit 1c51107f1e
3 changed files with 442 additions and 459 deletions

View File

@@ -273,7 +273,7 @@ export function ProxmoxDashboard() {
}
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="min-h-screen bg-background">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
@@ -610,8 +610,8 @@ 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">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0">
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
<SystemOverview key={`overview-${componentKey}`} />
</TabsContent>
@@ -632,11 +632,7 @@ export function ProxmoxDashboard() {
<Hardware key={`hardware-${componentKey}`} />
</TabsContent>
<TabsContent
value="terminal"
className="flex flex-col overflow-hidden mt-0 w-full"
style={{ height: "calc(100vh - 300px)" }}
>
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
@@ -648,21 +644,21 @@ export function ProxmoxDashboard() {
<Settings />
</TabsContent>
</Tabs>
</div>
<footer className="container mx-auto px-4 md:px-6 py-4 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>
<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">
<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>
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
</div>

View File

@@ -288,6 +288,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
cursorBlink: true,
scrollback: 2000,
disableStdin: false,
cols: isMobile ? 40 : layout === "grid" ? 60 : 120,
rows: isMobile ? 20 : layout === "grid" ? 15 : 30,
theme: {
background: "#000000",
foreground: "#ffffff",
@@ -316,47 +318,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
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)
fitAddon.fit()
const wsUrl = websocketUrl || getWebSocketUrl()
const ws = new WebSocket(wsUrl)
@@ -364,9 +326,6 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
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) => {
@@ -392,7 +351,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const handleResize = () => {
try {
performResize()
fitAddon.fit()
} catch {
// Ignore resize errors
}
@@ -496,360 +455,349 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
return (
<>
<style jsx>{`
:global(.xterm .xterm-viewport) {
padding: 0 !important;
}
:global(.xterm .xterm-screen) {
padding: 0 !important;
}
`}</style>
<div className="h-full w-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 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} / 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={addNewTerminal}
variant="outline"
size="sm"
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>
</Button>
<Button
onClick={() => setSearchModalOpen(true)}
variant="outline"
size="sm"
disabled={!activeTerminal?.isConnected}
className="h-8 gap-2 bg-blue-600 hover:bg-blue-700 border-blue-500 text-white disabled:opacity-50"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
</Button>
<Button
onClick={handleClear}
variant="outline"
size="sm"
disabled={!activeTerminal?.isConnected}
className="h-8 gap-2 bg-yellow-600 hover:bg-yellow-700 border-yellow-500 text-white disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</Button>
<Button
onClick={handleClose}
variant="outline"
size="sm"
className="h-8 gap-2 bg-red-600 hover:bg-red-700 border-red-500 text-white"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
</Button>
</div>
<div className="flex flex-col h-full bg-zinc-950 rounded-md overflow-hidden">
<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} / 4 terminals</span>
</div>
<div className="flex-1 overflow-hidden w-full">
{isMobile ? (
<Tabs value={activeTerminalId} onValueChange={setActiveTerminalId} className="h-full w-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"
<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={addNewTerminal}
variant="outline"
size="sm"
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>
</Button>
<Button
onClick={() => setSearchModalOpen(true)}
variant="outline"
size="sm"
disabled={!activeTerminal?.isConnected}
className="h-8 gap-2 bg-blue-600 hover:bg-blue-700 border-blue-500 text-white disabled:opacity-50"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
</Button>
<Button
onClick={handleClear}
variant="outline"
size="sm"
disabled={!activeTerminal?.isConnected}
className="h-8 gap-2 bg-yellow-600 hover:bg-yellow-700 border-yellow-500 text-white disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</Button>
<Button
onClick={handleClose}
variant="outline"
size="sm"
className="h-8 gap-2 bg-red-600 hover:bg-red-700 border-red-500 text-white"
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">Close</span>
</Button>
</div>
</div>
<div className="flex-1 min-h-0">
{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">
{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} className="flex-1 m-0 p-0">
<div
ref={setContainerRef(terminal.id)}
className="w-full h-full bg-black"
style={{ height: "calc(100vh - 24rem)" }}
/>
</TabsContent>
))}
</Tabs>
) : (
<div className={`${getLayoutClass()} h-full gap-0.5 bg-zinc-800 p-0.5`}>
{terminals.map((terminal) => (
<div key={terminal.id} className="relative bg-zinc-900 overflow-hidden">
<div className="absolute top-0 left-0 right-0 z-10 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"
}`}
>
{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 w-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 bg-black" />
</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>
)}
</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">
{lastKeyPressed && (
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-0.5 rounded mr-2">
Sent: {lastKeyPressed}
</span>
)}
<Button onClick={() => sendSequence("\x1b")} variant="outline" size="sm" className="h-8 px-3 text-xs">
ESC
</Button>
<Button onClick={() => sendSequence("\t")} variant="outline" size="sm" className="h-8 px-3 text-xs">
TAB
</Button>
<Button onClick={() => handleKeyButton("UP")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("DOWN")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("LEFT")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("RIGHT")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => sendSequence("\x03")} variant="outline" size="sm" className="h-8 px-3 text-xs">
CTRL+C
</Button>
<div ref={setContainerRef(terminal.id)} className="w-full h-full bg-black pt-7" />
</div>
))}
</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 ps', 'qm list', '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>
))}
{/* Powered by cheat.sh */}
<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 ps", "qm list", "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 (tar, grep, docker, etc.) or Proxmox commands (qm, pct, pvesh)
</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
</div>
</DialogContent>
</Dialog>
</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">
{lastKeyPressed && (
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-0.5 rounded mr-2">
Sent: {lastKeyPressed}
</span>
)}
<Button onClick={() => sendSequence("\x1b")} variant="outline" size="sm" className="h-8 px-3 text-xs">
ESC
</Button>
<Button onClick={() => sendSequence("\t")} variant="outline" size="sm" className="h-8 px-3 text-xs">
TAB
</Button>
<Button onClick={() => handleKeyButton("UP")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("DOWN")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("LEFT")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => handleKeyButton("RIGHT")} variant="outline" size="sm" className="h-8 px-3 text-xs">
</Button>
<Button onClick={() => sendSequence("\x03")} 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 ps', 'qm list', '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>
))}
{/* Powered by cheat.sh */}
<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 ps", "qm list", "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 (tar, grep, docker, etc.) or Proxmox commands (qm, pct, pvesh)
</span>
</div>
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -20,8 +20,79 @@ import requests
terminal_bp = Blueprint('terminal', __name__)
sock = Sock()
# Active terminal sessions - now stores session by session_id string
sessions = {}
# Active terminal sessions
active_sessions = {}
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
return {'success': True, 'active_sessions': len(active_sessions)}
@terminal_bp.route('/api/terminal/search-command', methods=['GET'])
def search_command():
"""Proxy endpoint for cheat.sh API to avoid CORS issues"""
query = request.args.get('q', '')
if not query or len(query) < 2:
return jsonify({'error': 'Query too short'}), 400
try:
url = f'https://cht.sh/{query.replace(" ", "+")}?QT'
headers = {
'User-Agent': 'curl/7.68.0'
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code == 200:
content = response.text
examples = []
current_description = []
for line in content.split('\n'):
stripped = line.strip()
# Ignorar líneas vacías
if not stripped:
continue
# Si es un comentario
if stripped.startswith('#'):
# Acumular descripciones
current_description.append(stripped[1:].strip())
# Si no es comentario, es un comando
elif stripped and not stripped.startswith('http'):
# Unir las descripciones acumuladas
description = ' '.join(current_description) if current_description else ''
examples.append({
'description': description,
'command': stripped
})
# Resetear descripciones para el siguiente comando
current_description = []
return jsonify({
'success': True,
'examples': examples
})
else:
return jsonify({
'success': False,
'error': f'API returned status {response.status_code}'
}), response.status_code
except requests.Timeout:
return jsonify({
'success': False,
'error': 'Request timeout'
}), 504
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
def set_winsize(fd, rows, cols):
"""Set terminal window size"""
@@ -50,33 +121,6 @@ def read_and_forward_output(master_fd, ws):
print(f"Error reading from PTY: {e}")
break
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
return {'success': True, 'active_sessions': len(sessions)}
@terminal_bp.route('/api/terminal/<session_id>/resize', methods=['POST'])
def resize_terminal(session_id):
"""Resize the PTY for a given terminal session."""
if session_id not in sessions:
return jsonify({'error': 'Session not found'}), 404
try:
data = request.get_json()
cols = int(data.get('cols', 120))
rows = int(data.get('rows', 30))
# Resize the PTY to match the frontend terminal dimensions
master_fd = sessions[session_id]['master_fd']
set_winsize(master_fd, rows, cols)
print(f"[v0] Terminal {session_id} resized to {cols}x{rows}")
return jsonify({'status': 'success', 'cols': cols, 'rows': rows})
except Exception as e:
print(f"Error resizing terminal {session_id}: {e}")
return jsonify({'error': str(e)}), 500
@sock.route('/ws/terminal')
def terminal_websocket(ws):
"""WebSocket endpoint for terminal sessions"""
@@ -95,8 +139,8 @@ def terminal_websocket(ws):
env=dict(os.environ, TERM='xterm-256color', PS1='\\u@\\h:\\w\\$ ')
)
session_id = str(int(time.time() * 1000))
sessions[session_id] = {
session_id = id(ws)
active_sessions[session_id] = {
'process': shell_process,
'master_fd': master_fd
}
@@ -105,10 +149,8 @@ def terminal_websocket(ws):
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
set_winsize(master_fd, 30, 80)
# Send session_id to frontend
ws.send(f"\x1b]0;SESSION_ID:{session_id}\x07")
# Set initial terminal size
set_winsize(master_fd, 30, 120)
# Start thread to read PTY output and forward to WebSocket
output_thread = threading.Thread(
@@ -127,19 +169,16 @@ def terminal_websocket(ws):
# Client closed connection
break
# Handle terminal resize (optional)
if data.startswith('\x1b[8;'):
try:
# Parse: \x1b[8;{rows};{cols}t
parts = data[4:-1].split(';')
if len(parts) >= 2:
rows, cols = int(parts[0]), int(parts[1])
set_winsize(master_fd, rows, cols)
print(f"[v0] Terminal resized via WebSocket to {rows}x{cols}")
rows, cols = int(parts[0]), int(parts[1])
set_winsize(master_fd, rows, cols)
continue
except Exception as e:
print(f"Error parsing resize: {e}")
except:
pass
# Send input to bash
try:
os.write(master_fd, data.encode('utf-8'))
@@ -174,8 +213,8 @@ def terminal_websocket(ws):
except:
pass
if session_id in sessions:
del sessions[session_id]
if session_id in active_sessions:
del active_sessions[session_id]
def init_terminal_routes(app):
"""Initialize terminal routes with Flask app"""