mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-12-14 16:16:21 +00:00
Update AppImage
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
Reference in New Issue
Block a user