mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2025-11-22 13:36:17 +00:00
Update AppImage
This commit is contained in:
33
AppImage/app/terminal/page.tsx
Normal file
33
AppImage/app/terminal/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { TerminalPanel } from "@/components/terminal-panel"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { TerminalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export default function TerminalPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background p-6">
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TerminalIcon className="h-8 w-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">System Terminal</h1>
|
||||||
|
<p className="text-muted-foreground">Execute commands and manage your Proxmox system</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="h-[calc(100vh-200px)]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Interactive Shell</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Full bash terminal with support for all system commands. Use touch gestures or keyboard shortcuts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[calc(100%-80px)]">
|
||||||
|
<TerminalPanel />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } from "lucide-react"
|
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||||
@@ -6,8 +6,7 @@ const menuItems = [
|
|||||||
{ name: "Network", href: "/network", icon: Network },
|
{ name: "Network", href: "/network", icon: Network },
|
||||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||||
{ name: "Hardware", href: "/hardware", icon: Cpu },
|
{ name: "Hardware", href: "/hardware", icon: Cpu },
|
||||||
|
{ name: "Terminal", href: "/terminal", icon: Terminal },
|
||||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||||
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ... existing code ...
|
|
||||||
|
|||||||
215
AppImage/components/terminal-panel.tsx
Normal file
215
AppImage/components/terminal-panel.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { Terminal } from "xterm"
|
||||||
|
import { FitAddon } from "xterm-addon-fit"
|
||||||
|
import "xterm/css/xterm.css"
|
||||||
|
|
||||||
|
type TerminalPanelProps = {
|
||||||
|
websocketUrl?: string // Custom WebSocket URL if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl = "ws://localhost:8008/ws/terminal" }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const termRef = useRef<Terminal | null>(null)
|
||||||
|
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
|
||||||
|
// For touch gestures
|
||||||
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 2000,
|
||||||
|
disableStdin: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
term.open(containerRef.current)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
termRef.current = term
|
||||||
|
fitAddonRef.current = fitAddon
|
||||||
|
|
||||||
|
// WebSocket connection to backend (Flask)
|
||||||
|
const ws = new WebSocket(websocketUrl)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
term.writeln("\x1b[32mConnected to ProxMenux terminal.\x1b[0m")
|
||||||
|
// Optional: notify backend to start shell
|
||||||
|
// ws.send(JSON.stringify({ type: 'start', shell: 'bash' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
// Backend sends plain text (bash output)
|
||||||
|
term.write(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send user input to backend
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
// Send raw data or JSON depending on backend implementation
|
||||||
|
ws.send(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-adjust terminal size on window resize
|
||||||
|
const handleResize = () => {
|
||||||
|
try {
|
||||||
|
fitAddon.fit()
|
||||||
|
} catch {
|
||||||
|
// Ignore resize errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize)
|
||||||
|
ws.close()
|
||||||
|
term.dispose()
|
||||||
|
}
|
||||||
|
}, [websocketUrl])
|
||||||
|
|
||||||
|
// --- Helpers for special key sequences ---
|
||||||
|
const sendSequence = (seq: string) => {
|
||||||
|
const term = termRef.current
|
||||||
|
const ws = wsRef.current
|
||||||
|
if (!term || !ws || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyButton = (key: string) => {
|
||||||
|
switch (key) {
|
||||||
|
case "UP":
|
||||||
|
sendSequence("\x1b[A")
|
||||||
|
break
|
||||||
|
case "DOWN":
|
||||||
|
sendSequence("\x1b[B")
|
||||||
|
break
|
||||||
|
case "RIGHT":
|
||||||
|
sendSequence("\x1b[C")
|
||||||
|
break
|
||||||
|
case "LEFT":
|
||||||
|
sendSequence("\x1b[D")
|
||||||
|
break
|
||||||
|
case "ESC":
|
||||||
|
sendSequence("\x1b")
|
||||||
|
break
|
||||||
|
case "TAB":
|
||||||
|
sendSequence("\t")
|
||||||
|
break
|
||||||
|
case "ENTER":
|
||||||
|
sendSequence("\r")
|
||||||
|
break
|
||||||
|
case "CTRL_C":
|
||||||
|
sendSequence("\x03") // Ctrl+C
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Touch gestures for arrow keys ---
|
||||||
|
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
touchStartRef.current = {
|
||||||
|
x: touch.clientX,
|
||||||
|
y: touch.clientY,
|
||||||
|
time: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: React.TouchEvent<HTMLDivElement>) => {
|
||||||
|
const start = touchStartRef.current
|
||||||
|
if (!start) return
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0]
|
||||||
|
const dx = touch.clientX - start.x
|
||||||
|
const dy = touch.clientY - start.y
|
||||||
|
const dt = Date.now() - start.time
|
||||||
|
|
||||||
|
const minDistance = 30 // Minimum pixels for swipe detection
|
||||||
|
const maxTime = 1000 // Maximum time in milliseconds
|
||||||
|
|
||||||
|
touchStartRef.current = null
|
||||||
|
|
||||||
|
if (dt > maxTime) return // Gesture too slow, ignore
|
||||||
|
|
||||||
|
if (Math.abs(dx) < minDistance && Math.abs(dy) < minDistance) {
|
||||||
|
return // Movement too small, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
// Horizontal swipe
|
||||||
|
if (dx > 0) {
|
||||||
|
handleKeyButton("RIGHT")
|
||||||
|
} else {
|
||||||
|
handleKeyButton("LEFT")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Vertical swipe
|
||||||
|
if (dy > 0) {
|
||||||
|
handleKeyButton("DOWN")
|
||||||
|
} else {
|
||||||
|
handleKeyButton("UP")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
{/* Terminal display */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 bg-black rounded-t-md overflow-hidden"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Touch keyboard bar for mobile/tablet */}
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center items-center px-2 py-2 bg-zinc-900 text-sm rounded-b-md">
|
||||||
|
<TouchKey label="ESC" onClick={() => handleKeyButton("ESC")} />
|
||||||
|
<TouchKey label="TAB" onClick={() => handleKeyButton("TAB")} />
|
||||||
|
<TouchKey label="↑" onClick={() => handleKeyButton("UP")} />
|
||||||
|
<TouchKey label="↓" onClick={() => handleKeyButton("DOWN")} />
|
||||||
|
<TouchKey label="←" onClick={() => handleKeyButton("LEFT")} />
|
||||||
|
<TouchKey label="→" onClick={() => handleKeyButton("RIGHT")} />
|
||||||
|
<TouchKey label="ENTER" onClick={() => handleKeyButton("ENTER")} />
|
||||||
|
<TouchKey label="CTRL+C" onClick={() => handleKeyButton("CTRL_C")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable button component for touch keyboard
|
||||||
|
type TouchKeyProps = {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TouchKey: React.FC<TouchKeyProps> = ({ label, onClick }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="px-3 py-1 rounded bg-zinc-800 hover:bg-zinc-700 active:bg-zinc-600 text-zinc-100 text-xs md:text-sm border border-zinc-700"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -60,6 +60,8 @@
|
|||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"zod": "3.25.67"
|
"zod": "3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
|
|||||||
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
|
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
|
||||||
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
|
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
|
||||||
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
|
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
|
||||||
|
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
|
||||||
|
|
||||||
echo "📋 Adding translation support..."
|
echo "📋 Adding translation support..."
|
||||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||||
@@ -292,6 +293,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
|||||||
googletrans==4.0.0-rc1 \
|
googletrans==4.0.0-rc1 \
|
||||||
httpx==0.13.3 \
|
httpx==0.13.3 \
|
||||||
httpcore==0.9.1 \
|
httpcore==0.9.1 \
|
||||||
|
flask-sock \
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
|
||||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||||
|
|||||||
@@ -1,92 +1,119 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
ProxMenux Flask Server
|
ProxMenux Flask Server
|
||||||
Provides REST API endpoints for Proxmox monitoring data
|
|
||||||
Runs on port 8008 and serves system metrics, storage info, network stats, etc.
|
- Provides REST API endpoints for Proxmox monitoring (system, storage, network, VMs, etc.)
|
||||||
Also serves the Next.js dashboard as static files
|
- Serves the Next.js dashboard as static files
|
||||||
|
- Integrates a web terminal powered by xterm.js
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, send_from_directory, send_file
|
|
||||||
from flask_cors import CORS
|
|
||||||
import psutil
|
|
||||||
import subprocess
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import socket
|
import urllib.parse
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
# Cache for Proxmox node name (to avoid repeated API calls)
|
|
||||||
_proxmox_node_cache = {'name': None, 'timestamp': 0}
|
|
||||||
|
|
||||||
def get_proxmox_node_name():
|
|
||||||
"""
|
|
||||||
Get the actual Proxmox node name from the Proxmox API.
|
|
||||||
Uses cache to avoid repeated API calls.
|
|
||||||
Falls back to short hostname if API call fails.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
cache_duration = 300 # 5 minutes cache
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if _proxmox_node_cache['name'] and (current_time - _proxmox_node_cache['timestamp']) < cache_duration:
|
|
||||||
return _proxmox_node_cache['name']
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Query Proxmox API directly to get the actual node name
|
|
||||||
result = subprocess.run(
|
|
||||||
['pvesh', 'get', '/nodes', '--output-format', 'json'],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=5
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
nodes = json.loads(result.stdout)
|
|
||||||
if nodes and len(nodes) > 0:
|
|
||||||
# Get the first node name (in most cases there's only one local node)
|
|
||||||
node_name = nodes[0].get('node', '')
|
|
||||||
if node_name:
|
|
||||||
_proxmox_node_cache['name'] = node_name
|
|
||||||
_proxmox_node_cache['timestamp'] = current_time
|
|
||||||
return node_name
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not get Proxmox node name from API: {e}")
|
|
||||||
|
|
||||||
# Fallback to short hostname (without domain) if API call fails
|
|
||||||
hostname = socket.gethostname()
|
|
||||||
short_hostname = hostname.split('.')[0]
|
|
||||||
return short_hostname
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import re # Added for regex matching
|
|
||||||
import select # Added for non-blocking read
|
|
||||||
import shutil # Added for shutil.which
|
|
||||||
import xml.etree.ElementTree as ET # Added for XML parsing
|
|
||||||
import math # Imported math for format_bytes function
|
|
||||||
import urllib.parse # Added for URL encoding
|
|
||||||
import platform # Added for platform.release()
|
|
||||||
import hashlib
|
|
||||||
import secrets
|
|
||||||
import jwt
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask_health_routes import health_bp
|
import jwt
|
||||||
|
import psutil
|
||||||
|
from flask import Flask, jsonify, request, send_file, send_from_directory
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
# Ensure local imports work even if working directory changes
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
from flask_auth_routes import auth_bp
|
from flask_terminal_routes import terminal_bp, init_terminal_routes # noqa: E402
|
||||||
from flask_proxmenux_routes import proxmenux_bp
|
from flask_health_routes import health_bp # noqa: E402
|
||||||
from jwt_middleware import require_auth
|
from flask_auth_routes import auth_bp # noqa: E402
|
||||||
|
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||||
|
from jwt_middleware import require_auth # noqa: E402
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
logger = logging.getLogger("proxmenux.flask")
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Proxmox node name cache
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
_PROXMOX_NODE_CACHE = {"name": None, "timestamp": 0.0}
|
||||||
|
_PROXMOX_NODE_CACHE_TTL = 300 # seconds (5 minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def get_proxmox_node_name() -> str:
|
||||||
|
"""
|
||||||
|
Retrieve the real Proxmox node name.
|
||||||
|
|
||||||
|
- First tries reading from: `pvesh get /nodes`
|
||||||
|
- Uses an in-memory cache to avoid repeated API calls
|
||||||
|
- Falls back to the short hostname if the API call fails
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
cached_name = _PROXMOX_NODE_CACHE.get("name")
|
||||||
|
cached_ts = _PROXMOX_NODE_CACHE.get("timestamp", 0.0)
|
||||||
|
|
||||||
|
# Cache hit
|
||||||
|
if cached_name and (now - float(cached_ts)) < _PROXMOX_NODE_CACHE_TTL:
|
||||||
|
return str(cached_name)
|
||||||
|
|
||||||
|
# Try Proxmox API
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pvesh", "get", "/nodes", "--output-format", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0 and result.stdout:
|
||||||
|
nodes = json.loads(result.stdout)
|
||||||
|
if isinstance(nodes, list) and nodes:
|
||||||
|
node_name = nodes[0].get("node")
|
||||||
|
if node_name:
|
||||||
|
_PROXMOX_NODE_CACHE["name"] = node_name
|
||||||
|
_PROXMOX_NODE_CACHE["timestamp"] = now
|
||||||
|
return node_name
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to get Proxmox node name from API: %s", exc)
|
||||||
|
|
||||||
|
# Fallback: short hostname (without domain)
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
short_hostname = hostname.split(".", 1)[0]
|
||||||
|
return short_hostname
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Flask application and Blueprints
|
||||||
|
# -------------------------------------------------------------------
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app) # Enable CORS for Next.js frontend
|
CORS(app) # Enable CORS for Next.js frontend
|
||||||
|
|
||||||
|
# Register Blueprints
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(proxmenux_bp)
|
app.register_blueprint(proxmenux_bp)
|
||||||
|
|
||||||
|
# Initialize terminal / WebSocket routes
|
||||||
|
init_terminal_routes(app)
|
||||||
|
|
||||||
|
|
||||||
def identify_gpu_type(name, vendor=None, bus=None, driver=None):
|
def identify_gpu_type(name, vendor=None, bus=None, driver=None):
|
||||||
|
|||||||
129
AppImage/scripts/flask_terminal_routes.py
Normal file
129
AppImage/scripts/flask_terminal_routes.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ProxMenux Terminal WebSocket Routes
|
||||||
|
Provides a WebSocket endpoint for interactive terminal sessions
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
from flask_sock import Sock
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import struct
|
||||||
|
import fcntl
|
||||||
|
import termios
|
||||||
|
import signal
|
||||||
|
|
||||||
|
terminal_bp = Blueprint('terminal', __name__)
|
||||||
|
sock = Sock()
|
||||||
|
|
||||||
|
# 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)}
|
||||||
|
|
||||||
|
def set_winsize(fd, rows, cols):
|
||||||
|
"""Set terminal window size"""
|
||||||
|
try:
|
||||||
|
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
||||||
|
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error setting window size: {e}")
|
||||||
|
|
||||||
|
@sock.route('/ws/terminal')
|
||||||
|
def terminal_websocket(ws):
|
||||||
|
"""WebSocket endpoint for terminal sessions"""
|
||||||
|
|
||||||
|
# Create pseudo-terminal
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Start bash process
|
||||||
|
shell_process = subprocess.Popen(
|
||||||
|
['/bin/bash', '-i'],
|
||||||
|
stdin=slave_fd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
env=dict(os.environ, TERM='xterm-256color', PS1='\\u@\\h:\\w\\$ ')
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = id(ws)
|
||||||
|
active_sessions[session_id] = {
|
||||||
|
'process': shell_process,
|
||||||
|
'master_fd': master_fd
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set non-blocking mode for master_fd
|
||||||
|
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||||
|
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||||
|
|
||||||
|
# Set initial terminal size
|
||||||
|
set_winsize(master_fd, 24, 80)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Use select to wait for data from either WebSocket or PTY
|
||||||
|
readable, _, _ = select.select([ws.sock, master_fd], [], [], 0.1)
|
||||||
|
|
||||||
|
# Read from WebSocket (user input)
|
||||||
|
if ws.sock in readable:
|
||||||
|
try:
|
||||||
|
data = ws.receive(timeout=0)
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Handle special commands (optional)
|
||||||
|
if data.startswith('\x1b[8;'): # Terminal resize
|
||||||
|
# Parse resize: ESC[8;{rows};{cols}t
|
||||||
|
try:
|
||||||
|
parts = data[4:-1].split(';')
|
||||||
|
rows, cols = int(parts[0]), int(parts[1])
|
||||||
|
set_winsize(master_fd, rows, cols)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Send input to bash
|
||||||
|
os.write(master_fd, data.encode('utf-8'))
|
||||||
|
except:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Read from PTY (bash output)
|
||||||
|
if master_fd in readable:
|
||||||
|
try:
|
||||||
|
output = os.read(master_fd, 4096)
|
||||||
|
if output:
|
||||||
|
ws.send(output.decode('utf-8', errors='ignore'))
|
||||||
|
except OSError:
|
||||||
|
# PTY closed
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if process is still alive
|
||||||
|
if shell_process.poll() is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Terminal session error: {e}")
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
shell_process.terminate()
|
||||||
|
shell_process.wait(timeout=1)
|
||||||
|
except:
|
||||||
|
shell_process.kill()
|
||||||
|
|
||||||
|
os.close(master_fd)
|
||||||
|
os.close(slave_fd)
|
||||||
|
|
||||||
|
if session_id in active_sessions:
|
||||||
|
del active_sessions[session_id]
|
||||||
|
|
||||||
|
ws.close()
|
||||||
|
|
||||||
|
def init_terminal_routes(app):
|
||||||
|
"""Initialize terminal routes with Flask app"""
|
||||||
|
sock.init_app(app)
|
||||||
|
app.register_blueprint(terminal_bp)
|
||||||
Reference in New Issue
Block a user