settings = $settings; $this->kanbanTab = $kanbanTab; $this->statusLabel = $statusLabel; $currentApiKey = &$apiKey; $currentPrivateKeyPath = &$privateKeyPath; // Create main tab container $this->tab = new Container('flex flex-row p-4 gap-4'); // Left side: Table with search and refresh $leftSide = new Container('flex flex-col gap-2 flex-1'); // Header row with refresh button and loading indicator on the right $headerRow = new Container('flex flex-row items-center gap-2'); // Refresh button $this->refreshButton = new Button( 'Server aktualisieren', 'flex shadow-lg/50 flex-row gap-2 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700', null, 'text-white', ); $refreshIcon = new Icon(IconName::sync, 16, 'text-white'); $this->refreshButton->setIcon($refreshIcon); $headerRow->addComponent($this->refreshButton); // Bulk action buttons for selected servers $rebootButton = new Button( 'Reboot ausgewählte', 'px-3 shadow-lg/50 py-2 bg-red-600 rounded hover:bg-red-700', null, 'text-white', ); $updateButton = new Button( 'Update ausgewählte', 'px-3 shadow-lg/50 hover:shadow-red-600 py-2 bg-amber-600 rounded hover:bg-amber-700', null, 'text-white', ); $headerRow->addComponent($rebootButton); $headerRow->addComponent($updateButton); // Loading indicator (top-right in the server tab header) $this->loadingIndicator = new LoadingIndicator('ml-auto'); $headerRow->addComponent($this->loadingIndicator); $leftSide->addComponent($headerRow); // Search input $this->searchInput = new TextInput( 'Suche...', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black mb-2', ); $leftSide->addComponent($this->searchInput); // Table container with header and virtual list $tableContainer = new Container('flex flex-col flex-1 border border-gray-300 rounded'); // Table header $headerContainer = new Container('flex flex-row w-full bg-gray-200 border-b-2 border-gray-400'); $columns = [ [ 'key' => 'selected', 'title' => '', 'width' => 40, 'render' => [$this, 'renderSelectCell'], ], ['key' => 'id', 'title' => 'ID', 'width' => 100], [ 'key' => 'tasks_indicator', 'title' => '', 'width' => 40, 'render' => [$this, 'renderTasksIndicatorCell'], ], ['key' => 'name', 'title' => 'Name'], ['key' => 'status', 'title' => 'Status', 'width' => 90], ['key' => 'type', 'title' => 'Typ', 'width' => 80], ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 150], [ 'key' => 'docker_status', 'title' => 'Docker', 'width' => 50, 'render' => [$this, 'renderDockerStatusCell'], ], [ 'key' => 'docker_running', 'title' => 'R', 'width' => 50, 'render' => [$this, 'renderDockerRunningCell'], ], [ 'key' => 'os_version', 'title' => 'Ubuntu', 'width' => 180, 'render' => [$this, 'renderOsVersionCell'], ], ['key' => 'release', 'title' => 'Release', 'width' => 140], [ 'key' => 'root', 'title' => 'Root', 'width' => 50, 'render' => [$this, 'renderRootCell'], ], [ 'key' => 'data', 'title' => 'Data', 'width' => 50, 'render' => [$this, 'renderDataCell'], ], [ 'key' => 'needs_reboot', 'title' => 'Neustart', 'width' => 100, 'render' => [$this, 'renderNeedsRebootCell'], ], [ 'key' => 'updates_available', 'title' => 'Updates', 'width' => 100, 'render' => [$this, 'renderUpdatesCell'], ], [ 'key' => 'last_backup', 'title' => 'Letztes Backup', 'width' => 180, 'render' => [$this, 'renderLastBackupCell'], ], ]; // Build header cells foreach ($columns as $column) { $title = $column['title'] ?? ''; $width = $column['width'] ?? null; $style = 'px-4 py-2 text-black font-bold border-r border-gray-300'; if ($width) { $style .= ' w-' . ((int) ($width / 4)); } else { $style .= ' flex-1'; } $headerLabel = new Label($title, $style); $headerContainer->addComponent($headerLabel); } $tableContainer->addComponent($headerContainer); // Virtual List View (Performance-optimized) $this->table = new VirtualListView('flex-1 bg-white'); $this->table->setColumns($columns); // Load initial test data $this->currentServerData = HetznerService::generateTestData(); $this->table->setData($this->currentServerData); $tableContainer->addComponent($this->table); $leftSide->addComponent($tableContainer); // Right side: Detail panel $detailPanel = new Container( 'flex flex-col gap-3 w-120 bg-white border-2 border-gray-300 rounded p-4 overflow-y-auto', ); $detailTitle = new Label('Server Details', 'text-xl font-bold text-black mb-2'); $detailPanel->addComponent($detailTitle); $this->detailId = new Label('-', 'text-sm text-gray-900 font-mono'); $this->detailName = new Label('-', 'text-lg font-semibold text-black'); $this->detailStatus = new Label('-', 'text-sm text-gray-700'); $this->detailType = new Label('-', 'text-sm text-gray-700'); $this->detailIpv4 = new Label('-', 'text-sm text-gray-700 font-mono'); $detailPanel->addComponent(new Label('ID:', 'text-xs text-gray-500')); $detailPanel->addComponent($this->detailId); $detailPanel->addComponent(new Label('Name:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent($this->detailName); $detailPanel->addComponent(new Label('Status:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent($this->detailStatus); $detailPanel->addComponent(new Label('Typ:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent($this->detailType); $detailPanel->addComponent(new Label('IPv4:', 'text-xs text-gray-500 mt-2')); $detailPanel->addComponent($this->detailIpv4); // Domains list $detailPanel->addComponent(new Label('Domains:', 'text-xs text-gray-500 mt-2')); $this->detailDomainsContainer = new Container('flex flex-col gap-1'); $detailPanel->addComponent($this->detailDomainsContainer); // SFTP Manager Button (handler will be set by SftpManagerTab) $this->sftpButton = new Button( 'SFTP Manager öffnen', 'w-full rounded-md bg-blue-500 px-3 py-2 text-sm font-semibold shadow-lg shadow-blue-500/50 focus:outline-none flex flex-row gap-2', null, 'text-white', ); $sftpIcon = new Icon(IconName::folder, 16, 'text-white'); $this->sftpButton->setIcon($sftpIcon); $detailPanel->addComponent($this->sftpButton); // SSH Terminal Button $this->sshTerminalButton = new Button( 'SSH Terminal öffnen', 'w-full border border-gray-300 rounded px-3 py-2 flex shadow-lg/100 shadow-lime-300 flex-row gap-2 bg-lime-300 text-black mb-2', null, 'text-black', ); $sshTerminalIcon = new Icon(IconName::terminal, 16, 'text-black'); $this->sshTerminalButton->setIcon($sshTerminalIcon); $serverListTab = $this; $this->sshTerminalButton->setOnClick(function () use ($serverListTab, &$currentPrivateKeyPath) { if ($serverListTab->selectedServer === null) { $serverListTab->statusLabel->setText('Kein Server ausgewählt'); return; } if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->statusLabel->setText('Private Key Pfad nicht konfiguriert'); return; } $host = $serverListTab->selectedServer['ipv4']; $keyPath = escapeshellarg($currentPrivateKeyPath); $sshCommand = "ssh -i {$keyPath} root@{$host}"; $terminals = [ 'gnome-terminal -- ' . $sshCommand, 'konsole -e ' . $sshCommand, 'xterm -e ' . $sshCommand, 'x-terminal-emulator -e ' . $sshCommand, ]; $opened = false; foreach ($terminals as $terminalCmd) { exec($terminalCmd . ' > /dev/null 2>&1 &', $output, $returnCode); if ($returnCode === 0) { $opened = true; $serverListTab->statusLabel->setText( 'SSH Terminal geöffnet für ' . $serverListTab->selectedServer['name'], ); break; } } if (!$opened) { $serverListTab->statusLabel->setText('Konnte kein Terminal öffnen. SSH Befehl: ' . $sshCommand); } }); $detailPanel->addComponent($this->sshTerminalButton); // Per-server API key and TODOs $detailPanel->addComponent(new Label('Server API Key:', 'text-xs text-gray-500 mt-2')); $this->serverApiKeyInput = new TextInput( 'Server API Key', 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black', ); $detailPanel->addComponent($this->serverApiKeyInput); $saveServerSettingsButton = new Button( 'Server-Einstellungen speichern', 'mt-1 px-3 py-2 bg-green-600 rounded hover:bg-green-700', null, 'text-white', ); $detailPanel->addComponent($saveServerSettingsButton); $detailPanel->addComponent(new Label('Tasks für diesen Server:', 'text-xs text-gray-500 mt-3')); $this->todoListContainer = new Container('flex flex-col gap-1'); $detailPanel->addComponent($this->todoListContainer); $todoInputRow = new Container('flex flex-row gap-2 mt-1'); $this->todoInput = new TextInput( 'Neue Task...', 'flex-1 border border-gray-300 rounded px-3 py-2 bg-white text-black', ); $addTodoButton = new Button('+', 'px-3 py-2 bg-blue-600 rounded hover:bg-blue-700', null, 'text-white'); $todoInputRow->addComponent($this->todoInput); $todoInputRow->addComponent($addTodoButton); $detailPanel->addComponent($todoInputRow); $this->tab->addComponent($leftSide); $this->tab->addComponent($detailPanel); // Setup event handlers $this->setupEventHandlers( $currentApiKey, $currentPrivateKeyPath, $rebootButton, $updateButton, $saveServerSettingsButton, $addTodoButton, ); } private function setupEventHandlers( string &$currentApiKey, string &$currentPrivateKeyPath, Button $rebootButton, Button $updateButton, Button $saveServerSettingsButton, Button $addTodoButton, ): void { // Table row selection $serverListTab = $this; $this->table->setOnRowSelect(function ($index, $row) use ($serverListTab) { if ($row) { $serverListTab->statusLabel->setText("Server: {$row['name']} - {$row['status']} ({$row['ipv4']})"); $serverListTab->detailId->setText("#{$row['id']}"); $serverListTab->detailName->setText($row['name']); $serverListTab->detailStatus->setText($row['status']); $serverListTab->detailType->setText($row['type']); $serverListTab->detailIpv4->setText($row['ipv4']); // Load per-server settings (API key, todos) $serverId = $row['id'] ?? null; $serverListTab->currentServerId = is_numeric($serverId) ? ((int) $serverId) : null; if ($serverListTab->currentServerId !== null) { $settingsKeyBase = 'servers.' . $serverListTab->currentServerId; $apiKey = $serverListTab->settings->get($settingsKeyBase . '.api_key', ''); $serverListTab->serverApiKeyInput->setValue($apiKey); $serverListTab->loadServerTasks(); } else { $serverListTab->serverApiKeyInput->setValue(''); $serverListTab->currentServerTodos = []; $serverListTab->renderTodoList(); } $domains = $row['domains'] ?? []; $serverListTab->updateDomainDetails(is_array($domains) ? $domains : []); $serverListTab->selectedServer = $row; } }); // Search functionality $this->searchInput->setOnChange(function ($value) use ($serverListTab) { $searchTerm = strtolower(trim($value)); if (empty($searchTerm)) { $serverListTab->table->setData($serverListTab->currentServerData); } else { $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ($searchTerm) { return ( str_contains(strtolower($row['name']), $searchTerm) || count(array_filter($row['domains'], function ($item) use ($searchTerm) { return str_contains(strtolower($item), $searchTerm); })) ); }); $serverListTab->table->setData(array_values($filteredData)); } }); // Refresh button - use TaskManager directly so we can control the loading indicator $this->refreshButton->setOnClick(function () use (&$currentApiKey, $serverListTab, &$currentPrivateKeyPath) { $serverListTab->loadingIndicator->setLoading(true); $task = TaskManager::getInstance()->runAsync(function () use (&$currentApiKey) { try { if (empty($currentApiKey)) { return ['error' => 'Kein API-Key konfiguriert']; } $hetznerClient = new \LKDev\HetznerCloud\HetznerAPIClient($currentApiKey); $servers = []; foreach ($hetznerClient->servers()->all() as $server) { $servers[] = [ 'id' => $server->id, 'name' => $server->name, 'status' => $server->status, 'type' => $server->serverType->name, 'ipv4' => $server->publicNet->ipv4->ip, 'docker_status' => 'pending', 'domains' => [], ]; } return [ 'success' => true, 'servers' => $servers, 'count' => count($servers), ]; } catch (\Exception $e) { return ['error' => 'Exception: ' . $e->getMessage()]; } }); $task->onComplete(function ($result) use (&$serverListTab, &$currentPrivateKeyPath) { if (is_array($result)) { if (isset($result['error'])) { $serverListTab->statusLabel->setText('Fehler: ' . $result['error']); echo "Error: {$result['error']}\n"; } elseif (isset($result['success'], $result['servers'])) { // Basisdaten setzen, Docker-Status initial auf "pending" $serverListTab->currentServerData = array_map(static fn($row) => array_merge([ 'docker_status' => 'pending', 'docker_running' => 'pending', 'docker' => null, 'docker_error' => null, 'needs_reboot' => 'unbekannt', 'updates_available' => 'unbekannt', 'os_version' => 'unbekannt', 'release' => 'unbekannt', 'root' => 'unbekannt', 'data' => 'unbekannt', 'last_backup' => 'unbekannt', ], $row), $result['servers']); $serverListTab->table->setData($serverListTab->currentServerData); $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); // Kanban-Board aktualisieren, damit Servernamen in den Karten up-to-date sind if ($serverListTab->kanbanTab !== null) { $serverListTab->kanbanTab->refresh(); } if (function_exists('desktop_notify')) { desktop_notify( 'Serverliste aktualisiert', 'Es wurden ' . $result['count'] . ' Server geladen.', ['timeout' => 4000, 'urgency' => 'normal'], ); } // Danach: pro Server asynchron Docker-Infos und Systemstatus nachladen foreach ($serverListTab->currentServerData as $index => $row) { $ip = $row['ipv4'] ?? ''; if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP'; $serverListTab->currentServerData[$index]['docker_status'] = 'error'; $serverListTab->currentServerData[$index]['docker_running'] = 'error'; continue; } $dockerTask = TaskManager::getInstance()->runAsync(function () use ( $ip, $currentPrivateKeyPath, $index, ) { try { $ssh = new \phpseclib3\Net\SSH2($ip); $key = \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey(file_get_contents( $currentPrivateKeyPath, )); if (!$ssh->login('root', $key)) { return [ 'index' => $index, 'docker' => 'fetch usage', 'docker_error' => 'SSH Login fehlgeschlagen', 'docker_status' => 'error', ]; } // Check if reboot is required (Ubuntu) $rebootOutput = trim($ssh->exec( '[ -f /var/run/reboot-required ] && echo yes || echo no', )); $needsReboot = $rebootOutput === 'yes'; // Check number of available package updates (Ubuntu) $updatesOutput = trim($ssh->exec( 'apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0', )); $updatesCount = is_numeric($updatesOutput) ? ((int) $updatesOutput) : 0; // Read Ubuntu version (PRETTY_NAME from /etc/os-release) $osOutput = trim($ssh->exec( 'grep "^PRETTY_NAME=" /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d \'"\'', )); $osVersion = $osOutput !== '' ? $osOutput : 'unbekannt'; // Last backup from borgmatic $lastBackup = 'unbekannt'; $borgmaticOutput = trim($ssh->exec( 'borgmatic list --last 1 --json 2>/dev/null || echo ""', )); if ($borgmaticOutput !== '') { $borgmaticData = json_decode($borgmaticOutput, true); if ( json_last_error() === JSON_ERROR_NONE && is_array($borgmaticData) && !empty($borgmaticData) ) { // borgmatic list --json gibt ein Array von Repositories zurück $firstRepo = reset($borgmaticData); if ( is_array($firstRepo) && isset($firstRepo['archives']) && is_array($firstRepo['archives']) && !empty($firstRepo['archives']) ) { $lastArchive = reset($firstRepo['archives']); if ( is_array($lastArchive) && isset($lastArchive['time']) && is_string($lastArchive['time']) ) { try { $backupTime = new \DateTime($lastArchive['time']); $lastBackup = $backupTime->format('d.m.Y H:i'); } catch (\Exception $e) { $lastBackup = $lastArchive['time']; } } } } } // Disk usage for / and /data $rootUsage = 'unbekannt'; $dfRoot = trim($ssh->exec('df -P / 2>/dev/null')); if ($dfRoot !== '') { $lines = preg_split('/\r\n|\r|\n/', $dfRoot); if (count($lines) >= 2) { $parts = preg_split('/\s+/', trim($lines[1])); if (isset($parts[4]) && $parts[4] !== '') { $rootUsage = $parts[4]; } } } $dataUsage = 'unbekannt'; $dfData = trim($ssh->exec('df -P /data 2>/dev/null')); if ($dfData !== '') { $lines = preg_split('/\r\n|\r|\n/', $dfData); if (count($lines) >= 2) { $parts = preg_split('/\s+/', trim($lines[1])); if (isset($parts[4]) && $parts[4] !== '') { $dataUsage = $parts[4]; } } } // Docker status for main application container $runningOutput = trim($ssh->exec( "docker ps --filter status=running --format '{{.ID}}' | wc -l", )); $runningCount = is_numeric($runningOutput) ? ((int) $runningOutput) : null; $output = $ssh->exec('docker inspect psc-web-1'); if (empty($output)) { return [ 'index' => $index, 'docker' => null, 'docker_error' => 'Leere Docker-Antwort', 'docker_status' => 'error', 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, 'root' => $rootUsage, 'data' => $dataUsage, 'last_backup' => $lastBackup, ]; } $json = json_decode($output, true); if (json_last_error() !== JSON_ERROR_NONE) { return [ 'index' => $index, 'docker' => null, 'docker_error' => 'Ungültige Docker-JSON-Antwort', 'docker_status' => 'error', 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, 'root' => $rootUsage, 'data' => $dataUsage, 'last_backup' => $lastBackup, ]; } $domains = []; if (isset($json[0]['Config']['Env']) && is_array($json[0]['Config']['Env'])) { $hosts = array_filter($json[0]['Config']['Env'], static function ($item) { return is_string($item) && str_starts_with($item, 'LETSENCRYPT_HOST'); }); if (!empty($hosts)) { $hostEntry = array_first($hosts); $hostString = substr((string) $hostEntry, strlen('LETSENCRYPT_HOST=')); $domains = array_values(array_filter( array_map('trim', explode(',', $hostString)), static fn($d) => $d !== '', )); } } $release = 'unbekannt'; if (!empty($domains)) { $releaseDomain = $domains[0]; $releaseDomain = trim($releaseDomain); if ($releaseDomain !== '') { $url = $releaseDomain; if ( !str_starts_with($url, 'http://') && !str_starts_with($url, 'https://') ) { $url = 'https://' . $url; } $url = rtrim($url, '/') . '/apps/api/system/version'; try { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => 5, ], ]); $response = file_get_contents($url, false, $context); if ($response !== false) { $data = json_decode($response, true); if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { $releaseValue = null; if (isset($data['version']) && is_string($data['version'])) { $releaseValue = trim($data['version']); } elseif ( isset($data['release']) && is_string($data['release']) ) { $releaseValue = trim($data['release']); } $dateValue = null; if (isset($data['datum']) && is_string($data['datum'])) { $dateValue = trim($data['datum']); } if ( $releaseValue !== null && $dateValue !== null && $dateValue !== '' ) { $release = $releaseValue . ' (' . $dateValue . ')'; } elseif ($releaseValue !== null) { $release = $releaseValue; } elseif ($dateValue !== null && $dateValue !== '') { $release = $dateValue; } else { $release = 'unbekannt'; } } else { $release = 'Ungültig'; } } else { $release = 'Fehler'; } } catch (\Throwable) { $release = 'Fehler'; } } } return [ 'index' => $index, 'docker' => $json, 'docker_error' => null, 'docker_status' => 'ok', 'docker_running' => $runningCount, 'needs_reboot' => $needsReboot, 'updates' => $updatesCount, 'os_version' => $osVersion, 'root' => $rootUsage, 'data' => $dataUsage, 'domains' => $domains, 'release' => $release, 'last_backup' => $lastBackup, ]; } catch (\Throwable $e) { return [ 'index' => $index, 'docker' => null, 'docker_error' => 'SSH-Fehler: ' . $e->getMessage(), 'docker_status' => 'error', 'docker_running' => null, 'needs_reboot' => null, 'updates' => null, 'os_version' => null, 'root' => 'unbekannt', 'data' => 'unbekannt', 'last_backup' => 'unbekannt', ]; } }); $dockerTask->onComplete(function ($dockerResult) use (&$serverListTab) { if (!is_array($dockerResult) || !isset($dockerResult['index'])) { return; } $i = $dockerResult['index']; if (!isset($serverListTab->currentServerData[$i])) { return; } if (array_key_exists('docker', $dockerResult)) { $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; $searchTerm = $serverListTab->searchInput->getValue(); if (empty($searchTerm)) { $serverListTab->table->setData($serverListTab->currentServerData); } else { $filteredData = array_filter( $serverListTab->currentServerData, function ($row) use ($searchTerm) { return ( str_contains(strtolower($row['name']), $searchTerm) || count(array_filter($row['domains'], function ($item) use ( $searchTerm, ) { return str_contains(strtolower($item), $searchTerm); })) ); }, ); $serverListTab->table->setData(array_values($filteredData)); } } if (array_key_exists('domains', $dockerResult)) { $domains = $dockerResult['domains']; if (!is_array($domains)) { $domains = []; } $serverListTab->currentServerData[$i]['domains'] = $domains; } // Map system status into human-readable table fields if (array_key_exists('needs_reboot', $dockerResult)) { $needsReboot = $dockerResult['needs_reboot']; if ($needsReboot === null) { $serverListTab->currentServerData[$i]['needs_reboot'] = 'unbekannt'; } else { $serverListTab->currentServerData[$i]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; } } if (array_key_exists('updates', $dockerResult)) { $updates = (int) ($dockerResult['updates'] ?? 0); $serverListTab->currentServerData[$i]['updates_available'] = $updates > 0 ? ('ja (' . $updates . ')') : 'nein'; } if (array_key_exists('os_version', $dockerResult)) { $osVersion = $dockerResult['os_version']; $serverListTab->currentServerData[$i]['os_version'] = $osVersion !== null ? $osVersion : 'unbekannt'; } if (array_key_exists('root', $dockerResult)) { $rootUsage = $dockerResult['root']; $serverListTab->currentServerData[$i]['root'] = $rootUsage !== null ? $rootUsage : 'unbekannt'; } if (array_key_exists('data', $dockerResult)) { $dataUsage = $dockerResult['data']; $serverListTab->currentServerData[$i]['data'] = $dataUsage !== null ? $dataUsage : 'unbekannt'; } if (array_key_exists('release', $dockerResult)) { $release = $dockerResult['release']; $serverListTab->currentServerData[$i]['release'] = $release !== null ? $release : 'unbekannt'; } if (array_key_exists('docker_error', $dockerResult)) { $serverListTab->currentServerData[$i]['docker_error'] = $dockerResult['docker_error']; } if (array_key_exists('docker_status', $dockerResult)) { $serverListTab->currentServerData[$i]['docker_status'] = $dockerResult['docker_status']; } if (array_key_exists('docker_running', $dockerResult)) { $serverListTab->currentServerData[$i]['docker_running'] = $dockerResult['docker_running'] !== null ? $dockerResult['docker_running'] : 'unbekannt'; } if (array_key_exists('last_backup', $dockerResult)) { $lastBackup = $dockerResult['last_backup']; $serverListTab->currentServerData[$i]['last_backup'] = $lastBackup !== null ? $lastBackup : 'unbekannt'; } }); $dockerTask->onError(function ($error) use (&$serverListTab, $index) { $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : ((string) $error); if (isset($serverListTab->currentServerData[$index])) { $serverListTab->currentServerData[$index]['docker_error'] = 'Async Fehler: ' . $errorMsg; $serverListTab->currentServerData[$index]['docker_running'] = 'error'; } }); } } } $serverListTab->loadingIndicator->setLoading(false); }); $task->onError(function ($error) use (&$serverListTab) { $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : ((string) $error); $serverListTab->statusLabel->setText('Async Fehler: ' . $errorMsg); echo "Async error: {$errorMsg}\n"; $serverListTab->loadingIndicator->setLoading(false); }); }); // Reboot selected servers $rebootButton->setOnClick(function () use (&$currentPrivateKeyPath, $serverListTab) { $selected = []; foreach ($serverListTab->currentServerData as $index => $row) { if (!empty($row['selected'])) { $selected[] = ['index' => $index, 'row' => $row]; } } if (empty($selected)) { $serverListTab->statusLabel->setText('Keine Server ausgewählt für Reboot'); return; } if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->statusLabel->setText('Private Key Pfad nicht konfiguriert'); return; } foreach ($selected as $item) { $row = $item['row']; $ip = $row['ipv4'] ?? ''; $name = $row['name'] ?? $ip; $index = $item['index']; if (empty($ip)) { continue; } $task = TaskManager::getInstance()->runAsync(function () use ($ip, $currentPrivateKeyPath, $index) { try { $ssh = new \phpseclib3\Net\SSH2($ip); $key = \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey(file_get_contents( $currentPrivateKeyPath, )); if (!$ssh->login('root', $key)) { return [ 'index' => $index, 'success' => false, 'error' => 'SSH Login fehlgeschlagen', ]; } $ssh->exec('reboot'); return [ 'index' => $index, 'success' => true, ]; } catch (\Throwable $e) { return [ 'index' => $index, 'success' => false, 'error' => 'SSH-Fehler: ' . $e->getMessage(), ]; } }); $task->onComplete(function ($result) use (&$serverListTab, $name) { if (!is_array($result) || !array_key_exists('success', $result)) { return; } if ($result['success']) { $serverListTab->statusLabel->setText('Reboot ausgelöst für ' . $name); if (function_exists('desktop_notify')) { desktop_notify('Reboot gestartet', 'Reboot ausgelöst für ' . $name, [ 'timeout' => 4000, 'urgency' => 'normal', ]); } } elseif (isset($result['error'])) { $serverListTab->statusLabel->setText('Reboot Fehler bei ' . $name . ': ' . $result['error']); if (function_exists('desktop_notify')) { desktop_notify('Reboot fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [ 'timeout' => 6000, 'urgency' => 'critical', ]); } } }); } }); // Update selected servers $updateButton->setOnClick(function () use (&$currentPrivateKeyPath, $serverListTab) { $selected = []; foreach ($serverListTab->currentServerData as $index => $row) { if (!empty($row['selected'])) { $selected[] = ['index' => $index, 'row' => $row]; } } if (empty($selected)) { $serverListTab->statusLabel->setText('Keine Server ausgewählt für Updates'); return; } if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->statusLabel->setText('Private Key Pfad nicht konfiguriert'); return; } foreach ($selected as $item) { $row = $item['row']; $ip = $row['ipv4'] ?? ''; $name = $row['name'] ?? $ip; $index = $item['index']; if (empty($ip)) { continue; } $task = TaskManager::getInstance()->runAsync(function () use ($ip, $currentPrivateKeyPath, $index) { try { $ssh = new \phpseclib3\Net\SSH2($ip); $key = \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey(file_get_contents( $currentPrivateKeyPath, )); if (!$ssh->login('root', $key)) { return [ 'index' => $index, 'success' => false, 'error' => 'SSH Login fehlgeschlagen', ]; } // Run update & upgrade non-interactively and then output reboot + update status in one go $statusOutput = trim($ssh->exec('apt-get update >/dev/null 2>&1 && ' . 'DEBIAN_FRONTEND=noninteractive apt-get -y upgrade >/dev/null 2>&1; ' . 'reboot_flag=$([ -f /var/run/reboot-required ] && echo yes || echo no); ' . 'updates=$(apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0); ' . 'echo "$reboot_flag $updates"')); $parts = preg_split('/\s+/', $statusOutput); $rebootFlag = $parts[0] ?? 'no'; $updatesCount = isset($parts[1]) && is_numeric($parts[1]) ? ((int) $parts[1]) : 0; $needsReboot = $rebootFlag === 'yes'; return [ 'index' => $index, 'success' => true, 'updates' => $updatesCount, 'needs_reboot' => $needsReboot, ]; } catch (\Throwable $e) { return [ 'index' => $index, 'success' => false, 'error' => 'SSH-Fehler: ' . $e->getMessage(), ]; } }); $task->onComplete(function ($result) use (&$serverListTab, $name) { if (!is_array($result) || !array_key_exists('success', $result)) { return; } $index = $result['index'] ?? null; if ($index !== null && isset($serverListTab->currentServerData[$index])) { if (isset($result['updates'])) { $updates = (int) $result['updates']; $serverListTab->currentServerData[$index]['updates_available'] = $updates > 0 ? ('ja (' . $updates . ')') : 'nein'; } if (array_key_exists('needs_reboot', $result)) { $needsReboot = (bool) $result['needs_reboot']; $serverListTab->currentServerData[$index]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; } $searchTerm = $serverListTab->searchInput->getValue(); if (empty($searchTerm)) { $serverListTab->table->setData($serverListTab->currentServerData); } else { $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( $searchTerm, ) { return ( str_contains(strtolower($row['name']), $searchTerm) || count(array_filter($row['domains'], function ($item) use ($searchTerm) { return str_contains(strtolower($item), $searchTerm); })) ); }); $serverListTab->table->setData(array_values($filteredData)); } } if ($result['success']) { $serverListTab->statusLabel->setText('Updates ausgeführt für ' . $name); if (function_exists('desktop_notify')) { desktop_notify('Update erfolgreich', 'Updates ausgeführt für ' . $name, [ 'timeout' => 5000, 'urgency' => 'normal', ]); } } elseif (isset($result['error'])) { $serverListTab->statusLabel->setText('Update Fehler bei ' . $name . ': ' . $result['error']); if (function_exists('desktop_notify')) { desktop_notify('Update fehlgeschlagen', 'Fehler bei ' . $name . ': ' . $result['error'], [ 'timeout' => 6000, 'urgency' => 'critical', ]); } } }); } }); // Save per-server settings (API key + todos) $saveServerSettingsButton->setOnClick(function () use ($serverListTab) { if ($serverListTab->currentServerId === null) { $serverListTab->statusLabel->setText('Kein Server für das Speichern ausgewählt'); return; } $settingsKeyBase = 'servers.' . $serverListTab->currentServerId; $apiKey = trim($serverListTab->serverApiKeyInput->getValue()); $serverListTab->settings->set($settingsKeyBase . '.api_key', $apiKey); $serverListTab->settings->save(); $serverListTab->statusLabel->setText('Server-Einstellungen gespeichert für Server #' . $serverListTab->currentServerId); }); // Add TODO for current server $addTodoButton->setOnClick(function () use ($serverListTab) { if ($serverListTab->currentServerId === null) { $serverListTab->statusLabel->setText('Kein Server für TODO ausgewählt'); return; } $text = trim($serverListTab->todoInput->getValue()); if ($text === '') { return; } // Add as Kanban task in board "neu" $tasks = $serverListTab->settings->get('kanban.tasks', []); if (!is_array($tasks)) { $tasks = []; } $newTask = [ 'id' => uniqid('task_', true), 'server_id' => $serverListTab->currentServerId, 'title' => $text, 'board' => 'neu', ]; $tasks[] = $newTask; $serverListTab->settings->set('kanban.tasks', $tasks); $serverListTab->settings->save(); $serverListTab->todoInput->setValue(''); $serverListTab->loadServerTasks(); if ($serverListTab->kanbanTab !== null) { $serverListTab->kanbanTab->refresh(); } }); } public function getContainer(): Container { return $this->tab; } public function getSftpButton(): Button { return $this->sftpButton; } private static function fetchReleaseFromDomain(string $domain): string { $domain = trim($domain); if ($domain === '') { return 'unbekannt'; } $url = $domain; if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { $url = 'https://' . $url; } $url = rtrim($url, '/') . '/apps/api/system/version'; try { $context = stream_context_create([ 'http' => [ 'method' => 'GET', 'timeout' => 5, ], ]); $response = file_get_contents($url, false, $context); if ($response === false) { return 'Fehler'; } $data = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { return 'Ungültig'; } if (isset($data['version']) && is_string($data['version'])) { return $data['version']; } if (isset($data['release']) && is_string($data['release'])) { return $data['release']; } return 'unbekannt'; } catch (\Throwable $e) { return 'Fehler'; } } public function renderNeedsRebootCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['needs_reboot'] ?? ''); $normalized = strtolower(trim($value)); $baseStyle = 'px-4 text-sm '; if ($normalized === 'nein') { $style = $baseStyle . 'text-green-600'; } elseif ($normalized === 'ja') { $style = $baseStyle . 'text-red-600'; } else { $style = $baseStyle . 'text-gray-600'; } return new Label($value, $style); } public function renderUpdatesCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['updates_available'] ?? ''); $normalized = strtolower(trim($value)); $baseStyle = 'px-4 text-sm '; if ($normalized === 'nein' || $normalized === 'nein (0)') { $style = $baseStyle . 'text-green-600'; } elseif (str_starts_with($normalized, 'ja')) { $style = $baseStyle . 'text-red-600'; } else { $style = $baseStyle . 'text-gray-600'; } return new Label($value, $style); } public function renderTasksIndicatorCell(array $rowData, int $rowIndex): Label { $serverId = $rowData['id'] ?? null; $hasOpenTasks = false; if ($serverId !== null) { $tasks = $this->settings->get('kanban.tasks', []); if (is_array($tasks)) { $intServerId = (int) $serverId; $doneBoard = (string) $this->settings->get('kanban.done_board', 'fertig'); foreach ($tasks as $task) { if (((int) ($task['server_id'] ?? 0)) === $intServerId) { $taskBoard = (string) ($task['board'] ?? 'neu'); if ($taskBoard !== $doneBoard) { $hasOpenTasks = true; break; } } } } } $symbol = '●'; $baseStyle = 'px-2 text-center text-sm '; $style = $baseStyle . ($hasOpenTasks ? 'text-red-500' : 'text-green-500'); return new Label($symbol, $style); } public function renderDockerStatusCell(array $rowData, int $rowIndex): Label { $status = (string) ($rowData['docker_status'] ?? ''); $hasError = strtolower(trim($status)) === 'error' || !empty($rowData['docker_error'] ?? null); $baseStyle = 'px-4 text-sm '; $style = $hasError ? ($baseStyle . 'text-red-600') : ($baseStyle . 'text-gray-800'); return new Label($status, $style); } public function renderDockerRunningCell(array $rowData, int $rowIndex): Label { $value = $rowData['docker_running'] ?? 'unbekannt'; $baseStyle = 'px-4 text-sm '; $style = $baseStyle . 'text-gray-800'; $text = (string) $value; if (is_numeric($value)) { $count = (int) $value; $text = (string) $count; if ($count < 6) { $style = $baseStyle . 'text-red-600'; } } else { $style = $baseStyle . 'text-gray-500 italic'; } return new Label($text, $style); } public function renderOsVersionCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['os_version'] ?? ''); $normalized = strtolower(trim($value)); $baseStyle = 'px-4 text-sm '; $style = $baseStyle . 'text-gray-800'; if (preg_match('/ubuntu\s+(\d+)\./i', $value, $matches)) { $major = (int) ($matches[1] ?? 0); if ($major > 0 && $major < 24) { $style = $baseStyle . 'text-red-600'; } } if ($normalized === 'unbekannt') { $style = $baseStyle . 'text-gray-500 italic'; } return new Label($value, $style); } public function renderRootCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['root'] ?? ''); return $this->renderDiskUsageCell($value); } public function renderDataCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['data'] ?? ''); return $this->renderDiskUsageCell($value); } private function renderDiskUsageCell(string $value): Label { $trimmed = trim($value); $baseStyle = 'px-4 text-sm '; $style = $baseStyle . 'text-gray-800'; if ($trimmed === '' || strtolower($trimmed) === 'unbekannt') { $style = $baseStyle . 'text-gray-500 italic'; } else { $numeric = $trimmed; if (str_ends_with($numeric, '%')) { $numeric = substr($numeric, 0, -1); } if (is_numeric($numeric) && ((int) $numeric) >= 90) { $style = $baseStyle . 'text-red-600'; } } return new Label($value, $style); } public function renderLastBackupCell(array $rowData, int $rowIndex): Label { $value = (string) ($rowData['last_backup'] ?? 'unbekannt'); $normalized = strtolower(trim($value)); $baseStyle = 'px-4 text-sm '; if ($normalized === 'unbekannt' || $normalized === '') { $style = $baseStyle . 'text-gray-500 italic'; return new Label($value, $style); } // Check if backup is older than 24 hours try { $backupTime = \DateTime::createFromFormat('d.m.Y H:i', $value); if ($backupTime !== false) { $now = new \DateTime(); $diff = $now->diff($backupTime); $hoursAgo = ($diff->days * 24) + $diff->h; if ($hoursAgo > 24) { $style = $baseStyle . 'text-red-600'; } else { $style = $baseStyle . 'text-green-600'; } } else { $style = $baseStyle . 'text-gray-800'; } } catch (\Exception $e) { $style = $baseStyle . 'text-gray-800'; } return new Label($value, $style); } private function updateDomainDetails(array $domains): void { $this->detailDomainsContainer->clearChildren(); if (empty($domains)) { $this->detailDomainsContainer->addComponent(new Label( 'Keine Domains gefunden', 'text-xs text-gray-500 italic', )); return; } foreach ($domains as $domain) { if (!is_string($domain) || trim($domain) === '') { continue; } $domain = trim($domain); $button = new Button($domain, 'text-sm text-blue-600 hover:text-blue-800 underline text-left'); $button->setOnClick(function () use ($domain) { $url = $domain; if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { $url = 'https://' . $url; } // Versuche, die Domain im Standardbrowser zu öffnen (Linux-Umgebung) $escapedUrl = escapeshellarg($url); exec("xdg-open {$escapedUrl} > /dev/null 2>&1 &"); }); $this->detailDomainsContainer->addComponent($button); } } private function renderTodoList(): void { $this->todoListContainer->clearChildren(); if (empty($this->currentServerTodos)) { $this->todoListContainer->addComponent(new Label('Keine Tasks', 'text-xs text-gray-500 italic')); return; } foreach ($this->currentServerTodos as $index => $task) { $title = (string) ($task['title'] ?? ''); $board = (string) ($task['board'] ?? 'neu'); $row = new Container('flex flex-row items-center gap-2'); $row->addComponent(new Label('- ' . $title . ' [' . $board . ']', 'text-xs text-gray-800 flex-1')); $removeButton = new Button( 'x', 'px-2 py-1 bg-red-500 rounded hover:bg-red-600', null, 'text-white text-xs', ); $serverListTab = $this; $removeButton->setOnClick(function () use ($serverListTab, $task) { $taskId = $task['id'] ?? null; if ($taskId === null) { return; } $tasks = $serverListTab->settings->get('kanban.tasks', []); if (!is_array($tasks)) { return; } $tasks = array_values(array_filter($tasks, static fn($t) => ($t['id'] ?? null) !== $taskId)); $serverListTab->settings->set('kanban.tasks', $tasks); $serverListTab->settings->save(); $serverListTab->loadServerTasks(); if ($serverListTab->kanbanTab !== null) { $serverListTab->kanbanTab->refresh(); } }); $row->addComponent($removeButton); $this->todoListContainer->addComponent($row); } } public function renderSelectCell(array $rowData, int $rowIndex): Checkbox { $isChecked = (bool) ($rowData['selected'] ?? false); $checkbox = new Checkbox('', $isChecked); $serverListTab = $this; $rowId = $rowData['id'] ?? null; $checkbox->setOnChange(function (bool $checked) use (&$serverListTab, &$rowData, $rowId) { if ($rowId === null) { return; } foreach ($serverListTab->currentServerData as $index => $row) { if (($row['id'] ?? null) === $rowId) { $serverListTab->currentServerData[$index]['selected'] = $checked; $serverListTab->table->setData($serverListTab->currentServerData); break; } } }); return $checkbox; } private function loadServerTasks(): void { $this->currentServerTodos = []; if ($this->currentServerId === null) { $this->renderTodoList(); return; } $tasks = $this->settings->get('kanban.tasks', []); if (!is_array($tasks)) { $tasks = []; } $this->currentServerTodos = array_values(array_filter($tasks, function ($task) { return ((int) ($task['server_id'] ?? 0)) === ((int) $this->currentServerId); })); $this->renderTodoList(); } public function refreshCurrentServerTasks(): void { $this->loadServerTasks(); } }