This commit is contained in:
Thomas Peterson 2025-12-08 10:19:54 +01:00
parent b991570285
commit 81d0f5a429
11 changed files with 764 additions and 122 deletions

View File

@ -32,6 +32,10 @@ class HetznerService
'needs_reboot' => 'unbekannt',
'updates_available' => 'unbekannt',
'os_version' => 'unbekannt',
'release' => 'unbekannt',
'root' => 'unbekannt',
'data' => 'unbekannt',
'last_backup' => 'unbekannt',
];
}
@ -63,6 +67,10 @@ class HetznerService
'needs_reboot' => 'nein',
'updates_available' => 'nein',
'os_version' => 'Ubuntu 22.04 LTS',
'release' => 'v1.0.0',
'root' => '35%',
'data' => '42%',
'last_backup' => 'unbekannt',
];
}
return $testData;

View File

@ -8,9 +8,9 @@ use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\TextInput;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Widget\Modal;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Widget\TextInput;
class KanbanTab
{
@ -125,7 +125,7 @@ class KanbanTab
}
foreach ($boards as $boardName) {
$isSelected = ($boardName === $this->currentEditingBoard);
$isSelected = $boardName === $this->currentEditingBoard;
$style = 'px-2 py-1 rounded text-xs border ';
if ($isSelected) {
@ -296,8 +296,20 @@ class KanbanTab
$tasks = [];
}
// Map server IDs to names if the server list tab is available
$serverNames = [];
if ($this->serverListTab !== null) {
foreach ($this->serverListTab->currentServerData as $row) {
$id = $row['id'] ?? null;
$name = $row['name'] ?? null;
if ($id !== null && $name !== null) {
$serverNames[(int) $id] = (string) $name;
}
}
}
foreach ($boards as $boardName) {
$column = new Container('flex flex-col bg-white rounded shadow-md w-64 max-h-full');
$column = new Container('flex flex-col bg-white rounded shadow-md w-128 max-h-full');
$columnHeader = new Container('px-3 py-2 border-b border-gray-300 bg-gray-100');
$columnHeader->addComponent(new Label($boardName, 'text-sm font-semibold text-gray-800'));
@ -316,11 +328,19 @@ class KanbanTab
foreach ($boardTasks as $task) {
$title = (string) ($task['title'] ?? '');
$serverId = $task['server_id'] ?? null;
$serverLabel = $serverId !== null ? ('Server #' . $serverId) : 'Kein Server';
$serverName = null;
if ($serverId !== null && isset($serverNames[(int) $serverId])) {
$serverName = $serverNames[(int) $serverId];
}
if ($serverName !== null) {
$serverLabel = $serverName . ' (#' . $serverId . ')';
} else {
$serverLabel = $serverId !== null ? ('Server #' . $serverId) : 'Kein Server';
}
$taskId = $task['id'] ?? null;
$card = new Container(
'flex flex-col gap-1 px-3 py-2 bg-white border border-gray-200 rounded shadow-sm',
'flex flex-col gap-1 px-3 py-2 bg-lime-100 border border-red-500 rounded-sm shadow-lg',
);
// Header row with title and action icons
@ -328,12 +348,7 @@ class KanbanTab
$headerRow->addComponent(new Label($title, 'text-xs text-gray-900 flex-1'));
// Edit icon button
$editButton = new Button(
'',
'p-1 rounded hover:bg-blue-100',
null,
'text-blue-600',
);
$editButton = new Button('', 'p-1 rounded hover:bg-blue-100', null, 'text-blue-600');
$editIcon = new Icon(IconName::edit, 12, 'text-blue-600');
$editButton->setIcon($editIcon);
@ -343,12 +358,7 @@ class KanbanTab
});
// Delete icon button
$deleteButton = new Button(
'',
'p-1 rounded hover:bg-red-100',
null,
'text-red-600',
);
$deleteButton = new Button('', 'p-1 rounded hover:bg-red-100', null, 'text-red-600');
$deleteIcon = new Icon(IconName::trash, 12, 'text-red-600');
$deleteButton->setIcon($deleteIcon);
@ -362,10 +372,7 @@ class KanbanTab
return;
}
$tasks = array_values(array_filter(
$tasks,
static fn($t) => ($t['id'] ?? null) !== $taskId,
));
$tasks = array_values(array_filter($tasks, static fn($t) => ($t['id'] ?? null) !== $taskId));
$kanbanTab->settings->set('kanban.tasks', $tasks);
$kanbanTab->settings->save();
@ -381,7 +388,7 @@ class KanbanTab
$card->addComponent($headerRow);
// Server label
$card->addComponent(new Label($serverLabel, 'text-[10px] text-gray-500'));
$card->addComponent(new Label($serverLabel, 'text-sm text-gray-500'));
$columnBody->addComponent($card);
}

View File

@ -118,12 +118,47 @@ class ServerListTab
'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' => 140],
['key' => 'docker_status', 'title' => 'Docker', 'width' => 80],
['key' => 'os_version', 'title' => 'Ubuntu', 'width' => 180],
['key' => 'ipv4', 'title' => 'IPv4', 'width' => 150],
[
'key' => 'docker_status',
'title' => 'Docker',
'width' => 100,
'render' => [$this, 'renderDockerStatusCell'],
],
[
'key' => 'docker_running',
'title' => 'Running',
'width' => 100,
'render' => [$this, 'renderDockerRunningCell'],
],
[
'key' => 'os_version',
'title' => 'Ubuntu',
'width' => 180,
'render' => [$this, 'renderOsVersionCell'],
],
['key' => 'release', 'title' => 'Release', 'width' => 140],
[
'key' => 'root',
'title' => 'Root',
'width' => 100,
'render' => [$this, 'renderRootCell'],
],
[
'key' => 'data',
'title' => 'Data',
'width' => 90,
'render' => [$this, 'renderDataCell'],
],
[
'key' => 'needs_reboot',
'title' => 'Neustart',
@ -136,6 +171,12 @@ class ServerListTab
'width' => 130,
'render' => [$this, 'renderUpdatesCell'],
],
[
'key' => 'last_backup',
'title' => 'Letztes Backup',
'width' => 180,
'render' => [$this, 'renderLastBackupCell'],
],
]);
// Load initial test data
@ -145,7 +186,7 @@ class ServerListTab
$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');
$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);
@ -385,16 +426,26 @@ class ServerListTab
// 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, false);
$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',
@ -410,6 +461,7 @@ class ServerListTab
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;
}
@ -427,7 +479,7 @@ class ServerListTab
if (!$ssh->login('root', $key)) {
return [
'index' => $index,
'docker' => null,
'docker' => 'fetch usage',
'docker_error' => 'SSH Login fehlgeschlagen',
'docker_status' => 'error',
];
@ -451,18 +503,88 @@ class ServerListTab
));
$osVersion = $osOutput !== '' ? $osOutput : 'unbekannt';
// Docker status for main application container
$output = $ssh->exec('docker inspect psc-web-1');
// 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,
];
}
@ -473,20 +595,111 @@ class ServerListTab
'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 [
@ -494,9 +707,13 @@ class ServerListTab
'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',
];
}
});
@ -512,23 +729,6 @@ class ServerListTab
}
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'];
$searchTerm = $serverListTab->searchInput->getValue();
if (empty($searchTerm)) {
@ -551,6 +751,14 @@ class ServerListTab
}
}
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'];
@ -577,6 +785,27 @@ class ServerListTab
: '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'];
@ -586,6 +815,20 @@ class ServerListTab
$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) {
@ -595,6 +838,7 @@ class ServerListTab
if (isset($serverListTab->currentServerData[$index])) {
$serverListTab->currentServerData[$index]['docker_error'] =
'Async Fehler: ' . $errorMsg;
$serverListTab->currentServerData[$index]['docker_running'] = 'error';
}
});
}
@ -894,12 +1138,57 @@ class ServerListTab
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 border-r border-gray-300 text-sm ';
$baseStyle = 'px-4 text-sm ';
if ($normalized === 'nein') {
$style = $baseStyle . 'text-green-600';
@ -917,7 +1206,7 @@ class ServerListTab
$value = (string) ($rowData['updates_available'] ?? '');
$normalized = strtolower(trim($value));
$baseStyle = 'px-4 border-r border-gray-300 text-sm ';
$baseStyle = 'px-4 text-sm ';
if ($normalized === 'nein' || $normalized === 'nein (0)') {
$style = $baseStyle . 'text-green-600';
@ -930,6 +1219,158 @@ class ServerListTab
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();

View File

@ -18,6 +18,7 @@ class SettingsModal
private TextInput $apiKeyInput;
private TextInput $privateKeyPathInput;
private TextInput $remoteStartDirInput;
private TextInput $doneBoardInput;
public function __construct(Settings $settings, MenuBar $menuBar, string &$apiKey, string &$privateKeyPath, string &$remoteStartDir)
{
@ -31,11 +32,17 @@ class SettingsModal
'Remote Start Directory',
'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black'
);
$this->doneBoardInput = new TextInput(
'Fertig-Board (z.B. fertig)',
'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black'
);
// Set initial values
$this->apiKeyInput->setValue($apiKey);
$this->privateKeyPathInput->setValue($privateKeyPath);
$this->remoteStartDirInput->setValue($remoteStartDir);
$doneBoard = (string) $settings->get('kanban.done_board', 'fertig');
$this->doneBoardInput->setValue($doneBoard);
// Create modal dialog
$modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3');
@ -63,6 +70,15 @@ class SettingsModal
$remoteStartDirFieldContainer->addComponent($this->remoteStartDirInput);
$modalDialog->addComponent($remoteStartDirFieldContainer);
// Done-board field
$doneBoardFieldContainer = new Container('flex flex-col gap-1');
$doneBoardFieldContainer->addComponent(new Label(
'Board-Name für erledigte Tasks (z.B. "fertig")',
'text-sm text-gray-600'
));
$doneBoardFieldContainer->addComponent($this->doneBoardInput);
$modalDialog->addComponent($doneBoardFieldContainer);
// Buttons
$buttonRow = new Container('flex flex-row gap-2 justify-end');
$cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-200 text-black rounded hover:bg-gray-300');
@ -80,20 +96,37 @@ class SettingsModal
$apiKeyInputRef = $this->apiKeyInput;
$privateKeyPathInputRef = $this->privateKeyPathInput;
$remoteStartDirInputRef = $this->remoteStartDirInput;
$doneBoardInputRef = $this->doneBoardInput;
$cancelButton->setOnClick(function () use ($menuBar, $settingsModal) {
$menuBar->closeAllMenus();
$settingsModal->hide();
});
$saveButton->setOnClick(function () use ($menuBar, $settingsModal, &$apiKey, &$privateKeyPath, &$remoteStartDir, $settings, $apiKeyInputRef, $privateKeyPathInputRef, $remoteStartDirInputRef) {
$saveButton->setOnClick(function () use (
$menuBar,
$settingsModal,
&$apiKey,
&$privateKeyPath,
&$remoteStartDir,
$settings,
$apiKeyInputRef,
$privateKeyPathInputRef,
$remoteStartDirInputRef,
$doneBoardInputRef
) {
$apiKey = trim($apiKeyInputRef->getValue());
$privateKeyPath = trim($privateKeyPathInputRef->getValue());
$remoteStartDir = trim($remoteStartDirInputRef->getValue());
$doneBoard = trim($doneBoardInputRef->getValue());
if ($doneBoard === '') {
$doneBoard = 'fertig';
}
$settings->set('api_key', $apiKey);
$settings->set('private_key_path', $privateKeyPath);
$settings->set('remote_start_dir', $remoteStartDir);
$settings->set('kanban.done_board', $doneBoard);
$settings->save();
$menuBar->closeAllMenus();

View File

@ -26,7 +26,9 @@ class SftpManagerTab
private TextInput $filenameInput;
private Modal $renameModal;
private TextInput $renameInput;
private Label $renamePathLabel;
private string $currentRenameFilePath = '';
private string $currentRenameMode = 'remote';
private Modal $deleteConfirmModal;
private string $currentDeleteFilePath = '';
private Label $deleteConfirmLabel;
@ -146,6 +148,11 @@ class SftpManagerTab
// Create delete confirmation modal
$this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel);
// Setup local rename handler
$this->localFileBrowser->setOnRenameFile(function ($path, $row) use ($sftpTab, $statusLabel) {
$sftpTab->handleLocalRename($path, $row, $statusLabel);
});
// Setup transfer button handlers
$uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) {
$sftpTab->handleUpload($currentPrivateKeyPath, $serverListTab, $statusLabel);
@ -1406,13 +1413,14 @@ class SftpManagerTab
$modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96');
// Title
$modalContent->addComponent(new Label('Datei umbenennen', 'text-lg font-bold text-black'));
$modalContent->addComponent(new Label('Umbenennen', 'text-lg font-bold text-black'));
// Path info
$this->renamePathLabel = new Label('', 'text-xs text-gray-600 font-mono break-words');
$modalContent->addComponent($this->renamePathLabel);
// Filename input
$this->renameInput = new TextInput(
'Neuer Dateiname',
'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black',
);
$this->renameInput = new TextInput('Neuer Name', 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black');
$modalContent->addComponent($this->renameInput);
// Button container
@ -1437,12 +1445,25 @@ class SftpManagerTab
}
$oldPath = $sftpTab->currentRenameFilePath;
$directory = dirname($oldPath);
$newPath = $directory . '/' . $newFilename;
$separator = $sftpTab->currentRenameMode === 'local' ? DIRECTORY_SEPARATOR : '/';
$directory = $sftpTab->normalizeDirectory(dirname($oldPath), $separator);
$newPath = $sftpTab->joinPath($directory, $newFilename, $separator);
// Close rename modal
$sftpTab->renameModal->setVisible(false);
if ($sftpTab->currentRenameMode === 'local') {
$succeeded = @rename($oldPath, $newPath);
if ($succeeded === false) {
$statusLabel->setText('Lokales Umbenennen fehlgeschlagen');
return;
}
$statusLabel->setText('Lokal umbenannt: ' . basename($newPath));
$sftpTab->localFileBrowser->loadDirectory($directory);
return;
}
// Perform rename via SFTP
$selectedServerRef = &$serverListTab->selectedServer;
@ -1606,9 +1627,25 @@ class SftpManagerTab
return;
}
$this->currentRenameMode = 'remote';
$this->currentRenameFilePath = $path;
$filename = basename($path);
$this->renameInput->setValue($filename);
$this->renamePathLabel->setText('Remote: ' . $path);
$this->renameModal->setVisible(true);
}
private function handleLocalRename(string $path, array $row, Label $statusLabel): void
{
if (!file_exists($path)) {
$statusLabel->setText('Lokale Datei/Ordner nicht gefunden');
return;
}
$this->currentRenameMode = 'local';
$this->currentRenameFilePath = $path;
$this->renameInput->setValue(basename($path));
$this->renamePathLabel->setText('Lokal: ' . $path);
$this->renameModal->setVisible(true);
}
@ -1629,4 +1666,23 @@ class SftpManagerTab
$this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?');
$this->deleteConfirmModal->setVisible(true);
}
private function normalizeDirectory(string $directory, string $separator): string
{
$normalized = rtrim($directory, $separator);
if ($normalized === '') {
return $separator;
}
return $normalized;
}
private function joinPath(string $directory, string $filename, string $separator): string
{
if ($directory === $separator) {
return $directory . $filename;
}
return $directory . $separator . $filename;
}
}

View File

@ -10,7 +10,9 @@ class Background implements Parser
{
$color = new \PHPNative\Tailwind\Style\Color();
preg_match_all('/bg-(.*)/', $style, $output_array);
// Nur den ersten bg-* Token ohne nachfolgende Klassen extrahieren
// Beispiel: "bg-lime-300 border ..." -> "lime-300"
preg_match_all('/bg-([^\s]+)/', $style, $output_array);
if (count($output_array[0]) > 0) {
$colorStyle = $output_array[1][0];

View File

@ -10,6 +10,7 @@ class Border implements Parser
{
$color = new \PHPNative\Tailwind\Style\Color();
// Rounded per side: rounded-t-sm / rounded-b-lg / rounded-l-md / rounded-r-xl
preg_match_all('/rounded-(t|b|l|r)-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array);
if (count($output_array[0]) > 0) {
$size = match ((string) $output_array[2][0]) {
@ -41,6 +42,7 @@ class Border implements Parser
};
}
// Rounded all corners: rounded-sm / rounded-lg / ...
preg_match_all('/rounded-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array);
if (count($output_array[0]) > 0) {
$size = match ((string) $output_array[1][0]) {
@ -61,7 +63,8 @@ class Border implements Parser
);
}
preg_match_all('/rounded/', $style, $output_array);
// Generic rounded (no size) -> small radius
preg_match_all('/(?<![a-z-])rounded(?![a-z-])/', $style, $output_array);
if (count($output_array[0]) > 0) {
$size = 4;
@ -73,24 +76,41 @@ class Border implements Parser
);
}
preg_match_all('/border-([tblr])-(.*)/', $style, $output_array);
if (count($output_array[0]) > 0) {
return match ((string) $output_array[1][0]) {
't' => new \PHPNative\Tailwind\Style\Border(true, top: (int) $output_array[2][0]),
'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: (int) $output_array[2][0]),
'r' => new \PHPNative\Tailwind\Style\Border(true, right: (int) $output_array[2][0]),
'l' => new \PHPNative\Tailwind\Style\Border(true, left: (int) $output_array[2][0]),
// Directional borders with explicit numeric width, e.g. border-t-2
if (preg_match('/^border-(t|b|l|r)-(\d+)$/', $style, $m)) {
$w = (int) $m[2];
return match ($m[1]) {
't' => new \PHPNative\Tailwind\Style\Border(true, top: $w),
'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: $w),
'r' => new \PHPNative\Tailwind\Style\Border(true, right: $w),
'l' => new \PHPNative\Tailwind\Style\Border(true, left: $w),
};
}
preg_match_all('/border-(.*)/', $style, $output_array);
if (count($output_array[0]) > 0) {
$colorStyle = $output_array[1][0];
$color = Color::parse($colorStyle);
return new \PHPNative\Tailwind\Style\Border(false, $color);
// Directional borders without width -> default 1px, e.g. border-r
if (preg_match('/^border-(t|b|l|r)$/', $style, $m)) {
$w = 1;
return match ($m[1]) {
't' => new \PHPNative\Tailwind\Style\Border(true, top: $w),
'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: $w),
'r' => new \PHPNative\Tailwind\Style\Border(true, right: $w),
'l' => new \PHPNative\Tailwind\Style\Border(true, left: $w),
};
}
preg_match_all('/border/', $style, $output_array);
// Color-only border: border-red-500 / border-lime-300
if (preg_match('/^border-(.+)$/', $style, $output_array)) {
$colorStyle = $output_array[1];
$color = Color::parse($colorStyle);
// Nur Farbe setzen, keine Breiten Breiten kommen von "border" oder "border-r" etc.
return new \PHPNative\Tailwind\Style\Border(
enabled: false,
color: $color,
);
}
// Plain "border" -> 1px Rahmen rundherum, Standardfarbe
preg_match_all('/(?<![a-z-])border(?![a-z-])/', $style, $output_array);
if (count($output_array[0]) > 0) {
return new \PHPNative\Tailwind\Style\Border(
enabled: true,
@ -111,41 +131,41 @@ class Border implements Parser
if ($style2->enabled && !$style1->enabled) {
$style1->enabled = true;
}
if ($style2->color->red != null) {
// Farbe nur übernehmen, wenn im zweiten Style wirklich gesetzt ist
if ($style2->color->isNotSet()) {
$style1->color->red = $style2->color->red;
}
if ($style2->color->green != null) {
$style1->color->green = $style2->color->green;
}
if ($style2->color->blue != null) {
$style1->color->blue = $style2->color->blue;
}
if ($style2->color->alpha != null) {
if ($style2->color->alpha !== null) {
$style1->color->alpha = $style2->color->alpha;
}
if ($style2->top != null) {
if ($style2->top !== null) {
$style1->top = $style2->top;
}
if ($style2->bottom != null) {
if ($style2->bottom !== null) {
$style1->bottom = $style2->bottom;
}
if ($style2->left != null) {
if ($style2->left !== null) {
$style1->left = $style2->left;
}
if ($style2->right != null) {
if ($style2->right !== null) {
$style1->right = $style2->right;
}
if ($style2->roundTopLeft != null) {
if ($style2->roundTopLeft !== null) {
$style1->roundTopLeft = $style2->roundTopLeft;
}
if ($style2->roundTopRight != null) {
if ($style2->roundTopRight !== null) {
$style1->roundTopRight = $style2->roundTopRight;
}
if ($style2->roundBottomLeft != null) {
if ($style2->roundBottomLeft !== null) {
$style1->roundBottomLeft = $style2->roundBottomLeft;
}
if ($style2->roundBottomRight != null) {
if ($style2->roundBottomRight !== null) {
$style1->roundBottomRight = $style2->roundBottomRight;
}
}
}

View File

@ -478,6 +478,75 @@ abstract class Component
]);
}
}
// Optional border stroke (separate von der Hintergrundfüllung)
if (isset($this->computedStyles[\PHPNative\Tailwind\Style\Border::class])) {
/** @var \PHPNative\Tailwind\Style\Border $border */
$border = $this->computedStyles[\PHPNative\Tailwind\Style\Border::class];
$top = (int) ($border->top ?? 0);
$bottom = (int) ($border->bottom ?? 0);
$left = (int) ($border->left ?? 0);
$right = (int) ($border->right ?? 0);
$hasStroke = $border->enabled || $top > 0 || $bottom > 0 || $left > 0 || $right > 0;
if ($hasStroke) {
$color = $border->color;
// Fallback-Farbe, wenn keine explizite gesetzt ist
$red = ($color->red >= 0) ? $color->red : 160;
$green = ($color->green >= 0) ? $color->green : 160;
$blue = ($color->blue >= 0) ? $color->blue : 160;
$alpha = $color->alpha;
sdl_set_render_draw_color($renderer, $red, $green, $blue, $alpha);
$x = (int) $this->viewport->x;
$y = (int) $this->viewport->y;
$w = (int) $this->viewport->width;
$h = (int) $this->viewport->height;
// Obere Kante
if ($top > 0) {
sdl_render_fill_rect($renderer, [
'x' => $x,
'y' => $y,
'w' => $w,
'h' => $top,
]);
}
// Untere Kante
if ($bottom > 0) {
sdl_render_fill_rect($renderer, [
'x' => $x,
'y' => $y + $h - $bottom,
'w' => $w,
'h' => $bottom,
]);
}
// Linke Kante
if ($left > 0) {
sdl_render_fill_rect($renderer, [
'x' => $x,
'y' => $y,
'w' => $left,
'h' => $h,
]);
}
// Rechte Kante
if ($right > 0) {
sdl_render_fill_rect($renderer, [
'x' => $x + $w - $right,
'y' => $y,
'w' => $right,
'h' => $h,
]);
}
}
}
if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) {
sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10);
sdl_render_rect($renderer, [

View File

@ -285,13 +285,21 @@ class FileBrowser extends Container
public function renderActionsCell(array $rowData, int $rowIndex): Container
{
// Match the cell style from Table (100px width for icon buttons)
$container = new Container('w-25 border-r border-gray-300 flex flex-row items-center justify-center gap-1');
$container = new Container('w-25 flex flex-row items-center justify-center gap-1');
// Only show action buttons for files (not directories)
if (!($rowData['isDir'] ?? false) && !empty($rowData['path'])) {
$fileBrowser = $this;
$isDir = (bool) ($rowData['isDir'] ?? false);
$hasPath = !empty($rowData['path']);
$isParentEntry = ($rowData['name'] ?? '') === '..';
// Edit button
// Skip action buttons for invalid rows or parent navigation
if (!$hasPath || $isParentEntry) {
return $container;
}
$fileBrowser = $this;
// Edit button only for files
if (!$isDir) {
$editButton = new Button('', 'text-blue-500 hover:text-blue-600 flex items-center justify-center');
$editIcon = new Icon(\PHPNative\Tailwind\Data\Icon::edit, 16, 'text-blue-500');
$editButton->setIcon($editIcon);
@ -301,19 +309,21 @@ class FileBrowser extends Container
}
});
$container->addComponent($editButton);
}
// Rename button
$renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center');
$renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500');
$renameButton->setIcon($renameIcon);
$renameButton->setOnClick(function () use ($fileBrowser, $rowData) {
if ($fileBrowser->onRenameFile !== null) {
($fileBrowser->onRenameFile)($rowData['path'], $rowData);
}
});
$container->addComponent($renameButton);
// Rename button for files and directories
$renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center');
$renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500');
$renameButton->setIcon($renameIcon);
$renameButton->setOnClick(function () use ($fileBrowser, $rowData) {
if ($fileBrowser->onRenameFile !== null) {
($fileBrowser->onRenameFile)($rowData['path'], $rowData);
}
});
$container->addComponent($renameButton);
// Delete button
// Delete button only for files
if (!$isDir) {
$deleteButton = new Button('', 'text-red-500 hover:text-red-600 flex items-center justify-center');
$deleteIcon = new Icon(\PHPNative\Tailwind\Data\Icon::trash, 16, 'text-red-500');
$deleteButton->setIcon($deleteIcon);

View File

@ -55,7 +55,7 @@ class Table extends Container
$title = $column['title'] ?? $key;
$width = $column['width'] ?? null;
$style = 'px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer';
$style = 'w-full px-4 py-2 text-black font-bold border-r border-gray-300 hover:bg-gray-300 cursor-pointer';
if ($width) {
$style .= ' w-' . ((int) ($width / 4));
} else {
@ -126,7 +126,7 @@ class Table extends Container
$value = $rowData[$key] ?? '';
$width = $column['width'] ?? null;
$cellStyle = 'px-4 py-2 text-black border-r border-gray-300';
$cellStyle = 'w-full px-4 py-2 text-black border-r border-gray-300';
if ($width) {
$cellStyle .= ' w-' . ((int) ($width / 4));
} else {

View File

@ -13,7 +13,7 @@ class VirtualListView extends Container
private float $scrollY = 0.0;
private int $firstVisibleRow = 0;
private int $lastVisibleRow = -1;
private ?int $selectedRowIndex = null;
private null|int $selectedRowIndex = null;
private $onRowSelect = null;
private const SCROLLBAR_WIDTH = 16;
@ -46,7 +46,7 @@ class VirtualListView extends Container
$this->onRowSelect = $callback;
}
public function getSelectedRow(): ?array
public function getSelectedRow(): null|array
{
if ($this->selectedRowIndex === null) {
return null;
@ -101,7 +101,7 @@ class VirtualListView extends Container
return $height;
}
private function updateVisibleRows(?TextRenderer $textRenderer): void
private function updateVisibleRows(null|TextRenderer $textRenderer): void
{
$this->clearChildren();
@ -116,8 +116,8 @@ class VirtualListView extends Container
$rowCount = count($this->rows);
$first = (int) floor($this->scrollY / $this->rowHeight);
$visibleCount = (int) ceil($viewportHeight / $this->rowHeight) + self::VISIBLE_BUFFER;
$last = min($rowCount - 1, $first + $visibleCount - 1);
$visibleCount = ((int) ceil($viewportHeight / $this->rowHeight)) + self::VISIBLE_BUFFER;
$last = min($rowCount - 1, ($first + $visibleCount) - 1);
$this->firstVisibleRow = $first;
$this->lastVisibleRow = $last;
@ -126,7 +126,7 @@ class VirtualListView extends Container
$rowData = $this->rows[$i];
$rowContainer = $this->buildRowContainer($rowData, $i);
$rowY = $this->contentViewport->y + ($i * $this->rowHeight) - $this->scrollY;
$rowY = ($this->contentViewport->y + ($i * $this->rowHeight)) - $this->scrollY;
$rowViewport = new Viewport(
x: $this->contentViewport->x,
@ -161,7 +161,7 @@ class VirtualListView extends Container
$value = $rowData[$key] ?? '';
$width = $column['width'] ?? null;
$cellStyle = 'px-4 py-2 text-black border-r border-gray-300';
$cellStyle = 'w-full px-4 py-2 text-black border-r border-gray-300';
if ($width) {
$cellStyle .= ' w-' . ((int) ($width / 4));
} else {
@ -202,9 +202,9 @@ class VirtualListView extends Container
if (
$mouseX < $this->contentViewport->x ||
$mouseX > ($this->contentViewport->x + $this->contentViewport->width) ||
$mouseY < $this->contentViewport->y ||
$mouseY > ($this->contentViewport->y + $this->contentViewport->height)
$mouseX > ($this->contentViewport->x + $this->contentViewport->width) ||
$mouseY < $this->contentViewport->y ||
$mouseY > ($this->contentViewport->y + $this->contentViewport->height)
) {
return parent::handleMouseWheel($mouseX, $mouseY, $deltaY);
}
@ -267,10 +267,7 @@ class VirtualListView extends Container
}
$scrollbarHeight = $viewportHeight;
$thumbHeight = max(
self::SCROLLBAR_MIN_SIZE,
($viewportHeight / $totalHeight) * $scrollbarHeight,
);
$thumbHeight = max(self::SCROLLBAR_MIN_SIZE, ($viewportHeight / $totalHeight) * $scrollbarHeight);
$maxScroll = $this->getMaxScroll();
if ($maxScroll <= 0.0) {
@ -337,9 +334,9 @@ class VirtualListRow extends Container
if (
$mouseX >= $this->viewport->x &&
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
$mouseY >= $this->viewport->y &&
$mouseY <= ($this->viewport->y + $this->viewport->height)
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
$mouseY >= $this->viewport->y &&
$mouseY <= ($this->viewport->y + $this->viewport->height)
) {
$this->table->selectRow($this->rowIndex);
return true;
@ -348,4 +345,3 @@ class VirtualListRow extends Container
return false;
}
}