diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php index 011fafa..d55222c 100644 --- a/examples/ServerManager/UI/ServerListTab.php +++ b/examples/ServerManager/UI/ServerListTab.php @@ -6,6 +6,7 @@ use PHPNative\Async\TaskManager; use PHPNative\Tailwind\Data\Icon as IconName; use PHPNative\Ui\Widget\Button; use PHPNative\Ui\Widget\Container; +use PHPNative\Ui\Widget\Checkbox; use PHPNative\Ui\Widget\Icon; use PHPNative\Ui\Widget\Label; use PHPNative\Ui\Widget\TabContainer; @@ -65,6 +66,22 @@ class ServerListTab $this->refreshButton->setIcon($refreshIcon); $headerRow->addComponent($this->refreshButton); + // Bulk action buttons for selected servers + $rebootButton = new Button( + 'Reboot ausgewählte', + 'px-3 py-2 bg-red-600 rounded hover:bg-red-700', + null, + 'text-white', + ); + $updateButton = new Button( + 'Update ausgewählte', + 'px-3 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); @@ -81,7 +98,13 @@ class ServerListTab // Table $this->table = new Table(style: ' flex-1'); $this->table->setColumns([ - ['key' => 'id', 'title' => 'ID', 'width' => 100], + [ + 'key' => 'selected', + 'title' => '', + 'width' => 40, + 'render' => [$this, 'renderSelectCell'], + ], + ['key' => 'id', 'title' => 'ID', 'width' => 80], ['key' => 'name', 'title' => 'Name'], ['key' => 'status', 'title' => 'Status', 'width' => 90], ['key' => 'type', 'title' => 'Typ', 'width' => 80], @@ -203,10 +226,15 @@ class ServerListTab $this->tab->addComponent($detailPanel); // Setup event handlers - $this->setupEventHandlers($currentApiKey, $currentPrivateKeyPath); + $this->setupEventHandlers($currentApiKey, $currentPrivateKeyPath, $rebootButton, $updateButton); } - private function setupEventHandlers(string &$currentApiKey, string &$currentPrivateKeyPath): void + private function setupEventHandlers( + string &$currentApiKey, + string &$currentPrivateKeyPath, + Button $rebootButton, + Button $updateButton, + ): void { // Table row selection $serverListTab = $this; @@ -493,6 +521,174 @@ class ServerListTab $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); + } elseif (isset($result['error'])) { + $serverListTab->statusLabel->setText('Reboot Fehler bei ' . $name . ': ' . $result['error']); + } + }); + } + }); + + // 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 + $ssh->exec( + 'apt-get update >/dev/null 2>&1 && ' . + 'DEBIAN_FRONTEND=noninteractive apt-get -y upgrade >/dev/null 2>&1', + ); + + // Re-check number of available updates + $updatesOutput = trim($ssh->exec( + 'apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0', + )); + $updatesCount = is_numeric($updatesOutput) ? (int) $updatesOutput : 0; + + return [ + 'index' => $index, + 'success' => true, + 'updates' => $updatesCount, + ]; + } 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'; + } + + $serverListTab->table->setData($serverListTab->currentServerData); + } + + if ($result['success']) { + $serverListTab->statusLabel->setText('Updates ausgeführt für ' . $name); + } elseif (isset($result['error'])) { + $serverListTab->statusLabel->setText('Update Fehler bei ' . $name . ': ' . $result['error']); + } + }); + } + }); } public function getContainer(): Container @@ -575,4 +771,21 @@ class ServerListTab $this->detailDomainsContainer->addComponent($button); } } + + public function renderSelectCell(array $rowData, int $rowIndex): Checkbox + { + $isChecked = (bool) ($rowData['selected'] ?? false); + $checkbox = new Checkbox('', $isChecked); + + $serverListTab = $this; + $checkbox->setOnChange(function (bool $checked) use ($serverListTab, $rowIndex) { + if (!isset($serverListTab->currentServerData[$rowIndex])) { + return; + } + + $serverListTab->currentServerData[$rowIndex]['selected'] = $checked; + }); + + return $checkbox; + } } diff --git a/src/Ui/Widget/Checkbox.php b/src/Ui/Widget/Checkbox.php index 0720594..68adc00 100644 --- a/src/Ui/Widget/Checkbox.php +++ b/src/Ui/Widget/Checkbox.php @@ -44,6 +44,18 @@ class Checkbox extends Component $this->onChange = $onChange; } + public function layout(null|TextRenderer $textRenderer = null): void + { + parent::layout($textRenderer); + + // Force a compact, fixed size for the checkbox + $checkboxSize = 20; + $this->viewport->width = $checkboxSize; + $this->viewport->height = $checkboxSize; + $this->contentViewport->width = $checkboxSize; + $this->contentViewport->height = $checkboxSize; + } + public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool { // Check if click is within checkbox bounds (not label)