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

View File

@ -8,9 +8,9 @@ use PHPNative\Ui\Widget\Button;
use PHPNative\Ui\Widget\Container; use PHPNative\Ui\Widget\Container;
use PHPNative\Ui\Widget\Icon; use PHPNative\Ui\Widget\Icon;
use PHPNative\Ui\Widget\Label; use PHPNative\Ui\Widget\Label;
use PHPNative\Ui\Widget\TextInput;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Widget\Modal; use PHPNative\Ui\Widget\Modal;
use PHPNative\Ui\Widget\TextArea;
use PHPNative\Ui\Widget\TextInput;
class KanbanTab class KanbanTab
{ {
@ -125,7 +125,7 @@ class KanbanTab
} }
foreach ($boards as $boardName) { foreach ($boards as $boardName) {
$isSelected = ($boardName === $this->currentEditingBoard); $isSelected = $boardName === $this->currentEditingBoard;
$style = 'px-2 py-1 rounded text-xs border '; $style = 'px-2 py-1 rounded text-xs border ';
if ($isSelected) { if ($isSelected) {
@ -296,8 +296,20 @@ class KanbanTab
$tasks = []; $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) { 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 = 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')); $columnHeader->addComponent(new Label($boardName, 'text-sm font-semibold text-gray-800'));
@ -316,11 +328,19 @@ class KanbanTab
foreach ($boardTasks as $task) { foreach ($boardTasks as $task) {
$title = (string) ($task['title'] ?? ''); $title = (string) ($task['title'] ?? '');
$serverId = $task['server_id'] ?? null; $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; $taskId = $task['id'] ?? null;
$card = new Container( $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 // 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')); $headerRow->addComponent(new Label($title, 'text-xs text-gray-900 flex-1'));
// Edit icon button // Edit icon button
$editButton = new Button( $editButton = new Button('', 'p-1 rounded hover:bg-blue-100', null, 'text-blue-600');
'',
'p-1 rounded hover:bg-blue-100',
null,
'text-blue-600',
);
$editIcon = new Icon(IconName::edit, 12, 'text-blue-600'); $editIcon = new Icon(IconName::edit, 12, 'text-blue-600');
$editButton->setIcon($editIcon); $editButton->setIcon($editIcon);
@ -343,12 +358,7 @@ class KanbanTab
}); });
// Delete icon button // Delete icon button
$deleteButton = new Button( $deleteButton = new Button('', 'p-1 rounded hover:bg-red-100', null, 'text-red-600');
'',
'p-1 rounded hover:bg-red-100',
null,
'text-red-600',
);
$deleteIcon = new Icon(IconName::trash, 12, 'text-red-600'); $deleteIcon = new Icon(IconName::trash, 12, 'text-red-600');
$deleteButton->setIcon($deleteIcon); $deleteButton->setIcon($deleteIcon);
@ -362,10 +372,7 @@ class KanbanTab
return; return;
} }
$tasks = array_values(array_filter( $tasks = array_values(array_filter($tasks, static fn($t) => ($t['id'] ?? null) !== $taskId));
$tasks,
static fn($t) => ($t['id'] ?? null) !== $taskId,
));
$kanbanTab->settings->set('kanban.tasks', $tasks); $kanbanTab->settings->set('kanban.tasks', $tasks);
$kanbanTab->settings->save(); $kanbanTab->settings->save();
@ -381,7 +388,7 @@ class KanbanTab
$card->addComponent($headerRow); $card->addComponent($headerRow);
// Server label // 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); $columnBody->addComponent($card);
} }

View File

@ -118,12 +118,47 @@ class ServerListTab
'render' => [$this, 'renderSelectCell'], 'render' => [$this, 'renderSelectCell'],
], ],
['key' => 'id', 'title' => 'ID', 'width' => 100], ['key' => 'id', 'title' => 'ID', 'width' => 100],
[
'key' => 'tasks_indicator',
'title' => '',
'width' => 40,
'render' => [$this, 'renderTasksIndicatorCell'],
],
['key' => 'name', 'title' => 'Name'], ['key' => 'name', 'title' => 'Name'],
['key' => 'status', 'title' => 'Status', 'width' => 90], ['key' => 'status', 'title' => 'Status', 'width' => 90],
['key' => 'type', 'title' => 'Typ', 'width' => 80], ['key' => 'type', 'title' => 'Typ', 'width' => 80],
['key' => 'ipv4', 'title' => 'IPv4', 'width' => 140], ['key' => 'ipv4', 'title' => 'IPv4', 'width' => 150],
['key' => 'docker_status', 'title' => 'Docker', 'width' => 80], [
['key' => 'os_version', 'title' => 'Ubuntu', 'width' => 180], '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', 'key' => 'needs_reboot',
'title' => 'Neustart', 'title' => 'Neustart',
@ -136,6 +171,12 @@ class ServerListTab
'width' => 130, 'width' => 130,
'render' => [$this, 'renderUpdatesCell'], 'render' => [$this, 'renderUpdatesCell'],
], ],
[
'key' => 'last_backup',
'title' => 'Letztes Backup',
'width' => 180,
'render' => [$this, 'renderLastBackupCell'],
],
]); ]);
// Load initial test data // Load initial test data
@ -145,7 +186,7 @@ class ServerListTab
$leftSide->addComponent($this->table); $leftSide->addComponent($this->table);
// Right side: Detail panel // 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'); $detailTitle = new Label('Server Details', 'text-xl font-bold text-black mb-2');
$detailPanel->addComponent($detailTitle); $detailPanel->addComponent($detailTitle);
@ -385,16 +426,26 @@ class ServerListTab
// Basisdaten setzen, Docker-Status initial auf "pending" // Basisdaten setzen, Docker-Status initial auf "pending"
$serverListTab->currentServerData = array_map(static fn($row) => array_merge([ $serverListTab->currentServerData = array_map(static fn($row) => array_merge([
'docker_status' => 'pending', 'docker_status' => 'pending',
'docker_running' => 'pending',
'docker' => null, 'docker' => null,
'docker_error' => null, 'docker_error' => null,
'needs_reboot' => 'unbekannt', 'needs_reboot' => 'unbekannt',
'updates_available' => 'unbekannt', 'updates_available' => 'unbekannt',
'os_version' => 'unbekannt', 'os_version' => 'unbekannt',
'release' => 'unbekannt',
'root' => 'unbekannt',
'data' => 'unbekannt',
'last_backup' => 'unbekannt',
], $row), $result['servers']); ], $row), $result['servers']);
$serverListTab->table->setData($serverListTab->currentServerData, false); $serverListTab->table->setData($serverListTab->currentServerData, false);
$serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); $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')) { if (function_exists('desktop_notify')) {
desktop_notify( desktop_notify(
'Serverliste aktualisiert', 'Serverliste aktualisiert',
@ -410,6 +461,7 @@ class ServerListTab
if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) {
$serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP'; $serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP';
$serverListTab->currentServerData[$index]['docker_status'] = 'error'; $serverListTab->currentServerData[$index]['docker_status'] = 'error';
$serverListTab->currentServerData[$index]['docker_running'] = 'error';
continue; continue;
} }
@ -427,7 +479,7 @@ class ServerListTab
if (!$ssh->login('root', $key)) { if (!$ssh->login('root', $key)) {
return [ return [
'index' => $index, 'index' => $index,
'docker' => null, 'docker' => 'fetch usage',
'docker_error' => 'SSH Login fehlgeschlagen', 'docker_error' => 'SSH Login fehlgeschlagen',
'docker_status' => 'error', 'docker_status' => 'error',
]; ];
@ -451,18 +503,88 @@ class ServerListTab
)); ));
$osVersion = $osOutput !== '' ? $osOutput : 'unbekannt'; $osVersion = $osOutput !== '' ? $osOutput : 'unbekannt';
// Docker status for main application container // Last backup from borgmatic
$output = $ssh->exec('docker inspect psc-web-1'); $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)) { if (empty($output)) {
return [ return [
'index' => $index, 'index' => $index,
'docker' => null, 'docker' => null,
'docker_error' => 'Leere Docker-Antwort', 'docker_error' => 'Leere Docker-Antwort',
'docker_status' => 'error', 'docker_status' => 'error',
'docker_running' => $runningCount,
'needs_reboot' => $needsReboot, 'needs_reboot' => $needsReboot,
'updates' => $updatesCount, 'updates' => $updatesCount,
'os_version' => $osVersion, 'os_version' => $osVersion,
'root' => $rootUsage,
'data' => $dataUsage,
'last_backup' => $lastBackup,
]; ];
} }
@ -473,20 +595,111 @@ class ServerListTab
'docker' => null, 'docker' => null,
'docker_error' => 'Ungültige Docker-JSON-Antwort', 'docker_error' => 'Ungültige Docker-JSON-Antwort',
'docker_status' => 'error', 'docker_status' => 'error',
'docker_running' => $runningCount,
'needs_reboot' => $needsReboot, 'needs_reboot' => $needsReboot,
'updates' => $updatesCount, 'updates' => $updatesCount,
'os_version' => $osVersion, '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 [ return [
'index' => $index, 'index' => $index,
'docker' => $json, 'docker' => $json,
'docker_error' => null, 'docker_error' => null,
'docker_status' => 'ok', 'docker_status' => 'ok',
'docker_running' => $runningCount,
'needs_reboot' => $needsReboot, 'needs_reboot' => $needsReboot,
'updates' => $updatesCount, 'updates' => $updatesCount,
'os_version' => $osVersion, 'os_version' => $osVersion,
'root' => $rootUsage,
'data' => $dataUsage,
'domains' => $domains,
'release' => $release,
'last_backup' => $lastBackup,
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return [ return [
@ -494,9 +707,13 @@ class ServerListTab
'docker' => null, 'docker' => null,
'docker_error' => 'SSH-Fehler: ' . $e->getMessage(), 'docker_error' => 'SSH-Fehler: ' . $e->getMessage(),
'docker_status' => 'error', 'docker_status' => 'error',
'docker_running' => null,
'needs_reboot' => null, 'needs_reboot' => null,
'updates' => null, 'updates' => null,
'os_version' => null, 'os_version' => null,
'root' => 'unbekannt',
'data' => 'unbekannt',
'last_backup' => 'unbekannt',
]; ];
} }
}); });
@ -512,23 +729,6 @@ class ServerListTab
} }
if (array_key_exists('docker', $dockerResult)) { 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->currentServerData[$i]['docker'] = $dockerResult['docker'];
$searchTerm = $serverListTab->searchInput->getValue(); $searchTerm = $serverListTab->searchInput->getValue();
if (empty($searchTerm)) { 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 // Map system status into human-readable table fields
if (array_key_exists('needs_reboot', $dockerResult)) { if (array_key_exists('needs_reboot', $dockerResult)) {
$needsReboot = $dockerResult['needs_reboot']; $needsReboot = $dockerResult['needs_reboot'];
@ -577,6 +785,27 @@ class ServerListTab
: 'unbekannt'; : '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)) { if (array_key_exists('docker_error', $dockerResult)) {
$serverListTab->currentServerData[$i]['docker_error'] = $serverListTab->currentServerData[$i]['docker_error'] =
$dockerResult['docker_error']; $dockerResult['docker_error'];
@ -586,6 +815,20 @@ class ServerListTab
$serverListTab->currentServerData[$i]['docker_status'] = $serverListTab->currentServerData[$i]['docker_status'] =
$dockerResult['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) { $dockerTask->onError(function ($error) use (&$serverListTab, $index) {
@ -595,6 +838,7 @@ class ServerListTab
if (isset($serverListTab->currentServerData[$index])) { if (isset($serverListTab->currentServerData[$index])) {
$serverListTab->currentServerData[$index]['docker_error'] = $serverListTab->currentServerData[$index]['docker_error'] =
'Async Fehler: ' . $errorMsg; 'Async Fehler: ' . $errorMsg;
$serverListTab->currentServerData[$index]['docker_running'] = 'error';
} }
}); });
} }
@ -894,12 +1138,57 @@ class ServerListTab
return $this->sftpButton; 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 public function renderNeedsRebootCell(array $rowData, int $rowIndex): Label
{ {
$value = (string) ($rowData['needs_reboot'] ?? ''); $value = (string) ($rowData['needs_reboot'] ?? '');
$normalized = strtolower(trim($value)); $normalized = strtolower(trim($value));
$baseStyle = 'px-4 border-r border-gray-300 text-sm '; $baseStyle = 'px-4 text-sm ';
if ($normalized === 'nein') { if ($normalized === 'nein') {
$style = $baseStyle . 'text-green-600'; $style = $baseStyle . 'text-green-600';
@ -917,7 +1206,7 @@ class ServerListTab
$value = (string) ($rowData['updates_available'] ?? ''); $value = (string) ($rowData['updates_available'] ?? '');
$normalized = strtolower(trim($value)); $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)') { if ($normalized === 'nein' || $normalized === 'nein (0)') {
$style = $baseStyle . 'text-green-600'; $style = $baseStyle . 'text-green-600';
@ -930,6 +1219,158 @@ class ServerListTab
return new Label($value, $style); 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 private function updateDomainDetails(array $domains): void
{ {
$this->detailDomainsContainer->clearChildren(); $this->detailDomainsContainer->clearChildren();

View File

@ -18,6 +18,7 @@ class SettingsModal
private TextInput $apiKeyInput; private TextInput $apiKeyInput;
private TextInput $privateKeyPathInput; private TextInput $privateKeyPathInput;
private TextInput $remoteStartDirInput; private TextInput $remoteStartDirInput;
private TextInput $doneBoardInput;
public function __construct(Settings $settings, MenuBar $menuBar, string &$apiKey, string &$privateKeyPath, string &$remoteStartDir) public function __construct(Settings $settings, MenuBar $menuBar, string &$apiKey, string &$privateKeyPath, string &$remoteStartDir)
{ {
@ -31,11 +32,17 @@ class SettingsModal
'Remote Start Directory', 'Remote Start Directory',
'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black' '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 // Set initial values
$this->apiKeyInput->setValue($apiKey); $this->apiKeyInput->setValue($apiKey);
$this->privateKeyPathInput->setValue($privateKeyPath); $this->privateKeyPathInput->setValue($privateKeyPath);
$this->remoteStartDirInput->setValue($remoteStartDir); $this->remoteStartDirInput->setValue($remoteStartDir);
$doneBoard = (string) $settings->get('kanban.done_board', 'fertig');
$this->doneBoardInput->setValue($doneBoard);
// Create modal dialog // Create modal dialog
$modalDialog = new Container('bg-white rounded-lg p-6 flex flex-col w-96 gap-3'); $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); $remoteStartDirFieldContainer->addComponent($this->remoteStartDirInput);
$modalDialog->addComponent($remoteStartDirFieldContainer); $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 // Buttons
$buttonRow = new Container('flex flex-row gap-2 justify-end'); $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'); $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; $apiKeyInputRef = $this->apiKeyInput;
$privateKeyPathInputRef = $this->privateKeyPathInput; $privateKeyPathInputRef = $this->privateKeyPathInput;
$remoteStartDirInputRef = $this->remoteStartDirInput; $remoteStartDirInputRef = $this->remoteStartDirInput;
$doneBoardInputRef = $this->doneBoardInput;
$cancelButton->setOnClick(function () use ($menuBar, $settingsModal) { $cancelButton->setOnClick(function () use ($menuBar, $settingsModal) {
$menuBar->closeAllMenus(); $menuBar->closeAllMenus();
$settingsModal->hide(); $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()); $apiKey = trim($apiKeyInputRef->getValue());
$privateKeyPath = trim($privateKeyPathInputRef->getValue()); $privateKeyPath = trim($privateKeyPathInputRef->getValue());
$remoteStartDir = trim($remoteStartDirInputRef->getValue()); $remoteStartDir = trim($remoteStartDirInputRef->getValue());
$doneBoard = trim($doneBoardInputRef->getValue());
if ($doneBoard === '') {
$doneBoard = 'fertig';
}
$settings->set('api_key', $apiKey); $settings->set('api_key', $apiKey);
$settings->set('private_key_path', $privateKeyPath); $settings->set('private_key_path', $privateKeyPath);
$settings->set('remote_start_dir', $remoteStartDir); $settings->set('remote_start_dir', $remoteStartDir);
$settings->set('kanban.done_board', $doneBoard);
$settings->save(); $settings->save();
$menuBar->closeAllMenus(); $menuBar->closeAllMenus();

View File

@ -26,7 +26,9 @@ class SftpManagerTab
private TextInput $filenameInput; private TextInput $filenameInput;
private Modal $renameModal; private Modal $renameModal;
private TextInput $renameInput; private TextInput $renameInput;
private Label $renamePathLabel;
private string $currentRenameFilePath = ''; private string $currentRenameFilePath = '';
private string $currentRenameMode = 'remote';
private Modal $deleteConfirmModal; private Modal $deleteConfirmModal;
private string $currentDeleteFilePath = ''; private string $currentDeleteFilePath = '';
private Label $deleteConfirmLabel; private Label $deleteConfirmLabel;
@ -146,6 +148,11 @@ class SftpManagerTab
// Create delete confirmation modal // Create delete confirmation modal
$this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); $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 // Setup transfer button handlers
$uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) {
$sftpTab->handleUpload($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'); $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96');
// Title // 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 // Filename input
$this->renameInput = new TextInput( $this->renameInput = new TextInput('Neuer Name', 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black');
'Neuer Dateiname',
'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black',
);
$modalContent->addComponent($this->renameInput); $modalContent->addComponent($this->renameInput);
// Button container // Button container
@ -1437,12 +1445,25 @@ class SftpManagerTab
} }
$oldPath = $sftpTab->currentRenameFilePath; $oldPath = $sftpTab->currentRenameFilePath;
$directory = dirname($oldPath); $separator = $sftpTab->currentRenameMode === 'local' ? DIRECTORY_SEPARATOR : '/';
$newPath = $directory . '/' . $newFilename; $directory = $sftpTab->normalizeDirectory(dirname($oldPath), $separator);
$newPath = $sftpTab->joinPath($directory, $newFilename, $separator);
// Close rename modal // Close rename modal
$sftpTab->renameModal->setVisible(false); $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 // Perform rename via SFTP
$selectedServerRef = &$serverListTab->selectedServer; $selectedServerRef = &$serverListTab->selectedServer;
@ -1606,9 +1627,25 @@ class SftpManagerTab
return; return;
} }
$this->currentRenameMode = 'remote';
$this->currentRenameFilePath = $path; $this->currentRenameFilePath = $path;
$filename = basename($path); $filename = basename($path);
$this->renameInput->setValue($filename); $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); $this->renameModal->setVisible(true);
} }
@ -1629,4 +1666,23 @@ class SftpManagerTab
$this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?'); $this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?');
$this->deleteConfirmModal->setVisible(true); $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(); $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) { if (count($output_array[0]) > 0) {
$colorStyle = $output_array[1][0]; $colorStyle = $output_array[1][0];

View File

@ -10,6 +10,7 @@ class Border implements Parser
{ {
$color = new \PHPNative\Tailwind\Style\Color(); $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); preg_match_all('/rounded-(t|b|l|r)-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array);
if (count($output_array[0]) > 0) { if (count($output_array[0]) > 0) {
$size = match ((string) $output_array[2][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); preg_match_all('/rounded-(none|sm|md|lg|xl|2xl|3xl)/', $style, $output_array);
if (count($output_array[0]) > 0) { if (count($output_array[0]) > 0) {
$size = match ((string) $output_array[1][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) { if (count($output_array[0]) > 0) {
$size = 4; $size = 4;
@ -73,24 +76,41 @@ class Border implements Parser
); );
} }
preg_match_all('/border-([tblr])-(.*)/', $style, $output_array); // Directional borders with explicit numeric width, e.g. border-t-2
if (count($output_array[0]) > 0) { if (preg_match('/^border-(t|b|l|r)-(\d+)$/', $style, $m)) {
return match ((string) $output_array[1][0]) { $w = (int) $m[2];
't' => new \PHPNative\Tailwind\Style\Border(true, top: (int) $output_array[2][0]), return match ($m[1]) {
'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: (int) $output_array[2][0]), 't' => new \PHPNative\Tailwind\Style\Border(true, top: $w),
'r' => new \PHPNative\Tailwind\Style\Border(true, right: (int) $output_array[2][0]), 'b' => new \PHPNative\Tailwind\Style\Border(true, bottom: $w),
'l' => new \PHPNative\Tailwind\Style\Border(true, left: (int) $output_array[2][0]), '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); // Directional borders without width -> default 1px, e.g. border-r
if (count($output_array[0]) > 0) { if (preg_match('/^border-(t|b|l|r)$/', $style, $m)) {
$colorStyle = $output_array[1][0]; $w = 1;
$color = Color::parse($colorStyle); return match ($m[1]) {
return new \PHPNative\Tailwind\Style\Border(false, $color); '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) { if (count($output_array[0]) > 0) {
return new \PHPNative\Tailwind\Style\Border( return new \PHPNative\Tailwind\Style\Border(
enabled: true, enabled: true,
@ -111,41 +131,41 @@ class Border implements Parser
if ($style2->enabled && !$style1->enabled) { if ($style2->enabled && !$style1->enabled) {
$style1->enabled = true; $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; $style1->color->red = $style2->color->red;
}
if ($style2->color->green != null) {
$style1->color->green = $style2->color->green; $style1->color->green = $style2->color->green;
}
if ($style2->color->blue != null) {
$style1->color->blue = $style2->color->blue; $style1->color->blue = $style2->color->blue;
} }
if ($style2->color->alpha != null) { if ($style2->color->alpha !== null) {
$style1->color->alpha = $style2->color->alpha; $style1->color->alpha = $style2->color->alpha;
} }
if ($style2->top != null) {
if ($style2->top !== null) {
$style1->top = $style2->top; $style1->top = $style2->top;
} }
if ($style2->bottom != null) { if ($style2->bottom !== null) {
$style1->bottom = $style2->bottom; $style1->bottom = $style2->bottom;
} }
if ($style2->left != null) { if ($style2->left !== null) {
$style1->left = $style2->left; $style1->left = $style2->left;
} }
if ($style2->right != null) { if ($style2->right !== null) {
$style1->right = $style2->right; $style1->right = $style2->right;
} }
if ($style2->roundTopLeft != null) { if ($style2->roundTopLeft !== null) {
$style1->roundTopLeft = $style2->roundTopLeft; $style1->roundTopLeft = $style2->roundTopLeft;
} }
if ($style2->roundTopRight != null) { if ($style2->roundTopRight !== null) {
$style1->roundTopRight = $style2->roundTopRight; $style1->roundTopRight = $style2->roundTopRight;
} }
if ($style2->roundBottomLeft != null) { if ($style2->roundBottomLeft !== null) {
$style1->roundBottomLeft = $style2->roundBottomLeft; $style1->roundBottomLeft = $style2->roundBottomLeft;
} }
if ($style2->roundBottomRight != null) { if ($style2->roundBottomRight !== null) {
$style1->roundBottomRight = $style2->roundBottomRight; $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) { if (defined('DEBUG_RENDERING') && DEBUG_RENDERING) {
sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10); sdl_set_render_draw_color($renderer, rand(0, 255), rand(0, 255), rand(0, 255), 10);
sdl_render_rect($renderer, [ sdl_render_rect($renderer, [

View File

@ -285,13 +285,21 @@ class FileBrowser extends Container
public function renderActionsCell(array $rowData, int $rowIndex): Container public function renderActionsCell(array $rowData, int $rowIndex): Container
{ {
// Match the cell style from Table (100px width for icon buttons) // 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) $isDir = (bool) ($rowData['isDir'] ?? false);
if (!($rowData['isDir'] ?? false) && !empty($rowData['path'])) { $hasPath = !empty($rowData['path']);
$fileBrowser = $this; $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'); $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'); $editIcon = new Icon(\PHPNative\Tailwind\Data\Icon::edit, 16, 'text-blue-500');
$editButton->setIcon($editIcon); $editButton->setIcon($editIcon);
@ -301,19 +309,21 @@ class FileBrowser extends Container
} }
}); });
$container->addComponent($editButton); $container->addComponent($editButton);
}
// Rename button // Rename button for files and directories
$renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center'); $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'); $renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500');
$renameButton->setIcon($renameIcon); $renameButton->setIcon($renameIcon);
$renameButton->setOnClick(function () use ($fileBrowser, $rowData) { $renameButton->setOnClick(function () use ($fileBrowser, $rowData) {
if ($fileBrowser->onRenameFile !== null) { if ($fileBrowser->onRenameFile !== null) {
($fileBrowser->onRenameFile)($rowData['path'], $rowData); ($fileBrowser->onRenameFile)($rowData['path'], $rowData);
} }
}); });
$container->addComponent($renameButton); $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'); $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'); $deleteIcon = new Icon(\PHPNative\Tailwind\Data\Icon::trash, 16, 'text-red-500');
$deleteButton->setIcon($deleteIcon); $deleteButton->setIcon($deleteIcon);

View File

@ -55,7 +55,7 @@ class Table extends Container
$title = $column['title'] ?? $key; $title = $column['title'] ?? $key;
$width = $column['width'] ?? null; $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) { if ($width) {
$style .= ' w-' . ((int) ($width / 4)); $style .= ' w-' . ((int) ($width / 4));
} else { } else {
@ -126,7 +126,7 @@ class Table extends Container
$value = $rowData[$key] ?? ''; $value = $rowData[$key] ?? '';
$width = $column['width'] ?? null; $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) { if ($width) {
$cellStyle .= ' w-' . ((int) ($width / 4)); $cellStyle .= ' w-' . ((int) ($width / 4));
} else { } else {

View File

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