From 38f85dc4989493bc43dd22406ae0ca2f6b8db1c8 Mon Sep 17 00:00:00 2001 From: dedhersel Date: Tue, 17 Feb 2026 12:43:27 +0100 Subject: [PATCH] First Commit --- .env.example | 30 + .gitignore | 132 +++ README.md | 213 ++++ app.py | 1710 +++++++++++++++++++++++++++++++ config.py | 33 + create_database.py | 242 +++++ models.py | 408 ++++++++ requirements.txt | 8 + schema.sql | 68 ++ static/css/dashboard.css | 586 +++++++++++ static/css/theme.css | 674 ++++++++++++ templates/404.html | 17 + templates/500.html | 17 + templates/admin_dashboard.html | 361 +++++++ templates/admin_users.html | 411 ++++++++ templates/base.html | 367 +++++++ templates/cluster_topology.html | 1094 ++++++++++++++++++++ templates/console.html | 81 ++ templates/domains.html | 441 ++++++++ templates/index.html | 288 ++++++ templates/ipam.html | 690 +++++++++++++ templates/login.html | 203 ++++ templates/overview.html | 335 ++++++ templates/profile.html | 232 +++++ templates/system_logs.html | 439 ++++++++ templates/user_dashboard.html | 859 ++++++++++++++++ 26 files changed, 9939 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py create mode 100644 create_database.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 schema.sql create mode 100644 static/css/dashboard.css create mode 100644 static/css/theme.css create mode 100644 templates/404.html create mode 100644 templates/500.html create mode 100644 templates/admin_dashboard.html create mode 100644 templates/admin_users.html create mode 100644 templates/base.html create mode 100644 templates/cluster_topology.html create mode 100644 templates/console.html create mode 100644 templates/domains.html create mode 100644 templates/index.html create mode 100644 templates/ipam.html create mode 100644 templates/login.html create mode 100644 templates/overview.html create mode 100644 templates/profile.html create mode 100644 templates/system_logs.html create mode 100644 templates/user_dashboard.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5c3a10c --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Flask Configuration +SECRET_KEY=your-secret-key-change-this-in-production + +# Database Configuration +DB_HOST=localhost +DB_USER=root +DB_PASSWORD=your-database-password +DB_NAME=proxmox_manager + +# Proxmox Configuration +PROXMOX_IP=yourproxmoxip +PROXMOX_PORT=8006 +API_TOKEN_ID=root@pam!applicationname +API_TOKEN_SECRET=your-proxmox-api-token-secret + +# Proxmox Public URL (per console via reverse proxy) +PROXMOX_PUBLIC_URL=https://proxmox.gwserver.it + +# App Settings +MAX_BACKUPS_PER_USER=3 +SESSION_TIMEOUT_MINUTES=60 + +# Cloudflare Configuration (per gestione sottodomini DNS) +# Ottieni API Token da: https://dash.cloudflare.com/profile/api-tokens +# Permessi richiesti: Zone.DNS (Edit) +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here +# Trova Zone ID nella dashboard Cloudflare -> Seleziona dominio -> Overview (sidebar destra) +CLOUDFLARE_ZONE_ID=your-cloudflare-zone-id-here +# Il dominio principale su cui creare i sottodomini +CLOUDFLARE_DOMAIN=example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35004b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +.idea/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# app +.vsls.json +.vscode +.vs + +.claude \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad8e418 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Proxmox Manager + +Applicazione web per gestire macchine virtuali Proxmox VE con supporto multi-utente, backup, snapshot e DNS Cloudflare. + +--- + +## Prerequisiti + +Prima di iniziare, assicurati di avere installato: + +- **Python 3.10+** — [scarica qui](https://www.python.org/downloads/) +- **MySQL o MariaDB** — database relazionale +- **Accesso a un'istanza Proxmox VE** con API token configurato +- _(Opzionale)_ **Account Cloudflare** con API token per la gestione DNS + +--- + +## Installazione passo per passo + +### 1. Clona o scarica il progetto + +```bash +# Se usi Git: +git clone +cd PROXMOX_FINAL + +# Oppure estrai lo zip nella cartella desiderata +``` + +### 2. Crea un ambiente virtuale Python + +```bash +# Windows +python -m venv venv +venv\Scripts\activate + +# Linux / macOS +python3 -m venv venv +source venv/bin/activate +``` + +### 3. Installa le dipendenze + +```bash +pip install -r requirements.txt +``` + +### 4. Configura le variabili d'ambiente + +Copia il file di esempio e compila i tuoi dati: + +```bash +# Windows +copy .env.example .env + +# Linux / macOS +cp .env.example .env +``` + +Apri `.env` con un editor di testo e modifica questi campi: + +```env +# Chiave segreta Flask (cambiala con una stringa casuale lunga) +SECRET_KEY=cambia-questa-stringa-con-qualcosa-di-random + +# Database MySQL/MariaDB +DB_HOST=localhost +DB_USER=root +DB_PASSWORD=la-tua-password-mysql +DB_NAME=proxmox_manager + +# Proxmox VE +PROXMOX_IP=192.168.1.100 # IP del tuo server Proxmox +PROXMOX_PORT=8006 +API_TOKEN_ID=root@pam!mytoken # ID del token API Proxmox +API_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + +# URL pubblico per l'accesso console (via reverse proxy) +PROXMOX_PUBLIC_URL=https://proxmox.tuodominio.it + +# Impostazioni applicazione +MAX_BACKUPS_PER_USER=3 +SESSION_TIMEOUT_MINUTES=60 + +# Cloudflare (opzionale, solo per gestione sottodomini DNS) +CLOUDFLARE_API_TOKEN=il-tuo-token-cloudflare +CLOUDFLARE_ZONE_ID=il-tuo-zone-id +CLOUDFLARE_DOMAIN=tuodominio.it +``` + +### 5. Crea il database + +Esegui lo script Python che crea automaticamente l'intero database: + +```bash +python create_database.py +``` + +Lo script creerà: +- Il database `proxmox_manager` +- Tutte le tabelle necessarie +- Un utente **admin** di default + +**Credenziali di default:** +| Utente | Password | Ruolo | +|--------|----------|-------| +| `admin` | `admin123` | Amministratore | +| `testuser` | `test123` | Utente normale | + +> **IMPORTANTE:** Cambia le password al primo accesso! + +### 6. Avvia l'applicazione + +```bash +python app.py +``` + +Apri il browser e vai su: **http://localhost:5000** + +--- + +## Come ottenere il Token API di Proxmox + +1. Accedi alla tua interfaccia Proxmox (`https://IP-PROXMOX:8006`) +2. Vai in **Datacenter** → **Permissions** → **API Tokens** +3. Clicca **Add** e crea un token per l'utente `root@pam` +4. Copia il **Token ID** e il **Token Secret** +5. Incollali nel file `.env`: + ``` + API_TOKEN_ID=root@pam!nome-del-token + API_TOKEN_SECRET=il-secret-copiato + ``` + +--- + +## Come ottenere il Token API di Cloudflare (opzionale) + +Solo se vuoi usare la funzione di gestione sottodomini DNS: + +1. Accedi a [dash.cloudflare.com](https://dash.cloudflare.com) +2. Vai su **My Profile** → **API Tokens** → **Create Token** +3. Scegli il template **Edit zone DNS** +4. Seleziona il tuo dominio e crea il token +5. Copia il token e incollalo nel `.env` +6. Per il **Zone ID**: torna sulla dashboard, seleziona il dominio → sidebar destra → **Zone ID** + +--- + +## Struttura del progetto + +``` +PROXMOX_FINAL/ +├── app.py # Applicazione principale Flask (route e logica) +├── models.py # Modelli database (User, VM, Log, ecc.) +├── config.py # Configurazione (legge .env) +├── create_database.py # Script per creare il database +├── schema.sql # Schema SQL (riferimento) +├── requirements.txt # Dipendenze Python +├── .env.example # Template configurazione +├── .env # La tua configurazione (NON condividere!) +├── static/ +│ └── css/ # Fogli di stile +└── templates/ # Pagine HTML (Jinja2) +``` + +--- + +## Funzionalità principali + +| Funzionalità | Descrizione | +|---|---| +| **Dashboard VM** | Visualizza e gestisci le tue VM assegnate | +| **Start / Stop / Reboot** | Controlla lo stato delle VM | +| **Snapshot** | Crea e cancella snapshot (max 3 per utente) | +| **Backup** | Crea e gestisci backup (max configurabile) | +| **Console** | Accedi alla console della VM via browser | +| **Sottodomini DNS** | Crea/elimina record DNS su Cloudflare | +| **IPAM** | Gestione indirizzi IP delle VM | +| **Pannello Admin** | Gestione utenti, assegnazione VM, log audit | +| **Topologia Cluster** | Visualizzazione grafica del cluster Proxmox | + +--- + +## Risoluzione problemi + +**Errore di connessione al database:** +- Verifica che MySQL/MariaDB sia avviato +- Controlla le credenziali nel file `.env` +- Assicurati che l'utente MySQL abbia i permessi sul database + +**Errore di connessione a Proxmox:** +- Verifica che l'IP di Proxmox sia raggiungibile +- Controlla che il token API sia valido e non scaduto +- L'applicazione ignora gli errori SSL (certificato self-signed Proxmox è normale) + +**Login non funziona con admin/admin123:** +- Assicurati di aver eseguito `python create_database.py` +- Prova a eseguire nuovamente lo script, sovrascriverà i dati esistenti + +**La porta 5000 è già in uso:** +- Su macOS la porta 5000 è occupata da AirPlay. Modifica in `app.py` la riga finale: + ```python + app.run(host='0.0.0.0', port=5001, debug=True) + ``` + +--- + +## Note di sicurezza + +- Non esporre l'applicazione su internet senza un reverse proxy (nginx/caddy) con HTTPS +- Cambia `SECRET_KEY` con una stringa casuale sicura +- Non committare mai il file `.env` su Git (è già in `.gitignore`) +- Cambia le password di default dopo il primo accesso diff --git a/app.py b/app.py new file mode 100644 index 0000000..b8e147e --- /dev/null +++ b/app.py @@ -0,0 +1,1710 @@ +from flask import Flask, render_template, jsonify, request, redirect, url_for, session, flash, Response +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +from functools import wraps +import requests +import urllib3 +import traceback +import json +import queue +import time +import subprocess +import platform +from datetime import datetime, timedelta +from config import Config +from models import User, UserVM, ActionLog, Subdomain, IPAssignment, get_db_connection + +# Notification queues per user +notification_queues = {} + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +app = Flask(__name__) +app.config.from_object(Config) + +# Flask-Login setup +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' +login_manager.login_message = 'Effettua il login per accedere a questa pagina.' + +# Proxmox API setup +BASE_URL = f"https://{Config.PROXMOX_IP}:{Config.PROXMOX_PORT}/api2/json" +HEADERS = { + "Authorization": f"PVEAPIToken={Config.API_TOKEN_ID}={Config.API_TOKEN_SECRET}" +} + +# User class per Flask-Login +class FlaskUser(UserMixin): + def __init__(self, user_data): + self.id = user_data['id'] + self.username = user_data['username'] + self.email = user_data['email'] + self.is_admin = user_data['is_admin'] + +@login_manager.user_loader +def load_user(user_id): + user_data = User.get_by_id(int(user_id)) + if user_data: + return FlaskUser(user_data) + return None + +# Decorator per verificare se l'utente è admin +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash('Accesso negato. Privilegi di amministratore richiesti.', 'error') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated_function + +# Helper per logging azioni +def log_user_action(vm_id, action_type, status, error_message=None): + try: + ActionLog.log_action( + user_id=current_user.id, + vm_id=vm_id, + action_type=action_type, + status=status, + error_message=error_message, + ip_address=request.remote_addr + ) + except Exception as e: + app.logger.error(f"Errore logging azione: {e}") + +# Helper per chiamate Proxmox API +def proxmox_api_call(method, endpoint, data=None, params=None, timeout=30): + """Helper per chiamate all'API Proxmox""" + url = f"{BASE_URL}{endpoint}" + # Timeout più lungo per operazioni su storage di rete (CIFS/NFS) + if '/storage/' in endpoint and '/content' in endpoint: + timeout = 120 + try: + if method.upper() == 'GET': + response = requests.get(url, headers=HEADERS, params=params, verify=False, timeout=timeout) + elif method.upper() == 'POST': + response = requests.post(url, headers=HEADERS, data=data, verify=False, timeout=timeout) + elif method.upper() == 'PUT': + response = requests.put(url, headers=HEADERS, data=data, verify=False, timeout=timeout) + elif method.upper() == 'DELETE': + response = requests.delete(url, headers=HEADERS, verify=False, timeout=timeout) + else: + return None, "Metodo HTTP non supportato" + + response.raise_for_status() + return response.json(), None + except requests.exceptions.RequestException as e: + return None, str(e) + +# ==================== ROUTES AUTENTICAZIONE ==================== + +@app.route('/') +def index(): + if current_user.is_authenticated: + return redirect(url_for('dashboard')) + return redirect(url_for('login')) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('dashboard')) + + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + remember = request.form.get('remember', False) + + if User.verify_password(username, password): + user_data = User.get_by_username(username) + user = FlaskUser(user_data) + login_user(user, remember=remember) + User.update_last_login(user.id) + + # Log del login + ActionLog.log_action(user.id, 0, 'login', 'success', ip_address=request.remote_addr) + + flash(f'Benvenuto, {user.username}!', 'success') + + next_page = request.args.get('next') + return redirect(next_page or url_for('dashboard')) + else: + flash('Username o password non validi.', 'error') + + return render_template('login.html') + +@app.route('/logout') +@login_required +def logout(): + logout_user() + flash('Logout effettuato con successo.', 'success') + return redirect(url_for('login')) + +@app.route('/profile') +@login_required +def profile(): + """Pagina profilo utente""" + return render_template('profile.html', user=current_user) + +@app.route('/domains') +@login_required +def domains(): + """Pagina gestione domini""" + return render_template('domains.html', user=current_user, domain=Config.CLOUDFLARE_DOMAIN) + +@app.route('/api/profile/update-email', methods=['POST']) +@login_required +def update_email(): + """Aggiorna email utente""" + try: + data = request.get_json() + new_email = data.get('email') + password = data.get('password') + + if not new_email or not password: + return jsonify({'status': 'error', 'message': 'Email e password richiesti'}), 400 + + # Verifica password corrente + if not User.verify_password(current_user.username, password): + return jsonify({'status': 'error', 'message': 'Password non corretta'}), 403 + + # Verifica che l'email non sia già usata + existing_user = User.get_by_username(new_email) # Check if email exists as username + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute("SELECT id FROM users WHERE email = %s AND id != %s", (new_email, current_user.id)) + if cursor.fetchone(): + return jsonify({'status': 'error', 'message': 'Email già in uso'}), 400 + finally: + conn.close() + + # Aggiorna email + if User.update_email(current_user.id, new_email): + return jsonify({'status': 'success', 'message': 'Email aggiornata con successo'}) + else: + return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 + + except Exception as e: + app.logger.error(f"Errore update_email: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/profile/update-password', methods=['POST']) +@login_required +def update_password(): + """Aggiorna password utente""" + try: + data = request.get_json() + current_password = data.get('current_password') + new_password = data.get('new_password') + confirm_password = data.get('confirm_password') + + if not current_password or not new_password or not confirm_password: + return jsonify({'status': 'error', 'message': 'Tutti i campi sono richiesti'}), 400 + + # Verifica password corrente + if not User.verify_password(current_user.username, current_password): + return jsonify({'status': 'error', 'message': 'Password corrente non corretta'}), 403 + + # Verifica che le nuove password corrispondano + if new_password != confirm_password: + return jsonify({'status': 'error', 'message': 'Le nuove password non corrispondono'}), 400 + + # Verifica lunghezza minima + if len(new_password) < 6: + return jsonify({'status': 'error', 'message': 'La password deve essere di almeno 6 caratteri'}), 400 + + # Aggiorna password + if User.update_password(current_user.id, new_password): + return jsonify({'status': 'success', 'message': 'Password aggiornata con successo'}) + else: + return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 + + except Exception as e: + app.logger.error(f"Errore update_password: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/dashboard') +@login_required +def dashboard(): + if current_user.is_admin: + return render_template('admin_dashboard.html', user=current_user) + else: + return render_template('user_dashboard.html', user=current_user) + +# ==================== API ROUTES - VM MANAGEMENT ==================== + +@app.route('/api/my-vms') +@login_required +def get_my_vms(): + """Recupera le VM dell'utente loggato con stato da Proxmox""" + try: + user_vms = UserVM.get_user_vms(current_user.id) + app.logger.info(f"User {current_user.id} has {len(user_vms)} VMs assigned: {user_vms}") + + # Recupera info da Proxmox per ogni VM + vms_with_status = [] + for vm in user_vms: + vm_id = vm['vm_id'] + + # Cerca la VM su tutti i nodi + app.logger.info(f"Fetching nodes list from Proxmox...") + nodes_data, error = proxmox_api_call('GET', '/nodes') + + if error: + app.logger.error(f"Error fetching nodes: {error}") + continue + + if not nodes_data or 'data' not in nodes_data: + app.logger.error(f"Invalid nodes response: {nodes_data}") + continue + + app.logger.info(f"Found {len(nodes_data.get('data', []))} nodes: {[n.get('node') for n in nodes_data.get('data', [])]}") + + vm_info = None + for node in nodes_data.get('data', []): + node_name = node['node'] + app.logger.info(f"Checking node: {node_name} for VM {vm_id}") + + # Prova QEMU + qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') + if qemu_data and 'data' in qemu_data: + vm_info = qemu_data['data'] + vm_info['type'] = 'qemu' + vm_info['node'] = node_name + app.logger.info(f"Found QEMU VM {vm_id} on node {node_name}") + break + + # Prova LXC + lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') + if lxc_data and 'data' in lxc_data: + vm_info = lxc_data['data'] + vm_info['type'] = 'lxc' + vm_info['node'] = node_name + app.logger.info(f"Found LXC VM {vm_id} on node {node_name}") + break + + if vm_info: + vms_with_status.append({ + 'vm_id': vm_id, + 'vm_name': vm['vm_name'] or vm_info.get('name', f'VM-{vm_id}'), + 'notes': vm['notes'], + 'ip_address': vm.get('ip_address'), + 'status': vm_info.get('status', 'unknown'), + 'uptime': vm_info.get('uptime', 0), + 'cpu': vm_info.get('cpu', 0), + 'mem': vm_info.get('mem', 0), + 'maxmem': vm_info.get('maxmem', 0), + 'disk': vm_info.get('disk', 0), + 'maxdisk': vm_info.get('maxdisk', 0), + 'type': vm_info['type'], + 'node': vm_info['node'] + }) + else: + app.logger.warning(f"VM {vm_id} not found in Proxmox!") + vms_with_status.append({ + 'vm_id': vm_id, + 'vm_name': vm['vm_name'] or f'VM-{vm_id}', + 'notes': vm['notes'], + 'ip_address': vm.get('ip_address'), + 'status': 'unknown', + 'type': 'unknown', + 'node': 'unknown', + 'cpu': 0, + 'mem': 0, + 'maxmem': 0, + 'disk': 0, + 'maxdisk': 0 + }) + + return jsonify({ + 'status': 'success', + 'data': vms_with_status + }) + except Exception as e: + app.logger.error(f"Errore get_my_vms: {traceback.format_exc()}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + +@app.route('/api/vm//status') +@login_required +def get_vm_status(vm_id): + """Recupera lo stato di una VM specifica con metriche real-time""" + try: + # Verifica che l'utente abbia accesso + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Cerca la VM su tutti i nodi + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') + if qemu_data and 'data' in qemu_data: + vm_info = qemu_data['data'] + + # Recupera info sui dischi + config_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/config') + if config_data and 'data' in config_data: + vm_info['config'] = config_data['data'] + + return jsonify({'status': 'success', 'data': vm_info, 'node': node_name, 'type': 'qemu'}) + + # Prova LXC + lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') + if lxc_data and 'data' in lxc_data: + vm_info = lxc_data['data'] + + # Recupera info sui dischi + config_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/config') + if config_data and 'data' in config_data: + vm_info['config'] = config_data['data'] + + return jsonify({'status': 'success', 'data': vm_info, 'node': node_name, 'type': 'lxc'}) + + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + app.logger.error(f"Errore get_vm_status: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//rrddata') +@login_required +def get_vm_rrddata(vm_id): + """Recupera dati RRD per grafici in tempo reale""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + timeframe = request.args.get('timeframe', 'hour') + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + rrd_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/rrddata', + params={'timeframe': timeframe}) + if rrd_data and 'data' in rrd_data: + return jsonify({'status': 'success', 'data': rrd_data['data']}) + + # Prova LXC + rrd_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/rrddata', + params={'timeframe': timeframe}) + if rrd_data and 'data' in rrd_data: + return jsonify({'status': 'success', 'data': rrd_data['data']}) + + return jsonify({'status': 'error', 'message': 'Dati non disponibili'}), 404 + + except Exception as e: + app.logger.error(f"Errore get_vm_rrddata: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== SNAPSHOT MANAGEMENT ==================== + +@app.route('/api/vm//snapshots') +@login_required +def get_vm_snapshots(vm_id): + """Recupera le snapshot di una VM (max 3 per utenti normali)""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/snapshot') + if snap_data and 'data' in snap_data: + snapshots = [s for s in snap_data['data'] if s.get('name') != 'current'] + + # Limita a 3 per utenti normali + if not current_user.is_admin: + snapshots = snapshots[:3] + + return jsonify({ + 'status': 'success', + 'count': len(snapshots), + 'data': snapshots, + 'node': node_name, + 'type': 'qemu' + }) + + # Prova LXC + snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/snapshot') + if snap_data and 'data' in snap_data: + snapshots = [s for s in snap_data['data'] if s.get('name') != 'current'] + + if not current_user.is_admin: + snapshots = snapshots[:3] + + return jsonify({ + 'status': 'success', + 'count': len(snapshots), + 'data': snapshots, + 'node': node_name, + 'type': 'lxc' + }) + + return jsonify({'status': 'success', 'count': 0, 'data': []}), 200 + + except Exception as e: + app.logger.error(f"Errore get_vm_snapshots: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//snapshot', methods=['POST']) +@login_required +def create_snapshot(vm_id): + """Crea una snapshot di una VM""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + data = request.get_json() + snapname = data.get('snapname', f'snap-{datetime.now().strftime("%Y%m%d-%H%M%S")}') + description = data.get('description', '') + + # Controlla se l'utente ha già 3 snapshot (se non admin) + if not current_user.is_admin: + nodes_data, _ = proxmox_api_call('GET', '/nodes') + if nodes_data: + for node in nodes_data.get('data', []): + node_name = node['node'] + snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/snapshot') + if not snap_data: + snap_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/snapshot') + + if snap_data and 'data' in snap_data: + existing_snaps = [s for s in snap_data['data'] if s.get('name') != 'current'] + if len(existing_snaps) >= 3: + return jsonify({ + 'status': 'error', + 'message': 'Hai raggiunto il limite di 3 snapshot. Elimina una snapshot esistente.' + }), 400 + + # Trova il nodo della VM e crea la snapshot + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'snapshot', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/snapshot', + data={'snapname': snapname, 'description': description}) + if result: + log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} creata') + return jsonify({'status': 'success', 'message': 'Snapshot creata con successo', 'snapname': snapname}) + + # Prova LXC + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/snapshot', + data={'snapname': snapname, 'description': description}) + if result: + log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} creata') + return jsonify({'status': 'success', 'message': 'Snapshot creata con successo', 'snapname': snapname}) + + log_user_action(vm_id, 'snapshot', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'snapshot', 'failed', str(e)) + app.logger.error(f"Errore create_snapshot: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//snapshot/', methods=['DELETE']) +@login_required +def delete_snapshot(vm_id, snapname): + """Elimina una snapshot""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('DELETE', f'/nodes/{node_name}/qemu/{vm_id}/snapshot/{snapname}') + if result: + log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} eliminata') + return jsonify({'status': 'success', 'message': 'Snapshot eliminata con successo'}) + + # Prova LXC + result, error = proxmox_api_call('DELETE', f'/nodes/{node_name}/lxc/{vm_id}/snapshot/{snapname}') + if result: + log_user_action(vm_id, 'snapshot', 'success', error_message=f'Snapshot {snapname} eliminata') + return jsonify({'status': 'success', 'message': 'Snapshot eliminata con successo'}) + + return jsonify({'status': 'error', 'message': 'Snapshot non trovata'}), 404 + + except Exception as e: + app.logger.error(f"Errore delete_snapshot: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//shutdown', methods=['POST']) +@login_required +def shutdown_vm(vm_id): + """Spegne una VM (graceful shutdown)""" + try: + # Verifica accesso + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Trova il nodo della VM + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'shutdown', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/shutdown') + if result: + log_user_action(vm_id, 'shutdown', 'success') + send_notification(current_user.id, 'success', f'VM {vm_id} in fase di spegnimento') + return jsonify({'status': 'success', 'message': 'VM spenta con successo'}) + + # Prova LXC + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/shutdown') + if result: + log_user_action(vm_id, 'shutdown', 'success') + send_notification(current_user.id, 'success', f'Container {vm_id} in fase di spegnimento') + return jsonify({'status': 'success', 'message': 'Container spento con successo'}) + + log_user_action(vm_id, 'shutdown', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'shutdown', 'failed', str(e)) + app.logger.error(f"Errore shutdown_vm: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//stop', methods=['POST']) +@login_required +def stop_vm(vm_id): + """Ferma forzatamente una VM (hard stop)""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'stop', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/stop') + if result: + log_user_action(vm_id, 'stop', 'success') + return jsonify({'status': 'success', 'message': 'VM fermata con successo'}) + + # Prova LXC + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/stop') + if result: + log_user_action(vm_id, 'stop', 'success') + return jsonify({'status': 'success', 'message': 'Container fermato con successo'}) + + log_user_action(vm_id, 'stop', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'stop', 'failed', str(e)) + app.logger.error(f"Errore stop_vm: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//start', methods=['POST']) +@login_required +def start_vm(vm_id): + """Avvia una VM""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'start', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/start') + if result: + log_user_action(vm_id, 'start', 'success') + send_notification(current_user.id, 'success', f'VM {vm_id} avviata con successo') + return jsonify({'status': 'success', 'message': 'VM avviata con successo'}) + + # Prova LXC + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/start') + if result: + log_user_action(vm_id, 'start', 'success') + send_notification(current_user.id, 'success', f'Container {vm_id} avviato con successo') + return jsonify({'status': 'success', 'message': 'Container avviato con successo'}) + + log_user_action(vm_id, 'start', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'start', 'failed', str(e)) + app.logger.error(f"Errore start_vm: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//reboot', methods=['POST']) +@login_required +def reboot_vm(vm_id): + """Riavvia una VM""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'restart', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/status/reboot') + if result: + log_user_action(vm_id, 'restart', 'success') + send_notification(current_user.id, 'success', f'VM {vm_id} riavviata con successo') + return jsonify({'status': 'success', 'message': 'VM riavviata con successo'}) + + # Prova LXC + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/status/reboot') + if result: + log_user_action(vm_id, 'restart', 'success') + send_notification(current_user.id, 'success', f'Container {vm_id} riavviato con successo') + return jsonify({'status': 'success', 'message': 'Container riavviato con successo'}) + + log_user_action(vm_id, 'restart', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'restart', 'failed', str(e)) + app.logger.error(f"Errore reboot_vm: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== API ROUTES - BACKUP MANAGEMENT ==================== + +@app.route('/api/vm//backups') +@login_required +def get_vm_backups(vm_id): + """Recupera i backup di una VM specifica (max 3 per utenti normali)""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + vm_backups = [] + + for node in nodes_data.get('data', []): + node_name = node['node'] + app.logger.info(f"[BACKUP DEBUG] Checking node: {node_name}") + + storage_data, storage_error = proxmox_api_call('GET', f'/nodes/{node_name}/storage') + if not storage_data: + app.logger.warning(f"[BACKUP DEBUG] No storage data for node {node_name}, error: {storage_error}") + continue + + for storage in storage_data.get('data', []): + storage_name = storage.get('storage') + storage_content = storage.get('content', '') + app.logger.info(f"[BACKUP DEBUG] Storage: {storage_name}, content: {storage_content}") + + if not storage_name or 'backup' not in storage_content: + app.logger.info(f"[BACKUP DEBUG] Skipping storage {storage_name} - no backup content") + continue + + content_data, content_error = proxmox_api_call('GET', f'/nodes/{node_name}/storage/{storage_name}/content', params={'content': 'backup'}) + if not content_data: + app.logger.warning(f"[BACKUP DEBUG] No content for storage {storage_name}, error: {content_error}") + continue + + backups = content_data.get('data', []) + app.logger.info(f"[BACKUP DEBUG] Storage {storage_name} has {len(backups)} backups") + + for backup in backups: + volid = backup.get('volid', '') + app.logger.info(f"[BACKUP DEBUG] Checking backup volid: {volid} for VM {vm_id}") + if f"-{vm_id}-" in volid: + backup['node'] = node_name + backup['storage'] = storage_name + vm_backups.append(backup) + app.logger.info(f"[BACKUP DEBUG] MATCH! Added backup for VM {vm_id}") + + # Ordina per data (più recente prima) + vm_backups.sort(key=lambda x: x.get('ctime', 0), reverse=True) + + return jsonify({ + 'status': 'success', + 'vmid': vm_id, + 'count': len(vm_backups), + 'data': vm_backups + }) + + except Exception as e: + app.logger.error(f"Errore get_vm_backups: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//backup', methods=['POST']) +@login_required +def create_backup(vm_id): + """Crea un backup di una VM""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Controlla se l'utente ha già 3 backup (se non admin) + if not current_user.is_admin: + nodes_data, _ = proxmox_api_call('GET', '/nodes') + if nodes_data: + backup_count = 0 + for node in nodes_data.get('data', []): + node_name = node['node'] + storage_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/storage') + if storage_data: + for storage in storage_data.get('data', []): + storage_name = storage.get('storage') + if storage_name and 'backup' in storage.get('content', ''): + content_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/storage/{storage_name}/content', params={'content': 'backup'}) + if content_data: + for backup in content_data.get('data', []): + if f"-{vm_id}-" in backup.get('volid', ''): + backup_count += 1 + + if backup_count >= Config.MAX_BACKUPS_PER_USER: + return jsonify({ + 'status': 'error', + 'message': f'Hai raggiunto il limite di {Config.MAX_BACKUPS_PER_USER} backup. Elimina un backup esistente prima di crearne uno nuovo.' + }), 400 + + # Trova il nodo della VM e crea il backup + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + log_user_action(vm_id, 'backup', 'failed', error) + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Verifica che la VM esista + qemu_exists, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') + lxc_exists, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') + + if qemu_exists or lxc_exists: + # Usa modalità snapshot: Proxmox crea snapshot temporanea, fa backup, e la rimuove automaticamente + backup_data = { + 'vmid': vm_id, + 'mode': 'snapshot', # Crea snapshot temporanea automaticamente + 'compress': 'zstd', + 'storage': 'hetzner-backup01', + 'remove': 1 # IMPORTANTE: Rimuove la snapshot temporanea dopo il backup + } + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/vzdump', data=backup_data) + if result: + log_user_action(vm_id, 'backup', 'success') + return jsonify({ + 'status': 'success', + 'message': 'Backup avviato! La VM rimarrà in esecuzione. La snapshot temporanea verrà rimossa automaticamente.', + 'data': result + }) + else: + log_user_action(vm_id, 'backup', 'failed', error) + return jsonify({'status': 'error', 'message': error or 'Errore durante creazione backup'}), 500 + + log_user_action(vm_id, 'backup', 'failed', 'VM non trovata') + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + log_user_action(vm_id, 'backup', 'failed', str(e)) + app.logger.error(f"Errore create_backup: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/backup////delete', methods=['DELETE']) +@login_required +def delete_backup(node, storage, volid): + """Elimina un backup""" + try: + # Estrai VM ID dal volid per verificare permessi + # Format: storage:backup/vzdump-qemu-104-2025_11_24-14_30_00.vma.zst + vm_id_match = volid.split('-') + if len(vm_id_match) >= 3: + try: + vm_id = int(vm_id_match[2]) + except (ValueError, IndexError): + return jsonify({'status': 'error', 'message': 'VM ID non valido nel backup'}), 400 + else: + return jsonify({'status': 'error', 'message': 'Formato backup non valido'}), 400 + + # Verifica accesso + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Elimina il backup + full_volid = f"{storage}:backup/{volid}" if not volid.startswith(storage) else volid + + result, error = proxmox_api_call('DELETE', f'/nodes/{node}/storage/{storage}/content/{full_volid}') + + if result: + log_user_action(vm_id, 'backup', 'success', error_message=f'Backup {volid} eliminato') + return jsonify({'status': 'success', 'message': 'Backup eliminato con successo'}) + else: + log_user_action(vm_id, 'backup', 'failed', error) + return jsonify({'status': 'error', 'message': error or 'Errore durante eliminazione'}), 500 + + except Exception as e: + app.logger.error(f"Errore delete_backup: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/vm//console') +@login_required +def get_vm_console(vm_id): + """Ottieni URL console VNC/SPICE per la VM""" + try: + if not current_user.is_admin and not UserVM.user_has_vm(current_user.id, vm_id): + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU - usa vncproxy + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/qemu/{vm_id}/vncproxy', + data={'websocket': 1}) + if result and 'data' in result: + console_data = result['data'] + ticket = console_data.get('ticket', '') + port = console_data.get('port', '') + upid = console_data.get('upid', '') + + # Costruisci URL per noVNC - usa URL pubblico via reverse proxy + console_url = ( + f"{Config.PROXMOX_PUBLIC_URL}/" + f"?console=kvm&novnc=1&vmid={vm_id}&vmname=VM-{vm_id}" + f"&node={node_name}&resize=scale&cmd=" + ) + + return jsonify({ + 'status': 'success', + 'console_url': console_url, + 'ticket': ticket, + 'type': 'vnc', + 'data': console_data + }) + + # Prova LXC - usa termproxy + result, error = proxmox_api_call('POST', f'/nodes/{node_name}/lxc/{vm_id}/termproxy') + if result and 'data' in result: + console_data = result['data'] + ticket = console_data.get('ticket', '') + port = console_data.get('port', '') + + # Costruisci URL per xterm.js - usa URL pubblico via reverse proxy + console_url = ( + f"{Config.PROXMOX_PUBLIC_URL}/" + f"?console=lxc&xtermjs=1&vmid={vm_id}&vmname=CT-{vm_id}" + f"&node={node_name}&resize=scale&cmd=" + ) + + return jsonify({ + 'status': 'success', + 'console_url': console_url, + 'ticket': ticket, + 'type': 'shell', + 'data': console_data + }) + + return jsonify({'status': 'error', 'message': 'VM non trovata'}), 404 + + except Exception as e: + app.logger.error(f"Errore get_vm_console: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== SUBDOMAIN MANAGEMENT (CLOUDFLARE) ==================== + +@app.route('/api/subdomains') +@login_required +def get_user_subdomains(): + """Recupera i sottodomini dell'utente loggato""" + try: + subdomains = Subdomain.get_user_subdomains(current_user.id) + return jsonify({'status': 'success', 'data': subdomains}) + except Exception as e: + app.logger.error(f"Errore get_user_subdomains: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/subdomain/create', methods=['POST']) +@login_required +def create_subdomain(): + """Crea un nuovo sottodominio su Cloudflare""" + try: + data = request.get_json() + subdomain = data.get('subdomain', '').strip().lower() + ip_address = data.get('ip_address', '').strip() + vm_id = data.get('vm_id') + proxied = data.get('proxied', True) + + if not subdomain or not ip_address: + return jsonify({'status': 'error', 'message': 'Sottodominio e IP richiesti'}), 400 + + # Validazione sottodominio (solo caratteri alfanumerici e trattini) + import re + if not re.match(r'^[a-z0-9-]+$', subdomain): + return jsonify({'status': 'error', 'message': 'Sottodominio non valido. Usa solo lettere, numeri e trattini.'}), 400 + + # Verifica se esiste già + if Subdomain.subdomain_exists(subdomain): + return jsonify({'status': 'error', 'message': 'Questo sottodominio esiste già'}), 400 + + # Chiama API Cloudflare + if not Config.CLOUDFLARE_API_TOKEN or not Config.CLOUDFLARE_ZONE_ID: + return jsonify({'status': 'error', 'message': 'Cloudflare non configurato'}), 500 + + import cloudflare + cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) + + full_domain = f'{subdomain}.{Config.CLOUDFLARE_DOMAIN}' + + # Crea record DNS su Cloudflare + record = cf.dns.records.create( + zone_id=Config.CLOUDFLARE_ZONE_ID, + type='A', + name=full_domain, + content=ip_address, + ttl=1, + proxied=proxied + ) + + # Salva nel database + subdomain_id = Subdomain.create( + user_id=current_user.id, + subdomain=subdomain, + ip_address=ip_address, + vm_id=vm_id, + proxied=proxied, + cloudflare_record_id=record.id + ) + + log_user_action(vm_id or 0, 'subdomain_create', 'success', error_message=f'Sottodominio {full_domain} creato') + + return jsonify({ + 'status': 'success', + 'message': f'Sottodominio {full_domain} creato con successo!', + 'subdomain_id': subdomain_id, + 'full_domain': full_domain + }) + + except cloudflare.APIError as e: + app.logger.error(f"Errore Cloudflare: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': f'Errore Cloudflare: {str(e)}'}), 500 + except Exception as e: + app.logger.error(f"Errore create_subdomain: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/subdomain//delete', methods=['DELETE']) +@login_required +def delete_subdomain(subdomain_id): + """Elimina un sottodominio""" + try: + # Recupera il sottodominio + subdomain_data = Subdomain.get_by_id(subdomain_id) + + if not subdomain_data: + return jsonify({'status': 'error', 'message': 'Sottodominio non trovato'}), 404 + + # Verifica ownership + if subdomain_data['user_id'] != current_user.id and not current_user.is_admin: + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Elimina da Cloudflare + if subdomain_data['cloudflare_record_id'] and Config.CLOUDFLARE_API_TOKEN: + try: + import cloudflare + cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) + cf.dns.records.delete( + zone_id=Config.CLOUDFLARE_ZONE_ID, + dns_record_id=subdomain_data['cloudflare_record_id'] + ) + except cloudflare.APIError as e: + app.logger.warning(f"Errore eliminazione Cloudflare: {e}") + + # Elimina dal database + if Subdomain.delete(subdomain_id, current_user.id if not current_user.is_admin else subdomain_data['user_id']): + log_user_action( + subdomain_data.get('vm_id', 0) or 0, + 'subdomain_delete', + 'success', + error_message=f'Sottodominio {subdomain_data["subdomain"]} eliminato' + ) + return jsonify({'status': 'success', 'message': 'Sottodominio eliminato con successo'}) + else: + return jsonify({'status': 'error', 'message': 'Errore durante eliminazione'}), 500 + + except Exception as e: + app.logger.error(f"Errore delete_subdomain: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/subdomain//update-ip', methods=['PUT']) +@login_required +def update_subdomain_ip(subdomain_id): + """Aggiorna l'IP di un sottodominio""" + try: + data = request.get_json() + new_ip = data.get('ip_address', '').strip() + + if not new_ip: + return jsonify({'status': 'error', 'message': 'IP richiesto'}), 400 + + subdomain_data = Subdomain.get_by_id(subdomain_id) + + if not subdomain_data: + return jsonify({'status': 'error', 'message': 'Sottodominio non trovato'}), 404 + + if subdomain_data['user_id'] != current_user.id and not current_user.is_admin: + return jsonify({'status': 'error', 'message': 'Accesso negato'}), 403 + + # Aggiorna su Cloudflare + if subdomain_data['cloudflare_record_id'] and Config.CLOUDFLARE_API_TOKEN: + try: + import cloudflare + cf = cloudflare.Cloudflare(api_token=Config.CLOUDFLARE_API_TOKEN) + full_domain = f"{subdomain_data['subdomain']}.{Config.CLOUDFLARE_DOMAIN}" + + cf.dns.records.update( + zone_id=Config.CLOUDFLARE_ZONE_ID, + dns_record_id=subdomain_data['cloudflare_record_id'], + type='A', + name=full_domain, + content=new_ip, + ttl=1, + proxied=subdomain_data['proxied'] + ) + except cloudflare.APIError as e: + return jsonify({'status': 'error', 'message': f'Errore Cloudflare: {str(e)}'}), 500 + + # Aggiorna nel database + if Subdomain.update_ip(subdomain_id, current_user.id if not current_user.is_admin else subdomain_data['user_id'], new_ip): + return jsonify({'status': 'success', 'message': 'IP aggiornato con successo'}) + else: + return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 + + except Exception as e: + app.logger.error(f"Errore update_subdomain_ip: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== ADMIN ROUTES ==================== + +@app.route('/admin/users') +@login_required +@admin_required +def admin_users(): + """Pannello admin - gestione utenti""" + return render_template('admin_users.html', user=current_user) + +@app.route('/api/admin/users') +@login_required +@admin_required +def api_admin_get_users(): + """API admin - lista utenti""" + try: + users = User.get_all_users() + return jsonify({'status': 'success', 'data': users}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/user//vms') +@login_required +@admin_required +def api_admin_get_user_vms(user_id): + """API admin - VM di un utente""" + try: + vms = UserVM.get_user_vms(user_id) + return jsonify({'status': 'success', 'data': vms}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/user//assign-vm', methods=['POST']) +@login_required +@admin_required +def api_admin_assign_vm(user_id): + """API admin - assegna VM a utente""" + try: + data = request.get_json() + vm_id = data.get('vm_id') + vm_name = data.get('vm_name') + notes = data.get('notes') + + if not vm_id: + return jsonify({'status': 'error', 'message': 'VM ID richiesto'}), 400 + + UserVM.assign_vm(user_id, vm_id, vm_name, notes) + return jsonify({'status': 'success', 'message': 'VM assegnata con successo'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/user//remove-vm/', methods=['DELETE']) +@login_required +@admin_required +def api_admin_remove_vm(user_id, vm_id): + """API admin - rimuove VM da utente""" + try: + UserVM.remove_vm(user_id, vm_id) + return jsonify({'status': 'success', 'message': 'VM rimossa con successo'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/vm//update-ip', methods=['PUT']) +@login_required +@admin_required +def api_admin_update_vm_ip(vm_id): + """API admin - aggiorna IP di una VM""" + try: + data = request.get_json() + ip_address = data.get('ip_address', '').strip() + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE user_vms SET ip_address = %s WHERE vm_id = %s" + cursor.execute(sql, (ip_address if ip_address else None, vm_id)) + return jsonify({'status': 'success', 'message': 'IP aggiornato con successo'}) + finally: + conn.close() + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/user/create', methods=['POST']) +@login_required +@admin_required +def api_admin_create_user(): + """API admin - crea nuovo utente""" + try: + data = request.get_json() + username = data.get('username') + email = data.get('email') + password = data.get('password') + is_admin = data.get('is_admin', False) + + if not username or not email or not password: + return jsonify({'status': 'error', 'message': 'Username, email e password richiesti'}), 400 + + user_id = User.create(username, email, password, is_admin) + return jsonify({'status': 'success', 'message': 'Utente creato con successo', 'user_id': user_id}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/user//toggle-status', methods=['POST']) +@login_required +@admin_required +def api_admin_toggle_user_status(user_id): + """API admin - attiva/disattiva utente""" + try: + data = request.get_json() + active = data.get('active', True) + + # Non permettere di disattivare se stesso + if user_id == current_user.id: + return jsonify({'status': 'error', 'message': 'Non puoi disattivare il tuo stesso account'}), 400 + + if User.update_active_status(user_id, active): + action = 'attivato' if active else 'disattivato' + return jsonify({'status': 'success', 'message': f'Utente {action} con successo'}) + else: + return jsonify({'status': 'error', 'message': 'Errore durante aggiornamento'}), 500 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/logs') +@login_required +@admin_required +def api_admin_get_logs(): + """API admin - recupera tutti i log""" + try: + limit = request.args.get('limit', 100, type=int) + logs = ActionLog.get_all_logs(limit) + return jsonify({'status': 'success', 'data': logs}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/stats') +@login_required +@admin_required +def api_admin_get_stats(): + """API admin - statistiche sistema""" + try: + # Conta utenti + users = User.get_all_users() + total_users = len(users) + + # Conta VM totali e attive + all_vms = UserVM.get_all_assignments() + total_vms = len(set(vm['vm_id'] for vm in all_vms)) # VM uniche + + # Conta VM attive controllando Proxmox + active_vms = 0 + nodes_data, _ = proxmox_api_call('GET', '/cluster/resources') + if nodes_data and 'data' in nodes_data: + for resource in nodes_data['data']: + if resource.get('type') in ['qemu', 'lxc'] and resource.get('status') == 'running': + active_vms += 1 + + # Conta azioni oggi + logs = ActionLog.get_all_logs(1000) + today = datetime.now().date() + actions_today = sum(1 for log in logs if datetime.fromisoformat(str(log['created_at'])).date() == today) + + return jsonify({ + 'status': 'success', + 'data': { + 'total_users': total_users, + 'total_vms': total_vms, + 'active_vms': active_vms, + 'actions_today': actions_today + } + }) + except Exception as e: + app.logger.error(f"Errore api_admin_get_stats: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== OVERVIEW & STATS ==================== + +@app.route('/overview') +@login_required +def overview(): + """Dashboard overview con statistiche aggregate""" + return render_template('overview.html', user=current_user) + +@app.route('/api/overview/stats') +@login_required +def get_overview_stats(): + """Ottieni statistiche aggregate per tutte le VM dell'utente (o tutte se admin)""" + try: + # Se admin, mostra TUTTE le VM, altrimenti solo le VM dell'utente + if current_user.is_admin: + # Recupera tutte le VM dal cluster + resources_data, _ = proxmox_api_call('GET', '/cluster/resources') + if resources_data and 'data' in resources_data: + all_resources = [r for r in resources_data['data'] if r.get('type') in ['qemu', 'lxc']] + user_vms = [{'vm_id': r['vmid'], 'vm_name': r.get('name', f"VM-{r['vmid']}")} for r in all_resources] + else: + user_vms = [] + else: + user_vms = UserVM.get_user_vms(current_user.id) + + stats = { + 'total_vms': len(user_vms), + 'running_vms': 0, + 'stopped_vms': 0, + 'total_cpu_usage': 0, + 'total_memory_used': 0, + 'total_memory_max': 0, + 'total_disk_used': 0, + 'total_disk_max': 0, + 'vms': [] + } + + nodes_data, _ = proxmox_api_call('GET', '/nodes') + if not nodes_data or 'data' not in nodes_data: + return jsonify({'status': 'error', 'message': 'Impossibile contattare Proxmox'}), 500 + + for vm in user_vms: + vm_id = vm['vm_id'] + vm_status = None + + for node in nodes_data.get('data', []): + node_name = node['node'] + + # Prova QEMU + qemu_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/qemu/{vm_id}/status/current') + if qemu_data and 'data' in qemu_data: + vm_status = qemu_data['data'] + vm_status['node'] = node_name + break + + # Prova LXC + lxc_data, _ = proxmox_api_call('GET', f'/nodes/{node_name}/lxc/{vm_id}/status/current') + if lxc_data and 'data' in lxc_data: + vm_status = lxc_data['data'] + vm_status['node'] = node_name + break + + if vm_status: + if vm_status.get('status') == 'running': + stats['running_vms'] += 1 + stats['total_cpu_usage'] += vm_status.get('cpu', 0) + stats['total_memory_used'] += vm_status.get('mem', 0) + else: + stats['stopped_vms'] += 1 + + stats['total_memory_max'] += vm_status.get('maxmem', 0) + stats['total_disk_max'] += vm_status.get('maxdisk', 0) + + mem_percent = 0 + if vm_status.get('maxmem', 0) > 0: + mem_percent = round((vm_status.get('mem', 0) / vm_status.get('maxmem', 1)) * 100, 1) + + stats['vms'].append({ + 'vm_id': vm_id, + 'name': vm['vm_name'] or vm_status.get('name', f'VM-{vm_id}'), + 'status': vm_status.get('status', 'unknown'), + 'cpu': vm_status.get('cpu', 0), + 'memory_percent': mem_percent + }) + + # Calcola medie + if stats['running_vms'] > 0: + stats['avg_cpu_usage'] = round((stats['total_cpu_usage'] / stats['running_vms']) * 100, 1) + else: + stats['avg_cpu_usage'] = 0 + + if stats['total_memory_max'] > 0: + stats['memory_percent'] = round((stats['total_memory_used'] / stats['total_memory_max']) * 100, 1) + else: + stats['memory_percent'] = 0 + + return jsonify({'status': 'success', 'data': stats}) + + except Exception as e: + app.logger.error(f"Errore get_overview_stats: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== NOTIFICATIONS (SSE) ==================== + +def get_user_queue(user_id): + """Ottieni o crea la coda di notifiche per un utente""" + if user_id not in notification_queues: + notification_queues[user_id] = queue.Queue(maxsize=100) + return notification_queues[user_id] + +def send_notification(user_id, notification_type, message, data=None): + """Invia una notifica a un utente specifico""" + try: + q = get_user_queue(user_id) + notification = { + 'type': 'notification', + 'notification_type': notification_type, + 'message': message, + 'data': data or {}, + 'timestamp': time.time() + } + # Non bloccare se la coda è piena + try: + q.put_nowait(notification) + except queue.Full: + pass + except Exception as e: + app.logger.error(f"Errore send_notification: {e}") + +@app.route('/api/notifications/stream') +@login_required +def notification_stream(): + """Endpoint SSE per notifiche real-time""" + def generate(): + q = get_user_queue(current_user.id) + + while True: + try: + # Attendi notifica con timeout + notification = q.get(timeout=30) + yield f"data: {json.dumps(notification)}\n\n" + except queue.Empty: + # Invia ping keepalive + yield f"data: {json.dumps({'type': 'ping'})}\n\n" + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive' + } + ) + +# ==================== NUOVE FUNZIONALITÀ ==================== + +# Log di Sistema +@app.route('/admin/system-logs') +@login_required +@admin_required +def system_logs(): + """Pagina log di sistema completa""" + return render_template('system_logs.html', user=current_user) + +# Topologia Cluster +@app.route('/admin/cluster-topology') +@login_required +@admin_required +def cluster_topology(): + """Pagina topologia cluster""" + return render_template('cluster_topology.html', user=current_user) + +@app.route('/api/admin/cluster/topology') +@login_required +@admin_required +def api_cluster_topology(): + """API per ottenere la topologia del cluster""" + try: + # Recupera informazioni sui nodi + nodes_data, error = proxmox_api_call('GET', '/nodes') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + # Recupera tutte le risorse del cluster + resources_data, error = proxmox_api_call('GET', '/cluster/resources') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + topology = { + 'nodes': nodes_data.get('data', []), + 'resources': resources_data.get('data', []) + } + + return jsonify({'status': 'success', 'data': topology}) + except Exception as e: + app.logger.error(f"Errore api_cluster_topology: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# IPAM (IP Address Management) +@app.route('/admin/ipam') +@login_required +@admin_required +def ipam(): + """Pagina gestione IPAM""" + return render_template('ipam.html', user=current_user) + +@app.route('/api/admin/ipam') +@login_required +@admin_required +def api_ipam(): + """API per ottenere tutti gli IP delle VM/LXC""" + try: + ipam_data = [] + + # Recupera assegnazioni manuali dal database + manual_assignments = {item['vm_id']: item for item in IPAssignment.get_all()} + + # Recupera tutte le risorse + resources_data, error = proxmox_api_call('GET', '/cluster/resources') + if error: + return jsonify({'status': 'error', 'message': error}), 500 + + # Filtra solo VM e LXC + resources = [r for r in resources_data.get('data', []) if r.get('type') in ['qemu', 'lxc']] + + for resource in resources: + vmid = resource.get('vmid') + vm_type = resource.get('type') + node = resource.get('node') + + # Controlla se c'è un'assegnazione manuale + manual = manual_assignments.get(vmid) + + if manual: + # Usa l'IP assegnato manualmente + ip_address = manual['ip_address'] + mac_address = manual['mac_address'] + notes = manual['notes'] + else: + # Recupera IP automaticamente + notes = None + if vm_type == 'qemu': + config_data, _ = proxmox_api_call('GET', f'/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces') + ip_address = 'N/A' + mac_address = None + + if config_data and 'data' in config_data: + result = config_data['data'].get('result', []) + for interface in result: + if interface.get('name') not in ['lo', 'sit0']: + ip_addresses = interface.get('ip-addresses', []) + for ip_info in ip_addresses: + if ip_info.get('ip-address-type') == 'ipv4': + ip_address = ip_info.get('ip-address', 'N/A') + break + mac_address = interface.get('hardware-address') + if ip_address != 'N/A': + break + else: # LXC + config_data, _ = proxmox_api_call('GET', f'/nodes/{node}/lxc/{vmid}/config') + ip_address = 'N/A' + mac_address = None + + if config_data and 'data' in config_data: + # Cerca net0, net1, etc. + for key in config_data['data']: + if key.startswith('net'): + net_config = config_data['data'][key] + # Formato: name=eth0,bridge=vmbr0,hwaddr=XX:XX:XX:XX:XX:XX,ip=192.168.1.100/24 + if 'ip=' in net_config: + ip_part = net_config.split('ip=')[1].split(',')[0] + ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part + if 'hwaddr=' in net_config: + mac_address = net_config.split('hwaddr=')[1].split(',')[0] + break + + # Controlla conflitti IP + conflict = False + if ip_address != 'N/A': + conflict = sum(1 for item in ipam_data if item['ip'] == ip_address) > 0 + + ipam_data.append({ + 'vmid': vmid, + 'name': resource.get('name', f'{vm_type}-{vmid}'), + 'type': vm_type, + 'ip': ip_address, + 'mac': mac_address, + 'node': node, + 'status': resource.get('status', 'unknown'), + 'conflict': conflict, + 'notes': notes, + 'manual_assignment': manual is not None + }) + + return jsonify({'status': 'success', 'data': ipam_data}) + except Exception as e: + app.logger.error(f"Errore api_ipam: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/ipam/scan', methods=['POST']) +@login_required +@admin_required +def api_ipam_scan(): + """Scansiona la rete per aggiornare gli IP""" + try: + # In una implementazione reale, qui si eseguirebbe una scansione di rete + # Per ora ricarica semplicemente i dati + return jsonify({'status': 'success', 'message': 'Scansione completata'}) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/ipam/assign', methods=['POST']) +@login_required +@admin_required +def api_ipam_assign(): + """Assegna manualmente un IP a una VM""" + try: + data = request.get_json() + vmid = data.get('vmid') + ip = data.get('ip') + notes = data.get('notes', '') + + if not vmid or not ip: + return jsonify({'status': 'error', 'message': 'VM ID e IP sono obbligatori'}), 400 + + # Valida formato IP + import re + ip_pattern = re.compile(r'^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$') + if not ip_pattern.match(ip): + return jsonify({'status': 'error', 'message': 'Formato IP non valido'}), 400 + + # Controlla conflitti IP + conflicting_vm = IPAssignment.check_ip_conflict(ip, exclude_vm_id=vmid) + if conflicting_vm: + return jsonify({ + 'status': 'error', + 'message': f'IP già assegnato alla VM {conflicting_vm}' + }), 409 + + # Salva assegnazione nel database + IPAssignment.create( + vm_id=vmid, + ip_address=ip, + assigned_by=current_user.id, + notes=notes + ) + + # Log azione + ActionLog.log_action( + user_id=current_user.id, + vm_id=vmid, + action_type='ip_assign', + status='success', + ip_address=request.remote_addr + ) + + return jsonify({ + 'status': 'success', + 'message': f'IP {ip} assegnato a VM {vmid}', + 'data': {'vmid': vmid, 'ip': ip, 'notes': notes} + }) + except Exception as e: + app.logger.error(f"Errore api_ipam_assign: {traceback.format_exc()}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + +@app.route('/api/admin/ipam/ping', methods=['POST']) +@login_required +@admin_required +def api_ipam_ping(): + """Esegue un ping verso un IP""" + try: + data = request.get_json() + ip = data.get('ip') + + if not ip: + return jsonify({'status': 'error', 'message': 'IP richiesto'}), 400 + + # Esegue ping (Windows usa -n, Linux usa -c) + import platform + param = '-n' if platform.system().lower() == 'windows' else '-c' + command = f'ping {param} 1 {ip}' + + import subprocess + result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=5) + + if result.returncode == 0: + return jsonify({'status': 'success', 'message': f'{ip} raggiungibile'}) + else: + return jsonify({'status': 'error', 'message': f'{ip} non raggiungibile'}), 400 + + except subprocess.TimeoutExpired: + return jsonify({'status': 'error', 'message': 'Timeout ping'}), 408 + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 + +# ==================== ERROR HANDLERS ==================== + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + +@app.errorhandler(500) +def internal_error(e): + app.logger.error(f"Errore 500: {traceback.format_exc()}") + return render_template('500.html'), 500 + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000, threaded=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..5674e99 --- /dev/null +++ b/config.py @@ -0,0 +1,33 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class Config: + # Flask + SECRET_KEY = os.getenv('SECRET_KEY', '63cWIYGHc66vwkIrx8ngocTvyJccBo2TcudU') + + # Database + DB_HOST = os.getenv('DB_HOST', 'localhost') + DB_USER = os.getenv('DB_USER', 'root') + DB_PASSWORD = os.getenv('DB_PASSWORD', '') + DB_NAME = os.getenv('DB_NAME', 'proxmox_manager') + + # Proxmox + PROXMOX_IP = os.getenv('PROXMOX_IP', '51.77.84.17') + PROXMOX_PORT = os.getenv('PROXMOX_PORT', '8006') + API_TOKEN_ID = os.getenv('API_TOKEN_ID', 'root@pam!access-api') + API_TOKEN_SECRET = os.getenv('API_TOKEN_SECRET', 'a4913906-6b9e-4cfd-bf1b-11efad1e186d') + + # Proxmox Public URL (per console via reverse proxy) + PROXMOX_PUBLIC_URL = os.getenv('PROXMOX_PUBLIC_URL', 'https://proxmox.gwserver.it') + + # App settings + MAX_BACKUPS_PER_USER = int(os.getenv('MAX_BACKUPS_PER_USER', 2)) + SESSION_TIMEOUT_MINUTES = int(os.getenv('SESSION_TIMEOUT_MINUTES', 60)) + PERMANENT_SESSION_LIFETIME = SESSION_TIMEOUT_MINUTES * 60 + + # Cloudflare + CLOUDFLARE_API_TOKEN = os.getenv('CLOUDFLARE_API_TOKEN', '') + CLOUDFLARE_ZONE_ID = os.getenv('CLOUDFLARE_ZONE_ID', '') + CLOUDFLARE_DOMAIN = os.getenv('CLOUDFLARE_DOMAIN', 'gwserver.it') diff --git a/create_database.py b/create_database.py new file mode 100644 index 0000000..c814693 --- /dev/null +++ b/create_database.py @@ -0,0 +1,242 @@ +""" +Script per creare il database di Proxmox Manager. + +Uso: + python create_database.py + +Requisiti: + - MySQL/MariaDB in esecuzione + - File .env configurato con DB_HOST, DB_USER, DB_PASSWORD, DB_NAME + - pip install -r requirements.txt +""" + +import sys +import os +import bcrypt +import pymysql +from dotenv import load_dotenv + +# Carica le variabili dal file .env +load_dotenv() + +DB_HOST = os.getenv('DB_HOST', 'localhost') +DB_USER = os.getenv('DB_USER', 'root') +DB_PASSWORD = os.getenv('DB_PASSWORD', '') +DB_NAME = os.getenv('DB_NAME', 'proxmox_manager') + + +def hash_password(password: str) -> str: + """Genera un hash bcrypt sicuro per la password.""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(12)).decode('utf-8') + + +def get_connection(database=None): + """Apre una connessione a MySQL.""" + try: + conn = pymysql.connect( + host=DB_HOST, + user=DB_USER, + password=DB_PASSWORD, + database=database, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor, + autocommit=True, + ) + return conn + except pymysql.Error as e: + print(f"\n[ERRORE] Impossibile connettersi a MySQL: {e}") + print("\nVerifica che:") + print(f" - MySQL/MariaDB sia in esecuzione su {DB_HOST}") + print(f" - Le credenziali nel file .env siano corrette (utente: {DB_USER})") + sys.exit(1) + + +def create_database(conn): + """Crea il database se non esiste.""" + with conn.cursor() as cur: + cur.execute( + f"CREATE DATABASE IF NOT EXISTS `{DB_NAME}` " + f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + print(f" [OK] Database '{DB_NAME}' pronto.") + + +def create_tables(conn): + """Crea tutte le tabelle necessarie.""" + + tables = { + + # ------------------------------------------------------------------ # + # 1. UTENTI # + # ------------------------------------------------------------------ # + 'users': """ + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP NULL, + active BOOLEAN DEFAULT TRUE, + INDEX idx_username (username), + INDEX idx_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + + # ------------------------------------------------------------------ # + # 2. ASSOCIAZIONE UTENTE ↔ VM # + # ------------------------------------------------------------------ # + 'user_vms': """ + CREATE TABLE IF NOT EXISTS user_vms ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + vm_id INT NOT NULL, + vm_name VARCHAR(100), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_user_vm (user_id, vm_id), + INDEX idx_user_id (user_id), + INDEX idx_vm_id (vm_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + + # ------------------------------------------------------------------ # + # 3. LOG AZIONI (audit trail) # + # ------------------------------------------------------------------ # + 'action_logs': """ + CREATE TABLE IF NOT EXISTS action_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + vm_id INT NOT NULL, + action_type ENUM( + 'start','stop','restart','shutdown', + 'backup','login','snapshot', + 'subdomain_create','subdomain_delete','ip_assign' + ) NOT NULL, + status ENUM('success','failed') NOT NULL, + error_message TEXT, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_vm_id (vm_id), + INDEX idx_created_at (created_at), + INDEX idx_action_type(action_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + + # ------------------------------------------------------------------ # + # 4. SOTTODOMINI CLOUDFLARE # + # ------------------------------------------------------------------ # + 'subdomains': """ + CREATE TABLE IF NOT EXISTS subdomains ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + subdomain VARCHAR(100) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + vm_id INT NULL, + proxied BOOLEAN DEFAULT FALSE, + cloudflare_record_id VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_subdomain(subdomain) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + + # ------------------------------------------------------------------ # + # 5. ASSEGNAZIONI IP (IPAM) # + # ------------------------------------------------------------------ # + 'ip_assignments': """ + CREATE TABLE IF NOT EXISTS ip_assignments ( + id INT AUTO_INCREMENT PRIMARY KEY, + vm_id INT UNIQUE NOT NULL, + ip_address VARCHAR(45) NOT NULL, + mac_address VARCHAR(17) NULL, + notes TEXT NULL, + assigned_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (assigned_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_vm_id (vm_id), + INDEX idx_ip_address(ip_address) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """, + } + + with conn.cursor() as cur: + for name, ddl in tables.items(): + cur.execute(ddl) + print(f" [OK] Tabella '{name}' pronta.") + + +def create_default_users(conn): + """Inserisce gli utenti di default (admin e testuser).""" + + users = [ + { + 'username': 'admin', + 'email': 'admin@localhost', + 'password': 'admin123', + 'is_admin': True, + }, + { + 'username': 'testuser', + 'email': 'test@localhost', + 'password': 'test123', + 'is_admin': False, + }, + ] + + with conn.cursor() as cur: + for u in users: + pw_hash = hash_password(u['password']) + cur.execute( + """ + INSERT INTO users (username, email, password_hash, is_admin) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE username = username + """, + (u['username'], u['email'], pw_hash, u['is_admin']), + ) + print(f" [OK] Utente '{u['username']}' (password: {u['password']}) pronto.") + + +def main(): + print("=" * 55) + print(" Proxmox Manager — Creazione Database") + print("=" * 55) + print(f"\nConnessione a MySQL ({DB_USER}@{DB_HOST})...") + + # Prima connessione senza database (per crearlo) + conn_no_db = get_connection(database=None) + create_database(conn_no_db) + conn_no_db.close() + + # Seconda connessione al database appena creato + conn = get_connection(database=DB_NAME) + + print("\nCreazione tabelle...") + create_tables(conn) + + print("\nCreazione utenti di default...") + create_default_users(conn) + + conn.close() + + print("\n" + "=" * 55) + print(" Database creato con successo!") + print("=" * 55) + print("\nCredenziali di accesso:") + print(" Amministratore : admin / admin123") + print(" Utente di test : testuser / test123") + print("\n IMPORTANTE: Cambia le password dopo il primo login!\n") + print("Per avviare l'applicazione:") + print(" python app.py") + print("\nPoi apri il browser su: http://localhost:5000\n") + + +if __name__ == '__main__': + main() diff --git a/models.py b/models.py new file mode 100644 index 0000000..7815f1f --- /dev/null +++ b/models.py @@ -0,0 +1,408 @@ +import pymysql +from datetime import datetime +import bcrypt +from config import Config + +def get_db_connection(): + """Crea connessione al database""" + return pymysql.connect( + host=Config.DB_HOST, + user=Config.DB_USER, + password=Config.DB_PASSWORD, + database=Config.DB_NAME, + cursorclass=pymysql.cursors.DictCursor, + autocommit=True + ) + +class User: + @staticmethod + def create(username, email, password, is_admin=False): + """Crea un nuovo utente""" + password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """INSERT INTO users (username, email, password_hash, is_admin) + VALUES (%s, %s, %s, %s)""" + cursor.execute(sql, (username, email, password_hash, is_admin)) + return cursor.lastrowid + finally: + conn.close() + + @staticmethod + def get_by_username(username): + """Recupera utente per username""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM users WHERE username = %s AND active = TRUE" + cursor.execute(sql, (username,)) + return cursor.fetchone() + finally: + conn.close() + + @staticmethod + def get_by_id(user_id): + """Recupera utente per ID""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM users WHERE id = %s AND active = TRUE" + cursor.execute(sql, (user_id,)) + return cursor.fetchone() + finally: + conn.close() + + @staticmethod + def verify_password(username, password): + """Verifica password utente""" + user = User.get_by_username(username) + if user: + stored_hash = user['password_hash'] + # Se è una stringa, convertila in bytes + if isinstance(stored_hash, str): + stored_hash = stored_hash.encode('utf-8') + return bcrypt.checkpw(password.encode('utf-8'), stored_hash) + return False + + @staticmethod + def update_last_login(user_id): + """Aggiorna timestamp ultimo login""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE users SET last_login = NOW() WHERE id = %s" + cursor.execute(sql, (user_id,)) + finally: + conn.close() + + @staticmethod + def get_all_users(): + """Recupera tutti gli utenti (solo admin)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT id, username, email, is_admin, created_at, last_login, active FROM users ORDER BY created_at DESC" + cursor.execute(sql) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def update_active_status(user_id, active): + """Attiva/disattiva un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE users SET active = %s WHERE id = %s" + cursor.execute(sql, (active, user_id)) + return cursor.rowcount > 0 + finally: + conn.close() + + @staticmethod + def update_password(user_id, new_password): + """Aggiorna la password di un utente""" + password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()) + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE users SET password_hash = %s WHERE id = %s" + cursor.execute(sql, (password_hash, user_id)) + return cursor.rowcount > 0 + finally: + conn.close() + + @staticmethod + def update_email(user_id, new_email): + """Aggiorna l'email di un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE users SET email = %s WHERE id = %s" + cursor.execute(sql, (new_email, user_id)) + return cursor.rowcount > 0 + finally: + conn.close() + +class UserVM: + @staticmethod + def assign_vm(user_id, vm_id, vm_name=None, notes=None): + """Assegna una VM a un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """INSERT INTO user_vms (user_id, vm_id, vm_name, notes) + VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE vm_name = VALUES(vm_name), notes = VALUES(notes)""" + cursor.execute(sql, (user_id, vm_id, vm_name, notes)) + return cursor.lastrowid + finally: + conn.close() + + @staticmethod + def get_user_vms(user_id): + """Recupera le VM di un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM user_vms WHERE user_id = %s ORDER BY vm_id" + cursor.execute(sql, (user_id,)) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def remove_vm(user_id, vm_id): + """Rimuove l'associazione VM-utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "DELETE FROM user_vms WHERE user_id = %s AND vm_id = %s" + cursor.execute(sql, (user_id, vm_id)) + return cursor.rowcount > 0 + finally: + conn.close() + + @staticmethod + def user_has_vm(user_id, vm_id): + """Verifica se l'utente ha accesso a questa VM""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT COUNT(*) as count FROM user_vms WHERE user_id = %s AND vm_id = %s" + cursor.execute(sql, (user_id, vm_id)) + result = cursor.fetchone() + return result['count'] > 0 + finally: + conn.close() + + @staticmethod + def get_all_assignments(): + """Recupera tutte le assegnazioni VM-utente (solo admin)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """SELECT uv.*, u.username + FROM user_vms uv + JOIN users u ON uv.user_id = u.id + ORDER BY u.username, uv.vm_id""" + cursor.execute(sql) + return cursor.fetchall() + finally: + conn.close() + +class Subdomain: + @staticmethod + def create(user_id, subdomain, ip_address, vm_id=None, proxied=True, cloudflare_record_id=None): + """Crea un nuovo sottodominio""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """INSERT INTO subdomains (user_id, subdomain, ip_address, vm_id, proxied, cloudflare_record_id) + VALUES (%s, %s, %s, %s, %s, %s)""" + cursor.execute(sql, (user_id, subdomain, ip_address, vm_id, proxied, cloudflare_record_id)) + return cursor.lastrowid + finally: + conn.close() + + @staticmethod + def get_user_subdomains(user_id): + """Recupera tutti i sottodomini di un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM subdomains WHERE user_id = %s ORDER BY created_at DESC" + cursor.execute(sql, (user_id,)) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def get_by_id(subdomain_id): + """Recupera sottodominio per ID""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM subdomains WHERE id = %s" + cursor.execute(sql, (subdomain_id,)) + return cursor.fetchone() + finally: + conn.close() + + @staticmethod + def delete(subdomain_id, user_id): + """Elimina un sottodominio (con controllo ownership)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "DELETE FROM subdomains WHERE id = %s AND user_id = %s" + cursor.execute(sql, (subdomain_id, user_id)) + return cursor.rowcount > 0 + finally: + conn.close() + + @staticmethod + def subdomain_exists(subdomain): + """Verifica se un sottodominio esiste già""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT COUNT(*) as count FROM subdomains WHERE subdomain = %s" + cursor.execute(sql, (subdomain,)) + result = cursor.fetchone() + return result['count'] > 0 + finally: + conn.close() + + @staticmethod + def update_ip(subdomain_id, user_id, new_ip): + """Aggiorna IP di un sottodominio""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "UPDATE subdomains SET ip_address = %s WHERE id = %s AND user_id = %s" + cursor.execute(sql, (new_ip, subdomain_id, user_id)) + return cursor.rowcount > 0 + finally: + conn.close() + +class IPAssignment: + @staticmethod + def create(vm_id, ip_address, assigned_by, mac_address=None, notes=None): + """Crea una nuova assegnazione IP""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """INSERT INTO ip_assignments (vm_id, ip_address, mac_address, notes, assigned_by) + VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + ip_address = VALUES(ip_address), + mac_address = VALUES(mac_address), + notes = VALUES(notes), + assigned_by = VALUES(assigned_by), + updated_at = CURRENT_TIMESTAMP""" + cursor.execute(sql, (vm_id, ip_address, mac_address, notes, assigned_by)) + return cursor.lastrowid + finally: + conn.close() + + @staticmethod + def get_by_vm_id(vm_id): + """Recupera assegnazione IP per VM ID""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "SELECT * FROM ip_assignments WHERE vm_id = %s" + cursor.execute(sql, (vm_id,)) + return cursor.fetchone() + finally: + conn.close() + + @staticmethod + def get_all(): + """Recupera tutte le assegnazioni IP""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """SELECT ia.*, u.username as assigned_by_username + FROM ip_assignments ia + LEFT JOIN users u ON ia.assigned_by = u.id + ORDER BY ia.updated_at DESC""" + cursor.execute(sql) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def delete(vm_id): + """Elimina un'assegnazione IP""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = "DELETE FROM ip_assignments WHERE vm_id = %s" + cursor.execute(sql, (vm_id,)) + return cursor.rowcount > 0 + finally: + conn.close() + + @staticmethod + def check_ip_conflict(ip_address, exclude_vm_id=None): + """Verifica se un IP è già assegnato""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + if exclude_vm_id: + sql = "SELECT vm_id FROM ip_assignments WHERE ip_address = %s AND vm_id != %s" + cursor.execute(sql, (ip_address, exclude_vm_id)) + else: + sql = "SELECT vm_id FROM ip_assignments WHERE ip_address = %s" + cursor.execute(sql, (ip_address,)) + result = cursor.fetchone() + return result['vm_id'] if result else None + finally: + conn.close() + +class ActionLog: + @staticmethod + def log_action(user_id, vm_id, action_type, status, error_message=None, ip_address=None): + """Registra un'azione dell'utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """INSERT INTO action_logs + (user_id, vm_id, action_type, status, error_message, ip_address) + VALUES (%s, %s, %s, %s, %s, %s)""" + cursor.execute(sql, (user_id, vm_id, action_type, status, error_message, ip_address)) + return cursor.lastrowid + finally: + conn.close() + + @staticmethod + def get_user_logs(user_id, limit=50): + """Recupera i log di un utente""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """SELECT * FROM action_logs + WHERE user_id = %s + ORDER BY created_at DESC + LIMIT %s""" + cursor.execute(sql, (user_id, limit)) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def get_all_logs(limit=100): + """Recupera tutti i log (solo admin)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """SELECT al.*, u.username + FROM action_logs al + JOIN users u ON al.user_id = u.id + ORDER BY al.created_at DESC + LIMIT %s""" + cursor.execute(sql, (limit,)) + return cursor.fetchall() + finally: + conn.close() + + @staticmethod + def get_vm_logs(vm_id, limit=50): + """Recupera i log di una VM specifica""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + sql = """SELECT al.*, u.username + FROM action_logs al + JOIN users u ON al.user_id = u.id + WHERE al.vm_id = %s + ORDER BY al.created_at DESC + LIMIT %s""" + cursor.execute(sql, (vm_id, limit)) + return cursor.fetchall() + finally: + conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..adedb9a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-Login==0.6.3 +Werkzeug==3.0.1 +requests==2.31.0 +urllib3==2.1.0 +PyMySQL==1.1.0 +bcrypt==4.1.2 +python-dotenv==1.0.0 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..8e43b47 --- /dev/null +++ b/schema.sql @@ -0,0 +1,68 @@ +-- Database creation +CREATE DATABASE IF NOT EXISTS proxmox_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE proxmox_manager; + +-- Tabella utenti +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP NULL, + active BOOLEAN DEFAULT TRUE, + INDEX idx_username (username), + INDEX idx_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Tabella associazione utente-VM +CREATE TABLE IF NOT EXISTS user_vms ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + vm_id INT NOT NULL, + vm_name VARCHAR(100), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_user_vm (user_id, vm_id), + INDEX idx_user_id (user_id), + INDEX idx_vm_id (vm_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Tabella log azioni +CREATE TABLE IF NOT EXISTS action_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + vm_id INT NOT NULL, + action_type ENUM('start', 'stop', 'restart', 'shutdown', 'backup', 'login') NOT NULL, + status ENUM('success', 'failed') NOT NULL, + error_message TEXT, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_vm_id (vm_id), + INDEX idx_created_at (created_at), + INDEX idx_action_type (action_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Crea utente admin di default +-- Username: admin +-- Password: admin123 +-- IMPORTANTE: Cambia la password dopo il primo login! +-- Se il login non funziona, esegui: python reset_passwords.py +INSERT INTO users (username, email, password_hash, is_admin) +VALUES ('admin', 'admin@localhost', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5NU7BqEa/5pjy', TRUE) +ON DUPLICATE KEY UPDATE username=username; + +-- Esempio: crea un utente normale di test +-- Username: testuser +-- Password: test123 +INSERT INTO users (username, email, password_hash, is_admin) +VALUES ('testuser', 'test@localhost', '$2b$12$rMtoH.08EhJjXpEoE7l8/.vjL5VJ5lQdH5YQJ3TpHfNhDzJ8k8F9W', FALSE) +ON DUPLICATE KEY UPDATE username=username; + +-- Esempio: assegna VM 114 all'utente test +-- INSERT INTO user_vms (user_id, vm_id, vm_name, notes) +-- VALUES (2, 114, 'buslino-vm', 'VM di test per l\'utente'); diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..902989c --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,586 @@ +/* =========================================== + PROXMOX MANAGER - DASHBOARD STYLES + VM Cards, Metrics, and Dashboard Components + =========================================== */ + +/* =========================================== + VM GRID LAYOUT + =========================================== */ + +.vm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + gap: var(--space-lg); + margin-top: var(--space-lg); +} + +@media (max-width: 480px) { + .vm-grid { + grid-template-columns: 1fr; + } +} + +/* =========================================== + VM CARDS + =========================================== */ + +.vm-card { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-lg); + transition: all var(--transition-normal); + position: relative; + overflow: hidden; +} + +.vm-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--border-default); + transition: background var(--transition-fast); +} + +.vm-card:hover { + border-color: var(--border-muted); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.vm-card.status-running::before { + background: var(--accent-green); +} + +.vm-card.status-stopped::before { + background: var(--accent-red); +} + +.vm-card.status-unknown::before { + background: var(--text-tertiary); +} + +/* VM Header */ +.vm-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-md); +} + +.vm-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-xs); +} + +.vm-id { + font-size: 0.8rem; + color: var(--text-secondary); + font-family: var(--font-mono); +} + +.vm-notes { + color: var(--text-secondary); + font-size: 0.85rem; + margin-bottom: var(--space-md); + padding: var(--space-sm); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + border-left: 2px solid var(--accent-purple); +} + +/* =========================================== + METRICS BOXES + =========================================== */ + +.metrics-realtime { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-sm); + margin: var(--space-md) 0; +} + +.metric-box { + background: var(--bg-tertiary); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: var(--space-md); + text-align: center; + position: relative; + overflow: hidden; + min-height: 100px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.metric-box.cpu { + background: linear-gradient(135deg, rgba(88, 166, 255, 0.15) 0%, rgba(88, 166, 255, 0.05) 100%); + border-color: rgba(88, 166, 255, 0.3); +} + +.metric-box.memory { + background: linear-gradient(135deg, rgba(163, 113, 247, 0.15) 0%, rgba(163, 113, 247, 0.05) 100%); + border-color: rgba(163, 113, 247, 0.3); +} + +.metric-box.disk { + background: linear-gradient(135deg, rgba(63, 185, 80, 0.15) 0%, rgba(63, 185, 80, 0.05) 100%); + border-color: rgba(63, 185, 80, 0.3); +} + +.metric-label { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--space-xs); +} + +.metric-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.metric-box.cpu .metric-value { + color: var(--accent-blue); +} + +.metric-box.memory .metric-value { + color: var(--accent-purple); +} + +.metric-box.disk .metric-value { + color: var(--accent-green); +} + +.metric-subvalue { + font-size: 0.75rem; + color: var(--text-secondary); + margin-top: var(--space-xs); +} + +.metric-box canvas { + max-height: 35px !important; + margin-top: var(--space-sm); +} + +/* Metric animation on update */ +.metric-box.updating { + animation: metricPulse 0.3s ease; +} + +@keyframes metricPulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.02); } + 100% { transform: scale(1); } +} + +/* =========================================== + VM STATS + =========================================== */ + +.vm-stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-sm); + margin: var(--space-md) 0; +} + +.stat-item { + background: var(--bg-tertiary); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + border: 1px solid var(--border-muted); +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.stat-value { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + margin-top: var(--space-xs); +} + +/* =========================================== + VM ACTIONS + =========================================== */ + +.vm-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border-muted); +} + +.vm-actions .btn { + flex: 1 1 auto; + min-width: 100px; +} + +.vm-actions .btn-icon { + min-width: auto; + padding: var(--space-sm); +} + +/* Action button states */ +.vm-actions .btn.loading { + pointer-events: none; + opacity: 0.7; +} + +.vm-actions .btn.loading::after { + content: ''; + width: 14px; + height: 14px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin-left: var(--space-sm); +} + +/* =========================================== + HISTORICAL CHARTS + =========================================== */ + +.metrics-historical { + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border-muted); +} + +.chart-controls { + display: flex; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} + +.chart-btn { + padding: var(--space-xs) var(--space-sm); + background: var(--bg-tertiary); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.chart-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.chart-btn.active { + background: var(--accent-blue); + border-color: var(--accent-blue); + color: #ffffff; +} + +.chart-container { + height: 150px; + position: relative; +} + +/* =========================================== + BACKUP & SNAPSHOT LISTS + =========================================== */ + +.backup-list, +.snapshot-list { + margin-top: var(--space-md); + max-height: 300px; + overflow-y: auto; +} + +.backup-item, +.snapshot-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-muted); + border-radius: var(--radius-md); + margin-bottom: var(--space-sm); + transition: all var(--transition-fast); +} + +.backup-item:hover, +.snapshot-item:hover { + border-color: var(--border-default); + background: var(--bg-hover); +} + +.backup-info, +.snapshot-info { + flex: 1; + min-width: 0; +} + +.backup-name, +.snapshot-name { + font-weight: 500; + color: var(--text-primary); + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.backup-date, +.snapshot-date { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: var(--space-xs); +} + +.backup-size { + font-size: 0.85rem; + color: var(--accent-blue); + font-weight: 600; + margin-right: var(--space-md); +} + +/* =========================================== + EMPTY STATE + =========================================== */ + +.no-vms { + text-align: center; + padding: var(--space-2xl); + color: var(--text-secondary); +} + +.no-vms-icon { + font-size: 4rem; + margin-bottom: var(--space-md); + opacity: 0.5; +} + +.no-vms h2 { + color: var(--text-primary); + margin-bottom: var(--space-sm); +} + +.no-vms p { + color: var(--text-secondary); +} + +/* =========================================== + DASHBOARD HEADER + =========================================== */ + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-lg); +} + +.dashboard-header h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.dashboard-header .subtitle { + color: var(--text-secondary); + font-size: 0.9rem; + margin-top: var(--space-xs); +} + +/* =========================================== + OVERVIEW STATS GRID + =========================================== */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-lg); + display: flex; + align-items: center; + gap: var(--space-md); + transition: all var(--transition-fast); +} + +.stat-card:hover { + border-color: var(--border-muted); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-card .stat-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + flex-shrink: 0; +} + +.stat-card.stat-total .stat-icon { + background: rgba(88, 166, 255, 0.15); +} + +.stat-card.stat-running .stat-icon { + background: rgba(63, 185, 80, 0.15); +} + +.stat-card.stat-stopped .stat-icon { + background: rgba(248, 81, 73, 0.15); +} + +.stat-card.stat-cpu .stat-icon { + background: rgba(163, 113, 247, 0.15); +} + +.stat-card .stat-content { + flex: 1; +} + +.stat-card .stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +.stat-card .stat-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: var(--space-xs); +} + +/* =========================================== + VM OVERVIEW LIST + =========================================== */ + +.vm-overview-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.vm-overview-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md); + background: var(--bg-tertiary); + border: 1px solid var(--border-muted); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.vm-overview-item:hover { + background: var(--bg-hover); + border-color: var(--border-default); +} + +.vm-overview-info { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.vm-overview-name { + font-weight: 500; + color: var(--text-primary); +} + +.vm-overview-id { + font-size: 0.8rem; + color: var(--text-secondary); + font-family: var(--font-mono); +} + +.vm-overview-metrics { + display: flex; + gap: var(--space-lg); + align-items: center; +} + +.vm-overview-metric { + text-align: right; +} + +.vm-overview-metric-value { + font-weight: 600; + color: var(--text-primary); +} + +.vm-overview-metric-label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.vm-overview-actions { + display: flex; + gap: var(--space-sm); +} + +/* =========================================== + RESPONSIVE ADJUSTMENTS + =========================================== */ + +@media (max-width: 768px) { + .metrics-realtime { + grid-template-columns: 1fr; + } + + .vm-actions { + flex-direction: column; + } + + .vm-actions .btn { + width: 100%; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .vm-overview-metrics { + display: none; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .dashboard-header { + flex-direction: column; + align-items: flex-start; + gap: var(--space-md); + } +} diff --git a/static/css/theme.css b/static/css/theme.css new file mode 100644 index 0000000..ef1f73f --- /dev/null +++ b/static/css/theme.css @@ -0,0 +1,674 @@ +/* =========================================== + PROXMOX MANAGER - DARK THEME + Modern dark mode inspired by VS Code/GitHub + =========================================== */ + +:root { + /* Background layers */ + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + --bg-active: #388bfd1a; + + /* Text colors */ + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-tertiary: #6e7681; + --text-link: #58a6ff; + + /* Accent colors */ + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-red: #f85149; + --accent-yellow: #d29922; + --accent-purple: #a371f7; + --accent-orange: #db6d28; + --accent-cyan: #39c5cf; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #58a6ff 0%, #a371f7 100%); + --gradient-success: linear-gradient(135deg, #3fb950 0%, #2ea043 100%); + --gradient-danger: linear-gradient(135deg, #f85149 0%, #da3633 100%); + --gradient-warning: linear-gradient(135deg, #d29922 0%, #bb8009 100%); + --gradient-purple: linear-gradient(135deg, #a371f7 0%, #8957e5 100%); + + /* Borders */ + --border-default: #30363d; + --border-muted: #21262d; + --border-subtle: #1b1f24; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-glow-blue: 0 0 20px rgba(88, 166, 255, 0.3); + --shadow-glow-green: 0 0 20px rgba(63, 185, 80, 0.3); + --shadow-glow-red: 0 0 20px rgba(248, 81, 73, 0.3); + + /* Status colors */ + --status-running-bg: rgba(63, 185, 80, 0.15); + --status-running-border: #238636; + --status-running-text: #3fb950; + --status-stopped-bg: rgba(248, 81, 73, 0.15); + --status-stopped-border: #da3633; + --status-stopped-text: #f85149; + --status-unknown-bg: rgba(110, 118, 129, 0.15); + --status-unknown-border: #6e7681; + --status-unknown-text: #8b949e; + + /* Spacing */ + --space-xs: 0.25rem; + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 350ms ease; + + /* Font */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; +} + +/* =========================================== + GLOBAL STYLES + =========================================== */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--text-link); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--accent-blue); + text-decoration: underline; +} + +/* =========================================== + NAVBAR + =========================================== */ + +.navbar { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-default); + padding: 0 var(--space-xl); + height: 64px; + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(12px); +} + +.navbar-brand { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + text-decoration: none; + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.navbar-brand:hover { + color: var(--accent-blue); + text-decoration: none; +} + +.navbar-menu { + display: flex; + gap: var(--space-xs); + align-items: center; +} + +.navbar-menu a { + color: var(--text-secondary); + text-decoration: none; + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + font-weight: 500; + font-size: 0.9rem; +} + +.navbar-menu a:hover { + background: var(--bg-hover); + color: var(--text-primary); + text-decoration: none; +} + +.navbar-menu a.active { + background: var(--bg-active); + color: var(--accent-blue); +} + +.navbar-user { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.navbar-user > span { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.badge-admin { + background: var(--gradient-danger); + color: white; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* =========================================== + CONTAINER + =========================================== */ + +.container { + max-width: 1400px; + margin: 0 auto; + padding: var(--space-xl); +} + +/* =========================================== + CARDS + =========================================== */ + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: var(--space-lg); + margin-bottom: var(--space-lg); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.card:hover { + border-color: var(--border-muted); +} + +.card-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-sm); +} + +.card-subtitle { + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* =========================================== + BUTTONS + =========================================== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1px solid transparent; + border-radius: var(--radius-md); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; + white-space: nowrap; +} + +.btn:hover { + text-decoration: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--accent-blue); + color: #ffffff; + border-color: var(--accent-blue); +} + +.btn-primary:hover:not(:disabled) { + background: #4c9aed; + box-shadow: var(--shadow-glow-blue); +} + +.btn-success { + background: var(--accent-green); + color: #ffffff; + border-color: var(--accent-green); +} + +.btn-success:hover:not(:disabled) { + background: #2ea043; + box-shadow: var(--shadow-glow-green); +} + +.btn-danger { + background: var(--accent-red); + color: #ffffff; + border-color: var(--accent-red); +} + +.btn-danger:hover:not(:disabled) { + background: #da3633; + box-shadow: var(--shadow-glow-red); +} + +.btn-warning { + background: var(--accent-yellow); + color: #000000; + border-color: var(--accent-yellow); +} + +.btn-warning:hover:not(:disabled) { + background: #bb8009; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-default); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--text-secondary); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border-color: transparent; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.btn-sm { + padding: var(--space-xs) var(--space-sm); + font-size: 0.8rem; +} + +.btn-lg { + padding: var(--space-md) var(--space-lg); + font-size: 1rem; +} + +/* =========================================== + STATUS BADGES + =========================================== */ + +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.status-badge::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; +} + +.status-running { + background: var(--status-running-bg); + color: var(--status-running-text); + border: 1px solid var(--status-running-border); +} + +.status-running::before { + background: var(--status-running-text); +} + +.status-stopped { + background: var(--status-stopped-bg); + color: var(--status-stopped-text); + border: 1px solid var(--status-stopped-border); +} + +.status-stopped::before { + background: var(--status-stopped-text); + animation: none; +} + +.status-unknown { + background: var(--status-unknown-bg); + color: var(--status-unknown-text); + border: 1px solid var(--status-unknown-border); +} + +.status-unknown::before { + background: var(--status-unknown-text); + animation: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* =========================================== + ALERTS + =========================================== */ + +.alert { + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); + border-left: 4px solid; + display: flex; + align-items: flex-start; + gap: var(--space-sm); +} + +.alert-success { + background: var(--status-running-bg); + border-color: var(--accent-green); + color: var(--accent-green); +} + +.alert-error { + background: var(--status-stopped-bg); + border-color: var(--accent-red); + color: var(--accent-red); +} + +.alert-info { + background: rgba(88, 166, 255, 0.15); + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.alert-warning { + background: rgba(210, 153, 34, 0.15); + border-color: var(--accent-yellow); + color: var(--accent-yellow); +} + +/* =========================================== + FORMS + =========================================== */ + +.form-group { + margin-bottom: var(--space-md); +} + +.form-group label { + display: block; + margin-bottom: var(--space-sm); + font-weight: 500; + color: var(--text-primary); + font-size: 0.9rem; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: var(--space-sm) var(--space-md); + background: var(--bg-primary); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 1rem; + font-family: inherit; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2); +} + +.form-group input::placeholder { + color: var(--text-tertiary); +} + +/* =========================================== + TABLES + =========================================== */ + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: var(--space-md); + text-align: left; + border-bottom: 1px solid var(--border-default); +} + +th { + background: var(--bg-tertiary); + font-weight: 600; + color: var(--text-secondary); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +tr:hover td { + background: var(--bg-hover); +} + +/* =========================================== + MODALS + =========================================== */ + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + animation: fadeIn var(--transition-fast); +} + +.modal-content { + background: var(--bg-secondary); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + margin: 5% auto; + padding: var(--space-xl); + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + animation: slideIn var(--transition-normal); +} + +.modal-content h2 { + color: var(--text-primary); + margin-bottom: var(--space-lg); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border-default); +} + +.close { + color: var(--text-secondary); + float: right; + font-size: 1.5rem; + font-weight: bold; + cursor: pointer; + transition: color var(--transition-fast); + line-height: 1; +} + +.close:hover { + color: var(--text-primary); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* =========================================== + LOADING STATES + =========================================== */ + +.loading { + text-align: center; + padding: var(--space-2xl); + color: var(--text-secondary); +} + +.spinner { + width: 48px; + height: 48px; + border: 3px solid var(--border-default); + border-top-color: var(--accent-blue); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto var(--space-md); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* =========================================== + SCROLLBAR + =========================================== */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-default); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* =========================================== + UTILITY CLASSES + =========================================== */ + +.text-muted { + color: var(--text-secondary); +} + +.text-primary { + color: var(--text-primary); +} + +.text-success { + color: var(--accent-green); +} + +.text-danger { + color: var(--accent-red); +} + +.text-warning { + color: var(--accent-yellow); +} + +.text-center { + text-align: center; +} + +.mb-0 { margin-bottom: 0; } +.mb-1 { margin-bottom: var(--space-sm); } +.mb-2 { margin-bottom: var(--space-md); } +.mb-3 { margin-bottom: var(--space-lg); } +.mb-4 { margin-bottom: var(--space-xl); } + +.mt-0 { margin-top: 0; } +.mt-1 { margin-top: var(--space-sm); } +.mt-2 { margin-top: var(--space-md); } +.mt-3 { margin-top: var(--space-lg); } +.mt-4 { margin-top: var(--space-xl); } + +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-1 { gap: var(--space-sm); } +.gap-2 { gap: var(--space-md); } +.gap-3 { gap: var(--space-lg); } diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..d5b965f --- /dev/null +++ b/templates/404.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Pagina non trovata - Proxmox Manager{% endblock %} + +{% block content %} +
+
🔍
+

404

+

Pagina non trovata

+

+ La pagina che stai cercando non esiste o è stata spostata. +

+ + Torna alla Home + +
+{% endblock %} diff --git a/templates/500.html b/templates/500.html new file mode 100644 index 0000000..d77f03a --- /dev/null +++ b/templates/500.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Errore del Server - Proxmox Manager{% endblock %} + +{% block content %} +
+
⚠️
+

500

+

Errore del Server

+

+ Si è verificato un errore interno. Riprova tra qualche istante. +

+ + Torna alla Home + +
+{% endblock %} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..9acaf17 --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,361 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Dashboard Amministratore

+

Panoramica del sistema

+
+
+ +
+
+
+ + + + + + +
+
+
Utenti Totali
+
-
+
+
+
+
+ + + + + +
+
+
VM Gestite
+
-
+
+
+
+
+ + + +
+
+
VM Attive
+
-
+
+
+
+
+ + + +
+
+
Azioni Oggi
+
-
+
+
+
+ + + +
+
Attività Recente
+
+
+
+

Caricamento attività...

+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..6e86737 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,411 @@ +{% extends "base.html" %} + +{% block title %}Gestione Utenti - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+
+

Gestione Utenti

+

Amministra utenti e assegnazioni VM

+
+ +
+
+ +
+
+
+
+

Caricamento utenti...

+
+
+
+ + + + + + + + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b2da191 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,367 @@ + + + + + + {% block title %}Proxmox Manager{% endblock %} + + + + + + + + + + + + + + + {% block extra_styles %}{% endblock %} + + + {% if current_user.is_authenticated %} + + {% endif %} + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + +
+ + + + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/templates/cluster_topology.html b/templates/cluster_topology.html new file mode 100644 index 0000000..051f468 --- /dev/null +++ b/templates/cluster_topology.html @@ -0,0 +1,1094 @@ +{% extends "base.html" %} + +{% block title %}Topologia Cluster - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Topologia del Cluster

+

↔️ Trascina NODI per riposizionare | 🖱️ Trascina sfondo per muovere | 🔍 Rotella per zoom | Click per dettagli

+
+
+ +
+ + + + + + + + + +
+ +
+
+
+ +
100%
+ + +
+ +
+ +
+
+

Statistiche Cluster

+
+ Nodi + - +
+
+ VM (QEMU) + - +
+
+ Container (LXC) + - +
+
+ Storage + - +
+
+ Totale VM Attive + - +
+
+ +
+
Legenda
+
+
+ Cluster +
+
+
+ Nodo Proxmox +
+
+
+ VM (QEMU) +
+
+
+ Container (LXC) +
+
+
+ Storage +
+
+
+
+ +
+
+
Dettagli Nodo
+ +
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/console.html b/templates/console.html new file mode 100644 index 0000000..34863db --- /dev/null +++ b/templates/console.html @@ -0,0 +1,81 @@ + + + + + + Console VM {{ vm_id }} - Proxmox Manager + + + +
+
+
+

Caricamento console...

+
+ +
+ + + + diff --git a/templates/domains.html b/templates/domains.html new file mode 100644 index 0000000..f40c137 --- /dev/null +++ b/templates/domains.html @@ -0,0 +1,441 @@ +{% extends "base.html" %} + +{% block title %}Gestione Domini - Proxmox Manager{% endblock %} + +{% block content %} +
+
+

Gestione Sottodomini

+

Crea e gestisci i tuoi sottodomini su {{ domain }}

+
+
+ + +
+
+ +
+
+
+

Caricamento sottodomini...

+
+
+ + + + + + +{% endblock %} + +{% block extra_scripts %} + + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..a81d567 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,288 @@ + + + + + + Proxmox API Tester + + + +

🔧 Proxmox API Tester

+ +
+

Informazioni Generali

+ + + + +
+ +
+

Gestione Backup

+ + +
+ +
+

Clicca su un pulsante per testare le API di Proxmox

+
+ + + + \ No newline at end of file diff --git a/templates/ipam.html b/templates/ipam.html new file mode 100644 index 0000000..3305ba2 --- /dev/null +++ b/templates/ipam.html @@ -0,0 +1,690 @@ +{% extends "base.html" %} + +{% block title %}Gestione IPAM - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Gestione IPAM

+

IP Address Management per VM e LXC

+
+
+ + + +
+
+ +
+
+

IP Totali

+
-
+
+
+
+
+
+

IP Assegnati

+
-
+
+
+
+
+
+

Conflitti

+
-
+
+
+
+
+
+ +
+
+ + + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + +
VM IDNomeTipoIndirizzo IPNodoStatoAzioni
+
+
+

Caricamento dati IPAM...

+
+
+
+
+ + + + + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..5109f91 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} + +{% block title %}Login - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/templates/overview.html b/templates/overview.html new file mode 100644 index 0000000..818ba56 --- /dev/null +++ b/templates/overview.html @@ -0,0 +1,335 @@ +{% extends "base.html" %} + +{% block title %}Overview - Proxmox Manager{% endblock %} + +{% block content %} +
+
+

Dashboard Overview

+

Riepilogo di tutte le tue macchine virtuali

+
+
+ +
+
+ + +
+
+
+ + + + + +
+
+
-
+
VM Totali
+
+
+
+
+ + + + +
+
+
-
+
In Esecuzione
+
+
+
+
+ + + + + +
+
+
-
+
Ferme
+
+
+
+
+ + + + + + + + + + + + +
+
+
-
+
CPU Medio
+
+
+
+ + +
+
Utilizzo Risorse
+
+
+

Memoria Totale

+
+ +
+
+
+

Stato VM

+
+ +
+
+
+
+ + +
+
Le Tue Macchine Virtuali
+
+
+
+

Caricamento...

+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..668627d --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,232 @@ +{% extends "base.html" %} + +{% block title %}Profilo - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Il Mio Profilo

+

Gestisci le tue informazioni personali

+
+
+ +
+ +
+
Informazioni Account
+ +
+
+
Username
+
{{ current_user.username }}
+
+ +
+
Email
+
{{ current_user.email or 'Non impostata' }}
+
+ +
+
Ruolo
+
+ {% if current_user.is_admin %} + ADMIN + {% else %} + UTENTE + {% endif %} +
+
+ +
+
ID Utente
+
#{{ current_user.id }}
+
+
+
+ + +
+ +
+

+ + + + + Modifica Email +

+
+
+ + +
+
+ + +
+ +
+
+ + +
+

+ + + + + Modifica Password +

+
+
+ + +
+
+ + + Minimo 6 caratteri +
+
+ + +
+ +
+
+
+
+ + +
+
Attività Recente
+
+
+
+

Caricamento attività...

+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/system_logs.html b/templates/system_logs.html new file mode 100644 index 0000000..4437f5f --- /dev/null +++ b/templates/system_logs.html @@ -0,0 +1,439 @@ +{% extends "base.html" %} + +{% block title %}Log di Sistema - Proxmox Manager{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block content %} +
+
+

Log di Sistema

+

Visualizza tutte le attività del sistema

+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
IDData/OraUtenteAzioneVM IDStatoIPDettagli
+
+
+

Caricamento log...

+
+
+
+ + +
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/templates/user_dashboard.html b/templates/user_dashboard.html new file mode 100644 index 0000000..5a9d86f --- /dev/null +++ b/templates/user_dashboard.html @@ -0,0 +1,859 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Proxmox Manager{% endblock %} + +{% block content %} +
+
+

Le Mie Macchine Virtuali

+

Gestisci le tue VM direttamente da qui

+
+
+ +
+
+ +
+
+
+

Caricamento VM in corso...

+
+
+ + + + + + + + + + + + +{% endblock %} + +{% block extra_styles %} + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %}