First Commit

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

17
templates/404.html Normal file
View File

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

17
templates/500.html Normal file
View File

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

View File

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

411
templates/admin_users.html Normal file
View File

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

367
templates/base.html Normal file
View File

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

File diff suppressed because it is too large Load Diff

81
templates/console.html Normal file
View File

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

441
templates/domains.html Normal file
View File

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

288
templates/index.html Normal file
View File

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

690
templates/ipam.html Normal file
View File

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

203
templates/login.html Normal file
View File

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

335
templates/overview.html Normal file
View File

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

232
templates/profile.html Normal file
View File

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

439
templates/system_logs.html Normal file
View File

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

View File

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