1095 lines
34 KiB
HTML
1095 lines
34 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Topologia Cluster - Proxmox Manager{% endblock %}
|
||
|
||
{% block extra_styles %}
|
||
<style>
|
||
.topology-container {
|
||
display: flex;
|
||
gap: var(--space-lg);
|
||
margin-bottom: var(--space-lg);
|
||
}
|
||
|
||
.topology-canvas-wrapper {
|
||
flex: 1;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-lg);
|
||
padding: 16px;
|
||
position: relative;
|
||
min-height: 700px;
|
||
height: 700px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
#topologyCanvas {
|
||
display: block;
|
||
border-radius: var(--radius-md);
|
||
background: repeating-linear-gradient(
|
||
0deg,
|
||
rgba(255, 255, 255, 0.03) 0px,
|
||
rgba(255, 255, 255, 0.03) 1px,
|
||
transparent 1px,
|
||
transparent 20px
|
||
),
|
||
repeating-linear-gradient(
|
||
90deg,
|
||
rgba(255, 255, 255, 0.03) 0px,
|
||
rgba(255, 255, 255, 0.03) 1px,
|
||
transparent 1px,
|
||
transparent 20px
|
||
);
|
||
cursor: grab;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
#topologyCanvas:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.topology-sidebar {
|
||
width: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--space-md);
|
||
}
|
||
|
||
.topology-stats {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--space-lg);
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: var(--space-sm) 0;
|
||
border-bottom: 1px solid var(--border-default);
|
||
}
|
||
|
||
.stat-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.stat-label {
|
||
color: var(--text-secondary);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.stat-value {
|
||
color: var(--text-primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.topology-legend {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--space-lg);
|
||
}
|
||
|
||
.legend-title {
|
||
font-weight: 600;
|
||
margin-bottom: var(--space-md);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-sm);
|
||
margin-bottom: var(--space-sm);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.legend-color {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: var(--radius-sm);
|
||
border: 2px solid var(--border-default);
|
||
}
|
||
|
||
.legend-color.cluster {
|
||
background: var(--accent-blue);
|
||
}
|
||
|
||
.legend-color.node {
|
||
background: var(--accent-purple);
|
||
}
|
||
|
||
.legend-color.vm {
|
||
background: var(--accent-green);
|
||
}
|
||
|
||
.legend-color.lxc {
|
||
background: var(--accent-yellow);
|
||
}
|
||
|
||
.legend-color.storage {
|
||
background: var(--accent-orange);
|
||
}
|
||
|
||
.topology-controls {
|
||
display: flex;
|
||
gap: var(--space-md);
|
||
margin-bottom: var(--space-lg);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-btn {
|
||
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);
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background: var(--accent-blue);
|
||
color: white;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
.control-btn.active {
|
||
background: var(--accent-blue);
|
||
color: white;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
.node-details {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--space-lg);
|
||
display: none;
|
||
}
|
||
|
||
.node-details.visible {
|
||
display: block;
|
||
}
|
||
|
||
.node-details-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
|
||
.node-details-title {
|
||
font-size: 1.2rem;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.close-details {
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
transition: color var(--transition-fast);
|
||
}
|
||
|
||
.close-details:hover {
|
||
color: var(--accent-red);
|
||
}
|
||
|
||
.detail-section {
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
|
||
.detail-section-title {
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
font-size: 0.85rem;
|
||
text-transform: uppercase;
|
||
margin-bottom: var(--space-sm);
|
||
}
|
||
|
||
.detail-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: var(--space-xs) 0;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.resource-bar {
|
||
width: 100%;
|
||
height: 8px;
|
||
background: var(--bg-tertiary);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
margin-top: var(--space-xs);
|
||
}
|
||
|
||
.resource-bar-fill {
|
||
height: 100%;
|
||
transition: width var(--transition-fast);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.resource-bar-fill.cpu {
|
||
background: var(--accent-blue);
|
||
}
|
||
|
||
.resource-bar-fill.memory {
|
||
background: var(--accent-purple);
|
||
}
|
||
|
||
.resource-bar-fill.disk {
|
||
background: var(--accent-orange);
|
||
}
|
||
|
||
.zoom-controls {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.zoom-btn {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-md);
|
||
color: var(--text-primary);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.2rem;
|
||
transition: all var(--transition-fast);
|
||
}
|
||
|
||
.zoom-btn:hover {
|
||
background: var(--accent-blue);
|
||
color: white;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
|
||
.zoom-level {
|
||
text-align: center;
|
||
font-size: 0.75rem;
|
||
color: var(--text-secondary);
|
||
padding: 4px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-default);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
@media (max-width: 1024px) {
|
||
.topology-container {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.topology-sidebar {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="dashboard-header">
|
||
<div>
|
||
<h1>Topologia del Cluster</h1>
|
||
<p class="subtitle">↔️ Trascina NODI per riposizionare | 🖱️ Trascina sfondo per muovere | 🔍 Rotella per zoom | Click per dettagli</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="topology-controls">
|
||
<button class="control-btn active" onclick="setViewMode('all')" id="btnAll">Vista Completa</button>
|
||
<button class="control-btn" onclick="setViewMode('nodes')" id="btnNodes">Solo Nodi</button>
|
||
<button class="control-btn" onclick="setViewMode('vms')" id="btnVms">Solo VM/LXC</button>
|
||
<button class="control-btn" onclick="setViewMode('storage')" id="btnStorage">Storage</button>
|
||
<button class="control-btn" onclick="refreshTopology()">🔄 Aggiorna</button>
|
||
<button class="control-btn" onclick="saveLayout()">💾 Salva Layout</button>
|
||
<button class="control-btn" onclick="loadLayout()">📂 Carica Layout</button>
|
||
<button class="control-btn" onclick="resetLayout()">🔄 Reset Layout</button>
|
||
<button class="control-btn" onclick="exportTopology()">📤 Esporta JSON</button>
|
||
</div>
|
||
|
||
<div class="topology-container">
|
||
<div class="topology-canvas-wrapper">
|
||
<div class="zoom-controls">
|
||
<button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
|
||
<div class="zoom-level" id="zoomLevel">100%</div>
|
||
<button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
|
||
<button class="zoom-btn" onclick="resetZoom()" title="Reset">⟲</button>
|
||
</div>
|
||
<canvas id="topologyCanvas"></canvas>
|
||
</div>
|
||
|
||
<div class="topology-sidebar">
|
||
<div class="topology-stats card">
|
||
<h3 style="margin-bottom: var(--space-md);">Statistiche Cluster</h3>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Nodi</span>
|
||
<span class="stat-value" id="statNodes">-</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">VM (QEMU)</span>
|
||
<span class="stat-value" id="statVMs">-</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Container (LXC)</span>
|
||
<span class="stat-value" id="statLXC">-</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Storage</span>
|
||
<span class="stat-value" id="statStorage">-</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">Totale VM Attive</span>
|
||
<span class="stat-value" id="statActive">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="topology-legend card">
|
||
<div class="legend-title">Legenda</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color cluster"></div>
|
||
<span>Cluster</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color node"></div>
|
||
<span>Nodo Proxmox</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color vm"></div>
|
||
<span>VM (QEMU)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color lxc"></div>
|
||
<span>Container (LXC)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color storage"></div>
|
||
<span>Storage</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="node-details card" id="nodeDetails">
|
||
<div class="node-details-header">
|
||
<div class="node-details-title" id="detailsTitle">Dettagli Nodo</div>
|
||
<span class="close-details" onclick="closeDetails()">✕</span>
|
||
</div>
|
||
<div id="detailsContent"></div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_scripts %}
|
||
<script>
|
||
let topologyData = null;
|
||
let currentViewMode = 'all';
|
||
let canvas, ctx;
|
||
|
||
// Zoom e Pan
|
||
let scale = 1;
|
||
let offsetX = 0;
|
||
let offsetY = 0;
|
||
let isDragging = false;
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let hasMoved = false;
|
||
|
||
// Drag dei nodi
|
||
let draggedNode = null;
|
||
let nodePositions = {}; // Salva posizioni custom dei nodi
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
canvas = document.getElementById('topologyCanvas');
|
||
ctx = canvas.getContext('2d');
|
||
|
||
// Set canvas size to match wrapper
|
||
resizeCanvas();
|
||
|
||
// Carica layout salvato se disponibile
|
||
const saved = localStorage.getItem('topology_layout');
|
||
if (saved) {
|
||
try {
|
||
nodePositions = JSON.parse(saved);
|
||
console.log('Layout salvato caricato:', Object.keys(nodePositions).length, 'nodi');
|
||
} catch (e) {
|
||
console.error('Errore caricamento layout:', e);
|
||
nodePositions = {};
|
||
}
|
||
}
|
||
|
||
loadTopology();
|
||
|
||
// Inizializza tutti gli event listener
|
||
initEventListeners();
|
||
|
||
// Resize on window resize
|
||
window.addEventListener('resize', function() {
|
||
resizeCanvas();
|
||
if (topologyData) {
|
||
drawTopology();
|
||
}
|
||
});
|
||
});
|
||
|
||
function resizeCanvas() {
|
||
const wrapper = canvas.parentElement;
|
||
|
||
// Get the actual available space
|
||
const wrapperWidth = wrapper.clientWidth;
|
||
const wrapperHeight = wrapper.clientHeight;
|
||
|
||
// Account for padding (16px on each side = 32px total)
|
||
canvas.width = wrapperWidth - 32;
|
||
canvas.height = Math.max(650, wrapperHeight - 32);
|
||
|
||
// Set CSS size to match
|
||
canvas.style.width = canvas.width + 'px';
|
||
canvas.style.height = canvas.height + 'px';
|
||
|
||
console.log('Canvas resized to:', canvas.width, 'x', canvas.height);
|
||
console.log('Wrapper size:', wrapperWidth, 'x', wrapperHeight);
|
||
}
|
||
|
||
async function loadTopology() {
|
||
const result = await apiCall('/api/admin/cluster/topology');
|
||
|
||
if (result.status === 'success') {
|
||
topologyData = result.data;
|
||
updateStats();
|
||
drawTopology();
|
||
} else {
|
||
showAlert('Errore nel caricamento della topologia: ' + result.message, 'error');
|
||
}
|
||
}
|
||
|
||
function updateStats() {
|
||
if (!topologyData) return;
|
||
|
||
const nodes = topologyData.nodes || [];
|
||
const resources = topologyData.resources || [];
|
||
|
||
const vms = resources.filter(r => r.type === 'qemu');
|
||
const lxc = resources.filter(r => r.type === 'lxc');
|
||
const storage = resources.filter(r => r.type === 'storage');
|
||
const active = resources.filter(r => (r.type === 'qemu' || r.type === 'lxc') && r.status === 'running');
|
||
|
||
document.getElementById('statNodes').textContent = nodes.length;
|
||
document.getElementById('statVMs').textContent = vms.length;
|
||
document.getElementById('statLXC').textContent = lxc.length;
|
||
document.getElementById('statStorage').textContent = storage.length;
|
||
document.getElementById('statActive').textContent = active.length;
|
||
}
|
||
|
||
function drawTopology() {
|
||
if (!topologyData) return;
|
||
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Salva stato e applica trasformazioni
|
||
ctx.save();
|
||
ctx.translate(offsetX, offsetY);
|
||
ctx.scale(scale, scale);
|
||
|
||
// Reset click areas
|
||
canvas.clickAreas = [];
|
||
|
||
const nodes = topologyData.nodes || [];
|
||
const resources = topologyData.resources || [];
|
||
|
||
// Draw cluster center - più centrato verticalmente
|
||
const centerX = canvas.width / 2;
|
||
const centerY = Math.min(150, canvas.height / 3);
|
||
|
||
// Usa posizione salvata se disponibile
|
||
const clusterKey = 'cluster_proxmox';
|
||
const clusterPos = nodePositions[clusterKey] || { x: centerX, y: centerY };
|
||
drawNode(clusterPos.x, clusterPos.y, 'Proxmox Cluster', 'cluster', 60);
|
||
|
||
// Draw nodes in a circle around cluster
|
||
const nodeRadius = 200;
|
||
nodes.forEach((node, idx) => {
|
||
const angle = (Math.PI * 2 * idx) / nodes.length - Math.PI / 2;
|
||
const defaultX = centerX + Math.cos(angle) * nodeRadius;
|
||
const defaultY = centerY + Math.sin(angle) * nodeRadius;
|
||
|
||
// Usa posizione salvata se disponibile
|
||
const nodeKey = `node_${node.node}`;
|
||
const nodePos = nodePositions[nodeKey] || { x: defaultX, y: defaultY };
|
||
|
||
// Draw connection to cluster
|
||
drawConnection(clusterPos.x, clusterPos.y, nodePos.x, nodePos.y, '#58a6ff');
|
||
|
||
// Draw node
|
||
drawNode(nodePos.x, nodePos.y, node.node, 'node', 50, node);
|
||
|
||
if (currentViewMode === 'all' || currentViewMode === 'vms') {
|
||
// Draw VMs/LXC for this node
|
||
const nodeResources = resources.filter(r =>
|
||
(r.type === 'qemu' || r.type === 'lxc') && r.node === node.node
|
||
);
|
||
|
||
nodeResources.forEach((res, resIdx) => {
|
||
const resAngle = angle + (resIdx - nodeResources.length / 2) * 0.3;
|
||
const defaultResX = nodePos.x + Math.cos(resAngle) * 120;
|
||
const defaultResY = nodePos.y + Math.sin(resAngle) * 120;
|
||
|
||
// Usa posizione salvata se disponibile
|
||
const resKey = `${res.type}_${res.vmid}`;
|
||
const resPos = nodePositions[resKey] || { x: defaultResX, y: defaultResY };
|
||
|
||
drawConnection(nodePos.x, nodePos.y, resPos.x, resPos.y, res.type === 'qemu' ? '#3fb950' : '#d2991e');
|
||
drawNode(resPos.x, resPos.y, res.name || `${res.type}-${res.vmid}`, res.type, 30, res);
|
||
});
|
||
}
|
||
});
|
||
|
||
if (currentViewMode === 'all' || currentViewMode === 'storage') {
|
||
// Draw storage
|
||
const storage = resources.filter(r => r.type === 'storage');
|
||
const storageY = canvas.height - 80;
|
||
storage.forEach((stor, idx) => {
|
||
const x = 100 + idx * 150;
|
||
drawNode(x, storageY, stor.storage, 'storage', 40, stor);
|
||
});
|
||
}
|
||
|
||
// Ripristina stato canvas
|
||
ctx.restore();
|
||
}
|
||
|
||
function drawNode(x, y, label, type, size, data = null) {
|
||
const colors = {
|
||
'cluster': '#58a6ff',
|
||
'node': '#a371f7',
|
||
'qemu': '#3fb950',
|
||
'lxc': '#d2991e',
|
||
'storage': '#ff8800'
|
||
};
|
||
|
||
const color = colors[type] || '#58a6ff';
|
||
|
||
// Draw circle
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, size, 0, Math.PI * 2);
|
||
ctx.fillStyle = color + '33';
|
||
ctx.fill();
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
|
||
// Draw status indicator for VMs/LXC
|
||
if (data && (type === 'qemu' || type === 'lxc')) {
|
||
const statusColor = data.status === 'running' ? '#3fb950' : '#ff4e50';
|
||
ctx.beginPath();
|
||
ctx.arc(x + size - 8, y - size + 8, 6, 0, Math.PI * 2);
|
||
ctx.fillStyle = statusColor;
|
||
ctx.fill();
|
||
}
|
||
|
||
// Draw icon
|
||
ctx.font = `${Math.max(size / 2, 20)}px Arial`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = color;
|
||
const icons = {
|
||
'cluster': '⚡',
|
||
'node': '🖥️',
|
||
'qemu': '💻',
|
||
'lxc': '📦',
|
||
'storage': '💾'
|
||
};
|
||
ctx.fillText(icons[type] || '•', x, y);
|
||
|
||
// Draw label
|
||
ctx.font = '12px Arial';
|
||
ctx.fillStyle = '#c9d1d9';
|
||
ctx.fillText(label.length > 15 ? label.substring(0, 15) + '...' : label, x, y + size + 15);
|
||
|
||
// Genera chiave univoca per questo nodo
|
||
let nodeKey;
|
||
if (type === 'cluster') {
|
||
nodeKey = 'cluster_proxmox';
|
||
} else if (type === 'node') {
|
||
nodeKey = `node_${label}`;
|
||
} else if (type === 'storage') {
|
||
nodeKey = `storage_${label}`;
|
||
} else if (data && data.vmid) {
|
||
nodeKey = `${type}_${data.vmid}`;
|
||
} else {
|
||
nodeKey = `${type}_${label}`;
|
||
}
|
||
|
||
// Store click area con chiave univoca (initialized in drawTopology)
|
||
canvas.clickAreas.push({ x, y, size, type, label, data, nodeKey });
|
||
}
|
||
|
||
function drawConnection(x1, y1, x2, y2, color) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.lineTo(x2, y2);
|
||
ctx.strokeStyle = color + '66';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
}
|
||
|
||
function handleCanvasClick(event) {
|
||
// Se il mouse si è mosso durante il drag, non è un click
|
||
if (hasMoved) {
|
||
hasMoved = false;
|
||
return;
|
||
}
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
|
||
// Calculate click position considering canvas scale
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
|
||
// Coordinate nel canvas
|
||
let x = (event.clientX - rect.left) * scaleX;
|
||
let y = (event.clientY - rect.top) * scaleY;
|
||
|
||
// Applica trasformazione inversa per zoom e pan
|
||
x = (x - offsetX) / scale;
|
||
y = (y - offsetY) / scale;
|
||
|
||
console.log('Click at:', x, y, 'scale:', scale, 'offset:', offsetX, offsetY);
|
||
console.log('Canvas areas:', canvas.clickAreas ? canvas.clickAreas.length : 0);
|
||
|
||
if (!canvas.clickAreas || canvas.clickAreas.length === 0) {
|
||
console.warn('No click areas defined!');
|
||
return;
|
||
}
|
||
|
||
for (const area of canvas.clickAreas) {
|
||
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
|
||
console.log(`Distance to ${area.label}:`, distance, 'threshold:', area.size);
|
||
|
||
if (distance <= area.size) {
|
||
console.log('Click detected on:', area.label);
|
||
showDetails(area);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
function showDetails(area) {
|
||
const details = document.getElementById('nodeDetails');
|
||
const title = document.getElementById('detailsTitle');
|
||
const content = document.getElementById('detailsContent');
|
||
|
||
title.textContent = `${area.label} (${area.type.toUpperCase()})`;
|
||
|
||
let html = '';
|
||
|
||
if (area.data) {
|
||
const data = area.data;
|
||
|
||
if (area.type === 'node') {
|
||
html = `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">Informazioni Nodo</div>
|
||
<div class="detail-item">
|
||
<span>Stato:</span>
|
||
<span><strong>${data.status || 'N/A'}</strong></span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>CPU:</span>
|
||
<span>${data.cpu ? (data.cpu * 100).toFixed(1) + '%' : 'N/A'}</span>
|
||
</div>
|
||
${data.cpu ? `<div class="resource-bar"><div class="resource-bar-fill cpu" style="width: ${data.cpu * 100}%"></div></div>` : ''}
|
||
<div class="detail-item">
|
||
<span>Memoria:</span>
|
||
<span>${data.mem && data.maxmem ? ((data.mem / data.maxmem) * 100).toFixed(1) + '%' : 'N/A'}</span>
|
||
</div>
|
||
${data.mem && data.maxmem ? `<div class="resource-bar"><div class="resource-bar-fill memory" style="width: ${(data.mem / data.maxmem) * 100}%"></div></div>` : ''}
|
||
<div class="detail-item">
|
||
<span>Uptime:</span>
|
||
<span>${data.uptime ? formatUptime(data.uptime) : 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (area.type === 'qemu' || area.type === 'lxc') {
|
||
html = `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">Informazioni ${area.type === 'qemu' ? 'VM' : 'Container'}</div>
|
||
<div class="detail-item">
|
||
<span>ID:</span>
|
||
<span><strong>${data.vmid || 'N/A'}</strong></span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Nodo:</span>
|
||
<span>${data.node || 'N/A'}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Stato:</span>
|
||
<span><strong style="color: ${data.status === 'running' ? 'var(--accent-green)' : 'var(--accent-red)'}">${data.status || 'N/A'}</strong></span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>CPU:</span>
|
||
<span>${data.cpu ? (data.cpu * 100).toFixed(1) + '%' : 'N/A'}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Memoria:</span>
|
||
<span>${data.mem && data.maxmem ? `${(data.mem / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxmem / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Disco:</span>
|
||
<span>${data.disk && data.maxdisk ? `${(data.disk / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxdisk / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Uptime:</span>
|
||
<span>${data.uptime ? formatUptime(data.uptime) : 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (area.type === 'storage') {
|
||
html = `
|
||
<div class="detail-section">
|
||
<div class="detail-section-title">Informazioni Storage</div>
|
||
<div class="detail-item">
|
||
<span>Nome:</span>
|
||
<span><strong>${data.storage || 'N/A'}</strong></span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Tipo:</span>
|
||
<span>${data.plugintype || 'N/A'}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span>Utilizzo:</span>
|
||
<span>${data.disk && data.maxdisk ? ((data.disk / data.maxdisk) * 100).toFixed(1) + '%' : 'N/A'}</span>
|
||
</div>
|
||
${data.disk && data.maxdisk ? `<div class="resource-bar"><div class="resource-bar-fill disk" style="width: ${(data.disk / data.maxdisk) * 100}%"></div></div>` : ''}
|
||
<div class="detail-item">
|
||
<span>Spazio:</span>
|
||
<span>${data.disk && data.maxdisk ? `${(data.disk / 1024 / 1024 / 1024).toFixed(2)} GB / ${(data.maxdisk / 1024 / 1024 / 1024).toFixed(2)} GB` : 'N/A'}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
html = '<p>Nessun dettaglio disponibile</p>';
|
||
}
|
||
|
||
content.innerHTML = html;
|
||
details.classList.add('visible');
|
||
}
|
||
|
||
function closeDetails() {
|
||
document.getElementById('nodeDetails').classList.remove('visible');
|
||
}
|
||
|
||
function setViewMode(mode) {
|
||
currentViewMode = mode;
|
||
|
||
document.querySelectorAll('.control-btn').forEach(btn => btn.classList.remove('active'));
|
||
|
||
// Fix per ID corretto dei bottoni
|
||
const btnMap = {
|
||
'all': 'btnAll',
|
||
'nodes': 'btnNodes',
|
||
'vms': 'btnVms',
|
||
'storage': 'btnStorage'
|
||
};
|
||
|
||
if (btnMap[mode]) {
|
||
document.getElementById(btnMap[mode]).classList.add('active');
|
||
}
|
||
|
||
drawTopology();
|
||
}
|
||
|
||
function refreshTopology() {
|
||
showAlert('Aggiornamento topologia...', 'info');
|
||
loadTopology();
|
||
}
|
||
|
||
function saveLayout() {
|
||
localStorage.setItem('topology_layout', JSON.stringify(nodePositions));
|
||
showAlert('Layout salvato! Le posizioni dei nodi sono state memorizzate.', 'success');
|
||
}
|
||
|
||
function loadLayout() {
|
||
const saved = localStorage.getItem('topology_layout');
|
||
if (saved) {
|
||
nodePositions = JSON.parse(saved);
|
||
drawTopology();
|
||
showAlert('Layout caricato!', 'success');
|
||
} else {
|
||
showAlert('Nessun layout salvato trovato', 'warning');
|
||
}
|
||
}
|
||
|
||
function resetLayout() {
|
||
if (confirm('Vuoi ripristinare il layout originale? Tutte le posizioni personalizzate verranno perse.')) {
|
||
nodePositions = {};
|
||
localStorage.removeItem('topology_layout');
|
||
drawTopology();
|
||
showAlert('Layout ripristinato!', 'success');
|
||
}
|
||
}
|
||
|
||
function exportTopology() {
|
||
if (!topologyData) {
|
||
showAlert('Nessun dato da esportare', 'warning');
|
||
return;
|
||
}
|
||
|
||
const dataStr = JSON.stringify(topologyData, null, 2);
|
||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `cluster_topology_${new Date().toISOString().split('T')[0]}.json`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
|
||
showAlert('Topologia esportata con successo', 'success');
|
||
}
|
||
|
||
function formatUptime(seconds) {
|
||
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`;
|
||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||
return `${minutes}m`;
|
||
}
|
||
|
||
// ==================== ZOOM E PAN ====================
|
||
|
||
function zoomIn() {
|
||
scale = Math.min(scale * 1.2, 3); // Max 3x
|
||
updateZoomLevel();
|
||
drawTopology();
|
||
}
|
||
|
||
function zoomOut() {
|
||
scale = Math.max(scale / 1.2, 0.3); // Min 0.3x
|
||
updateZoomLevel();
|
||
drawTopology();
|
||
}
|
||
|
||
function resetZoom() {
|
||
scale = 1;
|
||
offsetX = 0;
|
||
offsetY = 0;
|
||
updateZoomLevel();
|
||
drawTopology();
|
||
}
|
||
|
||
function updateZoomLevel() {
|
||
document.getElementById('zoomLevel').textContent = Math.round(scale * 100) + '%';
|
||
}
|
||
|
||
// Inizializza tutti gli event listener
|
||
function initEventListeners() {
|
||
let mouseDownX = 0;
|
||
let mouseDownY = 0;
|
||
let touchStartDistance = 0;
|
||
let touchStartScale = 1;
|
||
|
||
// Click handler
|
||
canvas.addEventListener('click', handleCanvasClick);
|
||
|
||
// Mouse wheel zoom
|
||
canvas.addEventListener('wheel', function(event) {
|
||
event.preventDefault();
|
||
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mouseX = event.clientX - rect.left;
|
||
const mouseY = event.clientY - rect.top;
|
||
|
||
// Calcola punto nel mondo prima dello zoom
|
||
const worldX = (mouseX - offsetX) / scale;
|
||
const worldY = (mouseY - offsetY) / scale;
|
||
|
||
// Applica zoom
|
||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||
const newScale = Math.min(Math.max(scale * zoomFactor, 0.3), 3);
|
||
|
||
// Calcola nuovo offset per mantenere punto sotto mouse
|
||
offsetX = mouseX - worldX * newScale;
|
||
offsetY = mouseY - worldY * newScale;
|
||
|
||
scale = newScale;
|
||
updateZoomLevel();
|
||
drawTopology();
|
||
});
|
||
|
||
// Pan con mouse drag / Drag nodi
|
||
canvas.addEventListener('mousedown', function(event) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
let x = (event.clientX - rect.left) * scaleX;
|
||
let y = (event.clientY - rect.top) * scaleY;
|
||
|
||
// Applica trasformazione inversa
|
||
x = (x - offsetX) / scale;
|
||
y = (y - offsetY) / scale;
|
||
|
||
// Controlla se si è cliccato su un nodo
|
||
draggedNode = null;
|
||
if (canvas.clickAreas) {
|
||
for (const area of canvas.clickAreas) {
|
||
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
|
||
if (distance <= area.size) {
|
||
draggedNode = area;
|
||
canvas.style.cursor = 'move';
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Se non è su un nodo, è pan del canvas
|
||
if (!draggedNode) {
|
||
isDragging = true;
|
||
canvas.style.cursor = 'grabbing';
|
||
}
|
||
|
||
hasMoved = false;
|
||
mouseDownX = event.clientX;
|
||
mouseDownY = event.clientY;
|
||
dragStartX = event.clientX - offsetX;
|
||
dragStartY = event.clientY - offsetY;
|
||
});
|
||
|
||
canvas.addEventListener('mousemove', function(event) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
|
||
if (draggedNode) {
|
||
// Drag del nodo
|
||
let x = (event.clientX - rect.left) * scaleX;
|
||
let y = (event.clientY - rect.top) * scaleY;
|
||
|
||
// Applica trasformazione inversa
|
||
x = (x - offsetX) / scale;
|
||
y = (y - offsetY) / scale;
|
||
|
||
// Salva nuova posizione
|
||
nodePositions[draggedNode.nodeKey] = { x, y };
|
||
|
||
hasMoved = true;
|
||
drawTopology();
|
||
} else if (isDragging) {
|
||
// Pan del canvas
|
||
const newOffsetX = event.clientX - dragStartX;
|
||
const newOffsetY = event.clientY - dragStartY;
|
||
|
||
// Se il mouse si è mosso di più di 3 pixel, considera come drag
|
||
const movedDistance = Math.sqrt(
|
||
Math.pow(event.clientX - mouseDownX, 2) +
|
||
Math.pow(event.clientY - mouseDownY, 2)
|
||
);
|
||
|
||
if (movedDistance > 3) {
|
||
hasMoved = true;
|
||
offsetX = newOffsetX;
|
||
offsetY = newOffsetY;
|
||
drawTopology();
|
||
}
|
||
} else {
|
||
// Hover detection
|
||
const rect = canvas.getBoundingClientRect();
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
let x = (event.clientX - rect.left) * scaleX;
|
||
let y = (event.clientY - rect.top) * scaleY;
|
||
|
||
// Applica trasformazione inversa
|
||
x = (x - offsetX) / scale;
|
||
y = (y - offsetY) / scale;
|
||
|
||
let isOverNode = false;
|
||
|
||
if (canvas.clickAreas) {
|
||
for (const area of canvas.clickAreas) {
|
||
const distance = Math.sqrt(Math.pow(x - area.x, 2) + Math.pow(y - area.y, 2));
|
||
if (distance <= area.size) {
|
||
isOverNode = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
canvas.style.cursor = isOverNode ? 'move' : 'grab';
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('mouseup', function() {
|
||
isDragging = false;
|
||
draggedNode = null;
|
||
canvas.style.cursor = 'grab';
|
||
});
|
||
|
||
canvas.addEventListener('mouseleave', function() {
|
||
isDragging = false;
|
||
draggedNode = null;
|
||
hasMoved = false;
|
||
canvas.style.cursor = 'grab';
|
||
});
|
||
|
||
// Touch support per mobile
|
||
canvas.addEventListener('touchstart', function(event) {
|
||
if (event.touches.length === 2) {
|
||
// Pinch zoom
|
||
const touch1 = event.touches[0];
|
||
const touch2 = event.touches[1];
|
||
touchStartDistance = Math.sqrt(
|
||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||
);
|
||
touchStartScale = scale;
|
||
} else if (event.touches.length === 1) {
|
||
// Pan
|
||
isDragging = true;
|
||
dragStartX = event.touches[0].clientX - offsetX;
|
||
dragStartY = event.touches[0].clientY - offsetY;
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('touchmove', function(event) {
|
||
event.preventDefault();
|
||
|
||
if (event.touches.length === 2) {
|
||
const touch1 = event.touches[0];
|
||
const touch2 = event.touches[1];
|
||
const currentDistance = Math.sqrt(
|
||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||
Math.pow(touch2.clientY - touch1.clientY, 2)
|
||
);
|
||
|
||
scale = Math.min(Math.max(touchStartScale * (currentDistance / touchStartDistance), 0.3), 3);
|
||
updateZoomLevel();
|
||
drawTopology();
|
||
} else if (event.touches.length === 1 && isDragging) {
|
||
offsetX = event.touches[0].clientX - dragStartX;
|
||
offsetY = event.touches[0].clientY - dragStartY;
|
||
drawTopology();
|
||
}
|
||
});
|
||
|
||
canvas.addEventListener('touchend', function() {
|
||
isDragging = false;
|
||
});
|
||
}
|
||
|
||
</script>
|
||
{% endblock %}
|