mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: Docker tiered container management (#31-35)
- DockerManager.php: install Docker CE, engine status, container lifecycle (run/stop/start/restart/remove/logs/inspect), image management (pull/list/remove), volumes, networks, compose stacks, per-user quotas, app catalog with 9 one-click templates - docker.php API endpoint covering all operations with role-based access control (admin/reseller/user isolation) - DB migration 006: docker_containers, docker_compose_stacks, docker_quotas tables - Admin panel: Docker sidebar link + full management page (containers, images, volumes, networks, compose stacks, quota editor) - Reseller panel: Docker tab with customer container view, quota management, and app catalog deployment for customers - User panel: Docker tab with container dashboard, quota display, and self-service app catalog (9 apps: WP, Ghost, Nextcloud, Gitea, Matomo, Vaultwarden, Node.js, Flask, Static) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
require_once NOVACPX_LIB . '/DockerManager.php';
|
||||
|
||||
$auth = Auth::getInstance();
|
||||
$role = $currentUser['role'];
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$dm = new DockerManager();
|
||||
$isAdmin = $role === 'admin';
|
||||
|
||||
match ($action) {
|
||||
|
||||
// ── Engine ──────────────────────────────────────────────────────────────
|
||||
'status' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
Response::success($dm->engineStatus());
|
||||
})(),
|
||||
|
||||
'install' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$msg = $dm->install();
|
||||
audit('docker.install', 'system');
|
||||
Response::success(null, $msg);
|
||||
})(),
|
||||
|
||||
'prune' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$out = $dm->systemPrune((bool)($body['volumes'] ?? false));
|
||||
audit('docker.prune', 'system');
|
||||
Response::success(['output' => $out], 'Prune complete');
|
||||
})(),
|
||||
|
||||
// ── Containers ──────────────────────────────────────────────────────────
|
||||
'containers' => (function() use ($dm, $currentUser, $isAdmin, $role) {
|
||||
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
||||
: ($currentUser['account_id'] ?? null);
|
||||
if ($role === 'reseller') $accountId = null; // resellers see their customers' containers below
|
||||
$list = $dm->listContainers($accountId);
|
||||
Response::success(['containers' => $list]);
|
||||
})(),
|
||||
|
||||
'container-action' => (function() use ($dm, $body, $currentUser, $isAdmin, $role) {
|
||||
$cid = $body['container_id'] ?? '';
|
||||
$act = $body['action'] ?? '';
|
||||
if (!$cid || !$act) Response::error('container_id and action required');
|
||||
// Access check for non-admins
|
||||
if (!$isAdmin) {
|
||||
$acctId = $currentUser['account_id'] ?? 0;
|
||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||
if (!$row) Response::error('Container not found', 404);
|
||||
}
|
||||
$out = $dm->containerAction($cid, $act);
|
||||
audit("docker.container.{$act}", "container:{$cid}");
|
||||
Response::success(['output' => $out]);
|
||||
})(),
|
||||
|
||||
'container-remove' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
||||
$cid = $body['container_id'] ?? '';
|
||||
$force = (bool)($body['force'] ?? false);
|
||||
if (!$cid) Response::error('container_id required');
|
||||
if (!$isAdmin) {
|
||||
$acctId = $currentUser['account_id'] ?? 0;
|
||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||
if (!$row) Response::error('Container not found', 404);
|
||||
}
|
||||
$dm->removeContainer($cid, $force);
|
||||
audit('docker.container.remove', "container:{$cid}");
|
||||
Response::success(null, 'Container removed');
|
||||
})(),
|
||||
|
||||
'container-logs' => (function() use ($dm, $currentUser, $isAdmin) {
|
||||
$cid = $_GET['container_id'] ?? '';
|
||||
$lines = min((int)($_GET['lines'] ?? 100), 500);
|
||||
if (!$cid) Response::error('container_id required');
|
||||
if (!$isAdmin) {
|
||||
$acctId = $currentUser['account_id'] ?? 0;
|
||||
$row = DB::getInstance()->fetchOne("SELECT id FROM docker_containers WHERE container_id=? AND account_id=?", [$cid, $acctId]);
|
||||
if (!$row) Response::error('Container not found', 404);
|
||||
}
|
||||
$logs = $dm->containerLogs($cid, $lines);
|
||||
Response::success(['logs' => $logs]);
|
||||
})(),
|
||||
|
||||
'container-inspect' => (function() use ($dm, $currentUser, $isAdmin) {
|
||||
$cid = $_GET['container_id'] ?? '';
|
||||
if (!$cid) Response::error('container_id required');
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$data = $dm->containerInspect($cid);
|
||||
Response::success(['inspect' => $data]);
|
||||
})(),
|
||||
|
||||
'container-run' => (function() use ($dm, $body, $currentUser, $isAdmin, $role) {
|
||||
if ($isAdmin) {
|
||||
$accountId = (int)($body['account_id'] ?? 0);
|
||||
if (!$accountId) Response::error('account_id required');
|
||||
} else {
|
||||
$accountId = $currentUser['account_id'] ?? 0;
|
||||
}
|
||||
if (!$accountId) Response::error('No account context');
|
||||
$image = $body['image'] ?? '';
|
||||
$name = $body['name'] ?? '';
|
||||
if (!$image || !$name) Response::error('image and name required');
|
||||
$result = $dm->runContainer($accountId, $image, $name, $body);
|
||||
audit('docker.container.run', "account:{$accountId} image:{$image}");
|
||||
Response::success($result, 'Container started');
|
||||
})(),
|
||||
|
||||
// ── Images ──────────────────────────────────────────────────────────────
|
||||
'images' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
Response::success(['images' => $dm->listImages()]);
|
||||
})(),
|
||||
|
||||
'image-pull' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$image = $body['image'] ?? '';
|
||||
if (!$image) Response::error('image required');
|
||||
$out = $dm->pullImage($image);
|
||||
audit('docker.image.pull', "image:{$image}");
|
||||
Response::success(['output' => $out], 'Image pulled');
|
||||
})(),
|
||||
|
||||
'image-remove' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$id = $body['image_id'] ?? '';
|
||||
if (!$id) Response::error('image_id required');
|
||||
$out = $dm->removeImage($id);
|
||||
audit('docker.image.remove', "image:{$id}");
|
||||
Response::success(['output' => $out], 'Image removed');
|
||||
})(),
|
||||
|
||||
// ── Volumes & Networks ───────────────────────────────────────────────────
|
||||
'volumes' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
Response::success(['volumes' => $dm->listVolumes()]);
|
||||
})(),
|
||||
|
||||
'networks' => (function() use ($dm, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
Response::success(['networks' => $dm->listNetworks()]);
|
||||
})(),
|
||||
|
||||
// ── Compose Stacks ───────────────────────────────────────────────────────
|
||||
'stacks' => (function() use ($dm, $currentUser, $isAdmin) {
|
||||
$accountId = $isAdmin ? (isset($_GET['account_id']) ? (int)$_GET['account_id'] : null)
|
||||
: ($currentUser['account_id'] ?? null);
|
||||
Response::success(['stacks' => $dm->listStacks($accountId)]);
|
||||
})(),
|
||||
|
||||
'stack-create' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
||||
$accountId = $isAdmin ? ($body['account_id'] ?? null) : ($currentUser['account_id'] ?? null);
|
||||
$name = $body['name'] ?? '';
|
||||
$yaml = $body['compose_yaml'] ?? '';
|
||||
if (!$name || !$yaml) Response::error('name and compose_yaml required');
|
||||
$result = $dm->createStack($accountId ? (int)$accountId : null, $name, $yaml);
|
||||
audit('docker.stack.create', "name:{$name}");
|
||||
Response::success($result, 'Stack created');
|
||||
})(),
|
||||
|
||||
'stack-action' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
$act = $body['action'] ?? '';
|
||||
if (!$id || !$act) Response::error('stack_id and action required');
|
||||
$out = $dm->composeAction($id, $act);
|
||||
audit("docker.stack.{$act}", "stack:{$id}");
|
||||
Response::success(['output' => $out]);
|
||||
})(),
|
||||
|
||||
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$id = (int)($body['stack_id'] ?? 0);
|
||||
if (!$id) Response::error('stack_id required');
|
||||
$dm->removeStack($id);
|
||||
audit('docker.stack.remove', "stack:{$id}");
|
||||
Response::success(null, 'Stack removed');
|
||||
})(),
|
||||
|
||||
// ── Quotas ───────────────────────────────────────────────────────────────
|
||||
'quota-get' => (function() use ($dm, $currentUser, $isAdmin) {
|
||||
$userId = $isAdmin && isset($_GET['user_id']) ? (int)$_GET['user_id'] : (int)$currentUser['uid'];
|
||||
Response::success(['quota' => $dm->getQuota($userId)]);
|
||||
})(),
|
||||
|
||||
'quota-set' => (function() use ($dm, $body, $isAdmin) {
|
||||
if (!$isAdmin) Response::error('Admin only', 403);
|
||||
$userId = (int)($body['user_id'] ?? 0);
|
||||
if (!$userId) Response::error('user_id required');
|
||||
$dm->setQuota($userId, (int)($body['max_containers'] ?? 2), (int)($body['max_memory_mb'] ?? 512), (float)($body['max_cpus'] ?? 1.0));
|
||||
audit('docker.quota.set', "user:{$userId}");
|
||||
Response::success(null, 'Quota saved');
|
||||
})(),
|
||||
|
||||
// ── App Catalog ──────────────────────────────────────────────────────────
|
||||
'catalog' => (function() use ($dm) {
|
||||
Response::success(['catalog' => DockerManager::getCatalog()]);
|
||||
})(),
|
||||
|
||||
'launch' => (function() use ($dm, $body, $currentUser, $isAdmin) {
|
||||
$accountId = $isAdmin ? (int)($body['account_id'] ?? 0) : ($currentUser['account_id'] ?? 0);
|
||||
if (!$accountId) Response::error('account_id required');
|
||||
$appKey = $body['app_key'] ?? '';
|
||||
$params = $body['params'] ?? [];
|
||||
if (!$appKey) Response::error('app_key required');
|
||||
$result = $dm->launchFromCatalog($accountId, $appKey, $params);
|
||||
audit("docker.launch.{$appKey}", "account:{$accountId}");
|
||||
Response::success($result, ucfirst($appKey) . ' launched successfully');
|
||||
})(),
|
||||
|
||||
default => Response::error("Unknown docker action: $action", 404),
|
||||
};
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
/**
|
||||
* DockerManager — Docker Engine install, container lifecycle, compose stacks, app catalog
|
||||
*/
|
||||
class DockerManager {
|
||||
|
||||
private DB $db;
|
||||
private string $appsDir = '/opt/novacpx/docker-apps';
|
||||
|
||||
public function __construct() {
|
||||
$this->db = DB::getInstance();
|
||||
}
|
||||
|
||||
// ── Engine ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function isInstalled(): bool {
|
||||
return is_executable('/usr/bin/docker') || is_executable('/usr/local/bin/docker');
|
||||
}
|
||||
|
||||
public function install(): string {
|
||||
if ($this->isInstalled()) return 'Docker is already installed';
|
||||
$script = <<<'SH'
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable --now docker
|
||||
usermod -aG docker www-data
|
||||
SH;
|
||||
$out = [];
|
||||
exec('bash -c ' . escapeshellarg($script) . ' 2>&1', $out, $rc);
|
||||
if ($rc !== 0) throw new RuntimeException("Docker install failed: " . implode("\n", $out));
|
||||
// Add sudoers entry so www-data can run docker without password
|
||||
file_put_contents('/etc/sudoers.d/novacpx-docker',
|
||||
"www-data ALL=(root) NOPASSWD: /usr/bin/docker\n");
|
||||
chmod('/etc/sudoers.d/novacpx-docker', 0440);
|
||||
novacpx_log('info', 'DockerManager: Docker CE installed');
|
||||
return 'Docker installed successfully';
|
||||
}
|
||||
|
||||
public function engineStatus(): array {
|
||||
if (!$this->isInstalled()) return ['installed' => false];
|
||||
$version = trim(shell_exec('sudo docker version --format "{{.Server.Version}}" 2>/dev/null') ?? '');
|
||||
$running = !empty($version);
|
||||
$df = [];
|
||||
if ($running) {
|
||||
$raw = shell_exec('sudo docker system df --format json 2>/dev/null') ?? '';
|
||||
foreach (explode("\n", trim($raw)) as $line) {
|
||||
$obj = json_decode($line, true);
|
||||
if ($obj) $df[] = $obj;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'installed' => true,
|
||||
'running' => $running,
|
||||
'version' => $version,
|
||||
'disk' => $df,
|
||||
];
|
||||
}
|
||||
|
||||
public function systemPrune(bool $volumes = false): string {
|
||||
$flag = $volumes ? '--volumes' : '';
|
||||
$out = shell_exec("sudo docker system prune -f {$flag} 2>&1") ?? '';
|
||||
novacpx_log('info', 'DockerManager: system prune');
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
// ── Containers ────────────────────────────────────────────────────────────
|
||||
|
||||
public function listContainers(?int $accountId = null): array {
|
||||
$query = "SELECT * FROM docker_containers";
|
||||
$params = [];
|
||||
if ($accountId) { $query .= " WHERE account_id = ?"; $params[] = $accountId; }
|
||||
$query .= " ORDER BY created_at DESC";
|
||||
$rows = $this->db->fetchAll($query, $params);
|
||||
// Sync live status from docker daemon
|
||||
foreach ($rows as &$row) {
|
||||
if ($row['container_id']) {
|
||||
$st = trim(shell_exec("sudo docker inspect --format='{{.State.Status}}' " . escapeshellarg($row['container_id']) . " 2>/dev/null") ?? '');
|
||||
if ($st && $row['status'] !== $st) {
|
||||
$this->db->execute("UPDATE docker_containers SET status=? WHERE id=?", [$st, $row['id']]);
|
||||
$row['status'] = $st;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function containerAction(string $containerId, string $action): string {
|
||||
$this->validateContainerId($containerId);
|
||||
$allowed = ['start','stop','restart','pause','unpause','kill'];
|
||||
if (!in_array($action, $allowed)) throw new RuntimeException("Invalid action: $action");
|
||||
$out = shell_exec("sudo docker {$action} " . escapeshellarg($containerId) . " 2>&1") ?? '';
|
||||
$status = match($action) {
|
||||
'start','restart','unpause' => 'running',
|
||||
'stop','pause','kill' => 'stopped',
|
||||
default => 'unknown',
|
||||
};
|
||||
$this->db->execute("UPDATE docker_containers SET status=? WHERE container_id=?", [$status, $containerId]);
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
public function removeContainer(string $containerId, bool $force = false): void {
|
||||
$this->validateContainerId($containerId);
|
||||
$flag = $force ? '-f' : '';
|
||||
shell_exec("sudo docker rm {$flag} " . escapeshellarg($containerId) . " 2>&1");
|
||||
$this->db->execute("DELETE FROM docker_containers WHERE container_id=?", [$containerId]);
|
||||
}
|
||||
|
||||
public function containerLogs(string $containerId, int $lines = 100): string {
|
||||
$this->validateContainerId($containerId);
|
||||
return shell_exec("sudo docker logs --tail=" . (int)$lines . " " . escapeshellarg($containerId) . " 2>&1") ?? '';
|
||||
}
|
||||
|
||||
public function containerInspect(string $containerId): array {
|
||||
$this->validateContainerId($containerId);
|
||||
$json = shell_exec("sudo docker inspect " . escapeshellarg($containerId) . " 2>&1") ?? '';
|
||||
$data = json_decode($json, true);
|
||||
return is_array($data) ? ($data[0] ?? []) : [];
|
||||
}
|
||||
|
||||
public function runContainer(int $accountId, string $image, string $name, array $opts = []): array {
|
||||
$account = $this->db->fetchOne("SELECT a.username, u.id as user_id FROM accounts a JOIN users u ON u.id=a.user_id WHERE a.id=?", [$accountId]);
|
||||
if (!$account) throw new RuntimeException("Account not found");
|
||||
|
||||
// Quota check
|
||||
$quota = $this->getQuota((int)$account['user_id']);
|
||||
$count = (int)$this->db->fetchOne("SELECT COUNT(*) as c FROM docker_containers WHERE account_id=?", [$accountId])['c'];
|
||||
if ($count >= $quota['max_containers']) throw new RuntimeException("Container quota exceeded ({$quota['max_containers']} max)");
|
||||
|
||||
$memMb = min((int)($opts['memory_mb'] ?? 256), $quota['max_memory_mb']);
|
||||
$cpus = min((float)($opts['cpus'] ?? 0.5), (float)$quota['max_cpus']);
|
||||
$safeName = 'novacpx-' . $account['username'] . '-' . preg_replace('/[^a-z0-9_-]/', '', strtolower($name));
|
||||
|
||||
$portArgs = '';
|
||||
if (!empty($opts['ports'])) {
|
||||
foreach ((array)$opts['ports'] as $p) {
|
||||
if (preg_match('/^\d+:\d+$/', $p)) $portArgs .= " -p " . escapeshellarg($p);
|
||||
}
|
||||
}
|
||||
$envArgs = '';
|
||||
if (!empty($opts['env'])) {
|
||||
foreach ((array)$opts['env'] as $k => $v) {
|
||||
if (preg_match('/^[A-Z_][A-Z0-9_]*$/', $k)) $envArgs .= " -e " . escapeshellarg("{$k}={$v}");
|
||||
}
|
||||
}
|
||||
|
||||
$cmd = "sudo docker run -d --name " . escapeshellarg($safeName)
|
||||
. " --memory={$memMb}m --cpus={$cpus}"
|
||||
. " --restart=unless-stopped"
|
||||
. $portArgs . $envArgs
|
||||
. " " . escapeshellarg($image) . " 2>&1";
|
||||
|
||||
$out = shell_exec($cmd) ?? '';
|
||||
$cid = trim($out);
|
||||
|
||||
if (strlen($cid) !== 64 || !ctype_xdigit($cid)) {
|
||||
throw new RuntimeException("Container failed to start: $out");
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
"INSERT INTO docker_containers (account_id, container_id, name, image, status, ports, memory_mb, cpus) VALUES (?,?,?,?,'running',?,?,?)",
|
||||
[$accountId, $cid, $safeName, $image, json_encode($opts['ports'] ?? []), $memMb, $cpus]
|
||||
);
|
||||
novacpx_log('info', "DockerManager: container {$safeName} started for account {$accountId}");
|
||||
return ['container_id' => $cid, 'name' => $safeName];
|
||||
}
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function listImages(): array {
|
||||
$out = shell_exec("sudo docker images --format '{{json .}}' 2>/dev/null") ?? '';
|
||||
$images = [];
|
||||
foreach (explode("\n", trim($out)) as $line) {
|
||||
$obj = json_decode($line, true);
|
||||
if ($obj) $images[] = $obj;
|
||||
}
|
||||
return $images;
|
||||
}
|
||||
|
||||
public function pullImage(string $image): string {
|
||||
if (!preg_match('/^[a-zA-Z0-9._\-\/:@]+$/', $image)) throw new RuntimeException("Invalid image name");
|
||||
$out = shell_exec("sudo docker pull " . escapeshellarg($image) . " 2>&1") ?? '';
|
||||
novacpx_log('info', "DockerManager: pulled image {$image}");
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
public function removeImage(string $imageId): string {
|
||||
if (!preg_match('/^[a-zA-Z0-9:._\-\/]+$/', $imageId)) throw new RuntimeException("Invalid image ID");
|
||||
return trim(shell_exec("sudo docker rmi " . escapeshellarg($imageId) . " 2>&1") ?? '');
|
||||
}
|
||||
|
||||
// ── Volumes & Networks ────────────────────────────────────────────────────
|
||||
|
||||
public function listVolumes(): array {
|
||||
$out = shell_exec("sudo docker volume ls --format '{{json .}}' 2>/dev/null") ?? '';
|
||||
$vols = [];
|
||||
foreach (explode("\n", trim($out)) as $line) {
|
||||
$obj = json_decode($line, true);
|
||||
if ($obj) $vols[] = $obj;
|
||||
}
|
||||
return $vols;
|
||||
}
|
||||
|
||||
public function listNetworks(): array {
|
||||
$out = shell_exec("sudo docker network ls --format '{{json .}}' 2>/dev/null") ?? '';
|
||||
$nets = [];
|
||||
foreach (explode("\n", trim($out)) as $line) {
|
||||
$obj = json_decode($line, true);
|
||||
if ($obj) $nets[] = $obj;
|
||||
}
|
||||
return $nets;
|
||||
}
|
||||
|
||||
// ── Compose Stacks ────────────────────────────────────────────────────────
|
||||
|
||||
public function composeAction(int $stackId, string $action): string {
|
||||
$stack = $this->db->fetchOne("SELECT * FROM docker_compose_stacks WHERE id=?", [$stackId]);
|
||||
if (!$stack) throw new RuntimeException("Stack not found");
|
||||
$dir = $stack['stack_dir'];
|
||||
if (!is_dir($dir)) throw new RuntimeException("Stack directory not found");
|
||||
|
||||
$allowed = ['up','down','pull','logs'];
|
||||
if (!in_array($action, $allowed)) throw new RuntimeException("Invalid action");
|
||||
|
||||
$cmd = match($action) {
|
||||
'up' => "sudo docker compose -f " . escapeshellarg("{$dir}/docker-compose.yml") . " up -d 2>&1",
|
||||
'down' => "sudo docker compose -f " . escapeshellarg("{$dir}/docker-compose.yml") . " down 2>&1",
|
||||
'pull' => "sudo docker compose -f " . escapeshellarg("{$dir}/docker-compose.yml") . " pull 2>&1",
|
||||
'logs' => "sudo docker compose -f " . escapeshellarg("{$dir}/docker-compose.yml") . " logs --tail=100 2>&1",
|
||||
};
|
||||
$out = shell_exec($cmd) ?? '';
|
||||
$status = match($action) { 'up' => 'running', 'down' => 'stopped', default => $stack['status'] };
|
||||
$this->db->execute("UPDATE docker_compose_stacks SET status=? WHERE id=?", [$status, $stackId]);
|
||||
return trim($out);
|
||||
}
|
||||
|
||||
public function createStack(?int $accountId, string $name, string $composeYaml): array {
|
||||
$safeName = preg_replace('/[^a-z0-9_-]/', '', strtolower($name));
|
||||
$dir = "{$this->appsDir}/" . ($accountId ? "account-{$accountId}" : 'admin') . "/{$safeName}";
|
||||
if (!is_dir($dir)) mkdir($dir, 0750, true);
|
||||
file_put_contents("{$dir}/docker-compose.yml", $composeYaml);
|
||||
$this->db->execute(
|
||||
"INSERT INTO docker_compose_stacks (account_id, name, stack_dir, compose_file, status) VALUES (?,?,?,?,'pending')",
|
||||
[$accountId, $safeName, $dir, $composeYaml]
|
||||
);
|
||||
$id = $this->db->fetchOne("SELECT LAST_INSERT_ID() as id")['id'];
|
||||
novacpx_log('info', "DockerManager: created stack {$safeName}");
|
||||
return ['id' => $id, 'dir' => $dir];
|
||||
}
|
||||
|
||||
public function listStacks(?int $accountId = null): array {
|
||||
$q = "SELECT * FROM docker_compose_stacks";
|
||||
$p = [];
|
||||
if ($accountId !== null) { $q .= " WHERE account_id=?"; $p[] = $accountId; }
|
||||
$q .= " ORDER BY created_at DESC";
|
||||
return $this->db->fetchAll($q, $p);
|
||||
}
|
||||
|
||||
public function removeStack(int $stackId): void {
|
||||
$stack = $this->db->fetchOne("SELECT * FROM docker_compose_stacks WHERE id=?", [$stackId]);
|
||||
if (!$stack) throw new RuntimeException("Stack not found");
|
||||
// Bring down first
|
||||
$dir = $stack['stack_dir'];
|
||||
if (is_dir($dir) && is_file("{$dir}/docker-compose.yml")) {
|
||||
shell_exec("sudo docker compose -f " . escapeshellarg("{$dir}/docker-compose.yml") . " down 2>&1");
|
||||
}
|
||||
$this->db->execute("DELETE FROM docker_compose_stacks WHERE id=?", [$stackId]);
|
||||
}
|
||||
|
||||
// ── Quotas ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getQuota(int $userId): array {
|
||||
$q = $this->db->fetchOne("SELECT * FROM docker_quotas WHERE user_id=?", [$userId]);
|
||||
return $q ?: ['max_containers' => 2, 'max_memory_mb' => 512, 'max_cpus' => 1.0];
|
||||
}
|
||||
|
||||
public function setQuota(int $userId, int $maxContainers, int $maxMemoryMb, float $maxCpus): void {
|
||||
$this->db->execute(
|
||||
"INSERT INTO docker_quotas (user_id, max_containers, max_memory_mb, max_cpus)
|
||||
VALUES (?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE max_containers=VALUES(max_containers), max_memory_mb=VALUES(max_memory_mb), max_cpus=VALUES(max_cpus)",
|
||||
[$userId, $maxContainers, $maxMemoryMb, $maxCpus]
|
||||
);
|
||||
}
|
||||
|
||||
// ── App Catalog (#35) ─────────────────────────────────────────────────────
|
||||
|
||||
public static function getCatalog(): array {
|
||||
return [
|
||||
'wordpress' => [
|
||||
'name' => 'WordPress',
|
||||
'description' => 'WordPress + MariaDB + Nginx',
|
||||
'icon' => 'W',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'db_pass', 'label' => 'DB Password', 'type' => 'password', 'required' => true],
|
||||
['key' => 'admin_user','label' => 'WP Admin User', 'type' => 'text', 'required' => true],
|
||||
['key' => 'admin_pass','label' => 'WP Admin Pass', 'type' => 'password', 'required' => true],
|
||||
['key' => 'admin_email','label' => 'WP Admin Email','type' => 'email', 'required' => true],
|
||||
],
|
||||
],
|
||||
'ghost' => [
|
||||
'name' => 'Ghost',
|
||||
'description' => 'Ghost blog platform',
|
||||
'icon' => 'G',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'db_pass', 'label' => 'DB Password', 'type' => 'password', 'required' => true],
|
||||
],
|
||||
],
|
||||
'nextcloud' => [
|
||||
'name' => 'Nextcloud',
|
||||
'description' => 'Nextcloud file storage',
|
||||
'icon' => 'N',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'admin_user', 'label' => 'Admin Username', 'type' => 'text', 'required' => true],
|
||||
['key' => 'admin_pass', 'label' => 'Admin Password', 'type' => 'password', 'required' => true],
|
||||
['key' => 'db_pass', 'label' => 'DB Password', 'type' => 'password', 'required' => true],
|
||||
],
|
||||
],
|
||||
'gitea' => [
|
||||
'name' => 'Gitea',
|
||||
'description' => 'Self-hosted Git service',
|
||||
'icon' => 'Git',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'db_pass', 'label' => 'DB Password', 'type' => 'password', 'required' => true],
|
||||
],
|
||||
],
|
||||
'matomo' => [
|
||||
'name' => 'Matomo',
|
||||
'description' => 'Analytics platform',
|
||||
'icon' => 'M',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'db_pass', 'label' => 'DB Password', 'type' => 'password', 'required' => true],
|
||||
],
|
||||
],
|
||||
'vaultwarden' => [
|
||||
'name' => 'Vaultwarden',
|
||||
'description' => 'Bitwarden-compatible password manager',
|
||||
'icon' => 'V',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'admin_token','label' => 'Admin Token', 'type' => 'password', 'required' => true],
|
||||
],
|
||||
],
|
||||
'nodejs' => [
|
||||
'name' => 'Node.js App',
|
||||
'description' => 'Node.js application (auto-detects port)',
|
||||
'icon' => 'JS',
|
||||
'params' => [
|
||||
['key' => 'image', 'label' => 'Docker Image', 'type' => 'text', 'required' => true, 'placeholder' => 'node:20-alpine'],
|
||||
['key' => 'port', 'label' => 'App Port', 'type' => 'number', 'required' => true, 'placeholder' => '3000'],
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
],
|
||||
],
|
||||
'flask' => [
|
||||
'name' => 'Python / Flask',
|
||||
'description' => 'Python Flask application',
|
||||
'icon' => 'Py',
|
||||
'params' => [
|
||||
['key' => 'image', 'label' => 'Docker Image', 'type' => 'text', 'required' => true, 'placeholder' => 'python:3.12-slim'],
|
||||
['key' => 'port', 'label' => 'App Port', 'type' => 'number', 'required' => true, 'placeholder' => '5000'],
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
],
|
||||
],
|
||||
'static' => [
|
||||
'name' => 'Static Site',
|
||||
'description' => 'Static site served via Nginx',
|
||||
'icon' => 'HTML',
|
||||
'params' => [
|
||||
['key' => 'domain', 'label' => 'Domain', 'type' => 'text', 'required' => true],
|
||||
['key' => 'port', 'label' => 'Port', 'type' => 'number', 'required' => true, 'placeholder' => '8080'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function launchFromCatalog(int $accountId, string $appKey, array $params): array {
|
||||
$catalog = self::getCatalog();
|
||||
if (!isset($catalog[$appKey])) throw new RuntimeException("Unknown app: $appKey");
|
||||
|
||||
$domain = preg_replace('/[^a-z0-9.\-]/', '', strtolower($params['domain'] ?? ''));
|
||||
if (!$domain) throw new RuntimeException("domain is required");
|
||||
|
||||
$yaml = $this->generateComposeYaml($appKey, $domain, $params);
|
||||
$stack = $this->createStack($accountId, "{$appKey}-{$domain}", $yaml);
|
||||
|
||||
// Write stack and start it
|
||||
$out = $this->composeAction((int)$stack['id'], 'up');
|
||||
novacpx_log('info', "DockerManager: launched {$appKey} for account {$accountId} on {$domain}");
|
||||
return ['stack_id' => $stack['id'], 'dir' => $stack['dir'], 'output' => $out];
|
||||
}
|
||||
|
||||
private function generateComposeYaml(string $appKey, string $domain, array $p): string {
|
||||
$dbPass = $p['db_pass'] ?? bin2hex(random_bytes(8));
|
||||
$adminPass = $p['admin_pass'] ?? bin2hex(random_bytes(8));
|
||||
$adminUser = $p['admin_user'] ?? 'admin';
|
||||
|
||||
return match($appKey) {
|
||||
'wordpress' => "version: '3.8'\nservices:\n db:\n image: mariadb:10.11\n restart: unless-stopped\n environment:\n MYSQL_ROOT_PASSWORD: {$dbPass}\n MYSQL_DATABASE: wordpress\n MYSQL_USER: wordpress\n MYSQL_PASSWORD: {$dbPass}\n volumes:\n - db_data:/var/lib/mysql\n wordpress:\n image: wordpress:latest\n restart: unless-stopped\n depends_on: [db]\n environment:\n WORDPRESS_DB_HOST: db\n WORDPRESS_DB_NAME: wordpress\n WORDPRESS_DB_USER: wordpress\n WORDPRESS_DB_PASSWORD: {$dbPass}\n volumes:\n - wp_data:/var/www/html\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n db_data:\n wp_data:\n",
|
||||
|
||||
'ghost' => "version: '3.8'\nservices:\n db:\n image: mariadb:10.11\n restart: unless-stopped\n environment:\n MYSQL_ROOT_PASSWORD: {$dbPass}\n MYSQL_DATABASE: ghost\n MYSQL_USER: ghost\n MYSQL_PASSWORD: {$dbPass}\n volumes:\n - db_data:/var/lib/mysql\n ghost:\n image: ghost:5\n restart: unless-stopped\n depends_on: [db]\n environment:\n url: https://{$domain}\n database__client: mysql\n database__connection__host: db\n database__connection__user: ghost\n database__connection__password: {$dbPass}\n database__connection__database: ghost\n volumes:\n - ghost_data:/var/lib/ghost/content\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n db_data:\n ghost_data:\n",
|
||||
|
||||
'nextcloud' => "version: '3.8'\nservices:\n db:\n image: mariadb:10.11\n restart: unless-stopped\n environment:\n MYSQL_ROOT_PASSWORD: {$dbPass}\n MYSQL_DATABASE: nextcloud\n MYSQL_USER: nextcloud\n MYSQL_PASSWORD: {$dbPass}\n volumes:\n - db_data:/var/lib/mysql\n nextcloud:\n image: nextcloud:latest\n restart: unless-stopped\n depends_on: [db]\n environment:\n NEXTCLOUD_ADMIN_USER: {$adminUser}\n NEXTCLOUD_ADMIN_PASSWORD: {$adminPass}\n MYSQL_HOST: db\n MYSQL_DATABASE: nextcloud\n MYSQL_USER: nextcloud\n MYSQL_PASSWORD: {$dbPass}\n NEXTCLOUD_TRUSTED_DOMAINS: {$domain}\n volumes:\n - nc_data:/var/www/html\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n db_data:\n nc_data:\n",
|
||||
|
||||
'gitea' => "version: '3.8'\nservices:\n db:\n image: mariadb:10.11\n restart: unless-stopped\n environment:\n MYSQL_ROOT_PASSWORD: {$dbPass}\n MYSQL_DATABASE: gitea\n MYSQL_USER: gitea\n MYSQL_PASSWORD: {$dbPass}\n volumes:\n - db_data:/var/lib/mysql\n gitea:\n image: gitea/gitea:latest\n restart: unless-stopped\n depends_on: [db]\n environment:\n DB_TYPE: mysql\n DB_HOST: db:3306\n DB_NAME: gitea\n DB_USER: gitea\n DB_PASSWD: {$dbPass}\n volumes:\n - gitea_data:/data\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n db_data:\n gitea_data:\n",
|
||||
|
||||
'matomo' => "version: '3.8'\nservices:\n db:\n image: mariadb:10.11\n restart: unless-stopped\n environment:\n MYSQL_ROOT_PASSWORD: {$dbPass}\n MYSQL_DATABASE: matomo\n MYSQL_USER: matomo\n MYSQL_PASSWORD: {$dbPass}\n volumes:\n - db_data:/var/lib/mysql\n matomo:\n image: matomo:latest\n restart: unless-stopped\n depends_on: [db]\n environment:\n MATOMO_DATABASE_HOST: db\n MATOMO_DATABASE_DBNAME: matomo\n MATOMO_DATABASE_USERNAME: matomo\n MATOMO_DATABASE_PASSWORD: {$dbPass}\n volumes:\n - matomo_data:/var/www/html\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n db_data:\n matomo_data:\n",
|
||||
|
||||
'vaultwarden' => "version: '3.8'\nservices:\n vaultwarden:\n image: vaultwarden/server:latest\n restart: unless-stopped\n environment:\n DOMAIN: https://{$domain}\n ADMIN_TOKEN: " . ($p['admin_token'] ?? bin2hex(random_bytes(16))) . "\n SIGNUPS_ALLOWED: 'false'\n volumes:\n - vw_data:/data\n labels:\n - 'novacpx.domain={$domain}'\nvolumes:\n vw_data:\n",
|
||||
|
||||
'nodejs', 'flask' => (function() use ($p, $domain, $appKey) {
|
||||
$image = $p['image'] ?? ($appKey === 'nodejs' ? 'node:20-alpine' : 'python:3.12-slim');
|
||||
$port = (int)($p['port'] ?? 3000);
|
||||
return "version: '3.8'\nservices:\n app:\n image: " . $image . "\n restart: unless-stopped\n ports:\n - '{$port}'\n labels:\n - 'novacpx.domain={$domain}'\n";
|
||||
})(),
|
||||
|
||||
'static' => "version: '3.8'\nservices:\n static:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - '" . ((int)($p['port'] ?? 8080)) . "'\n volumes:\n - ./public:/usr/share/nginx/html:ro\n labels:\n - 'novacpx.domain={$domain}'\n",
|
||||
|
||||
default => throw new RuntimeException("No compose template for: $appKey"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function validateContainerId(string $id): void {
|
||||
if (!preg_match('/^[a-f0-9]{8,64}$/', $id)) throw new RuntimeException("Invalid container ID");
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,10 @@ $_v = fn($f) => '?v=' . @filemtime(dirname(__DIR__) . $f);
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
WordPress
|
||||
</a>
|
||||
<a href="#" class="sidebar-link" data-page="docker">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="8" width="4" height="4" rx="1"/><rect x="8" y="8" width="4" height="4" rx="1"/><rect x="14" y="8" width="4" height="4" rx="1"/><rect x="8" y="2" width="4" height="4" rx="1"/><path d="M2 14c0 4 3 6 10 6s10-2 10-6"/><path d="M20 14c1.5 0 2.5-1 2.5-2.5S21.5 9 20 9h-1"/></svg>
|
||||
Docker
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
'nginx-proxy': nginxProxy,
|
||||
sessions,
|
||||
wordpress,
|
||||
docker,
|
||||
'ssl-manager': sslManager,
|
||||
firewall,
|
||||
'audit-log': auditLog,
|
||||
@@ -2236,3 +2237,293 @@ window.sessionsRevokeAll = () => {
|
||||
if(r?.success) setTimeout(()=>location.reload(),1500);
|
||||
},true);
|
||||
};
|
||||
|
||||
// ── #31-35 Docker Management ───────────────────────────────────────────────
|
||||
async function docker(el) {
|
||||
el.innerHTML = '<div style="padding:2rem;text-align:center;color:var(--text-muted)">Loading Docker status…</div>';
|
||||
const st = await Nova.api('docker', 'status');
|
||||
const status = st?.data || {};
|
||||
|
||||
if (!status.installed) {
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||||
<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">🐳</div>
|
||||
<h3>Docker is not installed</h3>
|
||||
<p class="text-muted" style="margin:.5rem 0 1.5rem">Install Docker CE + Compose on this server to enable container management.</p>
|
||||
<button class="btn btn-primary" onclick="dockerInstall(this)">Install Docker CE</button>
|
||||
</div></div>`;
|
||||
window.dockerInstall = async (btn) => {
|
||||
btn.disabled = true; btn.textContent = 'Installing… (this may take 2-3 minutes)';
|
||||
const r = await Nova.api('docker', 'install', { method: 'POST', body: {} });
|
||||
Nova.toast(r?.message || (r?.success ? 'Installed' : 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) Nova.loadPage('docker', window._novaPages);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = (id, label) => `<button class="btn btn-sm ${id===_dockerTab?'btn-primary':'btn-ghost'}" onclick="dockerTab('${id}')">${label}</button>`;
|
||||
window._dockerTab = window._dockerTab || 'containers';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card"><div class="stat-label">Engine</div><div class="stat-value stat-green">${Nova.escHtml(status.version || '—')}</div><div class="stat-sub">${status.running ? 'Running' : 'Stopped'}</div></div>
|
||||
${(status.disk||[]).map(d=>`<div class="stat-card"><div class="stat-label">${Nova.escHtml(d.Type||d.type||'?')}</div><div class="stat-value" style="font-size:1rem">${Nova.escHtml(d.TotalCount||d.Size||'—')}</div><div class="stat-sub">${Nova.escHtml(d.Reclaimable||d.reclaimable||'')}</div></div>`).join('')}
|
||||
</div>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
${tab('containers','Containers')} ${tab('images','Images')} ${tab('volumes','Volumes')} ${tab('networks','Networks')} ${tab('stacks','Compose Stacks')} ${tab('quotas','User Quotas')}
|
||||
<button class="btn btn-sm btn-danger" style="margin-left:auto" onclick="dockerPrune()">System Prune</button>
|
||||
</div>
|
||||
<div id="docker-tab-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window.dockerTab = async (id) => {
|
||||
window._dockerTab = id;
|
||||
document.querySelectorAll('[onclick^="dockerTab"]').forEach(b => {
|
||||
b.className = 'btn btn-sm ' + (b.getAttribute('onclick').includes(`'${id}'`) ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
await dockerLoadTab(id);
|
||||
};
|
||||
|
||||
window.dockerPrune = () => Nova.confirm('Remove all stopped containers, unused images, and build cache?', async () => {
|
||||
const r = await Nova.api('docker', 'prune', { method: 'POST', body: { volumes: false } });
|
||||
Nova.toast(r?.success ? 'Pruned' : 'Failed', r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab(_dockerTab);
|
||||
}, true);
|
||||
|
||||
await dockerLoadTab(window._dockerTab);
|
||||
}
|
||||
|
||||
async function dockerLoadTab(tab) {
|
||||
const tc = document.getElementById('docker-tab-content');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
if (tab === 'containers') {
|
||||
const r = await Nova.api('docker', 'containers');
|
||||
const rows = r?.data?.containers || [];
|
||||
tc.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<strong>${rows.length} containers</strong>
|
||||
<button class="btn btn-sm btn-primary" onclick="dockerRunModal()">+ Run Container</button>
|
||||
</div>
|
||||
${rows.length === 0 ? '<div class="card"><div class="card-body text-muted" style="text-align:center;padding:2rem">No containers</div></div>' : `
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr>
|
||||
<th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th>
|
||||
</tr></thead><tbody>
|
||||
${rows.map(c => `<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||||
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
|
||||
<td>${c.account_id || '—'}</td>
|
||||
<td style="font-size:.8rem">${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','restart')">Restart</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="dockerContainerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="dockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="dockerRemove('${Nova.escHtml(c.container_id||'')}')">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'images') {
|
||||
const r = await Nova.api('docker', 'images');
|
||||
const imgs = r?.data?.images || [];
|
||||
tc.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<strong>${imgs.length} images</strong>
|
||||
<button class="btn btn-sm btn-primary" onclick="dockerPullModal()">Pull Image</button>
|
||||
</div>
|
||||
${imgs.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No images</div>' : `
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Repository</th><th>Tag</th><th>ID</th><th>Size</th><th>Actions</th></tr></thead><tbody>
|
||||
${imgs.map(i => `<tr>
|
||||
<td>${Nova.escHtml(i.Repository||i.repository||'—')}</td>
|
||||
<td><code>${Nova.escHtml(i.Tag||i.tag||'latest')}</code></td>
|
||||
<td style="font-family:monospace;font-size:.78rem">${Nova.escHtml((i.ID||i.id||'').substring(7,19))}</td>
|
||||
<td>${Nova.escHtml(i.Size||i.size||'—')}</td>
|
||||
<td><button class="btn btn-xs btn-danger" onclick="dockerImgRemove('${Nova.escHtml(i.ID||i.id||'')}')">Remove</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'volumes') {
|
||||
const r = await Nova.api('docker', 'volumes');
|
||||
const vols = r?.data?.volumes || [];
|
||||
tc.innerHTML = `<strong>${vols.length} volumes</strong>
|
||||
${vols.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No volumes</div>' : `
|
||||
<div style="overflow-x:auto;margin-top:1rem"><table class="table"><thead><tr><th>Name</th><th>Driver</th><th>Scope</th></tr></thead><tbody>
|
||||
${vols.map(v=>`<tr><td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(v.Name||v.name||'')}</td><td>${Nova.escHtml(v.Driver||v.driver||'')}</td><td>${Nova.escHtml(v.Scope||v.scope||'')}</td></tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'networks') {
|
||||
const r = await Nova.api('docker', 'networks');
|
||||
const nets = r?.data?.networks || [];
|
||||
tc.innerHTML = `<strong>${nets.length} networks</strong>
|
||||
${nets.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No networks</div>' : `
|
||||
<div style="overflow-x:auto;margin-top:1rem"><table class="table"><thead><tr><th>Name</th><th>Driver</th><th>Scope</th><th>ID</th></tr></thead><tbody>
|
||||
${nets.map(n=>`<tr><td>${Nova.escHtml(n.Name||n.name||'')}</td><td>${Nova.escHtml(n.Driver||n.driver||'')}</td><td>${Nova.escHtml(n.Scope||n.scope||'')}</td><td style="font-family:monospace;font-size:.78rem">${Nova.escHtml((n.ID||n.id||'').substring(0,12))}</td></tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'stacks') {
|
||||
const r = await Nova.api('docker', 'stacks');
|
||||
const stacks = r?.data?.stacks || [];
|
||||
tc.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<strong>${stacks.length} stacks</strong>
|
||||
<button class="btn btn-sm btn-primary" onclick="dockerStackCreateModal()">+ Create Stack</button>
|
||||
</div>
|
||||
${stacks.length === 0 ? '<div class="text-muted" style="padding:2rem;text-align:center">No compose stacks</div>' : `
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Account</th><th>Created</th><th>Actions</th></tr></thead><tbody>
|
||||
${stacks.map(s=>`<tr>
|
||||
<td>${Nova.escHtml(s.name)}</td>
|
||||
<td>${Nova.badge(s.status, s.status==='running'?'green':s.status==='stopped'?'red':'yellow')}</td>
|
||||
<td>${s.account_id||'admin'}</td>
|
||||
<td style="font-size:.8rem">${new Date(s.created_at).toLocaleDateString()}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
||||
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const r = await Nova.api('accounts', 'list', { params: { limit: 200 } });
|
||||
const users = r?.data?.accounts || [];
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">Set Docker resource limits per user. Click a row to edit.</p>
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||
${users.map(u=>`<tr id="docker-quota-row-${u.user_id}">
|
||||
<td>${Nova.escHtml(u.username)}</td>
|
||||
<td id="dq-cnt-${u.user_id}">2</td>
|
||||
<td id="dq-mem-${u.user_id}">512 MB</td>
|
||||
<td id="dq-cpu-${u.user_id}">1.0</td>
|
||||
<td><button class="btn btn-xs btn-primary" onclick="dockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.dockerContainerAct = async (cid, action) => {
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('containers');
|
||||
};
|
||||
|
||||
window.dockerRemove = (cid) => Nova.confirm('Remove this container?', async () => {
|
||||
const r = await Nova.api('docker', 'container-remove', { method: 'DELETE', body: { container_id: cid, force: true } });
|
||||
Nova.toast(r?.success ? 'Removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('containers');
|
||||
}, true);
|
||||
|
||||
window.dockerLogs = async (cid, name) => {
|
||||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 200 } });
|
||||
const logs = r?.data?.logs || r?.message || 'No logs';
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(logs)}</pre>`);
|
||||
};
|
||||
|
||||
window.dockerImgRemove = (id) => Nova.confirm('Remove this image?', async () => {
|
||||
const r = await Nova.api('docker', 'image-remove', { method: 'DELETE', body: { image_id: id } });
|
||||
Nova.toast(r?.success ? 'Image removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('images');
|
||||
}, true);
|
||||
|
||||
window.dockerPullModal = () => {
|
||||
const ov = Nova.modal('Pull Image',
|
||||
`<div class="form-group"><label>Image Name</label><input id="di-image" class="form-control" placeholder="nginx:latest" autofocus></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerPullSubmit()">Pull</button>`
|
||||
);
|
||||
window.dockerPullSubmit = async () => {
|
||||
const image = document.getElementById('di-image').value.trim();
|
||||
if (!image) return;
|
||||
ov.remove();
|
||||
Nova.toast('Pulling image…', 'info', 10000);
|
||||
const r = await Nova.api('docker', 'image-pull', { method: 'POST', body: { image } });
|
||||
Nova.toast(r?.success ? 'Image pulled' : (r?.message || 'Pull failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('images');
|
||||
};
|
||||
};
|
||||
|
||||
window.dockerRunModal = () => {
|
||||
const ov = Nova.modal('Run Container',
|
||||
`<div class="form-group"><label>Image</label><input id="dr-image" class="form-control" placeholder="nginx:latest"></div>
|
||||
<div class="form-group"><label>Name</label><input id="dr-name" class="form-control" placeholder="my-app"></div>
|
||||
<div class="form-group"><label>Account ID</label><input id="dr-acct" type="number" class="form-control" placeholder="1"></div>
|
||||
<div class="form-group"><label>Ports (host:container, one per line)</label><textarea id="dr-ports" class="form-control" rows="2" placeholder="8080:80"></textarea></div>
|
||||
<div class="form-group"><label>Memory (MB)</label><input id="dr-mem" type="number" class="form-control" value="256"></div>
|
||||
<div class="form-group"><label>CPUs</label><input id="dr-cpus" type="number" step="0.1" class="form-control" value="0.5"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerRunSubmit()">Run</button>`
|
||||
);
|
||||
window.dockerRunSubmit = async () => {
|
||||
const image = document.getElementById('dr-image').value.trim();
|
||||
const name = document.getElementById('dr-name').value.trim();
|
||||
const acct = parseInt(document.getElementById('dr-acct').value) || 0;
|
||||
const ports = document.getElementById('dr-ports').value.trim().split('\n').map(p=>p.trim()).filter(Boolean);
|
||||
const mem = parseInt(document.getElementById('dr-mem').value) || 256;
|
||||
const cpus = parseFloat(document.getElementById('dr-cpus').value) || 0.5;
|
||||
if (!image || !name || !acct) { Nova.toast('Image, name and account required','error'); return; }
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'container-run', { method: 'POST', body: { image, name, account_id: acct, ports, memory_mb: mem, cpus } });
|
||||
Nova.toast(r?.success ? 'Container started' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
|
||||
if (r?.success) dockerLoadTab('containers');
|
||||
};
|
||||
};
|
||||
|
||||
window.dockerStackAct = async (id, action) => {
|
||||
Nova.toast(`Running docker compose ${action}…`, 'info', 5000);
|
||||
const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: id, action } });
|
||||
if (action === 'logs') {
|
||||
Nova.modal('Stack Logs', `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'')}</pre>`);
|
||||
} else {
|
||||
Nova.toast(r?.success ? `Stack ${action} complete` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
}
|
||||
};
|
||||
|
||||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
||||
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
||||
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
}, true);
|
||||
|
||||
window.dockerStackCreateModal = () => {
|
||||
const ov = Nova.modal('Create Compose Stack',
|
||||
`<div class="form-group"><label>Stack Name</label><input id="dsc-name" class="form-control" placeholder="my-stack"></div>
|
||||
<div class="form-group"><label>Account ID (leave blank for admin)</label><input id="dsc-acct" type="number" class="form-control"></div>
|
||||
<div class="form-group"><label>docker-compose.yml content</label><textarea id="dsc-yaml" class="form-control" rows="12" style="font-family:monospace;font-size:.8rem" placeholder="version: '3.8'\nservices:\n app:\n image: nginx\n"></textarea></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerStackCreateSubmit()">Create</button>`
|
||||
);
|
||||
window.dockerStackCreateSubmit = async () => {
|
||||
const name = document.getElementById('dsc-name').value.trim();
|
||||
const acct = document.getElementById('dsc-acct').value.trim();
|
||||
const yaml = document.getElementById('dsc-yaml').value;
|
||||
if (!name || !yaml) { Nova.toast('Name and YAML required','error'); return; }
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'stack-create', { method: 'POST', body: { name, account_id: acct||null, compose_yaml: yaml } });
|
||||
Nova.toast(r?.success ? 'Stack created' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) dockerLoadTab('stacks');
|
||||
};
|
||||
};
|
||||
|
||||
window.dockerQuotaModal = (userId, username) => {
|
||||
const ov = Nova.modal(`Docker Quota: ${username}`,
|
||||
`<div class="form-group"><label>Max Containers</label><input id="dq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
||||
<div class="form-group"><label>Max Memory (MB)</label><input id="dq-mem" type="number" class="form-control" value="512" min="64"></div>
|
||||
<div class="form-group"><label>Max CPUs</label><input id="dq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="dockerQuotaSubmit(${userId})">Save</button>`
|
||||
);
|
||||
window.dockerQuotaSubmit = async (uid) => {
|
||||
const cnt = parseInt(document.getElementById('dq-cnt').value) || 2;
|
||||
const mem = parseInt(document.getElementById('dq-mem').value) || 512;
|
||||
const cpus = parseFloat(document.getElementById('dq-cpus').value) || 1.0;
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'quota-set', { method: 'POST', body: { user_id: uid, max_containers: cnt, max_memory_mb: mem, max_cpus: cpus } });
|
||||
Nova.toast(r?.success ? 'Quota saved' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -297,8 +297,9 @@ const rNavItems = [
|
||||
{ id:'createAccount', label:'New Account', icon:'ni-add' },
|
||||
{ id:'packages', label:'Packages', icon:'ni-packages' },
|
||||
{ id:'dns', label:'DNS Zones', icon:'ni-dns' },
|
||||
{ id:'docker', label:'Docker', icon:'ni-docker' },
|
||||
];
|
||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS };
|
||||
const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker };
|
||||
|
||||
let _rActivePage = 'dashboard';
|
||||
|
||||
@@ -327,3 +328,157 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
renderRNav();
|
||||
window.resellerNav('dashboard');
|
||||
});
|
||||
|
||||
/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
|
||||
async function rDocker(el) {
|
||||
el.innerHTML = '<div class="loading">Loading…</div>';
|
||||
const [stRes, acctRes] = await Promise.all([
|
||||
Nova.api('docker', 'stacks'),
|
||||
Nova.api('accounts', 'list', { params: { limit: 200 } }),
|
||||
]);
|
||||
const stacks = stRes?.data?.stacks || [];
|
||||
const accts = acctRes?.data?.accounts || [];
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker</h2></div>
|
||||
<p class="text-muted" style="margin-bottom:1.5rem">Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.</p>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<button class="btn btn-sm ${_rDockerTab==='containers'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('containers')">Containers</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='quotas'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('quotas')">Customer Quotas</button>
|
||||
<button class="btn btn-sm ${_rDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="rDockerTab('catalog')">App Catalog</button>
|
||||
</div>
|
||||
<div id="rdocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window._rDockerAccts = accts;
|
||||
window._rDockerTab = window._rDockerTab || 'containers';
|
||||
|
||||
window.rDockerTab = async (tab) => {
|
||||
window._rDockerTab = tab;
|
||||
document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
|
||||
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
||||
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
await rDockerLoadTab(tab);
|
||||
};
|
||||
|
||||
await rDockerLoadTab(window._rDockerTab);
|
||||
}
|
||||
|
||||
window._rDockerTab = 'containers';
|
||||
|
||||
async function rDockerLoadTab(tab) {
|
||||
const tc = document.getElementById('rdocker-content');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = '<div class="loading">Loading…</div>';
|
||||
|
||||
if (tab === 'containers') {
|
||||
const r = await Nova.api('docker', 'containers');
|
||||
const rows = r?.data?.containers || [];
|
||||
tc.innerHTML = rows.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No containers for your accounts</div>'
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>Image</th><th>Status</th><th>Account</th><th>Actions</th></tr></thead><tbody>
|
||||
${rows.map(c=>`<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td style="font-size:.82rem">${Nova.escHtml(c.image)}</td>
|
||||
<td>${Nova.badge(c.status,c.status==='running'?'green':'red')}</td>
|
||||
<td>${c.account_id||'—'}</td>
|
||||
<td>
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="rDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="rDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'quotas') {
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = accts.length === 0
|
||||
? '<div class="text-muted" style="padding:2rem;text-align:center">No accounts</div>'
|
||||
: `<p class="text-muted" style="margin-bottom:1rem">Set Docker limits for each of your customers.</p>
|
||||
<div style="overflow-x:auto"><table class="table"><thead><tr><th>Username</th><th>Max Containers</th><th>Max Memory</th><th>Max CPUs</th><th>Actions</th></tr></thead><tbody>
|
||||
${accts.map(u=>`<tr>
|
||||
<td>${Nova.escHtml(u.username)}</td>
|
||||
<td>2</td><td>512 MB</td><td>1.0</td>
|
||||
<td><button class="btn btn-xs btn-primary" onclick="rDockerQuotaModal(${u.user_id},'${Nova.escHtml(u.username)}')">Edit</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
const r = await Nova.api('docker', 'catalog');
|
||||
const catalog = r?.data?.catalog || {};
|
||||
const accts = window._rDockerAccts || [];
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">Pre-install app stacks for your customers.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="cursor:pointer" onclick="rDockerLaunchModal('${key}','${Nova.escHtml(app.name)}')">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.5rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.25rem">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" style="margin-top:1rem">Deploy</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.rDockerAct = async (cid, action) => {
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
|
||||
window.rDockerLogs = async (cid, name) => {
|
||||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'')}</pre>`);
|
||||
};
|
||||
|
||||
window.rDockerQuotaModal = (userId, username) => {
|
||||
const ov = Nova.modal(`Docker Quota: ${username}`,
|
||||
`<div class="form-group"><label>Max Containers</label><input id="rdq-cnt" type="number" class="form-control" value="2" min="0"></div>
|
||||
<div class="form-group"><label>Max Memory (MB)</label><input id="rdq-mem" type="number" class="form-control" value="512" min="64"></div>
|
||||
<div class="form-group"><label>Max CPUs</label><input id="rdq-cpus" type="number" step="0.5" class="form-control" value="1.0" min="0.1"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerQuotaSubmit(${userId})">Save</button>`
|
||||
);
|
||||
window.rDockerQuotaSubmit = async (uid) => {
|
||||
ov.remove();
|
||||
const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
|
||||
user_id: uid,
|
||||
max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
|
||||
max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
|
||||
max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
|
||||
}});
|
||||
Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
|
||||
};
|
||||
};
|
||||
|
||||
window.rDockerLaunchModal = async (appKey, appName) => {
|
||||
const catRes = await Nova.api('docker', 'catalog');
|
||||
const app = catRes?.data?.catalog?.[appKey];
|
||||
if (!app) return;
|
||||
const accts = window._rDockerAccts || [];
|
||||
const acctOpts = accts.map(a=>`<option value="${a.id}">${Nova.escHtml(a.username)}</option>`).join('');
|
||||
const paramFields = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="rl-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
const ov = Nova.modal(`Deploy ${appName}`,
|
||||
`<div class="form-group"><label>Account</label><select id="rl-acct" class="form-control"><option value="">Select account</option>${acctOpts}</select></div>${paramFields}`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="rDockerLaunchSubmit('${appKey}')">Deploy</button>`
|
||||
);
|
||||
window.rDockerLaunchSubmit = async (key) => {
|
||||
const acctId = parseInt(document.getElementById('rl-acct').value)||0;
|
||||
if (!acctId) { Nova.toast('Select an account','error'); return; }
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
|
||||
ov.remove();
|
||||
Nova.toast('Deploying…', 'info', 10000);
|
||||
const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
|
||||
Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
|
||||
if (r?.success) rDockerLoadTab('containers');
|
||||
};
|
||||
};
|
||||
|
||||
@@ -69,6 +69,7 @@ const userPages = {
|
||||
files,
|
||||
stats: statsPage,
|
||||
backups,
|
||||
docker: dockerPage,
|
||||
'change-password': changePasswordPage,
|
||||
};
|
||||
|
||||
@@ -773,6 +774,7 @@ const navItems = [
|
||||
{ id: 'files', label: 'File Manager', icon: 'ni-files' },
|
||||
{ id: 'stats', label: 'Statistics', icon: 'ni-stats' },
|
||||
{ id: 'backups', label: 'Backups', icon: 'ni-backups' },
|
||||
{ id: 'docker', label: 'Docker', icon: 'ni-docker' },
|
||||
{ id: 'change-password', label: 'Change Password', icon: 'ni-lock' },
|
||||
];
|
||||
|
||||
@@ -841,6 +843,167 @@ window.submitChangePassword = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Docker (#34) ────────────────────────────────────────────────────────── */
|
||||
async function dockerPage(el) {
|
||||
el.innerHTML = '<div class="loading">Loading Docker…</div>';
|
||||
const [contRes, quotaRes, catRes] = await Promise.all([
|
||||
Nova.api('docker', 'containers'),
|
||||
Nova.api('docker', 'quota-get'),
|
||||
Nova.api('docker', 'catalog'),
|
||||
]);
|
||||
|
||||
const containers = contRes?.data?.containers || [];
|
||||
const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 };
|
||||
const catalog = catRes?.data?.catalog || {};
|
||||
const used = containers.length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="page-header"><h2 class="page-title">Docker Containers</h2></div>
|
||||
<div class="stats-grid" style="margin-bottom:1.5rem">
|
||||
<div class="stat-card"><div class="stat-label">Containers Used</div><div class="stat-value">${used} / ${quota.max_containers}</div><div class="mt-1">${Nova.progressBar(Math.round(used/Math.max(quota.max_containers,1)*100))}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Max Memory / Container</div><div class="stat-value stat-blue">${quota.max_memory_mb} MB</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Max CPUs / Container</div><div class="stat-value stat-green">${quota.max_cpus}</div></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<button class="btn btn-sm ${_uDockerTab==='my-containers'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('my-containers')">My Containers</button>
|
||||
<button class="btn btn-sm ${_uDockerTab==='catalog'?'btn-primary':'btn-ghost'}" onclick="uDockerTab('catalog')">App Catalog</button>
|
||||
</div>
|
||||
<div id="udocker-content"><div class="loading">Loading…</div></div>`;
|
||||
|
||||
window._uDockerContainers = containers;
|
||||
window._uDockerQuota = quota;
|
||||
window._uDockerCatalog = catalog;
|
||||
window._uDockerTab = window._uDockerTab || 'my-containers';
|
||||
|
||||
window.uDockerTab = async (tab) => {
|
||||
window._uDockerTab = tab;
|
||||
document.querySelectorAll('[onclick^="uDockerTab"]').forEach(b => {
|
||||
const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
|
||||
b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
|
||||
});
|
||||
uDockerLoadTab(tab);
|
||||
};
|
||||
|
||||
uDockerLoadTab(window._uDockerTab);
|
||||
}
|
||||
|
||||
window._uDockerTab = 'my-containers';
|
||||
|
||||
function uDockerLoadTab(tab) {
|
||||
const tc = document.getElementById('udocker-content');
|
||||
if (!tc) return;
|
||||
const containers = window._uDockerContainers || [];
|
||||
const catalog = window._uDockerCatalog || {};
|
||||
const quota = window._uDockerQuota || {};
|
||||
|
||||
if (tab === 'my-containers') {
|
||||
tc.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<strong>${containers.length} container${containers.length===1?'':'s'}</strong>
|
||||
<button class="btn btn-sm btn-primary" onclick="uDockerLaunchModal()" ${containers.length>=quota.max_containers?'disabled title="Quota reached"':''}>+ Launch App</button>
|
||||
</div>
|
||||
${containers.length === 0
|
||||
? `<div class="card"><div class="card-body" style="text-align:center;padding:3rem">
|
||||
<div style="font-size:2.5rem;margin-bottom:1rem">🐳</div>
|
||||
<p class="text-muted">No containers yet. Launch an app from the catalog!</p>
|
||||
<button class="btn btn-primary" onclick="uDockerTab('catalog')">Browse Catalog</button>
|
||||
</div></div>`
|
||||
: `<div style="overflow-x:auto"><table class="table"><thead><tr><th>Name</th><th>App</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${containers.map(c=>`<tr>
|
||||
<td style="font-family:monospace;font-size:.82rem">${Nova.escHtml(c.name)}</td>
|
||||
<td>${Nova.escHtml(c.app_key||c.image||'—')}</td>
|
||||
<td>${Nova.badge(c.status, c.status==='running'?'green':c.status==='stopped'?'red':'yellow')}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${c.status==='running'
|
||||
? `<button class="btn btn-xs btn-warning" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','stop')">Stop</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','restart')">Restart</button>`
|
||||
: `<button class="btn btn-xs btn-success" onclick="uDockerAct('${Nova.escHtml(c.container_id||'')}','start')">Start</button>`}
|
||||
<button class="btn btn-xs btn-ghost" onclick="uDockerLogs('${Nova.escHtml(c.container_id||'')}','${Nova.escHtml(c.name)}')">Logs</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`}`;
|
||||
|
||||
} else if (tab === 'catalog') {
|
||||
tc.innerHTML = `
|
||||
<p class="text-muted" style="margin-bottom:1rem">One-click app deployment. Each app runs as an isolated Docker container.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem">
|
||||
${Object.entries(catalog).map(([key,app])=>`
|
||||
<div class="card" style="cursor:pointer;transition:var(--transition)" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor=''">
|
||||
<div class="card-body" style="text-align:center;padding:1.5rem">
|
||||
<div style="font-size:1.8rem;font-weight:700;margin-bottom:.5rem;color:var(--primary)">${Nova.escHtml(app.icon)}</div>
|
||||
<div style="font-weight:600;margin-bottom:.25rem">${Nova.escHtml(app.name)}</div>
|
||||
<div style="font-size:.78rem;color:var(--text-muted)">${Nova.escHtml(app.description)}</div>
|
||||
<button class="btn btn-sm btn-primary" style="margin-top:1rem" onclick="uDockerLaunchApp('${key}')">Launch</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.uDockerAct = async (cid, action) => {
|
||||
const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
|
||||
Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
|
||||
if (r?.success) {
|
||||
const c = (window._uDockerContainers||[]).find(x=>x.container_id===cid);
|
||||
if (c) c.status = action==='stop'?'stopped':'running';
|
||||
uDockerLoadTab('my-containers');
|
||||
}
|
||||
};
|
||||
|
||||
window.uDockerLogs = async (cid, name) => {
|
||||
const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
|
||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.logs||'No logs available')}</pre>`);
|
||||
};
|
||||
|
||||
window.uDockerLaunchModal = () => uDockerLaunchApp(null);
|
||||
|
||||
window.uDockerLaunchApp = async (preselect) => {
|
||||
const catalog = window._uDockerCatalog || {};
|
||||
const entries = Object.entries(catalog);
|
||||
const appOpts = entries.map(([k,a])=>`<option value="${k}" ${k===preselect?'selected':''}>${Nova.escHtml(a.name)}</option>`).join('');
|
||||
|
||||
const ov = Nova.modal('Launch App',
|
||||
`<div class="form-group"><label>App</label>
|
||||
<select id="ul-app" class="form-control" onchange="uDockerUpdateParams(this.value)">${appOpts}</select></div>
|
||||
<div id="ul-params"></div>`,
|
||||
`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="uDockerLaunchSubmit()">Launch</button>`
|
||||
);
|
||||
|
||||
const initialKey = preselect || entries[0]?.[0];
|
||||
if (initialKey) uDockerUpdateParams(initialKey);
|
||||
|
||||
window.uDockerUpdateParams = (key) => {
|
||||
const app = catalog[key];
|
||||
if (!app) return;
|
||||
const tc = document.getElementById('ul-params');
|
||||
if (!tc) return;
|
||||
tc.innerHTML = (app.params||[]).map(p=>`
|
||||
<div class="form-group"><label>${Nova.escHtml(p.label)}${p.required?' *':''}</label>
|
||||
<input id="ul-${Nova.escHtml(p.key)}" type="${p.type||'text'}" class="form-control" ${p.placeholder?`placeholder="${Nova.escHtml(p.placeholder)}"`:''}></div>`).join('');
|
||||
};
|
||||
|
||||
window.uDockerLaunchSubmit = async () => {
|
||||
const key = document.getElementById('ul-app')?.value;
|
||||
const app = catalog[key];
|
||||
if (!app) return;
|
||||
const params = {};
|
||||
(app.params||[]).forEach(p => { params[p.key] = document.getElementById('ul-'+p.key)?.value||''; });
|
||||
const missing = (app.params||[]).filter(p=>p.required && !params[p.key]);
|
||||
if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
|
||||
ov.remove();
|
||||
Nova.toast(`Launching ${app.name}… this may take a minute`, 'info', 15000);
|
||||
const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
|
||||
Nova.toast(r?.success ? `${app.name} launched!` : (r?.message||'Launch failed'), r?.success?'success':'error');
|
||||
if (r?.success) {
|
||||
const cr = await Nova.api('docker', 'containers');
|
||||
window._uDockerContainers = cr?.data?.containers || [];
|
||||
uDockerTab('my-containers');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* ── Boot ────────────────────────────────────────────────────────────────── */
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initUser();
|
||||
|
||||
Reference in New Issue
Block a user