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