fix(docker): honest "not installed" state instead of a raw shell error

When a fleet host has no container runtime at all (e.g. a native
postfix/dovecot mail server), `docker ps` returns "sh: docker: not
found". The page surfaced that raw shell text inside a red error box,
which reads as a broken feature — the user reasonably expected the tab
to show containers.

Probe `available()` first: if the docker binary is genuinely absent,
render a clean, neutral "Docker nicht installiert — es gibt keine
Container zum Anzeigen" panel (box icon, no alarm styling) rather than
the shell error. A real fault (daemon down / permission) still shows
the actual error as before. Verified end-to-end against a real SSH host
with no runtime (the honest state) and against a real docker host (full
container list + Logs/Restart/Stop actions render).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 22:11:53 +02:00
parent 4ab255832d
commit 7e6c01d5fa
5 changed files with 50 additions and 4 deletions

View File

@ -29,6 +29,9 @@ class Index extends Component
public bool $ready = false; public bool $ready = false;
/** Docker binary absent on the host (e.g. a native mail server) — an honest state, not an error. */
public bool $notInstalled = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */ /** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null; public ?string $error = null;
@ -41,13 +44,20 @@ class Index extends Component
{ {
$this->containers = []; $this->containers = [];
$this->connected = false; $this->connected = false;
$this->notInstalled = false;
$this->error = null; $this->error = null;
$active = $this->activeServer(); $active = $this->activeServer();
if ($active && $active->credential_exists) { if ($active && $active->credential_exists) {
try { try {
// Probe first: a host with no docker binary (native mail/web server) gets a clean
// "not installed" panel instead of the raw "sh: docker: not found" shell error.
if (! $docker->available($active)) {
$this->notInstalled = true;
} else {
$this->containers = $docker->containers($active); $this->containers = $docker->containers($active);
$this->connected = true; $this->connected = true;
}
} catch (Throwable $e) { } catch (Throwable $e) {
$this->connected = false; $this->connected = false;
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8'); $this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');

View File

@ -8,7 +8,9 @@ return [
'disconnected' => 'Getrennt', 'disconnected' => 'Getrennt',
'unavailable_title' => 'Docker nicht verfügbar', 'unavailable_title' => 'Docker nicht verfügbar',
'unavailable_hint' => 'Kein Docker auf diesem Server, Daemon aus, oder keine Berechtigung.', 'unavailable_hint' => 'Daemon aus oder keine Berechtigung.',
'not_installed_title' => 'Docker nicht installiert',
'not_installed_hint' => 'Auf diesem Server ist kein Docker installiert es gibt keine Container zum Anzeigen.',
'empty_title' => 'Keine Container', 'empty_title' => 'Keine Container',
'empty_hint' => 'Auf diesem Server laufen keine Container.', 'empty_hint' => 'Auf diesem Server laufen keine Container.',

View File

@ -8,7 +8,9 @@ return [
'disconnected' => 'Disconnected', 'disconnected' => 'Disconnected',
'unavailable_title' => 'Docker unavailable', 'unavailable_title' => 'Docker unavailable',
'unavailable_hint' => 'No Docker on this server, the daemon is down, or no permission.', 'unavailable_hint' => 'The daemon is down, or no permission.',
'not_installed_title' => 'Docker not installed',
'not_installed_hint' => 'This server has no Docker installed there are no containers to show.',
'empty_title' => 'No containers', 'empty_title' => 'No containers',
'empty_hint' => 'No containers are running on this server.', 'empty_hint' => 'No containers are running on this server.',

View File

@ -26,6 +26,15 @@
<div class="h-12 animate-pulse rounded-md bg-raised/50"></div> <div class="h-12 animate-pulse rounded-md bg-raised/50"></div>
@endfor @endfor
</div> </div>
@elseif ($notInstalled)
{{-- Honest, neutral state: the host simply has no docker (not a fault). --}}
<div class="px-4 py-10 text-center sm:px-5">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-raised text-ink-3">
<x-icon name="box" class="h-5 w-5" />
</div>
<p class="mt-3 text-sm text-ink-2">{{ __('docker.not_installed_title') }}</p>
<p class="mx-auto mt-1 max-w-md font-mono text-[11px] text-ink-4">{{ __('docker.not_installed_hint') }}</p>
</div>
@elseif (! $connected) @elseif (! $connected)
<div class="px-4 py-10 text-center sm:px-5"> <div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p> <p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p>

View File

@ -49,6 +49,7 @@ class DockerComponentTest extends TestCase
private function stubDocker(array $containers = []): DockerService private function stubDocker(array $containers = []): DockerService
{ {
$docker = Mockery::mock(DockerService::class); $docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('available')->andReturn(true);
$docker->shouldReceive('containers')->andReturn($containers); $docker->shouldReceive('containers')->andReturn($containers);
app()->instance(DockerService::class, $docker); app()->instance(DockerService::class, $docker);
@ -68,6 +69,28 @@ class DockerComponentTest extends TestCase
->assertSee('web'); ->assertSee('web');
} }
public function test_shows_a_clean_not_installed_state_when_docker_is_absent(): void
{
// A host without a container runtime (e.g. a native postfix/dovecot mail server) must NOT
// render the raw "sh: 1: docker: not found" shell error — that reads as a broken feature.
// available() is probed first; false => a clean, honest "not installed" panel.
$this->actingAs($this->viewer());
$this->activeServer();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('available')->once()->andReturn(false);
$docker->shouldReceive('containers')->never(); // never even attempt ps when docker is absent
app()->instance(DockerService::class, $docker);
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('connected', false)
->assertSet('notInstalled', true)
->assertSet('error', null)
->assertSee(__('docker.not_installed_title'))
->assertDontSee('not found');
}
public function test_viewer_cannot_run_a_container_action(): void public function test_viewer_cannot_run_a_container_action(): void
{ {
$this->actingAs($this->viewer()); $this->actingAs($this->viewer());