Update AppImage

This commit is contained in:
MacRimi
2025-11-07 19:25:36 +01:00
parent fc7d0f2cd5
commit 25fc3d931e
6 changed files with 258 additions and 74 deletions

View File

@@ -2,10 +2,11 @@
import type React from "react"
import { useState } from "react"
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox"
import { Lock, User, AlertCircle, Server } from "lucide-react"
import { getApiUrl } from "../lib/api-config"
import Image from "next/image"
@@ -17,15 +18,49 @@ interface LoginProps {
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [rememberMe, setRememberMe] = useState(false)
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
useEffect(() => {
const savedUsername = localStorage.getItem("proxmenux-saved-username")
const savedPassword = localStorage.getItem("proxmenux-saved-password")
if (savedUsername && savedPassword) {
setUsername(savedUsername)
setPassword(savedPassword)
setRememberMe(true)
// Auto-login si hay credenciales guardadas
handleAutoLogin(savedUsername, savedPassword)
}
}, [])
const handleAutoLogin = async (user: string, pass: string) => {
try {
const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: user, password: pass, remember_me: true }),
})
const data = await response.json()
if (response.ok && data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
onLogin()
}
} catch (err) {
console.log("Auto-login failed, showing login form")
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!username || !password) {
setError("Please enter username and password")
setError("Por favor, introduce usuario y contraseña")
return
}
@@ -35,20 +70,30 @@ export function Login({ onLogin }: LoginProps) {
const response = await fetch(getApiUrl("/api/auth/login"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, remember_me: rememberMe }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Login failed")
throw new Error(data.error || "Fallo en el login")
}
// Save token
localStorage.setItem("proxmenux-auth-token", data.token)
if (rememberMe) {
localStorage.setItem("proxmenux-saved-username", username)
localStorage.setItem("proxmenux-saved-password", password)
} else {
// Limpiar credenciales guardadas si no se marcó recordar
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
}
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed")
setError(err instanceof Error ? err.message : "Fallo en el login")
} finally {
setLoading(false)
}
@@ -128,6 +173,18 @@ export function Login({ onLogin }: LoginProps) {
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
disabled={loading}
/>
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
Remember me
</Label>
</div>
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>

View File

@@ -1,5 +1,7 @@
"use client"
import type React from "react"
import { useState, useEffect, useMemo, useCallback } from "react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
@@ -28,6 +30,7 @@ import {
Cpu,
FileText,
SettingsIcon,
LogOut,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -74,6 +77,7 @@ export function ProxmoxDashboard() {
const [showNavigation, setShowNavigation] = useState(true)
const [lastScrollY, setLastScrollY] = useState(0)
const [showHealthModal, setShowHealthModal] = useState(false)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const fetchSystemData = useCallback(async () => {
const apiUrl = getApiUrl("/api/system-info")
@@ -184,6 +188,18 @@ export function ProxmoxDashboard() {
setIsRefreshing(false)
}
const handleLogout = (e: React.MouseEvent) => {
e.stopPropagation()
localStorage.removeItem("proxmenux-auth-token")
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
window.location.reload()
}
useEffect(() => {
setIsAuthenticated(!!localStorage.getItem("proxmenux-auth-token"))
}, [])
const statusIcon = useMemo(() => {
switch (systemStatus.status) {
case "healthy":
@@ -323,6 +339,19 @@ export function ProxmoxDashboard() {
Refresh
</Button>
{isAuthenticated && (
<Button
variant="outline"
size="sm"
onClick={handleLogout}
className="border-border/50 bg-transparent hover:bg-secondary"
title="Logout"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
)}
<div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>
@@ -348,6 +377,12 @@ export function ProxmoxDashboard() {
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
{isAuthenticated && (
<Button variant="ghost" size="sm" onClick={handleLogout} className="h-8 w-8 p-0" title="Logout">
<LogOut className="h-4 w-4" />
</Button>
)}
<div onClick={(e) => e.stopPropagation()}>
<ThemeToggle />
</div>

View File

@@ -98,7 +98,7 @@ export function Settings() {
const handleDisableAuth = async () => {
if (
!confirm(
"Are you sure you want to disable authentication? This will remove password protection from your dashboard.",
"¿Estás seguro de que quieres deshabilitar la autenticación? Esto eliminará la protección por contraseña de tu dashboard y tendrás que configurarla de nuevo si quieres reactivarla.",
)
) {
return
@@ -109,19 +109,35 @@ export function Settings() {
setSuccess("")
try {
const response = await fetch(getApiUrl("/api/auth/setup"), {
const response = await fetch(getApiUrl("/api/auth/disable"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_auth: false }),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
},
})
if (!response.ok) throw new Error("Failed to disable authentication")
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to disable authentication")
}
localStorage.removeItem("proxmenux-auth-token")
setSuccess("Authentication disabled successfully!")
setAuthEnabled(false)
localStorage.removeItem("proxmenux-saved-username")
localStorage.removeItem("proxmenux-saved-password")
localStorage.removeItem("proxmenux-auth-setup-complete")
setSuccess("¡Autenticación deshabilitada correctamente! La página se recargará...")
// Recargar la página después de 1.5 segundos
setTimeout(() => {
window.location.reload()
}, 1500)
} catch (err) {
setError("Failed to disable authentication. Please try again.")
setError(
err instanceof Error ? err.message : "No se pudo deshabilitar la autenticación. Por favor, inténtalo de nuevo.",
)
} finally {
setLoading(false)
}

View File

@@ -26,6 +26,7 @@ AUTH_CONFIG_FILE = CONFIG_DIR / "auth.json"
JWT_SECRET = "proxmenux-monitor-secret-key-change-in-production"
JWT_ALGORITHM = "HS256"
TOKEN_EXPIRATION_HOURS = 24
TOKEN_EXPIRATION_DAYS_REMEMBER = 30 # Token más largo para "recordar contraseña"
def ensure_config_dir():
@@ -94,15 +95,18 @@ def verify_password(password, password_hash):
return hash_password(password) == password_hash
def generate_token(username):
def generate_token(username, remember_me=False): # Añadido parámetro remember_me
"""Generate a JWT token for the given username"""
if not JWT_AVAILABLE:
return None
expiration_delta = timedelta(days=TOKEN_EXPIRATION_DAYS_REMEMBER) if remember_me else timedelta(hours=TOKEN_EXPIRATION_HOURS)
payload = {
'username': username,
'exp': datetime.utcnow() + timedelta(hours=TOKEN_EXPIRATION_HOURS),
'iat': datetime.utcnow()
'exp': datetime.utcnow() + expiration_delta,
'iat': datetime.utcnow(),
'remember_me': remember_me # Guardar preferencia en el token
}
try:
@@ -147,7 +151,7 @@ def get_auth_status():
config = load_auth_config()
return {
"auth_enabled": config.get("enabled", False),
"auth_configured": config.get("configured", False), # Frontend expects this field name
"auth_configured": config.get("configured", False),
"declined": config.get("declined", False),
"username": config.get("username") if config.get("enabled") else None,
"authenticated": False # Will be set to True by the route handler if token is valid
@@ -202,13 +206,16 @@ def disable_auth():
Disable authentication (different from decline - can be re-enabled)
Returns (success: bool, message: str)
"""
config = load_auth_config()
config["enabled"] = False
# Keep configured=True and don't set declined=True
# This allows re-enabling without showing the setup modal again
config = {
"enabled": False,
"username": None,
"password_hash": None,
"declined": False,
"configured": False
}
if save_auth_config(config):
return True, "Authentication disabled"
return True, "Authentication disabled successfully"
else:
return False, "Failed to save configuration"
@@ -232,7 +239,7 @@ def enable_auth():
return False, "Failed to save configuration"
def change_password(old_password, new_password):
def change_password(current_password, new_password): # Corregido nombre del parámetro
"""
Change the authentication password
Returns (success: bool, message: str)
@@ -242,7 +249,7 @@ def change_password(old_password, new_password):
if not config.get("enabled"):
return False, "Authentication is not enabled"
if not verify_password(old_password, config.get("password_hash", "")):
if not verify_password(current_password, config.get("password_hash", "")):
return False, "Current password is incorrect"
if len(new_password) < 6:
@@ -256,7 +263,7 @@ def change_password(old_password, new_password):
return False, "Failed to save new password"
def authenticate(username, password):
def authenticate(username, password, remember_me=False): # Añadido parámetro remember_me
"""
Authenticate a user with username and password
Returns (success: bool, token: str or None, message: str)
@@ -272,7 +279,7 @@ def authenticate(username, password):
if not verify_password(password, config.get("password_hash", "")):
return False, None, "Invalid username or password"
token = generate_token(username)
token = generate_token(username, remember_me)
if token:
return True, token, "Authentication successful"
else:

View File

@@ -33,28 +33,47 @@ def auth_setup():
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({"success": False, "error": "Username and password are required"}), 400
success, message = auth_manager.setup_auth(username, password)
if success:
return jsonify({"success": True, "message": message})
# Generate token for immediate login
token = auth_manager.generate_token(username)
return jsonify({"success": True, "message": message, "token": token})
else:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup"""
@auth_bp.route('/api/auth/skip', methods=['POST'])
def auth_skip():
"""Skip authentication setup (user declined)"""
try:
success, message = auth_manager.decline_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/decline', methods=['POST'])
def auth_decline():
"""Decline authentication setup (deprecated, use /api/auth/skip)"""
try:
success, message = auth_manager.decline_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/login', methods=['POST'])
@@ -64,15 +83,19 @@ def auth_login():
data = request.json
username = data.get('username')
password = data.get('password')
remember_me = data.get('remember_me', False) # Soporte para "recordar contraseña"
success, token, message = auth_manager.authenticate(username, password)
success, token, message = auth_manager.authenticate(username, password, remember_me)
if success:
return jsonify({"success": True, "token": token, "message": message})
response_data = {"success": True, "token": token, "message": message}
if remember_me:
response_data["remember_me"] = True # Indicar al frontend que guarde las credenciales
return jsonify(response_data)
else:
return jsonify({"success": False, "message": message}), 401
return jsonify({"success": False, "error": message}), 401
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/enable', methods=['POST'])
@@ -84,23 +107,31 @@ def auth_enable():
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/disable', methods=['POST'])
def auth_disable():
"""Disable authentication"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({"success": False, "error": "Authentication required"}), 401
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "error": "Invalid or expired token"}), 401
success, message = auth_manager.disable_auth()
if success:
return jsonify({"success": True, "message": message})
else:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500
@auth_bp.route('/api/auth/change-password', methods=['POST'])
@@ -108,14 +139,25 @@ def auth_change_password():
"""Change authentication password"""
try:
data = request.json
old_password = data.get('old_password')
current_password = data.get('current_password') # Corregido el nombre del campo
new_password = data.get('new_password')
success, message = auth_manager.change_password(old_password, new_password)
# Verify current authentication
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({"success": False, "error": "Authentication required"}), 401
username = auth_manager.verify_token(token)
if not username:
return jsonify({"success": False, "error": "Invalid or expired token"}), 401
success, message = auth_manager.change_password(current_password, new_password)
if success:
return jsonify({"success": True, "message": message})
# Generate new token
new_token = auth_manager.generate_token(username)
return jsonify({"success": True, "message": message, "token": new_token})
else:
return jsonify({"success": False, "message": message}), 400
return jsonify({"success": False, "error": message}), 400
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -950,31 +950,37 @@ def get_pcie_link_speed(disk_name):
import re
match = re.match(r'(nvme\d+)n\d+', disk_name)
if not match:
print(f"[v0] Could not extract controller from {disk_name}")
# print(f"[v0] Could not extract controller from {disk_name}")
pass
return pcie_info
controller = match.group(1) # nvme0n1 -> nvme0
print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
# print(f"[v0] Getting PCIe info for {disk_name}, controller: {controller}")
pass
# Path to PCIe device in sysfs
sys_path = f'/sys/class/nvme/{controller}/device'
print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
# print(f"[v0] Checking sys_path: {sys_path}, exists: {os.path.exists(sys_path)}")
pass
if os.path.exists(sys_path):
try:
pci_address = os.path.basename(os.readlink(sys_path))
print(f"[v0] PCI address for {disk_name}: {pci_address}")
# print(f"[v0] PCI address for {disk_name}: {pci_address}")
pass
# Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(f"[v0] lspci output for {pci_address}:")
# print(f"[v0] lspci output for {pci_address}:")
pass
for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}")
# print(f"[v0] Found LnkSta: {line}")
pass
# Example: "LnkSta: Speed 8GT/s, Width x4"
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
@@ -990,17 +996,20 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
# print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
pass
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
# print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
pass
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
# print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1015,39 +1024,48 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_max_gen'] = '4.0'
else:
pcie_info['pcie_max_gen'] = '5.0'
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
# print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
pass
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
# print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
pass
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
# print(f"[v0] lspci failed with return code: {result.returncode}")
pass
except Exception as e:
print(f"[v0] Error getting PCIe info via lspci: {e}")
# print(f"[v0] Error getting PCIe info via lspci: {e}")
pass
import traceback
traceback.print_exc()
else:
print(f"[v0] sys_path does not exist: {sys_path}")
# print(f"[v0] sys_path does not exist: {sys_path}")
pass
alt_sys_path = f'/sys/block/{disk_name}/device/device'
print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
# print(f"[v0] Trying alternative path: {alt_sys_path}, exists: {os.path.exists(alt_sys_path)}")
pass
if os.path.exists(alt_sys_path):
try:
# Get PCI address from the alternative path
pci_address = os.path.basename(os.readlink(alt_sys_path))
print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
# print(f"[v0] PCI address from alt path for {disk_name}: {pci_address}")
pass
# Use lspci to get detailed PCIe information
result = subprocess.run(['lspci', '-vvv', '-s', pci_address],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(f"[v0] lspci output for {pci_address} (from alt path):")
# print(f"[v0] lspci output for {pci_address} (from alt path):")
pass
for line in result.stdout.split('\n'):
# Look for "LnkSta:" line which shows current link status
if 'LnkSta:' in line:
print(f"[v0] Found LnkSta: {line}")
# print(f"[v0] Found LnkSta: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1062,17 +1080,20 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_gen'] = '4.0'
else:
pcie_info['pcie_gen'] = '5.0'
print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
# print(f"[v0] Current PCIe gen: {pcie_info['pcie_gen']}")
pass
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_width'] = f'x{width_match.group(1)}'
print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
# print(f"[v0] Current PCIe width: {pcie_info['pcie_width']}")
pass
# Look for "LnkCap:" line which shows maximum capabilities
elif 'LnkCap:' in line:
print(f"[v0] Found LnkCap: {line}")
# print(f"[v0] Found LnkCap: {line}")
pass
if 'Speed' in line:
speed_match = re.search(r'Speed\s+([\d.]+)GT/s', line)
if speed_match:
@@ -1087,26 +1108,32 @@ def get_pcie_link_speed(disk_name):
pcie_info['pcie_max_gen'] = '4.0'
else:
pcie_info['pcie_max_gen'] = '5.0'
print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
# print(f"[v0] Max PCIe gen: {pcie_info['pcie_max_gen']}")
pass
if 'Width' in line:
width_match = re.search(r'Width\s+x(\d+)', line)
if width_match:
pcie_info['pcie_max_width'] = f'x{width_match.group(1)}'
print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
# print(f"[v0] Max PCIe width: {pcie_info['pcie_max_width']}")
pass
else:
print(f"[v0] lspci failed with return code: {result.returncode}")
# print(f"[v0] lspci failed with return code: {result.returncode}")
pass
except Exception as e:
print(f"[v0] Error getting PCIe info from alt path: {e}")
# print(f"[v0] Error getting PCIe info from alt path: {e}")
pass
import traceback
traceback.print_exc()
except Exception as e:
print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
# print(f"[v0] Error in get_pcie_link_speed for {disk_name}: {e}")
pass
import traceback
traceback.print_exc()
print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
# print(f"[v0] Final PCIe info for {disk_name}: {pcie_info}")
pass
return pcie_info
# get_pcie_link_speed function definition ends here