Files

310 lines
28 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/bootstrap.php';
pnpaas_session_start();
if (empty($_SESSION['pnpaas_user_id'])) {
header('Location: index.php');
exit;
}
$userId = (int)$_SESSION['pnpaas_user_id'];
try {
$currentUserQuery = pnpaas_db()->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' => 5120, 'monthly_cents' => 299, 'yearly_cents' => 2999],
'm' => ['label' => 'M', 'memory' => 2048, 'cpu' => 2.00, 'storage' => 10240, 'monthly_cents' => 399, 'yearly_cents' => 3999],
'l' => ['label' => 'L', 'memory' => 4096, 'cpu' => 4.00, 'storage' => 25600, 'monthly_cents' => 499, 'yearly_cents' => 4999],
];
function dashboard_escape(string $value): string { return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); }
function dashboard_money(int $cents): string { return $cents > 0 ? number_format($cents / 100, 2, ',', '.') . ' €' : ''; }
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'] ?? '')));
$billingPeriod = (string)($_POST['billing_period'] ?? '');
$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($billingPeriod, ['monthly', 'yearly'], true) || !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, billing_period, price_gross_cents, payment_status, payment_method, container_name, port, volume_size_mb, memory_limit_mb, cpu_limit, status) VALUES (:owner, :name, :plan, :billing_period, :price_gross_cents, "pending", :method, :container, :port, :storage, :memory, :cpu, "pending_payment")');
$statement->execute(['owner' => $userId, 'name' => $name, 'plan' => $planKey, 'billing_period' => $billingPeriod, 'price_gross_cents' => $p[$billingPeriod . '_cents'], '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 WHERE i.status <> "deleted" 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 AND i.status <> "deleted" 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];
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>PnPaaS Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header class="topbar"><div class="topbar-brand"><strong>PnPaaS</strong><span class="topbar-subtitle"><?= $isAdmin ? 'Administration' : 'Kundenbereich' ?></span></div><nav class="main-nav" aria-label="Hauptnavigation"><a class="<?= !$showInstances && !$showCustomers ? 'active' : '' ?>" href="dashboard.php">Dashboard</a><a class="<?= $showInstances ? 'active' : '' ?>" href="dashboard.php?view=instances">Instanzverwaltung</a><?php if ($isAdmin): ?><a class="<?= $showCustomers ? 'active' : '' ?>" href="dashboard.php?view=customers">Kundenverwaltung</a><?php endif; ?></nav><div class="topbar-actions"><span><?= dashboard_escape($username) ?></span><a href="logout.php">Abmelden</a></div></header>
<main class="dashboard">
<?php if ($isAdmin && !$showInstances && !$showCustomers): ?>
<p class="eyebrow">Systemübersicht</p><h1>Realms</h1>
<p class="intro">Status und Ressourcen der Portainer-Server, auf denen die FoundryVTT-Instanzen ausgeführt werden.</p>
<?php if ($realmError !== null): ?><div class="alert alert-error" role="alert"><?= dashboard_escape($realmError) ?></div><?php endif; ?>
<?php if ($realms === [] && $realmError === null): ?><div class="empty-state">Keine Realms konfiguriert.</div><?php else: ?><section class="realm-grid" aria-label="Realm-Status">
<?php foreach ($realms as $realm): ?><article class="realm-card"><div class="realm-card-header"><div><p class="eyebrow">Portainer-Server</p><h2><?= dashboard_escape($realm['name']) ?></h2></div><span class="status-badge <?= $realm['online'] ? 'status-online' : 'status-offline' ?>"><?= $realm['online'] ? 'Server Up' : 'Server Down' ?></span></div>
<div class="realm-meta"><span>Docker <?= dashboard_escape($realm['version']) ?></span><span><?= (int)$realm['containers_running'] ?> / <?= (int)$realm['containers_total'] ?> Container aktiv</span></div>
<div class="realm-metrics"><div class="realm-metric"><span class="metric-label">CPU</span><strong><?= number_format((float)$realm['cpu_percent'], 1, ',', '.') ?> %</strong><small><?= (int)$realm['cpus'] ?> CPU-Kerne · Container-Auslastung</small></div><div class="realm-metric"><span class="metric-label">RAM</span><strong><?= dashboard_bytes((int)$realm['memory_used']) ?></strong><small>von <?= dashboard_bytes((int)$realm['memory_total']) ?> Docker-Speicher</small></div><div class="realm-metric"><span class="metric-label">Festplatte</span><strong><?= dashboard_bytes((int)$realm['disk_used']) ?></strong><small>belegte Docker-Daten · Kapazität nicht gemeldet</small></div></div>
<div class="realm-footer"><span>Docker Root: <code><?= dashboard_escape($realm['docker_root']) ?></code></span><span>Stand: <?= date('H:i:s') ?></span></div></article><?php endforeach; ?></section><?php endif; ?>
<p class="dashboard-link"><a href="dashboard.php?view=instances">Instanzen verwalten →</a></p>
<?php elseif ($showCustomers): ?>
<p class="eyebrow">Administration</p><h1>Kundenverwaltung</h1>
<p class="intro">Benutzerkonten und deren Zugriffsstatus.</p>
<section class="data-section"><div class="table-wrap"><table class="data-table"><thead><tr><th>Benutzername</th><th>E-Mail</th><th>Rolle</th><th>Status</th><th>Angelegt</th></tr></thead><tbody><?php foreach ($customers as $customer): ?><tr><td data-label="Benutzername"><strong><?= dashboard_escape((string)$customer['username']) ?></strong></td><td data-label="E-Mail"><?= dashboard_escape((string)$customer['email']) ?></td><td data-label="Rolle"><?= dashboard_escape((string)$customer['role']) ?></td><td data-label="Status"><?= dashboard_escape((string)$customer['status']) ?></td><td data-label="Angelegt"><?= dashboard_escape((string)$customer['created_at']) ?></td></tr><?php endforeach; ?></tbody></table></div></section>
<?php else: ?>
<p class="eyebrow">Übersicht</p><h1><?= $isAdmin ? 'Instanzen verwalten' : 'Meine Instanzen' ?></h1>
<p class="intro">FoundryVTT-Instanzen buchen und ihren Bereitstellungsstatus verfolgen.</p>
<?php if ($message !== null): ?><div class="alert alert-success" role="status"><?= dashboard_escape($message) ?></div><?php endif; ?>
<?php if ($error !== null): ?><div class="alert alert-error" role="alert"><?= dashboard_escape($error) ?></div><?php endif; ?>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Neue Buchung</p><h2>Instanz anfragen</h2></div><span class="status-badge">S · M · L</span></div>
<form method="post" class="login-form"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="request_instance"><label for="name">Instanzname</label><input id="name" name="name" required maxlength="120" placeholder="Meine Spielrunde"><label for="billing_period">Abrechnung</label><select id="billing_period" name="billing_period"><option value="monthly">Monatlich</option><option value="yearly">Jährlich (Rabatt)</option></select><label for="plan">Tarif</label><select id="plan" name="plan"><option value="s">S 2,99 € monatlich / 29,99 € jährlich · 512 MB RAM · 1 CPU · 5 GB</option><option value="m">M 3,99 € monatlich / 39,99 € jährlich · 2 GB RAM · 2 CPU · 10 GB</option><option value="l">L 4,99 € monatlich / 49,99 € jährlich · 4 GB RAM · 4 CPU · 25 GB</option></select><label for="payment_method">Zahlungsart</label><select id="payment_method" name="payment_method"><option value="paypal">PayPal</option><option value="bank_transfer">Überweisung</option></select><button type="submit">Instanz anfragen</button></form></section>
<section class="data-section"><div class="section-heading"><div><p class="eyebrow">Provisionierung</p><h2>Instanzen</h2></div><?php if ($isAdmin): ?><a href="dashboard.php">← Realms</a><?php endif; ?></div>
<?php if ($instances === []): ?><div class="empty-state">Noch keine Instanzen vorhanden.</div><?php else: ?><div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th><?php if ($isAdmin): ?><th>Benutzer</th><?php endif; ?><th>Tarif</th><th>Preis</th><th>Zahlung</th><th>Status</th><th>Adresse</th><th>Aktion</th></tr></thead><tbody><?php foreach ($instances as $instance): ?><tr><td data-label="Name"><strong><?= dashboard_escape((string)$instance['name']) ?></strong></td><?php if ($isAdmin): ?><td data-label="Benutzer"><?= dashboard_escape((string)$instance['username']) ?></td><?php endif; ?><td data-label="Tarif"><?= strtoupper(dashboard_escape((string)$instance['plan'])) ?></td><td data-label="Preis"><?= dashboard_escape(dashboard_money((int)($instance['price_gross_cents'] ?? 0))) ?> (<?= (string)($instance['billing_period'] ?? 'monthly') === 'yearly' ? 'jährlich' : 'monatlich' ?>)</td><td data-label="Zahlung"><?= dashboard_escape((string)$instance['payment_status']) ?></td><td data-label="Status"><span class="container-state <?= dashboard_escape((string)$instance['status']) ?>"><?= dashboard_escape(dashboard_status((string)$instance['status'])) ?></span><?php if (!empty($instance['error_message'])): ?><br><small><?= dashboard_escape((string)$instance['error_message']) ?></small><?php endif; ?></td><td data-label="Adresse"><?php if ($instance['status'] === 'running'): ?>:<?= dashboard_escape((string)$instance['port']) ?><?php else: ?><?php endif; ?></td><td data-label="Aktion"><?php if (in_array((string)$instance['status'], ['pending_payment', 'error'], true) && $isAdmin && ($instance['status'] === 'pending_payment' || $instance['payment_status'] === 'paid')): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="approve_instance"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit"><?= $instance['status'] === 'error' ? 'Erneut provisionieren' : 'Zahlung freigeben' ?></button></form><?php elseif (in_array($instance['status'], ['running', 'stopped'], true)): ?><div class="instance-actions"><form method="post" onsubmit="return confirm('Instanz wirklich neu bereitstellen? Die Daten in /data bleiben erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="redeploy"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Redeploy</button></form><?php if ($instance['status'] === 'running'): ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="stop"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Stoppen</button></form><?php else: ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="start"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Starten</button></form><?php endif; ?><form method="post"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="restart"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Neustarten</button></form><?php if ($isAdmin): ?><form method="post" onsubmit="return confirm('Instanz wirklich löschen? Das Datenverzeichnis bleibt erhalten.');"><input type="hidden" name="csrf_token" value="<?= dashboard_escape($csrf) ?>"><input type="hidden" name="action" value="instance_action"><input type="hidden" name="operation" value="delete"><input type="hidden" name="instance_id" value="<?= (int)$instance['id'] ?>"><button type="submit">Löschen</button></form><?php endif; ?></div><?php else: ?><?php endif; ?></td></tr><?php endforeach; ?></tbody></table></div><?php endif; ?></section>
<?php endif; ?></main></body></html>