First Commit
This commit is contained in:
17
templates/404.html
Normal file
17
templates/404.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Pagina non trovata - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 6rem; margin-bottom: 1rem;">🔍</div>
|
||||
<h1 style="font-size: 3rem; color: #667eea; margin-bottom: 1rem;">404</h1>
|
||||
<h2 style="color: #495057; margin-bottom: 2rem;">Pagina non trovata</h2>
|
||||
<p style="color: #868e96; margin-bottom: 2rem;">
|
||||
La pagina che stai cercando non esiste o è stata spostata.
|
||||
</p>
|
||||
<a href="{{ url_for('dashboard') if current_user.is_authenticated else url_for('login') }}" class="btn btn-primary">
|
||||
Torna alla Home
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
templates/500.html
Normal file
17
templates/500.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Errore del Server - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="text-align: center; padding: 4rem 2rem;">
|
||||
<div style="font-size: 6rem; margin-bottom: 1rem;">⚠️</div>
|
||||
<h1 style="font-size: 3rem; color: #ff6b6b; margin-bottom: 1rem;">500</h1>
|
||||
<h2 style="color: #495057; margin-bottom: 2rem;">Errore del Server</h2>
|
||||
<p style="color: #868e96; margin-bottom: 2rem;">
|
||||
Si è verificato un errore interno. Riprova tra qualche istante.
|
||||
</p>
|
||||
<a href="{{ url_for('dashboard') if current_user.is_authenticated else url_for('login') }}" class="btn btn-primary">
|
||||
Torna alla Home
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
361
templates/admin_dashboard.html
Normal file
361
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,361 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.admin-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-stat-card:hover {
|
||||
border-color: var(--border-muted);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.admin-stat-card .stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-stat-card.users .stat-icon {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.admin-stat-card.vms .stat-icon {
|
||||
background: rgba(163, 113, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.admin-stat-card.active .stat-icon {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.admin-stat-card.actions .stat-icon {
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.admin-stat-card .stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.admin-stat-card .stat-number {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.action-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.recent-activity {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.activity-item.success {
|
||||
border-left-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.activity-item.failed {
|
||||
border-left-color: var(--accent-red);
|
||||
}
|
||||
|
||||
.activity-user {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.activity-action {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-error {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-red);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h1>Dashboard Amministratore</h1>
|
||||
<p class="subtitle">Panoramica del sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-stats-grid" id="statsGrid">
|
||||
<div class="admin-stat-card users">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">Utenti Totali</div>
|
||||
<div class="stat-number" id="totalUsers">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card vms">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">VM Gestite</div>
|
||||
<div class="stat-number" id="totalVMs">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card active">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">VM Attive</div>
|
||||
<div class="stat-number" id="activeVMs">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card actions">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-label">Azioni Oggi</div>
|
||||
<div class="stat-number" id="actionsToday">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<a class="action-card" href="{{ url_for('admin_users') }}">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-title">Gestione Utenti</div>
|
||||
</a>
|
||||
<a class="action-card" href="{{ url_for('system_logs') }}">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-title">Log di Sistema</div>
|
||||
</a>
|
||||
<a class="action-card" href="{{ url_for('overview') }}">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-title">Overview VM</div>
|
||||
</a>
|
||||
<a class="action-card" href="{{ url_for('cluster_topology') }}">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<circle cx="6" cy="6" r="2"/>
|
||||
<circle cx="18" cy="6" r="2"/>
|
||||
<circle cx="6" cy="18" r="2"/>
|
||||
<circle cx="18" cy="18" r="2"/>
|
||||
<line x1="10.5" y1="10.5" x2="7.5" y2="7.5"/>
|
||||
<line x1="13.5" y1="10.5" x2="16.5" y2="7.5"/>
|
||||
<line x1="10.5" y1="13.5" x2="7.5" y2="16.5"/>
|
||||
<line x1="13.5" y1="13.5" x2="16.5" y2="16.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-title">Topologia Cluster</div>
|
||||
</a>
|
||||
<a class="action-card" href="{{ url_for('ipam') }}">
|
||||
<div class="action-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
||||
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="action-title">Gestione IPAM</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">Attività Recente</div>
|
||||
<div class="recent-activity" id="recentActivity">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento attività...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadDashboardStats();
|
||||
loadRecentActivity();
|
||||
|
||||
// Ricarica ogni 30 secondi
|
||||
setInterval(loadRecentActivity, 30000);
|
||||
});
|
||||
|
||||
async function loadDashboardStats() {
|
||||
const statsResult = await apiCall('/api/admin/stats');
|
||||
|
||||
if (statsResult.status === 'success') {
|
||||
const stats = statsResult.data;
|
||||
document.getElementById('totalUsers').textContent = stats.total_users;
|
||||
document.getElementById('totalVMs').textContent = stats.total_vms;
|
||||
document.getElementById('activeVMs').textContent = stats.active_vms;
|
||||
document.getElementById('actionsToday').textContent = stats.actions_today;
|
||||
} else {
|
||||
console.error('Errore caricamento statistiche:', statsResult);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentActivity() {
|
||||
const container = document.getElementById('recentActivity');
|
||||
|
||||
const result = await apiCall('/api/admin/logs?limit=20');
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (result.data.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted text-center" style="padding: var(--space-xl);">Nessuna attività registrata</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.data.map(log => {
|
||||
const statusClass = log.status === 'success' ? 'success' : 'failed';
|
||||
const actionIcons = {
|
||||
'start': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M10.804 8 5 4.633v6.734L10.804 8z"/></svg>',
|
||||
'stop': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><rect width="8" height="8" x="4" y="4"/></svg>',
|
||||
'restart': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/></svg>',
|
||||
'shutdown': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M7.5 1v7h1V1h-1z"/><path d="M3 8.812a4.999 4.999 0 0 1 2.578-4.375l-.485-.874A6 6 0 1 0 11 3.616l-.501.865A5 5 0 1 1 3 8.812z"/></svg>',
|
||||
'backup': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>',
|
||||
'login': '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6 3.5a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2a.5.5 0 0 0-1 0v2A1.5 1.5 0 0 0 6.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-8A1.5 1.5 0 0 0 5 3.5v2a.5.5 0 0 0 1 0v-2z"/><path fill-rule="evenodd" d="M11.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H1.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/></svg>'
|
||||
};
|
||||
const icon = actionIcons[log.action_type] || '<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><circle cx="8" cy="8" r="3"/></svg>';
|
||||
|
||||
return `
|
||||
<div class="activity-item ${statusClass}">
|
||||
<div>
|
||||
<span class="activity-user">${log.username}</span>
|
||||
<span style="color: var(--text-secondary); margin: 0 var(--space-xs);">${icon}</span>
|
||||
<span class="activity-action">${log.action_type}</span>
|
||||
${log.vm_id > 0 ? `<span class="text-muted">su VM ${log.vm_id}</span>` : ''}
|
||||
${log.status === 'failed' && log.error_message ? `<div class="activity-error">${log.error_message}</div>` : ''}
|
||||
</div>
|
||||
<div class="activity-time">${new Date(log.created_at).toLocaleString('it-IT')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
container.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
411
templates/admin_users.html
Normal file
411
templates/admin_users.html
Normal file
@@ -0,0 +1,411 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gestione Utenti - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.users-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-buttons .btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.vm-assignments {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.vm-assignment-item {
|
||||
background: white;
|
||||
padding: 0.75rem;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
background: #d3f9d8;
|
||||
color: #2b8a3e;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
background: #ffe3e3;
|
||||
color: #c92a2a;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h1 class="card-title">Gestione Utenti</h1>
|
||||
<p style="color: #868e96;">Amministra utenti e assegnazioni VM</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showCreateUserModal()">➕ Nuovo Utente</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div id="usersTableContainer">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento utenti...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Crea Utente -->
|
||||
<div id="createUserModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal('createUserModal')">×</span>
|
||||
<h2>Crea Nuovo Utente</h2>
|
||||
<form id="createUserForm" onsubmit="createUser(event)">
|
||||
<div class="form-group">
|
||||
<label for="newUsername">Username *</label>
|
||||
<input type="text" id="newUsername" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newEmail">Email *</label>
|
||||
<input type="email" id="newEmail" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newPassword">Password *</label>
|
||||
<input type="password" id="newPassword" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" id="newIsAdmin" style="width: auto;">
|
||||
Amministratore
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary">Crea Utente</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Gestisci VM -->
|
||||
<div id="manageVMsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal('manageVMsModal')">×</span>
|
||||
<h2 id="manageVMsTitle">Gestisci VM</h2>
|
||||
|
||||
<div class="vm-assignments" id="currentVMsList">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento VM...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1.5rem;">Aggiungi Nuova VM</h3>
|
||||
<form id="assignVMForm" onsubmit="assignVM(event)">
|
||||
<input type="hidden" id="assignUserId">
|
||||
<div class="form-group">
|
||||
<label for="vmId">VM ID *</label>
|
||||
<input type="number" id="vmId" required placeholder="es. 114">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vmName">Nome VM</label>
|
||||
<input type="text" id="vmName" placeholder="es. buslino-vm">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vmNotes">Note</label>
|
||||
<textarea id="vmNotes" rows="3" placeholder="Note opzionali sulla VM"></textarea>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('manageVMsModal')">Chiudi</button>
|
||||
<button type="submit" class="btn btn-primary">Assegna VM</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Imposta IP -->
|
||||
<div id="setIPModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<span class="close" onclick="closeModal('setIPModal')">×</span>
|
||||
<h2>Imposta Indirizzo IP</h2>
|
||||
<p id="setIPVmName" style="color: var(--text-secondary); margin-bottom: 1rem;"></p>
|
||||
|
||||
<form id="setIPForm" onsubmit="updateVMIP(event)">
|
||||
<input type="hidden" id="setIPVmId">
|
||||
<div class="form-group">
|
||||
<label for="vmIpAddress">Indirizzo IP</label>
|
||||
<input type="text"
|
||||
id="vmIpAddress"
|
||||
placeholder="es. 192.168.1.100"
|
||||
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$">
|
||||
<small style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
Lascia vuoto per rimuovere l'IP
|
||||
</small>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1.5rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('setIPModal')">Annulla</button>
|
||||
<button type="submit" class="btn btn-primary">Salva IP</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let currentUsers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const container = document.getElementById('usersTableContainer');
|
||||
|
||||
const result = await apiCall('/api/admin/users');
|
||||
|
||||
if (result.status === 'success') {
|
||||
currentUsers = result.data;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="users-table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Ruolo</th>
|
||||
<th>Ultimo Login</th>
|
||||
<th>Stato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${currentUsers.map(user => `
|
||||
<tr>
|
||||
<td>${user.id}</td>
|
||||
<td><strong>${user.username}</strong></td>
|
||||
<td>${user.email}</td>
|
||||
<td>
|
||||
${user.is_admin ?
|
||||
'<span class="badge-admin">ADMIN</span>' :
|
||||
'<span>Utente</span>'}
|
||||
</td>
|
||||
<td>${user.last_login ? new Date(user.last_login).toLocaleString('it-IT') : 'Mai'}</td>
|
||||
<td>
|
||||
<span class="badge ${user.active ? 'badge-active' : 'badge-inactive'}">
|
||||
${user.active ? 'Attivo' : 'Disattivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" onclick="manageUserVMs(${user.id}, '${user.username}')">
|
||||
🖥️ VM
|
||||
</button>
|
||||
${user.active ? `
|
||||
<button class="btn btn-warning" onclick="toggleUserStatus(${user.id}, false, '${user.username}')">
|
||||
🚫 Disattiva
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-success" onclick="toggleUserStatus(${user.id}, true, '${user.username}')">
|
||||
✅ Attiva
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateUserModal() {
|
||||
document.getElementById('createUserModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
|
||||
async function createUser(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
username: document.getElementById('newUsername').value,
|
||||
email: document.getElementById('newEmail').value,
|
||||
password: document.getElementById('newPassword').value,
|
||||
is_admin: document.getElementById('newIsAdmin').checked
|
||||
};
|
||||
|
||||
const result = await apiCall('/api/admin/user/create', 'POST', data);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('Utente creato con successo!', 'success');
|
||||
closeModal('createUserModal');
|
||||
document.getElementById('createUserForm').reset();
|
||||
loadUsers();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function manageUserVMs(userId, username) {
|
||||
document.getElementById('manageVMsTitle').textContent = `Gestisci VM - ${username}`;
|
||||
document.getElementById('assignUserId').value = userId;
|
||||
document.getElementById('manageVMsModal').style.display = 'block';
|
||||
|
||||
const vmsList = document.getElementById('currentVMsList');
|
||||
vmsList.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento...</p></div>';
|
||||
|
||||
const result = await apiCall(`/api/admin/user/${userId}/vms`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (result.data.length === 0) {
|
||||
vmsList.innerHTML = '<p style="text-align: center; color: #868e96;">Nessuna VM assegnata</p>';
|
||||
} else {
|
||||
vmsList.innerHTML = `
|
||||
<h3>VM Assegnate</h3>
|
||||
${result.data.map(vm => `
|
||||
<div class="vm-assignment-item">
|
||||
<div style="flex: 1;">
|
||||
<strong>VM ${vm.vm_id}</strong> - ${vm.vm_name || 'N/A'}
|
||||
${vm.notes ? `<br><small style="color: #868e96;">${vm.notes}</small>` : ''}
|
||||
${vm.ip_address ? `<br><code style="font-size: 0.8rem; color: #58a6ff;">${vm.ip_address}</code>` : '<br><small style="color: #868e96;">Nessun IP impostato</small>'}
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="showSetIPModal(${vm.vm_id}, '${vm.vm_name || 'VM ' + vm.vm_id}', '${vm.ip_address || ''}')">
|
||||
🌐 IP
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeVM(${userId}, ${vm.vm_id}, '${username}')">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
vmsList.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignVM(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const userId = document.getElementById('assignUserId').value;
|
||||
const data = {
|
||||
vm_id: parseInt(document.getElementById('vmId').value),
|
||||
vm_name: document.getElementById('vmName').value,
|
||||
notes: document.getElementById('vmNotes').value
|
||||
};
|
||||
|
||||
const result = await apiCall(`/api/admin/user/${userId}/assign-vm`, 'POST', data);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('VM assegnata con successo!', 'success');
|
||||
document.getElementById('assignVMForm').reset();
|
||||
// Ricarica la lista delle VM
|
||||
const username = document.getElementById('manageVMsTitle').textContent.split(' - ')[1];
|
||||
manageUserVMs(userId, username);
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeVM(userId, vmId, username) {
|
||||
if (!confirm(`Vuoi rimuovere la VM ${vmId} da ${username}?`)) return;
|
||||
|
||||
const result = await apiCall(`/api/admin/user/${userId}/remove-vm/${vmId}`, 'DELETE');
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('VM rimossa con successo!', 'success');
|
||||
manageUserVMs(userId, username);
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUserStatus(userId, active, username) {
|
||||
const action = active ? 'attivare' : 'disattivare';
|
||||
if (!confirm(`Vuoi davvero ${action} l'utente ${username}?`)) return;
|
||||
|
||||
const result = await apiCall(`/api/admin/user/${userId}/toggle-status`, 'POST', { active });
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert(result.message, 'success');
|
||||
loadUsers();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showSetIPModal(vmId, vmName, currentIp) {
|
||||
document.getElementById('setIPVmId').value = vmId;
|
||||
document.getElementById('setIPVmName').textContent = `VM ${vmId} - ${vmName}`;
|
||||
document.getElementById('vmIpAddress').value = currentIp || '';
|
||||
document.getElementById('setIPModal').style.display = 'block';
|
||||
}
|
||||
|
||||
async function updateVMIP(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const vmId = document.getElementById('setIPVmId').value;
|
||||
const ipAddress = document.getElementById('vmIpAddress').value.trim();
|
||||
|
||||
const result = await apiCall(`/api/admin/vm/${vmId}/update-ip`, 'PUT', {
|
||||
ip_address: ipAddress
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('IP aggiornato con successo!', 'success');
|
||||
closeModal('setIPModal');
|
||||
|
||||
// Ricarica la lista VM se il modal è aperto
|
||||
const manageVMsModal = document.getElementById('manageVMsModal');
|
||||
if (manageVMsModal.style.display === 'block') {
|
||||
const userId = document.getElementById('assignUserId').value;
|
||||
const username = document.getElementById('manageVMsTitle').textContent.split(' - ')[1];
|
||||
manageUserVMs(userId, username);
|
||||
}
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Chiudi modal cliccando fuori
|
||||
window.onclick = function(event) {
|
||||
if (event.target.className === 'modal') {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
367
templates/base.html
Normal file
367
templates/base.html
Normal file
@@ -0,0 +1,367 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Proxmox Manager{% endblock %}</title>
|
||||
|
||||
<!-- Chart.js per grafici -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- SweetAlert2 per dialoghi -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
|
||||
<!-- Theme CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
|
||||
|
||||
<!-- SweetAlert2 Dark Theme Override -->
|
||||
<style>
|
||||
/* SweetAlert2 Dark Theme */
|
||||
.swal2-popup {
|
||||
background: var(--bg-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.swal2-title {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.swal2-html-container {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.swal2-confirm {
|
||||
background: var(--accent-blue) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.swal2-cancel {
|
||||
background: var(--bg-tertiary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.swal2-timer-progress-bar {
|
||||
background: var(--accent-blue) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar">
|
||||
<a href="{{ url_for('dashboard') }}" class="navbar-brand">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
Proxmox Manager
|
||||
</a>
|
||||
<div class="navbar-menu">
|
||||
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' else '' }}">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('overview') }}" class="{{ 'active' if request.endpoint == 'overview' else '' }}">
|
||||
Overview
|
||||
</a>
|
||||
<a href="{{ url_for('domains') }}" class="{{ 'active' if request.endpoint == 'domains' else '' }}">
|
||||
Domini
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin_users') }}" class="{{ 'active' if request.endpoint == 'admin_users' else '' }}">
|
||||
Gestione Utenti
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('profile') }}" class="{{ 'active' if request.endpoint == 'profile' else '' }}">
|
||||
Profilo
|
||||
</a>
|
||||
</div>
|
||||
<div class="navbar-user">
|
||||
<span>{{ current_user.username }}</span>
|
||||
{% if current_user.is_admin %}
|
||||
<span class="badge-admin">ADMIN</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-ghost btn-sm">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Notification Container -->
|
||||
<div id="notification-container" class="notification-container"></div>
|
||||
|
||||
<script>
|
||||
// Helper function per chiamate API
|
||||
async function apiCall(url, method = 'GET', data = null) {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (data) {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return { status: 'error', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper per mostrare alert con SweetAlert2
|
||||
function showAlert(message, type = 'info') {
|
||||
const icons = {
|
||||
'success': 'success',
|
||||
'error': 'error',
|
||||
'info': 'info',
|
||||
'warning': 'warning'
|
||||
};
|
||||
|
||||
Swal.fire({
|
||||
icon: icons[type] || 'info',
|
||||
title: type === 'error' ? 'Errore' : type === 'success' ? 'Successo' : 'Info',
|
||||
text: message,
|
||||
showConfirmButton: true,
|
||||
timer: type === 'success' ? 3000 : undefined,
|
||||
timerProgressBar: type === 'success',
|
||||
background: 'var(--bg-secondary)',
|
||||
color: 'var(--text-primary)',
|
||||
confirmButtonColor: 'var(--accent-blue)'
|
||||
});
|
||||
}
|
||||
|
||||
// Toast notification leggera
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('notification-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `notification-toast notification-${type}`;
|
||||
|
||||
const icons = {
|
||||
'success': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
|
||||
'error': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
|
||||
'info': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>',
|
||||
'warning': '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="notification-icon">${icons[type] || icons.info}</div>
|
||||
<div class="notification-message">${message}</div>
|
||||
<button class="notification-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
// Auto dismiss
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Helper per formattare timestamp
|
||||
function formatDate(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('it-IT');
|
||||
}
|
||||
|
||||
// Helper per formattare byte
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper per formattare uptime
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return 'N/A';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) return `${days}g ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// Notification System with SSE
|
||||
class NotificationSystem {
|
||||
constructor() {
|
||||
this.eventSource = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
}
|
||||
|
||||
this.eventSource = new EventSource('/api/notifications/stream');
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== 'ping') {
|
||||
showToast(data.message, data.notification_type || 'info');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing notification:', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
this.eventSource.close();
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
setTimeout(() => this.connect(), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize notification system
|
||||
const notificationSystem = new NotificationSystem();
|
||||
|
||||
// Connect if user is authenticated
|
||||
{% if current_user.is_authenticated %}
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
notificationSystem.connect();
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<!-- Notification Styles -->
|
||||
<style>
|
||||
.notification-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: var(--space-lg);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateX(120%);
|
||||
transition: transform 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notification-toast.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.notification-toast.notification-success {
|
||||
border-left: 4px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.notification-toast.notification-success .notification-icon {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.notification-toast.notification-error {
|
||||
border-left: 4px solid var(--accent-red);
|
||||
}
|
||||
|
||||
.notification-toast.notification-error .notification-icon {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.notification-toast.notification-warning {
|
||||
border-left: 4px solid var(--accent-yellow);
|
||||
}
|
||||
|
||||
.notification-toast.notification-warning .notification-icon {
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.notification-toast.notification-info {
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.notification-toast.notification-info .notification-icon {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
1094
templates/cluster_topology.html
Normal file
1094
templates/cluster_topology.html
Normal file
File diff suppressed because it is too large
Load Diff
81
templates/console.html
Normal file
81
templates/console.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Console VM {{ vm_id }} - Proxmox Manager</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: #1a1a2e;
|
||||
overflow: hidden;
|
||||
}
|
||||
#console-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
.spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="console-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento console...</p>
|
||||
</div>
|
||||
<iframe id="console-iframe" style="display: none;"></iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Costruisci URL console Proxmox (usa URL pubblico via reverse proxy)
|
||||
const consoleUrl = `{{ config.PROXMOX_PUBLIC_URL }}/?console={{ console_type }}&novnc=1&node={{ node }}&resize=scale&vmid={{ vm_id }}&port={{ port }}`;
|
||||
|
||||
const iframe = document.getElementById('console-iframe');
|
||||
const loading = document.querySelector('.loading');
|
||||
|
||||
// Carica console nell'iframe
|
||||
iframe.onload = function() {
|
||||
loading.style.display = 'none';
|
||||
iframe.style.display = 'block';
|
||||
};
|
||||
|
||||
iframe.onerror = function() {
|
||||
loading.innerHTML = '<p style="color: #ff6b6b;">Errore caricamento console. Riprova.</p>';
|
||||
};
|
||||
|
||||
// Imposta src per caricare
|
||||
iframe.src = consoleUrl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
441
templates/domains.html
Normal file
441
templates/domains.html
Normal file
@@ -0,0 +1,441 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gestione Domini - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h1>Gestione Sottodomini</h1>
|
||||
<p class="subtitle">Crea e gestisci i tuoi sottodomini su {{ domain }}</p>
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2z"/>
|
||||
</svg>
|
||||
Crea Sottodominio
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadSubdomains()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="subdomains-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento sottodomini...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per creare sottodominio -->
|
||||
<div id="createModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<span class="close" onclick="closeCreateModal()">×</span>
|
||||
<h2>Crea Nuovo Sottodominio</h2>
|
||||
|
||||
<form id="createSubdomainForm" onsubmit="createSubdomain(event)">
|
||||
<div class="form-group">
|
||||
<label for="subdomain">Nome Sottodominio *</label>
|
||||
<div style="display: flex; align-items: center; gap: var(--space-sm);">
|
||||
<input type="text"
|
||||
id="subdomain"
|
||||
name="subdomain"
|
||||
class="form-control"
|
||||
placeholder="es: server01"
|
||||
pattern="[a-z0-9-]+"
|
||||
required
|
||||
style="flex: 1;">
|
||||
<span class="domain-suffix" style="color: var(--text-secondary);">.{{ domain }}</span>
|
||||
</div>
|
||||
<small class="form-text">Solo lettere minuscole, numeri e trattini</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ip_address">Indirizzo IP *</label>
|
||||
<input type="text"
|
||||
id="ip_address"
|
||||
name="ip_address"
|
||||
class="form-control"
|
||||
placeholder="es: 192.168.1.100"
|
||||
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
||||
required>
|
||||
<small class="form-text">Indirizzo IP di destinazione</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vm_id">VM Associata (opzionale)</label>
|
||||
<select id="vm_id" name="vm_id" class="form-control">
|
||||
<option value="">Nessuna VM</option>
|
||||
</select>
|
||||
<small class="form-text">Collega questo sottodominio a una VM</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="proxied" name="proxied" checked>
|
||||
<span>Abilita Cloudflare Proxy (CDN + Protezione DDoS)</span>
|
||||
</label>
|
||||
<small class="form-text">Consigliato per maggiore sicurezza e performance</small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-lg);">
|
||||
<button type="submit" class="btn btn-primary" style="flex: 1;">Crea Sottodominio</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCreateModal()">Annulla</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per modificare IP -->
|
||||
<div id="editModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<span class="close" onclick="closeEditModal()">×</span>
|
||||
<h2>Modifica Indirizzo IP</h2>
|
||||
|
||||
<form id="editIpForm" onsubmit="updateSubdomainIp(event)">
|
||||
<input type="hidden" id="edit_subdomain_id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit_subdomain_name">Sottodominio</label>
|
||||
<input type="text"
|
||||
id="edit_subdomain_name"
|
||||
class="form-control"
|
||||
disabled
|
||||
style="background: var(--bg-tertiary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit_ip_address">Nuovo Indirizzo IP *</label>
|
||||
<input type="text"
|
||||
id="edit_ip_address"
|
||||
name="ip_address"
|
||||
class="form-control"
|
||||
placeholder="es: 192.168.1.100"
|
||||
pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-lg);">
|
||||
<button type="submit" class="btn btn-primary" style="flex: 1;">Aggiorna IP</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">Annulla</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
const DOMAIN = '{{ domain }}';
|
||||
let userVMs = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSubdomains();
|
||||
loadUserVMs();
|
||||
});
|
||||
|
||||
async function loadUserVMs() {
|
||||
try {
|
||||
const result = await apiCall('/api/my-vms');
|
||||
if (result.status === 'success') {
|
||||
userVMs = result.data;
|
||||
const vmSelect = document.getElementById('vm_id');
|
||||
vmSelect.innerHTML = '<option value="">Nessuna VM</option>' +
|
||||
userVMs.map(vm => `<option value="${vm.vm_id}">VM ${vm.vm_id} - ${vm.vm_name}</option>`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Errore caricamento VM:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubdomains() {
|
||||
const container = document.getElementById('subdomains-container');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento sottodomini...</p></div>';
|
||||
|
||||
const result = await apiCall('/api/subdomains');
|
||||
|
||||
if (result.status === 'success') {
|
||||
const subdomains = result.data;
|
||||
|
||||
if (subdomains.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-vms">
|
||||
<div class="no-vms-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M2 12h20"/>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Nessun Sottodominio</h2>
|
||||
<p>Clicca su "Crea Sottodominio" per iniziare</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sottodominio</th>
|
||||
<th>Indirizzo IP</th>
|
||||
<th>VM</th>
|
||||
<th>Proxy</th>
|
||||
<th>Creato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${subdomains.map(sub => {
|
||||
const fullDomain = `${sub.subdomain}.${DOMAIN}`;
|
||||
const vmInfo = sub.vm_id ? `VM ${sub.vm_id}` : '-';
|
||||
const proxiedBadge = sub.proxied
|
||||
? '<span class="badge badge-success">☁️ Proxied</span>'
|
||||
: '<span class="badge badge-secondary">⚡ DNS Only</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 500;">${fullDomain}</div>
|
||||
<a href="http://${fullDomain}" target="_blank" style="font-size: 0.8rem; color: var(--accent-primary);">
|
||||
Apri →
|
||||
</a>
|
||||
</td>
|
||||
<td><code>${sub.ip_address}</code></td>
|
||||
<td>${vmInfo}</td>
|
||||
<td>${proxiedBadge}</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||
${formatDate(sub.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
onclick="showEditModal(${sub.id}, '${sub.subdomain}', '${sub.ip_address}')">
|
||||
Modifica IP
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
onclick="deleteSubdomain(${sub.id}, '${fullDomain}')">
|
||||
Elimina
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
Errore nel caricamento: ${result.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'block';
|
||||
document.getElementById('createSubdomainForm').reset();
|
||||
document.getElementById('proxied').checked = true;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function createSubdomain(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.target);
|
||||
const data = {
|
||||
subdomain: formData.get('subdomain').toLowerCase().trim(),
|
||||
ip_address: formData.get('ip_address').trim(),
|
||||
vm_id: formData.get('vm_id') ? parseInt(formData.get('vm_id')) : null,
|
||||
proxied: formData.get('proxied') === 'on'
|
||||
};
|
||||
|
||||
showToast('Creazione sottodominio in corso...', 'info');
|
||||
|
||||
const result = await apiCall('/api/subdomain/create', 'POST', data);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast(result.message, 'success');
|
||||
closeCreateModal();
|
||||
loadSubdomains();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showEditModal(subdomainId, subdomain, currentIp) {
|
||||
document.getElementById('edit_subdomain_id').value = subdomainId;
|
||||
document.getElementById('edit_subdomain_name').value = `${subdomain}.${DOMAIN}`;
|
||||
document.getElementById('edit_ip_address').value = currentIp;
|
||||
document.getElementById('editModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
document.getElementById('editModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function updateSubdomainIp(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const subdomainId = document.getElementById('edit_subdomain_id').value;
|
||||
const newIp = document.getElementById('edit_ip_address').value.trim();
|
||||
|
||||
showToast('Aggiornamento IP in corso...', 'info');
|
||||
|
||||
const result = await apiCall(`/api/subdomain/${subdomainId}/update-ip`, 'PUT', {
|
||||
ip_address: newIp
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast(result.message, 'success');
|
||||
closeEditModal();
|
||||
loadSubdomains();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubdomain(subdomainId, fullDomain) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Elimina Sottodominio',
|
||||
html: `Vuoi eliminare il sottodominio <strong>${fullDomain}</strong>?<br>Questa operazione non può essere annullata.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonColor: '#f85149'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Eliminazione sottodominio in corso...', 'info');
|
||||
|
||||
const response = await apiCall(`/api/subdomain/${subdomainId}/delete`, 'DELETE');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('Sottodominio eliminato con successo!', 'success');
|
||||
loadSubdomains();
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Chiudi modal cliccando fuori
|
||||
window.onclick = function(event) {
|
||||
if (event.target.classList.contains('modal')) {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: var(--space-md);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
margin-top: var(--space-xs);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
288
templates/index.html
Normal file
288
templates/index.html
Normal file
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Proxmox API Tester</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
.button-container {
|
||||
margin: 20px 0;
|
||||
}
|
||||
button {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
button.backup-btn {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
button.backup-btn:hover {
|
||||
background-color: #0b7dda;
|
||||
}
|
||||
#result {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
pre {
|
||||
background-color: #f4f4f4;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
.success {
|
||||
color: #388e3c;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.backup-size {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.backup-date {
|
||||
color: #666;
|
||||
}
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
margin: 5px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔧 Proxmox API Tester</h1>
|
||||
|
||||
<div class="button-container">
|
||||
<h3>Informazioni Generali</h3>
|
||||
<button onclick="testConnection()">Test Connessione</button>
|
||||
<button onclick="getNodes()">Lista Nodi</button>
|
||||
<button onclick="getResources()">Tutte le Risorse</button>
|
||||
<button onclick="getStorage()">Storage Disponibili</button>
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<h3>Gestione Backup</h3>
|
||||
<button class="backup-btn" onclick="getAllBackups()">📦 Tutti i Backup</button>
|
||||
<button class="backup-btn" onclick="promptVMBackups()">🔍 Backup di una VM</button>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<p>Clicca su un pulsante per testare le API di Proxmox</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('it-IT');
|
||||
}
|
||||
|
||||
async function makeRequest(endpoint) {
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = '<p>Caricamento...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="success">✓ Richiesta completata</h3>
|
||||
<pre>${JSON.stringify(data.data, null, 2)}</pre>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore</h3>
|
||||
<p>${data.message}</p>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore di connessione</h3>
|
||||
<p>${error.message}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllBackups() {
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = '<p>Caricamento backup...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backups');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.length > 0) {
|
||||
let tableHTML = `
|
||||
<h3 class="success">✓ Trovati ${data.count} backup</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VM/CT</th>
|
||||
<th>Nome File</th>
|
||||
<th>Storage</th>
|
||||
<th>Dimensione</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.data.forEach(backup => {
|
||||
const volid = backup.volid || '';
|
||||
const parts = volid.split('/');
|
||||
const filename = parts[parts.length - 1];
|
||||
const vmid = filename.match(/-(\d+)-/)?.[1] || 'N/A';
|
||||
|
||||
tableHTML += `
|
||||
<tr>
|
||||
<td><strong>${vmid}</strong></td>
|
||||
<td>${filename}</td>
|
||||
<td>${backup.storage}</td>
|
||||
<td class="backup-size">${formatBytes(backup.size || 0)}</td>
|
||||
<td class="backup-date">${formatDate(backup.ctime || 0)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHTML += '</tbody></table>';
|
||||
resultDiv.innerHTML = tableHTML;
|
||||
} else if (data.status === 'success') {
|
||||
resultDiv.innerHTML = '<h3>Nessun backup trovato</h3>';
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore</h3>
|
||||
<p>${data.message}</p>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore di connessione</h3>
|
||||
<p>${error.message}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptVMBackups() {
|
||||
const vmid = prompt('Inserisci il VMID (es: 100, 101, 102):');
|
||||
if (!vmid) return;
|
||||
|
||||
const resultDiv = document.getElementById('result');
|
||||
resultDiv.innerHTML = '<p>Caricamento backup per VM ' + vmid + '...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backups/vm/' + vmid);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success' && data.data.length > 0) {
|
||||
let tableHTML = `
|
||||
<h3 class="success">✓ Trovati ${data.count} backup per VM ${vmid}</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nome File</th>
|
||||
<th>Storage</th>
|
||||
<th>Dimensione</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.data.forEach(backup => {
|
||||
const volid = backup.volid || '';
|
||||
const parts = volid.split('/');
|
||||
const filename = parts[parts.length - 1];
|
||||
|
||||
tableHTML += `
|
||||
<tr>
|
||||
<td>${filename}</td>
|
||||
<td>${backup.storage}</td>
|
||||
<td class="backup-size">${formatBytes(backup.size || 0)}</td>
|
||||
<td class="backup-date">${formatDate(backup.ctime || 0)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHTML += '</tbody></table>';
|
||||
resultDiv.innerHTML = tableHTML;
|
||||
} else if (data.status === 'success') {
|
||||
resultDiv.innerHTML = '<h3>Nessun backup trovato per questa VM</h3>';
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore</h3>
|
||||
<p>${data.message}</p>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<h3 class="error">✗ Errore di connessione</h3>
|
||||
<p>${error.message}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
makeRequest('/api/test-connection');
|
||||
}
|
||||
|
||||
function getNodes() {
|
||||
makeRequest('/api/nodes');
|
||||
}
|
||||
|
||||
function getResources() {
|
||||
makeRequest('/api/cluster/resources');
|
||||
}
|
||||
|
||||
function getStorage() {
|
||||
makeRequest('/api/storage');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
690
templates/ipam.html
Normal file
690
templates/ipam.html
Normal file
@@ -0,0 +1,690 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gestione IPAM - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.ipam-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.ipam-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.ipam-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.ipam-stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.ipam-stat-card h3 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ipam-stat-card .stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.ipam-stat-card .stat-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.ipam-stat-card .stat-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-blue);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.ipam-filters {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.ipam-table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.ipam-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ipam-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 2px solid var(--border-default);
|
||||
}
|
||||
|
||||
.ipam-table th {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ipam-table th:hover {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.ipam-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.ipam-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ipam-table td {
|
||||
padding: var(--space-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--accent-blue);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background: rgba(255, 78, 80, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.status-badge.unknown {
|
||||
background: rgba(139, 148, 158, 0.15);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-badge.qemu {
|
||||
background: rgba(163, 113, 247, 0.15);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.type-badge.lxc {
|
||||
background: rgba(210, 153, 34, 0.15);
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.conflict-badge {
|
||||
background: rgba(255, 78, 80, 0.15);
|
||||
color: var(--accent-red);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ipam-header">
|
||||
<div>
|
||||
<h1>Gestione IPAM</h1>
|
||||
<p class="subtitle">IP Address Management per VM e LXC</p>
|
||||
</div>
|
||||
<div class="ipam-actions">
|
||||
<button class="btn btn-primary" onclick="scanNetwork()">🔍 Scansiona Rete</button>
|
||||
<button class="btn btn-primary" onclick="showAssignModal()">➕ Assegna IP Manuale</button>
|
||||
<button class="btn btn-secondary" onclick="exportIPAM()">💾 Esporta CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ipam-grid">
|
||||
<div class="ipam-stat-card">
|
||||
<h3>IP Totali</h3>
|
||||
<div class="stat-number" id="statTotalIPs">-</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" id="statBarTotal" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ipam-stat-card">
|
||||
<h3>IP Assegnati</h3>
|
||||
<div class="stat-number" id="statAssignedIPs">-</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" id="statBarAssigned" style="width: 0%; background: var(--accent-green);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ipam-stat-card">
|
||||
<h3>Conflitti</h3>
|
||||
<div class="stat-number" id="statConflicts">-</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" id="statBarConflicts" style="width: 0%; background: var(--accent-red);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ipam-filters">
|
||||
<input type="text" class="filter-input" id="filterIP" placeholder="🔍 Filtra per IP..." oninput="filterIPAM()">
|
||||
<input type="text" class="filter-input" id="filterName" placeholder="🔍 Filtra per nome VM..." oninput="filterIPAM()">
|
||||
<select class="filter-input" id="filterType" onchange="filterIPAM()">
|
||||
<option value="">Tutti i tipi</option>
|
||||
<option value="qemu">QEMU</option>
|
||||
<option value="lxc">LXC</option>
|
||||
</select>
|
||||
<select class="filter-input" id="filterStatus" onchange="filterIPAM()">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="stopped">Stopped</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ipam-table-container">
|
||||
<table class="ipam-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortTable('vmid')">VM ID</th>
|
||||
<th onclick="sortTable('name')">Nome</th>
|
||||
<th onclick="sortTable('type')">Tipo</th>
|
||||
<th onclick="sortTable('ip')">Indirizzo IP</th>
|
||||
<th onclick="sortTable('node')">Nodo</th>
|
||||
<th onclick="sortTable('status')">Stato</th>
|
||||
<th>Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ipamTableBody">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; padding: var(--space-xl);">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento dati IPAM...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Assegnazione IP -->
|
||||
<div class="modal" id="assignModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Assegna Indirizzo IP</div>
|
||||
<span class="modal-close" onclick="closeModal('assignModal')">×</span>
|
||||
</div>
|
||||
<form id="assignForm" onsubmit="submitAssignIP(event)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">VM ID</label>
|
||||
<input type="number" class="form-input" id="assignVmId" required min="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Indirizzo IP</label>
|
||||
<input type="text" class="form-input" id="assignIP" required placeholder="192.168.1.100" pattern="^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Note (opzionale)</label>
|
||||
<textarea class="form-input" id="assignNotes" rows="3" placeholder="Note aggiuntive..."></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Assegna</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('assignModal')">Annulla</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Dettagli IP -->
|
||||
<div class="modal" id="detailsModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">Dettagli IP</div>
|
||||
<span class="modal-close" onclick="closeModal('detailsModal')">×</span>
|
||||
</div>
|
||||
<div id="detailsContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let ipamData = [];
|
||||
let filteredData = [];
|
||||
let currentSort = { column: 'vmid', ascending: true };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadIPAM();
|
||||
});
|
||||
|
||||
async function loadIPAM() {
|
||||
const result = await apiCall('/api/admin/ipam');
|
||||
|
||||
if (result.status === 'success') {
|
||||
ipamData = result.data;
|
||||
updateStats();
|
||||
filterIPAM();
|
||||
} else {
|
||||
document.getElementById('ipamTableBody').innerHTML = `
|
||||
<tr><td colspan="7" style="text-align: center;">
|
||||
<div class="alert alert-error">${result.message}</div>
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const totalIPs = ipamData.length;
|
||||
const assignedIPs = ipamData.filter(item => item.ip && item.ip !== 'N/A').length;
|
||||
const conflicts = ipamData.filter(item => item.conflict).length;
|
||||
|
||||
document.getElementById('statTotalIPs').textContent = totalIPs;
|
||||
document.getElementById('statAssignedIPs').textContent = assignedIPs;
|
||||
document.getElementById('statConflicts').textContent = conflicts;
|
||||
|
||||
const assignedPercent = totalIPs > 0 ? (assignedIPs / totalIPs) * 100 : 0;
|
||||
const conflictPercent = totalIPs > 0 ? (conflicts / totalIPs) * 100 : 0;
|
||||
|
||||
document.getElementById('statBarAssigned').style.width = assignedPercent + '%';
|
||||
document.getElementById('statBarConflicts').style.width = conflictPercent + '%';
|
||||
}
|
||||
|
||||
function filterIPAM() {
|
||||
const filterIP = document.getElementById('filterIP').value.toLowerCase();
|
||||
const filterName = document.getElementById('filterName').value.toLowerCase();
|
||||
const filterType = document.getElementById('filterType').value;
|
||||
const filterStatus = document.getElementById('filterStatus').value;
|
||||
|
||||
filteredData = ipamData.filter(item => {
|
||||
const matchIP = !filterIP || (item.ip && item.ip.toLowerCase().includes(filterIP));
|
||||
const matchName = !filterName || (item.name && item.name.toLowerCase().includes(filterName));
|
||||
const matchType = !filterType || item.type === filterType;
|
||||
const matchStatus = !filterStatus || item.status === filterStatus;
|
||||
|
||||
return matchIP && matchName && matchType && matchStatus;
|
||||
});
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('ipamTableBody');
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: var(--space-xl);">Nessun risultato trovato</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = filteredData.map(item => `
|
||||
<tr>
|
||||
<td><strong>${item.vmid}</strong></td>
|
||||
<td>${item.name || 'N/A'}</td>
|
||||
<td><span class="type-badge ${item.type}">${item.type.toUpperCase()}</span></td>
|
||||
<td>
|
||||
<span class="ip-badge">${item.ip || 'N/A'}</span>
|
||||
${item.conflict ? '<span class="conflict-badge">CONFLITTO</span>' : ''}
|
||||
</td>
|
||||
<td>${item.node || 'N/A'}</td>
|
||||
<td><span class="status-badge ${item.status || 'unknown'}">${item.status || 'unknown'}</span></td>
|
||||
<td>
|
||||
<div class="action-btns">
|
||||
<button class="action-btn" onclick="showDetails(${item.vmid})" title="Dettagli">👁️</button>
|
||||
<button class="action-btn" onclick="pingIP('${item.ip}')" title="Ping" ${!item.ip || item.ip === 'N/A' ? 'disabled' : ''}>📡</button>
|
||||
<button class="action-btn" onclick="editIP(${item.vmid})" title="Modifica">✏️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function sortTable(column) {
|
||||
if (currentSort.column === column) {
|
||||
currentSort.ascending = !currentSort.ascending;
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.ascending = true;
|
||||
}
|
||||
|
||||
filteredData.sort((a, b) => {
|
||||
let valA = a[column];
|
||||
let valB = b[column];
|
||||
|
||||
if (typeof valA === 'string') valA = valA.toLowerCase();
|
||||
if (typeof valB === 'string') valB = valB.toLowerCase();
|
||||
|
||||
if (valA < valB) return currentSort.ascending ? -1 : 1;
|
||||
if (valA > valB) return currentSort.ascending ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function scanNetwork() {
|
||||
showAlert('Scansione della rete in corso...', 'info');
|
||||
const result = await apiCall('/api/admin/ipam/scan', 'POST');
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('Scansione completata con successo', 'success');
|
||||
loadIPAM();
|
||||
} else {
|
||||
showAlert('Errore durante la scansione: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showAssignModal() {
|
||||
document.getElementById('assignModal').classList.add('visible');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('visible');
|
||||
}
|
||||
|
||||
async function submitAssignIP(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const vmid = document.getElementById('assignVmId').value;
|
||||
const ip = document.getElementById('assignIP').value;
|
||||
const notes = document.getElementById('assignNotes').value;
|
||||
|
||||
const result = await apiCall('/api/admin/ipam/assign', 'POST', {
|
||||
vmid: parseInt(vmid),
|
||||
ip: ip,
|
||||
notes: notes
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('IP assegnato con successo', 'success');
|
||||
closeModal('assignModal');
|
||||
loadIPAM();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function showDetails(vmid) {
|
||||
const item = ipamData.find(i => i.vmid === vmid);
|
||||
if (!item) return;
|
||||
|
||||
const content = document.getElementById('detailsContent');
|
||||
content.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">VM ID</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.vmid}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nome</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.name || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tipo</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.type}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">IP</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.ip || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Nodo</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.node || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Stato</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.status || 'unknown'}</div>
|
||||
</div>
|
||||
${item.mac ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">MAC Address</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.mac}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${item.notes ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Note</label>
|
||||
<div class="form-input" style="background: var(--bg-secondary);">${item.notes}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
document.getElementById('detailsModal').classList.add('visible');
|
||||
}
|
||||
|
||||
async function pingIP(ip) {
|
||||
if (!ip || ip === 'N/A') return;
|
||||
|
||||
showAlert(`Ping di ${ip} in corso...`, 'info');
|
||||
|
||||
const result = await apiCall('/api/admin/ipam/ping', 'POST', { ip: ip });
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert(`Ping riuscito: ${result.message}`, 'success');
|
||||
} else {
|
||||
showAlert(`Ping fallito: ${result.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function editIP(vmid) {
|
||||
const item = ipamData.find(i => i.vmid === vmid);
|
||||
if (!item) return;
|
||||
|
||||
document.getElementById('assignVmId').value = item.vmid;
|
||||
document.getElementById('assignVmId').readOnly = true;
|
||||
document.getElementById('assignIP').value = item.ip || '';
|
||||
document.getElementById('assignNotes').value = item.notes || '';
|
||||
|
||||
showAssignModal();
|
||||
}
|
||||
|
||||
function exportIPAM() {
|
||||
if (filteredData.length === 0) {
|
||||
showAlert('Nessun dato da esportare', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['VM ID', 'Nome', 'Tipo', 'IP', 'Nodo', 'Stato', 'MAC', 'Note'];
|
||||
const rows = filteredData.map(item => [
|
||||
item.vmid,
|
||||
item.name || '',
|
||||
item.type,
|
||||
item.ip || '',
|
||||
item.node || '',
|
||||
item.status || '',
|
||||
item.mac || '',
|
||||
item.notes || ''
|
||||
]);
|
||||
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
csvContent += rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `ipam_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showAlert('Dati IPAM esportati con successo', 'success');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
203
templates/login.html
Normal file
203
templates/login.html
Normal file
@@ -0,0 +1,203 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-2xl);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.login-logo svg {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form .form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.login-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-form .form-group input {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-form .form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.login-form .form-group input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
margin-bottom: 0 !important;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
background: var(--accent-blue);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background: #4c9aed;
|
||||
box-shadow: var(--shadow-glow-blue);
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: var(--space-xl);
|
||||
padding-top: var(--space-lg);
|
||||
border-top: 1px solid var(--border-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.login-footer strong {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-lg) 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-muted);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
Proxmox Manager
|
||||
</div>
|
||||
<p class="login-subtitle">Accedi al tuo pannello di controllo</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('login') }}" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Inserisci username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Inserisci password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="remember" name="remember">
|
||||
<label for="remember">Ricordami su questo dispositivo</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login">Accedi</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>By Hersel Giannella</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
335
templates/overview.html
Normal file
335
templates/overview.html
Normal file
@@ -0,0 +1,335 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Overview - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h1>Dashboard Overview</h1>
|
||||
<p class="subtitle">Riepilogo di tutte le tue macchine virtuali</p>
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadOverviewStats()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid" id="stats-grid">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="total-vms">-</div>
|
||||
<div class="stat-label">VM Totali</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-running">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-green)" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="running-vms">-</div>
|
||||
<div class="stat-label">In Esecuzione</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-stopped">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-red)" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="stopped-vms">-</div>
|
||||
<div class="stat-label">Ferme</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-cpu">
|
||||
<div class="stat-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent-purple)" stroke-width="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
|
||||
<rect x="9" y="9" width="6" height="6"/>
|
||||
<line x1="9" y1="1" x2="9" y2="4"/>
|
||||
<line x1="15" y1="1" x2="15" y2="4"/>
|
||||
<line x1="9" y1="20" x2="9" y2="23"/>
|
||||
<line x1="15" y1="20" x2="15" y2="23"/>
|
||||
<line x1="20" y1="9" x2="23" y2="9"/>
|
||||
<line x1="20" y1="14" x2="23" y2="14"/>
|
||||
<line x1="1" y1="9" x2="4" y2="9"/>
|
||||
<line x1="1" y1="14" x2="4" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value" id="avg-cpu">-</div>
|
||||
<div class="stat-label">CPU Medio</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Charts -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-title">Utilizzo Risorse</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-xl);">
|
||||
<div>
|
||||
<h3 style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: var(--space-md);">Memoria Totale</h3>
|
||||
<div style="height: 200px; position: relative;">
|
||||
<canvas id="memoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: var(--space-md);">Stato VM</h3>
|
||||
<div style="height: 200px; position: relative;">
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VM List -->
|
||||
<div class="card">
|
||||
<div class="card-title">Le Tue Macchine Virtuali</div>
|
||||
<div class="vm-overview-list" id="vm-list">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let memoryChart = null;
|
||||
let statusChart = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadOverviewStats();
|
||||
});
|
||||
|
||||
async function loadOverviewStats() {
|
||||
const result = await apiCall('/api/overview/stats');
|
||||
|
||||
if (result.status === 'success') {
|
||||
const data = result.data;
|
||||
|
||||
// Update stat cards
|
||||
document.getElementById('total-vms').textContent = data.total_vms;
|
||||
document.getElementById('running-vms').textContent = data.running_vms;
|
||||
document.getElementById('stopped-vms').textContent = data.stopped_vms;
|
||||
document.getElementById('avg-cpu').textContent = data.avg_cpu_usage + '%';
|
||||
|
||||
// Render charts
|
||||
renderMemoryChart(data);
|
||||
renderStatusChart(data);
|
||||
|
||||
// Render VM list
|
||||
renderVMList(data.vms);
|
||||
} else {
|
||||
document.getElementById('vm-list').innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
Errore nel caricamento: ${result.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMemoryChart(data) {
|
||||
const ctx = document.getElementById('memoryChart').getContext('2d');
|
||||
|
||||
if (memoryChart) {
|
||||
memoryChart.destroy();
|
||||
}
|
||||
|
||||
const usedMemory = data.total_memory_used || 0;
|
||||
const freeMemory = (data.total_memory_max || 0) - usedMemory;
|
||||
|
||||
memoryChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Utilizzata', 'Disponibile'],
|
||||
datasets: [{
|
||||
data: [usedMemory, freeMemory],
|
||||
backgroundColor: ['#a371f7', '#21262d'],
|
||||
borderWidth: 0,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#8b949e',
|
||||
usePointStyle: true,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#21262d',
|
||||
titleColor: '#e6edf3',
|
||||
bodyColor: '#8b949e',
|
||||
borderColor: '#30363d',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return formatBytes(context.raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'centerText',
|
||||
afterDraw: function(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
|
||||
const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillStyle = '#e6edf3';
|
||||
ctx.font = 'bold 24px sans-serif';
|
||||
ctx.fillText(data.memory_percent + '%', centerX, centerY - 8);
|
||||
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText('utilizzata', centerX, centerY + 14);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatusChart(data) {
|
||||
const ctx = document.getElementById('statusChart').getContext('2d');
|
||||
|
||||
if (statusChart) {
|
||||
statusChart.destroy();
|
||||
}
|
||||
|
||||
statusChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Running', 'Stopped'],
|
||||
datasets: [{
|
||||
data: [data.running_vms, data.stopped_vms],
|
||||
backgroundColor: ['#3fb950', '#f85149'],
|
||||
borderWidth: 0,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#8b949e',
|
||||
usePointStyle: true,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#21262d',
|
||||
titleColor: '#e6edf3',
|
||||
bodyColor: '#8b949e',
|
||||
borderColor: '#30363d',
|
||||
borderWidth: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'centerText',
|
||||
afterDraw: function(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
|
||||
const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillStyle = '#e6edf3';
|
||||
ctx.font = 'bold 24px sans-serif';
|
||||
ctx.fillText(data.total_vms, centerX, centerY - 8);
|
||||
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText('totali', centerX, centerY + 14);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderVMList(vms) {
|
||||
const container = document.getElementById('vm-list');
|
||||
|
||||
if (!vms || vms.length === 0) {
|
||||
container.innerHTML = `
|
||||
<p class="text-muted text-center" style="padding: var(--space-xl);">
|
||||
Nessuna VM assegnata
|
||||
</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = vms.map(vm => {
|
||||
const statusClass = vm.status === 'running' ? 'status-running' : 'status-stopped';
|
||||
const statusText = vm.status === 'running' ? 'Running' : 'Stopped';
|
||||
const cpuPercent = Math.round((vm.cpu || 0) * 100);
|
||||
|
||||
return `
|
||||
<div class="vm-overview-item">
|
||||
<div class="vm-overview-info">
|
||||
<span class="status-badge ${statusClass}" style="margin-right: var(--space-md);">${statusText}</span>
|
||||
<div>
|
||||
<div class="vm-overview-name">${vm.name}</div>
|
||||
<div class="vm-overview-id">ID: ${vm.vm_id}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-overview-metrics">
|
||||
<div class="vm-overview-metric">
|
||||
<div class="vm-overview-metric-value">${cpuPercent}%</div>
|
||||
<div class="vm-overview-metric-label">CPU</div>
|
||||
</div>
|
||||
<div class="vm-overview-metric">
|
||||
<div class="vm-overview-metric-value">${vm.memory_percent || 0}%</div>
|
||||
<div class="vm-overview-metric-label">RAM</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-overview-actions">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-ghost btn-sm">Gestisci</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
232
templates/profile.html
Normal file
232
templates/profile.html
Normal file
@@ -0,0 +1,232 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profilo - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.profile-info-item {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.profile-info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-info-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.profile-info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-md);
|
||||
padding-bottom: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h1>Il Mio Profilo</h1>
|
||||
<p class="subtitle">Gestisci le tue informazioni personali</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-grid">
|
||||
<!-- Info Profilo -->
|
||||
<div class="card">
|
||||
<div class="card-title">Informazioni Account</div>
|
||||
|
||||
<div class="profile-info">
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-label">Username</div>
|
||||
<div class="profile-info-value">{{ current_user.username }}</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-label">Email</div>
|
||||
<div class="profile-info-value" id="currentEmail">{{ current_user.email or 'Non impostata' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-label">Ruolo</div>
|
||||
<div class="profile-info-value">
|
||||
{% if current_user.is_admin %}
|
||||
<span class="badge-admin">ADMIN</span>
|
||||
{% else %}
|
||||
<span class="status-badge" style="background: var(--bg-active); color: var(--accent-blue); border: 1px solid var(--accent-blue);">UTENTE</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-info-item">
|
||||
<div class="profile-info-label">ID Utente</div>
|
||||
<div class="profile-info-value">#{{ current_user.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Modifica -->
|
||||
<div>
|
||||
<!-- Cambio Email -->
|
||||
<div class="card form-section">
|
||||
<h3>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
Modifica Email
|
||||
</h3>
|
||||
<form id="emailForm" onsubmit="updateEmail(event)">
|
||||
<div class="form-group">
|
||||
<label for="newEmail">Nuova Email</label>
|
||||
<input type="email" id="newEmail" placeholder="nuova@email.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="emailPassword">Password Corrente</label>
|
||||
<input type="password" id="emailPassword" placeholder="Inserisci password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Aggiorna Email</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Cambio Password -->
|
||||
<div class="card form-section">
|
||||
<h3>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
Modifica Password
|
||||
</h3>
|
||||
<form id="passwordForm" onsubmit="updatePassword(event)">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">Password Corrente</label>
|
||||
<input type="password" id="currentPassword" placeholder="Password attuale" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newPassword">Nuova Password</label>
|
||||
<input type="password" id="newPassword" placeholder="Nuova password" required minlength="6">
|
||||
<small class="text-muted" style="font-size: 0.8rem; margin-top: var(--space-xs); display: block;">Minimo 6 caratteri</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Conferma Nuova Password</label>
|
||||
<input type="password" id="confirmPassword" placeholder="Ripeti nuova password" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Aggiorna Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiche Attività -->
|
||||
<div class="card">
|
||||
<div class="card-title">Attività Recente</div>
|
||||
<div id="userActivity">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento attività...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadUserActivity();
|
||||
});
|
||||
|
||||
async function updateEmail(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const newEmail = document.getElementById('newEmail').value;
|
||||
const password = document.getElementById('emailPassword').value;
|
||||
|
||||
const result = await apiCall('/api/profile/update-email', 'POST', {
|
||||
email: newEmail,
|
||||
password: password
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast('Email aggiornata con successo!', 'success');
|
||||
document.getElementById('currentEmail').textContent = newEmail;
|
||||
document.getElementById('emailForm').reset();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
showAlert('Le password non corrispondono', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiCall('/api/profile/update-password', 'POST', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword
|
||||
});
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast('Password aggiornata con successo!', 'success');
|
||||
document.getElementById('passwordForm').reset();
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserActivity() {
|
||||
const container = document.getElementById('userActivity');
|
||||
|
||||
container.innerHTML = `
|
||||
<p class="text-muted text-center" style="padding: var(--space-xl);">
|
||||
Le statistiche attività saranno disponibili a breve
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
439
templates/system_logs.html
Normal file
439
templates/system_logs.html
Normal file
@@ -0,0 +1,439 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log di Sistema - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.logs-filters {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logs-table-container {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.logs-table thead {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 2px solid var(--border-default);
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
padding: var(--space-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.logs-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.logs-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
padding: var(--space-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.log-status.success {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.log-status.failed {
|
||||
background: rgba(255, 78, 80, 0.15);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.log-action-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--accent-blue);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-error-message {
|
||||
color: var(--accent-red);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.export-btn:hover {
|
||||
background: var(--accent-blue-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.no-logs {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="logs-header">
|
||||
<div>
|
||||
<h1>Log di Sistema</h1>
|
||||
<p class="subtitle">Visualizza tutte le attività del sistema</p>
|
||||
</div>
|
||||
<button class="export-btn" onclick="exportLogs()">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16" style="margin-right: 8px; vertical-align: middle;">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z"/>
|
||||
</svg>
|
||||
Esporta CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="logs-filters">
|
||||
<div class="filter-group">
|
||||
<label>Utente</label>
|
||||
<select id="filterUser" onchange="filterLogs()">
|
||||
<option value="">Tutti gli utenti</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Azione</label>
|
||||
<select id="filterAction" onchange="filterLogs()">
|
||||
<option value="">Tutte le azioni</option>
|
||||
<option value="start">Start</option>
|
||||
<option value="stop">Stop</option>
|
||||
<option value="restart">Restart</option>
|
||||
<option value="shutdown">Shutdown</option>
|
||||
<option value="snapshot">Snapshot</option>
|
||||
<option value="backup">Backup</option>
|
||||
<option value="login">Login</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Stato</label>
|
||||
<select id="filterStatus" onchange="filterLogs()">
|
||||
<option value="">Tutti gli stati</option>
|
||||
<option value="success">Successo</option>
|
||||
<option value="failed">Fallito</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VM ID</label>
|
||||
<input type="number" id="filterVmId" placeholder="ID VM" onchange="filterLogs()" min="0">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Data</label>
|
||||
<input type="date" id="filterDate" onchange="filterLogs()">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Limiti</label>
|
||||
<select id="filterLimit" onchange="filterLogs()">
|
||||
<option value="50">50 record</option>
|
||||
<option value="100" selected>100 record</option>
|
||||
<option value="200">200 record</option>
|
||||
<option value="500">500 record</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="logs-table-container">
|
||||
<table class="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Data/Ora</th>
|
||||
<th>Utente</th>
|
||||
<th>Azione</th>
|
||||
<th>VM ID</th>
|
||||
<th>Stato</th>
|
||||
<th>IP</th>
|
||||
<th>Dettagli</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logsTableBody">
|
||||
<tr>
|
||||
<td colspan="8" class="no-logs">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento log...</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="pagination" style="display: none;">
|
||||
<button id="prevBtn" onclick="changePage(-1)">← Precedente</button>
|
||||
<span class="pagination-info" id="pageInfo">Pagina 1</span>
|
||||
<button id="nextBtn" onclick="changePage(1)">Successiva →</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let allLogs = [];
|
||||
let filteredLogs = [];
|
||||
let currentPage = 1;
|
||||
const logsPerPage = 50;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadLogs();
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const result = await apiCall('/api/admin/users');
|
||||
if (result.status === 'success') {
|
||||
const filterUser = document.getElementById('filterUser');
|
||||
result.data.forEach(user => {
|
||||
const option = document.createElement('option');
|
||||
option.value = user.username;
|
||||
option.textContent = user.username;
|
||||
filterUser.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const limit = document.getElementById('filterLimit').value;
|
||||
const result = await apiCall(`/api/admin/logs?limit=${limit}`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
allLogs = result.data;
|
||||
filterLogs();
|
||||
} else {
|
||||
document.getElementById('logsTableBody').innerHTML = `
|
||||
<tr><td colspan="8" class="no-logs">
|
||||
<div class="alert alert-error">${result.message}</div>
|
||||
</td></tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterLogs() {
|
||||
const filterUser = document.getElementById('filterUser').value.toLowerCase();
|
||||
const filterAction = document.getElementById('filterAction').value.toLowerCase();
|
||||
const filterStatus = document.getElementById('filterStatus').value.toLowerCase();
|
||||
const filterVmId = document.getElementById('filterVmId').value;
|
||||
const filterDate = document.getElementById('filterDate').value;
|
||||
|
||||
filteredLogs = allLogs.filter(log => {
|
||||
const matchUser = !filterUser || log.username.toLowerCase().includes(filterUser);
|
||||
const matchAction = !filterAction || log.action_type === filterAction;
|
||||
const matchStatus = !filterStatus || log.status === filterStatus;
|
||||
const matchVmId = !filterVmId || log.vm_id.toString() === filterVmId;
|
||||
const matchDate = !filterDate || new Date(log.created_at).toISOString().split('T')[0] === filterDate;
|
||||
|
||||
return matchUser && matchAction && matchStatus && matchVmId && matchDate;
|
||||
});
|
||||
|
||||
currentPage = 1;
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.getElementById('logsTableBody');
|
||||
const pagination = document.getElementById('pagination');
|
||||
|
||||
if (filteredLogs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="no-logs">Nessun log trovato con i filtri applicati</td></tr>';
|
||||
pagination.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filteredLogs.length / logsPerPage);
|
||||
const startIdx = (currentPage - 1) * logsPerPage;
|
||||
const endIdx = startIdx + logsPerPage;
|
||||
const logsToShow = filteredLogs.slice(startIdx, endIdx);
|
||||
|
||||
const actionIcons = {
|
||||
'start': '▶️',
|
||||
'stop': '⏹️',
|
||||
'restart': '🔄',
|
||||
'shutdown': '🛑',
|
||||
'snapshot': '📸',
|
||||
'backup': '💾',
|
||||
'login': '🔐'
|
||||
};
|
||||
|
||||
tbody.innerHTML = logsToShow.map(log => {
|
||||
const date = new Date(log.created_at);
|
||||
const formattedDate = date.toLocaleString('it-IT');
|
||||
const icon = actionIcons[log.action_type] || '•';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>#${log.id}</strong></td>
|
||||
<td>${formattedDate}</td>
|
||||
<td><strong>${log.username}</strong></td>
|
||||
<td><span class="log-action-badge">${icon} ${log.action_type}</span></td>
|
||||
<td>${log.vm_id > 0 ? `VM ${log.vm_id}` : '-'}</td>
|
||||
<td><span class="log-status ${log.status}">${log.status === 'success' ? 'Successo' : 'Fallito'}</span></td>
|
||||
<td><code>${log.ip_address || '-'}</code></td>
|
||||
<td>
|
||||
${log.error_message ? `<div class="log-error-message">${log.error_message}</div>` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Update pagination
|
||||
document.getElementById('pageInfo').textContent = `Pagina ${currentPage} di ${totalPages} (${filteredLogs.length} risultati)`;
|
||||
document.getElementById('prevBtn').disabled = currentPage === 1;
|
||||
document.getElementById('nextBtn').disabled = currentPage === totalPages;
|
||||
pagination.style.display = 'flex';
|
||||
}
|
||||
|
||||
function changePage(delta) {
|
||||
currentPage += delta;
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
if (filteredLogs.length === 0) {
|
||||
showAlert('Nessun log da esportare', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['ID', 'Data/Ora', 'Utente', 'Azione', 'VM ID', 'Stato', 'IP', 'Errore'];
|
||||
const rows = filteredLogs.map(log => [
|
||||
log.id,
|
||||
new Date(log.created_at).toLocaleString('it-IT'),
|
||||
log.username,
|
||||
log.action_type,
|
||||
log.vm_id,
|
||||
log.status,
|
||||
log.ip_address || '',
|
||||
log.error_message || ''
|
||||
]);
|
||||
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
csvContent += rows.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `system_logs_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showAlert('Log esportati con successo', 'success');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
859
templates/user_dashboard.html
Normal file
859
templates/user_dashboard.html
Normal file
@@ -0,0 +1,859 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Proxmox Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h1>Le Mie Macchine Virtuali</h1>
|
||||
<p class="subtitle">Gestisci le tue VM direttamente da qui</p>
|
||||
</div>
|
||||
<div class="dashboard-actions">
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadVMs()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||
</svg>
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vms-container">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento VM in corso...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per i backup -->
|
||||
<div id="backupModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeBackupModal()">×</span>
|
||||
<h2 id="backupModalTitle">Backup di VM</h2>
|
||||
<div id="backupModalContent">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento backup...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per le snapshot -->
|
||||
<div id="snapshotModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeSnapshotModal()">×</span>
|
||||
<h2 id="snapshotModalTitle">Snapshot di VM</h2>
|
||||
<div id="snapshotModalContent">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Caricamento snapshot...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per la console -->
|
||||
<div id="consoleModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 95vw; width: 1200px; height: 85vh; padding: 0; overflow: hidden;">
|
||||
<div class="console-header">
|
||||
<span id="consoleModalTitle">Console VM</span>
|
||||
<div class="console-toolbar">
|
||||
<button class="btn btn-ghost btn-sm" onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="toggleConsoleFullscreen()">Fullscreen</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="closeConsoleModal()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="consoleContainer" style="width: 100%; height: calc(100% - 48px); background: #000;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal per grafici storici -->
|
||||
<div id="historyModal" class="modal">
|
||||
<div class="modal-content" style="max-width: 900px; width: 90%;">
|
||||
<span class="close" onclick="closeHistoryModal()">×</span>
|
||||
<h2 id="historyModalTitle">Storico Metriche</h2>
|
||||
<div class="chart-controls" style="margin-bottom: var(--space-md);">
|
||||
<button class="chart-btn active" data-timeframe="hour" onclick="loadHistoricalData('hour')">1H</button>
|
||||
<button class="chart-btn" data-timeframe="day" onclick="loadHistoricalData('day')">24H</button>
|
||||
<button class="chart-btn" data-timeframe="week" onclick="loadHistoricalData('week')">7G</button>
|
||||
<button class="chart-btn" data-timeframe="month" onclick="loadHistoricalData('month')">30G</button>
|
||||
</div>
|
||||
<div id="historyChartContainer" style="height: 400px; position: relative;">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.console-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.console-header span {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.console-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
#consoleContainer iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
let currentVMs = [];
|
||||
let cpuCharts = {};
|
||||
let historyChart = null;
|
||||
let currentHistoryVmId = null;
|
||||
|
||||
// Carica le VM al caricamento della pagina
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadVMs();
|
||||
// Ricarica metriche ogni 5 secondi
|
||||
setInterval(refreshMetrics, 5000);
|
||||
});
|
||||
|
||||
function initCPUChart(vmId, cpuPercent) {
|
||||
const canvas = document.getElementById(`cpu-chart-${vmId}`);
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (cpuCharts[vmId]) {
|
||||
cpuCharts[vmId].destroy();
|
||||
}
|
||||
|
||||
cpuCharts[vmId] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: Array(10).fill(''),
|
||||
datasets: [{
|
||||
data: Array(10).fill(cpuPercent),
|
||||
borderColor: 'rgba(88, 166, 255, 0.8)',
|
||||
backgroundColor: 'rgba(88, 166, 255, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false }
|
||||
},
|
||||
scales: {
|
||||
x: { display: false },
|
||||
y: { display: false, min: 0, max: 100 }
|
||||
},
|
||||
animation: { duration: 500 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCPUChart(vmId, newValue) {
|
||||
const chart = cpuCharts[vmId];
|
||||
if (!chart) return;
|
||||
|
||||
chart.data.datasets[0].data.shift();
|
||||
chart.data.datasets[0].data.push(newValue);
|
||||
chart.update('none');
|
||||
}
|
||||
|
||||
async function refreshMetrics() {
|
||||
if (currentVMs.length === 0) return;
|
||||
|
||||
for (const vm of currentVMs) {
|
||||
try {
|
||||
const result = await apiCall(`/api/vm/${vm.vm_id}/status`);
|
||||
if (result.status === 'success') {
|
||||
const vmData = result.data;
|
||||
const vmCard = document.querySelector(`[data-vm-id="${vm.vm_id}"]`);
|
||||
if (!vmCard) continue;
|
||||
|
||||
const cpuPercent = vmData.cpu ? Math.round(vmData.cpu * 100) : 0;
|
||||
const memPercent = vmData.maxmem ? Math.round((vmData.mem / vmData.maxmem) * 100) : 0;
|
||||
const diskPercent = vmData.maxdisk ? Math.round((vmData.disk / vmData.maxdisk) * 100) : 0;
|
||||
|
||||
// Aggiorna valori CPU
|
||||
const cpuValue = vmCard.querySelector('.metric-box.cpu .metric-value');
|
||||
if (cpuValue) {
|
||||
cpuValue.textContent = cpuPercent + '%';
|
||||
updateCPUChart(vm.vm_id, cpuPercent);
|
||||
}
|
||||
|
||||
// Aggiorna valori RAM
|
||||
const memValue = vmCard.querySelector('.metric-box.memory .metric-value');
|
||||
const memSubvalue = vmCard.querySelector('.metric-box.memory .metric-subvalue');
|
||||
if (memValue) memValue.textContent = memPercent + '%';
|
||||
if (memSubvalue) memSubvalue.textContent = `${formatBytes(vmData.mem)} / ${formatBytes(vmData.maxmem)}`;
|
||||
|
||||
// Aggiorna valori Disk
|
||||
const diskValue = vmCard.querySelector('.metric-box.disk .metric-value');
|
||||
const diskSubvalue = vmCard.querySelector('.metric-box.disk .metric-subvalue');
|
||||
if (diskValue) diskValue.textContent = diskPercent + '%';
|
||||
if (diskSubvalue) diskSubvalue.textContent = `${formatBytes(vmData.disk || 0)} / ${formatBytes(vmData.maxdisk || 0)}`;
|
||||
|
||||
// Aggiorna uptime
|
||||
const uptimeValue = vmCard.querySelector('.stat-uptime');
|
||||
if (uptimeValue) uptimeValue.textContent = formatUptime(vmData.uptime);
|
||||
|
||||
// Se lo stato cambia, ricarica
|
||||
if (vm.status !== vmData.status) {
|
||||
loadVMs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Errore refresh metriche VM ${vm.vm_id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVMs() {
|
||||
const container = document.getElementById('vms-container');
|
||||
|
||||
const result = await apiCall('/api/my-vms');
|
||||
|
||||
if (result.status === 'success') {
|
||||
currentVMs = result.data;
|
||||
|
||||
if (currentVMs.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-vms">
|
||||
<div class="no-vms-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Nessuna VM assegnata</h2>
|
||||
<p>Contatta l'amministratore per richiedere l'accesso a una VM</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div class="vm-grid">' +
|
||||
currentVMs.map(vm => createVMCard(vm)).join('') +
|
||||
'</div>';
|
||||
|
||||
// Inizializza grafici CPU
|
||||
setTimeout(() => {
|
||||
currentVMs.forEach(vm => {
|
||||
const cpuPercent = vm.cpu ? Math.round(vm.cpu * 100) : 0;
|
||||
initCPUChart(vm.vm_id, cpuPercent);
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
Errore nel caricamento delle VM: ${result.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function createVMCard(vm) {
|
||||
const statusClass = vm.status === 'running' ? 'status-running' :
|
||||
vm.status === 'stopped' ? 'status-stopped' : 'status-unknown';
|
||||
const statusText = vm.status === 'running' ? 'Running' :
|
||||
vm.status === 'stopped' ? 'Stopped' : 'Unknown';
|
||||
|
||||
const memPercent = vm.maxmem ? Math.round((vm.mem / vm.maxmem) * 100) : 0;
|
||||
const cpuPercent = vm.cpu ? Math.round(vm.cpu * 100) : 0;
|
||||
const diskPercent = vm.maxdisk ? Math.round((vm.disk / vm.maxdisk) * 100) : 0;
|
||||
|
||||
return `
|
||||
<div class="vm-card ${statusClass}" data-vm-id="${vm.vm_id}">
|
||||
<div class="vm-header">
|
||||
<div>
|
||||
<div class="vm-title">${vm.vm_name}</div>
|
||||
<div class="vm-id">VM ID: ${vm.vm_id} | Node: ${vm.node}</div>
|
||||
</div>
|
||||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
|
||||
${vm.notes ? `<div class="vm-notes">${vm.notes}</div>` : ''}
|
||||
|
||||
<div class="metrics-realtime">
|
||||
<div class="metric-box cpu">
|
||||
<div class="metric-label">CPU</div>
|
||||
<div class="metric-value">${cpuPercent}%</div>
|
||||
<canvas id="cpu-chart-${vm.vm_id}"></canvas>
|
||||
</div>
|
||||
<div class="metric-box memory">
|
||||
<div class="metric-label">RAM</div>
|
||||
<div class="metric-value">${memPercent}%</div>
|
||||
<div class="metric-subvalue">${formatBytes(vm.mem)} / ${formatBytes(vm.maxmem)}</div>
|
||||
</div>
|
||||
<div class="metric-box disk">
|
||||
<div class="metric-label">Disco</div>
|
||||
<div class="metric-value">${diskPercent}%</div>
|
||||
<div class="metric-subvalue">${formatBytes(vm.disk || 0)} / ${formatBytes(vm.maxdisk || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vm-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value stat-uptime">${formatUptime(vm.uptime)}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Tipo</div>
|
||||
<div class="stat-value">${vm.type || 'QEMU'}</div>
|
||||
</div>
|
||||
${vm.ip_address ? `
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Indirizzo IP</div>
|
||||
<div class="stat-value"><code style="font-size: 0.85rem;">${vm.ip_address}</code></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="vm-actions">
|
||||
${vm.status === 'running' ? `
|
||||
<button class="btn btn-warning" onclick="shutdownVM(${vm.vm_id})">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M7.5 1v7h1V1h-1z"/><path d="M3 8.812a4.999 4.999 0 0 1 2.578-4.375l-.485-.874A6 6 0 1 0 11 3.616l-.501.865A5 5 0 1 1 3 8.812z"/></svg>
|
||||
Spegni
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="rebootVM(${vm.vm_id})">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/></svg>
|
||||
Riavvia
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openConsole(${vm.vm_id}, '${vm.vm_name}')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect width="14" height="10" x="1" y="2" rx="1"/><path d="M1 14h14v1H1z"/></svg>
|
||||
Console
|
||||
</button>
|
||||
` : `
|
||||
<button class="btn btn-success" onclick="startVM(${vm.vm_id})">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"/></svg>
|
||||
Avvia
|
||||
</button>
|
||||
`}
|
||||
<button class="btn btn-secondary" onclick="showHistory(${vm.vm_id}, '${vm.vm_name}')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 11H2v3h2v-3zm5-4H7v7h2V7zm5-5h-2v12h2V2zm-2-1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1h-2zM6 7a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm-5 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-3z"/></svg>
|
||||
Storico
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="showBackups(${vm.vm_id}, '${vm.vm_name}')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
|
||||
Backup
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="showSnapshots(${vm.vm_id}, '${vm.vm_name}')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M10.5 8.5a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/><path d="M2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4H2zm.5 2a.5.5 0 1 1 0-1 .5.5 0 0 1 0 1zm9 2.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z"/></svg>
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==================== VM ACTIONS ====================
|
||||
|
||||
async function startVM(vmId) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Avvia VM',
|
||||
text: 'Vuoi avviare questa macchina virtuale?',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Avvia',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Avvio VM in corso...', 'info');
|
||||
const response = await apiCall(`/api/vm/${vmId}/start`, 'POST');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('VM avviata con successo!', 'success');
|
||||
setTimeout(loadVMs, 2000);
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdownVM(vmId) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Spegni VM',
|
||||
text: 'Vuoi spegnere questa macchina virtuale? (shutdown graceful)',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Spegni',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Spegnimento VM in corso...', 'info');
|
||||
const response = await apiCall(`/api/vm/${vmId}/shutdown`, 'POST');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('VM in fase di spegnimento...', 'success');
|
||||
setTimeout(loadVMs, 2000);
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function rebootVM(vmId) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Riavvia VM',
|
||||
text: 'Vuoi riavviare questa macchina virtuale?',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Riavvia',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Riavvio VM in corso...', 'info');
|
||||
const response = await apiCall(`/api/vm/${vmId}/reboot`, 'POST');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('VM in fase di riavvio...', 'success');
|
||||
setTimeout(loadVMs, 2000);
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CONSOLE ====================
|
||||
|
||||
async function openConsole(vmId, vmName) {
|
||||
showToast('Apertura console...', 'info');
|
||||
|
||||
const result = await apiCall(`/api/vm/${vmId}/console`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
document.getElementById('consoleModalTitle').textContent = `Console: ${vmName} (VM ${vmId})`;
|
||||
|
||||
const container = document.getElementById('consoleContainer');
|
||||
container.innerHTML = `
|
||||
<iframe
|
||||
src="${result.console_url}"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
style="width: 100%; height: 100%; border: none;"
|
||||
></iframe>
|
||||
`;
|
||||
|
||||
document.getElementById('consoleModal').style.display = 'block';
|
||||
|
||||
showToast('Console aperta. Potrebbe essere necessario autenticarsi su Proxmox.', 'info');
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeConsoleModal() {
|
||||
document.getElementById('consoleModal').style.display = 'none';
|
||||
document.getElementById('consoleContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
function toggleConsoleFullscreen() {
|
||||
const modal = document.getElementById('consoleModal').querySelector('.modal-content');
|
||||
modal.requestFullscreen?.() || modal.webkitRequestFullscreen?.() || modal.msRequestFullscreen?.();
|
||||
}
|
||||
|
||||
function sendCtrlAltDel() {
|
||||
showToast('Funzione disponibile solo con noVNC integrato', 'warning');
|
||||
}
|
||||
|
||||
// ==================== HISTORY CHARTS ====================
|
||||
|
||||
function showHistory(vmId, vmName) {
|
||||
currentHistoryVmId = vmId;
|
||||
document.getElementById('historyModalTitle').textContent = `Storico Metriche: ${vmName}`;
|
||||
document.getElementById('historyModal').style.display = 'block';
|
||||
|
||||
// Reset active button
|
||||
document.querySelectorAll('.chart-btn').forEach(btn => btn.classList.remove('active'));
|
||||
document.querySelector('.chart-btn[data-timeframe="hour"]').classList.add('active');
|
||||
|
||||
loadHistoricalData('hour');
|
||||
}
|
||||
|
||||
async function loadHistoricalData(timeframe) {
|
||||
if (!currentHistoryVmId) return;
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.chart-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.timeframe === timeframe);
|
||||
});
|
||||
|
||||
const result = await apiCall(`/api/vm/${currentHistoryVmId}/rrddata?timeframe=${timeframe}`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
renderHistoryChart(result.data, timeframe);
|
||||
} else {
|
||||
showAlert('Errore caricamento dati storici: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistoryChart(data, timeframe) {
|
||||
const ctx = document.getElementById('historyChart').getContext('2d');
|
||||
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
const labels = data.map(d => {
|
||||
const date = new Date(d.time * 1000);
|
||||
if (timeframe === 'hour') {
|
||||
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (timeframe === 'day') {
|
||||
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
return date.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
});
|
||||
|
||||
historyChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU %',
|
||||
data: data.map(d => (d.cpu || 0) * 100),
|
||||
borderColor: '#58a6ff',
|
||||
backgroundColor: 'rgba(88, 166, 255, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'RAM %',
|
||||
data: data.map(d => {
|
||||
const mem = d.mem || d.memory || 0;
|
||||
const maxmem = d.maxmem || 1;
|
||||
return (mem / maxmem) * 100;
|
||||
}),
|
||||
borderColor: '#a371f7',
|
||||
backgroundColor: 'rgba(163, 113, 247, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: { color: '#8b949e' }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#21262d',
|
||||
titleColor: '#e6edf3',
|
||||
bodyColor: '#8b949e',
|
||||
borderColor: '#30363d',
|
||||
borderWidth: 1
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: { color: '#21262d' },
|
||||
ticks: { color: '#6e7681', maxTicksLimit: 10 }
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: { color: '#21262d' },
|
||||
ticks: {
|
||||
color: '#6e7681',
|
||||
callback: value => value + '%'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeHistoryModal() {
|
||||
document.getElementById('historyModal').style.display = 'none';
|
||||
currentHistoryVmId = null;
|
||||
}
|
||||
|
||||
// ==================== BACKUPS ====================
|
||||
|
||||
async function showBackups(vmId, vmName) {
|
||||
const modal = document.getElementById('backupModal');
|
||||
const title = document.getElementById('backupModalTitle');
|
||||
const content = document.getElementById('backupModalContent');
|
||||
|
||||
title.textContent = `Backup: ${vmName} (VM ${vmId})`;
|
||||
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento backup...</p></div>';
|
||||
modal.style.display = 'block';
|
||||
|
||||
const result = await apiCall(`/api/vm/${vmId}/backups`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (result.data.length === 0) {
|
||||
content.innerHTML = `
|
||||
<p class="text-muted text-center" style="padding: var(--space-xl);">Nessun backup disponibile</p>
|
||||
<div style="text-align: center;">
|
||||
<button class="btn btn-primary" onclick="createBackup(${vmId})">Crea Backup</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
content.innerHTML = `
|
||||
<p class="text-muted mb-2">Trovati ${result.count} backup</p>
|
||||
<div class="backup-list">
|
||||
${result.data.map(backup => {
|
||||
const volid = backup.volid || '';
|
||||
const parts = volid.split('/');
|
||||
const filename = parts[parts.length - 1];
|
||||
return `
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<div class="backup-name">${filename}</div>
|
||||
<div class="backup-date">${formatDate(backup.ctime || 0)}</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<span class="backup-size">${formatBytes(backup.size || 0)}</span>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteBackup('${backup.node}', '${backup.storage}', '${filename}', ${vmId})">
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: var(--space-lg);">
|
||||
<button class="btn btn-primary" onclick="createBackup(${vmId})">Crea Nuovo Backup</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
content.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(vmId) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Crea Backup',
|
||||
text: 'Vuoi creare un backup di questa VM? L\'operazione potrebbe richiedere diversi minuti.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Crea Backup',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Creazione backup in corso...', 'info');
|
||||
const response = await apiCall(`/api/vm/${vmId}/backup`, 'POST');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('Backup avviato con successo!', 'success');
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackup(node, storage, filename, vmId) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Elimina Backup',
|
||||
text: `Vuoi eliminare il backup "${filename}"? Questa operazione non può essere annullata.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonColor: '#f85149'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Eliminazione backup in corso...', 'info');
|
||||
const response = await apiCall(`/api/backup/${node}/${storage}/${filename}/delete`, 'DELETE');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('Backup eliminato con successo!', 'success');
|
||||
const vmName = document.getElementById('backupModalTitle').textContent.split(':')[1].split('(')[0].trim();
|
||||
showBackups(vmId, vmName);
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeBackupModal() {
|
||||
document.getElementById('backupModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== SNAPSHOTS ====================
|
||||
|
||||
async function showSnapshots(vmId, vmName) {
|
||||
const modal = document.getElementById('snapshotModal');
|
||||
const title = document.getElementById('snapshotModalTitle');
|
||||
const content = document.getElementById('snapshotModalContent');
|
||||
|
||||
title.textContent = `Snapshot: ${vmName} (VM ${vmId})`;
|
||||
content.innerHTML = '<div class="loading"><div class="spinner"></div><p>Caricamento snapshot...</p></div>';
|
||||
modal.style.display = 'block';
|
||||
|
||||
const result = await apiCall(`/api/vm/${vmId}/snapshots`);
|
||||
|
||||
if (result.status === 'success') {
|
||||
if (result.data.length === 0) {
|
||||
content.innerHTML = `
|
||||
<p class="text-muted text-center" style="padding: var(--space-xl);">Nessuna snapshot disponibile</p>
|
||||
<div style="text-align: center;">
|
||||
<button class="btn btn-primary" onclick="promptCreateSnapshot(${vmId})">Crea Snapshot</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
content.innerHTML = `
|
||||
<p class="text-muted mb-2">Trovate ${result.count} snapshot (max 3 consentite)</p>
|
||||
<div class="snapshot-list">
|
||||
${result.data.map(snapshot => {
|
||||
const snapTime = snapshot.snaptime ? formatDate(snapshot.snaptime) : 'N/A';
|
||||
return `
|
||||
<div class="snapshot-item">
|
||||
<div class="snapshot-info">
|
||||
<div class="snapshot-name">${snapshot.name}</div>
|
||||
<div class="snapshot-date">${snapTime}</div>
|
||||
${snapshot.description ? `<div class="text-muted" style="font-size: 0.8rem;">${snapshot.description}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm"
|
||||
onclick="deleteSnapshot(${vmId}, '${snapshot.name}', '${vmName}')">
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: var(--space-lg);">
|
||||
${result.count < 3 ? `
|
||||
<button class="btn btn-primary" onclick="promptCreateSnapshot(${vmId})">Crea Nuova Snapshot</button>
|
||||
` : `
|
||||
<p class="text-danger">Limite di 3 snapshot raggiunto. Elimina una snapshot per crearne una nuova.</p>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
content.innerHTML = `<div class="alert alert-error">${result.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptCreateSnapshot(vmId) {
|
||||
const { value: formValues } = await Swal.fire({
|
||||
title: 'Crea Snapshot',
|
||||
html: `
|
||||
<div class="form-group" style="text-align: left;">
|
||||
<label>Nome (opzionale)</label>
|
||||
<input id="swal-snapname" class="swal2-input" placeholder="auto-generato se vuoto">
|
||||
</div>
|
||||
<div class="form-group" style="text-align: left;">
|
||||
<label>Descrizione (opzionale)</label>
|
||||
<input id="swal-snapdesc" class="swal2-input" placeholder="Descrizione snapshot">
|
||||
</div>
|
||||
`,
|
||||
focusConfirm: false,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Crea',
|
||||
cancelButtonText: 'Annulla',
|
||||
preConfirm: () => {
|
||||
return {
|
||||
snapname: document.getElementById('swal-snapname').value,
|
||||
description: document.getElementById('swal-snapdesc').value
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (formValues) {
|
||||
await createSnapshot(vmId, formValues.snapname || undefined, formValues.description || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSnapshot(vmId, snapname, description) {
|
||||
showToast('Creazione snapshot in corso...', 'info');
|
||||
|
||||
const data = {};
|
||||
if (snapname) data.snapname = snapname;
|
||||
if (description) data.description = description;
|
||||
|
||||
const result = await apiCall(`/api/vm/${vmId}/snapshot`, 'POST', data);
|
||||
|
||||
if (result.status === 'success') {
|
||||
showToast('Snapshot creata con successo!', 'success');
|
||||
const vmName = document.getElementById('snapshotModalTitle').textContent.split(':')[1].split('(')[0].trim();
|
||||
showSnapshots(vmId, vmName);
|
||||
} else {
|
||||
showAlert('Errore: ' + result.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSnapshot(vmId, snapname, vmName) {
|
||||
const result = await Swal.fire({
|
||||
title: 'Elimina Snapshot',
|
||||
text: `Vuoi eliminare la snapshot "${snapname}"? Questa operazione non può essere annullata.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonColor: '#f85149'
|
||||
});
|
||||
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
showToast('Eliminazione snapshot in corso...', 'info');
|
||||
const response = await apiCall(`/api/vm/${vmId}/snapshot/${snapname}`, 'DELETE');
|
||||
|
||||
if (response.status === 'success') {
|
||||
showToast('Snapshot eliminata con successo!', 'success');
|
||||
showSnapshots(vmId, vmName);
|
||||
} else {
|
||||
showAlert('Errore: ' + response.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeSnapshotModal() {
|
||||
document.getElementById('snapshotModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== MODAL CLOSE ON OUTSIDE CLICK ====================
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target.classList.contains('modal')) {
|
||||
event.target.style.display = 'none';
|
||||
|
||||
// Cleanup console if closing console modal
|
||||
if (event.target.id === 'consoleModal') {
|
||||
document.getElementById('consoleContainer').innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user