From 7535ee2d63ebf7838fc2943bceef316da0582108 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 21:22:49 +0200 Subject: [PATCH] fix(ui,docker): normalise oversized buttons + surface the real Docker error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback on the new feature pages: buttons were oversized and Docker showed an empty list where containers exist. - Buttons: the new pages (groups, alerts×2, commands×2, certs, health) plus settings/login-protection used size="lg" (h-11) on inline form-submit buttons, dwarfing the h-9 inputs and the surrounding pills. Drop to the default `sm` (h-8) — matching every other form in the app (e.g. the e-mail save button). The auth full-width CTAs keep size="lg" (their intended use); x-ring size="lg" is unrelated. - Docker "empty" bug: DockerService::containers used `docker ps --format '{{json .}}'` (needs a modern Docker AND depends on JSON key casing) and returned [] on ANY failure — so a daemon/permission/ rootless-socket error read as "no containers". Switched to a tab-separated `--format` (portable to much older Docker, state derived from the Status text) and it now THROWS the real error on failure; the page shows that reason instead of a misleading empty state (so the actual cause is visible). - Patch rows: the pending/security/up-to-date pills now match the action button height (h-8) so the row reads cleanly. DockerServiceTest updated (tab-parse + state-from-status + throws-real-error). 742 tests green, Pint, vite build clean. Co-Authored-By: Claude Fable 5 --- app/Livewire/Docker/Index.php | 7 ++- app/Services/DockerService.php | 52 ++++++++++++++----- .../views/livewire/alerts/index.blade.php | 4 +- .../views/livewire/certs/index.blade.php | 2 +- .../views/livewire/commands/index.blade.php | 4 +- .../views/livewire/docker/index.blade.php | 3 ++ .../views/livewire/health/index.blade.php | 2 +- .../views/livewire/patch/index.blade.php | 6 +-- .../views/livewire/servers/groups.blade.php | 2 +- .../settings/login-protection.blade.php | 2 +- tests/Feature/DockerServiceTest.php | 27 ++++++---- 11 files changed, 75 insertions(+), 36 deletions(-) diff --git a/app/Livewire/Docker/Index.php b/app/Livewire/Docker/Index.php index ebabcca..fa20c6a 100644 --- a/app/Livewire/Docker/Index.php +++ b/app/Livewire/Docker/Index.php @@ -29,6 +29,9 @@ class Index extends Component public bool $ready = false; + /** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */ + public ?string $error = null; + public function title(): string { return __('docker.title'); @@ -38,14 +41,16 @@ class Index extends Component { $this->containers = []; $this->connected = false; + $this->error = null; $active = $this->activeServer(); if ($active && $active->credential_exists) { try { $this->containers = $docker->containers($active); $this->connected = true; - } catch (Throwable) { + } catch (Throwable $e) { $this->connected = false; + $this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8'); } } diff --git a/app/Services/DockerService.php b/app/Services/DockerService.php index 4695d3b..b7f4346 100644 --- a/app/Services/DockerService.php +++ b/app/Services/DockerService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Server; use InvalidArgumentException; +use RuntimeException; /** * Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes @@ -29,34 +30,57 @@ class DockerService */ public function containers(Server $server): array { - $res = $this->fleet->runPrivileged($server, "docker ps -a --format '{{json .}}'"); + // Tab-separated columns (NOT `{{json .}}`): works on much older Docker AND avoids a JSON + // key-casing dependency. `.State` was added late, so we derive state from `.Status` text — + // this is the whole reason a modern-only `{{json .}}` could return nothing on an older host. + $fmt = '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'; + $res = $this->fleet->runPrivileged($server, "docker ps -a --format '{$fmt}'"); + if (! $res['ok']) { - return []; // docker missing / daemon down / permission → empty, never throw to the UI + // Surface the REAL reason (daemon down / permission / rootless socket) rather than a + // misleading empty list — the component shows this instead of "no containers". + throw new RuntimeException($this->firstLine($res['output']) ?: 'docker ps failed'); } $out = []; foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) { - $line = trim($line); - if ($line === '') { - continue; - } - $row = json_decode($line, true); - if (! is_array($row)) { + if (trim($line) === '') { continue; } + $p = explode("\t", $line); + $status = trim($p[3] ?? ''); $out[] = [ - 'id' => (string) ($row['ID'] ?? ''), - 'name' => (string) ($row['Names'] ?? ''), - 'image' => (string) ($row['Image'] ?? ''), - 'state' => strtolower((string) ($row['State'] ?? '')), - 'status' => (string) ($row['Status'] ?? ''), - 'ports' => (string) ($row['Ports'] ?? ''), + 'id' => trim($p[0] ?? ''), + 'name' => trim($p[1] ?? ''), + 'image' => trim($p[2] ?? ''), + 'state' => $this->stateFromStatus($status), + 'status' => $status, + 'ports' => trim($p[4] ?? ''), ]; } return $out; } + /** Derive a coarse state from docker's Status text (portable across versions that lack .State). */ + private function stateFromStatus(string $status): string + { + $s = strtolower($status); + + return match (true) { + str_contains($s, '(paused)') => 'paused', + str_starts_with($s, 'up') => 'running', + str_starts_with($s, 'created') => 'created', + str_starts_with($s, 'restarting') => 'restarting', + default => 'exited', // Exited / Dead / Removal + }; + } + + private function firstLine(string $out): string + { + return trim(preg_split('/\r?\n/', trim($out))[0] ?? ''); + } + public function containerAction(Server $server, string $id, string $op): array { if (! in_array($op, self::ACTIONS, true)) { diff --git a/resources/views/livewire/alerts/index.blade.php b/resources/views/livewire/alerts/index.blade.php index 152455f..4a0e4cb 100644 --- a/resources/views/livewire/alerts/index.blade.php +++ b/resources/views/livewire/alerts/index.blade.php @@ -112,7 +112,7 @@
- {{ __('alerts.create') }} + {{ __('alerts.create') }}
@@ -169,7 +169,7 @@

{{ __('alerts.webhooks_hint') }}

- {{ __('alerts.save') }} + {{ __('alerts.save') }}
diff --git a/resources/views/livewire/certs/index.blade.php b/resources/views/livewire/certs/index.blade.php index f307ed2..7b7e583 100644 --- a/resources/views/livewire/certs/index.blade.php +++ b/resources/views/livewire/certs/index.blade.php @@ -29,7 +29,7 @@ @error('port')

{{ $message }}

@enderror - {{ __('certs.add') }} + {{ __('certs.add') }} diff --git a/resources/views/livewire/commands/index.blade.php b/resources/views/livewire/commands/index.blade.php index 02ec0b6..61bb5cb 100644 --- a/resources/views/livewire/commands/index.blade.php +++ b/resources/views/livewire/commands/index.blade.php @@ -59,7 +59,7 @@ @endif
- + {{ __('commands.run') }}
@@ -119,7 +119,7 @@ @error('runbookCommand')

{{ $message }}

@enderror - {{ __('commands.create') }} + {{ __('commands.create') }} diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php index f1ff9b1..500351f 100644 --- a/resources/views/livewire/docker/index.blade.php +++ b/resources/views/livewire/docker/index.blade.php @@ -30,6 +30,9 @@

{{ __('docker.unavailable_title') }}

{{ __('docker.unavailable_hint') }}

+ @if ($error) +
{{ $error }}
+ @endif
@elseif (empty($containers))
diff --git a/resources/views/livewire/health/index.blade.php b/resources/views/livewire/health/index.blade.php index ffac549..0693a9f 100644 --- a/resources/views/livewire/health/index.blade.php +++ b/resources/views/livewire/health/index.blade.php @@ -41,7 +41,7 @@ @error('port')

{{ $message }}

@enderror
- {{ __('health.add') }} + {{ __('health.add') }} diff --git a/resources/views/livewire/patch/index.blade.php b/resources/views/livewire/patch/index.blade.php index 0dfea9c..fe1eddb 100644 --- a/resources/views/livewire/patch/index.blade.php +++ b/resources/views/livewire/patch/index.blade.php @@ -45,12 +45,12 @@ @elseif (is_null($c['pending'])) {{ __('patch.unknown') }} @elseif ($c['pending'] === 0) - {{ __('patch.up_to_date') }} + {{ __('patch.up_to_date') }} @else
- {{ __('patch.pending_label', ['count' => $c['pending']]) }} + {{ __('patch.pending_label', ['count' => $c['pending']]) }} @if (($c['security'] ?? 0) > 0) - {{ __('patch.security_label', ['count' => $c['security']]) }} + {{ __('patch.security_label', ['count' => $c['security']]) }} @endif @can('operate') {{ __('patch.apply') }} diff --git a/resources/views/livewire/servers/groups.blade.php b/resources/views/livewire/servers/groups.blade.php index 4754f31..ccfd6e0 100644 --- a/resources/views/livewire/servers/groups.blade.php +++ b/resources/views/livewire/servers/groups.blade.php @@ -42,7 +42,7 @@ @endforeach
- {{ __('groups.create') }} + {{ __('groups.create') }} diff --git a/resources/views/livewire/settings/login-protection.blade.php b/resources/views/livewire/settings/login-protection.blade.php index 141b2a0..d43b181 100644 --- a/resources/views/livewire/settings/login-protection.blade.php +++ b/resources/views/livewire/settings/login-protection.blade.php @@ -40,7 +40,7 @@ @if ($currentIpExempt) · {{ __('settings.lp_current_ip_exempt') }} @endif

- {{ __('settings.lp_save') }} + {{ __('settings.lp_save') }} diff --git a/tests/Feature/DockerServiceTest.php b/tests/Feature/DockerServiceTest.php index c3d1e5a..02a8cea 100644 --- a/tests/Feature/DockerServiceTest.php +++ b/tests/Feature/DockerServiceTest.php @@ -33,27 +33,34 @@ class DockerServiceTest extends TestCase return [new DockerService($fleet), $fleet]; } - public function test_containers_parses_the_docker_ps_json(): void + public function test_containers_parses_the_tab_separated_ps_output_and_derives_state(): void { [$docker, $fleet] = $this->make(); - $json = json_encode(['ID' => 'abc123', 'Names' => 'web', 'Image' => 'nginx', 'State' => 'running', 'Status' => 'Up 2h', 'Ports' => '80/tcp'])."\n" - .json_encode(['ID' => 'def456', 'Names' => 'db', 'Image' => 'mariadb', 'State' => 'exited', 'Status' => 'Exited (0)', 'Ports' => '']); - $fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => $json]); + // id \t names \t image \t status \t ports — state is derived from the Status text. + $out = "abc123\tweb\tnginx\tUp 2 hours\t80/tcp\n" + ."def456\tdb\tmariadb\tExited (0) 3 minutes ago\t\n" + ."ghi789\tcache\tredis\tUp 1 hour (Paused)\t6379/tcp"; + $fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => $out]); $rows = $docker->containers($this->server()); - $this->assertCount(2, $rows); + $this->assertCount(3, $rows); $this->assertSame('web', $rows[0]['name']); - $this->assertSame('running', $rows[0]['state']); - $this->assertSame('exited', $rows[1]['state']); + $this->assertSame('running', $rows[0]['state']); // "Up ..." + $this->assertSame('exited', $rows[1]['state']); // "Exited ..." + $this->assertSame('paused', $rows[2]['state']); // "Up ... (Paused)" } - public function test_containers_returns_empty_when_docker_is_unavailable(): void + public function test_containers_throws_the_real_error_when_docker_fails(): void { [$docker, $fleet] = $this->make(); - $fleet->shouldReceive('runPrivileged')->andReturn(['ok' => false, 'output' => 'docker: command not found']); + // A daemon/permission error must NOT read as "no containers" — it surfaces to the UI. + $fleet->shouldReceive('runPrivileged')->andReturn(['ok' => false, 'output' => "Cannot connect to the Docker daemon at unix:///var/run/docker.sock.\nIs the docker daemon running?"]); - $this->assertSame([], $docker->containers($this->server())); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot connect to the Docker daemon'); + + $docker->containers($this->server()); } public function test_container_action_runs_the_right_command_for_a_valid_ref(): void