sdl3/examples/ServerManager/UI/ServerListTab.php
2025-12-19 17:04:05 +01:00

1537 lines
68 KiB
PHP

<?php
namespace ServerManager\UI;
use PHPNative\Async\TaskManager;
use PHPNative\Framework\Settings;
use PHPNative\Tailwind\Data\Icon as IconName;
use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Checkbox;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\TabContainer;
use PHPNative\Ui\Widget\TextInput;
use PHPNative\Ui\Widget\VirtualListView;
use ServerManager\Services\HetznerService;
use ServerManager\UI\KanbanTab;
use ServerManager\UI\LoadingIndicator;
class ServerListTab
{
private Settings $settings;
private null|KanbanTab $kanbanTab;
private Container $tab;
private VirtualListView $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;
private TextInput $serverApiKeyInput;
private Container $todoListContainer;
private TextInput $todoInput;
private array $currentServerTodos = [];
private null|int $currentServerId = null;
public function __construct(
string &$apiKey,
string &$privateKeyPath,
TabContainer $tabContainer,
Label $statusLabel,
Settings $settings,
null|KanbanTab $kanbanTab = null,
) {
$this->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();
}
}