Backup
This commit is contained in:
parent
b991570285
commit
81d0f5a429
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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, [
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user