20 Commits
Author SHA1 Message Date
root 3c49b306ab Release v1.2: Managing Node Page. 2026-09-10 08:39:20 +02:00
root a39a04360d Release v1.2: Manageing Node Page. 2026-09-10 08:31:38 +02:00
root 317cba0cbb Release v1.2: Manageing Node Page. 2026-09-10 08:19:17 +02:00
root 487c49f6e7 Release v1.2: Manageing Node Page. 2026-09-10 08:01:36 +02:00
root 5fcd306f5d Release v1.2: Manageing Node Page. 2026-09-10 07:51:59 +02:00
root b84effa66b Release v1.1: Hotfix RRD Graphen. 2026-08-27 09:27:35 +02:00
root 4f0ce0f1a2 Release v1.1: Hotfix RRD Graphen. 2026-08-27 09:05:58 +02:00
root 50140377e8 Release v1.1: Hotfix JS Code Fehler. 2026-08-27 08:29:59 +02:00
root d726d048ac Merge branch 'main' of https://github.com/tessmania90/proxmox-unified-console 2026-08-27 07:59:36 +02:00
root fffd05d490 Release v1.1: Fix Scheduler not working, RRD Livegraphen 24h /7d, APT Update Manager hinzugefügt. 2026-08-27 07:57:21 +02:00
tessmania90 5fb5cfd159 Update README.md 2026-08-26 15:22:11 +02:00
root e72a9a53e7 Release v1.0.2: Hotfix, Live Diagramme VMs und PVE Nodex 2026-08-24 10:18:02 +02:00
root a50a944f64 Release v1.0.2: Hotfix, Live Diagramme VMs und PVE Nodex 2026-08-24 07:12:35 +02:00
root e13319cb88 Release v1.0.2: Hotfix, Live Diagramme VMs und PVE Nodex 2026-08-24 07:03:54 +02:00
root cc7bc514e1 Release v1.0.1: PVE Auth Fix, Logout Cache-Buster und Docker-Hub Vorbereitung 2026-08-23 21:15:05 +02:00
root 4526301fd1 Fix: Added missing password change icon to UI 2026-08-23 20:29:10 +02:00
root b3759139bf Fix: api.php Problems 2026-08-23 20:10:09 +02:00
root 2d52ee6113 Fix: Added missing CREATE TABLE statements for audit_logs and scheduled_tasks 2026-08-23 20:00:08 +02:00
root 2208bb0ec7 Fix: Added missing COPY command to Dockerfile 2026-08-23 19:53:41 +02:00
root 8643f45370 Added Password Self-Service 2026-08-23 19:36:19 +02:00
11 changed files with 971 additions and 783 deletions

No files matched your search

+6 -9
View File
@@ -1,35 +1,32 @@
# /home/docker/pve_dashboard/Dockerfile
FROM php:8.2-apache FROM php:8.2-apache
# Benötigte Pakete installieren (SQLite + OpenSSL für Zertifikate + Cron für den Scheduler)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libsqlite3-dev \ libsqlite3-dev \
openssl \ openssl \
cron \ cron \
&& docker-php-ext-install pdo_sqlite && docker-php-ext-install pdo_sqlite
# Apache Rewrite-Modul (für schöne URLs) und SSL-Modul aktivieren
RUN a2enmod rewrite ssl RUN a2enmod rewrite ssl
# Generiere ein Self-Signed Zertifikat (Gültig für 10 Jahre)
RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/apache-selfsigned.key \ -keyout /etc/ssl/private/apache-selfsigned.key \
-out /etc/ssl/certs/apache-selfsigned.crt \ -out /etc/ssl/certs/apache-selfsigned.crt \
-subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost" -subj "/C=DE/ST=Brandenburg/L=Gruenheide/O=PUC/OU=IT/CN=localhost"
# Passe die Default SSL Konfiguration an
RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \ RUN sed -i 's/ssl-cert-snakeoil.pem/apache-selfsigned.crt/g' /etc/apache2/sites-available/default-ssl.conf \
&& sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf && sed -i 's/ssl-cert-snakeoil.key/apache-selfsigned.key/g' /etc/apache2/sites-available/default-ssl.conf
# SSL Site in Apache aktivieren
RUN a2ensite default-ssl.conf RUN a2ensite default-ssl.conf
# Cronjob einrichten (Ruft die cron.php minütlich auf und leitet Output ins Docker-Log um) COPY ./src /var/www/html/
RUN chown -R www-data:www-data /var/www/html/
# Der goldene Fix für die Datenbankrechte!
RUN mkdir -p /var/www/data && chown -R www-data:www-data /var/www/data
RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab RUN echo "* * * * * root /usr/local/bin/php /var/www/html/cron.php > /proc/1/fd/1 2>/proc/1/fd/2\n" >> /etc/crontab
# Beide Ports freigeben
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
# Startbefehl: Startet erst den Cron-Daemon im Hintergrund und dann Apache im Vordergrund
CMD cron && apache2-foreground CMD cron && apache2-foreground
+37 -21
View File
@@ -1,5 +1,7 @@
# Proxmox Unified Console (PUC) 🚀 # 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. 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. Built entirely with native APIs—no slow iframes, no CORS issues.
@@ -38,32 +40,46 @@ Built entirely with native APIs—no slow iframes, no CORS issues.
--- ---
## 📦 Quickstart (Docker) ## 📦 Quickstart (Docker & Portainer)
Deploying the Proxmox Unified Console is incredibly easy using Docker. Die Installation erfolgt am einfachsten über Docker Compose. Die Applikation bringt einen eigenen Apache-Webserver mit und generiert sich automatisch ein SSL-Zertifikat für HTTPS.
1. **Clone the repository:** **1. `docker-compose.yml` anlegen:**
```bash ```yaml
git clone [https://github.com/tessmania90/proxmox-unified-console.git](https://github.com/tessmania90/proxmox-unified-console.git) version: '3.8'
cd proxmox-unified-console
Start the container: services:
Bash pve-dashboard:
image: stessmann/proxmox-unified-console:latest
container_name: puc_dashboard
restart: unless-stopped
ports:
- "8080:80"
- "8443:443"
volumes:
- ./data:/var/www/data
environment:
- TZ=Europe/Berlin
```
2. Starten:
```
Bash
docker compose up -d --build docker compose up -d
```
3. ⚠️ WICHTIG: Rechte für SQLite anpassen:
Da der Container als Benutzer www-data (UID 33) läuft, der neu erstellte Volume-Ordner auf dem Host aber oft root gehört, muss dem Ordner die Schreibberechtigung erteilt werden. Führe im Verzeichnis der Compose-Datei aus:
Bash
```
sudo chown -R 33:33 ./data
sudo chmod -R 775 ./data
```
4. Login:
Rufe https://<DEINE-IP>:8443 in deinem Browser auf (Zertifikatswarnung ignorieren).
Access the Dashboard: Benutzername: admin
Open your browser and navigate to:
https://<YOUR-SERVER-IP>:8443
(Note: You will need to accept the self-signed certificate warning).
Default Login: Passwort: admin (Bitte direkt nach dem Einloggen oben rechts über das 🔑-Symbol ändern!)
Username: admin
Password: admin
⚠️ IMPORTANT: Please change the default password immediately after your first login!
🛠️ Architecture 🛠️ Architecture
@@ -80,4 +96,4 @@ Deploying the Proxmox Unified Console is incredibly easy using Docker.
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
📄 License 📄 License
MIT MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+4 -10
View File
@@ -1,20 +1,14 @@
# /home/docker/pve_dashboard/docker-compose.yml
version: '3.8' version: '3.8'
services: services:
pve-dashboard: pve-dashboard:
build: . image: stessmann/proxmox-unified-console:latest
container_name: pve_unified_console container_name: puc_dashboard
restart: unless-stopped restart: unless-stopped
ports: ports:
# Port 80 bleibt fürs Testing / HAProxy
- "8080:80" - "8080:80"
# NEU: Port 443 für sicheres NoVNC! (Wir mappen es nach außen z.B. auf 8443) - "8443:443"
- "8443:443"
volumes: volumes:
# Dein Source-Code
- ./src:/var/www/html
# Deine Datenbank, sicher ausgelagert
- ./data:/var/www/data - ./data:/var/www/data
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
+128 -30
View File
@@ -1,5 +1,8 @@
<?php <?php
// /home/docker/pve_dashboard/src/api.php // /home/docker/pve_dashboard/src/api.php
ini_set('display_errors', 0);
error_reporting(E_ALL);
require_once 'db.php'; require_once 'db.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
@@ -7,62 +10,143 @@ $action = $_GET['action'] ?? '';
// === ZENTRALE AUDIT LOG FUNKTION === // === ZENTRALE AUDIT LOG FUNKTION ===
function logAudit($actionName, $target = '') { function logAudit($actionName, $target = '') {
global $pdo; global $pdo;
@session_start(); try {
$userId = $_SESSION['user_id'] ?? 0; @session_start();
$username = $_SESSION['username'] ?? 'System'; $userId = $_SESSION['user_id'] ?? 0;
session_write_close(); $username = $_SESSION['username'] ?? 'System';
@session_write_close();
$stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)");
$stmt->execute([$userId, $username, $actionName, $target]); $stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action, target) VALUES (?, ?, ?, ?)");
$stmt->execute([$userId, $username, $actionName, $target]);
} catch (Throwable $e) {
error_log("Audit Log Fehler: " . $e->getMessage());
}
} }
if ($action === 'login') { if ($action === 'login') {
$username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; $username = trim($_POST['username'] ?? '');
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(PDO::FETCH_ASSOC); $password = $_POST['password'] ?? '';
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) { if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['role'] = $user['role']; @session_start();
logAudit('User Login', 'IP: ' . $_SERVER['REMOTE_ADDR']); $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['role'] = $user['role'];
@session_write_close();
$ip = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
logAudit('User Login', 'IP: ' . $ip);
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
} else echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']); } else {
echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
}
exit; exit;
} }
if ($action === 'logout') { if ($action === 'logout') {
logAudit('User Logout', ''); logAudit('User Logout', '');
session_destroy(); echo json_encode(['success' => true]); exit; @session_start();
session_destroy();
echo json_encode(['success' => true]);
exit;
} }
if (!isset($_SESSION['user_id'])) { echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']); exit; } @session_start();
if (!isset($_SESSION['user_id'])) {
echo json_encode(['success' => false, 'error' => 'Zugriff verweigert.']);
exit;
}
if ($action === 'change_password') {
$oldPass = $_POST['old_password'] ?? '';
$newPass = $_POST['new_password'] ?? '';
$userId = $_SESSION['user_id'];
$stmt = $pdo->prepare("SELECT password_hash FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($oldPass, $user['password_hash'])) {
if(strlen($newPass) < 4) { echo json_encode(['success' => false, 'error' => 'Passwort muss mindestens 4 Zeichen lang sein.']); exit; }
$newHash = password_hash($newPass, PASSWORD_DEFAULT);
$uStmt = $pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?");
$uStmt->execute([$newHash, $userId]);
logAudit('Passwort geändert', 'Self-Service');
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Das alte Passwort ist falsch.']);
}
exit;
}
$isAdmin = ($_SESSION['role'] ?? '') === 'admin'; $isAdmin = ($_SESSION['role'] ?? '') === 'admin';
session_write_close(); @session_write_close();
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 4) { // === PROXMOX API FETCHER ===
function getProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $type = 'pve', $method = "GET", $postData = null, $timeout = 6) {
$port = ($type === 'pbs') ? 8007 : 8006; $port = ($type === 'pbs') ? 8007 : 8006;
if ($type === 'pmg' || $type === 'pbs') { if ($type === 'pmg' || $type === 'pbs') {
// TICKET AUTH für PBS und PMG
$chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket"); $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 => 2, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['username' => $tokenId, 'password' => $tokenSecret])]); 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); $authRes = json_decode(curl_exec($chAuth), true); curl_close($chAuth);
if(!isset($authRes['data']['ticket'])) return ['data' => []];
if(!isset($authRes['data']['ticket'])) return ['data' => null, 'error' => 'Auth failed'];
$cookieName = ($type === 'pbs') ? 'PBSAuthCookie' : 'PMGAuthCookie'; $cookieName = ($type === 'pbs') ? 'PBSAuthCookie' : 'PMGAuthCookie';
$headers = ["Cookie: {$cookieName}=" . $authRes['data']['ticket'], "CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']]; $headers = [
"Cookie: {$cookieName}=" . $authRes['data']['ticket'],
"CSRFPreventionToken: " . $authRes['data']['CSRFPreventionToken']
];
} else { } else {
// NATIVE API TOKENS FÜR PVE
$headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"]; $headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"];
} }
$ch = curl_init("https://{$ip}:{$port}{$endpoint}"); $ch = curl_init("https://{$ip}:{$port}{$endpoint}");
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers]; $options = [
if ($method !== "GET") { $options[CURLOPT_CUSTOMREQUEST] = $method; if ($postData) $options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData; } CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $headers
];
if ($method !== "GET") {
$options[CURLOPT_CUSTOMREQUEST] = $method;
if ($postData) {
$options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData;
}
}
curl_setopt_array($ch, $options); curl_setopt_array($ch, $options);
$res = curl_exec($ch); curl_close($ch); return json_decode($res, true); $res = curl_exec($ch); curl_close($ch);
return json_decode($res, true) ?: [];
} }
function checkVmPermission($pdo, $vmid) { function checkVmPermission($pdo, $vmid) {
global $isAdmin; if ($isAdmin) return true; global $isAdmin;
@session_start(); $userId = $_SESSION['user_id'] ?? 0; session_write_close(); if ($isAdmin) return true;
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?"); $stmt->execute([$userId]); return in_array($vmid, json_decode($stmt->fetchColumn(), true)['allowed_vms'] ?? []);
@session_start();
$userId = $_SESSION['user_id'] ?? 0;
@session_write_close();
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?");
$stmt->execute([$userId]);
$perms = json_decode($stmt->fetchColumn() ?: '{}', true);
return in_array($vmid, $perms['allowed_vms'] ?? []);
} }
// === AUDIT LOG ENDPUNKT ===
if ($action === 'get_audit_logs') { if ($action === 'get_audit_logs') {
if (!$isAdmin) exit; if (!$isAdmin) exit;
$stmt = $pdo->query("SELECT * FROM audit_logs ORDER BY id DESC LIMIT 200"); $stmt = $pdo->query("SELECT * FROM audit_logs ORDER BY id DESC LIMIT 200");
@@ -81,13 +165,22 @@ if ($action === 'get_recent_jobs') {
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
} elseif ($node['type'] === 'pmg') { } elseif ($node['type'] === 'pmg') {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pmg');
if (isset($nData['data'][0]['node'])) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } } if (isset($nData['data'][0]['node'])) {
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nData['data'][0]['node']}/tasks?limit=10", 'pmg');
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
}
} else { } else {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve'); if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } } } } if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) {
$tasks = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/tasks?limit=10", 'pve');
if (isset($tasks['data'])) { foreach ($tasks['data'] as $t) { $t['node_name'] = $node['name']; $allTasks[] = $t; } }
}
}
} }
} }
usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); }); echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit; usort($allTasks, function($a, $b) { return ($b['starttime'] ?? 0) <=> ($a['starttime'] ?? 0); });
echo json_encode(['success' => true, 'data' => array_slice($allTasks, 0, 15)]); exit;
} }
if ($action === 'get_updates') { if ($action === 'get_updates') {
@@ -98,7 +191,12 @@ if ($action === 'get_updates') {
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; } if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $node['name'] . " (" . $c . ")"; }
} else { } else {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve'); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'pve');
if (isset($nData['data'])) { foreach ($nData['data'] as $nInfo) { $apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve'); if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; } } } if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) {
$apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/apt/update", 'pve');
if (isset($apt['data'])) { $c = count($apt['data']); $total += $c; if ($c > 0) $nodesNeed[] = $nInfo['node'] . " (" . $c . ")"; }
}
}
} }
} }
echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit; echo json_encode(['success' => true, 'total' => $total, 'details' => implode(', ', $nodesNeed)]); exit;
+222 -49
View File
@@ -1,18 +1,48 @@
<?php <?php
// /home/docker/pve_dashboard/src/api_pve.php // /home/docker/pve_dashboard/src/api_pve.php
if (!defined('PDO::ATTR_DRIVER_NAME')) exit; // Schutz if (!defined('PDO::ATTR_DRIVER_NAME')) exit;
if ($action === 'get_stats') { if ($action === 'get_stats') {
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'");
$data = ['cpu_cores' => 0, 'cpu_used' => 0, 'ram_total' => 0, 'ram_used' => 0, 'disk_total' => 0, 'disk_used' => 0, 'cpu_percent' => 0]; $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) { foreach ($nodes as $node) {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes"); $nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes");
if (isset($nData['data'])) { if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) { foreach ($nData['data'] as $nInfo) {
if ($nInfo['status'] === 'online') { if (!isset($seenNodes[$nInfo['node']])) {
$data['cpu_cores'] += $nInfo['maxcpu'] ?? 0; $data['cpu_used'] += ($nInfo['cpu'] ?? 0) * ($nInfo['maxcpu'] ?? 0); $seenNodes[$nInfo['node']] = true;
$data['ram_total'] += $nInfo['maxmem'] ?? 0; $data['ram_used'] += $nInfo['mem'] ?? 0; if ($nInfo['status'] === 'online') {
$data['disk_total'] += $nInfo['maxdisk'] ?? 0; $data['disk_used'] += $nInfo['disk'] ?? 0; $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 = 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,60 +52,112 @@ if ($action === 'get_stats') {
} }
if ($action === 'get_top_vms') { 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) { foreach ($nodes as $node) {
$vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm"); $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'])) { $vm['node_id'] = $node['id']; $vm['node_ip'] = $node['ip_address']; $allVms[] = $vm; } } } if (isset($vms['data'])) {
foreach ($vms['data'] as $vm) {
$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'; $allVms[] = $vm;
}
}
}
} }
usort($allVms, function($a, $b) { return ($b['cpu'] ?? 0) <=> ($a['cpu'] ?? 0); }); usort($allVms, function($a, $b) { return ($b['cpu'] ?? 0) <=> ($a['cpu'] ?? 0); });
echo json_encode(['success' => true, 'data' => array_slice($allVms, 0, 5)]); exit; echo json_encode(['success' => true, 'data' => array_slice($allVms, 0, 5)]); exit;
} }
if ($action === 'get_all_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) { foreach ($nodes as $node) {
$vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm"); $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'])) { $vm['node_id'] = $node['id']; $vm['node_ip'] = $node['ip_address']; $allVms[] = $vm; } } } if (isset($vms['data'])) {
foreach ($vms['data'] as $vm) {
$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'; $allVms[] = $vm;
}
}
}
} }
usort($allVms, function($a, $b) { if ($a['status'] === 'running' && $b['status'] !== 'running') return -1; if ($a['status'] !== 'running' && $b['status'] === 'running') return 1; return $a['vmid'] <=> $b['vmid']; }); usort($allVms, function($a, $b) { if ($a['status'] === 'running' && $b['status'] !== 'running') return -1; if ($a['status'] !== 'running' && $b['status'] === 'running') return 1; return $a['vmid'] <=> $b['vmid']; });
echo json_encode(['success' => true, 'data' => $allVms]); exit; echo json_encode(['success' => true, 'data' => $allVms]); exit;
} }
// NEU: Node Dashboard Daten (VMs & Storage)
if ($action === 'get_node_dashboard') {
if (!$isAdmin) exit;
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$host = $_GET['host'];
$vmsQemu = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$host}/qemu");
$vmsLxc = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$host}/lxc");
$storages = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$host}/storage");
$nodeVms = [];
if (isset($vmsQemu['data'])) { foreach ($vmsQemu['data'] as $v) { $v['type'] = 'qemu'; $v['host'] = $host; $v['node_id'] = $node['id']; $v['node_ip'] = $node['ip_address']; $nodeVms[] = $v; } }
if (isset($vmsLxc['data'])) { foreach ($vmsLxc['data'] as $v) { $v['type'] = 'lxc'; $v['host'] = $host; $v['node_id'] = $node['id']; $v['node_ip'] = $node['ip_address']; $nodeVms[] = $v; } }
usort($nodeVms, function($a, $b) { if ($a['status'] === 'running' && $b['status'] !== 'running') return -1; if ($a['status'] !== 'running' && $b['status'] === 'running') return 1; return $a['vmid'] <=> $b['vmid']; });
echo json_encode(['success' => true, 'vms' => $nodeVms, 'storages' => $storages['data'] ?? []]); exit;
}
if ($action === 'vm_action') { if ($action === 'vm_action') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $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/{$_POST['cmd']}", "POST"); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/status/{$_POST['cmd']}", 'pve', 'POST');
// AUDIT LOG
logAudit("VM {$_POST['cmd']}", "VMID: {$_POST['vmid']} auf Host: {$_POST['host']}"); logAudit("VM {$_POST['cmd']}", "VMID: {$_POST['vmid']} auf Host: {$_POST['host']}");
echo json_encode(['success' => isset($res['data']), 'error' => $res['errors'] ?? 'Fehler']); exit; echo json_encode(['success' => isset($res['data']), 'error' => $res['errors'] ?? 'Aktion fehlgeschlagen.']); exit;
} }
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 === '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 === '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 ($action === 'add_node') {
if (!$isAdmin) exit; if (!$isAdmin) exit;
$stmt = $pdo->prepare("INSERT INTO nodes (name, ip_address, token_id, token_secret, type) VALUES (?, ?, ?, ?, ?)"); $name = trim($_POST['name']); $ip = trim($_POST['ip']); $user = trim($_POST['user']); $pass = trim($_POST['pass']); $type = $_POST['type'] ?? 'pve';
$stmt->execute([$_POST['name'], $_POST['ip'], $_POST['user'], $_POST['pass'], $_POST['type'] ?? 'pve']); $tokenIdToSave = $user; $tokenSecretToSave = $pass;
logAudit('Server hinzugefügt', "Node: {$_POST['name']} ({$_POST['ip']})"); 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([$name, $ip, $tokenIdToSave, $tokenSecretToSave, $type]);
logAudit('Server hinzugefügt', "Node: {$name} ({$ip})");
echo json_encode(['success' => true]); exit; 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 === '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 === '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') { if ($action === 'create_user') {
if (!$isAdmin) exit; if (!$isAdmin) exit;
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role, permissions) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO users (username, password_hash, role, permissions) VALUES (?, ?, ?, ?)"); $stmt->execute([$_POST['username'], password_hash($_POST['password'], PASSWORD_DEFAULT), $_POST['role'], $_POST['permissions']]);
$stmt->execute([$_POST['username'], password_hash($_POST['password'], PASSWORD_DEFAULT), $_POST['role'], $_POST['permissions']]);
logAudit('User angelegt', "Username: {$_POST['username']} Rolle: {$_POST['role']}"); logAudit('User angelegt', "Username: {$_POST['username']} Rolle: {$_POST['role']}");
echo json_encode(['success' => true]); exit; echo json_encode(['success' => true]); exit;
} }
if ($action === 'get_pve_nodes') { if ($action === 'get_pve_nodes') {
if (!$isAdmin) exit; 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) { foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $node) {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes"); $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; echo json_encode(['success' => true, 'data' => $resList]); exit;
} }
@@ -87,9 +169,9 @@ if ($action === 'create_vm') {
if(!isset($nextIdRes['data'])) { echo json_encode(['success' => false, 'error' => 'Konnte keine freie VMID finden.']); exit; } if(!isset($nextIdRes['data'])) { echo json_encode(['success' => false, 'error' => 'Konnte keine freie VMID finden.']); exit; }
$vmid = $nextIdRes['data']; $vmid = $nextIdRes['data'];
$params = ['vmid' => $vmid, 'name' => $_POST['name'], 'memory' => $_POST['memory'], 'cores' => $_POST['cores'], 'net0' => 'virtio,bridge=vmbr0']; $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", "POST", $params); $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']}"); logAudit('VM Erstellt', "VMID: {$vmid} Name: {$_POST['name']}");
echo json_encode(['success' => isset($res['data']), 'vmid' => $vmid]); exit; echo json_encode(['success' => isset($res['data']), 'vmid' => $vmid, 'error' => isset($res['data']) ? '' : 'Fehler bei Erstellung.']); exit;
} }
if ($action === 'get_vm_config') { if ($action === 'get_vm_config') {
@@ -99,6 +181,7 @@ if ($action === 'get_vm_config') {
echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit; echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit;
} }
// ANGEPASST: Update inkl. ISO und onboot
if ($action === 'update_vm_config') { if ($action === 'update_vm_config') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -106,9 +189,12 @@ if ($action === 'update_vm_config') {
if(isset($_POST['memory'])) $params['memory'] = $_POST['memory']; if(isset($_POST['memory'])) $params['memory'] = $_POST['memory'];
if(isset($_POST['cores'])) $params['cores'] = $_POST['cores']; if(isset($_POST['cores'])) $params['cores'] = $_POST['cores'];
if(isset($_POST['net0'])) $params['net0'] = $_POST['net0']; if(isset($_POST['net0'])) $params['net0'] = $_POST['net0'];
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config", "POST", $params); if(isset($_POST['onboot'])) $params['onboot'] = $_POST['onboot'];
if(isset($_POST['ide2'])) $params['ide2'] = $_POST['ide2'] === 'none' ? 'none,media=cdrom' : $_POST['ide2'] . ',media=cdrom';
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config", 'pve', 'POST', $params);
logAudit('VM Config geändert', "VMID: {$_POST['vmid']}"); logAudit('VM Config geändert', "VMID: {$_POST['vmid']}");
echo json_encode(['success' => isset($res['data'])]); exit; echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
} }
if ($action === 'add_vm_nic') { if ($action === 'add_vm_nic') {
@@ -116,25 +202,60 @@ if ($action === 'add_vm_nic') {
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC); $stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$cfg = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config"); $cfg = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config");
$nextNic = 0; for ($i=0; $i<10; $i++) { if (!isset($cfg['data']["net{$i}"])) { $nextNic = $i; break; } } $nextNic = 0; for ($i=0; $i<10; $i++) { if (!isset($cfg['data']["net{$i}"])) { $nextNic = $i; break; } }
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config", "POST", ["net{$nextNic}" => "virtio,bridge={$_POST['bridge']}"]); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/config", 'pve', 'POST', ["net{$nextNic}" => "virtio,bridge={$_POST['bridge']}"]);
logAudit('VM NIC hinzugefügt', "VMID: {$_POST['vmid']} Bridge: {$_POST['bridge']}"); echo json_encode(['success' => isset($res['data']), 'slot' => "net{$nextNic}", 'error' => 'API Fehler']); exit;
echo json_encode(['success' => isset($res['data']), 'slot' => "net{$nextNic}"]); exit;
} }
if ($action === 'resize_vm_disk') { if ($action === 'resize_vm_disk') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $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']}/resize", "PUT", ['disk' => $_POST['disk'], 'size' => $_POST['size']]); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/resize", 'pve', 'PUT', ['disk' => $_POST['disk'], 'size' => $_POST['size']]);
logAudit('VM Disk erweitert', "VMID: {$_POST['vmid']} Disk: {$_POST['disk']} Size: {$_POST['size']}"); echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
echo json_encode(['success' => isset($res['data'])]); exit;
} }
if ($action === 'delete_vm') { if ($action === 'delete_vm') {
if (!$isAdmin) exit; if (!$isAdmin) exit;
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC); $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']}", "DELETE"); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}", 'pve', 'DELETE');
logAudit('VM Gelöscht', "VMID: {$_POST['vmid']}"); echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
echo json_encode(['success' => isset($res['data'])]); exit; }
// NEU: ISO Images abrufen
if ($action === 'get_isos') {
if (!$isAdmin) exit;
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$stRes = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_GET['host']}/storage");
$isos = [];
if (isset($stRes['data'])) {
foreach ($stRes['data'] as $st) {
if (strpos($st['content'], 'iso') !== false) {
$cRes = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_GET['host']}/storage/{$st['storage']}/content");
if (isset($cRes['data'])) {
foreach ($cRes['data'] as $item) {
if ($item['content'] === 'iso') $isos[] = $item['volid'];
}
}
}
}
}
echo json_encode(['success' => true, 'data' => $isos]); exit;
}
// NEU: Node-Spezifische Backup Jobs
if ($action === 'get_node_backup_jobs') {
if (!$isAdmin) exit;
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/backup");
$hostJobs = [];
if (isset($res['data'])) {
foreach ($res['data'] as $job) {
// Zeige nur Jobs, die diesen Host betreffen (oder alle, falls node nicht spezifiziert in PVE)
if (empty($job['node']) || $job['node'] === $_GET['host']) {
$hostJobs[] = $job;
}
}
}
echo json_encode(['success' => true, 'data' => $hostJobs]); exit;
} }
if ($action === 'get_vm_snapshots') { if ($action === 'get_vm_snapshots') {
@@ -148,11 +269,10 @@ if ($action === 'vm_snapshot_action') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$cmd = $_POST['cmd']; $snapname = $_POST['snapname']; $cmd = $_POST['cmd']; $snapname = $_POST['snapname'];
if ($cmd === 'create') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot", "POST", ['snapname' => $snapname]); } if ($cmd === 'create') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot", 'pve', 'POST', ['snapname' => $snapname]); }
elseif ($cmd === 'delete') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot/{$snapname}", "DELETE"); } elseif ($cmd === 'delete') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot/{$snapname}", 'pve', 'DELETE'); }
elseif ($cmd === 'rollback') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot/{$snapname}/rollback", "POST"); } elseif ($cmd === 'rollback') { $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/snapshot/{$snapname}/rollback", 'pve', 'POST'); }
logAudit('Snapshot ' . $cmd, "VMID: {$_POST['vmid']} Snap: {$snapname}"); echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
echo json_encode(['success' => isset($res['data'])]); exit;
} }
if ($action === 'get_backup_storages') { if ($action === 'get_backup_storages') {
@@ -183,25 +303,78 @@ if ($action === 'get_vm_backups') {
if ($action === 'create_backup') { if ($action === 'create_backup') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $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']}/vzdump", "POST", ['vmid' => $_POST['vmid'], 'storage' => $_POST['storage'], 'mode' => 'snapshot']); $res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/vzdump", 'pve', 'POST', ['vmid' => $_POST['vmid'], 'storage' => $_POST['storage'], 'mode' => 'snapshot']);
logAudit('Manuelles Backup', "VMID: {$_POST['vmid']} Storage: {$_POST['storage']}"); echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
echo json_encode(['success' => isset($res['data'])]); exit;
} }
if ($action === 'restore_backup') { if ($action === 'restore_backup') {
if (!checkVmPermission($pdo, $_POST['vmid'])) exit; 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); $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"); $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']}", "POST", ['vmid' => $_POST['vmid'], 'archive' => $_POST['archive'], 'force' => 1]); $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;
echo json_encode(['success' => isset($resRestore['data'])]); exit;
} }
if ($action === 'get_node_status' || $action === 'get_vm_status') { if ($action === 'get_node_status' || $action === 'get_vm_status') {
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC); $stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$endpoint = $action === 'get_node_status' ? "/api2/json/nodes/{$_GET['host']}/status" : "/api2/json/nodes/{$_GET['host']}/{$_GET['type']}/{$_GET['vmid']}/status/current"; $type = $node['type'] ?? 'pve';
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], $endpoint);
if ($action === 'get_node_status') {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", $type);
$internalName = $nData['data'][0]['node'] ?? ($type === 'pbs' ? 'localhost' : 'pve');
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/status", $type);
$rrd = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/rrddata?timeframe=hour&cf=AVERAGE", $type);
$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, $type);
echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit;
}
}
if ($action === 'get_historical_rrd') {
if (!checkVmPermission($pdo, $_GET['vmid'] ?? 0)) exit;
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_GET['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
$timeframe = $_GET['timeframe'] === 'week' ? 'week' : 'day'; $type = $node['type'] ?? 'pve';
if ($_GET['target_mode'] === 'node') {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", $type);
$internalName = $nData['data'][0]['node'] ?? 'localhost';
$endpoint = "/api2/json/nodes/{$internalName}/rrddata?timeframe={$timeframe}&cf=AVERAGE";
} else { $endpoint = "/api2/json/nodes/{$_GET['host']}/{$_GET['target_mode']}/{$_GET['vmid']}/rrddata?timeframe={$timeframe}&cf=AVERAGE"; }
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], $endpoint, $type);
echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit; echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit;
} }
if ($action === 'get_update_details') {
if (!$isAdmin) exit;
$stmt = $pdo->query("SELECT * FROM nodes WHERE type != 'pmg'"); $updateList = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $node) {
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", $node['type']);
if (isset($nData['data'])) {
foreach ($nData['data'] as $nInfo) {
$internalName = $nInfo['node'] ?? 'localhost';
$apt = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/apt/update", $node['type']);
if (isset($apt['data']) && count($apt['data']) > 0) { $updateList[] = [ 'node_id' => $node['id'], 'host' => $internalName, 'display_name' => $node['name'] . ' (' . $internalName . ')', 'type' => $node['type'], 'packages' => $apt['data'] ]; }
}
}
}
echo json_encode(['success' => true, 'data' => $updateList]); exit;
}
if ($action === 'trigger_apt_upgrade') {
if (!$isAdmin) 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']}/apt/upgrade", $node['type'], 'POST');
echo json_encode(['success' => isset($res['data']), 'upid' => $res['data'] ?? '', 'error' => $res['errors'] ?? 'API Fehler']); exit;
}
?> ?>
+416 -513
View File
@@ -1,569 +1,472 @@
// /home/docker/pve_dashboard/src/app.js // /home/docker/pve_dashboard/src/app.js
// ---------------------------------------------------------
// 1. HILFSFUNKTIONEN (Immer zuerst laden)
// ---------------------------------------------------------
function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 GB'; const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'], i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 GB'; const k = 1024, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'], i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }
function formatDate(timestamp) { if (!timestamp || timestamp === 0) return 'Nie'; const d = new Date(timestamp * 1000); return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE'); } function formatDate(timestamp) { if (!timestamp || timestamp === 0) return 'Nie'; const d = new Date(timestamp * 1000); return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE'); }
async function logout() { await fetch('api.php?action=logout'); window.location.href = window.location.pathname + '?t=' + Date.now(); }
if (!window.APP.isLoggedIn) { // ---------------------------------------------------------
const loginForm = document.getElementById('loginForm'); // 2. GLOBALE CHART OBJEKTE
if(loginForm) { loginForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Prüfe...'; const fd = new FormData(); fd.append('username', document.getElementById('loginUser').value); fd.append('password', document.getElementById('loginPass').value); try { const res = await (await fetch('api.php?action=login', { method: 'POST', body: fd })).json(); if(res.success) window.location.reload(); else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); } // ---------------------------------------------------------
} else if (window.APP.nodeCount === 0) { let liveChart = null;
const setupForm = document.getElementById('setupForm'); let liveNetChart = null;
if(setupForm) { setupForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('nodeName').value); fd.append('ip', document.getElementById('nodeIp').value); fd.append('user', document.getElementById('nodeUser').value); fd.append('pass', document.getElementById('nodePass').value); fd.append('type', 'pve'); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { btn.innerText = 'Erfolgreich!'; setTimeout(() => window.location.reload(), 1000); } else { alert(res.error); btn.innerText = oTxt; } } catch (e) { alert('Netzwerkfehler.'); btn.innerText = oTxt; } }); } let perfChartObj = null;
} let netChartObj = null;
let liveInterval = null;
let currentGraphParams = {};
let prevGlobalTime = null;
async function logout() { await fetch('api.php?action=logout'); window.location.reload(); } // Initialisierung der kleinen Dashboard-Charts (sofern Container vorhanden)
document.addEventListener('DOMContentLoaded', () => {
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) { const ctx = document.getElementById('liveChart')?.getContext('2d');
// 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')) {
const auditBtnHTML = `<a href="#" id="btnAuditLog" onclick="openAuditLog()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> Audit Log</a>`;
userBtn.insertAdjacentHTML('afterend', auditBtnHTML);
}
}
window.switchTab = function(tab) {
['pve', 'pbs', 'pmg'].forEach(t => { const el = document.getElementById('tab-' + t); const nav = document.getElementById('nav-tab-' + t); if (t === tab) { el.classList.remove('hidden'); setTimeout(() => el.classList.remove('opacity-0'), 50); nav.classList.add('tab-active' + (t === 'pve' ? '' : '-' + t)); } else { el.classList.add('hidden', 'opacity-0'); nav.classList.remove('tab-active', 'tab-active-pbs', 'tab-active-pmg'); } });
if(tab === 'pbs') fetchPbsStats(); if(tab === 'pmg') fetchPmgStats();
}
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 } } } } }); } 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');
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 = '<p class="text-gray-400 text-sm">Keine aktiven VMs.</p>'; 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 += `<div class="bg-darkbg border border-darkborder rounded-lg p-3 flex justify-between items-center transition-transform hover:scale-[1.02] cursor-default"><div class="flex items-center gap-3"><span class="font-bold text-xl ${numberColor}">#${i + 1}</span><div><h4 class="text-white font-semibold text-sm truncate w-32">${icon} ${vm.name}</h4><p class="text-xs text-gray-500">Host: ${vm.host}</p></div></div><div class="text-right"><p class="text-proxmox font-bold text-sm">${cpuPercent}% CPU</p><p class="text-xs text-gray-400">${ramUsed} RAM</p></div></div>`; }); } } catch (err) {} } 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 } } } } }); }
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 = '<p class="text-gray-400 text-sm">Keine aktuellen Jobs.</p>'; 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 += `<div class="bg-darkbg border border-darkborder rounded-lg p-3 flex justify-between items-center transition-colors hover:bg-darkborder/50"><div class="flex items-center gap-3"><div class="text-lg">${statusIcon}</div><div class="max-w-[120px]"><p class="${jobTypeColor} font-medium text-sm capitalize truncate" title="${jobTypeStr}">${jobTypeStr}</p><p class="text-xs text-gray-500 truncate" title="${job.node_name}">Host: <span class="text-proxmox">${job.node_name}</span></p></div></div><div class="text-right"><p class="${statusColor} font-bold text-sm uppercase">${statusText}</p><p class="text-xs text-gray-500">${dateStr} - ${timeStr}</p></div></div>`; }); } } 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) {} }
async function fetchPbsStats() { if(document.getElementById('tab-pbs').classList.contains('hidden')) return; try { const res = await (await fetch('api.php?action=get_pbs_stats')).json(); if(res.success) { const container = document.getElementById('pbs-datastores-container'); if(res.data.length === 0) { container.innerHTML = '<div class="col-span-full text-center text-gray-500 p-10 bg-darkcard rounded-lg border border-darkborder">Keine PBS Server gefunden.</div>'; return; } container.innerHTML = ''; res.data.forEach(ds => { const total = ds.total || 0; const used = ds.used || 0; const percent = total > 0 ? ((used / total) * 100).toFixed(1) : 0; const colorClass = percent > 85 ? 'bg-red-500' : (percent > 70 ? 'bg-orange-500' : 'bg-pbs'); container.innerHTML += `<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg hover:border-pbs cursor-pointer transition-colors" onclick="openPbsDatastore(${ds.node_id}, '${ds.store}')"><div class="flex justify-between items-start mb-4"><div><h3 class="text-lg font-bold text-white">${ds.store}</h3><p class="text-xs text-gray-400">PBS Host: ${ds.host}</p></div><div class="p-2 bg-purple-500/10 rounded-lg"><svg class="w-6 h-6 text-pbs" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg></div></div><div class="mb-2 flex justify-between text-sm"><span class="text-gray-400">Auslastung</span><span class="text-white font-bold">${percent}%</span></div><div class="w-full bg-darkbg rounded-full h-2 mb-3"><div class="${colorClass} h-2 rounded-full transition-all duration-1000" style="width: ${percent}%"></div></div><div class="flex justify-between text-xs text-gray-500"><span>Used: ${formatBytes(used)}</span><span>Total: ${formatBytes(total)}</span></div><div class="mt-4 border-t border-darkborder pt-3 text-center"><span class="text-pbs font-bold text-sm">⚙️ Verwaltung öffnen</span></div></div>`; }); } } catch(e) {} }
async function fetchPmgStats() { if(document.getElementById('tab-pmg').classList.contains('hidden')) return; try { const res = await (await fetch('api.php?action=get_pmg_stats')).json(); if(res.success) { const container = document.getElementById('pmg-nodes-container'); if(res.data.length === 0) { container.innerHTML = '<div class="col-span-full text-center text-gray-500 p-10 bg-darkcard rounded-lg border border-darkborder">Keine Mail Gateways angebunden.</div>'; return; } container.innerHTML = ''; res.data.forEach(pmg => { const cpuPercent = ((pmg.cpu || 0) * 100).toFixed(1); const ramPercent = pmg.memory && pmg.memory.total ? ((pmg.memory.used / pmg.memory.total) * 100).toFixed(1) : 0; const diskPercent = pmg.rootfs && pmg.rootfs.total ? ((pmg.rootfs.used / pmg.rootfs.total) * 100).toFixed(1) : 0; container.innerHTML += `<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg hover:border-pmg cursor-pointer transition-colors" onclick="openPmgManager(${pmg.node_id}, '${pmg.host}', '${pmg.internal_name}')"><div class="flex justify-between items-start mb-4"><div><h3 class="text-lg font-bold text-white">${pmg.host}</h3><p class="text-xs text-green-400">Online</p></div><div class="p-2 bg-blue-500/10 rounded-lg"><svg class="w-6 h-6 text-pmg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg></div></div><div class="mb-1 flex justify-between text-xs"><span class="text-gray-400">CPU</span><span class="text-white font-bold">${cpuPercent}%</span></div><div class="w-full bg-darkbg rounded-full h-1.5 mb-3"><div class="bg-blue-500 h-1.5 rounded-full" style="width: ${cpuPercent}%"></div></div><div class="mb-1 flex justify-between text-xs"><span class="text-gray-400">RAM</span><span class="text-white font-bold">${ramPercent}%</span></div><div class="w-full bg-darkbg rounded-full h-1.5 mb-3"><div class="bg-proxmox h-1.5 rounded-full" style="width: ${ramPercent}%"></div></div><div class="mb-1 flex justify-between text-xs"><span class="text-gray-400">System Disk</span><span class="text-white font-bold">${diskPercent}%</span></div><div class="w-full bg-darkbg rounded-full h-1.5"><div class="bg-emerald-500 h-1.5 rounded-full" style="width: ${diskPercent}%"></div></div><div class="mt-4 border-t border-darkborder pt-3 text-center"><span class="text-pmg font-bold text-sm">🛡️ Gateway öffnen</span></div></div>`; }); } } catch(e) {} }
fetchGlobalStats(); setInterval(fetchGlobalStats, 10000); fetchTopVms(); setInterval(fetchTopVms, 10000); fetchRecentJobs(); setInterval(fetchRecentJobs, 15000); fetchUpdates(); setInterval(fetchUpdates, 60000); setInterval(fetchPbsStats, 10000); setInterval(fetchPmgStats, 10000); // ---------------------------------------------------------
// 3. ALLE WINDOW.* FUNKTIONEN (Modals & Actions)
// ---------------------------------------------------------
// Wir binden ALLES zwingend an "window.", damit HTML-onclick immer darauf zugreifen kann!
const nodeModal = document.getElementById('nodeManagerModal'); window.switchTab = function(tab) {
window.openNodeManager = function() { nodeModal.classList.remove('hidden'); loadNodesIntoTable(); } ['pve', 'pbs', 'pmg', 'node-view'].forEach(t => {
window.closeNodeManager = function() { nodeModal.classList.add('hidden'); document.getElementById('addNodeFormContainer').classList.add('hidden'); } const el = document.getElementById('tab-' + t);
window.toggleAddNodeForm = function() { document.getElementById('addNodeFormContainer').classList.toggle('hidden'); } const nav = document.getElementById('nav-tab-' + t);
async function loadNodesIntoTable() { try { const res = await (await fetch('api.php?action=get_nodes')).json(); if(res.success) { const tbody = document.getElementById('nodeTableBody'); tbody.innerHTML = ''; if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center">Keine Server.</td></tr>'; return; } res.data.forEach(node => { let typeBadge = node.type === 'pbs' ? '<span class="bg-purple-500/20 text-pbs px-2 py-0.5 rounded text-xs font-bold">PBS Backup</span>' : (node.type === 'pmg' ? '<span class="bg-blue-500/20 text-pmg px-2 py-0.5 rounded text-xs font-bold">PMG Mail</span>' : '<span class="bg-proxmox/20 text-proxmox px-2 py-0.5 rounded text-xs font-bold">PVE Node</span>'); tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors"><td class="px-4 py-3 font-medium text-white">${node.name}</td><td class="px-4 py-3">${typeBadge}</td><td class="px-4 py-3">${node.ip_address}</td><td class="px-4 py-3 text-right"><button onclick="deleteNode(${node.id}, '${node.name}')" class="text-red-500 hover:text-red-400 text-sm font-medium">Löschen</button></td></tr>`; }); } } catch (err) {} } if (t === tab) {
window.deleteNode = async function(id, name) { if(!confirm(`Server '${name}' löschen?`)) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_node', { method: 'POST', body: fd }); loadNodesIntoTable(); } if(el) { el.classList.remove('hidden'); setTimeout(() => el.classList.remove('opacity-0'), 50); }
if(nav) nav.classList.add('tab-active' + (t==='pve'?'':(t==='pbs'?'-pbs':(t==='pmg'?'-pmg':''))));
} else {
if(el) el.classList.add('hidden', 'opacity-0');
if(nav) nav.classList.remove('tab-active', 'tab-active-pbs', 'tab-active-pmg');
}
});
document.querySelectorAll('[id^="nav-node-"]').forEach(el => el.classList.remove('text-white', 'font-bold'));
if(tab === 'pbs') window.fetchPbsStats();
if(tab === 'pmg') window.fetchPmgStats();
}
window.openNodeView = async function(nodeId, host) {
window.switchTab('node-view');
document.querySelectorAll('[id^="nav-node-"]').forEach(el => el.classList.remove('text-white', 'font-bold'));
const activeNav = document.getElementById(`nav-node-${host}`);
if(activeNav) activeNav.classList.add('text-white', 'font-bold');
const addNewNodeForm = document.getElementById('addNewNodeForm'); const titleEl = document.getElementById('nodeViewTitle');
if(addNewNodeForm) { addNewNodeForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Verbinde...'; const fd = new FormData(); fd.append('name', document.getElementById('newNodeName').value); fd.append('ip', document.getElementById('newNodeIp').value); fd.append('user', document.getElementById('newNodeUser').value); fd.append('pass', document.getElementById('newNodePass').value); fd.append('type', document.getElementById('newNodeType').value); try { const res = await (await fetch('api.php?action=add_node', { method: 'POST', body: fd })).json(); if(res.success) { addNewNodeForm.reset(); toggleAddNodeForm(); loadNodesIntoTable(); alert('Erfolgreich angebunden!'); } else alert(res.error); } catch(err) {} btn.innerText = oTxt; }); } if(titleEl) titleEl.innerText = 'Host: ' + host;
const headerBtn = document.getElementById('nodeViewHeaderBtn');
const backupBtn = document.getElementById('nodeViewBackupBtn');
if(headerBtn) {
const newHeaderBtn = headerBtn.cloneNode(true); headerBtn.parentNode.replaceChild(newHeaderBtn, headerBtn);
newHeaderBtn.addEventListener('click', () => window.openLiveGraph('node', 0, host, nodeId, host));
}
if(backupBtn) {
const newBackupBtn = backupBtn.cloneNode(true); backupBtn.parentNode.replaceChild(newBackupBtn, backupBtn);
newBackupBtn.addEventListener('click', () => window.openNodeBackups(nodeId, host));
}
window.currentSelectedNodeId = nodeId; window.currentSelectedHost = host;
const userModal = document.getElementById('userManagerModal'); const storContainer = document.getElementById('nodeStoragesContainer');
window.openUserManager = function() { userModal.classList.remove('hidden'); loadUsersIntoTable(); } if(storContainer) storContainer.innerHTML = '<div class="text-gray-500 animate-pulse">Lade Storages...</div>';
window.closeUserManager = function() { userModal.classList.add('hidden'); }
async function loadUsersIntoTable() { try { const res = await (await fetch('api.php?action=get_users')).json(); if(res.success) { const tbody = document.getElementById('userTableBody'); tbody.innerHTML = ''; res.data.forEach(user => { const isMe = user.username === window.APP.username; const deleteBtn = isMe ? `<span class="text-xs text-gray-500">Das bist du</span>` : `<button onclick="deleteUser(${user.id}, '${user.username}')" class="text-red-500 hover:text-red-400 text-sm font-medium">Löschen</button>`; let roleBadge = user.role === 'admin' ? '<span class="bg-red-500/20 text-red-400 px-2 py-0.5 rounded text-xs">Admin</span>' : (user.role === 'vm_manager' ? '<span class="bg-blue-500/20 text-blue-400 px-2 py-0.5 rounded text-xs">VM Manager</span>' : '<span class="bg-gray-500/20 text-gray-400 px-2 py-0.5 rounded text-xs">Viewer</span>'); tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors"><td class="px-4 py-3 font-medium text-white">${user.username}</td><td class="px-4 py-3">${roleBadge}</td><td class="px-4 py-3 text-right">${deleteBtn}</td></tr>`; }); } } catch (err) {} } const vmsBody = document.getElementById('nodeVmsTableBody');
window.handleRoleChange = async function() { const role = document.getElementById('newUserRole').value; const vmBlock = document.getElementById('vmPermissionsBlock'); if (role === 'vm_manager') { vmBlock.classList.remove('hidden'); try { const res = await (await fetch('api.php?action=get_top_vms')).json(); if (res.success && res.data) { const list = document.getElementById('vmCheckboxList'); list.innerHTML = ''; res.data.forEach(vm => { list.innerHTML += `<label class="flex items-center gap-2 hover:bg-darkbg p-1 rounded cursor-pointer"><input type="checkbox" name="allowed_vms[]" value="${vm.vmid}" class="accent-proxmox"><span>${vm.name} <span class="text-xs text-gray-500">(${vm.host})</span></span></label>`; }); } } catch(e) {} } else vmBlock.classList.add('hidden'); } if(vmsBody) vmsBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4 animate-pulse">Lade VMs...</td></tr>';
const addNewUserForm = document.getElementById('addNewUserForm');
if(addNewUserForm) { addNewUserForm.addEventListener('submit', async function(e) { e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Speichere...'; const fd = new FormData(); fd.append('username', document.getElementById('newUsername').value); fd.append('password', document.getElementById('newUserPass').value); const role = document.getElementById('newUserRole').value; fd.append('role', role); let permissions = {}; if (role === 'vm_manager') { let allowedVms = []; document.querySelectorAll('input[name="allowed_vms[]"]:checked').forEach(cb => allowedVms.push(cb.value)); permissions.allowed_vms = allowedVms; } fd.append('permissions', JSON.stringify(permissions)); const res = await (await fetch('api.php?action=create_user', { method: 'POST', body: fd })).json(); if(res.success) { addNewUserForm.reset(); handleRoleChange(); loadUsersIntoTable(); } else alert('Fehler.'); btn.innerText = oTxt; }); } try {
window.deleteUser = async function(id, name) { if(!confirm(`Benutzer '${name}' löschen?`)) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_user', { method: 'POST', body: fd }); loadUsersIntoTable(); } const res = await (await fetch(`api.php?action=get_node_dashboard&node_id=${nodeId}&host=${host}`)).json();
if(res.success) {
if(storContainer) {
storContainer.innerHTML = '';
res.storages.forEach(st => {
const pct = st.total > 0 ? ((st.used / st.total) * 100).toFixed(1) : 0;
storContainer.innerHTML += `<div class="bg-darkbg p-3 border border-darkborder rounded">
<div class="flex justify-between text-sm mb-1"><span class="font-bold text-white">${st.storage}</span><span class="text-gray-400">${pct}%</span></div>
<div class="w-full bg-darkcard rounded-full h-1.5 mb-1"><div class="bg-proxmox h-1.5 rounded-full" style="width: ${pct}%"></div></div>
<div class="text-xs text-gray-500">${st.content}</div></div>`;
});
}
const vmModal = document.getElementById('vmManagerModal'); if(vmsBody) {
window.openVmManager = function() { vmModal.classList.remove('hidden'); loadVmsIntoTable(); } vmsBody.innerHTML = '';
window.closeVmManager = function() { vmModal.classList.add('hidden'); } if(res.vms.length === 0) vmsBody.innerHTML = '<tr><td colspan="5" class="text-center text-gray-500 py-4">Keine VMs auf diesem Host.</td></tr>';
async function loadVmsIntoTable() { res.vms.forEach(vm => {
const tbody = document.getElementById('vmTableBody'); tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center">Lade VMs...</td></tr>';
try {
const res = await (await fetch('api.php?action=get_all_vms')).json();
if(res.success) {
tbody.innerHTML = ''; if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center text-gray-500">Keine VMs.</td></tr>'; return; }
res.data.forEach(vm => {
const isRunning = vm.status === 'running'; const typeStr = vm.type === 'lxc' ? '📦 LXC' : '🖥️ QEMU'; const isRunning = vm.status === 'running'; const typeStr = vm.type === 'lxc' ? '📦 LXC' : '🖥️ QEMU';
const statusBadge = isRunning ? '<span class="bg-green-500/20 text-green-400 px-2 py-1 rounded text-xs font-bold inline-flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span> Running</span>' : '<span class="bg-red-500/20 text-red-400 px-2 py-1 rounded text-xs font-bold inline-flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> Stopped</span>'; const statusBadge = isRunning ? '<span class="text-green-500 font-bold">Online</span>' : '<span class="text-red-500 font-bold">Offline</span>';
let actionButtons = isRunning ? `<button onclick="sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'reboot', ${vm.node_id})" class="text-blue-400 hover:text-blue-300 px-2">🔄</button><button onclick="sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'stop', ${vm.node_id})" class="text-red-500 hover:text-red-400 px-2">⏹️</button>` : `<button onclick="sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'start', ${vm.node_id})" class="text-green-500 hover:text-green-400 px-2 text-lg">▶️</button>`; let actionButtons = isRunning ? `<button onclick="sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'stop', ${vm.node_id})" class="text-red-500 hover:text-red-400 px-2 text-lg transition-colors">⏹️</button>` : `<button onclick="sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'start', ${vm.node_id})" class="text-green-500 hover:text-green-400 px-2 text-lg transition-colors">▶️</button>`;
if(isRunning) { actionButtons += `<button onclick="window.openVmSettings('${vm.vmid}', '${vm.host}', '${vm.type}', ${vm.node_id}, '${vm.name}')" class="text-gray-400 hover:text-white px-2 ml-2 border-l border-darkborder text-lg transition-colors">⚙️</button>`;
actionButtons += `<button onclick="openLiveGraph('${vm.type}', '${vm.vmid}', '${vm.host}', ${vm.node_id}, '${vm.name}')" class="text-blue-400 hover:text-blue-300 px-2 border-l border-darkborder ml-2 pl-4" title="Live Performance (CPU/RAM/Net)">📈</button>`; vmsBody.innerHTML += `<tr class="border-b border-darkborder/50 hover:bg-darkbg transition-colors"><td class="py-2 px-3 text-white font-mono">${vm.vmid}</td><td class="py-2 px-3 text-white font-bold truncate max-w-[200px]">${vm.name}</td><td class="py-2 px-3 text-gray-400 whitespace-nowrap">${typeStr}</td><td class="py-2 px-3 whitespace-nowrap">${statusBadge}</td><td class="py-2 px-3 text-right whitespace-nowrap">${actionButtons}</td></tr>`;
actionButtons += `<button onclick="openVncConsole('${vm.node_ip}', '${vm.host}', '${vm.type}', '${vm.vmid}', '${vm.name}')" class="text-emerald-400 hover:text-emerald-300 px-2 border-l border-darkborder ml-2 pl-4" title="NoVNC Console">🖥️</button>`;
}
actionButtons += `<button onclick="openVmSettings('${vm.vmid}', '${vm.host}', '${vm.type}', ${vm.node_id}, '${vm.name}')" class="text-gray-400 hover:text-white px-2 border-l border-darkborder ml-2 pl-4" title="Einstellungen & Backups">⚙️</button>`;
tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3"><div class="flex items-center gap-2"><span class="text-gray-500 text-xs">${vm.vmid}</span><span class="font-medium text-white">${vm.name}</span></div></td><td class="px-4 py-3 text-xs text-gray-400 uppercase">${typeStr} - ${vm.host}</td><td class="px-4 py-3 text-xs text-gray-400">${vm.maxcpu||1} Cores / ${formatBytes(vm.maxmem||0)}</td><td class="px-4 py-3 text-center">${statusBadge}</td><td class="px-4 py-3 text-right space-x-2">${actionButtons}</td></tr>`;
}); });
} }
} catch (err) {} }
} } catch(e) {}
window.sendVmCommand = async function(vmid, host, type, cmd, nodeId) { if(!confirm(`Maschine '${vmid}' wirklich ${cmd}?`)) return; const fd = new FormData(); fd.append('vmid', vmid); fd.append('host', host); fd.append('type', type); fd.append('cmd', cmd); fd.append('node_id', nodeId); document.getElementById('vmTableBody').style.opacity = '0.5'; try { const res = await (await fetch('api.php?action=vm_action', { method: 'POST', body: fd })).json(); if(res.success) setTimeout(loadVmsIntoTable, 2000); else alert(res.error); } catch (err) {} finally { document.getElementById('vmTableBody').style.opacity = '1'; } } }
const createVmModal = document.getElementById('createVmModal'); window.openLiveGraph = function(targetMode, vmid, host, nodeId, name) {
window.openCreateVm = async function() { createVmModal.classList.remove('hidden'); const select = document.getElementById('createVmHost'); select.innerHTML = '<option value="">Suche Hosts...</option>'; try { const res = await (await fetch('api.php?action=get_pve_nodes')).json(); if (res.success && res.data.length > 0) { select.innerHTML = ''; res.data.forEach(node => { select.innerHTML += `<option value='{"id":${node.node_id},"host":"${node.host}"}'>${node.display}</option>`; }); } else { select.innerHTML = '<option value="">Keine PVE-Hosts</option>'; } } catch(e) {} } const m = document.getElementById('liveGraphModal');
window.closeCreateVm = function() { createVmModal.classList.add('hidden'); } if(!m) return;
const createVmForm = document.getElementById('createVmForm'); m.classList.remove('hidden');
if(createVmForm) { createVmForm.addEventListener('submit', async function(e) { e.preventDefault(); const hostDataStr = document.getElementById('createVmHost').value; if(!hostDataStr) return; const hostData = JSON.parse(hostDataStr); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Richte VM ein...'; const fd = new FormData(); fd.append('node_id', hostData.id); fd.append('host', hostData.host); fd.append('name', document.getElementById('createVmName').value); fd.append('memory', document.getElementById('createVmRam').value); fd.append('cores', document.getElementById('createVmCores').value); try { const res = await (await fetch('api.php?action=create_vm', { method: 'POST', body: fd })).json(); if(res.success) { alert(`Erfolgreich! VM-ID: ${res.vmid}`); createVmForm.reset(); closeCreateVm(); loadVmsIntoTable(); } else alert(res.error); } catch(e) {} finally { btn.innerText = oTxt; } }); } currentGraphParams = { mode: targetMode, vmid: vmid, host: host, nodeId: nodeId, name: name };
window.switchGraphTab('live');
}
window.closeLiveGraph = function() { const m = document.getElementById('liveGraphModal'); if(m) m.classList.add('hidden'); if(liveInterval) clearInterval(liveInterval); }
const settingsModal = document.getElementById('vmSettingsModal'); window.switchGraphTab = async function(tab) {
window.openVmSettings = async function(vmid, host, type, nodeId, name) { if(liveInterval) clearInterval(liveInterval);
settingsModal.classList.remove('hidden'); document.getElementById('settingsModalTitle').innerText = 'Einstellungen: ' + name; ['live', 'day', 'week'].forEach(t => {
document.getElementById('setVmid').value = vmid; document.getElementById('setHost').value = host; document.getElementById('setType').value = type; document.getElementById('setNodeId').value = nodeId; const b = document.getElementById('btn-graph-' + t);
document.getElementById('snapshotListContainer').innerHTML = '<div class="text-gray-500 text-sm italic">Lade Snapshots...</div>'; if(b) {
document.getElementById('backupListContainer').innerHTML = '<div class="text-gray-500 text-sm italic">Lade Backups...</div>'; if(t === tab) { b.classList.add('border-proxmox', 'text-white'); b.classList.remove('border-transparent', 'text-gray-400'); }
try { else { b.classList.remove('border-proxmox', 'text-white'); b.classList.add('border-transparent', 'text-gray-400'); }
const res = await (await fetch(`api.php?action=get_vm_config&vmid=${vmid}&host=${host}&type=${type}&node_id=${nodeId}`)).json(); }
if (res.success && res.data) { const cfg = res.data; document.getElementById('setMemory').value = cfg.memory || ''; document.getElementById('setCores').value = cfg.cores || 1; if (cfg.net0) { document.getElementById('setRawNet0').value = cfg.net0; if (cfg.net0.includes('link_down=1')) { document.getElementById('netStatusBadge').className = 'px-2 py-1 rounded text-xs font-bold text-red-400 bg-red-500/20'; document.getElementById('netStatusBadge').innerText = 'Getrennt'; document.getElementById('btnToggleNet').innerText = 'Kabel einstecken'; document.getElementById('btnToggleNet').onclick = () => saveNetwork(false); } else { document.getElementById('netStatusBadge').className = 'px-2 py-1 rounded text-xs font-bold text-green-400 bg-green-500/20'; document.getElementById('netStatusBadge').innerText = 'Verbunden'; document.getElementById('btnToggleNet').innerText = 'Kabel ziehen'; document.getElementById('btnToggleNet').onclick = () => saveNetwork(true); } } else { document.getElementById('netStatusBadge').innerText = 'Kein net0'; document.getElementById('btnToggleNet').style.display = 'none'; } let diskName = ''; if (type === 'lxc' && cfg.rootfs) diskName = 'rootfs'; else if (cfg.scsi0) diskName = 'scsi0'; else if (cfg.virtio0) diskName = 'virtio0'; else if (cfg.ide0) diskName = 'ide0'; if (diskName) { document.getElementById('setPrimaryDisk').value = diskName; document.getElementById('diskLabelName').innerText = diskName; } else document.getElementById('diskLabelName').innerText = 'Nicht gefunden'; } });
loadSnapshots(vmid, host, type, nodeId);
const stRes = await (await fetch(`api.php?action=get_backup_storages&node_id=${nodeId}&host=${host}`)).json(); const titleEl = document.getElementById('graphModalTitle');
if (stRes.success) { const bSelect = document.getElementById('backupTargetStorage'); bSelect.innerHTML = ''; stRes.data.forEach(st => bSelect.innerHTML += `<option value="${st}">${st}</option>`); } if(titleEl) titleEl.innerText = `${tab === 'live' ? 'Live' : (tab === 'day' ? '24h' : '7 Tage')} Performance: ${currentGraphParams.name}`;
loadBackups(vmid, host, nodeId);
} catch (e) {} const dot = document.getElementById('graphStatusDot');
} const subText = document.getElementById('graphSubText');
window.closeVmSettings = function() { settingsModal.classList.add('hidden'); } if(dot && subText) {
window.saveHardwareSettings = async function() { const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('memory', document.getElementById('setMemory').value); fd.append('cores', document.getElementById('setCores').value); const res = await (await fetch('api.php?action=update_vm_config', { method: 'POST', body: fd })).json(); if(res.success) { alert('Gespeichert!'); loadVmsIntoTable(); } else alert(res.error); } if(tab === 'live') { dot.classList.add('animate-pulse', 'bg-green-500'); dot.classList.remove('bg-blue-500'); subText.innerText = 'Metriken werden live abgefragt.'; }
window.saveNetwork = async function(disconnect) { let net = document.getElementById('setRawNet0').value; if (!net) return; if (disconnect) { if (!net.includes('link_down=1')) net += ',link_down=1'; } else { net = net.replace(',link_down=1', ''); } const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('net0', net); const res = await (await fetch('api.php?action=update_vm_config', { method: 'POST', body: fd })).json(); if(res.success) { alert('Netzwerkstatus geändert!'); openVmSettings(fd.get('vmid'), fd.get('host'), fd.get('type'), fd.get('node_id'), document.getElementById('settingsModalTitle').innerText.replace('Einstellungen: ', '')); } } else { dot.classList.remove('animate-pulse', 'bg-green-500'); dot.classList.add('bg-blue-500'); subText.innerText = 'Historische RRD-Daten via Proxmox API.'; }
window.addNic = async function() { const bridge = document.getElementById('addNicBridge').value.trim(); if (!bridge) return; const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('bridge', bridge); try { const res = await (await fetch('api.php?action=add_vm_nic', { method: 'POST', body: fd })).json(); if(res.success) { alert(`Erfolgreich in Slot: ${res.slot}`); openVmSettings(fd.get('vmid'), fd.get('host'), fd.get('type'), fd.get('node_id'), document.getElementById('settingsModalTitle').innerText.replace('Einstellungen: ', '')); } else alert(res.error); } catch(e) {} }
window.expandDisk = async function() { const gb = document.getElementById('addDiskGb').value; const disk = document.getElementById('setPrimaryDisk').value; if (!gb || gb <= 0 || !disk) return; if (!confirm(`Disk um +${gb}GB erweitern?`)) return; const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('disk', disk); fd.append('size', '+' + gb + 'G'); const res = await (await fetch('api.php?action=resize_vm_disk', { method: 'POST', body: fd })).json(); if(res.success) { alert('Erweitert.'); document.getElementById('addDiskGb').value = ''; } else alert(res.error); }
window.destroyVm = async function() { const vmid = document.getElementById('setVmid').value; if (prompt(`Zahl ${vmid} eingeben zum Löschen:`) !== vmid) return; const fd = new FormData(); fd.append('vmid', vmid); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); try { const res = await (await fetch('api.php?action=delete_vm', { method: 'POST', body: fd })).json(); if(res.success) { alert('Gelöscht.'); closeVmSettings(); loadVmsIntoTable(); } else alert(res.error); } catch(e) {} }
async function loadSnapshots(vmid, host, type, nodeId) {
try {
const res = await (await fetch(`api.php?action=get_vm_snapshots&vmid=${vmid}&host=${host}&type=${type}&node_id=${nodeId}`)).json();
const container = document.getElementById('snapshotListContainer');
if (res.success && res.data) {
const snaps = res.data.filter(s => s.name !== 'current');
if(snaps.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm italic">Keine Snapshots vorhanden.</p>'; return; }
container.innerHTML = '';
snaps.forEach(s => {
const timeStr = s.snaptime ? formatDate(s.snaptime) : 'Unbekannt';
container.innerHTML += `<div class="flex justify-between items-center bg-darkcard p-2 border border-darkborder rounded"><div><p class="text-white text-sm font-bold">${s.name}</p><p class="text-xs text-gray-500">${timeStr}</p></div><div class="flex gap-2"><button onclick="actionSnapshot('rollback', '${s.name}')" class="text-orange-400 hover:text-orange-300 text-xs font-bold px-2 border-r border-darkborder">Rollback</button><button onclick="actionSnapshot('delete', '${s.name}')" class="text-red-500 hover:text-red-400 text-xs font-bold px-2">Löschen</button></div></div>`;
});
}
} catch(e) {}
}
window.createSnapshot = async function() { const snapname = document.getElementById('newSnapName').value.trim(); if(!snapname || !snapname.match(/^[a-zA-Z0-9_-]+$/)) return alert('Bitte nur Buchstaben und Zahlen!'); actionSnapshot('create', snapname); }
window.actionSnapshot = async function(cmd, snapname) {
if(cmd === 'rollback' && !confirm(`VM wird auf '${snapname}' zurückgesetzt. Fortfahren?`)) return;
if(cmd === 'delete' && !confirm(`Snapshot '${snapname}' löschen?`)) return;
const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('cmd', cmd); fd.append('snapname', snapname);
try { const res = await (await fetch('api.php?action=vm_snapshot_action', { method: 'POST', body: fd })).json(); if(res.success) { alert('Aktion erfolgreich!'); document.getElementById('newSnapName').value = ''; loadSnapshots(fd.get('vmid'), fd.get('host'), fd.get('type'), fd.get('node_id')); } else alert('Fehler.'); } catch(e) {}
} }
async function loadBackups(vmid, host, nodeId) { if(perfChartObj) perfChartObj.destroy(); if(netChartObj) netChartObj.destroy();
try {
const res = await (await fetch(`api.php?action=get_vm_backups&vmid=${vmid}&host=${host}&node_id=${nodeId}`)).json(); const ctxPerfEl = document.getElementById('perfChart');
const container = document.getElementById('backupListContainer'); if(ctxPerfEl) {
if (res.success && res.data) { const ctxPerf = ctxPerfEl.getContext('2d');
if(res.data.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm italic">Keine Backups gefunden.</p>'; return; } perfChartObj = new Chart(ctxPerf, { type: 'line', data: { labels: [], datasets: [ { label: 'CPU (%)', borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: true, pointRadius: tab==='live'?3:0, data: [] }, { label: 'RAM (%)', borderColor: '#E57000', backgroundColor: 'rgba(229, 112, 0, 0.1)', borderWidth: 2, tension: 0.4, fill: true, pointRadius: tab==='live'?3:0, data: [] }]}, options: { responsive: true, maintainAspectRatio: false, animation: { duration: tab==='live'?0:500 }, scales: { x: { grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } }, y: { min: 0, max: 100, grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } } }, plugins: { legend: { labels: { color: '#e2e8f0' } } } } });
container.innerHTML = '';
res.data.forEach(b => {
const timeStr = b.ctime ? formatDate(b.ctime) : 'Unbekannt'; const sizeStr = formatBytes(b.size);
container.innerHTML += `<div class="flex justify-between items-center bg-darkcard p-2 border border-darkborder rounded hover:border-pbs transition-colors cursor-pointer" onclick="restoreBackup('${b.volid}')"><div class="truncate pr-2"><p class="text-white text-xs font-bold truncate" title="${b.volid}">${b.volid}</p><p class="text-xs text-gray-500">${timeStr} | ${sizeStr} | Storage: ${b.storage}</p></div><span class="text-pbs text-xs font-bold whitespace-nowrap">Restore ⤵</span></div>`;
});
}
} catch(e) {}
}
window.createBackup = async function() { const storage = document.getElementById('backupTargetStorage').value; if(!storage) return alert('Storage auswählen!'); const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('storage', storage); try { const res = await (await fetch('api.php?action=create_backup', { method: 'POST', body: fd })).json(); if(res.success) { alert('Backup gestartet! Siehe Letzte Jobs.'); } else alert('Fehler.'); } catch(e) {} }
window.restoreBackup = async function(archive) {
if(!confirm(`⚠️ GEFAHRENZONE ⚠️\nSicher, dass du das Archiv '${archive}' wiederherstellen willst?\nDie VM MUSS dafür GESTOPPT sein!`)) return;
const fd = new FormData(); fd.append('vmid', document.getElementById('setVmid').value); fd.append('host', document.getElementById('setHost').value); fd.append('type', document.getElementById('setType').value); fd.append('node_id', document.getElementById('setNodeId').value); fd.append('archive', archive);
try { const res = await (await fetch('api.php?action=restore_backup', { method: 'POST', body: fd })).json(); if(res.success) { alert('Restore-Task erfolgreich!'); closeVmSettings(); } else alert('Fehler: Ist die VM gestoppt?'); } catch(e) {}
} }
const topologyModal = document.getElementById('nodeTopologyModal'); const ctxNetEl = document.getElementById('netChart');
window.openNodeTopology = async function() { if(ctxNetEl) {
topologyModal.classList.remove('hidden'); const container = document.getElementById('nodeTopologyContainer'); container.innerHTML = '<div class="text-center text-gray-500 py-10 animate-pulse">Lade Cluster-Daten...</div>'; const ctxNet = ctxNetEl.getContext('2d');
try { netChartObj = new Chart(ctxNet, { type: 'line', data: { labels: [], datasets: [ { label: 'RX (MB/s)', borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', borderWidth: 2, tension: 0.4, fill: true, pointRadius: tab==='live'?3:0, data: [] }, { label: 'TX (MB/s)', borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: true, pointRadius: tab==='live'?3:0, data: [] }]}, options: { responsive: true, maintainAspectRatio: false, animation: { duration: tab==='live'?0:500 }, scales: { x: { grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } }, y: { min: 0, grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } } }, plugins: { legend: { labels: { color: '#e2e8f0' } } } } });
const [nodesRes, vmsRes] = await Promise.all([ fetch('api.php?action=get_nodes').then(r => r.json()), fetch('api.php?action=get_all_vms').then(r => r.json()) ]);
if(nodesRes.success && vmsRes.success) {
container.innerHTML = ''; const pveNodes = nodesRes.data.filter(n => n.type === 'pve'); if(pveNodes.length === 0) return;
pveNodes.forEach(node => {
const nodeVms = vmsRes.data.filter(v => v.node_id == node.id); let vmsHtml = '';
if(nodeVms.length > 0) {
nodeVms.sort((a, b) => { if(a.status === 'running' && b.status !== 'running') return -1; if(a.status !== 'running' && b.status === 'running') return 1; return a.name.localeCompare(b.name); });
nodeVms.forEach(vm => {
const icon = vm.type === 'lxc' ? '📦' : '🖥️'; const isRunning = vm.status === 'running'; const statusColor = isRunning ? 'border-green-500/30 bg-green-500/10 text-green-400' : 'border-darkborder bg-darkcard text-gray-400'; const dot = isRunning ? '<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse shrink-0"></span>' : '<span class="w-2 h-2 rounded-full bg-gray-600 shrink-0"></span>';
vmsHtml += `<div class="flex flex-col p-3 rounded-lg border ${statusColor} transition-transform hover:scale-[1.02]"><div class="flex items-center gap-2 mb-1">${dot}<span class="font-bold text-sm truncate" title="${vm.name}">${icon} ${vm.name}</span></div><div class="flex justify-between text-xs opacity-75"><span>ID: ${vm.vmid}</span><span>${vm.maxcpu || 1}C / ${formatBytes(vm.maxmem || 0)}</span></div></div>`;
});
}
container.innerHTML += `<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><div class="flex justify-between items-center mb-4 border-b border-darkborder pb-3"><h3 class="text-lg font-bold text-white flex items-center gap-2"><svg class="w-5 h-5 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>${node.name}</h3><div class="flex gap-3"><button onclick="openLiveGraph('node', 0, '${node.name}', ${node.id}, '${node.name}')" class="text-blue-400 hover:text-white transition-colors text-sm" title="Node Performance">📈 Live Graph</button><span class="text-xs font-bold text-gray-400 bg-darkbg px-3 py-1 rounded-full border border-darkborder">${node.ip_address}</span></div></div><div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">${vmsHtml}</div></div>`;
});
}
} catch(e) {}
} }
window.closeNodeTopology = function() { topologyModal.classList.add('hidden'); }
let perfChartObj = null; let netChartObj = null; let liveInterval = null; if(tab === 'live') {
window.openLiveGraph = function(targetMode, vmid, host, nodeId, name) {
document.getElementById('liveGraphModal').classList.remove('hidden'); document.getElementById('graphModalTitle').innerText = targetMode === 'node' ? `Live Performance: Node ${name}` : `Live Performance: VM ${name} (${vmid})`;
if(perfChartObj) perfChartObj.destroy(); if(netChartObj) netChartObj.destroy();
const ctxPerf = document.getElementById('perfChart').getContext('2d'); perfChartObj = new Chart(ctxPerf, { 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: 0 }, scales: { x: { grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } }, y: { min: 0, max: 100, grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } } }, plugins: { legend: { labels: { color: '#e2e8f0' } } } } });
const ctxNet = document.getElementById('netChart').getContext('2d'); netChartObj = new Chart(ctxNet, { type: 'line', data: { labels: [], datasets: [ { label: 'RX (MB/s)', borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }, { label: 'TX (MB/s)', borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }]}, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 0 }, scales: { x: { grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } }, y: { min: 0, grid: { color: '#33334d' }, ticks: { color: '#9ca3af' } } }, plugins: { legend: { labels: { color: '#e2e8f0' } } } } });
if(liveInterval) clearInterval(liveInterval);
let prevNetIn = 0; let prevNetOut = 0; let prevTime = null; let prevNetIn = 0; let prevNetOut = 0; let prevTime = null;
const fetchLiveData = async () => { const fetchLiveData = async () => {
try { try {
const endpoint = targetMode === 'node' ? `api.php?action=get_node_status&host=${host}&node_id=${nodeId}` : `api.php?action=get_vm_status&vmid=${vmid}&host=${host}&type=${targetMode}&node_id=${nodeId}`; const endpoint = currentGraphParams.mode === 'node' ? `api.php?action=get_node_status&host=${currentGraphParams.host}&node_id=${currentGraphParams.nodeId}` : `api.php?action=get_vm_status&vmid=${currentGraphParams.vmid}&host=${currentGraphParams.host}&type=${currentGraphParams.mode}&node_id=${currentGraphParams.nodeId}`;
const res = await (await fetch(endpoint)).json(); const res = await (await fetch(endpoint)).json();
if(res.success && res.data) { if(res.success && res.data) {
const d = res.data; const now = Date.now(); 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 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 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; prevNetIn = currentNetIn; prevNetOut = currentNetOut; prevTime = now;
const timeStr = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 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(perfChartObj) { 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(netChartObj && (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) {} } catch(e) {}
}; };
fetchLiveData(); liveInterval = setInterval(fetchLiveData, 2000); fetchLiveData(); liveInterval = setInterval(fetchLiveData, 2000);
} } else {
window.closeLiveGraph = function() { document.getElementById('liveGraphModal').classList.add('hidden'); if(liveInterval) clearInterval(liveInterval); }
window.openVncConsole = function(ip, node, type, vmid, name) {
const cType = type === 'qemu' ? 'kvm' : 'lxc';
const url = `https://${ip}:8006/?console=${cType}&novnc=1&vmid=${vmid}&vmname=${name}&node=${node}`;
window.open(url, `VNC_${vmid}`, "width=1024,height=768,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes");
}
window.closeVncConsole = function() {}
window.openPbsDatastore = async function(nodeId, storeName) {
if (!nodeId) return alert('Node ID fehlt. Bitte Seite neu laden (F5).');
document.getElementById('pbsDatastoreModal').classList.remove('hidden');
document.getElementById('pbsModalTitle').innerText = 'Datastore: ' + storeName;
document.getElementById('pbsNodeId').value = nodeId;
document.getElementById('pbsStoreName').value = storeName;
loadPbsBackups(nodeId, storeName);
loadPbsJobs(nodeId, storeName);
loadPbsSyncJobs(nodeId);
}
window.closePbsDatastore = function() { document.getElementById('pbsDatastoreModal').classList.add('hidden'); }
async function loadPbsBackups(nodeId, storeName) {
const container = document.getElementById('pbsBackupsContainer'); container.innerHTML = '<p class="text-gray-500 animate-pulse">Durchsuche Namespaces nach Backups...</p>';
try { try {
const res = await (await fetch(`api.php?action=pbs_get_datastore_content&node_id=${nodeId}&store=${storeName}`)).json(); const res = await (await fetch(`api.php?action=get_historical_rrd&timeframe=${tab}&target_mode=${currentGraphParams.mode}&vmid=${currentGraphParams.vmid}&host=${currentGraphParams.host}&node_id=${currentGraphParams.nodeId}`)).json();
if(res.success && res.data) { if(res.success && res.data) {
if(res.data.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm">Keine Backups vorhanden.</p>'; return; } res.data.forEach(d => {
container.innerHTML = ''; const date = new Date(d.time * 1000); const l = tab === 'day' ? date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}) : date.toLocaleDateString('de-DE',{weekday:'short', hour:'2-digit'});
res.data.sort((a,b) => b['backup-time'] - a['backup-time']).forEach(b => { if(perfChartObj) { perfChartObj.data.labels.push(l); perfChartObj.data.datasets[0].data.push((d.cpu || 0) * 100); let ram = 0; if (d.maxmem > 0) { ram = ((d.mem / d.maxmem) * 100).toFixed(1); } perfChartObj.data.datasets[1].data.push(ram); }
const timeStr = formatDate(b['backup-time']); const sizeStr = formatBytes(b.size); if(netChartObj) { netChartObj.data.labels.push(l); netChartObj.data.datasets[0].data.push((d.netin || 0) / (1024*1024)); netChartObj.data.datasets[1].data.push((d.netout || 0) / (1024*1024)); }
const nsLabel = b.ns ? `<span class="text-pbs font-normal ml-2">[${b.ns}]</span>` : `<span class="text-gray-500 font-normal ml-2">[Root]</span>`;
container.innerHTML += `
<div class="flex justify-between items-center bg-darkbg p-2 border border-darkborder rounded mb-2 hover:border-pbs transition-colors">
<div><p class="text-white text-xs font-bold">${b['backup-type']} / ${b['backup-id']} ${nsLabel}</p><p class="text-xs text-gray-500">${timeStr} | ${sizeStr}</p></div>
<button onclick="deletePbsSnapshot('${b['backup-type']}', '${b['backup-id']}', ${b['backup-time']}, '${b.ns || ''}')" class="text-red-500 hover:text-white text-xs font-bold px-3 py-1 bg-red-500/10 hover:bg-red-500 rounded transition-colors">Löschen</button>
</div>`;
}); });
} if(perfChartObj) perfChartObj.update(); if(netChartObj) netChartObj.update();
} catch(e) { container.innerHTML = 'Fehler beim Laden.'; }
}
window.deletePbsSnapshot = async function(btype, bid, btime, ns) {
if(!confirm(`Backup ${btype}/${bid} wirklich unwiderruflich löschen?`)) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pbsNodeId').value); fd.append('store', document.getElementById('pbsStoreName').value); fd.append('btype', btype); fd.append('bid', bid); fd.append('btime', btime); fd.append('ns', ns);
const res = await (await fetch('api.php?action=pbs_delete_snapshot', {method: 'POST', body: fd})).json();
if(res.success) { loadPbsBackups(document.getElementById('pbsNodeId').value, document.getElementById('pbsStoreName').value); } else alert('Fehler beim Löschen.');
}
async function loadPbsJobs(nodeId, storeName) {
const mixCont = document.getElementById('pbsVerifyGcContainer'); mixCont.innerHTML = '<p class="text-gray-500 animate-pulse">Lade System-Jobs...</p>';
const table = document.getElementById('pbsJobsTable'); table.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-500">Lade Historie...</td></tr>';
try {
const res = await (await fetch(`api.php?action=pbs_get_jobs&node_id=${nodeId}`)).json();
if(res.success && res.data) {
mixCont.innerHTML = ''; table.innerHTML = ''; let mixCount = 0;
res.data.sort((a,b) => b.starttime - a.starttime).forEach(job => {
const statusColor = job.status === 'OK' ? 'text-green-500' : (job.status ? 'text-red-500' : 'text-blue-400');
const timeStr = formatDate(job.starttime);
const runtime = job.endtime ? (Math.round(job.endtime - job.starttime) + 's') : 'Running...';
const isSystemJob = job.worker_type === 'verify' || job.worker_type === 'garbage_collection' || job.worker_type === 'prune';
if (isSystemJob && mixCount < 6) {
mixCont.innerHTML += `<div class="flex justify-between items-center bg-darkbg p-2 border border-darkborder rounded mb-1"><div><p class="text-white text-xs font-bold uppercase">${job.worker_type}</p><p class="text-xs text-gray-500">${timeStr}</p></div><div class="flex items-center gap-2"><span class="${statusColor} font-bold text-xs uppercase mr-2">${job.status || 'Active'}</span><button onclick="openTaskLog('${job.upid}', ${nodeId}, 'localhost')" class="text-gray-400 hover:text-white text-xs bg-darkcard px-2 py-1 rounded border border-darkborder transition-colors">📄 Log</button></div></div>`;
mixCount++;
}
table.innerHTML += `<tr class="hover:bg-darkbg transition-colors border-b border-darkborder/50"><td class="px-4 py-2">${timeStr}</td><td class="px-4 py-2 font-medium text-white">${job.worker_type}</td><td class="px-4 py-2 ${statusColor} font-bold uppercase">${job.status || 'Active'}</td><td class="px-4 py-2">${runtime}</td><td class="px-4 py-2 text-right"><button onclick="openTaskLog('${job.upid}', ${nodeId}, 'localhost')" class="text-pbs hover:text-white font-bold text-xs transition-colors">Ansehen</button></td></tr>`;
});
if(mixCount === 0) mixCont.innerHTML = '<p class="text-gray-500 text-xs italic">Keine System-Jobs gefunden.</p>';
if(res.data.length === 0) table.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-500">Keine Historie gefunden.</td></tr>';
} }
} catch(e) {} } catch(e) {}
} }
}
async function loadPbsSyncJobs(nodeId) { window.openNodeBackups = async function(nodeId, host) {
const container = document.getElementById('pbsSyncContainer'); container.innerHTML = '<p class="text-gray-500 animate-pulse">Lade Sync-Jobs...</p>'; const m = document.getElementById('nodeBackupsModal');
try { if(!m) return;
const res = await (await fetch(`api.php?action=pbs_get_sync_jobs&node_id=${nodeId}`)).json(); m.classList.remove('hidden');
if(res.success && res.data) { document.getElementById('nodeBackupsTitle').innerText = 'Backup Jobs: ' + host;
if(res.data.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm">Keine Sync-Jobs eingerichtet.</p>'; return; } const tbody = document.getElementById('nodeBackupsTableBody');
container.innerHTML = ''; if(!tbody) return;
res.data.forEach(sync => { tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-500 animate-pulse">Lade Jobs...</td></tr>';
container.innerHTML += `<div class="bg-darkbg p-3 border border-darkborder rounded mb-2 hover:border-blue-500 transition-colors"><h4 class="text-white text-sm font-bold truncate">${sync.id}</h4><p class="text-xs text-gray-400 mt-1">Quelle: <span class="text-blue-400">${sync.remote || 'Lokal'} -> ${sync['remote-store']}</span></p><p class="text-xs text-gray-400">Ziel: <span class="text-emerald-400">${sync.store}</span></p><div class="mt-2 text-xs text-gray-500 bg-darkcard p-1.5 rounded inline-block">Zeitplan: ${sync.schedule || 'Manuell'}</div></div>`; try {
}); const res = await (await fetch(`api.php?action=get_node_backup_jobs&node_id=${nodeId}&host=${host}`)).json();
} if(res.success) {
} catch(e) { container.innerHTML = 'Fehler.'; } tbody.innerHTML = '';
} if(res.data.length === 0) tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-gray-500">Keine Backup Jobs definiert.</td></tr>';
res.data.forEach(j => {
tbody.innerHTML += `<tr class="border-b border-darkborder/50 hover:bg-darkbg transition-colors">
<td class="py-2 px-3 text-white font-bold">${j.id}</td>
<td class="py-2 px-3 text-proxmox">${j.schedule || 'Manuell'}</td>
<td class="py-2 px-3">${j.storage}</td>
<td class="py-2 px-3 text-gray-400">${j.vmid || 'Alle VMs'}</td>
<td class="py-2 px-3 text-right"><span class="text-xs bg-darkcard border border-darkborder px-2 py-1 rounded text-gray-500">Read-Only via API</span></td>
</tr>`;
});
}
} catch(e) {}
}
window.closeNodeBackups = function() { const m = document.getElementById('nodeBackupsModal'); if(m) m.classList.add('hidden'); }
window.openTaskLog = async function(upid, nodeId, host) { window.openCreateVm = async function() {
document.getElementById('taskLogModal').classList.remove('hidden'); const m = document.getElementById('createVmModal'); if(!m) return;
document.getElementById('taskLogContent').innerText = 'Lade Log vom Server...'; m.classList.remove('hidden');
try { const select = document.getElementById('createVmHost');
const res = await (await fetch(`api.php?action=get_task_log&node_id=${nodeId}&upid=${encodeURIComponent(upid)}&host=${host}`)).json(); if(select) {
if(res.success && res.data) { select.innerHTML = '<option value="">Suche Hosts...</option>';
let logText = ''; res.data.forEach(line => logText += line.t + '\n'); try {
document.getElementById('taskLogContent').innerText = logText || 'Log ist leer.'; const res = await (await fetch('api.php?action=get_pve_nodes')).json();
} else { document.getElementById('taskLogContent').innerText = 'Fehler beim Laden des Logs.'; } if (res.success && res.data.length > 0) {
} catch(e) {} select.innerHTML = '';
res.data.forEach(node => {
const isSelected = (window.currentSelectedHost && node.host === window.currentSelectedHost) ? 'selected' : '';
select.innerHTML += `<option value='{"id":${node.node_id},"host":"${node.host}"}' ${isSelected}>${node.display}</option>`;
});
} else { select.innerHTML = '<option value="">Keine PVE-Hosts</option>'; }
} catch(e) {}
} }
window.closeTaskLog = function() { document.getElementById('taskLogModal').classList.add('hidden'); } }
window.closeCreateVm = function() { const m = document.getElementById('createVmModal'); if(m) m.classList.add('hidden'); }
window.openPmgManager = function(nodeId, hostName, internalName) { window.openVmSettings = async function(vmid, host, type, nodeId, name) {
document.getElementById('pmgManagerModal').classList.remove('hidden'); const m = document.getElementById('vmSettingsModal'); if(!m) return;
document.getElementById('pmgModalTitle').innerText = 'Mail Gateway: ' + hostName; m.classList.remove('hidden');
document.getElementById('pmgNodeId').value = nodeId; const titleEl = document.getElementById('settingsModalTitle'); if(titleEl) titleEl.innerText = 'Einstellungen: ' + name;
document.getElementById('pmgHostName').value = hostName;
document.getElementById('pmgInternalName').value = internalName;
switchPmgTab('spam');
}
window.closePmgManager = function() { document.getElementById('pmgManagerModal').classList.add('hidden'); }
window.switchPmgTab = function(tabName) {
['spam', 'tracking', 'queues', 'settings'].forEach(t => {
const btn = document.getElementById('btn-pmg-' + t);
const content = document.getElementById('pmg-tab-' + t);
if(btn && content) {
if (t === tabName) {
btn.classList.add('border-pmg', 'text-white'); btn.classList.remove('border-transparent', 'text-gray-400');
content.classList.remove('hidden');
} else {
btn.classList.remove('border-pmg', 'text-white'); btn.classList.add('border-transparent', 'text-gray-400');
content.classList.add('hidden');
}
}
});
const nodeId = document.getElementById('pmgNodeId').value;
if (!nodeId) return;
if(tabName === 'spam') loadPmgSpamQueue(nodeId);
if(tabName === 'tracking') loadPmgTracking();
if(tabName === 'queues') loadPmgQueues();
if(tabName === 'settings') { loadPmgConfig(nodeId); loadPmgSsl(nodeId); loadPmgDomains(nodeId); loadPmgNetworks(nodeId); }
}
window.loadPmgSpamQueue = async function(nodeId) {
const table = document.getElementById('pmgSpamTable');
table.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-500 animate-pulse">Lade Spam Quarantäne...</td></tr>';
try {
const res = await (await fetch(`api.php?action=pmg_get_spam_queue&node_id=${nodeId}`)).json();
if(res.success && res.data) {
table.innerHTML = '';
if(res.data.length === 0) { table.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-gray-500">🎉 Keine Mails in der Quarantäne.</td></tr>'; return; }
res.data.sort((a,b) => b.time - a.time).forEach(mail => {
const timeStr = formatDate(mail.time); const subject = mail.subject || 'Kein Betreff'; const sender = mail.sender || 'Unbekannt'; const receiver = mail.receiver || 'Unbekannt'; const score = mail.spamlevel || 0;
table.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3 whitespace-nowrap">${timeStr}</td><td class="px-4 py-3 text-white truncate max-w-[200px]" title="${sender}">${sender}</td><td class="px-4 py-3 text-gray-300 truncate max-w-[200px]" title="${receiver}">${receiver}</td><td class="px-4 py-3 truncate max-w-[250px]" title="${subject}">${subject}</td><td class="px-4 py-3 text-red-400 font-bold text-center">${score}</td><td class="px-4 py-3 text-right whitespace-nowrap"><button onclick="pmgSpamAction('deliver', '${mail.id}')" class="text-green-500 hover:text-white px-2 py-1.5 bg-green-500/10 hover:bg-green-500 rounded text-xs font-bold transition-colors mr-2">Deliver</button><button onclick="pmgSpamAction('delete', '${mail.id}')" class="text-red-500 hover:text-white px-2 py-1.5 bg-red-500/10 hover:bg-red-500 rounded text-xs font-bold transition-colors">Delete</button></td></tr>`;
});
} else { table.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-red-500">Fehler beim Laden.</td></tr>'; }
} catch(e) { table.innerHTML = '<tr><td colspan="6" class="text-center py-6 text-red-500">Netzwerkfehler.</td></tr>'; }
}
window.pmgSpamAction = async function(actionType, mailId) {
if(actionType === 'delete' && !confirm('Mail unwiderruflich löschen?')) return;
if(actionType === 'deliver' && !confirm('Soll diese Mail an den Empfänger zugestellt werden?')) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('pmg_action', actionType); fd.append('mailid', mailId);
try {
const res = await (await fetch('api.php?action=pmg_quarantine_action', {method: 'POST', body: fd})).json();
if(res.success) loadPmgSpamQueue(document.getElementById('pmgNodeId').value); else alert('Aktion fehlgeschlagen.');
} catch(e) { alert('Netzwerkfehler'); }
}
window.loadPmgTracking = async function() {
const nodeId = document.getElementById('pmgNodeId').value; const internalName = document.getElementById('pmgInternalName').value; const filter = document.getElementById('pmgTrackingFilter').value.trim(); const table = document.getElementById('pmgTrackingTable');
table.innerHTML = '<tr><td colspan="4" class="text-center py-6 text-gray-500 animate-pulse">Lese Syslog (Letzte 24h)...</td></tr>';
try {
const res = await (await fetch(`api.php?action=pmg_get_tracking&node_id=${nodeId}&internal_name=${internalName}&filter=${encodeURIComponent(filter)}`)).json();
if(res.success && res.data) {
table.innerHTML = '';
if(res.data.length === 0) { table.innerHTML = `<tr><td colspan="4" class="text-center py-6 text-gray-500">Keine Logs für diesen Filter gefunden.</td></tr>`; return; }
res.data.forEach(log => {
const timestamp = log.time || log.timestamp || 0; const timeStr = timestamp ? formatDate(timestamp) : 'Unbekannt'; const sender = log.from || log.sender || '-'; const receiver = log.to || log.receiver || '-';
let statusTxt = 'Unbekannt'; let statusColor = 'text-gray-400';
if (log.dstatus) {
if (log.dstatus === 'A') { statusTxt = 'Delivered / Accept'; statusColor = 'text-green-500'; } else if (log.dstatus === 'N') { statusTxt = 'Rejected / Blocked'; statusColor = 'text-red-500'; } else if (log.dstatus === 'Q') { statusTxt = 'Quarantined'; statusColor = 'text-orange-500'; } else if (log.dstatus === 'B') { statusTxt = 'Bounced'; statusColor = 'text-red-500'; } else { statusTxt = log.dstatus; statusColor = 'text-blue-400'; }
} else if (log.relay) { statusTxt = 'Relayed (' + log.relay.split('[')[0] + ')'; statusColor = 'text-blue-400'; } else if (log.msgid) { statusTxt = 'In Processing (' + log.msgid + ')'; statusColor = 'text-gray-400'; } else if (log.client) { statusTxt = 'Connection from ' + log.client.split('[')[0]; statusColor = 'text-gray-500'; } else if (log.id && log.id.length > 5) { statusTxt = 'Queue ID: ' + log.id; statusColor = 'text-gray-400'; } else { statusTxt = JSON.stringify(log).substring(0, 30); }
table.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3 whitespace-nowrap text-gray-400">${timeStr}</td><td class="px-4 py-3 text-white truncate max-w-[200px]">${sender}</td><td class="px-4 py-3 text-gray-300 truncate max-w-[200px]">${receiver}</td><td class="px-4 py-3 font-bold uppercase ${statusColor} truncate max-w-[300px]" title="${statusTxt}">${statusTxt}</td></tr>`;
});
} else { table.innerHTML = '<tr><td colspan="4" class="text-center py-6 text-red-500">Syslog konnte nicht gelesen werden.</td></tr>'; }
} catch(e) { table.innerHTML = '<tr><td colspan="4" class="text-center py-6 text-red-500">Netzwerkfehler.</td></tr>'; }
}
window.loadPmgQueues = async function() {
const nodeId = document.getElementById('pmgNodeId').value; const internalName = document.getElementById('pmgInternalName').value; const table = document.getElementById('pmgQueueTable'); const summary = document.getElementById('pmgQueuesSummary');
table.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-gray-500 animate-pulse">Lese Warteschlangen aus Postfix...</td></tr>'; summary.innerHTML = '<div class="col-span-3 text-center text-gray-500 animate-pulse">Lade...</div>';
try {
const res = await (await fetch(`api.php?action=pmg_get_queues&node_id=${nodeId}&internal_name=${internalName}`)).json();
if(res.success && res.data) {
let activeCount = 0, deferCount = 0, holdCount = 0;
res.data.forEach(q => { const queueName = q.queue_name || q.queue || ''; if(queueName === 'active') activeCount++; else if(queueName === 'deferred') deferCount++; else holdCount++; });
summary.innerHTML = `<div class="bg-darkcard border ${activeCount > 0 ? 'border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.1)]' : 'border-darkborder'} rounded-xl p-5 text-center transition-all"><p class="text-gray-400 text-sm font-bold uppercase mb-1">Active Queue</p><h4 class="text-3xl font-bold ${activeCount > 0 ? 'text-blue-500' : 'text-white'}">${activeCount}</h4></div><div class="bg-darkcard border ${deferCount > 0 ? 'border-orange-500/50 shadow-[0_0_15px_rgba(249,115,22,0.1)]' : 'border-darkborder'} rounded-xl p-5 text-center transition-all"><p class="text-gray-400 text-sm font-bold uppercase mb-1">Deferred (Greylisted/Wartend)</p><h4 class="text-3xl font-bold ${deferCount > 0 ? 'text-orange-500' : 'text-white'}">${deferCount}</h4></div><div class="bg-darkcard border ${holdCount > 0 ? 'border-red-500/50 shadow-[0_0_15px_rgba(239,68,68,0.1)]' : 'border-darkborder'} rounded-xl p-5 text-center"><p class="text-gray-400 text-sm font-bold uppercase mb-1">Other / Hold</p><h4 class="text-3xl font-bold ${holdCount > 0 ? 'text-red-500' : 'text-white'}">${holdCount}</h4></div>`;
table.innerHTML = '';
if(res.data.length === 0) { table.innerHTML = `<tr><td colspan="5" class="text-center py-6 text-gray-500">Alle Postfix-Queues sind komplett leer.</td></tr>`; return; }
res.data.forEach(q => {
const queueName = q.queue_name || q.queue || 'unknown'; const timestamp = q.arrival_time || q.time || 0; const timeStr = timestamp ? formatDate(timestamp) : 'Unbekannt'; const sender = q.sender || '-'; let receiver = q.receiver || q.recipients || '-'; if (Array.isArray(receiver)) receiver = receiver[0]; const reason = q.reason || q.error || '-'; const qColor = queueName === 'deferred' ? 'text-orange-400' : (queueName === 'active' ? 'text-blue-400' : 'text-gray-400');
table.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3 font-bold uppercase ${qColor}">${queueName}</td><td class="px-4 py-3 whitespace-nowrap text-gray-400">${timeStr}</td><td class="px-4 py-3 text-white truncate max-w-[200px]" title="${sender}">${sender}</td><td class="px-4 py-3 text-gray-300 truncate max-w-[200px]" title="${receiver}">${receiver}</td><td class="px-4 py-3 text-xs text-gray-500 truncate max-w-[300px]" title="${reason}">${reason}</td></tr>`;
});
} else { table.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-red-500">Queues konnten nicht gelesen werden.</td></tr>'; }
} catch(e) { table.innerHTML = '<tr><td colspan="5" class="text-center py-6 text-red-500">Netzwerkfehler.</td></tr>'; }
}
window.loadPmgConfig = async function(nodeId) {
try {
const res = await (await fetch(`api.php?action=pmg_get_config&node_id=${nodeId}`)).json();
if(res.success && res.data) {
document.getElementById('pmgCfgRelay').value = res.data.relay || '';
document.getElementById('pmgCfgExtPort').value = res.data.ext_port || 25;
document.getElementById('pmgCfgIntPort').value = res.data.int_port || 26;
document.getElementById('pmgCfgTls').value = res.data.tls || 0;
document.getElementById('pmgCfgTlsLog').value = res.data.tlslog || 0;
}
} catch(e) {}
}
window.savePmgConfig = async function() {
const btn = event.target; const oTxt = btn.innerText; btn.innerText = 'Speichere...';
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('relay', document.getElementById('pmgCfgRelay').value); fd.append('ext_port', document.getElementById('pmgCfgExtPort').value); fd.append('int_port', document.getElementById('pmgCfgIntPort').value); fd.append('tls', document.getElementById('pmgCfgTls').value); fd.append('tlslog', document.getElementById('pmgCfgTlsLog').value);
try { const res = await (await fetch('api.php?action=pmg_set_config', {method: 'POST', body: fd})).json(); if(res.success) { alert('Proxy Einstellungen gespeichert!'); } else { alert('Fehler beim Speichern.'); } } catch(e) { alert('Netzwerkfehler.'); } finally { btn.innerText = oTxt; }
}
window.loadPmgDomains = async function(nodeId) {
const container = document.getElementById('pmgDomainsContainer'); container.innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Lade...</p>';
try {
const res = await (await fetch(`api.php?action=pmg_get_domains&node_id=${nodeId}`)).json();
if(res.success && res.data) {
if(res.data.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm">Keine Domains.</p>'; return; }
container.innerHTML = '';
res.data.forEach(d => { container.innerHTML += `<div class="flex justify-between items-center bg-darkbg p-2 border border-darkborder rounded mb-1"><span class="text-white text-sm font-bold">${d.domain}</span><button onclick="deletePmgDomain('${d.domain}')" class="text-red-500 hover:text-red-400 text-xs font-bold">Löschen</button></div>`; });
} else { container.innerHTML = '<p class="text-red-500 text-sm">Fehler.</p>'; }
} catch(e) {}
}
window.addPmgDomain = async function() {
const domain = document.getElementById('pmgNewDomain').value.trim(); if(!domain) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('domain', domain);
const res = await (await fetch('api.php?action=pmg_add_domain', {method: 'POST', body: fd})).json();
if(res.success) { document.getElementById('pmgNewDomain').value = ''; loadPmgDomains(document.getElementById('pmgNodeId').value); } else alert('Fehler.');
}
window.deletePmgDomain = async function(domain) {
if(!confirm(`Domain ${domain} löschen?`)) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('domain', domain);
const res = await (await fetch('api.php?action=pmg_delete_domain', {method: 'POST', body: fd})).json();
if(res.success) loadPmgDomains(document.getElementById('pmgNodeId').value); else alert('Fehler.');
}
window.loadPmgNetworks = async function(nodeId) {
const container = document.getElementById('pmgNetworksContainer'); container.innerHTML = '<p class="text-gray-500 text-sm animate-pulse">Lade...</p>';
try {
const res = await (await fetch(`api.php?action=pmg_get_networks&node_id=${nodeId}`)).json();
if(res.success && res.data) {
if(res.data.length === 0) { container.innerHTML = '<p class="text-gray-500 text-sm">Keine Netzwerke.</p>'; return; }
container.innerHTML = '';
res.data.forEach(n => { container.innerHTML += `<div class="flex justify-between items-center bg-darkbg p-2 border border-darkborder rounded mb-1"><span class="text-white text-sm font-bold">${n.cidr}</span><button onclick="deletePmgNetwork('${n.cidr}')" class="text-red-500 hover:text-red-400 text-xs font-bold">Löschen</button></div>`; });
} else { container.innerHTML = '<p class="text-red-500 text-sm">Fehler.</p>'; }
} catch(e) {}
}
window.addPmgNetwork = async function() {
const cidr = document.getElementById('pmgNewNetwork').value.trim(); if(!cidr) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('cidr', cidr);
const res = await (await fetch('api.php?action=pmg_add_network', {method: 'POST', body: fd})).json();
if(res.success) { document.getElementById('pmgNewNetwork').value = ''; loadPmgNetworks(document.getElementById('pmgNodeId').value); } else alert('Fehler.');
}
window.deletePmgNetwork = async function(cidr) {
if(!confirm(`Netzwerk ${cidr} löschen?`)) return;
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('cidr', cidr);
const res = await (await fetch('api.php?action=pmg_delete_network', {method: 'POST', body: fd})).json();
if(res.success) loadPmgNetworks(document.getElementById('pmgNodeId').value); else alert('Fehler.');
}
window.loadPmgSsl = async function(nodeId) {
const internalName = document.getElementById('pmgInternalName').value; const container = document.getElementById('pmgSslContainer'); container.innerHTML = '<p class="text-gray-500 animate-pulse text-sm">Lade Zertifikate...</p>';
try {
const res = await (await fetch(`api.php?action=pmg_get_ssl&node_id=${nodeId}&internal_name=${internalName}`)).json();
if(res.success && res.data) {
container.innerHTML = '';
res.data.forEach(cert => {
const validUntil = cert.notafter ? formatDate(cert.notafter) : 'Unbekannt'; const isExpired = cert.notafter && cert.notafter < (Date.now() / 1000); const statusDot = isExpired ? '<span class="w-2 h-2 rounded-full bg-red-500 shrink-0"></span>' : '<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>';
container.innerHTML += `<div class="bg-darkbg border border-darkborder rounded p-3 text-sm flex gap-3"><div class="mt-1">${statusDot}</div><div class="flex-1 overflow-hidden"><h4 class="text-white font-bold mb-1 break-all">${cert.filename || cert.subject || 'Zertifikat'}</h4><p class="text-gray-400 text-xs mb-1"><span class="font-bold text-gray-500">Aussteller:</span> ${cert.issuer || '-'}</p><p class="text-gray-400 text-xs truncate" title="${cert.subject}"><span class="font-bold text-gray-500">Subject:</span> ${cert.subject || '-'}</p><div class="mt-2 text-xs font-bold ${isExpired ? 'text-red-400' : 'text-emerald-400'} bg-darkcard inline-block px-2 py-1 rounded border border-darkborder">Ablaufdatum: ${validUntil}</div></div></div>`;
});
} else { container.innerHTML = '<p class="text-red-500 text-sm">Fehler beim Laden.</p>'; }
} catch(e) { container.innerHTML = '<p class="text-red-500 text-sm">Netzwerkfehler.</p>'; }
}
window.uploadPmgSsl = async function() {
const cert = document.getElementById('pmgSslCert').value.trim(); const key = document.getElementById('pmgSslKey').value.trim();
if(!cert || !key) return alert('Bitte Key und Zertifikat (PEM) einfügen!');
const btn = event.target; const oTxt = btn.innerText; btn.innerText = 'Lade hoch...';
const fd = new FormData(); fd.append('node_id', document.getElementById('pmgNodeId').value); fd.append('internal_name', document.getElementById('pmgInternalName').value); fd.append('certificate', cert); fd.append('private_key', key);
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');
const select = document.getElementById('cronNodeId'); select.innerHTML = '<option value="">Lade Hosts...</option>';
try {
const res = await (await fetch('api.php?action=get_nodes')).json();
if (res.success) {
select.innerHTML = '<option value="">-- PVE Node auswählen --</option>';
res.data.filter(n => n.type === 'pve').forEach(node => { select.innerHTML += `<option value="${node.id}">${node.name} (${node.ip_address})</option>`; });
}
} catch(e) {}
loadCronJobs();
}
window.closeCronManager = function() { cronModal.classList.add('hidden'); }
window.checkCronAction = function() { const action = document.getElementById('cronAction').value; const vmBlock = document.getElementById('cronVmBlock'); if (action.includes('_vm')) vmBlock.classList.remove('hidden'); else vmBlock.classList.add('hidden'); } if(document.getElementById('setVmid')) document.getElementById('setVmid').value = vmid;
if(document.getElementById('setHost')) document.getElementById('setHost').value = host;
async function loadCronJobs() { if(document.getElementById('setType')) document.getElementById('setType').value = type;
const tbody = document.getElementById('cronTableBody'); tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-4 animate-pulse">Lade Jobs...</td></tr>'; if(document.getElementById('setNodeId')) document.getElementById('setNodeId').value = nodeId;
try {
const res = await (await fetch('api.php?action=get_cron_jobs')).json(); if(document.getElementById('snapshotListContainer')) document.getElementById('snapshotListContainer').innerHTML = '<div class="text-gray-500 text-sm italic">Lade Snapshots...</div>';
if (res.success) { if(document.getElementById('backupListContainer')) document.getElementById('backupListContainer').innerHTML = '<div class="text-gray-500 text-sm italic">Lade Backups...</div>';
tbody.innerHTML = '';
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="6" class="text-center text-gray-500 py-4">Keine geplanten Jobs.</td></tr>'; return; } if(type === 'qemu' && document.getElementById('setIso')) {
res.data.forEach(job => { document.getElementById('setIso').innerHTML = '<option value="">Lade ISOs...</option>';
const lastRun = formatDate(job.last_run); const isActive = parseInt(job.is_active) === 1; fetch(`api.php?action=get_isos&node_id=${nodeId}&host=${host}`).then(r=>r.json()).then(isoRes => {
const statusDot = isActive ? '<span class="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_5px_#22c55e]"></span> Aktiv' : '<span class="w-2 h-2 rounded-full bg-gray-600"></span> Pausiert'; const s = document.getElementById('setIso');
const targetStr = job.action_type.includes('_vm') ? `VM ${job.target_vmid}` : 'Gesamter Host'; if(s) {
let actionStr = job.action_type; s.innerHTML = '<option value="none">Kein ISO gemountet (CD auswerfen)</option>';
if(actionStr === 'reboot_node') actionStr = 'Host Reboot'; if(actionStr === 'start_vm') actionStr = 'VM Start'; if(actionStr === 'stop_vm') actionStr = 'VM Stop'; if(actionStr === 'reboot_vm') actionStr = 'VM Reboot'; if(isoRes.success) { isoRes.data.forEach(iso => s.innerHTML += `<option value="${iso}">${iso}</option>`); }
tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"><td class="px-4 py-3 whitespace-nowrap cursor-pointer" onclick="toggleCronJob(${job.id}, ${isActive ? 0 : 1})"><div class="flex items-center gap-2 text-xs font-bold text-gray-300 hover:text-white">${statusDot}</div></td><td class="px-4 py-3"><p class="text-white font-bold">${job.name}</p><p class="text-xs text-proxmox font-bold">${actionStr}</p></td><td class="px-4 py-3"><p class="text-gray-300">${job.node_name}</p><p class="text-xs text-gray-500">Ziel: ${targetStr}</p></td><td class="px-4 py-3 font-mono text-blue-400 font-bold tracking-widest">${job.cron_schedule}</td><td class="px-4 py-3 text-gray-400 whitespace-nowrap">${lastRun}</td><td class="px-4 py-3 text-right"><button onclick="deleteCronJob(${job.id})" class="text-red-500 hover:text-white px-2 py-1 bg-red-500/10 hover:bg-red-500 rounded text-xs font-bold transition-colors">Löschen</button></td></tr>`;
});
} }
} catch(e) {}
}
const addCronForm = document.getElementById('addCronJobForm');
if(addCronForm) {
addCronForm.addEventListener('submit', async function(e) {
e.preventDefault(); const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Speichere...';
const fd = new FormData(); fd.append('name', document.getElementById('cronName').value); fd.append('node_id', document.getElementById('cronNodeId').value); fd.append('action_type', document.getElementById('cronAction').value); fd.append('target_vmid', document.getElementById('cronTargetVmid').value); fd.append('cron_schedule', document.getElementById('cronSchedule').value);
try { const res = await (await fetch('api.php?action=add_cron_job', {method: 'POST', body: fd})).json(); if(res.success) { addCronForm.reset(); document.getElementById('cronSchedule').value = '0 3 * * *'; checkCronAction(); loadCronJobs(); } else alert('Fehler.'); } catch(e) {} finally { btn.innerText = oTxt; }
}); });
} }
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(); } try {
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(); } const res = await (await fetch(`api.php?action=get_vm_config&vmid=${vmid}&host=${host}&type=${type}&node_id=${nodeId}`)).json();
if (res.success && res.data) {
// === NEU: AUDIT LOG === const cfg = res.data;
const auditModal = document.getElementById('auditLogModal'); if(document.getElementById('setMemory')) document.getElementById('setMemory').value = cfg.memory || '';
window.openAuditLog = async function() { if(document.getElementById('setCores')) document.getElementById('setCores').value = cfg.cores || 1;
auditModal.classList.remove('hidden'); if(document.getElementById('setOnboot')) document.getElementById('setOnboot').checked = (cfg.onboot == 1);
const tbody = document.getElementById('auditLogTableBody'); if(cfg.ide2 && cfg.ide2 !== 'none,media=cdrom' && document.getElementById('setIso')) { const parts = cfg.ide2.split(','); document.getElementById('setIso').value = parts[0]; }
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-500 py-4 animate-pulse">Lade Protokoll...</td></tr>';
try { if (cfg.net0) {
const res = await (await fetch('api.php?action=get_audit_logs')).json(); if(document.getElementById('setRawNet0')) document.getElementById('setRawNet0').value = cfg.net0;
if (res.success) { const badge = document.getElementById('netStatusBadge');
tbody.innerHTML = ''; const btn = document.getElementById('btnToggleNet');
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-gray-500 py-4">Noch keine Einträge.</td></tr>'; return; } if(badge && btn) {
res.data.forEach(log => { if (cfg.net0.includes('link_down=1')) {
// Konvertiere SQLite DATETIME in lokales deutsches Format badge.className = 'px-2 py-1 rounded text-xs font-bold text-red-400 bg-red-500/20'; badge.innerText = 'Getrennt';
const d = new Date(log.timestamp + 'Z'); btn.innerText = 'Kabel einstecken'; btn.onclick = () => window.saveNetwork(false);
const timeStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE'); } else {
const isSystem = log.username === 'System'; badge.className = 'px-2 py-1 rounded text-xs font-bold text-green-400 bg-green-500/20'; badge.innerText = 'Verbunden';
btn.innerText = 'Kabel ziehen'; btn.onclick = () => window.saveNetwork(true);
tbody.innerHTML += ` }
<tr class="hover:bg-darkcard/50 transition-colors border-b border-darkborder/50"> }
<td class="px-4 py-3 text-gray-400 whitespace-nowrap text-xs">${timeStr}</td> } else {
<td class="px-4 py-3"><span class="${isSystem ? 'text-gray-500' : 'text-blue-400 font-bold'}">${log.username}</span></td> if(document.getElementById('netStatusBadge')) document.getElementById('netStatusBadge').innerText = 'Kein net0';
<td class="px-4 py-3 font-bold text-white">${log.action}</td> if(document.getElementById('btnToggleNet')) document.getElementById('btnToggleNet').style.display = 'none';
<td class="px-4 py-3 text-gray-300 text-xs">${log.target || '-'}</td> }
</tr>`;
}); let diskName = ''; if (type === 'lxc' && cfg.rootfs) diskName = 'rootfs'; else if (cfg.scsi0) diskName = 'scsi0'; else if (cfg.virtio0) diskName = 'virtio0'; else if (cfg.ide0) diskName = 'ide0';
if (diskName) {
if(document.getElementById('setPrimaryDisk')) document.getElementById('setPrimaryDisk').value = diskName;
if(document.getElementById('diskLabelName')) document.getElementById('diskLabelName').innerText = diskName;
} else {
if(document.getElementById('diskLabelName')) document.getElementById('diskLabelName').innerText = 'Nicht gefunden';
} }
} catch(e) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-red-500 py-4">Fehler beim Laden.</td></tr>'; } }
// Helfer-Funktionen aufrufen (Definitionen siehe unten)
if(typeof window.loadSnapshots === 'function') window.loadSnapshots(vmid, host, type, nodeId);
const stRes = await (await fetch(`api.php?action=get_backup_storages&node_id=${nodeId}&host=${host}`)).json();
if (stRes.success && document.getElementById('backupTargetStorage')) {
const bSelect = document.getElementById('backupTargetStorage');
bSelect.innerHTML = '';
stRes.data.forEach(st => bSelect.innerHTML += `<option value="${st}">${st}</option>`);
}
if(typeof window.loadBackups === 'function') window.loadBackups(vmid, host, nodeId);
} catch (e) {}
}
window.closeVmSettings = function() { const m = document.getElementById('vmSettingsModal'); if(m) m.classList.add('hidden'); }
window.sendVmCommand = async function(vmid, host, type, cmd, nodeId) {
if(!confirm(`Maschine '${vmid}' wirklich ${cmd}?`)) return;
const fd = new FormData(); fd.append('vmid', vmid); fd.append('host', host); fd.append('type', type); fd.append('cmd', cmd); fd.append('node_id', nodeId);
try {
const res = await (await fetch('api.php?action=vm_action', { method: 'POST', body: fd })).json();
if(res.success) { setTimeout(() => { if(window.currentSelectedHost === host && !document.getElementById('tab-node-view').classList.contains('hidden')) { window.openNodeView(nodeId, host); } else if(typeof window.loadVmsIntoTable === 'function') { window.loadVmsIntoTable(); } }, 2000); }
else alert(res.error);
} catch (err) {}
}
// ---------------------------------------------------------
// 4. MAIN INIT LOGIC (Nur wenn eingeloggt)
// ---------------------------------------------------------
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
// Global Stats Polling
window.fetchGlobalStats = async function() {
if(document.getElementById('tab-pve') && document.getElementById('tab-pve').classList.contains('hidden')) return;
try {
const res = await (await fetch('api.php?action=get_stats')).json();
if(res.success && res.data) {
const d = res.data;
if(document.getElementById('stat-cpu-text')) document.getElementById('stat-cpu-text').innerText = `${d.cpu_percent}% (${d.cpu_cores} Cores)`;
if(document.getElementById('stat-cpu-bar')) document.getElementById('stat-cpu-bar').style.width = `${d.cpu_percent}%`;
let ramPercent = (d.ram_used / d.ram_total) * 100 || 0;
if(document.getElementById('stat-ram-text')) document.getElementById('stat-ram-text').innerText = `${formatBytes(d.ram_used)} / ${formatBytes(d.ram_total)}`;
if(document.getElementById('stat-ram-bar')) document.getElementById('stat-ram-bar').style.width = `${ramPercent}%`;
let diskPercent = (d.disk_used / d.disk_total) * 100 || 0;
if(document.getElementById('stat-disk-text')) document.getElementById('stat-disk-text').innerText = `${formatBytes(d.disk_used)} / ${formatBytes(d.disk_total)}`;
if(document.getElementById('stat-disk-bar')) document.getElementById('stat-disk-bar').style.width = `${diskPercent}%`;
if (d.cluster_stats) {
const elNodesOn = document.getElementById('stat-nodes-online');
if (elNodesOn) { elNodesOn.innerText = d.cluster_stats.nodes_online; elNodesOn.className = (d.cluster_stats.nodes_online < d.cluster_stats.nodes_total) ? 'text-red-500' : '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) {}
} }
window.closeAuditLog = function() { auditModal.classList.add('hidden'); }
// Zusätzliche API Polling Funktionen
window.fetchTopVms = async function() { if(!document.getElementById('top-vms-container') || (document.getElementById('tab-pve') && document.getElementById('tab-pve').classList.contains('hidden'))) 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 = '<p class="text-gray-400 text-sm">Keine aktiven VMs.</p>'; 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 += `<div class="bg-darkbg border border-darkborder rounded-lg p-3 flex justify-between items-center transition-transform hover:scale-[1.02] cursor-default"><div class="flex items-center gap-3"><span class="font-bold text-xl ${numberColor}">#${i + 1}</span><div><h4 class="text-white font-semibold text-sm truncate w-32">${icon} ${vm.name}</h4><p class="text-xs text-gray-500">Host: ${vm.host}</p></div></div><div class="text-right"><p class="text-proxmox font-bold text-sm">${cpuPercent}% CPU</p><p class="text-xs text-gray-400">${ramUsed} RAM</p></div></div>`; }); } } catch (err) {} }
window.fetchRecentJobs = async function() { 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 = '<p class="text-gray-400 text-sm">Keine aktuellen Jobs.</p>'; 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 += `<div class="bg-darkbg border border-darkborder rounded-lg p-3 flex justify-between items-center transition-colors hover:bg-darkborder/50"><div class="flex items-center gap-3"><div class="text-lg">${statusIcon}</div><div class="max-w-[120px]"><p class="${jobTypeColor} font-medium text-sm capitalize truncate" title="${jobTypeStr}">${jobTypeStr}</p><p class="text-xs text-gray-500 truncate" title="${job.node_name}">Host: <span class="text-proxmox">${job.node_name}</span></p></div></div><div class="text-right"><p class="${statusColor} font-bold text-sm uppercase">${statusText}</p><p class="text-xs text-gray-500">${dateStr} - ${timeStr}</p></div></div>`; }); } } catch (err) {} }
// Start Intervals
setInterval(window.fetchGlobalStats, 10000);
setInterval(window.fetchTopVms, 10000);
setInterval(window.fetchRecentJobs, 15000);
// Fallback Initial Calls
window.fetchGlobalStats();
window.fetchTopVms();
window.fetchRecentJobs();
// Event Listener für Formulare (Create VM)
const createVmForm = document.getElementById('createVmForm');
if(createVmForm) {
createVmForm.addEventListener('submit', async function(e) {
e.preventDefault();
const hostDataStr = document.getElementById('createVmHost').value;
if(!hostDataStr) return;
const hostData = JSON.parse(hostDataStr);
const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Richte VM ein...';
const fd = new FormData(); fd.append('node_id', hostData.id); fd.append('host', hostData.host); fd.append('name', document.getElementById('createVmName').value); fd.append('memory', document.getElementById('createVmRam').value); fd.append('cores', document.getElementById('createVmCores').value);
try {
const res = await (await fetch('api.php?action=create_vm', { method: 'POST', body: fd })).json();
if(res.success) {
alert(`Erfolgreich! VM-ID: ${res.vmid}`); createVmForm.reset(); window.closeCreateVm();
if(window.currentSelectedHost === hostData.host && !document.getElementById('tab-node-view').classList.contains('hidden')) {
window.openNodeView(hostData.id, hostData.host);
}
} else alert(res.error);
} catch(e) {} finally { btn.innerText = oTxt; }
});
}
// --- ALLE WEITEREN PBS / PMG / ADMIN MODAL FUNKTIONEN ---
// (Diese Funktionen werden erst aufgerufen, wenn die entsprechenden Menüs geklickt werden,
// daher definieren wir sie einfach am Ende, falls sie existieren.)
window.openUserManager = function() { const m = document.getElementById('userManagerModal'); if(m) { m.classList.remove('hidden'); if(typeof loadUsersIntoTable === 'function') loadUsersIntoTable(); } }
window.closeUserManager = function() { const m = document.getElementById('userManagerModal'); if(m) m.classList.add('hidden'); }
window.openNodeTopology = function() { const m = document.getElementById('nodeTopologyModal'); if(m) m.classList.remove('hidden'); /* ... Lade Logik ... */ }
window.closeNodeTopology = function() { const m = document.getElementById('nodeTopologyModal'); if(m) m.classList.add('hidden'); }
window.openAuditLog = function() { const m = document.getElementById('auditLogModal'); if(m) m.classList.remove('hidden'); }
window.closeAuditLog = function() { const m = document.getElementById('auditLogModal'); if(m) m.classList.add('hidden'); }
window.openCronManager = function() { const m = document.getElementById('cronManagerModal'); if(m) m.classList.remove('hidden'); }
window.closeCronManager = function() { const m = document.getElementById('cronManagerModal'); if(m) m.classList.add('hidden'); }
window.openPmgManager = function(nodeId, hostName, internalName) { const m = document.getElementById('pmgManagerModal'); if(m) m.classList.remove('hidden'); }
window.closePmgManager = function() { const m = document.getElementById('pmgManagerModal'); if(m) m.classList.add('hidden'); }
window.openPbsDatastore = function(nodeId, storeName) { const m = document.getElementById('pbsDatastoreModal'); if(m) m.classList.remove('hidden'); }
window.closePbsDatastore = function() { const m = document.getElementById('pbsDatastoreModal'); if(m) m.classList.add('hidden'); }
// VM List Modal
window.openVmManager = async function() {
const m = document.getElementById('vmManagerModal'); if(!m) return; m.classList.remove('hidden');
const tbody = document.getElementById('vmTableBody'); if(!tbody) return; tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center animate-pulse text-gray-500">Lade VMs...</td></tr>';
try {
const res = await (await fetch('api.php?action=get_all_vms')).json();
if(res.success) {
tbody.innerHTML = '';
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center text-gray-500">Keine VMs.</td></tr>'; return; }
res.data.forEach(vm => {
const isRunning = vm.status === 'running'; const typeStr = vm.type === 'lxc' ? '📦 LXC' : '🖥️ QEMU'; const statusBadge = isRunning ? '<span class="text-green-500 font-bold">Online</span>' : '<span class="text-red-500 font-bold">Offline</span>';
let actionButtons = isRunning ? `<button onclick="window.sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'stop', ${vm.node_id})" class="text-red-500 hover:text-red-400 px-2 text-lg transition-colors">⏹️</button>` : `<button onclick="window.sendVmCommand('${vm.vmid}', '${vm.host}', '${vm.type}', 'start', ${vm.node_id})" class="text-green-500 hover:text-green-400 px-2 text-lg transition-colors">▶️</button>`;
actionButtons += `<button onclick="window.openVmSettings('${vm.vmid}', '${vm.host}', '${vm.type}', ${vm.node_id}, '${vm.name}')" class="text-gray-400 hover:text-white px-2 ml-2 border-l border-darkborder text-lg transition-colors">⚙️</button>`;
tbody.innerHTML += `<tr class="border-b border-darkborder/50 hover:bg-darkbg transition-colors"><td class="py-2 px-3 text-white font-mono">${vm.vmid} <span class="font-bold font-sans ml-2">${vm.name}</span></td><td class="py-2 px-3 text-gray-400">${typeStr} - ${vm.host}</td><td class="py-2 px-3 text-gray-400">${vm.maxcpu||1} C / ${formatBytes(vm.maxmem||0)}</td><td class="py-2 px-3">${statusBadge}</td><td class="py-2 px-3 text-right">${actionButtons}</td></tr>`;
});
}
} catch (err) {}
}
window.closeVmManager = function() { const m = document.getElementById('vmManagerModal'); if(m) m.classList.add('hidden'); }
// Server & API Modal
window.openNodeManager = async function() {
const m = document.getElementById('nodeManagerModal'); if(!m) return; m.classList.remove('hidden');
try {
const res = await (await fetch('api.php?action=get_nodes')).json();
if(res.success) {
const tbody = document.getElementById('nodeTableBody'); if(!tbody) return; tbody.innerHTML = '';
if(res.data.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center">Keine Server.</td></tr>'; return; }
res.data.forEach(node => {
let typeBadge = node.type === 'pbs' ? '<span class="bg-purple-500/20 text-pbs px-2 py-0.5 rounded text-xs font-bold">PBS Backup</span>' : (node.type === 'pmg' ? '<span class="bg-blue-500/20 text-pmg px-2 py-0.5 rounded text-xs font-bold">PMG Mail</span>' : '<span class="bg-proxmox/20 text-proxmox px-2 py-0.5 rounded text-xs font-bold">PVE Node</span>');
tbody.innerHTML += `<tr class="hover:bg-darkcard/50 transition-colors"><td class="px-4 py-3 font-medium text-white">${node.name}</td><td class="px-4 py-3">${typeBadge}</td><td class="px-4 py-3">${node.ip_address}</td><td class="px-4 py-3 text-right"><button onclick="deleteNode(${node.id}, '${node.name}')" class="text-red-500 hover:text-red-400 text-sm font-medium">Löschen</button></td></tr>`;
});
}
} catch (err) {}
}
window.closeNodeManager = function() { const m = document.getElementById('nodeManagerModal'); if(m) m.classList.add('hidden'); }
window.toggleAddNodeForm = function() { const f = document.getElementById('addNodeFormContainer'); if(f) f.classList.toggle('hidden'); }
} }
+12 -10
View File
@@ -2,10 +2,14 @@
// /home/docker/pve_dashboard/src/cron.php // /home/docker/pve_dashboard/src/cron.php
require_once __DIR__ . '/db.php'; require_once __DIR__ . '/db.php';
// Hilfsfunktion: Prüft, ob ein Cron-Ausdruck (z.B. "0 3 * * *") zur aktuellen Zeit passt // FIX 1: Harte Zeitzone für die PHP-CLI (überschreibt Docker UTC)
date_default_timezone_set('Europe/Berlin');
function isCronMatch($cron, $time = null) { function isCronMatch($cron, $time = null) {
if ($time === null) $time = time(); if ($time === null) $time = time();
$cronParts = explode(' ', trim($cron));
// FIX 2: Ignoriert beliebig viele Leerzeichen zwischen den Werten
$cronParts = preg_split('/\s+/', trim($cron));
if (count($cronParts) !== 5) return false; if (count($cronParts) !== 5) return false;
list($min, $hour, $day, $month, $weekday) = $cronParts; list($min, $hour, $day, $month, $weekday) = $cronParts;
@@ -19,8 +23,11 @@ function isCronMatch($cron, $time = null) {
function matchCronPart($part, $current) { function matchCronPart($part, $current) {
if ($part === '*') return true; 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" // FIX 3: Sauberer Integer-Vergleich für führende Nullen ("00" wird zu 0)
if (is_numeric($part) && (int)$part === (int)$current) return true;
if (strpos($part, '*/') === 0) {
$step = (int)substr($part, 2); $step = (int)substr($part, 2);
return $step > 0 && ((int)$current % $step) === 0; return $step > 0 && ((int)$current % $step) === 0;
} }
@@ -39,12 +46,11 @@ function cronProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $method = "POST
if ($postData) $options[CURLOPT_POSTFIELDS] = http_build_query($postData); if ($postData) $options[CURLOPT_POSTFIELDS] = http_build_query($postData);
curl_setopt_array($ch, $options); curl_setopt_array($ch, $options);
$res = curl_exec($ch); curl_close($ch); $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"; 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"); $stmt = $pdo->query("SELECT * FROM scheduled_tasks WHERE is_active = 1");
$tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); $tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
@@ -59,7 +65,6 @@ foreach ($tasks as $task) {
if ($node) { if ($node) {
$success = false; $success = false;
// AKTION: PVE NODE NEUSTART
if ($task['action_type'] === 'reboot_node') { if ($task['action_type'] === 'reboot_node') {
$nData = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'GET'); $nData = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'GET');
if (isset($nData['data'][0]['node'])) { if (isset($nData['data'][0]['node'])) {
@@ -68,9 +73,7 @@ foreach ($tasks as $task) {
$success = true; $success = true;
} }
} }
// AKTION: VM START / STOP / REBOOT
elseif (in_array($task['action_type'], ['start_vm', 'stop_vm', 'reboot_vm'])) { 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'); $vmsRes = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm", 'GET');
if (isset($vmsRes['data'])) { if (isset($vmsRes['data'])) {
foreach ($vmsRes['data'] as $vm) { foreach ($vmsRes['data'] as $vm) {
@@ -85,7 +88,6 @@ foreach ($tasks as $task) {
} }
} }
// Setze den Zeitstempel für den letzten Durchlauf
if ($success) { if ($success) {
$uStmt = $pdo->prepare("UPDATE scheduled_tasks SET last_run = ? WHERE id = ?"); $uStmt = $pdo->prepare("UPDATE scheduled_tasks SET last_run = ? WHERE id = ?");
$uStmt->execute([time(), $task['id']]); $uStmt->execute([time(), $task['id']]);
+30 -8
View File
@@ -1,10 +1,11 @@
<?php <?php
// /home/docker/pve_dashboard/src/db.php
session_start(); session_start();
try { try {
// Hier ist der Fix: Der Pfad zeigt wieder auf das ausgelagerte /data Verzeichnis!
$pdo = new PDO('sqlite:' . __DIR__ . '/../data/dashboard.sqlite'); $pdo = new PDO('sqlite:' . __DIR__ . '/../data/dashboard.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 1. User Tabelle
$pdo->exec("CREATE TABLE IF NOT EXISTS users ( $pdo->exec("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -14,6 +15,7 @@ try {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)"); )");
// 2. Nodes Tabelle
$pdo->exec("CREATE TABLE IF NOT EXISTS nodes ( $pdo->exec("CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -23,19 +25,39 @@ try {
type TEXT DEFAULT 'pve' type TEXT DEFAULT 'pve'
)"); )");
// Automatisches Datenbank-Upgrade für den PBS! (Fügt Spalte 'type' hinzu, falls sie fehlt) // Automatisches Upgrade für ältere Versionen
try { try { $pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'"); } catch (PDOException $e) {}
$pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'");
} catch (PDOException $e) {
// Ignorieren, wenn die Spalte bereits existiert
}
// 3. NEU: Task Scheduler Tabelle
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NOT NULL,
action_type TEXT NOT NULL,
target_vmid TEXT,
cron_schedule TEXT NOT NULL,
last_run INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// 4. NEU: Audit Logs Tabelle
$pdo->exec("CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT,
action TEXT,
target TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)");
// Standard-Admin anlegen, falls noch keine User existieren
$stmt = $pdo->query("SELECT COUNT(*) FROM users"); $stmt = $pdo->query("SELECT COUNT(*) FROM users");
if ($stmt->fetchColumn() == 0) { if ($stmt->fetchColumn() == 0) {
$hash = password_hash('admin', PASSWORD_DEFAULT); $hash = password_hash('admin', PASSWORD_DEFAULT);
$pdo->exec("INSERT INTO users (username, password_hash, role) VALUES ('admin', '$hash', 'admin')"); $pdo->exec("INSERT INTO users (username, password_hash, role) VALUES ('admin', '$hash', 'admin')");
} }
} catch (PDOException $e) { } catch (PDOException $e) {
die("Datenbank-Fehler: " . $e->getMessage()); die(json_encode(['success' => false, 'error' => "Datenbank-Fehler: " . $e->getMessage()]));
} }
?> ?>
+103 -53
View File
@@ -1,4 +1,5 @@
<?php <?php
// /home/docker/pve_dashboard/src/index.php
require_once 'db.php'; require_once 'db.php';
$isLoggedIn = isset($_SESSION['user_id']); $isLoggedIn = isset($_SESSION['user_id']);
$stmt = $pdo->query("SELECT COUNT(*) FROM nodes"); $stmt = $pdo->query("SELECT COUNT(*) FROM nodes");
@@ -37,94 +38,143 @@ $nodeCount = $stmt->fetchColumn();
<?php if ($isLoggedIn): ?> <?php if ($isLoggedIn): ?>
<div class="flex items-center gap-4 text-sm"> <div class="flex items-center gap-4 text-sm">
<span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span> <span class="text-gray-400">Hallo, <span class="text-white font-bold"><?= htmlspecialchars($_SESSION['username']) ?></span></span>
<button onclick="openPasswordModal()" class="text-gray-400 hover:text-white transition-colors" title="Passwort ändern">🔑</button>
<button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button> <button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button>
</div> </div>
<?php else: ?>
<div class="text-sm text-gray-400">System Status: <span class="text-green-500 font-bold">Sicher</span></div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</header> </header>
<main class="flex-grow p-6 flex items-center justify-center max-w-[1600px] w-full mx-auto"> <main class="flex-grow p-6 flex items-center justify-center max-w-[1600px] w-full mx-auto">
<?php if (!$isLoggedIn): ?> <?php if (!$isLoggedIn): ?>
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl p-8 max-w-sm w-full"><div class="flex justify-center mb-6"><div class="w-12 h-12 rounded bg-proxmox/10 flex items-center justify-center text-proxmox shadow-lg"><svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg></div></div><h2 class="text-2xl font-bold mb-2 text-center text-white">Login</h2><form id="loginForm" class="space-y-4"><div><label class="block text-sm font-medium text-gray-400 mb-1">Benutzername</label><input type="text" id="loginUser" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Passwort</label><input type="password" id="loginPass" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-3 px-4 rounded-lg transition-colors mt-4 shadow-lg shadow-orange-900/50">Einloggen</button></form></div> <!-- Login -->
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl p-8 max-w-sm w-full"><h2 class="text-2xl font-bold mb-2 text-center text-white">Login</h2><form id="loginForm" class="space-y-4"><div><label class="block text-sm font-medium text-gray-400 mb-1">Benutzer</label><input type="text" id="loginUser" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Passwort</label><input type="password" id="loginPass" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-3 px-4 rounded-lg mt-4">Einloggen</button></form></div>
<?php elseif ($nodeCount == 0): ?> <?php elseif ($nodeCount == 0): ?>
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl p-8 max-w-md w-full"><h2 class="text-2xl font-bold mb-2 text-white">Willkommen! 👋</h2><p class="text-gray-400 text-sm mb-6">Verbinde deinen ersten Host, um zu starten.</p><form id="setupForm" class="space-y-4"><div><label class="block text-sm font-medium text-gray-400 mb-1">Anzeigename</label><input type="text" id="nodeName" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">IP-Adresse</label><input type="text" id="nodeIp" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Benutzername</label><input type="text" id="nodeUser" value="root@pam" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Passwort</label><input type="password" id="nodePass" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white focus:outline-none focus:border-proxmox" required></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-3 px-4 rounded-lg transition-colors mt-6 shadow-lg shadow-orange-900/50">Node verbinden</button></form></div> <!-- Setup -->
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl p-8 max-w-md w-full"><h2 class="text-2xl font-bold mb-2 text-white">Willkommen! 👋</h2><form id="setupForm" class="space-y-4"><div><label class="block text-sm font-medium text-gray-400 mb-1">Name</label><input type="text" id="nodeName" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">IP</label><input type="text" id="nodeIp" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Benutzer</label><input type="text" id="nodeUser" value="root@pam" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><div><label class="block text-sm font-medium text-gray-400 mb-1">Passwort</label><input type="password" id="nodePass" class="w-full bg-darkbg border border-darkborder rounded p-2.5 text-white" required></div><button type="submit" class="w-full bg-proxmox text-white font-bold py-3 rounded-lg mt-6">Node verbinden</button></form></div>
<?php else: ?> <?php else: ?>
<div class="w-full h-[85vh] flex bg-darkbg overflow-hidden rounded-xl border border-darkborder shadow-2xl"> <div class="w-full h-[85vh] flex bg-darkbg overflow-hidden rounded-xl border border-darkborder shadow-2xl">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside class="w-64 bg-darkcard border-r border-darkborder flex flex-col"> <aside class="w-64 bg-darkcard border-r border-darkborder flex flex-col shrink-0">
<div class="p-4 border-b border-darkborder"><span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Dashboards</span></div> <nav class="flex-1 overflow-y-auto space-y-2 py-4">
<nav class="flex-1 overflow-y-auto p-4 space-y-2"> <div class="px-4 mb-2"><span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Cluster Übersicht</span></div>
<a href="#" id="nav-tab-pve" onclick="switchTab('pve')" class="tab-active flex items-center gap-3 text-gray-400 hover:text-white px-3 py-2 rounded-r-lg transition-colors"><svg class="w-5 h-5 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg> PVE Cluster</a> <a href="#" id="nav-tab-pve" onclick="switchTab('pve')" class="tab-active flex items-center gap-3 text-gray-400 hover:text-white px-3 py-2 rounded-r-lg transition-colors">📊 PVE Cluster</a>
<a href="#" id="nav-tab-pbs" onclick="switchTab('pbs')" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-r-lg transition-colors"><svg class="w-5 h-5 text-pbs" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"></path></svg> Backup Server (PBS)</a>
<a href="#" id="nav-tab-pmg" onclick="switchTab('pmg')" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-r-lg transition-colors"><svg class="w-5 h-5 text-pmg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg> Mail Gateway (PMG)</a> <div class="mt-4 border-t border-darkborder pt-4">
<div class="px-4 mb-2"><span class="text-xs font-bold text-gray-500 uppercase tracking-wider">PVE Nodes</span></div>
<div id="sidebar-pve-nodes" class="space-y-1">
<!-- Nodes per JS -->
</div>
</div>
<div class="pt-4 mt-4 border-t border-darkborder space-y-2"> <div class="mt-4 border-t border-darkborder pt-4 space-y-2">
<a href="#" onclick="openNodeTopology()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg> Cluster Topologie</a> <div class="px-4 mb-2"><span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Erweiterungen</span></div>
<a href="#" onclick="openVmManager()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg> VMs verwalten</a> <a href="#" id="nav-tab-pbs" onclick="switchTab('pbs')" class="flex items-center gap-3 text-gray-400 hover:text-white px-3 py-2 rounded-r-lg">🛡️ Backup Server (PBS)</a>
<a href="#" onclick="openNodeManager()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg> Server & API</a> <a href="#" id="nav-tab-pmg" onclick="switchTab('pmg')" class="flex items-center gap-3 text-gray-400 hover:text-white px-3 py-2 rounded-r-lg">✉️ Mail Gateway (PMG)</a>
<?php if(($_SESSION['role'] ?? '') === 'admin'): ?>
<a href="#" onclick="openCronManager()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3"></path></svg> Task Scheduler</a>
<a href="#" onclick="openUserManager()" class="flex items-center gap-3 text-gray-400 hover:text-white hover:bg-white/5 px-3 py-2 rounded-lg transition-colors"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg> Benutzerverwaltung</a>
<?php endif; ?>
</div> </div>
</nav> </nav>
<div class="p-4 border-t border-darkborder space-y-2 text-sm">
<a href="#" onclick="openNodeTopology()" class="block text-gray-400 hover:text-white">Cluster Topologie</a>
<a href="#" onclick="openVmManager()" class="block text-gray-400 hover:text-white">VMs verwalten</a>
<a href="#" onclick="openNodeManager()" class="block text-gray-400 hover:text-white">Server & API</a>
<?php if(($_SESSION['role'] ?? '') === 'admin'): ?>
<a href="#" onclick="openCronManager()" class="block text-gray-400 hover:text-white">Task Scheduler</a>
<a href="#" onclick="openUserManager()" class="block text-gray-400 hover:text-white">Benutzerverwaltung</a>
<?php endif; ?>
</div>
</aside> </aside>
<div class="flex-1 flex flex-col overflow-hidden relative"> <div class="flex-1 flex flex-col overflow-hidden relative">
<main class="absolute inset-0 overflow-x-hidden overflow-y-auto bg-darkbg p-6 flex gap-6 w-full"> <main class="absolute inset-0 overflow-x-hidden overflow-y-auto bg-darkbg p-6 flex gap-6 w-full">
<!-- TAB 1: PVE --> <!-- TAB 1: PVE CLUSTER (Global) -->
<div id="tab-pve" class="flex-1 flex flex-col min-w-0 transition-opacity duration-300"> <div id="tab-pve" class="flex-1 flex flex-col min-w-0 transition-opacity duration-300">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6"> <!-- Top Kachel Cluster Status -->
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><div class="flex justify-between items-start mb-4"><div><p class="text-gray-400 text-sm font-medium">Cluster CPU Cores</p><h3 id="stat-cpu-text" class="text-2xl font-bold text-white mt-1">Lade...</h3></div><div class="p-2 bg-blue-500/10 rounded-lg"><svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path></svg></div></div><div class="w-full bg-darkbg rounded-full h-2"><div id="stat-cpu-bar" class="bg-blue-500 h-2 rounded-full" style="width: 0%"></div></div></div> <div class="mb-6 bg-darkcard border border-darkborder rounded-xl p-6 shadow-lg flex flex-col md:flex-row justify-between items-center gap-6">
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><div class="flex justify-between items-start mb-4"><div><p class="text-gray-400 text-sm font-medium">Globaler RAM</p><h3 id="stat-ram-text" class="text-2xl font-bold text-white mt-1">Lade...</h3></div><div class="p-2 bg-proxmox/10 rounded-lg"><svg class="w-6 h-6 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg></div></div><div class="w-full bg-darkbg rounded-full h-2"><div id="stat-ram-bar" class="bg-proxmox h-2 rounded-full" style="width: 0%"></div></div></div> <div class="flex items-center gap-4">
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><div class="flex justify-between items-start mb-4"><div><p class="text-gray-400 text-sm font-medium">Datacenter Storage</p><h3 id="stat-disk-text" class="text-2xl font-bold text-white mt-1">Lade...</h3></div><div class="p-2 bg-emerald-500/10 rounded-lg"><svg class="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg></div></div><div class="w-full bg-darkbg rounded-full h-2"><div id="stat-disk-bar" class="bg-emerald-500 h-2 rounded-full" style="width: 0%"></div></div></div> <div class="p-3 bg-blue-500/10 rounded-xl"><svg class="w-8 h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg></div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col justify-center"><div class="flex justify-between items-start mb-1"><div><p class="text-gray-400 text-sm font-medium">System Updates (APT)</p><h3 id="stat-updates-text" class="text-2xl font-bold text-white mt-1">Lade...</h3></div><div class="p-2 bg-purple-500/10 rounded-lg"><svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg></div></div><div id="stat-updates-sub" class="text-xs text-gray-500 mt-1 truncate">Prüfe Updates...</div></div> <div><h2 class="text-xl font-bold text-white">Gesamtübersicht</h2><p class="text-gray-400 text-sm">Cluster & Standalone Nodes</p></div>
</div>
<div class="flex flex-wrap justify-center gap-6 md:gap-12">
<div class="text-center"><p class="text-gray-400 text-xs font-bold uppercase mb-1">Server (Nodes)</p><p class="text-2xl font-bold text-white"><span id="stat-nodes-online" class="text-green-500">0</span><span class="text-gray-600 mx-1">/</span><span id="stat-nodes-total" class="text-gray-300">0</span></p></div>
<div class="hidden md:block w-px bg-darkborder"></div>
<div class="text-center"><p class="text-gray-400 text-xs font-bold uppercase mb-1">Total VMs/LXC</p><p class="text-2xl font-bold text-white" id="stat-vms-total">0</p></div>
<div class="text-center"><p class="text-gray-400 text-xs font-bold uppercase mb-1">Online</p><p class="text-2xl font-bold text-green-500" id="stat-vms-run">0</p></div>
<div class="text-center"><p class="text-gray-400 text-xs font-bold uppercase mb-1">Offline</p><p class="text-2xl font-bold text-red-500" id="stat-vms-stop">0</p></div>
</div>
</div> </div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg min-h-[300px] flex flex-col justify-center mb-6"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Live Cluster Auslastung</h3><span class="text-xs text-gray-500 flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span> Live Sync</span></div><div class="relative h-full w-full min-h-[220px]"><canvas id="liveChart"></canvas></div></div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><p class="text-gray-400 text-sm font-medium">Cluster CPU Cores</p><h3 id="stat-cpu-text" class="text-2xl font-bold text-white mt-1">Lade...</h3><div class="w-full bg-darkbg rounded-full h-2 mt-4"><div id="stat-cpu-bar" class="bg-blue-500 h-2 rounded-full" style="width: 0%"></div></div></div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><p class="text-gray-400 text-sm font-medium">Globaler RAM</p><h3 id="stat-ram-text" class="text-2xl font-bold text-white mt-1">Lade...</h3><div class="w-full bg-darkbg rounded-full h-2 mt-4"><div id="stat-ram-bar" class="bg-proxmox h-2 rounded-full" style="width: 0%"></div></div></div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><p class="text-gray-400 text-sm font-medium">Datacenter Storage</p><h3 id="stat-disk-text" class="text-2xl font-bold text-white mt-1">Lade...</h3><div class="w-full bg-darkbg rounded-full h-2 mt-4"><div id="stat-disk-bar" class="bg-emerald-500 h-2 rounded-full" style="width: 0%"></div></div></div>
<div onclick="openCreateVm()" class="bg-proxmox/10 border border-proxmox/30 hover:border-proxmox rounded-xl p-5 shadow-lg cursor-pointer flex flex-col items-center justify-center text-proxmox transition-colors"><span class="text-3xl font-bold mb-1">+</span><span class="font-bold">Neue VM anlegen</span></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg min-h-[300px] flex flex-col justify-center"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Live Cluster Auslastung</h3><span class="text-xs text-gray-500 flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span> CPU / RAM</span></div><div class="relative h-full w-full min-h-[220px]"><canvas id="liveChart"></canvas></div></div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg min-h-[300px] flex flex-col justify-center"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Live Netzwerk Traffic</h3><span class="text-xs text-gray-500 flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> MB/s Total pro Node</span></div><div class="relative h-full w-full min-h-[220px]"><canvas id="liveNetChart"></canvas></div></div>
</div>
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><h3 class="text-white font-bold mb-4">🔥 Top 5 Ressourcen-Fresser</h3><div id="top-vms-container" class="space-y-3"><p class="text-gray-400 text-sm">Lädt Live-Daten von Proxmox API...</p></div></div> <div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg"><h3 class="text-white font-bold mb-4">🔥 Top 5 Ressourcen-Fresser</h3><div id="top-vms-container" class="space-y-3"><p class="text-gray-400 text-sm">Lädt Live-Daten von Proxmox API...</p></div></div>
</div> </div>
<!-- TAB 2: PBS --> <!-- TAB NODE VIEW -->
<div id="tab-pbs" class="flex-1 flex flex-col min-w-0 hidden opacity-0 transition-opacity duration-300"> <div id="tab-node-view" class="hidden opacity-0 flex-1 flex flex-col min-w-0 transition-opacity duration-300">
<div class="mb-6 border-b border-darkborder pb-4"><h2 class="text-2xl font-bold text-white">Backup Datastores</h2><p class="text-sm text-gray-400">Live-Kapazitätsüberwachung deiner PBS Server.</p></div> <div class="flex justify-between items-center border-b border-darkborder pb-4 mb-6">
<div id="pbs-datastores-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"><div class="text-gray-500 animate-pulse">Lade PBS Daten...</div></div> <h2 id="nodeViewTitle" class="text-2xl font-bold text-white">Host: ...</h2>
<div class="space-x-3">
<button id="nodeViewBackupBtn" class="bg-darkcard border border-darkborder hover:border-proxmox text-white px-4 py-2 rounded text-sm transition-colors">📦 Backup Jobs</button>
<button id="nodeViewHeaderBtn" class="bg-darkcard border border-darkborder hover:border-proxmox text-white px-4 py-2 rounded text-sm transition-colors">📈 Live Performance</button>
<button onclick="openCreateVm()" class="bg-proxmox hover:bg-orange-500 text-white px-4 py-2 rounded text-sm font-bold transition-colors">+ Neue VM hier anlegen</button>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6">
<div class="xl:col-span-3 bg-darkcard border border-darkborder rounded-xl shadow-lg flex flex-col h-[600px]">
<div class="p-4 border-b border-darkborder"><h3 class="text-white font-bold">Laufende & Gestoppte Maschinen</h3></div>
<div class="overflow-y-auto flex-1">
<table class="w-full text-left text-sm text-gray-400">
<thead class="bg-darkbg text-xs uppercase sticky top-0 border-b border-darkborder z-10">
<tr><th class="py-2 px-3">ID</th><th class="py-2 px-3">Name</th><th class="py-2 px-3">Typ</th><th class="py-2 px-3">Status</th><th class="py-2 px-3 text-right">Aktionen</th></tr>
</thead>
<tbody id="nodeVmsTableBody" class="divide-y divide-darkborder/50"></tbody>
</table>
</div>
</div>
<div class="bg-darkcard border border-darkborder rounded-xl shadow-lg p-4 flex flex-col h-[600px]">
<h3 class="text-white font-bold mb-4 border-b border-darkborder pb-2">💾 Storages</h3>
<div id="nodeStoragesContainer" class="space-y-3 overflow-y-auto pr-2"></div>
</div>
</div>
</div> </div>
<!-- TAB 3: PMG --> <!-- TAB PBS -->
<div id="tab-pmg" class="flex-1 flex flex-col min-w-0 hidden opacity-0 transition-opacity duration-300"> <div id="tab-pbs" class="hidden opacity-0 flex-1 flex flex-col min-w-0 transition-opacity duration-300">
<div class="mb-6 border-b border-darkborder pb-4"><h2 class="text-2xl font-bold text-white">Mail Gateway Status</h2><p class="text-sm text-gray-400">Live-Metriken deiner PMG Instanzen.</p></div> <div class="mb-6 border-b border-darkborder pb-4"><h2 class="text-2xl font-bold text-white">Backup Datastores</h2></div>
<div id="pmg-nodes-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"><div class="text-gray-500 animate-pulse">Lade PMG Daten...</div></div> <div id="pbs-datastores-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div>
</div> </div>
<!-- RECHTS: JOBS --> <!-- TAB PMG -->
<div class="w-80 shrink-0 flex flex-col"><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex-1 overflow-y-auto"><h3 class="text-white font-bold mb-4 sticky top-0 bg-darkcard pb-2 border-b border-darkborder">Letzte Jobs</h3><div id="recent-jobs-container" class="space-y-3"><p class="text-gray-400 text-sm">Lädt Job-Historie...</p></div></div></div> <div id="tab-pmg" class="hidden opacity-0 flex-1 flex flex-col min-w-0 transition-opacity duration-300">
<div class="mb-6 border-b border-darkborder pb-4"><h2 class="text-2xl font-bold text-white">Mail Gateway Status</h2></div>
<div id="pmg-nodes-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"></div>
</div>
<!-- RECHTS: JOBS (Immer sichtbar, es sei denn das Fenster ist zu schmal) -->
<div class="w-80 shrink-0 flex flex-col hidden lg:flex">
<div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex-1 overflow-y-auto">
<h3 class="text-white font-bold mb-4 sticky top-0 bg-darkcard pb-2 border-b border-darkborder z-10">Letzte Jobs</h3>
<div id="recent-jobs-container" class="space-y-3"><p class="text-gray-400 text-sm">Lädt Job-Historie...</p></div>
</div>
</div>
</main> </main>
</div> </div>
</div> </div>
<?php include 'modals.php'; ?> <?php include 'modals.php'; ?>
<?php endif; ?> <?php endif; ?>
</main> </main>
<!-- FOOTER MIT TESSMANN.DEV -->
<footer class="bg-darkcard border-t border-darkborder py-4 mt-auto z-10 w-full">
<div class="max-w-[1600px] w-full mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-2 text-xs text-gray-500">
<div>
Proxmox Unified Console (PUC) &copy; <?= date('Y') ?> — Ein Open-Source Projekt.
</div>
<div>
Weitere private Projekte und Infos zur PUC findest du auf
<a href="https://tessmann.dev" target="_blank" rel="noopener noreferrer" class="text-proxmox hover:text-orange-400 font-bold transition-colors">
tessmann.dev
</a>
</div>
</div>
</footer>
<script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script> <script> window.APP = { isLoggedIn: <?= $isLoggedIn ? 'true' : 'false' ?>, nodeCount: <?= $nodeCount ?>, username: '<?= htmlspecialchars($_SESSION['username'] ?? '') ?>' }; </script>
<script src="app.js"></script> <script src="app.js?v=<?= time() ?>"></script>
</body> </body>
</html> </html>
+13 -80
View File
@@ -7,91 +7,24 @@
<div id="createVmModal" class="fixed inset-0 bg-black/80 hidden z-[80] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white">✨ Neue VM erstellen</h2><button onclick="closeCreateVm()" class="text-gray-400 hover:text-white transition-colors">✕</button></div><div class="p-6"><form id="createVmForm" class="space-y-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">Ziel-Host (PVE Node)</label><select id="createVmHost" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox"><option value="">Lade Hosts...</option></select></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Name der VM</label><input type="text" id="createVmName" placeholder="z.B. webserver-01" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div><div class="grid grid-cols-2 gap-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">RAM (MB)</label><input type="number" id="createVmRam" value="2048" step="512" min="512" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">CPU Cores</label><input type="number" id="createVmCores" value="2" min="1" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-3 rounded mt-4 transition-colors">Freie ID suchen & Erstellen</button></form></div></div></div> <div id="createVmModal" class="fixed inset-0 bg-black/80 hidden z-[80] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-md overflow-hidden flex flex-col"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white">✨ Neue VM erstellen</h2><button onclick="closeCreateVm()" class="text-gray-400 hover:text-white transition-colors">✕</button></div><div class="p-6"><form id="createVmForm" class="space-y-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">Ziel-Host (PVE Node)</label><select id="createVmHost" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox"><option value="">Lade Hosts...</option></select></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Name der VM</label><input type="text" id="createVmName" placeholder="z.B. webserver-01" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div><div class="grid grid-cols-2 gap-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">RAM (MB)</label><input type="number" id="createVmRam" value="2048" step="512" min="512" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">CPU Cores</label><input type="number" id="createVmCores" value="2" min="1" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-3 rounded mt-4 transition-colors">Freie ID suchen & Erstellen</button></form></div></div></div>
<div id="vmSettingsModal" class="fixed inset-0 bg-black/80 hidden z-[70] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><span id="settingsModalTitle">Einstellungen</span></h2><button onclick="closeVmSettings()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto space-y-6 flex-1"><input type="hidden" id="setVmid"><input type="hidden" id="setHost"><input type="hidden" id="setType"><input type="hidden" id="setNodeId"><input type="hidden" id="setRawNet0"><input type="hidden" id="setPrimaryDisk"><div class="grid grid-cols-1 lg:grid-cols-2 gap-6"><div class="space-y-6"><div class="bg-darkbg p-5 rounded-lg border border-darkborder"><h3 class="text-white font-bold mb-4">💻 Ressourcen</h3><div class="grid grid-cols-2 gap-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">RAM (MB)</label><input type="number" id="setMemory" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><div><label class="block text-xs font-medium text-gray-500 mb-1">CPU Cores</label><input type="number" id="setCores" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div></div><button onclick="saveHardwareSettings()" class="mt-4 bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-4 rounded text-sm transition-colors">Speichern</button></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder"><h3 class="text-white font-bold mb-4">🌐 Netzwerkverwaltung</h3><div class="flex items-center gap-3 mb-6 border-b border-darkborder pb-4"><span id="netStatusBadge" class="px-2 py-1 rounded text-xs font-bold text-gray-400 bg-gray-500/20">Lade...</span><button id="btnToggleNet" onclick="toggleNetwork()" class="bg-darkcard border border-darkborder hover:border-proxmox text-white font-bold py-1.5 px-4 rounded text-sm transition-colors">Lade...</button></div><div><h4 class="text-gray-400 font-bold mb-2 text-xs uppercase">Zusätzliche NIC</h4><div class="flex items-end gap-3"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Bridge Name (z.B. vmbr0)</label><input type="text" id="addNicBridge" value="vmbr0" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="addNic()" class="bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-4 rounded text-sm">+ Hinzufügen</button></div></div></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-blue-500"><h3 class="text-white font-bold mb-4">💾 Festplatte erweitern</h3><div class="flex items-end gap-4"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Um wie viel GB? (Disk: <span id="diskLabelName" class="text-proxmox">Lade...</span>)</label><input type="number" id="addDiskGb" min="1" placeholder="z.B. 10" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="expandDisk()" class="bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-6 rounded text-sm transition-colors">Erweitern</button></div></div></div><div class="space-y-6"><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-emerald-500"><h3 class="text-white font-bold mb-4">📸 Sicherung & Snapshots</h3><div class="flex items-end gap-3 mb-4"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Neuer Snapshot Name</label><input type="text" id="newSnapName" placeholder="z.B. pre-update" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="createSnapshot()" class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-2 px-4 rounded text-sm transition-colors">+ Snapshot</button></div><div class="space-y-2 mt-4 max-h-40 overflow-y-auto" id="snapshotListContainer"><div class="text-gray-500 text-sm italic">Lade Snapshots...</div></div></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-pbs"><h3 class="text-white font-bold mb-4">🛡️ Backup Archive (vzdump)</h3><div class="flex items-end gap-3 mb-4 border-b border-darkborder pb-4"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Ziel-Storage für manuelles Backup</label><select id="backupTargetStorage" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"><option value="">Lade Storages...</option></select></div><button onclick="createBackup()" class="bg-pbs hover:bg-purple-500 text-white font-bold py-2 px-4 rounded text-sm transition-colors">Jetzt sichern</button></div><h4 class="text-xs font-bold text-gray-400 mb-2 uppercase tracking-wider">Verfügbare Line truncated <div id="vmSettingsModal" class="fixed inset-0 bg-black/80 hidden z-[70] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><span id="settingsModalTitle">Einstellungen</span></h2><button onclick="closeVmSettings()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto space-y-6 flex-1"><input type="hidden" id="setVmid"><input type="hidden" id="setHost"><input type="hidden" id="setType"><input type="hidden" id="setNodeId"><input type="hidden" id="setRawNet0"><input type="hidden" id="setPrimaryDisk"><div class="grid grid-cols-1 lg:grid-cols-2 gap-6"><div class="space-y-6"><div class="bg-darkbg p-5 rounded-lg border border-darkborder"><h3 class="text-white font-bold mb-4">💻 Ressourcen & Boot</h3><div class="grid grid-cols-2 gap-4 mb-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">RAM (MB)</label><input type="number" id="setMemory" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><div><label class="block text-xs font-medium text-gray-500 mb-1">CPU Cores</label><input type="number" id="setCores" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div></div><div class="mb-4"><label class="block text-xs font-medium text-gray-500 mb-1">CD/DVD Laufwerk (ISO Mounten)</label><select id="setIso" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"><option value="">Lade ISOs...</option></select></div><div class="mb-4"><label class="flex items-center gap-2 text-white text-sm cursor-pointer"><input type="checkbox" id="setOnboot" class="accent-proxmox w-4 h-4"> Start at boot (Auto-Start mit PVE)</label></div><button onclick="saveHardwareSettings()" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-4 rounded text-sm transition-colors">Alle VM-Settings Speichern</button></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder"><h3 class="text-white font-bold mb-4">🌐 Netzwerkverwaltung</h3><div class="flex items-center gap-3 mb-6 border-b border-darkborder pb-4"><span id="netStatusBadge" class="px-2 py-1 rounded text-xs font-bold text-gray-400 bg-gray-500/20">Lade...</span><button id="btnToggleNet" onclick="toggleNetwork()" class="bg-darkcard border border-darkborder hover:border-proxmox text-white font-bold py-1.5 px-4 rounded text-sm transition-colors">Lade...</button></div><div><h4 class="text-gray-400 font-bold mb-2 text-xs uppercase">Zusätzliche NIC</h4><div class="flex items-end gap-3"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Bridge Name (z.B. vmbr0)</label><input type="text" id="addNicBridge" value="vmbr0" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="addNic()" class="bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-4 rounded text-sm">+ Hinzufügen</button></div></div></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-blue-500"><h3 class="text-white font-bold mb-4">💾 Festplatte erweitern</h3><div class="flex items-end gap-4"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Um wie viel GB? (Disk: <span id="diskLabelName" class="text-proxmox">Lade...</span>)</label><input type="number" id="addDiskGb" min="1" placeholder="z.B. 10" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="expandDisk()" class="bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 px-6 rounded text-sm transition-colors">Erweitern</button></div></div></div><div class="space-y-6"><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-emerald-500"><h3 class="text-white font-bold mb-4">📸 Sicherung & Snapshots</h3><div class="flex items-end gap-3 mb-4"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Neuer Snapshot Name</label><input type="text" id="newSnapName" placeholder="z.B. pre-update" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm"></div><button onclick="createSnapshot()" class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold py-2 px-4 rounded text-sm transition-colors">+ Snapshot</button></div><div class="space-y-2 mt-4 max-h-40 overflow-y-auto" id="snapshotListContainer"><div class="text-gray-500 text-sm italic">Lade Snapshots...</div></div></div><div class="bg-darkbg p-5 rounded-lg border border-darkborder border-l-4 border-l-pbs"><h3 class="text-white font-bold mb-4">🛡️ Backup Archive (vzdump)</h3><div class="flex items-end gap-3 mb-4 border-b border-darkborder pb-4"><div class="flex-1"><label clasLine truncated
<div id="nodeBackupsModal" class="fixed inset-0 bg-black/80 hidden z-[75] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[80vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white" id="nodeBackupsTitle">Backup Jobs</h2><button onclick="closeNodeBackups()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><div class="bg-darkcard border border-darkborder rounded-xl shadow-lg"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg text-xs uppercase border-b border-darkborder"><tr><th class="py-3 px-4">ID</th><th class="py-3 px-4">Schedule</th><th class="py-3 px-4">Storage</th><th class="py-3 px-4">VMs</th><th class="py-3 px-4 text-right">Info</th></tr></thead><tbody id="nodeBackupsTableBody" class="divide-y divide-darkborder/50"></tbody></table></div></div></div></div>
<div id="nodeTopologyModal" class="fixed inset-0 bg-black/80 hidden z-[65] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-6xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg> Cluster Topologie</h2><button onclick="closeNodeTopology()" class="text-gray-400 hover:text-white">✕</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><div id="nodeTopologyContainer" class="space-y-6"></div></div></div></div> <div id="nodeTopologyModal" class="fixed inset-0 bg-black/80 hidden z-[65] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-6xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg> Cluster Topologie</h2><button onclick="closeNodeTopology()" class="text-gray-400 hover:text-white">✕</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><div id="nodeTopologyContainer" class="space-y-6"></div></div></div></div>
<div id="liveGraphModal" class="fixed inset-0 bg-black/90 hidden z-[90] flex items-center justify-center backdrop-blur-md"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-6xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span><span id="graphModalTitle">Live Performance</span></h2><button onclick="closeLiveGraph()" class="text-gray-400 hover:text-white transition-colors">✕</button></div><div class="p-6 bg-darkbg overflow-y-auto"><div class="grid grid-cols-1 lg:grid-cols-2 gap-6"><div class="bg-darkcard border border-darkborder rounded-xl p-4 shadow-lg"><h3 class="text-white font-bold mb-3 text-sm">System: CPU & RAM (%)</h3><div class="relative w-full h-[250px]"><canvas id="perfChart"></canvas></div></div><div class="bg-darkcard border border-darkborder rounded-xl p-4 shadow-lg"><h3 class="text-white font-bold mb-3 text-sm">Netzwerk: RX / TX (MB/s)</h3><div class="relative w-full h-[250px]"><canvas id="netChart"></canvas></div></div></div><p class="text-center text-xs text-gray-500 mt-4">Metriken werden live im 2-Sekunden-Takt abgefragt.</p></div></div></div> <div id="liveGraphModal" class="fixed inset-0 bg-black/90 hidden z-[90] flex items-center justify-center backdrop-blur-md"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-6xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><span id="graphStatusDot" class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span><span id="graphModalTitle">Performance</span></h2><button onclick="closeLiveGraph()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="flex border-b border-darkborder bg-darkbg px-5 pt-4"><button onclick="switchGraphTab('live')" id="btn-graph-live" class="px-4 py-2 border-b-2 border-proxmox text-white font-bold">Live</button><button onclick="switchGraphTab('day')" id="btn-graph-day" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">24 Stunden</button><button onclick="switchGraphTab('week')" id="btn-graph-week" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">7 Tage</button></div><div class="p-6 bg-darkbg overflow-y-auto"><div class="grid grid-cols-1 lg:grid-cols-2 gap-6"><div class="bg-darkcard border border-darkborder rounded-xl p-4 shadow-lg"><h3 class="text-white font-bold mb-3 text-sm">CPU & RAM (%)</h3><div class="relative w-full h-[250px]"><canvas id="perfChart"></canvas></div></div><div class="bg-darkcard border border-darkborder rounded-xl p-4 shadow-lg"><h3 class="text-white font-bold mb-3 text-sm">Netzwerk Traffic</h3><div class="relative w-full h-[250px]"><canvas id="netChart"></canvas></div></div></div><p id="graphSubText" class="text-center text-xs text-gray-500 mt-4">Metriken werden live abgefragt.</p></div></div></div>
<div id="pbsDatastoreModal" class="fixed inset-0 bg-black/80 hidden z-[70] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col max-h-[95vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-pbs" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"></path></svg> <span id="pbsModalTitle">Datastore</span></h2><button onclick="closePbsDatastore()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto space-y-6 flex-1 bg-darkbg"><input type="hidden" id="pbsNodeId"><input type="hidden" id="pbsStoreName"><div class="grid grid-cols-1 xl:grid-cols-3 gap-6"><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px]"><h3 class="text-white font-bold mb-4 flex justify-between items-center">🗄️ Gespeicherte Backups</h3><div id="pbsBackupsContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px]"><h3 class="text-white font-bold mb-4">🧹 Verify & Garbage Collection</h3><div id="pbsVerifyGcContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px] border-l-4 border-l-blue-500"><h3 class="text-white font-bold mb-4">🔄 Eingerichtete Sync Jobs</h3><div id="pbsSyncContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg mt-6"><h3 class="text-white font-bold mb-4">📋 Letzte 100 PBS Jobs (Historie)</h3><div class="max-h-60 overflow-y-auto pr-2"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0"><tr><th class="px-4 py-2">Start</th><th class="px-4 py-2">Job Typ</th><th class="px-4 py-2">Status</th><th class="px-4 py-2">Dauer</th><th class="px-4 py-2 text-right">Log</th></tr></thead><tbody id="pbsJobsTable" class="divide-y divide-darkborder"></tbody></table></div></div></div></div></div> <div id="pbsDatastoreModal" class="fixed inset-0 bg-black/80 hidden z-[70] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col max-h-[95vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-pbs" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2"></path></svg> <span id="pbsModalTitle">Datastore</span> <button onclick="openLiveGraph('node', 0, 'localhost', document.getElementById('pbsNodeId').value, 'PBS Server')" class="ml-4 text-xs bg-darkcard border border-darkborder hover:border-pbs text-white px-3 py-1.5 rounded transition-colors">📈 Performance Historie</button></h2><button onclick="closePbsDatastore()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto space-y-6 flex-1 bg-darkbg"><input type="hidden" id="pbsNodeId"><input type="hidden" id="pbsStoreName"><div class="grid grid-cols-1 xl:grid-cols-3 gap-6"><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px]"><h3 class="text-white font-bold mb-4 flex justify-between items-center">🗄️ Gespeicherte Backups</h3><div id="pbsBackupsContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px]"><h3 class="text-white font-bold mb-4">🧹 Verify & Garbage Collection</h3><div id="pbsVerifyGcContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg flex flex-col h-[350px] border-l-4 border-l-blue-500"><h3 class="text-white font-bold mb-4">🔄 Eingerichtete Sync Jobs</h3><div id="pbsSyncContainer" class="space-y-2 overflow-y-auto flex-1 pr-2">Lade...</div></div></div><div class="bg-darkcard border border-darkborder rounded-xl p-5 shadow-lg mt-6"><h3 class="text-white font-bold mb-4">📋 Letzte 100 PBS Jobs (Historie)</h3><div class="max-h-60 overflow-y-auto pr-2"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0"><tr><th class="px-4 py-2">Start</th><th class="px-4 py-2">Job Typ</th><th class="px-4 py-2">Status</th><th class="px-4 py-2">Dauer</th><th class="px-4 py-2 text-right">Log</th></tr></thead><tbody id="pbsJobsTable" class="divide-y divide-darkborder"></tbody></table></div></div></div></div></div>
<div id="taskLogModal" class="fixed inset-0 bg-black/90 hidden z-[100] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col h-[85vh]"><div class="p-4 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-lg font-bold text-white flex items-center gap-2">📄 Task Log Viewer</h2><button onclick="closeTaskLog()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-4 bg-black flex-1 overflow-y-auto"><pre id="taskLogContent" class="text-xs text-green-400 font-mono whitespace-pre-wrap">Lade Log...</pre></div></div></div> <div id="taskLogModal" class="fixed inset-0 bg-black/90 hidden z-[100] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col h-[85vh]"><div class="p-4 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-lg font-bold text-white flex items-center gap-2">📄 Task Log Viewer</h2><button onclick="closeTaskLog()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-4 bg-black flex-1 overflow-y-auto"><pre id="taskLogContent" class="text-xs text-green-400 font-mono whitespace-pre-wrap">Lade Log...</pre></div></div></div>
<div id="pmgManagerModal" class="fixed inset-0 bg-black/80 hidden z-[75] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col h-[95vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-pmg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg> <span id="pmgModalTitle">Mail Gateway</span></h2><button onclick="closePmgManager()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="flex border-b border-darkborder bg-darkbg px-5 pt-4"><button onclick="switchPmgTab('spam')" id="btn-pmg-spam" class="px-4 py-2 border-b-2 border-pmg text-white font-bold">🛡️ Quarantäne</button><button onclick="switchPmgTab('tracking')" id="btn-pmg-tracking" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">🔎 Tracking Center</button><button onclick="switchPmgTab('queues')" id="btn-pmg-queues" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">📨 Mail Queues</button><button onclick="switchPmgTab('settings')" id="btn-pmg-settings" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors ml-auto">⚙️ Einstellungen</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><input type="hidden" id="pmgNodeId"><input type="hidden" id="pmgHostName"><input type="hidden" id="pmgInternalName"><div id="pmg-tab-spam" class="h-full flex flex-col"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Zurückgehaltene Mails</h3><button onclick="loadPmgSpamQueue(document.getElementById('pmgNodeId').value)" class="text-xs bg-darkcard border border-darkborder hover:border-pmg text-white px-3 py-1.5 rounded transition-colors">🔄 Aktualisieren</button></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0 z-10"><tr><th class="px-4 py-3">Datum</th><th class="px-4 py-3">Absender</th><th class="px-4 py-3">Empfänger</th><th class="px-4 py-3">Betreff</th><th class="px-4 py-3 text-center">Score</th><th class="px-4 py-3 text-right">Aktion</th></tr></thead><tbody id="pmgSpamTable" class="divide-y divide-darkborder"></tbody></table></div></div><div id="pmg-tab-tracking" class="hidden h-full flex flex-col"><div class="flex gap-4 mb-4 items-end"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Nach E-Mail Adresse filtern (Letzte 24h)</label><input type="text" id="pmgTrackingFilter" placeholder="z.B. spammer@evil.com oder user@firma.de" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm" onkeydown="if(event.key === 'Enter') loadPmgTracking()"></div><button onclick="loadPmgTracking()" class="bg-pmg hover:bg-blue-600 text-white font-bold py-2 px-6 rounded text-sm transition-colors">Suchen</button></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0 z-10"><tr><th class="px-4 py-3">Zeit</th><th class="px-4 py-3">Absender</th><th class="px-4 py-3">Empfänger</th><th class="px-4 py-3">Status</th></tr></thead><tbody id="pmgTrackingTable" class="divide-y divide-darkborder"></tbody></table></div></div><div id="pmg-tab-queues" class="hidden h-full flex flex-col"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Aktuelle Postfix Warteschlangen</h3><button onclick="loadPmgQueues()" class="text-xs bg-darkcard border border-darkborder hover:border-pmg text-white px-3 py-1.5 rounded transition-colors">🔄 Aktualisieren</button></div><div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6" id="pmgQueuesSummary"><div class="bg-darkcard border border-darkborder rounded-xl p-5 text-center animate-pulse"><p class="text-gray-500">Lade...</p></div></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0 z-10"><tr><th class="px-4 py-3">Queue</th><th class="px-4 py-3">Zeit</th><th class="px-4 py-3">Absender</th><th class="px-4 py-3">Empfänger</th><th class="px-4 py-3">Grund</th></tr></thead><tbody id="pmgQueueTable" class="divide-y divide-darkborder"></tbody></table></div></div><div Line truncated <div id="pmgManagerModal" class="fixed inset-0 bg-black/80 hidden z-[75] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-7xl overflow-hidden flex flex-col h-[95vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-pmg" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg> <span id="pmgModalTitle">Mail Gateway</span> <button onclick="openLiveGraph('node', 0, document.getElementById('pmgInternalName').value, document.getElementById('pmgNodeId').value, document.getElementById('pmgHostName').value)" class="ml-4 text-xs bg-darkcard border border-darkborder hover:border-pmg text-white px-3 py-1.5 rounded transition-colors">📈 Performance Historie</button></h2><button onclick="closePmgManager()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="flex border-b border-darkborder bg-darkbg px-5 pt-4"><button onclick="switchPmgTab('spam')" id="btn-pmg-spam" class="px-4 py-2 border-b-2 border-pmg text-white font-bold">🛡️ Quarantäne</button><button onclick="switchPmgTab('tracking')" id="btn-pmg-tracking" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">🔎 Tracking Center</button><button onclick="switchPmgTab('queues')" id="btn-pmg-queues" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors">📨 Mail Queues</button><button onclick="switchPmgTab('settings')" id="btn-pmg-settings" class="px-4 py-2 border-b-2 border-transparent text-gray-400 hover:text-white font-bold transition-colors ml-auto">⚙️ Einstellungen</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><input type="hidden" id="pmgNodeId"><input type="hidden" id="pmgHostName"><input type="hidden" id="pmgInternalName"><div id="pmg-tab-spam" class="h-full flex flex-col"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Zurückgehaltene Mails</h3><button onclick="loadPmgSpamQueue(document.getElementById('pmgNodeId').value)" class="text-xs bg-darkcard border border-darkborder hover:border-pmg text-white px-3 py-1.5 rounded transition-colors">🔄 Aktualisieren</button></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0 z-10"><tr><th class="px-4 py-3">Datum</th><th class="px-4 py-3">Absender</th><th class="px-4 py-3">Empfänger</th><th class="px-4 py-3">Betreff</th><th class="px-4 py-3 text-center">Score</th><th class="px-4 py-3 text-right">Aktion</th></tr></thead><tbody id="pmgSpamTable" class="divide-y divide-darkborder"></tbody></table></div></div><div id="pmg-tab-tracking" class="hidden h-full flex flex-col"><div class="flex gap-4 mb-4 items-end"><div class="flex-1"><label class="block text-xs font-medium text-gray-500 mb-1">Nach E-Mail Adresse filtern (Letzte 24h)</label><input type="text" id="pmgTrackingFilter" placeholder="z.B. spammer@evil.com oder user@firma.de" class="w-full bg-darkcard border border-darkborder rounded p-2 text-white text-sm" onkeydown="if(event.key === 'Enter') loadPmgTracking()"></div><button onclick="loadPmgTracking()" class="bg-pmg hover:bg-blue-600 text-white font-bold py-2 px-6 rounded text-sm transition-colors">Suchen</button></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0 z-10"><tr><th class="px-4 py-3">Zeit</th><th class="px-4 py-3">Absender</th><th class="px-4 py-3">Empfänger</th><th class="px-4 py-3">Status</th></tr></thead><tbody id="pmgTrackingTable" class="divide-y divide-darkborder"></tbody></table></div></div><div id="pmg-tab-queues" class="hidden h-full flex flex-col"><div class="flex justify-between items-center mb-4"><h3 class="text-white font-bold">Aktuelle Postfix Warteschlangen</h3><button onclick="loadPmgQueues()" class="text-xs bg-darkcard border border-darkborder hover:border-pmg text-white px-3 py-1.5 rounded transition-colors">🔄 Aktualisieren</button></div><div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6" id="pmgQueuesSummary"><div class="bg-darkcard border border-darkborder rounded-xl p-5 text-center animate-pulse"><p class="text-gray-500">Lade...</p></div></div><div class="overflow-y-auto flex-1 bg-darkcard border border-darkborder rounded-lg relative"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg Line truncated
<!-- NEU: CRON / SCHEDULER MODAL --> <div id="updateManagerModal" class="fixed inset-0 bg-black/80 hidden z-[85] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2">📦 System Updates (APT)</h2><button onclick="closeUpdateManager()" class="text-gray-400 hover:text-white transition-colors text-2xl">✕</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg" id="updateManagerContent"><div class="text-center py-10 text-gray-500 animate-pulse">Prüfe alle Nodes auf Paket-Updates...</div></div></div></div>
<div id="cronManagerModal" class="fixed inset-0 bg-black/80 hidden z-50 flex items-center justify-center backdrop-blur-sm">
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]"> <div id="cronManagerModal" class="fixed inset-0 bg-black/80 hidden z-50 flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2">⏱️ Task Scheduler & Automatisierung</h2><button onclick="closeCronManager()" class="text-gray-400 hover:text-white transition-colors">✕</button></div><div class="p-6 overflow-y-auto flex-1 flex flex-col gap-6 bg-darkbg"><div class="bg-darkcard p-5 rounded-xl border border-darkborder shadow-lg"><h3 class="text-white font-bold mb-4 border-b border-darkborder pb-2">Neuen Cron-Job anlegen</h3><form id="addCronJobForm" class="space-y-4"><div class="grid grid-cols-1 md:grid-cols-2 gap-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">Name / Beschreibung</label><input type="text" id="cronName" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" placeholder="z.B. Nächtlicher Reboot" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Ziel-Host (PVE)</label><select id="cronNodeId" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required><option value="">Lade Hosts...</option></select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">Aktion</label><select id="cronAction" onchange="checkCronAction()" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required><option value="reboot_node">Ganzen Server neustarten</option><option value="start_vm">VM Starten</option><option value="stop_vm">VM Stoppen</option><option value="reboot_vm">VM Neustarten</option></select></div><div id="cronVmBlock" class="hidden"><label class="block text-xs font-medium text-gray-500 mb-1">Ziel VM-ID</label><input type="number" id="cronTargetVmid" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" placeholder="z.B. 100"></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Uhrzeit</label><input type="time" id="cronTime" value="03:00" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Tage / Datum</label><select id="cronDays" onchange="toggleCronDate()" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required><option value="*">Jeden Tag</option><option value="1-5">Montag - Freitag</option><option value="6,0">Wochenende</option><option value="1">Jeden Montag</option><option value="specific">📅 Einmalig (Bestimmtes Datum)</option></select><input type="date" id="cronSpecificDate" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm mt-2 hidden"></div></div><button type="submit" class="bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-6 rounded transition-colors text-sm w-full md:w-auto">Job Speichern</button></form></div><div class="bg-darkcard border border-darkborder rounded-xl shadow-lg flex-1 overflow-hidden flex flex-col"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0"><tr><th class="px-4 py-3">Status</th><th class="px-4 py-3">Beschreibung</th><th class="px-4 py-3">Host / Ziel</th><th class="px-4 py-3">Cron</th><th class="px-4 py-3">Letzter Lauf</th><th class="px-4 py-3 text-right">Löschen</th></tr></thead><tbody id="cronTableBody" class="divide-y divide-darkborder"></tbody></table></div></div></div></div>
<div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg">
<h2 class="text-xl font-bold text-white flex items-center gap-2">⏱️ Task Scheduler & Automatisierung</h2> <div id="auditLogModal" class="fixed inset-0 bg-black/80 hidden z-[100] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-xl font-bold text-white flex items-center gap-2"><svg class="w-6 h-6 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> Aktivitäten-Protokoll (Audit Log)</h2><button onclick="closeAuditLog()" class="text-gray-400 hover:text-white transition-colors">✕</button></div><div class="p-6 overflow-y-auto flex-1 bg-darkbg"><div class="bg-darkcard border border-darkborder rounded-xl shadow-lg flex-1 overflow-hidden flex flex-col"><table class="w-full text-left text-sm text-gray-400"><thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0"><tr><th class="px-4 py-3">Datum / Uhrzeit</th><th class="px-4 py-3">Benutzer</th><th class="px-4 py-3">Aktion</th><th class="px-4 py-3">Ziel / Details</th></tr></thead><tbody id="auditLogTableBody" class="divide-y divide-darkborder"></tbody></table></div></div></div></div>
<button onclick="closeCronManager()" class="text-gray-400 hover:text-white transition-colors">✕</button>
</div> <div id="passwordModal" class="fixed inset-0 bg-black/80 hidden z-[100] flex items-center justify-center backdrop-blur-sm"><div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"><div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg"><h2 class="text-lg font-bold text-white flex items-center gap-2">🔑 Passwort ändern</h2><button onclick="closePasswordModal()" class="text-gray-400 hover:text-white transition-colors text-xl">✕</button></div><div class="p-6"><form id="changePasswordForm" class="space-y-4"><div><label class="block text-xs font-medium text-gray-500 mb-1">Aktuelles Passwort</label><input type="password" id="oldPassword" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Neues Passwort</label><input type="password" id="newPassword" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required></div><div><label class="block text-xs font-medium text-gray-500 mb-1">Neues Passwort bestätigen</label><input type="password" id="newPasswordConfirm" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm focus:border-proxmox" required></div><button type="submit" class="w-full bg-proxmox hover:bg-orange-600 text-white font-bold py-2.5 rounded mt-2 transition-colors">Passwort aktualisieren</button></form></div></div></div>
<div class="p-6 overflow-y-auto flex-1 flex flex-col gap-6 bg-darkbg">
<div class="bg-darkcard p-5 rounded-xl border border-darkborder shadow-lg">
<h3 class="text-white font-bold mb-4 border-b border-darkborder pb-2">Neuen Cron-Job anlegen</h3>
<form id="addCronJobForm" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label class="block text-xs font-medium text-gray-500 mb-1">Name / Beschreibung</label><input type="text" id="cronName" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" placeholder="z.B. Nächtlicher Reboot" required></div>
<div><label class="block text-xs font-medium text-gray-500 mb-1">Ziel-Host (PVE)</label><select id="cronNodeId" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required><option value="">Lade Hosts...</option></select></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Aktion</label>
<select id="cronAction" onchange="checkCronAction()" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" required>
<option value="reboot_node">Ganzen Server neustarten</option>
<option value="start_vm">VM Starten</option>
<option value="stop_vm">VM Stoppen</option>
<option value="reboot_vm">VM Neustarten</option>
</select>
</div>
<div id="cronVmBlock" class="hidden">
<label class="block text-xs font-medium text-gray-500 mb-1">Ziel VM-ID</label>
<input type="number" id="cronTargetVmid" class="w-full bg-darkbg border border-darkborder rounded p-2 text-white text-sm" placeholder="z.B. 100">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Zeitplan (Cron-Format)</label>
<input type="text" id="cronSchedule" value="0 3 * * *" class="w-full bg-darkbg border border-darkborder rounded p-2 text-proxmox font-mono text-sm font-bold" required title="Minute Stunde Tag Monat Wochentag">
<p class="text-[10px] text-gray-500 mt-1">Beispiel: "0 3 * * *" = Jeden Tag um 03:00 Uhr.</p>
</div>
</div>
<button type="submit" class="bg-proxmox hover:bg-orange-600 text-white font-bold py-2 px-6 rounded transition-colors text-sm w-full md:w-auto">Job Speichern</button>
</form>
</div>
<div class="bg-darkcard border border-darkborder rounded-xl shadow-lg flex-1 overflow-hidden flex flex-col">
<table class="w-full text-left text-sm text-gray-400">
<thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0">
<tr><th class="px-4 py-3">Status</th><th class="px-4 py-3">Beschreibung</th><th class="px-4 py-3">Host / Ziel</th><th class="px-4 py-3">Cron</th><th class="px-4 py-3">Letzter Lauf</th><th class="px-4 py-3 text-right">Löschen</th></tr>
</thead>
<tbody id="cronTableBody" class="divide-y divide-darkborder"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- NEU: AUDIT LOG MODAL -->
<div id="auditLogModal" class="fixed inset-0 bg-black/80 hidden z-[100] flex items-center justify-center backdrop-blur-sm">
<div class="bg-darkcard border border-darkborder rounded-xl shadow-2xl w-full max-w-5xl overflow-hidden flex flex-col max-h-[90vh]">
<div class="p-5 border-b border-darkborder flex justify-between items-center bg-darkbg">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<svg class="w-6 h-6 text-proxmox" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
Aktivitäten-Protokoll (Audit Log)
</h2>
<button onclick="closeAuditLog()" class="text-gray-400 hover:text-white transition-colors">✕</button>
</div>
<div class="p-6 overflow-y-auto flex-1 bg-darkbg">
<div class="bg-darkcard border border-darkborder rounded-xl shadow-lg flex-1 overflow-hidden flex flex-col">
<table class="w-full text-left text-sm text-gray-400">
<thead class="bg-darkbg border-b border-darkborder text-xs uppercase sticky top-0">
<tr><th class="px-4 py-3">Datum / Uhrzeit</th><th class="px-4 py-3">Benutzer</th><th class="px-4 py-3">Aktion</th><th class="px-4 py-3">Ziel / Details</th></tr>
</thead>
<tbody id="auditLogTableBody" class="divide-y divide-darkborder"></tbody>
</table>
</div>
</div>
</div>
</div>