diff --git a/app/Exceptions/DockerNotInstalled.php b/app/Exceptions/DockerNotInstalled.php new file mode 100644 index 0000000..457d4a3 --- /dev/null +++ b/app/Exceptions/DockerNotInstalled.php @@ -0,0 +1,13 @@ +available($server)) { - $this->notInstalled = true; - } else { - $this->containers = $docker->containers($server); - $this->connected = true; - } + // ONE SSH round-trip: containers() derives the "not installed" state from the docker ps + // call itself (a missing binary throws DockerNotInstalled), so no separate available() probe. + $this->containers = $docker->containers($server); + $this->connected = true; + } catch (DockerNotInstalled) { + $this->notInstalled = true; } catch (Throwable $e) { $this->connected = false; $this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8'); diff --git a/app/Livewire/Servers/ServerDocker.php b/app/Livewire/Servers/ServerDocker.php index 9530e3b..720fff4 100644 --- a/app/Livewire/Servers/ServerDocker.php +++ b/app/Livewire/Servers/ServerDocker.php @@ -2,6 +2,7 @@ namespace App\Livewire\Servers; +use App\Exceptions\DockerNotInstalled; use App\Models\AuditEvent; use App\Models\Server; use App\Services\DockerService; @@ -43,12 +44,12 @@ class ServerDocker extends Component // hydration, so check the relation directly here. if ($this->server->credential()->exists()) { try { - if (! $docker->available($this->server)) { - $this->notInstalled = true; - } else { - $this->containers = $docker->containers($this->server); - $this->connected = true; - } + // ONE SSH round-trip: a missing docker binary throws DockerNotInstalled (derived from + // the docker ps call), so no separate available() probe is needed. + $this->containers = $docker->containers($this->server); + $this->connected = true; + } catch (DockerNotInstalled) { + $this->notInstalled = true; } 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 7bcbee0..94eac85 100644 --- a/app/Services/DockerService.php +++ b/app/Services/DockerService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\DockerNotInstalled; use App\Models\Server; use InvalidArgumentException; use RuntimeException; @@ -46,9 +47,16 @@ class DockerService $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'"); if (! $res['ok']) { + $err = $this->firstLine($res['output']); + // Distinguish "docker binary absent" (a native server, no runtime) from a real fault + // right here, so callers get the honest "not installed" state from THIS single `docker + // ps` call instead of paying a second SSH round-trip on a separate available() probe. + if (preg_match('/docker:\s.*not found/i', $err)) { + throw new DockerNotInstalled($err); + } // 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'); + throw new RuntimeException($err ?: 'docker ps failed'); } $out = []; diff --git a/lang/de/docker.php b/lang/de/docker.php index 62cb89e..b7236b9 100644 --- a/lang/de/docker.php +++ b/lang/de/docker.php @@ -8,9 +8,7 @@ return [ 'disconnected' => 'Getrennt', 'host_subtitle' => 'Container auf dem Clusev-Host. Container einzelner Server findest du auf deren Detailseite im Reiter „Docker".', - 'target_label' => 'Ziel', 'host_target' => 'Clusev-Host', - 'server_target' => 'Server', 'host_unconfigured_title' => 'Host-SSH nicht eingerichtet', 'host_unconfigured_hint' => 'Richte den Host-SSH-Zugang unter Terminal (Kachel „Clusev-Host“) ein, um die Container dieses Hosts zu sehen.', diff --git a/lang/de/terminal.php b/lang/de/terminal.php index 3b8bf52..bd3aeea 100644 --- a/lang/de/terminal.php +++ b/lang/de/terminal.php @@ -3,8 +3,6 @@ return [ 'title' => 'Terminal — Clusev', 'eyebrow' => 'Host', - 'heading' => 'Terminal', - 'subtitle' => 'Interaktive Shell — pro Server per SSH, oder die Clusev-Umgebung selbst.', 'host_heading' => 'Host-Terminal', 'host_page_subtitle' => 'Echte SSH-Shell auf der Maschine, auf der Clusev läuft. Server-Shells findest du auf der jeweiligen Server-Detailseite im Reiter „Terminal".', 'connect' => 'Verbinden', @@ -12,18 +10,9 @@ return [ 'no_permission_title' => 'Kein Zugriff', 'no_permission_hint' => 'Für das Terminal dieses Servers fehlt dir die Berechtigung.', - 'targets_title' => 'Ziele', - 'targets_subtitle' => 'Wähle eine Sitzung', 'host_label' => 'Clusev-Host', 'host_not_configured' => 'Nicht eingerichtet', 'host_configure' => 'Host-Login einrichten', - 'servers_heading' => 'Server', - 'search_placeholder' => 'Server suchen…', - 'search_clear' => 'Suche zurücksetzen', - 'no_match' => 'Keine Treffer.', - 'no_servers' => 'Noch keine Server angelegt.', - 'no_target' => 'Kein Ziel gewählt', - 'pick_target' => 'Ziel links wählen, um eine Sitzung zu starten.', 'status_idle' => 'Bereit', 'status_connecting' => 'Verbinde…', diff --git a/lang/en/docker.php b/lang/en/docker.php index 59e9e31..008a905 100644 --- a/lang/en/docker.php +++ b/lang/en/docker.php @@ -8,9 +8,7 @@ return [ 'disconnected' => 'Disconnected', 'host_subtitle' => 'Containers on the Clusev host. A specific server’s containers live on its detail page under the “Docker” tab.', - 'target_label' => 'Target', 'host_target' => 'Clusev host', - 'server_target' => 'Server', 'host_unconfigured_title' => 'Host SSH not configured', 'host_unconfigured_hint' => 'Set up the host SSH login under Terminal (the “Clusev host” tile) to see this host’s containers.', diff --git a/lang/en/terminal.php b/lang/en/terminal.php index a20daeb..714e118 100644 --- a/lang/en/terminal.php +++ b/lang/en/terminal.php @@ -3,8 +3,6 @@ return [ 'title' => 'Terminal — Clusev', 'eyebrow' => 'Host', - 'heading' => 'Terminal', - 'subtitle' => 'Interactive shell — per server over SSH, or the Clusev environment itself.', 'host_heading' => 'Host terminal', 'host_page_subtitle' => 'A real SSH shell on the machine Clusev runs on. A server’s shell lives on its detail page under the “Terminal” tab.', 'connect' => 'Connect', @@ -12,18 +10,9 @@ return [ 'no_permission_title' => 'No access', 'no_permission_hint' => 'You don’t have permission to open this server’s terminal.', - 'targets_title' => 'Targets', - 'targets_subtitle' => 'Pick a session', 'host_label' => 'Clusev host', 'host_not_configured' => 'Not configured', 'host_configure' => 'Set up host login', - 'servers_heading' => 'Servers', - 'search_placeholder' => 'Search servers…', - 'search_clear' => 'Clear search', - 'no_match' => 'No matches.', - 'no_servers' => 'No servers added yet.', - 'no_target' => 'No target selected', - 'pick_target' => 'Pick a target on the left to start a session.', 'status_idle' => 'Ready', 'status_connecting' => 'Connecting…', diff --git a/resources/views/components/docker-container-row.blade.php b/resources/views/components/docker-container-row.blade.php new file mode 100644 index 0000000..60aef84 --- /dev/null +++ b/resources/views/components/docker-container-row.blade.php @@ -0,0 +1,34 @@ +@props(['container', 'canOperate' => true]) +@php + // container state -> status-pill status (single source of truth for both the host Docker page + // and the per-server Docker tab). + $c = $container; + $pill = match (true) { + $c['state'] === 'running' => 'online', + $c['state'] === 'paused' => 'warning', + in_array($c['state'], ['created', 'restarting'], true) => 'pending', + default => 'offline', // exited / dead / removing + }; +@endphp +{{-- wire:click targets (viewLogs / action) resolve to the enclosing Livewire component — both + Docker\Index and Servers\ServerDocker expose the same method names, so the row works in either. --}} +
+ +
+

{{ $c['name'] }}

+

{{ $c['image'] }}

+
+ + + @if ($canOperate) +
+ {{ __('docker.logs') }} + @if ($c['state'] === 'running') + {{ __('docker.restart') }} + {{ __('docker.stop') }} + @else + {{ __('docker.start') }} + @endif +
+ @endif +
diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php index c995640..aef374e 100644 --- a/resources/views/livewire/docker/index.blade.php +++ b/resources/views/livewire/docker/index.blade.php @@ -1,13 +1,3 @@ -@php - // container state -> status-pill status - $pill = fn (string $s) => match (true) { - $s === 'running' => 'online', - $s === 'paused' => 'warning', - in_array($s, ['created', 'restarting'], true) => 'pending', - default => 'offline', // exited / dead / removing - }; -@endphp -
{{-- Header --}}
@@ -61,24 +51,7 @@ @else
@foreach ($containers as $c) -
- -
-

{{ $c['name'] }}

-

{{ $c['image'] }}

-
- - -
- {{ __('docker.logs') }} - @if ($c['state'] === 'running') - {{ __('docker.restart') }} - {{ __('docker.stop') }} - @else - {{ __('docker.start') }} - @endif -
-
+ @endforeach
@endif diff --git a/resources/views/livewire/servers/server-docker.blade.php b/resources/views/livewire/servers/server-docker.blade.php index 265ee4d..f937f3c 100644 --- a/resources/views/livewire/servers/server-docker.blade.php +++ b/resources/views/livewire/servers/server-docker.blade.php @@ -1,12 +1,3 @@ -@php - $pill = fn (string $s) => match (true) { - $s === 'running' => 'online', - $s === 'paused' => 'warning', - in_array($s, ['created', 'restarting'], true) => 'pending', - default => 'offline', - }; -@endphp -
@if (! $ready) @@ -39,26 +30,7 @@ @else
@foreach ($containers as $c) -
- -
-

{{ $c['name'] }}

-

{{ $c['image'] }}

-
- - -
- @can('operate') - {{ __('docker.logs') }} - @if ($c['state'] === 'running') - {{ __('docker.restart') }} - {{ __('docker.stop') }} - @else - {{ __('docker.start') }} - @endif - @endcan -
-
+ @endforeach
@endif diff --git a/tests/Feature/DockerComponentTest.php b/tests/Feature/DockerComponentTest.php index ab2ec77..bbcdbbb 100644 --- a/tests/Feature/DockerComponentTest.php +++ b/tests/Feature/DockerComponentTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Exceptions\DockerNotInstalled; use App\Livewire\Docker\Index; use App\Livewire\Modals\ContainerLogs; use App\Models\HostCredential; @@ -56,7 +57,6 @@ class DockerComponentTest extends TestCase private function stubDocker(array $containers = []): DockerService { $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('available')->andReturn(true); $docker->shouldReceive('containers')->andReturn($containers); app()->instance(DockerService::class, $docker); @@ -100,8 +100,8 @@ class DockerComponentTest extends TestCase $this->actingAs($this->admin()); $this->hostCred(); $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('available')->once()->andReturn(false); - $docker->shouldReceive('containers')->never(); + // A missing docker binary surfaces as DockerNotInstalled from the single containers() call. + $docker->shouldReceive('containers')->once()->andThrow(new DockerNotInstalled('sh: 1: docker: not found')); app()->instance(DockerService::class, $docker); Livewire::test(Index::class) diff --git a/tests/Feature/DockerServiceTest.php b/tests/Feature/DockerServiceTest.php index ce84422..2e37599 100644 --- a/tests/Feature/DockerServiceTest.php +++ b/tests/Feature/DockerServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Exceptions\DockerNotInstalled; use App\Models\Server; use App\Services\DockerService; use App\Services\FleetService; @@ -63,6 +64,18 @@ class DockerServiceTest extends TestCase $docker->containers($this->server()); } + public function test_containers_throws_docker_not_installed_when_the_binary_is_missing(): void + { + [$docker, $fleet] = $this->make(); + // A missing docker binary is detected from the single `docker ps` call (no separate probe), + // and surfaces as DockerNotInstalled so the page can show the honest "not installed" state. + $fleet->shouldReceive('runPrivileged')->andReturn(['ok' => false, 'output' => 'sh: 1: docker: not found']); + + $this->expectException(DockerNotInstalled::class); + + $docker->containers($this->server()); + } + public function test_container_action_runs_the_right_command_for_a_valid_ref(): void { [$docker, $fleet] = $this->make(); diff --git a/tests/Feature/ServerDockerTest.php b/tests/Feature/ServerDockerTest.php index cbe4d0c..1d60e10 100644 --- a/tests/Feature/ServerDockerTest.php +++ b/tests/Feature/ServerDockerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Exceptions\DockerNotInstalled; use App\Livewire\Servers\ServerDocker; use App\Models\Server; use App\Models\User; @@ -47,7 +48,6 @@ class ServerDockerTest extends TestCase private function stubDocker(array $containers = []): DockerService { $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('available')->andReturn(true); $docker->shouldReceive('containers')->andReturn($containers); app()->instance(DockerService::class, $docker); @@ -100,8 +100,7 @@ class ServerDockerTest extends TestCase $this->actingAs($this->viewer()); $server = $this->server(); $docker = Mockery::mock(DockerService::class); - $docker->shouldReceive('available')->once()->andReturn(false); - $docker->shouldReceive('containers')->never(); + $docker->shouldReceive('containers')->once()->andThrow(new DockerNotInstalled('sh: 1: docker: not found')); app()->instance(DockerService::class, $docker); Livewire::test(ServerDocker::class, ['server' => $server])