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)