691 lines
21 KiB
HTML
691 lines
21 KiB
HTML
{% 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 %}
|