diff --git a/README.md b/README.md index c6ffd4c..32e6386 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Proxmox Unified Console (PUC) 🚀 +![PUC Dashboard Preview](assets/dashboard.png) + A lightning-fast, unified web dashboard to manage your **Proxmox Virtual Environment (PVE)**, **Proxmox Backup Server (PBS)**, and **Proxmox Mail Gateway (PMG)** from a single, clean interface. Built entirely with native APIs—no slow iframes, no CORS issues. diff --git a/assets/dashboard.png b/assets/dashboard.png new file mode 100644 index 0000000..2ea2650 Binary files /dev/null and b/assets/dashboard.png differ diff --git a/src/api.php b/src/api.php index 5448651..516290e 100644 --- a/src/api.php +++ b/src/api.php @@ -85,40 +85,37 @@ if ($action === 'change_password') { $isAdmin = ($_SESSION['role'] ?? '') === 'admin'; @session_write_close(); +// === PROXMOX API FETCHER === function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 6) { $port = ($type === 'pbs') ? 8007 : 8006; - $chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket"); - curl_setopt_array($chAuth, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_TIMEOUT => 4, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret]) - ]); - $authRes = json_decode(curl_exec($chAuth), true); - curl_close($chAuth); - - if(!isset($authRes['data']['ticket'])) { - return ['data' => null, 'error' => 'Authentifizierung fehlgeschlagen']; - } + if ($type === 'pmg' || $type === 'pbs') { + // TICKET AUTH für PBS und PMG + $chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket"); + curl_setopt_array($chAuth, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 4, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret]) + ]); + $authRes = json_decode(curl_exec($chAuth), true); curl_close($chAuth); + + if(!isset($authRes['data']['ticket'])) return ['data' => null, 'error' => 'Auth failed']; - $cookieName = 'PVEAuthCookie'; - if ($type === 'pbs') $cookieName = 'PBSAuthCookie'; - if ($type === 'pmg') $cookieName = 'PMGAuthCookie'; - - $headers = [ - "Cookie: {$cookieName}=" . $authRes['data']['ticket'], - "CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken'] - ]; + $cookieName = ($type === 'pbs') ? 'PBSAuthCookie' : 'PMGAuthCookie'; + $headers = [ + "Cookie: {$cookieName}=" . $authRes['data']['ticket'], + "CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken'] + ]; + } else { + // NATIVE API TOKENS FÜR PVE + $headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"]; + } $ch = curl_init("https://{$ip}:{$port}{$endpoint}"); $options = [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_TIMEOUT => $timeout, + CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers ]; @@ -130,8 +127,7 @@ function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $ } curl_setopt_array($ch, $options); - $res = curl_exec($ch); - curl_close($ch); + $res = curl_exec($ch); curl_close($ch); return json_decode($res, true) ?: []; } diff --git a/src/api_pve.php b/src/api_pve.php index 72a04d2..994148a 100644 --- a/src/api_pve.php +++ b/src/api_pve.php @@ -3,16 +3,64 @@ if (!defined('PDO::ATTR_DRIVER_NAME')) exit; // Schutz vor direktem Aufruf if ($action === 'get_stats') { - $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); - $data = ['cpu_cores' => 0, 'cpu_used' => 0, 'ram_total' => 0, 'ram_used' => 0, 'disk_total' => 0, 'disk_used' => 0, 'cpu_percent' => 0]; + $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); + $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); + $data = [ + 'cpu_cores' => 0, 'cpu_used' => 0, 'ram_total' => 0, 'ram_used' => 0, + 'disk_total' => 0, 'disk_used' => 0, 'cpu_percent' => 0, 'nodes_net' => [], + 'cluster_stats' => ['nodes_total' => count($nodes), 'nodes_online' => 0, 'vms_total' => 0, 'vms_running' => 0, 'vms_stopped' => 0] + ]; + + $seenNodes = []; + $seenVms = []; + foreach ($nodes as $node) { $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes"); if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { - if ($nInfo['status'] === 'online') { - $data['cpu_cores'] += $nInfo['maxcpu'] ?? 0; $data['cpu_used'] += ($nInfo['cpu'] ?? 0) * ($nInfo['maxcpu'] ?? 0); - $data['ram_total'] += $nInfo['maxmem'] ?? 0; $data['ram_used'] += $nInfo['mem'] ?? 0; - $data['disk_total'] += $nInfo['maxdisk'] ?? 0; $data['disk_used'] += $nInfo['disk'] ?? 0; + // Duplikate (z.B. in Clustern) vermeiden + if (!isset($seenNodes[$nInfo['node']])) { + $seenNodes[$nInfo['node']] = true; + + if ($nInfo['status'] === 'online') { + $data['cluster_stats']['nodes_online']++; + $data['cpu_cores'] += $nInfo['maxcpu'] ?? 0; + $data['cpu_used'] += ($nInfo['cpu'] ?? 0) * ($nInfo['maxcpu'] ?? 0); + $data['ram_total'] += $nInfo['maxmem'] ?? 0; + $data['ram_used'] += $nInfo['mem'] ?? 0; + $data['disk_total'] += $nInfo['maxdisk'] ?? 0; + $data['disk_used'] += $nInfo['disk'] ?? 0; + + $nRrd = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/rrddata?timeframe=hour&cf=AVERAGE"); + $netin = 0; $netout = 0; + if (isset($nRrd['data']) && is_array($nRrd['data'])) { + for ($i = count($nRrd['data']) - 1; $i >= 0; $i--) { + if (isset($nRrd['data'][$i]['netin']) && $nRrd['data'][$i]['netin'] !== null) { + $netin = $nRrd['data'][$i]['netin']; + $netout = $nRrd['data'][$i]['netout']; + break; + } + } + } + $data['nodes_net'][] = ['name' => $nInfo['node'], 'netin' => $netin, 'netout' => $netout]; + } + } + } + } + + // VMs von allen angebundenen Hosts ziehen und Duplikate filtern + $vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm"); + if (isset($vms['data'])) { + foreach($vms['data'] as $vm) { + $uniqueId = $vm['vmid'] . '@' . ($vm['node'] ?? 'unknown'); + if (!isset($seenVms[$uniqueId])) { + $seenVms[$uniqueId] = true; + $data['cluster_stats']['vms_total']++; + if (isset($vm['status']) && $vm['status'] === 'running') { + $data['cluster_stats']['vms_running']++; + } else { + $data['cluster_stats']['vms_stopped']++; + } } } } @@ -22,12 +70,14 @@ if ($action === 'get_stats') { } if ($action === 'get_top_vms') { - $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; + $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; $seenVms = []; foreach ($nodes as $node) { $vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm"); if (isset($vms['data'])) { foreach ($vms['data'] as $vm) { - if (checkVmPermission($pdo, $vm['vmid'])) { + $uniqueId = $vm['vmid'] . '@' . ($vm['node'] ?? 'unknown'); + if (!isset($seenVms[$uniqueId]) && checkVmPermission($pdo, $vm['vmid'])) { + $seenVms[$uniqueId] = true; $vm['node_id'] = $node['id']; $vm['node_ip'] = $node['ip_address']; $vm['host'] = $vm['node'] ?? 'unknown'; @@ -41,12 +91,14 @@ if ($action === 'get_top_vms') { } if ($action === 'get_all_vms') { - $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; + $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; $seenVms = []; foreach ($nodes as $node) { $vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm"); if (isset($vms['data'])) { foreach ($vms['data'] as $vm) { - if (checkVmPermission($pdo, $vm['vmid'])) { + $uniqueId = $vm['vmid'] . '@' . ($vm['node'] ?? 'unknown'); + if (!isset($seenVms[$uniqueId]) && checkVmPermission($pdo, $vm['vmid'])) { + $seenVms[$uniqueId] = true; $vm['node_id'] = $node['id']; $vm['node_ip'] = $node['ip_address']; $vm['host'] = $vm['node'] ?? 'unknown'; @@ -68,14 +120,63 @@ if ($action === 'vm_action') { } if ($action === 'get_nodes') { if (!$isAdmin) exit; $stmt = $pdo->query("SELECT id, name, ip_address, type FROM nodes"); echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); exit; } + if ($action === 'delete_node') { if (!$isAdmin) exit; $stmt = $pdo->prepare("DELETE FROM nodes WHERE id = ?"); $stmt->execute([$_POST['id']]); logAudit('Server gelöscht', "Node ID: {$_POST['id']}"); echo json_encode(['success' => true]); exit; } + if ($action === 'add_node') { if (!$isAdmin) exit; + + $name = trim($_POST['name']); + $ip = trim($_POST['ip']); + $user = trim($_POST['user']); + $pass = trim($_POST['pass']); + $type = $_POST['type'] ?? 'pve'; + + $tokenIdToSave = $user; + $tokenSecretToSave = $pass; + + if ($type === 'pve') { + $chAuth = curl_init("https://{$ip}:8006/api2/json/access/ticket"); + curl_setopt_array($chAuth, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 5, + CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['username' => $user, 'password' => $pass]) + ]); + $authRes = json_decode(curl_exec($chAuth), true); + curl_close($chAuth); + + if(!isset($authRes['data']['ticket'])) { + echo json_encode(['success' => false, 'error' => 'Proxmox Login fehlgeschlagen. Passwort falsch?']); exit; + } + + $ticket = $authRes['data']['ticket']; + $csrf = $authRes['data']['CSRFPreventionToken']; + + $tokenName = 'pvedash' . rand(1000, 9999); + $chToken = curl_init("https://{$ip}:8006/api2/json/access/users/{$user}/token/{$tokenName}"); + curl_setopt_array($chToken, [ + CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 5, + CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['privsep' => 0]), + CURLOPT_HTTPHEADER => ["Cookie: PVEAuthCookie={$ticket}", "CSRFPreventionToken: {$csrf}"] + ]); + $tokenRes = json_decode(curl_exec($chToken), true); + curl_close($chToken); + + if(!isset($tokenRes['data']['value'])) { + echo json_encode(['success' => false, 'error' => 'Konnte API Token nicht erstellen. Admin-Rechte?']); exit; + } + + $tokenIdToSave = $user . '!' . $tokenName; + $tokenSecretToSave = $tokenRes['data']['value']; + } + $stmt = $pdo->prepare("INSERT INTO nodes (name, ip_address, token_id, token_secret, type) VALUES (?, ?, ?, ?, ?)"); - $stmt->execute([$_POST['name'], $_POST['ip'], $_POST['user'], $_POST['pass'], $_POST['type'] ?? 'pve']); - logAudit('Server hinzugefügt', "Node: {$_POST['name']} ({$_POST['ip']})"); + $stmt->execute([$name, $ip, $tokenIdToSave, $tokenSecretToSave, $type]); + logAudit('Server hinzugefügt', "Node: {$name} ({$ip})"); echo json_encode(['success' => true]); exit; } + if ($action === 'get_users') { if (!$isAdmin) exit; $stmt = $pdo->query("SELECT id, username, role FROM users"); echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); exit; } if ($action === 'delete_user') { if (!$isAdmin) exit; $stmt = $pdo->prepare("DELETE FROM users WHERE id = ?"); $stmt->execute([$_POST['id']]); logAudit('User gelöscht', "User ID: {$_POST['id']}"); echo json_encode(['success' => true]); exit; } if ($action === 'create_user') { @@ -88,10 +189,17 @@ if ($action === 'create_user') { if ($action === 'get_pve_nodes') { if (!$isAdmin) exit; - $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $resList = []; + $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $resList = []; $seenNodes = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $node) { $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes"); - if(isset($nData['data'])) { foreach($nData['data'] as $n) { if($n['status'] === 'online') { $resList[] = ['node_id' => $node['id'], 'host' => $n['node'], 'display' => $node['name'] . ' -> ' . $n['node']]; } } } + if(isset($nData['data'])) { + foreach($nData['data'] as $n) { + if($n['status'] === 'online' && !isset($seenNodes[$n['node']])) { + $seenNodes[$n['node']] = true; + $resList[] = ['node_id' => $node['id'], 'host' => $n['node'], 'display' => $node['name'] . ' -> ' . $n['node']]; + } + } + } } echo json_encode(['success' => true, 'data' => $resList]); exit; } @@ -105,7 +213,7 @@ if ($action === 'create_vm') { $params = ['vmid' => $vmid, 'name' => $_POST['name'], 'memory' => $_POST['memory'], 'cores' => $_POST['cores'], 'net0' => 'virtio,bridge=vmbr0']; $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/qemu", 'pve', 'POST', $params); logAudit('VM Erstellt', "VMID: {$vmid} Name: {$_POST['name']}"); - echo json_encode(['success' => isset($res['data']), 'vmid' => $vmid, 'error' => isset($res['data']) ? '' : 'Fehler bei der VM Erstellung in Proxmox.']); exit; + echo json_encode(['success' => isset($res['data']), 'vmid' => $vmid, 'error' => isset($res['data']) ? '' : 'Fehler bei Erstellung.']); exit; } if ($action === 'get_vm_config') { @@ -208,7 +316,7 @@ if ($action === 'restore_backup') { if (!checkVmPermission($pdo, $_POST['vmid'])) exit; $stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/status/current"); - if (isset($res['data']) && $res['data']['status'] === 'running') { echo json_encode(['success' => false, 'error' => 'VM muss gestoppt sein!']); exit; } + if (isset($res['data']) && $res['data']['status'] === 'running') { echo json_encode(['success' => false, 'error' => 'VM gestoppt?']); exit; } $resRestore = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}", 'pve', 'POST', ['vmid' => $_POST['vmid'], 'archive' => $_POST['archive'], 'force' => 1]); logAudit('Backup Restore', "VMID: {$_POST['vmid']} Archive: {$_POST['archive']}"); echo json_encode(['success' => isset($resRestore['data']), 'error' => 'API Fehler']); exit; @@ -220,12 +328,31 @@ if ($action === 'get_node_status' || $action === 'get_vm_status') { if ($action === 'get_node_status') { $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes"); $internalName = $nData['data'][0]['node'] ?? 'pve'; - $endpoint = "/api2/json/nodes/{$internalName}/status"; + + $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/status"); + $rrd = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/rrddata?timeframe=hour&cf=AVERAGE"); + + $netin = 0; $netout = 0; + if (isset($rrd['data']) && is_array($rrd['data'])) { + for ($i = count($rrd['data']) - 1; $i >= 0; $i--) { + if (isset($rrd['data'][$i]['netin']) && $rrd['data'][$i]['netin'] !== null) { + $netin = $rrd['data'][$i]['netin']; + $netout = $rrd['data'][$i]['netout']; + break; + } + } + } + + $data = $res['data'] ?? []; + $data['netin'] = $netin; + $data['netout'] = $netout; + $data['is_rrd_net'] = true; + + echo json_encode(['success' => true, 'data' => $data]); exit; } else { $endpoint = "/api2/json/nodes/{$_GET['host']}/{$_GET['type']}/{$_GET['vmid']}/status/current"; + $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], $endpoint); + echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit; } - - $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], $endpoint); - echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit; } ?> \ No newline at end of file diff --git a/src/app.js b/src/app.js index bfea446..0deb1be 100644 --- a/src/app.js +++ b/src/app.js @@ -18,7 +18,6 @@ async function logout() { if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { - // Inject Audit Log Button automatically into Sidebar for Admins if (window.APP.username === 'admin' || document.querySelector('a[onclick="openUserManager()"]')) { const userBtn = document.querySelector('a[onclick="openUserManager()"]'); if (userBtn && !document.getElementById('btnAuditLog')) { @@ -27,7 +26,6 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { } } - // === NEU: PASSWORT ÄNDERN LOGIK === const pwdModal = document.getElementById('passwordModal'); window.openPasswordModal = function() { if(pwdModal) { pwdModal.classList.remove('hidden'); document.getElementById('changePasswordForm').reset(); } } window.closePasswordModal = function() { if(pwdModal) pwdModal.classList.add('hidden'); } @@ -63,7 +61,84 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { const ctx = document.getElementById('liveChart')?.getContext('2d'); let liveChart; if(ctx) { liveChart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'CPU (%)', borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }, { label: 'RAM (%)', borderColor: '#E57000', backgroundColor: 'rgba(229, 112, 0, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, scales: { x: { ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } }, y: { min: 0, max: 100, ticks: { color: '#9ca3af', callback: v => v + '%' }, grid: { color: '#33334d' } } }, plugins: { legend: { labels: { color: '#e2e8f0', usePointStyle: true } } } } }); } - async function fetchGlobalStats() { if(!document.getElementById('stat-cpu-text')) return; try { const res = await (await fetch('api.php?action=get_stats')).json(); if(res.success && res.data) { const d = res.data; document.getElementById('stat-cpu-text').innerText = `${d.cpu_percent}% (${d.cpu_cores} Cores)`; document.getElementById('stat-cpu-bar').style.width = `${d.cpu_percent}%`; let ramPercent = (d.ram_used / d.ram_total) * 100 || 0; document.getElementById('stat-ram-text').innerText = `${formatBytes(d.ram_used)} / ${formatBytes(d.ram_total)}`; document.getElementById('stat-ram-bar').style.width = `${ramPercent}%`; let diskPercent = (d.disk_used / d.disk_total) * 100 || 0; document.getElementById('stat-disk-text').innerText = `${formatBytes(d.disk_used)} / ${formatBytes(d.disk_total)}`; document.getElementById('stat-disk-bar').style.width = `${diskPercent}%`; if(liveChart) { const now = new Date(); const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0') + ':' + now.getSeconds().toString().padStart(2, '0'); liveChart.data.labels.push(timeStr); liveChart.data.datasets[0].data.push(d.cpu_percent); liveChart.data.datasets[1].data.push(ramPercent.toFixed(1)); if (liveChart.data.labels.length > 15) { liveChart.data.labels.shift(); liveChart.data.datasets[0].data.shift(); liveChart.data.datasets[1].data.shift(); } liveChart.update(); } } } catch (err) {} } + const ctxNet = document.getElementById('liveNetChart')?.getContext('2d'); let liveNetChart; + if(ctxNet) { liveNetChart = new Chart(ctxNet, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, scales: { x: { ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } }, y: { min: 0, ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } } }, plugins: { legend: { labels: { color: '#e2e8f0', usePointStyle: true } } } } }); } + + let prevGlobalTime = null; + + async function fetchGlobalStats() { + if(!document.getElementById('stat-cpu-text')) return; + try { + const res = await (await fetch('api.php?action=get_stats')).json(); + if(res.success && res.data) { + const d = res.data; + document.getElementById('stat-cpu-text').innerText = `${d.cpu_percent}% (${d.cpu_cores} Cores)`; + document.getElementById('stat-cpu-bar').style.width = `${d.cpu_percent}%`; + let ramPercent = (d.ram_used / d.ram_total) * 100 || 0; + document.getElementById('stat-ram-text').innerText = `${formatBytes(d.ram_used)} / ${formatBytes(d.ram_total)}`; + document.getElementById('stat-ram-bar').style.width = `${ramPercent}%`; + let diskPercent = (d.disk_used / d.disk_total) * 100 || 0; + document.getElementById('stat-disk-text').innerText = `${formatBytes(d.disk_used)} / ${formatBytes(d.disk_total)}`; + document.getElementById('stat-disk-bar').style.width = `${diskPercent}%`; + + // Neue Übersichtskachel füttern + if (d.cluster_stats) { + const elNodesOn = document.getElementById('stat-nodes-online'); + if (elNodesOn) { + elNodesOn.innerText = d.cluster_stats.nodes_online; + if (d.cluster_stats.nodes_online < d.cluster_stats.nodes_total) { + elNodesOn.className = 'text-red-500'; + } else { + elNodesOn.className = 'text-green-500'; + } + } + if(document.getElementById('stat-nodes-total')) document.getElementById('stat-nodes-total').innerText = d.cluster_stats.nodes_total; + if(document.getElementById('stat-vms-total')) document.getElementById('stat-vms-total').innerText = d.cluster_stats.vms_total; + if(document.getElementById('stat-vms-run')) document.getElementById('stat-vms-run').innerText = d.cluster_stats.vms_running; + if(document.getElementById('stat-vms-stop')) document.getElementById('stat-vms-stop').innerText = d.cluster_stats.vms_stopped; + } + + if(liveChart) { + const now = new Date(); + const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0') + ':' + now.getSeconds().toString().padStart(2, '0'); + liveChart.data.labels.push(timeStr); + liveChart.data.datasets[0].data.push(d.cpu_percent); + liveChart.data.datasets[1].data.push(ramPercent.toFixed(1)); + if (liveChart.data.labels.length > 15) { + liveChart.data.labels.shift(); liveChart.data.datasets[0].data.shift(); liveChart.data.datasets[1].data.shift(); + } + liveChart.update(); + + if(liveNetChart && d.nodes_net) { + const nowTs = Date.now(); + if (prevGlobalTime !== null) { + liveNetChart.data.labels.push(timeStr); + if (liveNetChart.data.labels.length > 15) liveNetChart.data.labels.shift(); + const colors = ['#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#06b6d4']; + + d.nodes_net.forEach((n, idx) => { + let rxSpeed = n.netin / (1024 * 1024); + let txSpeed = n.netout / (1024 * 1024); + let totalSpeed = (rxSpeed + txSpeed).toFixed(2); + + let ds = liveNetChart.data.datasets.find(ds => ds.label === n.name); + if (!ds) { + const c = colors[idx % colors.length]; + ds = { label: n.name, borderColor: c, backgroundColor: c + '1a', borderWidth: 2, tension: 0.4, fill: true, data: new Array(Math.max(0, liveNetChart.data.labels.length - 1)).fill(0) }; + liveNetChart.data.datasets.push(ds); + } + ds.data.push(totalSpeed); + if (ds.data.length > 15) ds.data.shift(); + }); + liveNetChart.update(); + } + prevGlobalTime = nowTs; + } + } + } + } catch (err) {} + } + async function fetchTopVms() { if(!document.getElementById('top-vms-container')) return; try { const res = await (await fetch('api.php?action=get_top_vms')).json(); if(res.success && res.data) { const container = document.getElementById('top-vms-container'); container.innerHTML = ''; if(res.data.length === 0) { container.innerHTML = '

Keine aktiven VMs.

'; return; } res.data.forEach((vm, i) => { const cpuPercent = ((vm.cpu || 0) * 100).toFixed(1); const ramUsed = formatBytes(vm.mem || 0); const icon = vm.type === 'lxc' ? '📦' : '🖥️'; const numberColor = i === 0 ? 'text-red-500' : (i === 1 ? 'text-orange-400' : (i === 2 ? 'text-yellow-400' : 'text-gray-400')); container.innerHTML += `
#${i + 1}

${icon} ${vm.name}

Host: ${vm.host}

${cpuPercent}% CPU

${ramUsed} RAM

`; }); } } catch (err) {} } async function fetchRecentJobs() { if(!document.getElementById('recent-jobs-container')) return; try { const res = await (await fetch('api.php?action=get_recent_jobs')).json(); if(res.success && res.data) { const container = document.getElementById('recent-jobs-container'); container.innerHTML = ''; if(res.data.length === 0) { container.innerHTML = '

Keine aktuellen Jobs.

'; return; } res.data.forEach(job => { const jobTypeStr = job.type || job.worker_type || 'unknown'; let statusColor = 'text-gray-400', statusIcon = '⏳', statusText = job.status || 'running...'; if(statusText.toLowerCase() === 'ok') { statusColor = 'text-green-500'; statusIcon = '✅'; } else if(statusText !== 'running...') { statusColor = 'text-red-500'; statusIcon = '❌'; } else { statusColor = 'text-blue-400'; statusIcon = '🔄'; } const date = new Date(job.starttime * 1000); const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }), dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); const isBackup = jobTypeStr.includes('sync') || jobTypeStr.includes('prune') || jobTypeStr.includes('garbage_collection') || jobTypeStr.includes('vzdump') || jobTypeStr.includes('verify'); const jobTypeColor = isBackup ? 'text-purple-400' : 'text-white'; container.innerHTML += `
${statusIcon}

${jobTypeStr}

Host: ${job.node_name}

${statusText}

${dateStr} - ${timeStr}

`; }); } } catch (err) { console.error(err); } } async function fetchUpdates() { if(!document.getElementById('stat-updates-text')) return; try { const res = await (await fetch('api.php?action=get_updates')).json(); if(res.success) { const el = document.getElementById('stat-updates-text'), subEl = document.getElementById('stat-updates-sub'); if(res.total === 0) { el.innerText = '0 Updates'; el.className = 'text-2xl font-bold text-green-500 mt-1'; subEl.innerText = 'Alle Systeme sind aktuell.'; } else { el.innerText = res.total + ' Updates'; el.className = 'text-2xl font-bold text-red-500 mt-1'; subEl.innerText = 'Auf: ' + res.details; } } } catch (err) {} } @@ -227,12 +302,28 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { const d = res.data; const now = Date.now(); let cpuRaw = 0; if (d.cpu !== undefined) cpuRaw = d.cpu; else if (d.cpuinfo && d.cpuinfo.cpus) cpuRaw = 0; const cpu = (cpuRaw * 100).toFixed(1); let ram = 0; if (d.maxmem && d.maxmem > 0) { ram = ((d.mem / d.maxmem) * 100).toFixed(1); } else if (d.memory && d.memory.total > 0) { ram = ((d.memory.used / d.memory.total) * 100).toFixed(1); } - let currentNetIn = d.netin || 0; let currentNetOut = d.netout || 0; let rxSpeed = 0; let txSpeed = 0; - if(prevTime !== null) { const timeSec = (now - prevTime) / 1000; if (timeSec > 0) { rxSpeed = Math.max(0, ((currentNetIn - prevNetIn) / timeSec / (1024 * 1024))).toFixed(2); txSpeed = Math.max(0, ((currentNetOut - prevNetOut) / timeSec / (1024 * 1024))).toFixed(2); } } + + let currentNetIn = d.netin || 0; let currentNetOut = d.netout || 0; + let rxSpeed = 0; let txSpeed = 0; + + if (d.is_rrd_net) { + rxSpeed = (currentNetIn / (1024 * 1024)).toFixed(2); + txSpeed = (currentNetOut / (1024 * 1024)).toFixed(2); + } else { + if(prevTime !== null) { + const timeSec = (now - prevTime) / 1000; + if (timeSec > 0) { + rxSpeed = Math.max(0, ((currentNetIn - prevNetIn) / timeSec / (1024 * 1024))).toFixed(2); + txSpeed = Math.max(0, ((currentNetOut - prevNetOut) / timeSec / (1024 * 1024))).toFixed(2); + } + } + } + prevNetIn = currentNetIn; prevNetOut = currentNetOut; prevTime = now; const timeStr = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + perfChartObj.data.labels.push(timeStr); perfChartObj.data.datasets[0].data.push(cpu); perfChartObj.data.datasets[1].data.push(ram); if(perfChartObj.data.labels.length > 30) { perfChartObj.data.labels.shift(); perfChartObj.data.datasets[0].data.shift(); perfChartObj.data.datasets[1].data.shift(); } perfChartObj.update(); - if(prevTime !== null) { netChartObj.data.labels.push(timeStr); netChartObj.data.datasets[0].data.push(rxSpeed); netChartObj.data.datasets[1].data.push(txSpeed); if(netChartObj.data.labels.length > 30) { netChartObj.data.labels.shift(); netChartObj.data.datasets[0].data.shift(); netChartObj.data.datasets[1].data.shift(); } netChartObj.update(); } + if(prevTime !== null || d.is_rrd_net) { netChartObj.data.labels.push(timeStr); netChartObj.data.datasets[0].data.push(rxSpeed); netChartObj.data.datasets[1].data.push(txSpeed); if(netChartObj.data.labels.length > 30) { netChartObj.data.labels.shift(); netChartObj.data.datasets[0].data.shift(); netChartObj.data.datasets[1].data.shift(); } netChartObj.update(); } } } catch(e) {} }; @@ -519,7 +610,6 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { try { const res = await (await fetch('api.php?action=pmg_upload_ssl', {method: 'POST', body: fd})).json(); if(res.success) { alert('Zertifikat hochgeladen! Dienste werden neu gestartet.'); document.getElementById('pmgSslCert').value = ''; document.getElementById('pmgSslKey').value = ''; loadPmgSsl(document.getElementById('pmgNodeId').value); } else { alert('Fehler beim Upload. (Format prüfen)'); } } catch(e) { alert('Netzwerkfehler.'); } finally { btn.innerText = oTxt; } } - // === SCHEDULER === const cronModal = document.getElementById('cronManagerModal'); window.openCronManager = async function() { cronModal.classList.remove('hidden'); @@ -568,7 +658,6 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { window.toggleCronJob = async function(id, newState) { const fd = new FormData(); fd.append('id', id); fd.append('is_active', newState); await fetch('api.php?action=toggle_cron_job', {method: 'POST', body: fd}); loadCronJobs(); } window.deleteCronJob = async function(id) { if(!confirm('Diesen geplanten Job wirklich löschen?')) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_cron_job', {method: 'POST', body: fd}); loadCronJobs(); } - // === AUDIT LOG === const auditModal = document.getElementById('auditLogModal'); window.openAuditLog = async function() { auditModal.classList.remove('hidden'); diff --git a/src/cron.php b/src/cron.php index cf63dae..a923eb6 100644 --- a/src/cron.php +++ b/src/cron.php @@ -2,7 +2,6 @@ // /home/docker/pve_dashboard/src/cron.php require_once __DIR__ . '/db.php'; -// Hilfsfunktion: Prüft, ob ein Cron-Ausdruck (z.B. "0 3 * * *") zur aktuellen Zeit passt function isCronMatch($cron, $time = null) { if ($time === null) $time = time(); $cronParts = explode(' ', trim($cron)); @@ -20,14 +19,14 @@ function isCronMatch($cron, $time = null) { function matchCronPart($part, $current) { if ($part === '*') return true; if ($part === (string)(int)$current) return true; - if (strpos($part, '*/') === 0) { // Unterstützt z.B. */5 für "alle 5 Minuten" + if (strpos($part, '*/') === 0) { $step = (int)substr($part, 2); return $step > 0 && ((int)$current % $step) === 0; } return false; } -// Mini-Proxmox-Fetcher für das Cron-Skript +// Mini-Proxmox-Fetcher für das Cron-Skript (Mit Token-Auth für PVE!) function cronProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $method = "POST", $postData = null) { $headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"]; $ch = curl_init("https://{$ip}:8006{$endpoint}"); @@ -39,12 +38,11 @@ function cronProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $method = "POST if ($postData) $options[CURLOPT_POSTFIELDS] = http_build_query($postData); curl_setopt_array($ch, $options); $res = curl_exec($ch); curl_close($ch); - return json_decode($res, true); + return json_decode($res, true) ?: []; } echo "[" . date('Y-m-d H:i:s') . "] Starte Scheduler-Check...\n"; -// Hole alle aktiven Tasks $stmt = $pdo->query("SELECT * FROM scheduled_tasks WHERE is_active = 1"); $tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); @@ -59,7 +57,6 @@ foreach ($tasks as $task) { if ($node) { $success = false; - // AKTION: PVE NODE NEUSTART if ($task['action_type'] === 'reboot_node') { $nData = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'GET'); if (isset($nData['data'][0]['node'])) { @@ -68,9 +65,7 @@ foreach ($tasks as $task) { $success = true; } } - // AKTION: VM START / STOP / REBOOT elseif (in_array($task['action_type'], ['start_vm', 'stop_vm', 'reboot_vm'])) { - // Suche Host und Typ der VM $vmsRes = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm", 'GET'); if (isset($vmsRes['data'])) { foreach ($vmsRes['data'] as $vm) { @@ -85,7 +80,6 @@ foreach ($tasks as $task) { } } - // Setze den Zeitstempel für den letzten Durchlauf if ($success) { $uStmt = $pdo->prepare("UPDATE scheduled_tasks SET last_run = ? WHERE id = ?"); $uStmt->execute([time(), $task['id']]); diff --git a/src/index.php b/src/index.php index e6a5fb0..59505bc 100644 --- a/src/index.php +++ b/src/index.php @@ -38,11 +38,9 @@ $nodeCount = $stmt->fetchColumn();
Hallo, - -
@@ -84,13 +82,58 @@ $nodeCount = $stmt->fetchColumn();
-
+ + +
+
+
+ +
+
+

Gesamtübersicht

+

Cluster & Standalone Nodes

+
+
+
+
+

Server (Nodes)

+

0/0

+
+ +
+

Total VMs/LXC

+

0

+
+
+

Online

+

0

+
+
+

Offline

+

0

+
+
+
+ + +

Cluster CPU Cores

Lade...

Globaler RAM

Lade...

Datacenter Storage

Lade...

System Updates (APT)

Lade...

Prüfe Updates...
-

Live Cluster Auslastung

Live Sync
+ +
+
+

Live Cluster Auslastung

CPU / RAM
+
+
+
+

Live Netzwerk Traffic

MB/s Total pro Node
+
+
+
+

🔥 Top 5 Ressourcen-Fresser

Lädt Live-Daten von Proxmox API...

@@ -130,8 +173,6 @@ $nodeCount = $stmt->fetchColumn(); - - \ No newline at end of file