First Commit
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal 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
132
.gitignore
vendored
Normal 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
213
README.md
Normal 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
|
||||||
33
config.py
Normal file
33
config.py
Normal 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
242
create_database.py
Normal 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
408
models.py
Normal 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
8
requirements.txt
Normal 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
68
schema.sql
Normal 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
586
static/css/dashboard.css
Normal 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
674
static/css/theme.css
Normal 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
17
templates/404.html
Normal 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
17
templates/500.html
Normal 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 %}
|
||||||
361
templates/admin_dashboard.html
Normal file
361
templates/admin_dashboard.html
Normal 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
411
templates/admin_users.html
Normal 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')">×</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')">×</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')">×</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
367
templates/base.html
Normal 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()">×</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>
|
||||||
1094
templates/cluster_topology.html
Normal file
1094
templates/cluster_topology.html
Normal file
File diff suppressed because it is too large
Load Diff
81
templates/console.html
Normal file
81
templates/console.html
Normal 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
441
templates/domains.html
Normal 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()">×</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()">×</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
288
templates/index.html
Normal 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
690
templates/ipam.html
Normal 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
203
templates/login.html
Normal 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
335
templates/overview.html
Normal 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
232
templates/profile.html
Normal 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
439
templates/system_logs.html
Normal 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 %}
|
||||||
859
templates/user_dashboard.html
Normal file
859
templates/user_dashboard.html
Normal 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()">×</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()">×</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()">×</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()">×</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 %}
|
||||||
Reference in New Issue
Block a user