560 lines
25 KiB
PHP
560 lines
25 KiB
PHP
<?php
|
|
|
|
namespace ServerManager\UI;
|
|
|
|
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\Icon;
|
|
use PHPNative\Ui\Widget\Label;
|
|
use PHPNative\Ui\Widget\TabContainer;
|
|
use PHPNative\Ui\Widget\Table;
|
|
use PHPNative\Ui\Widget\TextInput;
|
|
use ServerManager\Services\HetznerService;
|
|
use ServerManager\UI\LoadingIndicator;
|
|
|
|
class ServerListTab
|
|
{
|
|
private Container $tab;
|
|
private Table $table;
|
|
private TextInput $searchInput;
|
|
private Label $statusLabel;
|
|
private Button $refreshButton;
|
|
private Button $sftpButton;
|
|
private Button $sshTerminalButton;
|
|
|
|
public array $currentServerData = [];
|
|
public null|array $selectedServer = null;
|
|
|
|
private Label $detailId;
|
|
private Label $detailName;
|
|
private Label $detailStatus;
|
|
private Label $detailType;
|
|
private Label $detailIpv4;
|
|
private Container $detailDomainsContainer;
|
|
private LoadingIndicator $loadingIndicator;
|
|
|
|
public function __construct(
|
|
string &$apiKey,
|
|
string &$privateKeyPath,
|
|
TabContainer $tabContainer,
|
|
Label $statusLabel,
|
|
) {
|
|
$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);
|
|
|
|
// 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
|
|
$this->table = new Table(style: ' flex-1');
|
|
$this->table->setColumns([
|
|
['key' => 'id', 'title' => 'ID', 'width' => 80],
|
|
['key' => 'name', 'title' => 'Name'],
|
|
['key' => 'status', 'title' => 'Status', 'width' => 100],
|
|
['key' => 'type', 'title' => 'Typ', 'width' => 80],
|
|
['key' => 'ipv4', 'title' => 'IPv4', 'width' => 160],
|
|
['key' => 'docker_status', 'title' => 'Docker', 'width' => 90],
|
|
[
|
|
'key' => 'needs_reboot',
|
|
'title' => 'Neustart',
|
|
'width' => 150,
|
|
'render' => [$this, 'renderNeedsRebootCell'],
|
|
],
|
|
[
|
|
'key' => 'updates_available',
|
|
'title' => 'Updates',
|
|
'width' => 150,
|
|
'render' => [$this, 'renderUpdatesCell'],
|
|
],
|
|
]);
|
|
|
|
// Load initial test data
|
|
$this->currentServerData = HetznerService::generateTestData();
|
|
$this->table->setData($this->currentServerData);
|
|
|
|
$leftSide->addComponent($this->table);
|
|
|
|
// Right side: Detail panel
|
|
$detailPanel = new Container('flex flex-col gap-3 w-120 bg-white border-2 border-gray-300 rounded p-4');
|
|
$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);
|
|
|
|
$this->tab->addComponent($leftSide);
|
|
$this->tab->addComponent($detailPanel);
|
|
|
|
// Setup event handlers
|
|
$this->setupEventHandlers($currentApiKey, $currentPrivateKeyPath);
|
|
}
|
|
|
|
private function setupEventHandlers(string &$currentApiKey, string &$currentPrivateKeyPath): 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']);
|
|
|
|
$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' => null,
|
|
'docker_error' => null,
|
|
'needs_reboot' => 'unbekannt',
|
|
'updates_available' => 'unbekannt',
|
|
], $row), $result['servers']);
|
|
|
|
$serverListTab->table->setData($serverListTab->currentServerData);
|
|
$serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden');
|
|
|
|
// 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';
|
|
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' => null,
|
|
'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;
|
|
|
|
// Docker status for main application container
|
|
$output = $ssh->exec('docker inspect psc-web-1');
|
|
|
|
if (empty($output)) {
|
|
return [
|
|
'index' => $index,
|
|
'docker' => null,
|
|
'docker_error' => 'Leere Docker-Antwort',
|
|
'docker_status' => 'error',
|
|
'needs_reboot' => $needsReboot,
|
|
'updates' => $updatesCount,
|
|
];
|
|
}
|
|
|
|
$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',
|
|
'needs_reboot' => $needsReboot,
|
|
'updates' => $updatesCount,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'index' => $index,
|
|
'docker' => $json,
|
|
'docker_error' => null,
|
|
'docker_status' => 'ok',
|
|
'needs_reboot' => $needsReboot,
|
|
'updates' => $updatesCount,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
return [
|
|
'index' => $index,
|
|
'docker' => null,
|
|
'docker_error' => 'SSH-Fehler: ' . $e->getMessage(),
|
|
'docker_status' => 'error',
|
|
'needs_reboot' => null,
|
|
'updates' => null,
|
|
];
|
|
}
|
|
});
|
|
|
|
$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)) {
|
|
if (isset($dockerResult['docker'][0]['Config']['Env'])) {
|
|
$hosts = array_filter(
|
|
$dockerResult['docker'][0]['Config']['Env'],
|
|
function ($item) {
|
|
if (str_starts_with($item, 'LETSENCRYPT_HOST')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
);
|
|
$domains = explode(
|
|
',',
|
|
substr(array_first($hosts), strlen('LETSENCRYPT_HOST=')),
|
|
);
|
|
|
|
$serverListTab->currentServerData[$i]['domains'] = $domains;
|
|
}
|
|
$serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker'];
|
|
$serverListTab->table->setData($serverListTab->currentServerData);
|
|
}
|
|
|
|
// 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('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'];
|
|
}
|
|
});
|
|
|
|
$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->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);
|
|
});
|
|
});
|
|
}
|
|
|
|
public function getContainer(): Container
|
|
{
|
|
return $this->tab;
|
|
}
|
|
|
|
public function getSftpButton(): Button
|
|
{
|
|
return $this->sftpButton;
|
|
}
|
|
|
|
public function renderNeedsRebootCell(array $rowData, int $rowIndex): Label
|
|
{
|
|
$value = (string) ($rowData['needs_reboot'] ?? '');
|
|
$normalized = strtolower(trim($value));
|
|
|
|
$baseStyle = 'px-4 py-2 border-r border-gray-300 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 py-2 border-r border-gray-300 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|