Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e72a9a53e7 | ||
|
|
a50a944f64 | ||
|
|
e13319cb88 | ||
|
|
cc7bc514e1 | ||
|
|
4526301fd1 | ||
|
|
b3759139bf | ||
|
|
2d52ee6113 | ||
|
|
2208bb0ec7 | ||
|
|
8643f45370 |
+6
-9
@@ -1,35 +1,32 @@
|
||||
# /home/docker/pve_dashboard/Dockerfile
|
||||
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 \
|
||||
libsqlite3-dev \
|
||||
openssl \
|
||||
cron \
|
||||
&& docker-php-ext-install pdo_sqlite
|
||||
|
||||
# Apache Rewrite-Modul (für schöne URLs) und SSL-Modul aktivieren
|
||||
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 \
|
||||
-keyout /etc/ssl/private/apache-selfsigned.key \
|
||||
-out /etc/ssl/certs/apache-selfsigned.crt \
|
||||
-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 \
|
||||
&& 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
|
||||
|
||||
# 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
|
||||
|
||||
# Beide Ports freigeben
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
|
||||
# Startbefehl: Startet erst den Cron-Daemon im Hintergrund und dann Apache im Vordergrund
|
||||
CMD cron && apache2-foreground
|
||||
@@ -1,5 +1,7 @@
|
||||
# Proxmox Unified Console (PUC) 🚀
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -38,32 +40,45 @@ 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:**
|
||||
```bash
|
||||
git clone [https://github.com/tessmania90/proxmox-unified-console.git](https://github.com/tessmania90/proxmox-unified-console.git)
|
||||
cd proxmox-unified-console
|
||||
**1. `docker-compose.yml` anlegen:**
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
Start the container:
|
||||
Bash
|
||||
services:
|
||||
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
|
||||
|
||||
docker compose up -d --build
|
||||
2. Starten:
|
||||
Bash
|
||||
|
||||
Access the Dashboard:
|
||||
Open your browser and navigate to:
|
||||
https://<YOUR-SERVER-IP>:8443
|
||||
(Note: You will need to accept the self-signed certificate warning).
|
||||
docker compose up -d
|
||||
|
||||
Default Login:
|
||||
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
|
||||
|
||||
Username: admin
|
||||
sudo chown -R 33:33 ./data
|
||||
sudo chmod -R 775 ./data
|
||||
|
||||
Password: admin
|
||||
4. Login:
|
||||
Rufe https://<DEINE-IP>:8443 in deinem Browser auf (Zertifikatswarnung ignorieren).
|
||||
|
||||
⚠️ IMPORTANT: Please change the default password immediately after your first login!
|
||||
Benutzername: admin
|
||||
|
||||
Passwort: admin (Bitte direkt nach dem Einloggen oben rechts über das 🔑-Symbol ändern!)
|
||||
|
||||
🛠️ Architecture
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
+2
-8
@@ -1,20 +1,14 @@
|
||||
# /home/docker/pve_dashboard/docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pve-dashboard:
|
||||
build: .
|
||||
container_name: pve_unified_console
|
||||
image: stessmann/proxmox-unified-console:latest
|
||||
container_name: puc_dashboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Port 80 bleibt fürs Testing / HAProxy
|
||||
- "8080:80"
|
||||
# NEU: Port 443 für sicheres NoVNC! (Wir mappen es nach außen z.B. auf 8443)
|
||||
- "8443:443"
|
||||
volumes:
|
||||
# Dein Source-Code
|
||||
- ./src:/var/www/html
|
||||
# Deine Datenbank, sicher ausgelagert
|
||||
- ./data:/var/www/data
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
+122
-24
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
// /home/docker/pve_dashboard/src/api.php
|
||||
ini_set('display_errors', 0);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once 'db.php';
|
||||
header('Content-Type: application/json');
|
||||
$action = $_GET['action'] ?? '';
|
||||
@@ -7,62 +10,143 @@ $action = $_GET['action'] ?? '';
|
||||
// === ZENTRALE AUDIT LOG FUNKTION ===
|
||||
function logAudit($actionName, $target = '') {
|
||||
global $pdo;
|
||||
try {
|
||||
@session_start();
|
||||
$userId = $_SESSION['user_id'] ?? 0;
|
||||
$username = $_SESSION['username'] ?? 'System';
|
||||
session_write_close();
|
||||
@session_write_close();
|
||||
|
||||
$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') {
|
||||
$username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? '';
|
||||
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?"); $stmt->execute([$username]); $user = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$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'])) {
|
||||
$_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; $_SESSION['role'] = $user['role'];
|
||||
logAudit('User Login', 'IP: ' . $_SERVER['REMOTE_ADDR']);
|
||||
@session_start();
|
||||
$_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]);
|
||||
} else echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
|
||||
} else {
|
||||
echo json_encode(['success' => false, 'error' => 'Login fehlgeschlagen.']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === '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';
|
||||
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;
|
||||
|
||||
if ($type === 'pmg' || $type === 'pbs') {
|
||||
// TICKET AUTH für PBS und PMG
|
||||
$chAuth = curl_init("https://{$ip}:{$port}/api2/json/access/ticket");
|
||||
curl_setopt_array($chAuth, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 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);
|
||||
if(!isset($authRes['data']['ticket'])) return ['data' => []];
|
||||
|
||||
if(!isset($authRes['data']['ticket'])) return ['data' => null, 'error' => 'Auth failed'];
|
||||
|
||||
$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 {
|
||||
// NATIVE API TOKENS FÜR PVE
|
||||
$headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"];
|
||||
}
|
||||
|
||||
$ch = curl_init("https://{$ip}:{$port}{$endpoint}");
|
||||
$options = [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers];
|
||||
if ($method !== "GET") { $options[CURLOPT_CUSTOMREQUEST] = $method; if ($postData) $options[CURLOPT_POSTFIELDS] = is_array($postData) ? http_build_query($postData) : $postData; }
|
||||
$options = [
|
||||
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);
|
||||
$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) {
|
||||
global $isAdmin; if ($isAdmin) return true;
|
||||
@session_start(); $userId = $_SESSION['user_id'] ?? 0; session_write_close();
|
||||
$stmt = $pdo->prepare("SELECT permissions FROM users WHERE id = ?"); $stmt->execute([$userId]); return in_array($vmid, json_decode($stmt->fetchColumn(), true)['allowed_vms'] ?? []);
|
||||
global $isAdmin;
|
||||
if ($isAdmin) return true;
|
||||
|
||||
@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 (!$isAdmin) exit;
|
||||
$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; } }
|
||||
} elseif ($node['type'] === '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 {
|
||||
$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') {
|
||||
@@ -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 . ")"; }
|
||||
} else {
|
||||
$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;
|
||||
|
||||
+189
-38
@@ -1,18 +1,66 @@
|
||||
<?php
|
||||
// /home/docker/pve_dashboard/src/api_pve.php
|
||||
if (!defined('PDO::ATTR_DRIVER_NAME')) exit; // Schutz
|
||||
if (!defined('PDO::ATTR_DRIVER_NAME')) exit; // Schutz vor direktem Aufruf
|
||||
|
||||
if ($action === 'get_stats') {
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$data = ['cpu_cores' => 0, 'cpu_used' => 0, 'ram_total' => 0, 'ram_used' => 0, 'disk_total' => 0, 'disk_used' => 0, 'cpu_percent' => 0];
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'");
|
||||
$nodes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$data = [
|
||||
'cpu_cores' => 0, 'cpu_used' => 0, 'ram_total' => 0, 'ram_used' => 0,
|
||||
'disk_total' => 0, 'disk_used' => 0, 'cpu_percent' => 0, 'nodes_net' => [],
|
||||
'cluster_stats' => ['nodes_total' => count($nodes), 'nodes_online' => 0, 'vms_total' => 0, 'vms_running' => 0, 'vms_stopped' => 0]
|
||||
];
|
||||
|
||||
$seenNodes = [];
|
||||
$seenVms = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes");
|
||||
if (isset($nData['data'])) {
|
||||
foreach ($nData['data'] as $nInfo) {
|
||||
// Duplikate (z.B. in Clustern) vermeiden
|
||||
if (!isset($seenNodes[$nInfo['node']])) {
|
||||
$seenNodes[$nInfo['node']] = true;
|
||||
|
||||
if ($nInfo['status'] === 'online') {
|
||||
$data['cpu_cores'] += $nInfo['maxcpu'] ?? 0; $data['cpu_used'] += ($nInfo['cpu'] ?? 0) * ($nInfo['maxcpu'] ?? 0);
|
||||
$data['ram_total'] += $nInfo['maxmem'] ?? 0; $data['ram_used'] += $nInfo['mem'] ?? 0;
|
||||
$data['disk_total'] += $nInfo['maxdisk'] ?? 0; $data['disk_used'] += $nInfo['disk'] ?? 0;
|
||||
$data['cluster_stats']['nodes_online']++;
|
||||
$data['cpu_cores'] += $nInfo['maxcpu'] ?? 0;
|
||||
$data['cpu_used'] += ($nInfo['cpu'] ?? 0) * ($nInfo['maxcpu'] ?? 0);
|
||||
$data['ram_total'] += $nInfo['maxmem'] ?? 0;
|
||||
$data['ram_used'] += $nInfo['mem'] ?? 0;
|
||||
$data['disk_total'] += $nInfo['maxdisk'] ?? 0;
|
||||
$data['disk_used'] += $nInfo['disk'] ?? 0;
|
||||
|
||||
$nRrd = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$nInfo['node']}/rrddata?timeframe=hour&cf=AVERAGE");
|
||||
$netin = 0; $netout = 0;
|
||||
if (isset($nRrd['data']) && is_array($nRrd['data'])) {
|
||||
for ($i = count($nRrd['data']) - 1; $i >= 0; $i--) {
|
||||
if (isset($nRrd['data'][$i]['netin']) && $nRrd['data'][$i]['netin'] !== null) {
|
||||
$netin = $nRrd['data'][$i]['netin'];
|
||||
$netout = $nRrd['data'][$i]['netout'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$data['nodes_net'][] = ['name' => $nInfo['node'], 'netin' => $netin, 'netout' => $netout];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VMs von allen angebundenen Hosts ziehen und Duplikate filtern
|
||||
$vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm");
|
||||
if (isset($vms['data'])) {
|
||||
foreach($vms['data'] as $vm) {
|
||||
$uniqueId = $vm['vmid'] . '@' . ($vm['node'] ?? 'unknown');
|
||||
if (!isset($seenVms[$uniqueId])) {
|
||||
$seenVms[$uniqueId] = true;
|
||||
$data['cluster_stats']['vms_total']++;
|
||||
if (isset($vm['status']) && $vm['status'] === 'running') {
|
||||
$data['cluster_stats']['vms_running']++;
|
||||
} else {
|
||||
$data['cluster_stats']['vms_stopped']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,20 +70,42 @@ if ($action === 'get_stats') {
|
||||
}
|
||||
|
||||
if ($action === 'get_top_vms') {
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = [];
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; $seenVms = [];
|
||||
foreach ($nodes as $node) {
|
||||
$vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm");
|
||||
if (isset($vms['data'])) { foreach ($vms['data'] as $vm) { if (checkVmPermission($pdo, $vm['vmid'])) { $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); });
|
||||
echo json_encode(['success' => true, 'data' => array_slice($allVms, 0, 5)]); exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_all_vms') {
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = [];
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $nodes = $stmt->fetchAll(PDO::FETCH_ASSOC); $allVms = []; $seenVms = [];
|
||||
foreach ($nodes as $node) {
|
||||
$vms = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm");
|
||||
if (isset($vms['data'])) { foreach ($vms['data'] as $vm) { if (checkVmPermission($pdo, $vm['vmid'])) { $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']; });
|
||||
echo json_encode(['success' => true, 'data' => $allVms]); exit;
|
||||
@@ -44,22 +114,69 @@ if ($action === 'get_all_vms') {
|
||||
if ($action === 'vm_action') {
|
||||
if (!checkVmPermission($pdo, $_POST['vmid'])) exit;
|
||||
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/status/{$_POST['cmd']}", "POST");
|
||||
|
||||
// AUDIT LOG
|
||||
$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');
|
||||
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 === 'delete_node') { if (!$isAdmin) exit; $stmt = $pdo->prepare("DELETE FROM nodes WHERE id = ?"); $stmt->execute([$_POST['id']]); logAudit('Server gelöscht', "Node ID: {$_POST['id']}"); echo json_encode(['success' => true]); exit; }
|
||||
|
||||
if ($action === 'add_node') {
|
||||
if (!$isAdmin) exit;
|
||||
|
||||
$name = trim($_POST['name']);
|
||||
$ip = trim($_POST['ip']);
|
||||
$user = trim($_POST['user']);
|
||||
$pass = trim($_POST['pass']);
|
||||
$type = $_POST['type'] ?? 'pve';
|
||||
|
||||
$tokenIdToSave = $user;
|
||||
$tokenSecretToSave = $pass;
|
||||
|
||||
if ($type === 'pve') {
|
||||
$chAuth = curl_init("https://{$ip}:8006/api2/json/access/ticket");
|
||||
curl_setopt_array($chAuth, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['username' => $user, 'password' => $pass])
|
||||
]);
|
||||
$authRes = json_decode(curl_exec($chAuth), true);
|
||||
curl_close($chAuth);
|
||||
|
||||
if(!isset($authRes['data']['ticket'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Proxmox Login fehlgeschlagen. Passwort falsch?']); exit;
|
||||
}
|
||||
|
||||
$ticket = $authRes['data']['ticket'];
|
||||
$csrf = $authRes['data']['CSRFPreventionToken'];
|
||||
|
||||
$tokenName = 'pvedash' . rand(1000, 9999);
|
||||
$chToken = curl_init("https://{$ip}:8006/api2/json/access/users/{$user}/token/{$tokenName}");
|
||||
curl_setopt_array($chToken, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false, CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query(['privsep' => 0]),
|
||||
CURLOPT_HTTPHEADER => ["Cookie: PVEAuthCookie={$ticket}", "CSRFPreventionToken: {$csrf}"]
|
||||
]);
|
||||
$tokenRes = json_decode(curl_exec($chToken), true);
|
||||
curl_close($chToken);
|
||||
|
||||
if(!isset($tokenRes['data']['value'])) {
|
||||
echo json_encode(['success' => false, 'error' => 'Konnte API Token nicht erstellen. Admin-Rechte?']); exit;
|
||||
}
|
||||
|
||||
$tokenIdToSave = $user . '!' . $tokenName;
|
||||
$tokenSecretToSave = $tokenRes['data']['value'];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO nodes (name, ip_address, token_id, token_secret, type) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$_POST['name'], $_POST['ip'], $_POST['user'], $_POST['pass'], $_POST['type'] ?? 'pve']);
|
||||
logAudit('Server hinzugefügt', "Node: {$_POST['name']} ({$_POST['ip']})");
|
||||
$stmt->execute([$name, $ip, $tokenIdToSave, $tokenSecretToSave, $type]);
|
||||
logAudit('Server hinzugefügt', "Node: {$name} ({$ip})");
|
||||
echo json_encode(['success' => true]); exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_users') { if (!$isAdmin) exit; $stmt = $pdo->query("SELECT id, username, role FROM users"); echo json_encode(['success' => true, 'data' => $stmt->fetchAll(PDO::FETCH_ASSOC)]); exit; }
|
||||
if ($action === 'delete_user') { if (!$isAdmin) exit; $stmt = $pdo->prepare("DELETE FROM users WHERE id = ?"); $stmt->execute([$_POST['id']]); logAudit('User gelöscht', "User ID: {$_POST['id']}"); echo json_encode(['success' => true]); exit; }
|
||||
if ($action === 'create_user') {
|
||||
@@ -72,10 +189,17 @@ if ($action === 'create_user') {
|
||||
|
||||
if ($action === 'get_pve_nodes') {
|
||||
if (!$isAdmin) exit;
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $resList = [];
|
||||
$stmt = $pdo->query("SELECT * FROM nodes WHERE type = 'pve'"); $resList = []; $seenNodes = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $node) {
|
||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes");
|
||||
if(isset($nData['data'])) { foreach($nData['data'] as $n) { if($n['status'] === 'online') { $resList[] = ['node_id' => $node['id'], 'host' => $n['node'], 'display' => $node['name'] . ' -> ' . $n['node']]; } } }
|
||||
if(isset($nData['data'])) {
|
||||
foreach($nData['data'] as $n) {
|
||||
if($n['status'] === 'online' && !isset($seenNodes[$n['node']])) {
|
||||
$seenNodes[$n['node']] = true;
|
||||
$resList[] = ['node_id' => $node['id'], 'host' => $n['node'], 'display' => $node['name'] . ' -> ' . $n['node']];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
echo json_encode(['success' => true, 'data' => $resList]); exit;
|
||||
}
|
||||
@@ -87,9 +211,9 @@ if ($action === 'create_vm') {
|
||||
if(!isset($nextIdRes['data'])) { echo json_encode(['success' => false, 'error' => 'Konnte keine freie VMID finden.']); exit; }
|
||||
$vmid = $nextIdRes['data'];
|
||||
$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']}");
|
||||
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') {
|
||||
@@ -106,9 +230,9 @@ if ($action === 'update_vm_config') {
|
||||
if(isset($_POST['memory'])) $params['memory'] = $_POST['memory'];
|
||||
if(isset($_POST['cores'])) $params['cores'] = $_POST['cores'];
|
||||
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);
|
||||
$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']}");
|
||||
echo json_encode(['success' => isset($res['data'])]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'add_vm_nic') {
|
||||
@@ -116,25 +240,25 @@ if ($action === 'add_vm_nic') {
|
||||
$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");
|
||||
$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}"]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'slot' => "net{$nextNic}", 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'resize_vm_disk') {
|
||||
if (!checkVmPermission($pdo, $_POST['vmid'])) exit;
|
||||
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/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'])]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'delete_vm') {
|
||||
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']}/{$_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'])]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_vm_snapshots') {
|
||||
@@ -148,11 +272,11 @@ if ($action === 'vm_snapshot_action') {
|
||||
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);
|
||||
$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]); }
|
||||
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 === '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"); }
|
||||
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}", '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", 'pve', 'POST'); }
|
||||
logAudit('Snapshot ' . $cmd, "VMID: {$_POST['vmid']} Snap: {$snapname}");
|
||||
echo json_encode(['success' => isset($res['data'])]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'get_backup_storages') {
|
||||
@@ -183,25 +307,52 @@ if ($action === 'get_vm_backups') {
|
||||
if ($action === 'create_backup') {
|
||||
if (!checkVmPermission($pdo, $_POST['vmid'])) exit;
|
||||
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/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'])]); exit;
|
||||
echo json_encode(['success' => isset($res['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
if ($action === 'restore_backup') {
|
||||
if (!checkVmPermission($pdo, $_POST['vmid'])) exit;
|
||||
$stmt = $pdo->prepare("SELECT * FROM nodes WHERE id = ?"); $stmt->execute([$_POST['node_id']]); $node = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}/{$_POST['vmid']}/status/current");
|
||||
if (isset($res['data']) && $res['data']['status'] === 'running') { echo json_encode(['success' => false, 'error' => 'VM muss gestoppt sein!']); exit; }
|
||||
$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]);
|
||||
if (isset($res['data']) && $res['data']['status'] === 'running') { echo json_encode(['success' => false, 'error' => 'VM gestoppt?']); exit; }
|
||||
$resRestore = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$_POST['host']}/{$_POST['type']}", 'pve', 'POST', ['vmid' => $_POST['vmid'], 'archive' => $_POST['archive'], 'force' => 1]);
|
||||
logAudit('Backup Restore', "VMID: {$_POST['vmid']} Archive: {$_POST['archive']}");
|
||||
echo json_encode(['success' => isset($resRestore['data'])]); exit;
|
||||
echo json_encode(['success' => isset($resRestore['data']), 'error' => 'API Fehler']); exit;
|
||||
}
|
||||
|
||||
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);
|
||||
$endpoint = $action === 'get_node_status' ? "/api2/json/nodes/{$_GET['host']}/status" : "/api2/json/nodes/{$_GET['host']}/{$_GET['type']}/{$_GET['vmid']}/status/current";
|
||||
|
||||
if ($action === 'get_node_status') {
|
||||
$nData = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes");
|
||||
$internalName = $nData['data'][0]['node'] ?? 'pve';
|
||||
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/status");
|
||||
$rrd = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes/{$internalName}/rrddata?timeframe=hour&cf=AVERAGE");
|
||||
|
||||
$netin = 0; $netout = 0;
|
||||
if (isset($rrd['data']) && is_array($rrd['data'])) {
|
||||
for ($i = count($rrd['data']) - 1; $i >= 0; $i--) {
|
||||
if (isset($rrd['data'][$i]['netin']) && $rrd['data'][$i]['netin'] !== null) {
|
||||
$netin = $rrd['data'][$i]['netin'];
|
||||
$netout = $rrd['data'][$i]['netout'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = $res['data'] ?? [];
|
||||
$data['netin'] = $netin;
|
||||
$data['netout'] = $netout;
|
||||
$data['is_rrd_net'] = true;
|
||||
|
||||
echo json_encode(['success' => true, 'data' => $data]); exit;
|
||||
} else {
|
||||
$endpoint = "/api2/json/nodes/{$_GET['host']}/{$_GET['type']}/{$_GET['vmid']}/status/current";
|
||||
$res = getProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], $endpoint);
|
||||
echo json_encode(['success' => true, 'data' => $res['data'] ?? []]); exit;
|
||||
}
|
||||
}
|
||||
?>
|
||||
+129
-17
@@ -11,11 +11,13 @@ if (!window.APP.isLoggedIn) {
|
||||
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; } }); }
|
||||
}
|
||||
|
||||
async function logout() { await fetch('api.php?action=logout'); window.location.reload(); }
|
||||
async function logout() {
|
||||
await fetch('api.php?action=logout');
|
||||
window.location.href = window.location.pathname + '?t=' + Date.now();
|
||||
}
|
||||
|
||||
if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
|
||||
// Inject Audit Log Button automatically into Sidebar for Admins
|
||||
if (window.APP.username === 'admin' || document.querySelector('a[onclick="openUserManager()"]')) {
|
||||
const userBtn = document.querySelector('a[onclick="openUserManager()"]');
|
||||
if (userBtn && !document.getElementById('btnAuditLog')) {
|
||||
@@ -24,6 +26,33 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
}
|
||||
}
|
||||
|
||||
const pwdModal = document.getElementById('passwordModal');
|
||||
window.openPasswordModal = function() { if(pwdModal) { pwdModal.classList.remove('hidden'); document.getElementById('changePasswordForm').reset(); } }
|
||||
window.closePasswordModal = function() { if(pwdModal) pwdModal.classList.add('hidden'); }
|
||||
|
||||
const pwdForm = document.getElementById('changePasswordForm');
|
||||
if(pwdForm) {
|
||||
pwdForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const oldP = document.getElementById('oldPassword').value;
|
||||
const newP = document.getElementById('newPassword').value;
|
||||
const confirmP = document.getElementById('newPasswordConfirm').value;
|
||||
|
||||
if (newP !== confirmP) { alert('Die neuen Passwörter stimmen nicht überein!'); return; }
|
||||
|
||||
const btn = this.querySelector('button[type="submit"]'); const oTxt = btn.innerText; btn.innerText = 'Speichere...';
|
||||
const fd = new FormData(); fd.append('old_password', oldP); fd.append('new_password', newP);
|
||||
|
||||
try {
|
||||
const res = await (await fetch('api.php?action=change_password', {method: 'POST', body: fd})).json();
|
||||
if(res.success) {
|
||||
alert('Passwort erfolgreich geändert! Bitte neu anmelden.');
|
||||
closePasswordModal(); logout();
|
||||
} else { alert(res.error || 'Fehler beim Ändern des Passworts.'); }
|
||||
} catch(err) { alert('Netzwerkfehler.'); } finally { btn.innerText = oTxt; }
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -32,7 +61,84 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
const ctx = document.getElementById('liveChart')?.getContext('2d'); let liveChart;
|
||||
if(ctx) { liveChart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'CPU (%)', borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }, { label: 'RAM (%)', borderColor: '#E57000', backgroundColor: 'rgba(229, 112, 0, 0.1)', borderWidth: 2, tension: 0.4, fill: true, data: [] }] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, scales: { x: { ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } }, y: { min: 0, max: 100, ticks: { color: '#9ca3af', callback: v => v + '%' }, grid: { color: '#33334d' } } }, plugins: { legend: { labels: { color: '#e2e8f0', usePointStyle: true } } } } }); }
|
||||
|
||||
async function fetchGlobalStats() { if(!document.getElementById('stat-cpu-text')) return; try { const res = await (await fetch('api.php?action=get_stats')).json(); if(res.success && res.data) { const d = res.data; document.getElementById('stat-cpu-text').innerText = `${d.cpu_percent}% (${d.cpu_cores} Cores)`; document.getElementById('stat-cpu-bar').style.width = `${d.cpu_percent}%`; let ramPercent = (d.ram_used / d.ram_total) * 100 || 0; document.getElementById('stat-ram-text').innerText = `${formatBytes(d.ram_used)} / ${formatBytes(d.ram_total)}`; document.getElementById('stat-ram-bar').style.width = `${ramPercent}%`; let diskPercent = (d.disk_used / d.disk_total) * 100 || 0; document.getElementById('stat-disk-text').innerText = `${formatBytes(d.disk_used)} / ${formatBytes(d.disk_total)}`; document.getElementById('stat-disk-bar').style.width = `${diskPercent}%`; if(liveChart) { const now = new Date(); const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0') + ':' + now.getSeconds().toString().padStart(2, '0'); liveChart.data.labels.push(timeStr); liveChart.data.datasets[0].data.push(d.cpu_percent); liveChart.data.datasets[1].data.push(ramPercent.toFixed(1)); if (liveChart.data.labels.length > 15) { liveChart.data.labels.shift(); liveChart.data.datasets[0].data.shift(); liveChart.data.datasets[1].data.shift(); } liveChart.update(); } } } catch (err) {} }
|
||||
const ctxNet = document.getElementById('liveNetChart')?.getContext('2d'); let liveNetChart;
|
||||
if(ctxNet) { liveNetChart = new Chart(ctxNet, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, animation: { duration: 500 }, scales: { x: { ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } }, y: { min: 0, ticks: { color: '#9ca3af' }, grid: { color: '#33334d' } } }, plugins: { legend: { labels: { color: '#e2e8f0', usePointStyle: true } } } } }); }
|
||||
|
||||
let prevGlobalTime = null;
|
||||
|
||||
async function fetchGlobalStats() {
|
||||
if(!document.getElementById('stat-cpu-text')) return;
|
||||
try {
|
||||
const res = await (await fetch('api.php?action=get_stats')).json();
|
||||
if(res.success && res.data) {
|
||||
const d = res.data;
|
||||
document.getElementById('stat-cpu-text').innerText = `${d.cpu_percent}% (${d.cpu_cores} Cores)`;
|
||||
document.getElementById('stat-cpu-bar').style.width = `${d.cpu_percent}%`;
|
||||
let ramPercent = (d.ram_used / d.ram_total) * 100 || 0;
|
||||
document.getElementById('stat-ram-text').innerText = `${formatBytes(d.ram_used)} / ${formatBytes(d.ram_total)}`;
|
||||
document.getElementById('stat-ram-bar').style.width = `${ramPercent}%`;
|
||||
let diskPercent = (d.disk_used / d.disk_total) * 100 || 0;
|
||||
document.getElementById('stat-disk-text').innerText = `${formatBytes(d.disk_used)} / ${formatBytes(d.disk_total)}`;
|
||||
document.getElementById('stat-disk-bar').style.width = `${diskPercent}%`;
|
||||
|
||||
// Neue Übersichtskachel füttern
|
||||
if (d.cluster_stats) {
|
||||
const elNodesOn = document.getElementById('stat-nodes-online');
|
||||
if (elNodesOn) {
|
||||
elNodesOn.innerText = d.cluster_stats.nodes_online;
|
||||
if (d.cluster_stats.nodes_online < d.cluster_stats.nodes_total) {
|
||||
elNodesOn.className = 'text-red-500';
|
||||
} else {
|
||||
elNodesOn.className = 'text-green-500';
|
||||
}
|
||||
}
|
||||
if(document.getElementById('stat-nodes-total')) document.getElementById('stat-nodes-total').innerText = d.cluster_stats.nodes_total;
|
||||
if(document.getElementById('stat-vms-total')) document.getElementById('stat-vms-total').innerText = d.cluster_stats.vms_total;
|
||||
if(document.getElementById('stat-vms-run')) document.getElementById('stat-vms-run').innerText = d.cluster_stats.vms_running;
|
||||
if(document.getElementById('stat-vms-stop')) document.getElementById('stat-vms-stop').innerText = d.cluster_stats.vms_stopped;
|
||||
}
|
||||
|
||||
if(liveChart) {
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0') + ':' + now.getSeconds().toString().padStart(2, '0');
|
||||
liveChart.data.labels.push(timeStr);
|
||||
liveChart.data.datasets[0].data.push(d.cpu_percent);
|
||||
liveChart.data.datasets[1].data.push(ramPercent.toFixed(1));
|
||||
if (liveChart.data.labels.length > 15) {
|
||||
liveChart.data.labels.shift(); liveChart.data.datasets[0].data.shift(); liveChart.data.datasets[1].data.shift();
|
||||
}
|
||||
liveChart.update();
|
||||
|
||||
if(liveNetChart && d.nodes_net) {
|
||||
const nowTs = Date.now();
|
||||
if (prevGlobalTime !== null) {
|
||||
liveNetChart.data.labels.push(timeStr);
|
||||
if (liveNetChart.data.labels.length > 15) liveNetChart.data.labels.shift();
|
||||
const colors = ['#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#06b6d4'];
|
||||
|
||||
d.nodes_net.forEach((n, idx) => {
|
||||
let rxSpeed = n.netin / (1024 * 1024);
|
||||
let txSpeed = n.netout / (1024 * 1024);
|
||||
let totalSpeed = (rxSpeed + txSpeed).toFixed(2);
|
||||
|
||||
let ds = liveNetChart.data.datasets.find(ds => ds.label === n.name);
|
||||
if (!ds) {
|
||||
const c = colors[idx % colors.length];
|
||||
ds = { label: n.name, borderColor: c, backgroundColor: c + '1a', borderWidth: 2, tension: 0.4, fill: true, data: new Array(Math.max(0, liveNetChart.data.labels.length - 1)).fill(0) };
|
||||
liveNetChart.data.datasets.push(ds);
|
||||
}
|
||||
ds.data.push(totalSpeed);
|
||||
if (ds.data.length > 15) ds.data.shift();
|
||||
});
|
||||
liveNetChart.update();
|
||||
}
|
||||
prevGlobalTime = nowTs;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
async function fetchTopVms() { if(!document.getElementById('top-vms-container')) return; try { const res = await (await fetch('api.php?action=get_top_vms')).json(); if(res.success && res.data) { const container = document.getElementById('top-vms-container'); container.innerHTML = ''; if(res.data.length === 0) { container.innerHTML = '<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) {} }
|
||||
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) {} }
|
||||
@@ -196,12 +302,28 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
const d = res.data; const now = Date.now();
|
||||
let cpuRaw = 0; if (d.cpu !== undefined) cpuRaw = d.cpu; else if (d.cpuinfo && d.cpuinfo.cpus) cpuRaw = 0; const cpu = (cpuRaw * 100).toFixed(1);
|
||||
let ram = 0; if (d.maxmem && d.maxmem > 0) { ram = ((d.mem / d.maxmem) * 100).toFixed(1); } else if (d.memory && d.memory.total > 0) { ram = ((d.memory.used / d.memory.total) * 100).toFixed(1); }
|
||||
let currentNetIn = d.netin || 0; let currentNetOut = d.netout || 0; let rxSpeed = 0; let txSpeed = 0;
|
||||
if(prevTime !== null) { const timeSec = (now - prevTime) / 1000; if (timeSec > 0) { rxSpeed = Math.max(0, ((currentNetIn - prevNetIn) / timeSec / (1024 * 1024))).toFixed(2); txSpeed = Math.max(0, ((currentNetOut - prevNetOut) / timeSec / (1024 * 1024))).toFixed(2); } }
|
||||
|
||||
let currentNetIn = d.netin || 0; let currentNetOut = d.netout || 0;
|
||||
let rxSpeed = 0; let txSpeed = 0;
|
||||
|
||||
if (d.is_rrd_net) {
|
||||
rxSpeed = (currentNetIn / (1024 * 1024)).toFixed(2);
|
||||
txSpeed = (currentNetOut / (1024 * 1024)).toFixed(2);
|
||||
} else {
|
||||
if(prevTime !== null) {
|
||||
const timeSec = (now - prevTime) / 1000;
|
||||
if (timeSec > 0) {
|
||||
rxSpeed = Math.max(0, ((currentNetIn - prevNetIn) / timeSec / (1024 * 1024))).toFixed(2);
|
||||
txSpeed = Math.max(0, ((currentNetOut - prevNetOut) / timeSec / (1024 * 1024))).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevNetIn = currentNetIn; prevNetOut = currentNetOut; prevTime = now;
|
||||
const timeStr = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
perfChartObj.data.labels.push(timeStr); perfChartObj.data.datasets[0].data.push(cpu); perfChartObj.data.datasets[1].data.push(ram); if(perfChartObj.data.labels.length > 30) { perfChartObj.data.labels.shift(); perfChartObj.data.datasets[0].data.shift(); perfChartObj.data.datasets[1].data.shift(); } perfChartObj.update();
|
||||
if(prevTime !== null) { netChartObj.data.labels.push(timeStr); netChartObj.data.datasets[0].data.push(rxSpeed); netChartObj.data.datasets[1].data.push(txSpeed); if(netChartObj.data.labels.length > 30) { netChartObj.data.labels.shift(); netChartObj.data.datasets[0].data.shift(); netChartObj.data.datasets[1].data.shift(); } netChartObj.update(); }
|
||||
if(prevTime !== null || d.is_rrd_net) { netChartObj.data.labels.push(timeStr); netChartObj.data.datasets[0].data.push(rxSpeed); netChartObj.data.datasets[1].data.push(txSpeed); if(netChartObj.data.labels.length > 30) { netChartObj.data.labels.shift(); netChartObj.data.datasets[0].data.shift(); netChartObj.data.datasets[1].data.shift(); } netChartObj.update(); }
|
||||
}
|
||||
} catch(e) {}
|
||||
};
|
||||
@@ -488,7 +610,6 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
try { const res = await (await fetch('api.php?action=pmg_upload_ssl', {method: 'POST', body: fd})).json(); if(res.success) { alert('Zertifikat hochgeladen! Dienste werden neu gestartet.'); document.getElementById('pmgSslCert').value = ''; document.getElementById('pmgSslKey').value = ''; loadPmgSsl(document.getElementById('pmgNodeId').value); } else { alert('Fehler beim Upload. (Format prüfen)'); } } catch(e) { alert('Netzwerkfehler.'); } finally { btn.innerText = oTxt; }
|
||||
}
|
||||
|
||||
// === SCHEDULER ===
|
||||
const cronModal = document.getElementById('cronManagerModal');
|
||||
window.openCronManager = async function() {
|
||||
cronModal.classList.remove('hidden');
|
||||
@@ -537,7 +658,6 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
window.toggleCronJob = async function(id, newState) { const fd = new FormData(); fd.append('id', id); fd.append('is_active', newState); await fetch('api.php?action=toggle_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
||||
window.deleteCronJob = async function(id) { if(!confirm('Diesen geplanten Job wirklich löschen?')) return; const fd = new FormData(); fd.append('id', id); await fetch('api.php?action=delete_cron_job', {method: 'POST', body: fd}); loadCronJobs(); }
|
||||
|
||||
// === NEU: AUDIT LOG ===
|
||||
const auditModal = document.getElementById('auditLogModal');
|
||||
window.openAuditLog = async function() {
|
||||
auditModal.classList.remove('hidden');
|
||||
@@ -549,18 +669,10 @@ if (window.APP.isLoggedIn && window.APP.nodeCount > 0) {
|
||||
tbody.innerHTML = '';
|
||||
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; }
|
||||
res.data.forEach(log => {
|
||||
// Konvertiere SQLite DATETIME in lokales deutsches Format
|
||||
const d = new Date(log.timestamp + 'Z');
|
||||
const timeStr = d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE');
|
||||
const isSystem = log.username === 'System';
|
||||
|
||||
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>
|
||||
<td class="px-4 py-3"><span class="${isSystem ? 'text-gray-500' : 'text-blue-400 font-bold'}">${log.username}</span></td>
|
||||
<td class="px-4 py-3 font-bold text-white">${log.action}</td>
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">${log.target || '-'}</td>
|
||||
</tr>`;
|
||||
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><td class="px-4 py-3"><span class="${isSystem ? 'text-gray-500' : 'text-blue-400 font-bold'}">${log.username}</span></td><td class="px-4 py-3 font-bold text-white">${log.action}</td><td class="px-4 py-3 text-gray-300 text-xs">${log.target || '-'}</td></tr>`;
|
||||
});
|
||||
}
|
||||
} catch(e) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-red-500 py-4">Fehler beim Laden.</td></tr>'; }
|
||||
|
||||
+3
-9
@@ -2,7 +2,6 @@
|
||||
// /home/docker/pve_dashboard/src/cron.php
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
// Hilfsfunktion: Prüft, ob ein Cron-Ausdruck (z.B. "0 3 * * *") zur aktuellen Zeit passt
|
||||
function isCronMatch($cron, $time = null) {
|
||||
if ($time === null) $time = time();
|
||||
$cronParts = explode(' ', trim($cron));
|
||||
@@ -20,14 +19,14 @@ function isCronMatch($cron, $time = null) {
|
||||
function matchCronPart($part, $current) {
|
||||
if ($part === '*') return true;
|
||||
if ($part === (string)(int)$current) return true;
|
||||
if (strpos($part, '*/') === 0) { // Unterstützt z.B. */5 für "alle 5 Minuten"
|
||||
if (strpos($part, '*/') === 0) {
|
||||
$step = (int)substr($part, 2);
|
||||
return $step > 0 && ((int)$current % $step) === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mini-Proxmox-Fetcher für das Cron-Skript
|
||||
// Mini-Proxmox-Fetcher für das Cron-Skript (Mit Token-Auth für PVE!)
|
||||
function cronProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $method = "POST", $postData = null) {
|
||||
$headers = ["Authorization: PVEAPIToken={$tokenId}={$tokenSecret}"];
|
||||
$ch = curl_init("https://{$ip}:8006{$endpoint}");
|
||||
@@ -39,12 +38,11 @@ function cronProxmoxData($ip, $tokenId, $tokenSecret, $endpoint, $method = "POST
|
||||
if ($postData) $options[CURLOPT_POSTFIELDS] = http_build_query($postData);
|
||||
curl_setopt_array($ch, $options);
|
||||
$res = curl_exec($ch); curl_close($ch);
|
||||
return json_decode($res, true);
|
||||
return json_decode($res, true) ?: [];
|
||||
}
|
||||
|
||||
echo "[" . date('Y-m-d H:i:s') . "] Starte Scheduler-Check...\n";
|
||||
|
||||
// Hole alle aktiven Tasks
|
||||
$stmt = $pdo->query("SELECT * FROM scheduled_tasks WHERE is_active = 1");
|
||||
$tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -59,7 +57,6 @@ foreach ($tasks as $task) {
|
||||
if ($node) {
|
||||
$success = false;
|
||||
|
||||
// AKTION: PVE NODE NEUSTART
|
||||
if ($task['action_type'] === 'reboot_node') {
|
||||
$nData = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/nodes", 'GET');
|
||||
if (isset($nData['data'][0]['node'])) {
|
||||
@@ -68,9 +65,7 @@ foreach ($tasks as $task) {
|
||||
$success = true;
|
||||
}
|
||||
}
|
||||
// AKTION: VM START / STOP / REBOOT
|
||||
elseif (in_array($task['action_type'], ['start_vm', 'stop_vm', 'reboot_vm'])) {
|
||||
// Suche Host und Typ der VM
|
||||
$vmsRes = cronProxmoxData($node['ip_address'], $node['token_id'], $node['token_secret'], "/api2/json/cluster/resources?type=vm", 'GET');
|
||||
if (isset($vmsRes['data'])) {
|
||||
foreach ($vmsRes['data'] as $vm) {
|
||||
@@ -85,7 +80,6 @@ foreach ($tasks as $task) {
|
||||
}
|
||||
}
|
||||
|
||||
// Setze den Zeitstempel für den letzten Durchlauf
|
||||
if ($success) {
|
||||
$uStmt = $pdo->prepare("UPDATE scheduled_tasks SET last_run = ? WHERE id = ?");
|
||||
$uStmt->execute([time(), $task['id']]);
|
||||
|
||||
+30
-8
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
// /home/docker/pve_dashboard/src/db.php
|
||||
session_start();
|
||||
try {
|
||||
// Hier ist der Fix: Der Pfad zeigt wieder auf das ausgelagerte /data Verzeichnis!
|
||||
$pdo = new PDO('sqlite:' . __DIR__ . '/../data/dashboard.sqlite');
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
// 1. User Tabelle
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -14,6 +15,7 @@ try {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)");
|
||||
|
||||
// 2. Nodes Tabelle
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -23,19 +25,39 @@ try {
|
||||
type TEXT DEFAULT 'pve'
|
||||
)");
|
||||
|
||||
// Automatisches Datenbank-Upgrade für den PBS! (Fügt Spalte 'type' hinzu, falls sie fehlt)
|
||||
try {
|
||||
$pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'");
|
||||
} catch (PDOException $e) {
|
||||
// Ignorieren, wenn die Spalte bereits existiert
|
||||
}
|
||||
// Automatisches Upgrade für ältere Versionen
|
||||
try { $pdo->exec("ALTER TABLE nodes ADD COLUMN type TEXT DEFAULT 'pve'"); } catch (PDOException $e) {}
|
||||
|
||||
// 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");
|
||||
if ($stmt->fetchColumn() == 0) {
|
||||
$hash = password_hash('admin', PASSWORD_DEFAULT);
|
||||
$pdo->exec("INSERT INTO users (username, password_hash, role) VALUES ('admin', '$hash', 'admin')");
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
die("Datenbank-Fehler: " . $e->getMessage());
|
||||
die(json_encode(['success' => false, 'error' => "Datenbank-Fehler: " . $e->getMessage()]));
|
||||
}
|
||||
?>
|
||||
+52
-4
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
// /home/docker/pve_dashboard/src/index.php
|
||||
require_once 'db.php';
|
||||
$isLoggedIn = isset($_SESSION['user_id']);
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM nodes");
|
||||
@@ -37,6 +38,9 @@ $nodeCount = $stmt->fetchColumn();
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<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>
|
||||
<button onclick="openPasswordModal()" class="text-gray-400 hover:text-white transition-colors" title="Passwort ändern">
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4v-3.286l5.742-5.742C9.4 11.135 9 10.126 9 9a6 6 0 0112 0z"></path></svg>
|
||||
</button>
|
||||
<button onclick="logout()" class="text-red-400 hover:text-red-300 font-medium transition-colors">Abmelden</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
@@ -78,13 +82,58 @@ $nodeCount = $stmt->fetchColumn();
|
||||
|
||||
<!-- TAB 1: PVE -->
|
||||
<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">
|
||||
|
||||
<!-- NEUE TOP KACHEL: Gesamtübersicht -->
|
||||
<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="flex items-center gap-4">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Hardware 4 Columns -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-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">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="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="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="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>
|
||||
<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 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>
|
||||
|
||||
@@ -109,7 +158,6 @@ $nodeCount = $stmt->fetchColumn();
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
@@ -125,6 +173,6 @@ $nodeCount = $stmt->fetchColumn();
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
@@ -95,3 +95,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- NEU: PASSWORT ÄNDERN MODAL -->
|
||||
<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>
|
||||
Reference in New Issue
Block a user