Update AppImage

This commit is contained in:
MacRimi
2025-11-21 18:32:10 +01:00
parent fe6679f16a
commit 23280fd97b
7 changed files with 477 additions and 70 deletions

View 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>
)
}

View File

@@ -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 = [
{ name: "Overview", href: "/", icon: LayoutDashboard },
@@ -6,8 +6,7 @@ const menuItems = [
{ name: "Network", href: "/network", icon: Network },
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
{ name: "Hardware", href: "/hardware", icon: Cpu },
{ name: "Terminal", href: "/terminal", icon: Terminal },
{ name: "System Logs", href: "/logs", icon: FileText },
{ name: "Settings", href: "/settings", icon: SettingsIcon },
]
// ... existing code ...

View 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>
)

View File

@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.0.1",
"version": "1.0.2",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {
@@ -60,6 +60,8 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"zod": "3.25.67"
},
"devDependencies": {

View File

@@ -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/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_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
echo "📋 Adding translation support..."
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 \
httpx==0.13.3 \
httpcore==0.9.1 \
flask-sock \
beautifulsoup4
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'

View File

@@ -1,92 +1,119 @@
#!/usr/bin/env python3
"""
ProxMenux Flask Server
Provides REST API endpoints for Proxmox monitoring data
Runs on port 8008 and serves system metrics, storage info, network stats, etc.
Also serves the Next.js dashboard as static files
- Provides REST API endpoints for Proxmox monitoring (system, storage, network, VMs, etc.)
- 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 logging
import math
import os
import platform
import re
import select
import shutil
import socket
import subprocess
import sys
import time
import socket
# 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
import urllib.parse
import xml.etree.ElementTree as ET
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 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_proxmenux_routes import proxmenux_bp
from jwt_middleware import require_auth
from flask_terminal_routes import terminal_bp, init_terminal_routes # noqa: E402
from flask_health_routes import health_bp # noqa: E402
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__)
CORS(app) # Enable CORS for Next.js frontend
# Register Blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(health_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):

View 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)