First Commit

This commit is contained in:
dedhersel
2026-02-17 12:43:27 +01:00
commit 38f85dc498
26 changed files with 9939 additions and 0 deletions

30
.env.example Normal file
View File

@@ -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

132
.gitignore vendored Normal file
View File

@@ -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

213
README.md Normal file
View File

@@ -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 <url-repository>
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

1710
app.py Normal file

File diff suppressed because it is too large Load Diff

33
config.py Normal file
View File

@@ -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')

242
create_database.py Normal file
View File

@@ -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()

408
models.py Normal file
View File

@@ -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()

8
requirements.txt Normal file
View File

@@ -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

68
schema.sql Normal file
View File

@@ -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');

586
static/css/dashboard.css Normal file
View File

@@ -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);
}
}

674
static/css/theme.css Normal file
View File

@@ -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); }

17
templates/404.html Normal file
View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Pagina non trovata - Proxmox Manager{% endblock %}
{% block content %}
<div style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 6rem; margin-bottom: 1rem;">🔍</div>
<h1 style="font-size: 3rem; color: #667eea; margin-bottom: 1rem;">404</h1>
<h2 style="color: #495057; margin-bottom: 2rem;">Pagina non trovata</h2>
<p style="color: #868e96; margin-bottom: 2rem;">
La pagina che stai cercando non esiste o è stata spostata.
</p>
<a href="{{ url_for('dashboard') if current_user.is_authenticated else url_for('login') }}" class="btn btn-primary">
Torna alla Home
</a>
</div>
{% endblock %}

17
templates/500.html Normal file
View File

@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Errore del Server - Proxmox Manager{% endblock %}
{% block content %}
<div style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 6rem; margin-bottom: 1rem;">⚠️</div>
<h1 style="font-size: 3rem; color: #ff6b6b; margin-bottom: 1rem;">500</h1>
<h2 style="color: #495057; margin-bottom: 2rem;">Errore del Server</h2>
<p style="color: #868e96; margin-bottom: 2rem;">
Si è verificato un errore interno. Riprova tra qualche istante.
</p>
<a href="{{ url_for('dashboard') if current_user.is_authenticated else url_for('login') }}" class="btn btn-primary">
Torna alla Home
</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,361 @@
{% extends "base.html" %}
{% block title %}Admin Dashboard - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.admin-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);
}
.admin-stat-card:hover {
border-color: var(--border-muted);
transform: translateY(-2px);
}
.admin-stat-card .stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.admin-stat-card.users .stat-icon {
background: rgba(88, 166, 255, 0.15);
color: var(--accent-blue);
}
.admin-stat-card.vms .stat-icon {
background: rgba(163, 113, 247, 0.15);
color: var(--accent-purple);
}
.admin-stat-card.active .stat-icon {
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.admin-stat-card.actions .stat-icon {
background: rgba(210, 153, 34, 0.15);
color: var(--accent-yellow);
}
.admin-stat-card .stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: var(--space-xs);
}
.admin-stat-card .stat-number {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
}
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.action-card {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-lg);
text-align: center;
cursor: pointer;
transition: all var(--transition-fast);
text-decoration: none;
}
.action-card:hover {
transform: translateY(-3px);
border-color: var(--accent-blue);
box-shadow: var(--shadow-md);
}
.action-icon {
width: 48px;
height: 48px;
margin: 0 auto var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-blue);
}
.action-title {
font-weight: 600;
color: var(--text-primary);
font-size: 0.9rem;
}
.recent-activity {
max-height: 450px;
overflow-y: auto;
}
.activity-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: var(--space-sm);
border-left: 3px solid var(--accent-blue);
}
.activity-item.success {
border-left-color: var(--accent-green);
}
.activity-item.failed {
border-left-color: var(--accent-red);
}
.activity-user {
font-weight: 600;
color: var(--text-primary);
}
.activity-action {
color: var(--accent-blue);
font-weight: 500;
}
.activity-time {
font-size: 0.8rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.activity-error {
font-size: 0.8rem;
color: var(--accent-red);
margin-top: var(--space-xs);
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Dashboard Amministratore</h1>
<p class="subtitle">Panoramica del sistema</p>
</div>
</div>
<div class="admin-stats-grid" id="statsGrid">
<div class="admin-stat-card users">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div>
<div class="stat-label">Utenti Totali</div>
<div class="stat-number" id="totalUsers">-</div>
</div>
</div>
<div class="admin-stat-card vms">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div>
<div class="stat-label">VM Gestite</div>
<div class="stat-number" id="totalVMs">-</div>
</div>
</div>
<div class="admin-stat-card active">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
</div>
<div>
<div class="stat-label">VM Attive</div>
<div class="stat-number" id="activeVMs">-</div>
</div>
</div>
<div class="admin-stat-card actions">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div class="stat-label">Azioni Oggi</div>
<div class="stat-number" id="actionsToday">-</div>
</div>
</div>
</div>
<div class="quick-actions">
<a class="action-card" href="{{ url_for('admin_users') }}">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="action-title">Gestione Utenti</div>
</a>
<a class="action-card" href="{{ url_for('system_logs') }}">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</div>
<div class="action-title">Log di Sistema</div>
</a>
<a class="action-card" href="{{ url_for('overview') }}">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
</div>
<div class="action-title">Overview VM</div>
</a>
<a class="action-card" href="{{ url_for('cluster_topology') }}">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<circle cx="6" cy="6" r="2"/>
<circle cx="18" cy="6" r="2"/>
<circle cx="6" cy="18" r="2"/>
<circle cx="18" cy="18" r="2"/>
<line x1="10.5" y1="10.5" x2="7.5" y2="7.5"/>
<line x1="13.5" y1="10.5" x2="16.5" y2="7.5"/>
<line x1="10.5" y1="13.5" x2="7.5" y2="16.5"/>
<line x1="13.5" y1="13.5" x2="16.5" y2="16.5"/>
</svg>
</div>
<div class="action-title">Topologia Cluster</div>
</a>
<a class="action-card" href="{{ url_for('ipam') }}">
<div class="action-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</div>
<div class="action-title">Gestione IPAM</div>
</a>
</div>
<div class="card">
<div class="card-title">Attività Recente</div>
<div class="recent-activity" id="recentActivity">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento attività...</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
loadDashboardStats();
loadRecentActivity();
// Ricarica ogni 30 secondi
setInterval(loadRecentActivity, 30000);
});
async function loadDashboardStats() {
const statsResult = await apiCall('/api/admin/stats');
if (statsResult.status === 'success') {
const stats = statsResult.data;
document.getElementById('totalUsers').textContent = stats.total_users;
document.getElementById('totalVMs').textContent = stats.total_vms;
document.getElementById('activeVMs').textContent = stats.active_vms;
document.getElementById('actionsToday').textContent = stats.actions_today;
} else {
console.error('Errore caricamento statistiche:', statsResult);
}
}
async function loadRecentActivity() {
const container = document.getElementById('recentActivity');
const result = await apiCall('/api/admin/logs?limit=20');
if (result.status === 'success') {
if (result.data.length === 0) {
container.innerHTML = '<p class="text-muted text-center" style="padding: var(--space-xl);">Nessuna attività registrata</p>';
return;
}
container.innerHTML = result.data.map(log => {
const statusClass = log.status === 'success' ? 'success' : 'failed';
const actionIcons = {
'start': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M10.804 8 5 4.633v6.734L10.804 8z"/></svg>',
'stop': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>',
'restart': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/></svg>',
'shutdown': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M7.5 1v7h1V1h-1z"/><path d="M3 8.812a4.999 4.999 0 0 1 2.578-4.375l-.485-.874A6 6 0 1 0 11 3.616l-.501.865A5 5 0 1 1 3 8.812z"/></svg>',
'backup': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>',
'login': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z"/><path fill-rule="evenodd" d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/></svg>'
};
const icon = actionIcons[log.action_type] || '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><circle cx="8" cy="8" r="3"/></svg>';
return `
<div class="activity-item ${statusClass}">
<div>
<span class="activity-user">${log.username}</span>
<span style="color: var(--text-secondary); margin: 0 var(--space-xs);">${icon}</span>
<span class="activity-action">${log.action_type}</span>
${log.vm_id > 0 ? `<span class="text-muted">su VM ${log.vm_id}</span>` : ''}
${log.status === 'failed' && log.error_message ? `<div class="activity-error">${log.error_message}</div>` : ''}
</div>
<div class="activity-time">${new Date(log.created_at).toLocaleString('it-IT')}</div>
</div>
`;
}).join('');
} else {
container.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
}
}
</script>
{% endblock %}

411
templates/admin_users.html Normal file
View File

@@ -0,0 +1,411 @@
{% extends "base.html" %}
{% block title %}Gestione Utenti - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.users-table-container {
overflow-x: auto;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.action-buttons .btn {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
.modal-content {
max-width: 600px;
}
.vm-assignments {
background: #f8f9fa;
padding: 1rem;
border-radius: 5px;
margin-top: 1rem;
}
.vm-assignment-item {
background: white;
padding: 0.75rem;
border-radius: 5px;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
font-weight: 500;
}
.badge-active {
background: #d3f9d8;
color: #2b8a3e;
}
.badge-inactive {
background: #ffe3e3;
color: #c92a2a;
}
</style>
{% endblock %}
{% block content %}
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1 class="card-title">Gestione Utenti</h1>
<p style="color: #868e96;">Amministra utenti e assegnazioni VM</p>
</div>
<button class="btn btn-primary" onclick="showCreateUserModal()"> Nuovo Utente</button>
</div>
</div>
<div class="card">
<div id="usersTableContainer">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento utenti...</p>
</div>
</div>
</div>
<!-- Modal Crea Utente -->
<div id="createUserModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('createUserModal')">&times;</span>
<h2>Crea Nuovo Utente</h2>
<form id="createUserForm" onsubmit="createUser(event)">
<div class="form-group">
<label for="newUsername">Username *</label>
<input type="text" id="newUsername" required>
</div>
<div class="form-group">
<label for="newEmail">Email *</label>
<input type="email" id="newEmail" required>
</div>
<div class="form-group">
<label for="newPassword">Password *</label>
<input type="password" id="newPassword" required>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="newIsAdmin" style="width: auto;">
Amministratore
</label>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Annulla</button>
<button type="submit" class="btn btn-primary">Crea Utente</button>
</div>
</form>
</div>
</div>
<!-- Modal Gestisci VM -->
<div id="manageVMsModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('manageVMsModal')">&times;</span>
<h2 id="manageVMsTitle">Gestisci VM</h2>
<div class="vm-assignments" id="currentVMsList">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento VM...</p>
</div>
</div>
<h3 style="margin-top: 1.5rem;">Aggiungi Nuova VM</h3>
<form id="assignVMForm" onsubmit="assignVM(event)">
<input type="hidden" id="assignUserId">
<div class="form-group">
<label for="vmId">VM ID *</label>
<input type="number" id="vmId" required placeholder="es. 114">
</div>
<div class="form-group">
<label for="vmName">Nome VM</label>
<input type="text" id="vmName" placeholder="es. buslino-vm">
</div>
<div class="form-group">
<label for="vmNotes">Note</label>
<textarea id="vmNotes" rows="3" placeholder="Note opzionali sulla VM"></textarea>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary" onclick="closeModal('manageVMsModal')">Chiudi</button>
<button type="submit" class="btn btn-primary">Assegna VM</button>
</div>
</form>
</div>
</div>
<!-- Modal Imposta IP -->
<div id="setIPModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<span class="close" onclick="closeModal('setIPModal')">&times;</span>
<h2>Imposta Indirizzo IP</h2>
<p id="setIPVmName" style="color: var(--text-secondary); margin-bottom: 1rem;"></p>
<form id="setIPForm" onsubmit="updateVMIP(event)">
<input type="hidden" id="setIPVmId">
<div class="form-group">
<label for="vmIpAddress">Indirizzo IP</label>
<input type="text"
id="vmIpAddress"
placeholder="es. 192.168.1.100"
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$">
<small style="color: var(--text-secondary); font-size: 0.85rem;">
Lascia vuoto per rimuovere l'IP
</small>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal('setIPModal')">Annulla</button>
<button type="submit" class="btn btn-primary">Salva IP</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let currentUsers = [];
document.addEventListener('DOMContentLoaded', function() {
loadUsers();
});
async function loadUsers() {
const container = document.getElementById('usersTableContainer');
const result = await apiCall('/api/admin/users');
if (result.status === 'success') {
currentUsers = result.data;
container.innerHTML = `
<div class="users-table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Ruolo</th>
<th>Ultimo Login</th>
<th>Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
${currentUsers.map(user => `
<tr>
<td>${user.id}</td>
<td><strong>${user.username}</strong></td>
<td>${user.email}</td>
<td>
${user.is_admin ?
'<span class="badge-admin">ADMIN</span>' :
'<span>Utente</span>'}
</td>
<td>${user.last_login ? new Date(user.last_login).toLocaleString('it-IT') : 'Mai'}</td>
<td>
<span class="badge ${user.active ? 'badge-active' : 'badge-inactive'}">
${user.active ? 'Attivo' : 'Disattivo'}
</span>
</td>
<td>
<div class="action-buttons">
<button class="btn btn-primary" onclick="manageUserVMs(${user.id}, '${user.username}')">
🖥️ VM
</button>
${user.active ? `
<button class="btn btn-warning" onclick="toggleUserStatus(${user.id}, false, '${user.username}')">
🚫 Disattiva
</button>
` : `
<button class="btn btn-success" onclick="toggleUserStatus(${user.id}, true, '${user.username}')">
✅ Attiva
</button>
`}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
container.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
}
}
function showCreateUserModal() {
document.getElementById('createUserModal').style.display = 'block';
}
function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
async function createUser(event) {
event.preventDefault();
const data = {
username: document.getElementById('newUsername').value,
email: document.getElementById('newEmail').value,
password: document.getElementById('newPassword').value,
is_admin: document.getElementById('newIsAdmin').checked
};
const result = await apiCall('/api/admin/user/create', 'POST', data);
if (result.status === 'success') {
showAlert('Utente creato con successo!', 'success');
closeModal('createUserModal');
document.getElementById('createUserForm').reset();
loadUsers();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function manageUserVMs(userId, username) {
document.getElementById('manageVMsTitle').textContent = `Gestisci VM - ${username}`;
document.getElementById('assignUserId').value = userId;
document.getElementById('manageVMsModal').style.display = 'block';
const vmsList = document.getElementById('currentVMsList');
vmsList.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento...</p></div>';
const result = await apiCall(`/api/admin/user/${userId}/vms`);
if (result.status === 'success') {
if (result.data.length === 0) {
vmsList.innerHTML = '<p style="text-align: center; color: #868e96;">Nessuna VM assegnata</p>';
} else {
vmsList.innerHTML = `
<h3>VM Assegnate</h3>
${result.data.map(vm => `
<div class="vm-assignment-item">
<div style="flex: 1;">
<strong>VM ${vm.vm_id}</strong> - ${vm.vm_name || 'N/A'}
${vm.notes ? `<br><small style="color: #868e96;">${vm.notes}</small>` : ''}
${vm.ip_address ? `<br><code style="font-size: 0.8rem; color: #58a6ff;">${vm.ip_address}</code>` : '<br><small style="color: #868e96;">Nessun IP impostato</small>'}
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-secondary btn-sm" onclick="showSetIPModal(${vm.vm_id}, '${vm.vm_name || 'VM ' + vm.vm_id}', '${vm.ip_address || ''}')">
🌐 IP
</button>
<button class="btn btn-danger btn-sm" onclick="removeVM(${userId}, ${vm.vm_id}, '${username}')">
🗑️
</button>
</div>
</div>
`).join('')}
`;
}
} else {
vmsList.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
}
}
async function assignVM(event) {
event.preventDefault();
const userId = document.getElementById('assignUserId').value;
const data = {
vm_id: parseInt(document.getElementById('vmId').value),
vm_name: document.getElementById('vmName').value,
notes: document.getElementById('vmNotes').value
};
const result = await apiCall(`/api/admin/user/${userId}/assign-vm`, 'POST', data);
if (result.status === 'success') {
showAlert('VM assegnata con successo!', 'success');
document.getElementById('assignVMForm').reset();
// Ricarica la lista delle VM
const username = document.getElementById('manageVMsTitle').textContent.split(' - ')[1];
manageUserVMs(userId, username);
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function removeVM(userId, vmId, username) {
if (!confirm(`Vuoi rimuovere la VM ${vmId} da ${username}?`)) return;
const result = await apiCall(`/api/admin/user/${userId}/remove-vm/${vmId}`, 'DELETE');
if (result.status === 'success') {
showAlert('VM rimossa con successo!', 'success');
manageUserVMs(userId, username);
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function toggleUserStatus(userId, active, username) {
const action = active ? 'attivare' : 'disattivare';
if (!confirm(`Vuoi davvero ${action} l'utente ${username}?`)) return;
const result = await apiCall(`/api/admin/user/${userId}/toggle-status`, 'POST', { active });
if (result.status === 'success') {
showAlert(result.message, 'success');
loadUsers();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
function showSetIPModal(vmId, vmName, currentIp) {
document.getElementById('setIPVmId').value = vmId;
document.getElementById('setIPVmName').textContent = `VM ${vmId} - ${vmName}`;
document.getElementById('vmIpAddress').value = currentIp || '';
document.getElementById('setIPModal').style.display = 'block';
}
async function updateVMIP(event) {
event.preventDefault();
const vmId = document.getElementById('setIPVmId').value;
const ipAddress = document.getElementById('vmIpAddress').value.trim();
const result = await apiCall(`/api/admin/vm/${vmId}/update-ip`, 'PUT', {
ip_address: ipAddress
});
if (result.status === 'success') {
showAlert('IP aggiornato con successo!', 'success');
closeModal('setIPModal');
// Ricarica la lista VM se il modal è aperto
const manageVMsModal = document.getElementById('manageVMsModal');
if (manageVMsModal.style.display === 'block') {
const userId = document.getElementById('assignUserId').value;
const username = document.getElementById('manageVMsTitle').textContent.split(' - ')[1];
manageUserVMs(userId, username);
}
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
// Chiudi modal cliccando fuori
window.onclick = function(event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script>
{% endblock %}

367
templates/base.html Normal file
View File

@@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Proxmox Manager{% endblock %}</title>
<!-- Chart.js per grafici -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- SweetAlert2 per dialoghi -->
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<!-- Theme CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
<!-- SweetAlert2 Dark Theme Override -->
<style>
/* SweetAlert2 Dark Theme */
.swal2-popup {
background: var(--bg-secondary) !important;
border: 1px solid var(--border-default) !important;
border-radius: var(--radius-lg) !important;
color: var(--text-primary) !important;
}
.swal2-title {
color: var(--text-primary) !important;
}
.swal2-html-container {
color: var(--text-secondary) !important;
}
.swal2-confirm {
background: var(--accent-blue) !important;
border-radius: var(--radius-md) !important;
}
.swal2-cancel {
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-radius: var(--radius-md) !important;
}
.swal2-timer-progress-bar {
background: var(--accent-blue) !important;
}
</style>
{% block extra_styles %}{% endblock %}
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar">
<a href="{{ url_for('dashboard') }}" class="navbar-brand">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
Proxmox Manager
</a>
<div class="navbar-menu">
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' else '' }}">
Dashboard
</a>
<a href="{{ url_for('overview') }}" class="{{ 'active' if request.endpoint == 'overview' else '' }}">
Overview
</a>
<a href="{{ url_for('domains') }}" class="{{ 'active' if request.endpoint == 'domains' else '' }}">
Domini
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin_users') }}" class="{{ 'active' if request.endpoint == 'admin_users' else '' }}">
Gestione Utenti
</a>
{% endif %}
<a href="{{ url_for('profile') }}" class="{{ 'active' if request.endpoint == 'profile' else '' }}">
Profilo
</a>
</div>
<div class="navbar-user">
<span>{{ current_user.username }}</span>
{% if current_user.is_admin %}
<span class="badge-admin">ADMIN</span>
{% endif %}
<a href="{{ url_for('logout') }}" class="btn btn-ghost btn-sm">Logout</a>
</div>
</nav>
{% endif %}
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<!-- Notification Container -->
<div id="notification-container" class="notification-container"></div>
<script>
// Helper function per chiamate API
async function apiCall(url, method = 'GET', data = null) {
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(url, options);
const result = await response.json();
return result;
} catch (error) {
console.error('API Error:', error);
return { status: 'error', message: error.message };
}
}
// Helper per mostrare alert con SweetAlert2
function showAlert(message, type = 'info') {
const icons = {
'success': 'success',
'error': 'error',
'info': 'info',
'warning': 'warning'
};
Swal.fire({
icon: icons[type] || 'info',
title: type === 'error' ? 'Errore' : type === 'success' ? 'Successo' : 'Info',
text: message,
showConfirmButton: true,
timer: type === 'success' ? 3000 : undefined,
timerProgressBar: type === 'success',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
confirmButtonColor: 'var(--accent-blue)'
});
}
// Toast notification leggera
function showToast(message, type = 'info') {
const container = document.getElementById('notification-container');
const toast = document.createElement('div');
toast.className = `notification-toast notification-${type}`;
const icons = {
'success': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
'error': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
'info': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>',
'warning': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>'
};
toast.innerHTML = `
<div class="notification-icon">${icons[type] || icons.info}</div>
<div class="notification-message">${message}</div>
<button class="notification-close" onclick="this.parentElement.remove()">&times;</button>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.add('show');
});
// Auto dismiss
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// Helper per formattare timestamp
function formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString('it-IT');
}
// Helper per formattare byte
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
// Helper per formattare uptime
function formatUptime(seconds) {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}g ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
// Notification System with SSE
class NotificationSystem {
constructor() {
this.eventSource = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
}
connect() {
if (this.eventSource) {
this.eventSource.close();
}
this.eventSource = new EventSource('/api/notifications/stream');
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== 'ping') {
showToast(data.message, data.notification_type || 'info');
}
} catch (e) {
console.error('Error parsing notification:', e);
}
};
this.eventSource.onerror = () => {
this.eventSource.close();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
setTimeout(() => this.connect(), 5000);
}
};
this.eventSource.onopen = () => {
this.reconnectAttempts = 0;
};
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
// Initialize notification system
const notificationSystem = new NotificationSystem();
// Connect if user is authenticated
{% if current_user.is_authenticated %}
document.addEventListener('DOMContentLoaded', () => {
notificationSystem.connect();
});
{% endif %}
</script>
<!-- Notification Styles -->
<style>
.notification-container {
position: fixed;
top: 80px;
right: var(--space-lg);
z-index: 9999;
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-width: 400px;
pointer-events: none;
}
.notification-toast {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
transform: translateX(120%);
transition: transform 0.3s ease;
pointer-events: auto;
}
.notification-toast.show {
transform: translateX(0);
}
.notification-toast.notification-success {
border-left: 4px solid var(--accent-green);
}
.notification-toast.notification-success .notification-icon {
color: var(--accent-green);
}
.notification-toast.notification-error {
border-left: 4px solid var(--accent-red);
}
.notification-toast.notification-error .notification-icon {
color: var(--accent-red);
}
.notification-toast.notification-warning {
border-left: 4px solid var(--accent-yellow);
}
.notification-toast.notification-warning .notification-icon {
color: var(--accent-yellow);
}
.notification-toast.notification-info {
border-left: 4px solid var(--accent-blue);
}
.notification-toast.notification-info .notification-icon {
color: var(--accent-blue);
}
.notification-icon {
flex-shrink: 0;
}
.notification-message {
flex: 1;
color: var(--text-primary);
font-size: 0.9rem;
}
.notification-close {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 1.25rem;
padding: 0;
line-height: 1;
transition: color var(--transition-fast);
}
.notification-close:hover {
color: var(--text-primary);
}
</style>
{% block extra_scripts %}{% endblock %}
</body>
</html>

File diff suppressed because it is too large Load Diff

81
templates/console.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Console VM {{ vm_id }} - Proxmox Manager</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #1a1a2e;
overflow: hidden;
}
#console-container {
width: 100vw;
height: 100vh;
position: relative;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: white;
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.1);
border-top: 4px solid #667eea;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="console-container">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento console...</p>
</div>
<iframe id="console-iframe" style="display: none;"></iframe>
</div>
<script>
// Costruisci URL console Proxmox (usa URL pubblico via reverse proxy)
const consoleUrl = `{{ config.PROXMOX_PUBLIC_URL }}/?console={{ console_type }}&novnc=1&node={{ node }}&resize=scale&vmid={{ vm_id }}&port={{ port }}`;
const iframe = document.getElementById('console-iframe');
const loading = document.querySelector('.loading');
// Carica console nell'iframe
iframe.onload = function() {
loading.style.display = 'none';
iframe.style.display = 'block';
};
iframe.onerror = function() {
loading.innerHTML = '<p style="color: #ff6b6b;">Errore caricamento console. Riprova.</p>';
};
// Imposta src per caricare
iframe.src = consoleUrl;
</script>
</body>
</html>

441
templates/domains.html Normal file
View File

@@ -0,0 +1,441 @@
{% extends "base.html" %}
{% block title %}Gestione Domini - Proxmox Manager{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Gestione Sottodomini</h1>
<p class="subtitle">Crea e gestisci i tuoi sottodomini su {{ domain }}</p>
</div>
<div class="dashboard-actions">
<button class="btn btn-primary" onclick="showCreateModal()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2z"/>
</svg>
Crea Sottodominio
</button>
<button class="btn btn-ghost btn-sm" onclick="loadSubdomains()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Aggiorna
</button>
</div>
</div>
<div id="subdomains-container">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento sottodomini...</p>
</div>
</div>
<!-- Modal per creare sottodominio -->
<div id="createModal" class="modal">
<div class="modal-content" style="max-width: 600px;">
<span class="close" onclick="closeCreateModal()">&times;</span>
<h2>Crea Nuovo Sottodominio</h2>
<form id="createSubdomainForm" onsubmit="createSubdomain(event)">
<div class="form-group">
<label for="subdomain">Nome Sottodominio *</label>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<input type="text"
id="subdomain"
name="subdomain"
class="form-control"
placeholder="es: server01"
pattern="[a-z0-9-]+"
required
style="flex: 1;">
<span class="domain-suffix" style="color: var(--text-secondary);">.{{ domain }}</span>
</div>
<small class="form-text">Solo lettere minuscole, numeri e trattini</small>
</div>
<div class="form-group">
<label for="ip_address">Indirizzo IP *</label>
<input type="text"
id="ip_address"
name="ip_address"
class="form-control"
placeholder="es: 192.168.1.100"
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
required>
<small class="form-text">Indirizzo IP di destinazione</small>
</div>
<div class="form-group">
<label for="vm_id">VM Associata (opzionale)</label>
<select id="vm_id" name="vm_id" class="form-control">
<option value="">Nessuna VM</option>
</select>
<small class="form-text">Collega questo sottodominio a una VM</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="proxied" name="proxied" checked>
<span>Abilita Cloudflare Proxy (CDN + Protezione DDoS)</span>
</label>
<small class="form-text">Consigliato per maggiore sicurezza e performance</small>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-lg);">
<button type="submit" class="btn btn-primary" style="flex: 1;">Crea Sottodominio</button>
<button type="button" class="btn btn-secondary" onclick="closeCreateModal()">Annulla</button>
</div>
</form>
</div>
</div>
<!-- Modal per modificare IP -->
<div id="editModal" class="modal">
<div class="modal-content" style="max-width: 500px;">
<span class="close" onclick="closeEditModal()">&times;</span>
<h2>Modifica Indirizzo IP</h2>
<form id="editIpForm" onsubmit="updateSubdomainIp(event)">
<input type="hidden" id="edit_subdomain_id">
<div class="form-group">
<label for="edit_subdomain_name">Sottodominio</label>
<input type="text"
id="edit_subdomain_name"
class="form-control"
disabled
style="background: var(--bg-tertiary);">
</div>
<div class="form-group">
<label for="edit_ip_address">Nuovo Indirizzo IP *</label>
<input type="text"
id="edit_ip_address"
name="ip_address"
class="form-control"
placeholder="es: 192.168.1.100"
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
required>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-lg);">
<button type="submit" class="btn btn-primary" style="flex: 1;">Aggiorna IP</button>
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">Annulla</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const DOMAIN = '{{ domain }}';
let userVMs = [];
document.addEventListener('DOMContentLoaded', function() {
loadSubdomains();
loadUserVMs();
});
async function loadUserVMs() {
try {
const result = await apiCall('/api/my-vms');
if (result.status === 'success') {
userVMs = result.data;
const vmSelect = document.getElementById('vm_id');
vmSelect.innerHTML = '<option value="">Nessuna VM</option>' +
userVMs.map(vm => `<option value="${vm.vm_id}">VM ${vm.vm_id} - ${vm.vm_name}</option>`).join('');
}
} catch (error) {
console.error('Errore caricamento VM:', error);
}
}
async function loadSubdomains() {
const container = document.getElementById('subdomains-container');
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento sottodomini...</p></div>';
const result = await apiCall('/api/subdomains');
if (result.status === 'success') {
const subdomains = result.data;
if (subdomains.length === 0) {
container.innerHTML = `
<div class="no-vms">
<div class="no-vms-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h2>Nessun Sottodominio</h2>
<p>Clicca su "Crea Sottodominio" per iniziare</p>
</div>
`;
} else {
container.innerHTML = `
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>Sottodominio</th>
<th>Indirizzo IP</th>
<th>VM</th>
<th>Proxy</th>
<th>Creato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody>
${subdomains.map(sub => {
const fullDomain = `${sub.subdomain}.${DOMAIN}`;
const vmInfo = sub.vm_id ? `VM ${sub.vm_id}` : '-';
const proxiedBadge = sub.proxied
? '<span class="badge badge-success">☁️ Proxied</span>'
: '<span class="badge badge-secondary">⚡ DNS Only</span>';
return `
<tr>
<td>
<div style="font-weight: 500;">${fullDomain}</div>
<a href="http://${fullDomain}" target="_blank" style="font-size: 0.8rem; color: var(--accent-primary);">
Apri →
</a>
</td>
<td><code>${sub.ip_address}</code></td>
<td>${vmInfo}</td>
<td>${proxiedBadge}</td>
<td style="color: var(--text-secondary); font-size: 0.9rem;">
${formatDate(sub.created_at)}
</td>
<td>
<button class="btn btn-sm btn-secondary"
onclick="showEditModal(${sub.id}, '${sub.subdomain}', '${sub.ip_address}')">
Modifica IP
</button>
<button class="btn btn-sm btn-danger"
onclick="deleteSubdomain(${sub.id}, '${fullDomain}')">
Elimina
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
} else {
container.innerHTML = `
<div class="alert alert-error">
Errore nel caricamento: ${result.message}
</div>
`;
}
}
function showCreateModal() {
document.getElementById('createModal').style.display = 'block';
document.getElementById('createSubdomainForm').reset();
document.getElementById('proxied').checked = true;
}
function closeCreateModal() {
document.getElementById('createModal').style.display = 'none';
}
async function createSubdomain(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = {
subdomain: formData.get('subdomain').toLowerCase().trim(),
ip_address: formData.get('ip_address').trim(),
vm_id: formData.get('vm_id') ? parseInt(formData.get('vm_id')) : null,
proxied: formData.get('proxied') === 'on'
};
showToast('Creazione sottodominio in corso...', 'info');
const result = await apiCall('/api/subdomain/create', 'POST', data);
if (result.status === 'success') {
showToast(result.message, 'success');
closeCreateModal();
loadSubdomains();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
function showEditModal(subdomainId, subdomain, currentIp) {
document.getElementById('edit_subdomain_id').value = subdomainId;
document.getElementById('edit_subdomain_name').value = `${subdomain}.${DOMAIN}`;
document.getElementById('edit_ip_address').value = currentIp;
document.getElementById('editModal').style.display = 'block';
}
function closeEditModal() {
document.getElementById('editModal').style.display = 'none';
}
async function updateSubdomainIp(event) {
event.preventDefault();
const subdomainId = document.getElementById('edit_subdomain_id').value;
const newIp = document.getElementById('edit_ip_address').value.trim();
showToast('Aggiornamento IP in corso...', 'info');
const result = await apiCall(`/api/subdomain/${subdomainId}/update-ip`, 'PUT', {
ip_address: newIp
});
if (result.status === 'success') {
showToast(result.message, 'success');
closeEditModal();
loadSubdomains();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function deleteSubdomain(subdomainId, fullDomain) {
const result = await Swal.fire({
title: 'Elimina Sottodominio',
html: `Vuoi eliminare il sottodominio <strong>${fullDomain}</strong>?<br>Questa operazione non può essere annullata.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#f85149'
});
if (!result.isConfirmed) return;
showToast('Eliminazione sottodominio in corso...', 'info');
const response = await apiCall(`/api/subdomain/${subdomainId}/delete`, 'DELETE');
if (response.status === 'success') {
showToast('Sottodominio eliminato con successo!', 'success');
loadSubdomains();
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
// Chiudi modal cliccando fuori
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
event.target.style.display = 'none';
}
}
</script>
<style>
.table-responsive {
overflow-x: auto;
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--space-md);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background: var(--bg-tertiary);
}
.data-table th {
padding: var(--space-md);
text-align: left;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.data-table td {
padding: var(--space-md);
border-top: 1px solid var(--border-default);
}
.data-table tbody tr:hover {
background: var(--bg-tertiary);
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
display: inline-block;
}
.badge-success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.badge-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.form-group {
margin-bottom: var(--space-lg);
}
.form-group label {
display: block;
margin-bottom: var(--space-sm);
font-weight: 500;
color: var(--text-primary);
}
.form-control {
width: 100%;
padding: var(--space-sm) var(--space-md);
background: var(--bg-tertiary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.9rem;
}
.form-control:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-text {
display: block;
margin-top: var(--space-xs);
font-size: 0.8rem;
color: var(--text-secondary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
</style>
{% endblock %}

288
templates/index.html Normal file
View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proxmox API Tester</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 {
color: #333;
}
.button-container {
margin: 20px 0;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #45a049;
}
button.backup-btn {
background-color: #2196F3;
}
button.backup-btn:hover {
background-color: #0b7dda;
}
#result {
background-color: white;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-top: 20px;
}
pre {
background-color: #f4f4f4;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
.error {
color: #d32f2f;
}
.success {
color: #388e3c;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #4CAF50;
color: white;
}
tr:hover {
background-color: #f5f5f5;
}
.backup-size {
color: #666;
font-size: 0.9em;
}
.backup-date {
color: #666;
}
input[type="text"] {
padding: 8px;
margin: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>🔧 Proxmox API Tester</h1>
<div class="button-container">
<h3>Informazioni Generali</h3>
<button onclick="testConnection()">Test Connessione</button>
<button onclick="getNodes()">Lista Nodi</button>
<button onclick="getResources()">Tutte le Risorse</button>
<button onclick="getStorage()">Storage Disponibili</button>
</div>
<div class="button-container">
<h3>Gestione Backup</h3>
<button class="backup-btn" onclick="getAllBackups()">📦 Tutti i Backup</button>
<button class="backup-btn" onclick="promptVMBackups()">🔍 Backup di una VM</button>
</div>
<div id="result">
<p>Clicca su un pulsante per testare le API di Proxmox</p>
</div>
<script>
function formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString('it-IT');
}
async function makeRequest(endpoint) {
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '<p>Caricamento...</p>';
try {
const response = await fetch(endpoint);
const data = await response.json();
if (data.status === 'success') {
resultDiv.innerHTML = `
<h3 class="success">✓ Richiesta completata</h3>
<pre>${JSON.stringify(data.data, null, 2)}</pre>
`;
} else {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore</h3>
<p>${data.message}</p>
`;
}
} catch (error) {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore di connessione</h3>
<p>${error.message}</p>
`;
}
}
async function getAllBackups() {
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '<p>Caricamento backup...</p>';
try {
const response = await fetch('/api/backups');
const data = await response.json();
if (data.status === 'success' && data.data.length > 0) {
let tableHTML = `
<h3 class="success">✓ Trovati ${data.count} backup</h3>
<table>
<thead>
<tr>
<th>VM/CT</th>
<th>Nome File</th>
<th>Storage</th>
<th>Dimensione</th>
<th>Data</th>
</tr>
</thead>
<tbody>
`;
data.data.forEach(backup => {
const volid = backup.volid || '';
const parts = volid.split('/');
const filename = parts[parts.length - 1];
const vmid = filename.match(/-(\d+)-/)?.[1] || 'N/A';
tableHTML += `
<tr>
<td><strong>${vmid}</strong></td>
<td>${filename}</td>
<td>${backup.storage}</td>
<td class="backup-size">${formatBytes(backup.size || 0)}</td>
<td class="backup-date">${formatDate(backup.ctime || 0)}</td>
</tr>
`;
});
tableHTML += '</tbody></table>';
resultDiv.innerHTML = tableHTML;
} else if (data.status === 'success') {
resultDiv.innerHTML = '<h3>Nessun backup trovato</h3>';
} else {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore</h3>
<p>${data.message}</p>
`;
}
} catch (error) {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore di connessione</h3>
<p>${error.message}</p>
`;
}
}
async function promptVMBackups() {
const vmid = prompt('Inserisci il VMID (es: 100, 101, 102):');
if (!vmid) return;
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = '<p>Caricamento backup per VM ' + vmid + '...</p>';
try {
const response = await fetch('/api/backups/vm/' + vmid);
const data = await response.json();
if (data.status === 'success' && data.data.length > 0) {
let tableHTML = `
<h3 class="success">✓ Trovati ${data.count} backup per VM ${vmid}</h3>
<table>
<thead>
<tr>
<th>Nome File</th>
<th>Storage</th>
<th>Dimensione</th>
<th>Data</th>
</tr>
</thead>
<tbody>
`;
data.data.forEach(backup => {
const volid = backup.volid || '';
const parts = volid.split('/');
const filename = parts[parts.length - 1];
tableHTML += `
<tr>
<td>${filename}</td>
<td>${backup.storage}</td>
<td class="backup-size">${formatBytes(backup.size || 0)}</td>
<td class="backup-date">${formatDate(backup.ctime || 0)}</td>
</tr>
`;
});
tableHTML += '</tbody></table>';
resultDiv.innerHTML = tableHTML;
} else if (data.status === 'success') {
resultDiv.innerHTML = '<h3>Nessun backup trovato per questa VM</h3>';
} else {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore</h3>
<p>${data.message}</p>
`;
}
} catch (error) {
resultDiv.innerHTML = `
<h3 class="error">✗ Errore di connessione</h3>
<p>${error.message}</p>
`;
}
}
function testConnection() {
makeRequest('/api/test-connection');
}
function getNodes() {
makeRequest('/api/nodes');
}
function getResources() {
makeRequest('/api/cluster/resources');
}
function getStorage() {
makeRequest('/api/storage');
}
</script>
</body>
</html>

690
templates/ipam.html Normal file
View File

@@ -0,0 +1,690 @@
{% extends "base.html" %}
{% block title %}Gestione IPAM - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.ipam-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
flex-wrap: wrap;
gap: var(--space-md);
}
.ipam-actions {
display: flex;
gap: var(--space-sm);
}
.ipam-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.ipam-stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.ipam-stat-card h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: var(--space-sm);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ipam-stat-card .stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-xs);
}
.ipam-stat-card .stat-bar {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-top: var(--space-sm);
}
.ipam-stat-card .stat-bar-fill {
height: 100%;
background: var(--accent-blue);
transition: width var(--transition-fast);
}
.ipam-filters {
display: flex;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.filter-input {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.9rem;
min-width: 200px;
}
.ipam-table-container {
overflow-x: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.ipam-table {
width: 100%;
border-collapse: collapse;
}
.ipam-table thead {
background: var(--bg-tertiary);
border-bottom: 2px solid var(--border-default);
}
.ipam-table th {
padding: var(--space-md);
text-align: left;
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
}
.ipam-table th:hover {
color: var(--accent-blue);
}
.ipam-table tbody tr {
border-bottom: 1px solid var(--border-default);
transition: background var(--transition-fast);
}
.ipam-table tbody tr:hover {
background: var(--bg-tertiary);
}
.ipam-table td {
padding: var(--space-md);
color: var(--text-primary);
font-size: 0.9rem;
}
.ip-badge {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
background: rgba(88, 166, 255, 0.15);
color: var(--accent-blue);
border-radius: var(--radius-sm);
font-family: 'Courier New', monospace;
font-weight: 600;
font-size: 0.9rem;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.running {
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.status-badge.stopped {
background: rgba(255, 78, 80, 0.15);
color: var(--accent-red);
}
.status-badge.unknown {
background: rgba(139, 148, 158, 0.15);
color: var(--text-tertiary);
}
.type-badge {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 600;
}
.type-badge.qemu {
background: rgba(163, 113, 247, 0.15);
color: var(--accent-purple);
}
.type-badge.lxc {
background: rgba(210, 153, 34, 0.15);
color: var(--accent-yellow);
}
.action-btns {
display: flex;
gap: var(--space-xs);
}
.action-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-primary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: 0.85rem;
}
.action-btn:hover {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.visible {
display: flex;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: var(--space-xl);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
cursor: pointer;
color: var(--text-secondary);
font-size: 1.5rem;
line-height: 1;
}
.modal-close:hover {
color: var(--accent-red);
}
.form-group {
margin-bottom: var(--space-md);
}
.form-label {
display: block;
margin-bottom: var(--space-xs);
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.9rem;
}
.form-input:focus {
outline: none;
border-color: var(--accent-blue);
}
.modal-actions {
display: flex;
gap: var(--space-md);
margin-top: var(--space-lg);
}
.conflict-badge {
background: rgba(255, 78, 80, 0.15);
color: var(--accent-red);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
margin-left: var(--space-xs);
}
</style>
{% endblock %}
{% block content %}
<div class="ipam-header">
<div>
<h1>Gestione IPAM</h1>
<p class="subtitle">IP Address Management per VM e LXC</p>
</div>
<div class="ipam-actions">
<button class="btn btn-primary" onclick="scanNetwork()">🔍 Scansiona Rete</button>
<button class="btn btn-primary" onclick="showAssignModal()"> Assegna IP Manuale</button>
<button class="btn btn-secondary" onclick="exportIPAM()">💾 Esporta CSV</button>
</div>
</div>
<div class="ipam-grid">
<div class="ipam-stat-card">
<h3>IP Totali</h3>
<div class="stat-number" id="statTotalIPs">-</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="statBarTotal" style="width: 100%"></div>
</div>
</div>
<div class="ipam-stat-card">
<h3>IP Assegnati</h3>
<div class="stat-number" id="statAssignedIPs">-</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="statBarAssigned" style="width: 0%; background: var(--accent-green);"></div>
</div>
</div>
<div class="ipam-stat-card">
<h3>Conflitti</h3>
<div class="stat-number" id="statConflicts">-</div>
<div class="stat-bar">
<div class="stat-bar-fill" id="statBarConflicts" style="width: 0%; background: var(--accent-red);"></div>
</div>
</div>
</div>
<div class="card">
<div class="ipam-filters">
<input type="text" class="filter-input" id="filterIP" placeholder="🔍 Filtra per IP..." oninput="filterIPAM()">
<input type="text" class="filter-input" id="filterName" placeholder="🔍 Filtra per nome VM..." oninput="filterIPAM()">
<select class="filter-input" id="filterType" onchange="filterIPAM()">
<option value="">Tutti i tipi</option>
<option value="qemu">QEMU</option>
<option value="lxc">LXC</option>
</select>
<select class="filter-input" id="filterStatus" onchange="filterIPAM()">
<option value="">Tutti gli stati</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
</div>
</div>
<div class="card">
<div class="ipam-table-container">
<table class="ipam-table">
<thead>
<tr>
<th onclick="sortTable('vmid')">VM ID</th>
<th onclick="sortTable('name')">Nome</th>
<th onclick="sortTable('type')">Tipo</th>
<th onclick="sortTable('ip')">Indirizzo IP</th>
<th onclick="sortTable('node')">Nodo</th>
<th onclick="sortTable('status')">Stato</th>
<th>Azioni</th>
</tr>
</thead>
<tbody id="ipamTableBody">
<tr>
<td colspan="7" style="text-align: center; padding: var(--space-xl);">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento dati IPAM...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Modal Assegnazione IP -->
<div class="modal" id="assignModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Assegna Indirizzo IP</div>
<span class="modal-close" onclick="closeModal('assignModal')">×</span>
</div>
<form id="assignForm" onsubmit="submitAssignIP(event)">
<div class="form-group">
<label class="form-label">VM ID</label>
<input type="number" class="form-input" id="assignVmId" required min="100">
</div>
<div class="form-group">
<label class="form-label">Indirizzo IP</label>
<input type="text" class="form-input" id="assignIP" required placeholder="192.168.1.100" pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$">
</div>
<div class="form-group">
<label class="form-label">Note (opzionale)</label>
<textarea class="form-input" id="assignNotes" rows="3" placeholder="Note aggiuntive..."></textarea>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Assegna</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('assignModal')">Annulla</button>
</div>
</form>
</div>
</div>
<!-- Modal Dettagli IP -->
<div class="modal" id="detailsModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Dettagli IP</div>
<span class="modal-close" onclick="closeModal('detailsModal')">×</span>
</div>
<div id="detailsContent"></div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let ipamData = [];
let filteredData = [];
let currentSort = { column: 'vmid', ascending: true };
document.addEventListener('DOMContentLoaded', function() {
loadIPAM();
});
async function loadIPAM() {
const result = await apiCall('/api/admin/ipam');
if (result.status === 'success') {
ipamData = result.data;
updateStats();
filterIPAM();
} else {
document.getElementById('ipamTableBody').innerHTML = `
<tr><td colspan="7" style="text-align: center;">
<div class="alert alert-error">${result.message}</div>
</td></tr>
`;
}
}
function updateStats() {
const totalIPs = ipamData.length;
const assignedIPs = ipamData.filter(item => item.ip && item.ip !== 'N/A').length;
const conflicts = ipamData.filter(item => item.conflict).length;
document.getElementById('statTotalIPs').textContent = totalIPs;
document.getElementById('statAssignedIPs').textContent = assignedIPs;
document.getElementById('statConflicts').textContent = conflicts;
const assignedPercent = totalIPs > 0 ? (assignedIPs / totalIPs) * 100 : 0;
const conflictPercent = totalIPs > 0 ? (conflicts / totalIPs) * 100 : 0;
document.getElementById('statBarAssigned').style.width = assignedPercent + '%';
document.getElementById('statBarConflicts').style.width = conflictPercent + '%';
}
function filterIPAM() {
const filterIP = document.getElementById('filterIP').value.toLowerCase();
const filterName = document.getElementById('filterName').value.toLowerCase();
const filterType = document.getElementById('filterType').value;
const filterStatus = document.getElementById('filterStatus').value;
filteredData = ipamData.filter(item => {
const matchIP = !filterIP || (item.ip && item.ip.toLowerCase().includes(filterIP));
const matchName = !filterName || (item.name && item.name.toLowerCase().includes(filterName));
const matchType = !filterType || item.type === filterType;
const matchStatus = !filterStatus || item.status === filterStatus;
return matchIP && matchName && matchType && matchStatus;
});
renderTable();
}
function renderTable() {
const tbody = document.getElementById('ipamTableBody');
if (filteredData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: var(--space-xl);">Nessun risultato trovato</td></tr>';
return;
}
tbody.innerHTML = filteredData.map(item => `
<tr>
<td><strong>${item.vmid}</strong></td>
<td>${item.name || 'N/A'}</td>
<td><span class="type-badge ${item.type}">${item.type.toUpperCase()}</span></td>
<td>
<span class="ip-badge">${item.ip || 'N/A'}</span>
${item.conflict ? '<span class="conflict-badge">CONFLITTO</span>' : ''}
</td>
<td>${item.node || 'N/A'}</td>
<td><span class="status-badge ${item.status || 'unknown'}">${item.status || 'unknown'}</span></td>
<td>
<div class="action-btns">
<button class="action-btn" onclick="showDetails(${item.vmid})" title="Dettagli">👁️</button>
<button class="action-btn" onclick="pingIP('${item.ip}')" title="Ping" ${!item.ip || item.ip === 'N/A' ? 'disabled' : ''}>📡</button>
<button class="action-btn" onclick="editIP(${item.vmid})" title="Modifica">✏️</button>
</div>
</td>
</tr>
`).join('');
}
function sortTable(column) {
if (currentSort.column === column) {
currentSort.ascending = !currentSort.ascending;
} else {
currentSort.column = column;
currentSort.ascending = true;
}
filteredData.sort((a, b) => {
let valA = a[column];
let valB = b[column];
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return currentSort.ascending ? -1 : 1;
if (valA > valB) return currentSort.ascending ? 1 : -1;
return 0;
});
renderTable();
}
async function scanNetwork() {
showAlert('Scansione della rete in corso...', 'info');
const result = await apiCall('/api/admin/ipam/scan', 'POST');
if (result.status === 'success') {
showAlert('Scansione completata con successo', 'success');
loadIPAM();
} else {
showAlert('Errore durante la scansione: ' + result.message, 'error');
}
}
function showAssignModal() {
document.getElementById('assignModal').classList.add('visible');
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('visible');
}
async function submitAssignIP(event) {
event.preventDefault();
const vmid = document.getElementById('assignVmId').value;
const ip = document.getElementById('assignIP').value;
const notes = document.getElementById('assignNotes').value;
const result = await apiCall('/api/admin/ipam/assign', 'POST', {
vmid: parseInt(vmid),
ip: ip,
notes: notes
});
if (result.status === 'success') {
showAlert('IP assegnato con successo', 'success');
closeModal('assignModal');
loadIPAM();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function showDetails(vmid) {
const item = ipamData.find(i => i.vmid === vmid);
if (!item) return;
const content = document.getElementById('detailsContent');
content.innerHTML = `
<div class="form-group">
<label class="form-label">VM ID</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.vmid}</div>
</div>
<div class="form-group">
<label class="form-label">Nome</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.name || 'N/A'}</div>
</div>
<div class="form-group">
<label class="form-label">Tipo</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.type}</div>
</div>
<div class="form-group">
<label class="form-label">IP</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.ip || 'N/A'}</div>
</div>
<div class="form-group">
<label class="form-label">Nodo</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.node || 'N/A'}</div>
</div>
<div class="form-group">
<label class="form-label">Stato</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.status || 'unknown'}</div>
</div>
${item.mac ? `
<div class="form-group">
<label class="form-label">MAC Address</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.mac}</div>
</div>
` : ''}
${item.notes ? `
<div class="form-group">
<label class="form-label">Note</label>
<div class="form-input" style="background: var(--bg-secondary);">${item.notes}</div>
</div>
` : ''}
`;
document.getElementById('detailsModal').classList.add('visible');
}
async function pingIP(ip) {
if (!ip || ip === 'N/A') return;
showAlert(`Ping di ${ip} in corso...`, 'info');
const result = await apiCall('/api/admin/ipam/ping', 'POST', { ip: ip });
if (result.status === 'success') {
showAlert(`Ping riuscito: ${result.message}`, 'success');
} else {
showAlert(`Ping fallito: ${result.message}`, 'error');
}
}
function editIP(vmid) {
const item = ipamData.find(i => i.vmid === vmid);
if (!item) return;
document.getElementById('assignVmId').value = item.vmid;
document.getElementById('assignVmId').readOnly = true;
document.getElementById('assignIP').value = item.ip || '';
document.getElementById('assignNotes').value = item.notes || '';
showAssignModal();
}
function exportIPAM() {
if (filteredData.length === 0) {
showAlert('Nessun dato da esportare', 'warning');
return;
}
const headers = ['VM ID', 'Nome', 'Tipo', 'IP', 'Nodo', 'Stato', 'MAC', 'Note'];
const rows = filteredData.map(item => [
item.vmid,
item.name || '',
item.type,
item.ip || '',
item.node || '',
item.status || '',
item.mac || '',
item.notes || ''
]);
let csvContent = headers.join(',') + '\n';
csvContent += rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `ipam_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showAlert('Dati IPAM esportati con successo', 'success');
}
</script>
{% endblock %}

203
templates/login.html Normal file
View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Login - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl);
}
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
width: 100%;
max-width: 420px;
box-shadow: var(--shadow-lg);
}
.login-header {
text-align: center;
margin-bottom: var(--space-xl);
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-sm);
}
.login-logo svg {
color: var(--accent-blue);
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.login-form .form-group {
margin-bottom: var(--space-lg);
}
.login-form .form-group label {
display: block;
margin-bottom: var(--space-sm);
font-weight: 500;
color: var(--text-primary);
font-size: 0.9rem;
}
.login-form .form-group input {
width: 100%;
padding: 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;
transition: all var(--transition-fast);
}
.login-form .form-group input:focus {
outline: none;
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
.login-form .form-group input::placeholder {
color: var(--text-tertiary);
}
.checkbox-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent-blue);
cursor: pointer;
}
.checkbox-group label {
margin-bottom: 0 !important;
cursor: pointer;
color: var(--text-secondary);
}
.btn-login {
width: 100%;
padding: var(--space-md);
background: var(--accent-blue);
color: #ffffff;
border: none;
border-radius: var(--radius-md);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-login:hover {
background: #4c9aed;
box-shadow: var(--shadow-glow-blue);
}
.btn-login:active {
transform: translateY(1px);
}
.login-footer {
margin-top: var(--space-xl);
padding-top: var(--space-lg);
border-top: 1px solid var(--border-muted);
text-align: center;
}
.login-footer p {
color: var(--text-tertiary);
font-size: 0.85rem;
margin-bottom: var(--space-xs);
}
.login-footer strong {
color: var(--text-secondary);
}
.login-divider {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-lg) 0;
color: var(--text-tertiary);
font-size: 0.85rem;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border-muted);
}
</style>
{% endblock %}
{% block content %}
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
Proxmox Manager
</div>
<p class="login-subtitle">Accedi al tuo pannello di controllo</p>
</div>
<form method="POST" action="{{ url_for('login') }}" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Inserisci username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Inserisci password" required>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="remember" name="remember">
<label for="remember">Ricordami su questo dispositivo</label>
</div>
<button type="submit" class="btn-login">Accedi</button>
</form>
<div class="login-footer">
<p>By Hersel Giannella</p>
</div>
</div>
{% endblock %}

335
templates/overview.html Normal file
View File

@@ -0,0 +1,335 @@
{% extends "base.html" %}
{% block title %}Overview - Proxmox Manager{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Dashboard Overview</h1>
<p class="subtitle">Riepilogo di tutte le tue macchine virtuali</p>
</div>
<div class="dashboard-actions">
<button class="btn btn-ghost btn-sm" onclick="loadOverviewStats()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Aggiorna
</button>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid" id="stats-grid">
<div class="stat-card stat-total">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="total-vms">-</div>
<div class="stat-label">VM Totali</div>
</div>
</div>
<div class="stat-card stat-running">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-green)" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="running-vms">-</div>
<div class="stat-label">In Esecuzione</div>
</div>
</div>
<div class="stat-card stat-stopped">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-red)" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="stopped-vms">-</div>
<div class="stat-label">Ferme</div>
</div>
</div>
<div class="stat-card stat-cpu">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
<rect x="9" y="9" width="6" height="6"/>
<line x1="9" y1="1" x2="9" y2="4"/>
<line x1="15" y1="1" x2="15" y2="4"/>
<line x1="9" y1="20" x2="9" y2="23"/>
<line x1="15" y1="20" x2="15" y2="23"/>
<line x1="20" y1="9" x2="23" y2="9"/>
<line x1="20" y1="14" x2="23" y2="14"/>
<line x1="1" y1="9" x2="4" y2="9"/>
<line x1="1" y1="14" x2="4" y2="14"/>
</svg>
</div>
<div class="stat-content">
<div class="stat-value" id="avg-cpu">-</div>
<div class="stat-label">CPU Medio</div>
</div>
</div>
</div>
<!-- Resource Charts -->
<div class="card mb-3">
<div class="card-title">Utilizzo Risorse</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-xl);">
<div>
<h3 style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: var(--space-md);">Memoria Totale</h3>
<div style="height: 200px; position: relative;">
<canvas id="memoryChart"></canvas>
</div>
</div>
<div>
<h3 style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: var(--space-md);">Stato VM</h3>
<div style="height: 200px; position: relative;">
<canvas id="statusChart"></canvas>
</div>
</div>
</div>
</div>
<!-- VM List -->
<div class="card">
<div class="card-title">Le Tue Macchine Virtuali</div>
<div class="vm-overview-list" id="vm-list">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento...</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let memoryChart = null;
let statusChart = null;
document.addEventListener('DOMContentLoaded', function() {
loadOverviewStats();
});
async function loadOverviewStats() {
const result = await apiCall('/api/overview/stats');
if (result.status === 'success') {
const data = result.data;
// Update stat cards
document.getElementById('total-vms').textContent = data.total_vms;
document.getElementById('running-vms').textContent = data.running_vms;
document.getElementById('stopped-vms').textContent = data.stopped_vms;
document.getElementById('avg-cpu').textContent = data.avg_cpu_usage + '%';
// Render charts
renderMemoryChart(data);
renderStatusChart(data);
// Render VM list
renderVMList(data.vms);
} else {
document.getElementById('vm-list').innerHTML = `
<div class="alert alert-error">
Errore nel caricamento: ${result.message}
</div>
`;
}
}
function renderMemoryChart(data) {
const ctx = document.getElementById('memoryChart').getContext('2d');
if (memoryChart) {
memoryChart.destroy();
}
const usedMemory = data.total_memory_used || 0;
const freeMemory = (data.total_memory_max || 0) - usedMemory;
memoryChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Utilizzata', 'Disponibile'],
datasets: [{
data: [usedMemory, freeMemory],
backgroundColor: ['#a371f7', '#21262d'],
borderWidth: 0,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: '#8b949e',
usePointStyle: true,
padding: 16
}
},
tooltip: {
backgroundColor: '#21262d',
titleColor: '#e6edf3',
bodyColor: '#8b949e',
borderColor: '#30363d',
borderWidth: 1,
callbacks: {
label: function(context) {
return formatBytes(context.raw);
}
}
}
}
},
plugins: [{
id: 'centerText',
afterDraw: function(chart) {
const ctx = chart.ctx;
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#e6edf3';
ctx.font = 'bold 24px sans-serif';
ctx.fillText(data.memory_percent + '%', centerX, centerY - 8);
ctx.fillStyle = '#8b949e';
ctx.font = '12px sans-serif';
ctx.fillText('utilizzata', centerX, centerY + 14);
ctx.restore();
}
}]
});
}
function renderStatusChart(data) {
const ctx = document.getElementById('statusChart').getContext('2d');
if (statusChart) {
statusChart.destroy();
}
statusChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Running', 'Stopped'],
datasets: [{
data: [data.running_vms, data.stopped_vms],
backgroundColor: ['#3fb950', '#f85149'],
borderWidth: 0,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: '#8b949e',
usePointStyle: true,
padding: 16
}
},
tooltip: {
backgroundColor: '#21262d',
titleColor: '#e6edf3',
bodyColor: '#8b949e',
borderColor: '#30363d',
borderWidth: 1
}
}
},
plugins: [{
id: 'centerText',
afterDraw: function(chart) {
const ctx = chart.ctx;
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#e6edf3';
ctx.font = 'bold 24px sans-serif';
ctx.fillText(data.total_vms, centerX, centerY - 8);
ctx.fillStyle = '#8b949e';
ctx.font = '12px sans-serif';
ctx.fillText('totali', centerX, centerY + 14);
ctx.restore();
}
}]
});
}
function renderVMList(vms) {
const container = document.getElementById('vm-list');
if (!vms || vms.length === 0) {
container.innerHTML = `
<p class="text-muted text-center" style="padding: var(--space-xl);">
Nessuna VM assegnata
</p>
`;
return;
}
container.innerHTML = vms.map(vm => {
const statusClass = vm.status === 'running' ? 'status-running' : 'status-stopped';
const statusText = vm.status === 'running' ? 'Running' : 'Stopped';
const cpuPercent = Math.round((vm.cpu || 0) * 100);
return `
<div class="vm-overview-item">
<div class="vm-overview-info">
<span class="status-badge ${statusClass}" style="margin-right: var(--space-md);">${statusText}</span>
<div>
<div class="vm-overview-name">${vm.name}</div>
<div class="vm-overview-id">ID: ${vm.vm_id}</div>
</div>
</div>
<div class="vm-overview-metrics">
<div class="vm-overview-metric">
<div class="vm-overview-metric-value">${cpuPercent}%</div>
<div class="vm-overview-metric-label">CPU</div>
</div>
<div class="vm-overview-metric">
<div class="vm-overview-metric-value">${vm.memory_percent || 0}%</div>
<div class="vm-overview-metric-label">RAM</div>
</div>
</div>
<div class="vm-overview-actions">
<a href="{{ url_for('dashboard') }}" class="btn btn-ghost btn-sm">Gestisci</a>
</div>
</div>
`;
}).join('');
}
</script>
{% endblock %}

232
templates/profile.html Normal file
View File

@@ -0,0 +1,232 @@
{% extends "base.html" %}
{% block title %}Profilo - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
}
@media (max-width: 768px) {
.profile-grid {
grid-template-columns: 1fr;
}
}
.profile-info {
background: var(--bg-tertiary);
padding: var(--space-lg);
border-radius: var(--radius-md);
border-left: 4px solid var(--accent-blue);
}
.profile-info-item {
margin-bottom: var(--space-md);
}
.profile-info-item:last-child {
margin-bottom: 0;
}
.profile-info-label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
margin-bottom: var(--space-xs);
}
.profile-info-value {
color: var(--text-primary);
font-size: 1rem;
}
.form-section {
margin-bottom: var(--space-lg);
}
.form-section h3 {
color: var(--text-primary);
margin-bottom: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--border-muted);
font-size: 1rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Il Mio Profilo</h1>
<p class="subtitle">Gestisci le tue informazioni personali</p>
</div>
</div>
<div class="profile-grid">
<!-- Info Profilo -->
<div class="card">
<div class="card-title">Informazioni Account</div>
<div class="profile-info">
<div class="profile-info-item">
<div class="profile-info-label">Username</div>
<div class="profile-info-value">{{ current_user.username }}</div>
</div>
<div class="profile-info-item">
<div class="profile-info-label">Email</div>
<div class="profile-info-value" id="currentEmail">{{ current_user.email or 'Non impostata' }}</div>
</div>
<div class="profile-info-item">
<div class="profile-info-label">Ruolo</div>
<div class="profile-info-value">
{% if current_user.is_admin %}
<span class="badge-admin">ADMIN</span>
{% else %}
<span class="status-badge" style="background: var(--bg-active); color: var(--accent-blue); border: 1px solid var(--accent-blue);">UTENTE</span>
{% endif %}
</div>
</div>
<div class="profile-info-item">
<div class="profile-info-label">ID Utente</div>
<div class="profile-info-value">#{{ current_user.id }}</div>
</div>
</div>
</div>
<!-- Form Modifica -->
<div>
<!-- Cambio Email -->
<div class="card form-section">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
Modifica Email
</h3>
<form id="emailForm" onsubmit="updateEmail(event)">
<div class="form-group">
<label for="newEmail">Nuova Email</label>
<input type="email" id="newEmail" placeholder="nuova@email.com" required>
</div>
<div class="form-group">
<label for="emailPassword">Password Corrente</label>
<input type="password" id="emailPassword" placeholder="Inserisci password" required>
</div>
<button type="submit" class="btn btn-primary">Aggiorna Email</button>
</form>
</div>
<!-- Cambio Password -->
<div class="card form-section">
<h3>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
Modifica Password
</h3>
<form id="passwordForm" onsubmit="updatePassword(event)">
<div class="form-group">
<label for="currentPassword">Password Corrente</label>
<input type="password" id="currentPassword" placeholder="Password attuale" required>
</div>
<div class="form-group">
<label for="newPassword">Nuova Password</label>
<input type="password" id="newPassword" placeholder="Nuova password" required minlength="6">
<small class="text-muted" style="font-size: 0.8rem; margin-top: var(--space-xs); display: block;">Minimo 6 caratteri</small>
</div>
<div class="form-group">
<label for="confirmPassword">Conferma Nuova Password</label>
<input type="password" id="confirmPassword" placeholder="Ripeti nuova password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary">Aggiorna Password</button>
</form>
</div>
</div>
</div>
<!-- Statistiche Attività -->
<div class="card">
<div class="card-title">Attività Recente</div>
<div id="userActivity">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento attività...</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
loadUserActivity();
});
async function updateEmail(event) {
event.preventDefault();
const newEmail = document.getElementById('newEmail').value;
const password = document.getElementById('emailPassword').value;
const result = await apiCall('/api/profile/update-email', 'POST', {
email: newEmail,
password: password
});
if (result.status === 'success') {
showToast('Email aggiornata con successo!', 'success');
document.getElementById('currentEmail').textContent = newEmail;
document.getElementById('emailForm').reset();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function updatePassword(event) {
event.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
showAlert('Le password non corrispondono', 'error');
return;
}
const result = await apiCall('/api/profile/update-password', 'POST', {
current_password: currentPassword,
new_password: newPassword,
confirm_password: confirmPassword
});
if (result.status === 'success') {
showToast('Password aggiornata con successo!', 'success');
document.getElementById('passwordForm').reset();
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function loadUserActivity() {
const container = document.getElementById('userActivity');
container.innerHTML = `
<p class="text-muted text-center" style="padding: var(--space-xl);">
Le statistiche attività saranno disponibili a breve
</p>
`;
}
</script>
{% endblock %}

439
templates/system_logs.html Normal file
View File

@@ -0,0 +1,439 @@
{% extends "base.html" %}
{% block title %}Log di Sistema - Proxmox Manager{% endblock %}
{% block extra_styles %}
<style>
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
flex-wrap: wrap;
gap: var(--space-md);
}
.logs-filters {
display: flex;
gap: var(--space-md);
align-items: center;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.filter-group label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.9rem;
}
.logs-table-container {
overflow-x: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table thead {
background: var(--bg-tertiary);
border-bottom: 2px solid var(--border-default);
}
.logs-table th {
padding: var(--space-md);
text-align: left;
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.logs-table tbody tr {
border-bottom: 1px solid var(--border-default);
transition: background var(--transition-fast);
}
.logs-table tbody tr:hover {
background: var(--bg-tertiary);
}
.logs-table td {
padding: var(--space-md);
color: var(--text-primary);
font-size: 0.9rem;
}
.log-status {
display: inline-flex;
align-items: center;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.log-status.success {
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.log-status.failed {
background: rgba(255, 78, 80, 0.15);
color: var(--accent-red);
}
.log-action-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: rgba(88, 166, 255, 0.15);
color: var(--accent-blue);
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
}
.log-error-message {
color: var(--accent-red);
font-size: 0.85rem;
font-style: italic;
margin-top: var(--space-xs);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-lg);
}
.pagination button {
padding: var(--space-sm) var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
transition: all var(--transition-fast);
}
.pagination button:hover:not(:disabled) {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
color: var(--text-secondary);
font-size: 0.9rem;
}
.export-btn {
padding: var(--space-sm) var(--space-md);
background: var(--accent-blue);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.export-btn:hover {
background: var(--accent-blue-hover);
transform: translateY(-1px);
}
.no-logs {
text-align: center;
padding: var(--space-xl);
color: var(--text-tertiary);
}
</style>
{% endblock %}
{% block content %}
<div class="logs-header">
<div>
<h1>Log di Sistema</h1>
<p class="subtitle">Visualizza tutte le attività del sistema</p>
</div>
<button class="export-btn" onclick="exportLogs()">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="margin-right: 8px; vertical-align: middle;">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
</svg>
Esporta CSV
</button>
</div>
<div class="card">
<div class="logs-filters">
<div class="filter-group">
<label>Utente</label>
<select id="filterUser" onchange="filterLogs()">
<option value="">Tutti gli utenti</option>
</select>
</div>
<div class="filter-group">
<label>Azione</label>
<select id="filterAction" onchange="filterLogs()">
<option value="">Tutte le azioni</option>
<option value="start">Start</option>
<option value="stop">Stop</option>
<option value="restart">Restart</option>
<option value="shutdown">Shutdown</option>
<option value="snapshot">Snapshot</option>
<option value="backup">Backup</option>
<option value="login">Login</option>
</select>
</div>
<div class="filter-group">
<label>Stato</label>
<select id="filterStatus" onchange="filterLogs()">
<option value="">Tutti gli stati</option>
<option value="success">Successo</option>
<option value="failed">Fallito</option>
</select>
</div>
<div class="filter-group">
<label>VM ID</label>
<input type="number" id="filterVmId" placeholder="ID VM" onchange="filterLogs()" min="0">
</div>
<div class="filter-group">
<label>Data</label>
<input type="date" id="filterDate" onchange="filterLogs()">
</div>
<div class="filter-group">
<label>Limiti</label>
<select id="filterLimit" onchange="filterLogs()">
<option value="50">50 record</option>
<option value="100" selected>100 record</option>
<option value="200">200 record</option>
<option value="500">500 record</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="logs-table-container">
<table class="logs-table">
<thead>
<tr>
<th>ID</th>
<th>Data/Ora</th>
<th>Utente</th>
<th>Azione</th>
<th>VM ID</th>
<th>Stato</th>
<th>IP</th>
<th>Dettagli</th>
</tr>
</thead>
<tbody id="logsTableBody">
<tr>
<td colspan="8" class="no-logs">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento log...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination" style="display: none;">
<button id="prevBtn" onclick="changePage(-1)">← Precedente</button>
<span class="pagination-info" id="pageInfo">Pagina 1</span>
<button id="nextBtn" onclick="changePage(1)">Successiva →</button>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let allLogs = [];
let filteredLogs = [];
let currentPage = 1;
const logsPerPage = 50;
document.addEventListener('DOMContentLoaded', function() {
loadLogs();
loadUsers();
});
async function loadUsers() {
const result = await apiCall('/api/admin/users');
if (result.status === 'success') {
const filterUser = document.getElementById('filterUser');
result.data.forEach(user => {
const option = document.createElement('option');
option.value = user.username;
option.textContent = user.username;
filterUser.appendChild(option);
});
}
}
async function loadLogs() {
const limit = document.getElementById('filterLimit').value;
const result = await apiCall(`/api/admin/logs?limit=${limit}`);
if (result.status === 'success') {
allLogs = result.data;
filterLogs();
} else {
document.getElementById('logsTableBody').innerHTML = `
<tr><td colspan="8" class="no-logs">
<div class="alert alert-error">${result.message}</div>
</td></tr>
`;
}
}
function filterLogs() {
const filterUser = document.getElementById('filterUser').value.toLowerCase();
const filterAction = document.getElementById('filterAction').value.toLowerCase();
const filterStatus = document.getElementById('filterStatus').value.toLowerCase();
const filterVmId = document.getElementById('filterVmId').value;
const filterDate = document.getElementById('filterDate').value;
filteredLogs = allLogs.filter(log => {
const matchUser = !filterUser || log.username.toLowerCase().includes(filterUser);
const matchAction = !filterAction || log.action_type === filterAction;
const matchStatus = !filterStatus || log.status === filterStatus;
const matchVmId = !filterVmId || log.vm_id.toString() === filterVmId;
const matchDate = !filterDate || new Date(log.created_at).toISOString().split('T')[0] === filterDate;
return matchUser && matchAction && matchStatus && matchVmId && matchDate;
});
currentPage = 1;
renderLogs();
}
function renderLogs() {
const tbody = document.getElementById('logsTableBody');
const pagination = document.getElementById('pagination');
if (filteredLogs.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">Nessun log trovato con i filtri applicati</td></tr>';
pagination.style.display = 'none';
return;
}
const totalPages = Math.ceil(filteredLogs.length / logsPerPage);
const startIdx = (currentPage - 1) * logsPerPage;
const endIdx = startIdx + logsPerPage;
const logsToShow = filteredLogs.slice(startIdx, endIdx);
const actionIcons = {
'start': '▶️',
'stop': '⏹️',
'restart': '🔄',
'shutdown': '🛑',
'snapshot': '📸',
'backup': '💾',
'login': '🔐'
};
tbody.innerHTML = logsToShow.map(log => {
const date = new Date(log.created_at);
const formattedDate = date.toLocaleString('it-IT');
const icon = actionIcons[log.action_type] || '•';
return `
<tr>
<td><strong>#${log.id}</strong></td>
<td>${formattedDate}</td>
<td><strong>${log.username}</strong></td>
<td><span class="log-action-badge">${icon} ${log.action_type}</span></td>
<td>${log.vm_id > 0 ? `VM ${log.vm_id}` : '-'}</td>
<td><span class="log-status ${log.status}">${log.status === 'success' ? 'Successo' : 'Fallito'}</span></td>
<td><code>${log.ip_address || '-'}</code></td>
<td>
${log.error_message ? `<div class="log-error-message">${log.error_message}</div>` : '-'}
</td>
</tr>
`;
}).join('');
// Update pagination
document.getElementById('pageInfo').textContent = `Pagina ${currentPage} di ${totalPages} (${filteredLogs.length} risultati)`;
document.getElementById('prevBtn').disabled = currentPage === 1;
document.getElementById('nextBtn').disabled = currentPage === totalPages;
pagination.style.display = 'flex';
}
function changePage(delta) {
currentPage += delta;
renderLogs();
}
function exportLogs() {
if (filteredLogs.length === 0) {
showAlert('Nessun log da esportare', 'warning');
return;
}
const headers = ['ID', 'Data/Ora', 'Utente', 'Azione', 'VM ID', 'Stato', 'IP', 'Errore'];
const rows = filteredLogs.map(log => [
log.id,
new Date(log.created_at).toLocaleString('it-IT'),
log.username,
log.action_type,
log.vm_id,
log.status,
log.ip_address || '',
log.error_message || ''
]);
let csvContent = headers.join(',') + '\n';
csvContent += rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `system_logs_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showAlert('Log esportati con successo', 'success');
}
</script>
{% endblock %}

View File

@@ -0,0 +1,859 @@
{% extends "base.html" %}
{% block title %}Dashboard - Proxmox Manager{% endblock %}
{% block content %}
<div class="dashboard-header">
<div>
<h1>Le Mie Macchine Virtuali</h1>
<p class="subtitle">Gestisci le tue VM direttamente da qui</p>
</div>
<div class="dashboard-actions">
<button class="btn btn-ghost btn-sm" onclick="loadVMs()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Aggiorna
</button>
</div>
</div>
<div id="vms-container">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento VM in corso...</p>
</div>
</div>
<!-- Modal per i backup -->
<div id="backupModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeBackupModal()">&times;</span>
<h2 id="backupModalTitle">Backup di VM</h2>
<div id="backupModalContent">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento backup...</p>
</div>
</div>
</div>
</div>
<!-- Modal per le snapshot -->
<div id="snapshotModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeSnapshotModal()">&times;</span>
<h2 id="snapshotModalTitle">Snapshot di VM</h2>
<div id="snapshotModalContent">
<div class="loading">
<div class="spinner"></div>
<p>Caricamento snapshot...</p>
</div>
</div>
</div>
</div>
<!-- Modal per la console -->
<div id="consoleModal" class="modal">
<div class="modal-content" style="max-width: 95vw; width: 1200px; height: 85vh; padding: 0; overflow: hidden;">
<div class="console-header">
<span id="consoleModalTitle">Console VM</span>
<div class="console-toolbar">
<button class="btn btn-ghost btn-sm" onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
<button class="btn btn-ghost btn-sm" onclick="toggleConsoleFullscreen()">Fullscreen</button>
<button class="btn btn-ghost btn-sm" onclick="closeConsoleModal()">&times;</button>
</div>
</div>
<div id="consoleContainer" style="width: 100%; height: calc(100% - 48px); background: #000;"></div>
</div>
</div>
<!-- Modal per grafici storici -->
<div id="historyModal" class="modal">
<div class="modal-content" style="max-width: 900px; width: 90%;">
<span class="close" onclick="closeHistoryModal()">&times;</span>
<h2 id="historyModalTitle">Storico Metriche</h2>
<div class="chart-controls" style="margin-bottom: var(--space-md);">
<button class="chart-btn active" data-timeframe="hour" onclick="loadHistoricalData('hour')">1H</button>
<button class="chart-btn" data-timeframe="day" onclick="loadHistoricalData('day')">24H</button>
<button class="chart-btn" data-timeframe="week" onclick="loadHistoricalData('week')">7G</button>
<button class="chart-btn" data-timeframe="month" onclick="loadHistoricalData('month')">30G</button>
</div>
<div id="historyChartContainer" style="height: 400px; position: relative;">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
{% endblock %}
{% block extra_styles %}
<style>
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-default);
}
.console-header span {
font-weight: 600;
color: var(--text-primary);
}
.console-toolbar {
display: flex;
gap: var(--space-sm);
}
#consoleContainer iframe {
width: 100%;
height: 100%;
border: none;
}
</style>
{% endblock %}
{% block extra_scripts %}
<script>
let currentVMs = [];
let cpuCharts = {};
let historyChart = null;
let currentHistoryVmId = null;
// Carica le VM al caricamento della pagina
document.addEventListener('DOMContentLoaded', function() {
loadVMs();
// Ricarica metriche ogni 5 secondi
setInterval(refreshMetrics, 5000);
});
function initCPUChart(vmId, cpuPercent) {
const canvas = document.getElementById(`cpu-chart-${vmId}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (cpuCharts[vmId]) {
cpuCharts[vmId].destroy();
}
cpuCharts[vmId] = new Chart(ctx, {
type: 'line',
data: {
labels: Array(10).fill(''),
datasets: [{
data: Array(10).fill(cpuPercent),
borderColor: 'rgba(88, 166, 255, 0.8)',
backgroundColor: 'rgba(88, 166, 255, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
x: { display: false },
y: { display: false, min: 0, max: 100 }
},
animation: { duration: 500 }
}
});
}
function updateCPUChart(vmId, newValue) {
const chart = cpuCharts[vmId];
if (!chart) return;
chart.data.datasets[0].data.shift();
chart.data.datasets[0].data.push(newValue);
chart.update('none');
}
async function refreshMetrics() {
if (currentVMs.length === 0) return;
for (const vm of currentVMs) {
try {
const result = await apiCall(`/api/vm/${vm.vm_id}/status`);
if (result.status === 'success') {
const vmData = result.data;
const vmCard = document.querySelector(`[data-vm-id="${vm.vm_id}"]`);
if (!vmCard) continue;
const cpuPercent = vmData.cpu ? Math.round(vmData.cpu * 100) : 0;
const memPercent = vmData.maxmem ? Math.round((vmData.mem / vmData.maxmem) * 100) : 0;
const diskPercent = vmData.maxdisk ? Math.round((vmData.disk / vmData.maxdisk) * 100) : 0;
// Aggiorna valori CPU
const cpuValue = vmCard.querySelector('.metric-box.cpu .metric-value');
if (cpuValue) {
cpuValue.textContent = cpuPercent + '%';
updateCPUChart(vm.vm_id, cpuPercent);
}
// Aggiorna valori RAM
const memValue = vmCard.querySelector('.metric-box.memory .metric-value');
const memSubvalue = vmCard.querySelector('.metric-box.memory .metric-subvalue');
if (memValue) memValue.textContent = memPercent + '%';
if (memSubvalue) memSubvalue.textContent = `${formatBytes(vmData.mem)} / ${formatBytes(vmData.maxmem)}`;
// Aggiorna valori Disk
const diskValue = vmCard.querySelector('.metric-box.disk .metric-value');
const diskSubvalue = vmCard.querySelector('.metric-box.disk .metric-subvalue');
if (diskValue) diskValue.textContent = diskPercent + '%';
if (diskSubvalue) diskSubvalue.textContent = `${formatBytes(vmData.disk || 0)} / ${formatBytes(vmData.maxdisk || 0)}`;
// Aggiorna uptime
const uptimeValue = vmCard.querySelector('.stat-uptime');
if (uptimeValue) uptimeValue.textContent = formatUptime(vmData.uptime);
// Se lo stato cambia, ricarica
if (vm.status !== vmData.status) {
loadVMs();
break;
}
}
} catch (error) {
console.error(`Errore refresh metriche VM ${vm.vm_id}:`, error);
}
}
}
async function loadVMs() {
const container = document.getElementById('vms-container');
const result = await apiCall('/api/my-vms');
if (result.status === 'success') {
currentVMs = result.data;
if (currentVMs.length === 0) {
container.innerHTML = `
<div class="no-vms">
<div class="no-vms-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<h2>Nessuna VM assegnata</h2>
<p>Contatta l'amministratore per richiedere l'accesso a una VM</p>
</div>
`;
return;
}
container.innerHTML = '<div class="vm-grid">' +
currentVMs.map(vm => createVMCard(vm)).join('') +
'</div>';
// Inizializza grafici CPU
setTimeout(() => {
currentVMs.forEach(vm => {
const cpuPercent = vm.cpu ? Math.round(vm.cpu * 100) : 0;
initCPUChart(vm.vm_id, cpuPercent);
});
}, 100);
} else {
container.innerHTML = `
<div class="alert alert-error">
Errore nel caricamento delle VM: ${result.message}
</div>
`;
}
}
function createVMCard(vm) {
const statusClass = vm.status === 'running' ? 'status-running' :
vm.status === 'stopped' ? 'status-stopped' : 'status-unknown';
const statusText = vm.status === 'running' ? 'Running' :
vm.status === 'stopped' ? 'Stopped' : 'Unknown';
const memPercent = vm.maxmem ? Math.round((vm.mem / vm.maxmem) * 100) : 0;
const cpuPercent = vm.cpu ? Math.round(vm.cpu * 100) : 0;
const diskPercent = vm.maxdisk ? Math.round((vm.disk / vm.maxdisk) * 100) : 0;
return `
<div class="vm-card ${statusClass}" data-vm-id="${vm.vm_id}">
<div class="vm-header">
<div>
<div class="vm-title">${vm.vm_name}</div>
<div class="vm-id">VM ID: ${vm.vm_id} | Node: ${vm.node}</div>
</div>
<span class="status-badge ${statusClass}">${statusText}</span>
</div>
${vm.notes ? `<div class="vm-notes">${vm.notes}</div>` : ''}
<div class="metrics-realtime">
<div class="metric-box cpu">
<div class="metric-label">CPU</div>
<div class="metric-value">${cpuPercent}%</div>
<canvas id="cpu-chart-${vm.vm_id}"></canvas>
</div>
<div class="metric-box memory">
<div class="metric-label">RAM</div>
<div class="metric-value">${memPercent}%</div>
<div class="metric-subvalue">${formatBytes(vm.mem)} / ${formatBytes(vm.maxmem)}</div>
</div>
<div class="metric-box disk">
<div class="metric-label">Disco</div>
<div class="metric-value">${diskPercent}%</div>
<div class="metric-subvalue">${formatBytes(vm.disk || 0)} / ${formatBytes(vm.maxdisk || 0)}</div>
</div>
</div>
<div class="vm-stats">
<div class="stat-item">
<div class="stat-label">Uptime</div>
<div class="stat-value stat-uptime">${formatUptime(vm.uptime)}</div>
</div>
<div class="stat-item">
<div class="stat-label">Tipo</div>
<div class="stat-value">${vm.type || 'QEMU'}</div>
</div>
${vm.ip_address ? `
<div class="stat-item">
<div class="stat-label">Indirizzo IP</div>
<div class="stat-value"><code style="font-size: 0.85rem;">${vm.ip_address}</code></div>
</div>
` : ''}
</div>
<div class="vm-actions">
${vm.status === 'running' ? `
<button class="btn btn-warning" onclick="shutdownVM(${vm.vm_id})">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M7.5 1v7h1V1h-1z"/><path d="M3 8.812a4.999 4.999 0 0 1 2.578-4.375l-.485-.874A6 6 0 1 0 11 3.616l-.501.865A5 5 0 1 1 3 8.812z"/></svg>
Spegni
</button>
<button class="btn btn-secondary" onclick="rebootVM(${vm.vm_id})">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/></svg>
Riavvia
</button>
<button class="btn btn-primary" onclick="openConsole(${vm.vm_id}, '${vm.vm_name}')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect width="14" height="10" x="1" y="2" rx="1"/><path d="M1 14h14v1H1z"/></svg>
Console
</button>
` : `
<button class="btn btn-success" onclick="startVM(${vm.vm_id})">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"/></svg>
Avvia
</button>
`}
<button class="btn btn-secondary" onclick="showHistory(${vm.vm_id}, '${vm.vm_name}')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 11H2v3h2v-3zm5-4H7v7h2V7zm5-5h-2v12h2V2zm-2-1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1h-2zM6 7a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm-5 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3z"/></svg>
Storico
</button>
<button class="btn btn-secondary" onclick="showBackups(${vm.vm_id}, '${vm.vm_name}')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
Backup
</button>
<button class="btn btn-secondary" onclick="showSnapshots(${vm.vm_id}, '${vm.vm_name}')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M10.5 8.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/><path d="M2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4H2zm.5 2a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 2.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z"/></svg>
Snapshot
</button>
</div>
</div>
`;
}
// ==================== VM ACTIONS ====================
async function startVM(vmId) {
const result = await Swal.fire({
title: 'Avvia VM',
text: 'Vuoi avviare questa macchina virtuale?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Avvia',
cancelButtonText: 'Annulla'
});
if (!result.isConfirmed) return;
showToast('Avvio VM in corso...', 'info');
const response = await apiCall(`/api/vm/${vmId}/start`, 'POST');
if (response.status === 'success') {
showToast('VM avviata con successo!', 'success');
setTimeout(loadVMs, 2000);
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
async function shutdownVM(vmId) {
const result = await Swal.fire({
title: 'Spegni VM',
text: 'Vuoi spegnere questa macchina virtuale? (shutdown graceful)',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Spegni',
cancelButtonText: 'Annulla'
});
if (!result.isConfirmed) return;
showToast('Spegnimento VM in corso...', 'info');
const response = await apiCall(`/api/vm/${vmId}/shutdown`, 'POST');
if (response.status === 'success') {
showToast('VM in fase di spegnimento...', 'success');
setTimeout(loadVMs, 2000);
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
async function rebootVM(vmId) {
const result = await Swal.fire({
title: 'Riavvia VM',
text: 'Vuoi riavviare questa macchina virtuale?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Riavvia',
cancelButtonText: 'Annulla'
});
if (!result.isConfirmed) return;
showToast('Riavvio VM in corso...', 'info');
const response = await apiCall(`/api/vm/${vmId}/reboot`, 'POST');
if (response.status === 'success') {
showToast('VM in fase di riavvio...', 'success');
setTimeout(loadVMs, 2000);
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
// ==================== CONSOLE ====================
async function openConsole(vmId, vmName) {
showToast('Apertura console...', 'info');
const result = await apiCall(`/api/vm/${vmId}/console`);
if (result.status === 'success') {
document.getElementById('consoleModalTitle').textContent = `Console: ${vmName} (VM ${vmId})`;
const container = document.getElementById('consoleContainer');
container.innerHTML = `
<iframe
src="${result.console_url}"
allow="clipboard-read; clipboard-write"
style="width: 100%; height: 100%; border: none;"
></iframe>
`;
document.getElementById('consoleModal').style.display = 'block';
showToast('Console aperta. Potrebbe essere necessario autenticarsi su Proxmox.', 'info');
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
function closeConsoleModal() {
document.getElementById('consoleModal').style.display = 'none';
document.getElementById('consoleContainer').innerHTML = '';
}
function toggleConsoleFullscreen() {
const modal = document.getElementById('consoleModal').querySelector('.modal-content');
modal.requestFullscreen?.() || modal.webkitRequestFullscreen?.() || modal.msRequestFullscreen?.();
}
function sendCtrlAltDel() {
showToast('Funzione disponibile solo con noVNC integrato', 'warning');
}
// ==================== HISTORY CHARTS ====================
function showHistory(vmId, vmName) {
currentHistoryVmId = vmId;
document.getElementById('historyModalTitle').textContent = `Storico Metriche: ${vmName}`;
document.getElementById('historyModal').style.display = 'block';
// Reset active button
document.querySelectorAll('.chart-btn').forEach(btn => btn.classList.remove('active'));
document.querySelector('.chart-btn[data-timeframe="hour"]').classList.add('active');
loadHistoricalData('hour');
}
async function loadHistoricalData(timeframe) {
if (!currentHistoryVmId) return;
// Update active button
document.querySelectorAll('.chart-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.timeframe === timeframe);
});
const result = await apiCall(`/api/vm/${currentHistoryVmId}/rrddata?timeframe=${timeframe}`);
if (result.status === 'success') {
renderHistoryChart(result.data, timeframe);
} else {
showAlert('Errore caricamento dati storici: ' + result.message, 'error');
}
}
function renderHistoryChart(data, timeframe) {
const ctx = document.getElementById('historyChart').getContext('2d');
if (historyChart) {
historyChart.destroy();
}
const labels = data.map(d => {
const date = new Date(d.time * 1000);
if (timeframe === 'hour') {
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
} else if (timeframe === 'day') {
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' });
}
});
historyChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'CPU %',
data: data.map(d => (d.cpu || 0) * 100),
borderColor: '#58a6ff',
backgroundColor: 'rgba(88, 166, 255, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0
},
{
label: 'RAM %',
data: data.map(d => {
const mem = d.mem || d.memory || 0;
const maxmem = d.maxmem || 1;
return (mem / maxmem) * 100;
}),
borderColor: '#a371f7',
backgroundColor: 'rgba(163, 113, 247, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
labels: { color: '#8b949e' }
},
tooltip: {
backgroundColor: '#21262d',
titleColor: '#e6edf3',
bodyColor: '#8b949e',
borderColor: '#30363d',
borderWidth: 1
}
},
scales: {
x: {
display: true,
grid: { color: '#21262d' },
ticks: { color: '#6e7681', maxTicksLimit: 10 }
},
y: {
display: true,
min: 0,
max: 100,
grid: { color: '#21262d' },
ticks: {
color: '#6e7681',
callback: value => value + '%'
}
}
}
}
});
}
function closeHistoryModal() {
document.getElementById('historyModal').style.display = 'none';
currentHistoryVmId = null;
}
// ==================== BACKUPS ====================
async function showBackups(vmId, vmName) {
const modal = document.getElementById('backupModal');
const title = document.getElementById('backupModalTitle');
const content = document.getElementById('backupModalContent');
title.textContent = `Backup: ${vmName} (VM ${vmId})`;
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento backup...</p></div>';
modal.style.display = 'block';
const result = await apiCall(`/api/vm/${vmId}/backups`);
if (result.status === 'success') {
if (result.data.length === 0) {
content.innerHTML = `
<p class="text-muted text-center" style="padding: var(--space-xl);">Nessun backup disponibile</p>
<div style="text-align: center;">
<button class="btn btn-primary" onclick="createBackup(${vmId})">Crea Backup</button>
</div>
`;
} else {
content.innerHTML = `
<p class="text-muted mb-2">Trovati ${result.count} backup</p>
<div class="backup-list">
${result.data.map(backup => {
const volid = backup.volid || '';
const parts = volid.split('/');
const filename = parts[parts.length - 1];
return `
<div class="backup-item">
<div class="backup-info">
<div class="backup-name">${filename}</div>
<div class="backup-date">${formatDate(backup.ctime || 0)}</div>
</div>
<div style="display: flex; align-items: center; gap: var(--space-md);">
<span class="backup-size">${formatBytes(backup.size || 0)}</span>
<button class="btn btn-danger btn-sm"
onclick="deleteBackup('${backup.node}', '${backup.storage}', '${filename}', ${vmId})">
Elimina
</button>
</div>
</div>
`;
}).join('')}
</div>
<div style="text-align: center; margin-top: var(--space-lg);">
<button class="btn btn-primary" onclick="createBackup(${vmId})">Crea Nuovo Backup</button>
</div>
`;
}
} else {
content.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
}
}
async function createBackup(vmId) {
const result = await Swal.fire({
title: 'Crea Backup',
text: 'Vuoi creare un backup di questa VM? L\'operazione potrebbe richiedere diversi minuti.',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Crea Backup',
cancelButtonText: 'Annulla'
});
if (!result.isConfirmed) return;
showToast('Creazione backup in corso...', 'info');
const response = await apiCall(`/api/vm/${vmId}/backup`, 'POST');
if (response.status === 'success') {
showToast('Backup avviato con successo!', 'success');
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
async function deleteBackup(node, storage, filename, vmId) {
const result = await Swal.fire({
title: 'Elimina Backup',
text: `Vuoi eliminare il backup "${filename}"? Questa operazione non può essere annullata.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#f85149'
});
if (!result.isConfirmed) return;
showToast('Eliminazione backup in corso...', 'info');
const response = await apiCall(`/api/backup/${node}/${storage}/${filename}/delete`, 'DELETE');
if (response.status === 'success') {
showToast('Backup eliminato con successo!', 'success');
const vmName = document.getElementById('backupModalTitle').textContent.split(':')[1].split('(')[0].trim();
showBackups(vmId, vmName);
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
function closeBackupModal() {
document.getElementById('backupModal').style.display = 'none';
}
// ==================== SNAPSHOTS ====================
async function showSnapshots(vmId, vmName) {
const modal = document.getElementById('snapshotModal');
const title = document.getElementById('snapshotModalTitle');
const content = document.getElementById('snapshotModalContent');
title.textContent = `Snapshot: ${vmName} (VM ${vmId})`;
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento snapshot...</p></div>';
modal.style.display = 'block';
const result = await apiCall(`/api/vm/${vmId}/snapshots`);
if (result.status === 'success') {
if (result.data.length === 0) {
content.innerHTML = `
<p class="text-muted text-center" style="padding: var(--space-xl);">Nessuna snapshot disponibile</p>
<div style="text-align: center;">
<button class="btn btn-primary" onclick="promptCreateSnapshot(${vmId})">Crea Snapshot</button>
</div>
`;
} else {
content.innerHTML = `
<p class="text-muted mb-2">Trovate ${result.count} snapshot (max 3 consentite)</p>
<div class="snapshot-list">
${result.data.map(snapshot => {
const snapTime = snapshot.snaptime ? formatDate(snapshot.snaptime) : 'N/A';
return `
<div class="snapshot-item">
<div class="snapshot-info">
<div class="snapshot-name">${snapshot.name}</div>
<div class="snapshot-date">${snapTime}</div>
${snapshot.description ? `<div class="text-muted" style="font-size: 0.8rem;">${snapshot.description}</div>` : ''}
</div>
<button class="btn btn-danger btn-sm"
onclick="deleteSnapshot(${vmId}, '${snapshot.name}', '${vmName}')">
Elimina
</button>
</div>
`;
}).join('')}
</div>
<div style="text-align: center; margin-top: var(--space-lg);">
${result.count < 3 ? `
<button class="btn btn-primary" onclick="promptCreateSnapshot(${vmId})">Crea Nuova Snapshot</button>
` : `
<p class="text-danger">Limite di 3 snapshot raggiunto. Elimina una snapshot per crearne una nuova.</p>
`}
</div>
`;
}
} else {
content.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
}
}
async function promptCreateSnapshot(vmId) {
const { value: formValues } = await Swal.fire({
title: 'Crea Snapshot',
html: `
<div class="form-group" style="text-align: left;">
<label>Nome (opzionale)</label>
<input id="swal-snapname" class="swal2-input" placeholder="auto-generato se vuoto">
</div>
<div class="form-group" style="text-align: left;">
<label>Descrizione (opzionale)</label>
<input id="swal-snapdesc" class="swal2-input" placeholder="Descrizione snapshot">
</div>
`,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Crea',
cancelButtonText: 'Annulla',
preConfirm: () => {
return {
snapname: document.getElementById('swal-snapname').value,
description: document.getElementById('swal-snapdesc').value
};
}
});
if (formValues) {
await createSnapshot(vmId, formValues.snapname || undefined, formValues.description || undefined);
}
}
async function createSnapshot(vmId, snapname, description) {
showToast('Creazione snapshot in corso...', 'info');
const data = {};
if (snapname) data.snapname = snapname;
if (description) data.description = description;
const result = await apiCall(`/api/vm/${vmId}/snapshot`, 'POST', data);
if (result.status === 'success') {
showToast('Snapshot creata con successo!', 'success');
const vmName = document.getElementById('snapshotModalTitle').textContent.split(':')[1].split('(')[0].trim();
showSnapshots(vmId, vmName);
} else {
showAlert('Errore: ' + result.message, 'error');
}
}
async function deleteSnapshot(vmId, snapname, vmName) {
const result = await Swal.fire({
title: 'Elimina Snapshot',
text: `Vuoi eliminare la snapshot "${snapname}"? Questa operazione non può essere annullata.`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Annulla',
confirmButtonColor: '#f85149'
});
if (!result.isConfirmed) return;
showToast('Eliminazione snapshot in corso...', 'info');
const response = await apiCall(`/api/vm/${vmId}/snapshot/${snapname}`, 'DELETE');
if (response.status === 'success') {
showToast('Snapshot eliminata con successo!', 'success');
showSnapshots(vmId, vmName);
} else {
showAlert('Errore: ' + response.message, 'error');
}
}
function closeSnapshotModal() {
document.getElementById('snapshotModal').style.display = 'none';
}
// ==================== MODAL CLOSE ON OUTSIDE CLICK ====================
window.onclick = function(event) {
if (event.target.classList.contains('modal')) {
event.target.style.display = 'none';
// Cleanup console if closing console modal
if (event.target.id === 'consoleModal') {
document.getElementById('consoleContainer').innerHTML = '';
}
}
}
</script>
{% endblock %}