From 23280fd97b4bb1d109cc6eda4a88e7c842ba0af4 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 21 Nov 2025 18:32:10 +0100 Subject: [PATCH] Update AppImage --- AppImage/app/terminal/page.tsx | 33 ++++ AppImage/components/sidebar.tsx | 5 +- AppImage/components/terminal-panel.tsx | 215 ++++++++++++++++++++++ AppImage/package.json | 4 +- AppImage/scripts/build_appimage.sh | 2 + AppImage/scripts/flask_server.py | 159 +++++++++------- AppImage/scripts/flask_terminal_routes.py | 129 +++++++++++++ 7 files changed, 477 insertions(+), 70 deletions(-) create mode 100644 AppImage/app/terminal/page.tsx create mode 100644 AppImage/components/terminal-panel.tsx create mode 100644 AppImage/scripts/flask_terminal_routes.py diff --git a/AppImage/app/terminal/page.tsx b/AppImage/app/terminal/page.tsx new file mode 100644 index 0000000..910bdb9 --- /dev/null +++ b/AppImage/app/terminal/page.tsx @@ -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 ( +
+
+
+ +
+

System Terminal

+

Execute commands and manage your Proxmox system

+
+
+ + + + Interactive Shell + + Full bash terminal with support for all system commands. Use touch gestures or keyboard shortcuts. + + + + + + +
+
+ ) +} diff --git a/AppImage/components/sidebar.tsx b/AppImage/components/sidebar.tsx index c4acc78..b8e7c28 100644 --- a/AppImage/components/sidebar.tsx +++ b/AppImage/components/sidebar.tsx @@ -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 ... diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx new file mode 100644 index 0000000..6168dc2 --- /dev/null +++ b/AppImage/components/terminal-panel.tsx @@ -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 = ({ websocketUrl = "ws://localhost:8008/ws/terminal" }) => { + const containerRef = useRef(null) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const wsRef = useRef(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) => { + const touch = e.touches[0] + touchStartRef.current = { + x: touch.clientX, + y: touch.clientY, + time: Date.now(), + } + } + + const handleTouchEnd = (e: React.TouchEvent) => { + 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 ( +
+ {/* Terminal display */} +
+ + {/* Touch keyboard bar for mobile/tablet */} +
+ handleKeyButton("ESC")} /> + handleKeyButton("TAB")} /> + handleKeyButton("UP")} /> + handleKeyButton("DOWN")} /> + handleKeyButton("LEFT")} /> + handleKeyButton("RIGHT")} /> + handleKeyButton("ENTER")} /> + handleKeyButton("CTRL_C")} /> +
+
+ ) +} + +// Reusable button component for touch keyboard +type TouchKeyProps = { + label: string + onClick: () => void +} + +const TouchKey: React.FC = ({ label, onClick }) => ( + +) diff --git a/AppImage/package.json b/AppImage/package.json index af6ae56..2b9e9d7 100644 --- a/AppImage/package.json +++ b/AppImage/package.json @@ -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": { diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index d92686b..0d4a254 100644 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -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' diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 9edd8a8..15637fc 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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): diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py new file mode 100644 index 0000000..b6175a5 --- /dev/null +++ b/AppImage/scripts/flask_terminal_routes.py @@ -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)