prepare('SELECT username, role, status FROM users WHERE id = :id LIMIT 1'); $currentUserQuery->execute(['id' => $userId]); $currentUser = $currentUserQuery->fetch(); } catch (Throwable $exception) { error_log('PnPaaS session user lookup error: ' . $exception->getMessage()); $currentUser = false; } if (!is_array($currentUser) || (string)$currentUser['status'] !== 'active') { $_SESSION = []; session_destroy(); header('Location: index.php'); exit; } $_SESSION['pnpaas_admin'] = (string)$currentUser['username']; $_SESSION['pnpaas_role'] = (string)$currentUser['role']; $role = (string)$currentUser['role']; $username = (string)$currentUser['username']; $isAdmin = $role === 'admin'; $csrf = pnpaas_csrf_token(); $message = null; $error = null; $plans = [ 's' => ['label' => 'S', 'memory' => 512, 'cpu' => 1.00, 'storage' => 1024], 'm' => ['label' => 'M', 'memory' => 2048, 'cpu' => 2.00, 'storage' => 10240], 'l' => ['label' => 'L', 'memory' => 4096, 'cpu' => 4.00, 'storage' => 25600], ]; function dashboard_escape(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } function dashboard_status(string $status): string { return match ($status) { 'pending_payment' => 'Zahlung ausstehend', 'provisioning' => 'Wird eingerichtet', 'running' => 'Läuft', 'stopped' => 'Gestoppt', 'error' => 'Fehler', default => ucfirst($status), }; } function dashboard_debug_detail(Throwable $exception): string { $detail = trim($exception->getMessage()); $detail = preg_replace('/(?:X-API-Key|Authorization|Bearer)\s*:\s*[^\s,;]+/i', '[credential redacted]', $detail) ?? $detail; $detail = preg_replace('/("?(?:token|password|api[_-]?key|secret)"?\s*[:=]\s*)[^,;\s}]+/i', '$1[redacted]', $detail) ?? $detail; return substr($detail !== '' ? $detail : 'Keine technische Fehlerbeschreibung vorhanden.', 0, 800); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!pnpaas_valid_csrf((string)($_POST['csrf_token'] ?? ''))) { $error = 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.'; } else { $action = (string)($_POST['action'] ?? ''); try { $db = pnpaas_db(); if ($action === 'request_instance') { $name = trim((string)($_POST['name'] ?? '')); $planKey = strtolower(trim((string)($_POST['plan'] ?? ''))); $paymentMethod = (string)($_POST['payment_method'] ?? 'bank_transfer'); if ($name === '' || !preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{1,119}$/', $name)) { throw new RuntimeException('Bitte einen gültigen Instanznamen eingeben.'); } if (!isset($plans[$planKey]) || !in_array($paymentMethod, ['paypal', 'bank_transfer'], true)) { throw new RuntimeException('Tarif oder Zahlungsart ist ungültig.'); } $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $name) ?? 'foundry'); $containerName = 'pnpaas-' . trim($slug, '-') . '-' . bin2hex(random_bytes(3)); $port = (int)$db->query('SELECT COALESCE(MAX(port), 29999) + 1 FROM instances')->fetchColumn(); if ($port > 60000) throw new RuntimeException('Es sind derzeit keine Ports verfügbar.'); $p = $plans[$planKey]; $statement = $db->prepare('INSERT INTO instances (owner_user_id, name, plan, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :plan, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")'); $statement->execute(['owner' => $userId, 'name' => $name, 'plan' => $planKey, 'method' => $paymentMethod, 'container' => $containerName, 'port' => $port, 'storage' => $p['storage'], 'memory' => $p['memory'], 'cpu' => $p['cpu']]); $message = 'Die Instanzanfrage wurde erfasst und wartet auf die Zahlungsfreigabe.'; } elseif ($action === 'approve_instance' && $isAdmin) { $instanceId = (int)($_POST['instance_id'] ?? 0); $query = $db->prepare('SELECT * FROM instances WHERE id = :id AND ((status = "pending_payment") OR (status = "error" AND payment_status = "paid")) LIMIT 1'); $query->execute(['id' => $instanceId]); $instance = $query->fetch(); if (!$instance) throw new RuntimeException('Diese Instanzanfrage ist nicht mehr freigabefähig.'); $db->prepare('UPDATE instances SET payment_status = "paid", paid_at = CURRENT_TIMESTAMP, status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); $endpoint = pnpaas_portainer_endpoint_id(); $dataPath = '/opt/foundry/instances/' . $instance['container_name']; $payload = [ 'Image' => 'pnpaas/foundryvtt:14.368', 'Env' => ['FOUNDRY_VERSION=14.368'], 'ExposedPorts' => ['30000/tcp' => new stdClass()], 'HostConfig' => [ 'Binds' => [$dataPath . ':/data'], 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], 'Memory' => (int)$instance['memory_limit_mb'] * 1024 * 1024, 'NanoCpus' => (int)((float)$instance['cpu_limit'] * 1000000000), 'RestartPolicy' => ['Name' => 'unless-stopped'], ], ]; $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); $containerId = (string)($created['Id'] ?? ''); if ($containerId === '') throw new RuntimeException('Portainer hat keine Container-ID geliefert.'); pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . rawurlencode($containerId) . '/start', 'POST', []); $db->prepare('UPDATE instances SET portainer_container_id = :cid, status = "running" WHERE id = :id')->execute(['cid' => $containerId, 'id' => $instanceId]); $message = 'Die Instanz wurde bezahlt markiert, erstellt und gestartet.'; } elseif ($action === 'instance_action') { $instanceId = (int)($_POST['instance_id'] ?? 0); $operation = (string)($_POST['operation'] ?? ''); $allowedOperations = ['start', 'stop', 'restart', 'redeploy', 'delete']; if (!in_array($operation, $allowedOperations, true)) { throw new RuntimeException('Nicht unterstützte Instanzaktion.'); } $query = $db->prepare('SELECT * FROM instances WHERE id = :id AND status <> "deleted" AND (owner_user_id = :owner OR :is_admin = 1) LIMIT 1'); $query->execute(['id' => $instanceId, 'owner' => $userId, 'is_admin' => $isAdmin ? 1 : 0]); $instance = $query->fetch(); if (!$instance || empty($instance['portainer_container_id'])) { throw new RuntimeException('Die Instanz ist nicht verfügbar.'); } if ($operation === 'delete' && !$isAdmin) { throw new RuntimeException('Nur Administratoren dürfen Instanzen löschen.'); } $endpoint = pnpaas_portainer_endpoint_id(); $containerId = rawurlencode((string)$instance['portainer_container_id']); if ($operation === 'redeploy') { $db->prepare('UPDATE instances SET status = "provisioning", error_message = NULL WHERE id = :id')->execute(['id' => $instanceId]); pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '?force=true', 'DELETE', null); $dataPath = '/opt/foundry/instances/' . $instance['container_name']; $payload = [ 'Image' => 'pnpaas/foundryvtt:14.368', 'Env' => ['FOUNDRY_VERSION=14.368'], 'ExposedPorts' => ['30000/tcp' => new stdClass()], 'HostConfig' => [ 'Binds' => [$dataPath . ':/data'], 'PortBindings' => ['30000/tcp' => [['HostPort' => (string)$instance['port']]]], 'Memory' => (int)$instance['memory_limit_mb'] * 1024 * 1024, 'NanoCpus' => (int)((float)$instance['cpu_limit'] * 1000000000), 'RestartPolicy' => ['Name' => 'unless-stopped'], ], ]; $created = pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/create?name=' . rawurlencode($instance['container_name']), 'POST', $payload); $newContainerId = (string)($created['Id'] ?? ''); if ($newContainerId === '') throw new RuntimeException('Portainer hat keine neue Container-ID geliefert.'); pnpaas_portainer_request('/api/endpoints/' . $endpoint . '/docker/containers/' . rawurlencode($newContainerId) . '/start', 'POST', []); $db->prepare('UPDATE instances SET portainer_container_id = :cid, status = "running", error_message = NULL WHERE id = :id')->execute(['cid' => $newContainerId, 'id' => $instanceId]); $message = 'Die Instanz wurde neu bereitgestellt. Die Daten in /data wurden beibehalten.'; } else { $apiPath = $operation === 'delete' ? '/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '?force=true' : '/api/endpoints/' . $endpoint . '/docker/containers/' . $containerId . '/' . $operation; pnpaas_portainer_request($apiPath, $operation === 'delete' ? 'DELETE' : 'POST', []); $newStatus = match ($operation) { 'start', 'restart' => 'running', 'stop' => 'stopped', 'delete' => 'deleted', }; $db->prepare('UPDATE instances SET status = :status, error_message = NULL WHERE id = :id')->execute(['status' => $newStatus, 'id' => $instanceId]); $message = match ($operation) { 'start' => 'Die Instanz wurde gestartet.', 'stop' => 'Die Instanz wurde gestoppt.', 'restart' => 'Die Instanz wurde neu gestartet.', 'delete' => 'Die Instanz wurde gelöscht. Das Datenverzeichnis bleibt zur Sicherheit erhalten.', }; } } } catch (Throwable $exception) { if (in_array(($action ?? ''), ['approve_instance', 'instance_action'], true) && isset($instanceId)) { try { pnpaas_db()->prepare('UPDATE instances SET status = "error", error_message = :error WHERE id = :id')->execute(['error' => 'Die Bereitstellung oder Aktion konnte nicht abgeschlossen werden.', 'id' => $instanceId]); } catch (Throwable) {} } $debugDetail = dashboard_debug_detail($exception); error_log('PnPaaS instance action error: ' . $debugDetail); $error = 'Die angeforderte Aktion konnte nicht ausgeführt werden. Bitte versuchen Sie es erneut.'; if ($isAdmin) $error .= ' Technischer Hinweis: ' . $debugDetail; } } } $view = (string)($_GET['view'] ?? ''); $showCustomers = $isAdmin && $view === 'customers'; $showInstances = !$isAdmin || $view === 'instances' || ($isAdmin && $_SERVER['REQUEST_METHOD'] === 'POST'); $instances = []; $customers = []; $realms = []; $realmError = null; if ($isAdmin && !$showInstances) { try { $configuredEndpointId = pnpaas_portainer_endpoint_id(); // Die Endpoint-Metadaten sind für eingeschränkte Portainer-Benutzer nicht immer lesbar. // Die konfigurierte ID reicht aus; der eigentliche Zugriff erfolgt über den Docker-Proxy. $endpoints = [[ 'Id' => $configuredEndpointId, 'Name' => 'Portainer-Umgebung ' . $configuredEndpointId, 'Status' => 1, ]]; foreach ($endpoints as $endpoint) { $endpointId = (int)($endpoint['Id'] ?? 0); if ($endpointId < 1) continue; $realm = [ 'name' => (string)($endpoint['Name'] ?? ('Realm ' . $endpointId)), 'online' => ((int)($endpoint['Status'] ?? 0) === 1), 'version' => 'nicht verfügbar', 'cpus' => 0, 'cpu_percent' => 0.0, 'memory_total' => 0, 'memory_used' => 0, 'disk_used' => 0, 'docker_root' => 'nicht verfügbar', 'containers_running' => 0, 'containers_total' => 0, ]; try { $info = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/info'); $disk = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/system/df'); $containers = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/containers/json?all=1'); $memoryUsed = 0; $cpuPercent = 0.0; foreach ($containers as $container) { if (($container['State'] ?? '') !== 'running' || empty($container['Id'])) continue; try { $stats = pnpaas_portainer_request('/api/endpoints/' . $endpointId . '/docker/containers/' . rawurlencode((string)$container['Id']) . '/stats?stream=false'); $memoryUsed += (int)($stats['memory_stats']['usage'] ?? 0); $cpuDelta = (int)($stats['cpu_stats']['cpu_usage']['total_usage'] ?? 0) - (int)($stats['precpu_stats']['cpu_usage']['total_usage'] ?? 0); $systemDelta = (int)($stats['cpu_stats']['system_cpu_usage'] ?? 0) - (int)($stats['precpu_stats']['system_cpu_usage'] ?? 0); $onlineCpus = max(1, (int)($stats['cpu_stats']['online_cpus'] ?? $info['NCPU'] ?? 1)); if ($systemDelta > 0) $cpuPercent += ($cpuDelta / $systemDelta) * $onlineCpus * 100; } catch (Throwable) { // Ein einzelner Container darf die Realm-Anzeige nicht ausfallen lassen. } } $diskUsed = (int)($disk['LayersSize'] ?? 0); foreach (($disk['Containers'] ?? []) as $item) $diskUsed += (int)($item['SizeRw'] ?? 0); foreach (($disk['BuildCache'] ?? []) as $item) $diskUsed += (int)($item['Size'] ?? 0); $realm['online'] = true; $realm['version'] = (string)($info['ServerVersion'] ?? 'unbekannt'); $realm['cpus'] = (int)($info['NCPU'] ?? 0); $realm['cpu_percent'] = $cpuPercent; $realm['memory_total'] = (int)($info['MemTotal'] ?? 0); $realm['memory_used'] = $memoryUsed; $realm['disk_used'] = $diskUsed; $realm['docker_root'] = (string)($info['DockerRootDir'] ?? 'unbekannt'); $realm['containers_running'] = (int)($info['ContainersRunning'] ?? 0); $realm['containers_total'] = (int)($info['Containers'] ?? 0); } catch (Throwable $exception) { error_log('PnPaaS realm ' . $endpointId . ' error: ' . $exception->getMessage()); } $realms[] = $realm; } } catch (Throwable $exception) { error_log('PnPaaS realm dashboard error: ' . $exception->getMessage()); $realmError = 'Die Realm-Daten konnten derzeit nicht vollständig geladen werden.'; } } if ($showInstances) { $statement = $isAdmin ? pnpaas_db()->query('SELECT i.*, u.username FROM instances i JOIN users u ON u.id = i.owner_user_id ORDER BY i.created_at DESC') : (function () use ($userId) { $s = pnpaas_db()->prepare('SELECT i.*, u.username FROM instances i JOIN users u ON u.id = i.owner_user_id WHERE i.owner_user_id = :owner ORDER BY i.created_at DESC'); $s->execute(['owner' => $userId]); return $s; })(); $instances = $statement->fetchAll(); } if ($showCustomers) { $customers = pnpaas_db()->query('SELECT id, username, email, role, status, created_at FROM users ORDER BY created_at DESC')->fetchAll(); } function dashboard_bytes(int $bytes): string { if ($bytes <= 0) return 'nicht verfügbar'; $units = ['B', 'KB', 'MB', 'GB', 'TB']; $index = min((int)floor(log($bytes, 1024)), count($units) - 1); return number_format($bytes / (1024 ** $index), 1, ',', '.') . ' ' . $units[$index]; } ?> PnPaaS – Dashboard
PnPaaS
Abmelden

Systemübersicht

Realms

Status und Ressourcen der Portainer-Server, auf denen die FoundryVTT-Instanzen ausgeführt werden.

Keine Realms konfiguriert.

Portainer-Server

Docker / Container aktiv
CPU % CPU-Kerne · Container-Auslastung
RAMvon Docker-Speicher
Festplattebelegte Docker-Daten · Kapazität nicht gemeldet

Administration

Kundenverwaltung

Benutzerkonten und deren Zugriffsstatus.

BenutzernameE-MailRolleStatusAngelegt

Übersicht

FoundryVTT-Instanzen buchen und ihren Bereitstellungsstatus verfolgen.

Neue Buchung

Instanz anfragen

S · M · L

Provisionierung

Instanzen

← Realms
Noch keine Instanzen vorhanden.
NameBenutzerTarifZahlungStatusAdresseAktion

: