perf/cleanup: one SSH round-trip for Docker, drop dead i18n, dedupe row

Optimizations from a multi-agent analysis pass (low-risk, verified):

- Docker pages did TWO SSH connect/login/exec cycles per load —
  available() (`command -v docker`) then containers() (`docker ps`).
  Fold availability into the single `docker ps` call: a missing binary
  now throws App\Exceptions\DockerNotInstalled (detected from the ps
  error), which the pages catch for the "not installed" state. Halves
  the SSH round-trips on the host Docker page AND the per-server Docker
  tab, and drops each container action from 3 connects to 2 (action +
  one reload probe instead of two). available() stays for its own test/
  reuse but is no longer on the hot path.

- Remove 13 dead translation keys left by the Docker/Terminal split
  (terminal: heading/subtitle/targets_*/servers_heading/search_*/no_*/
  pick_target; docker: target_label/server_target) — confirmed 0
  references, de+en kept in parity.

- Extract the duplicated container row + state→pill mapping into
  x-docker-container-row, shared by the host Docker page and the
  per-server Docker tab (one place to restyle/fix the row).

749 tests (new DockerNotInstalled unit + component coverage);
R12-verified (host Docker + server Docker tab render, zero console
errors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 20:06:27 +02:00
parent 4d349eebb9
commit b0c46f2b18
14 changed files with 90 additions and 104 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Thrown by DockerService::containers() when the `docker` binary is absent on the target (a native
* server with no container runtime), as opposed to a daemon-down / permission fault. Detecting this
* from the single `docker ps` call lets the pages render the honest "not installed" state WITHOUT a
* second SSH round-trip just to probe availability.
*/
class DockerNotInstalled extends RuntimeException {}

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Docker;
use App\Exceptions\DockerNotInstalled;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
@ -72,14 +73,12 @@ class Index extends Component
}
try {
// Probe first: a host with no docker binary gets a clean "not installed" panel instead
// of the raw "sh: docker: not found" shell error.
if (! $docker->available($server)) {
$this->notInstalled = true;
} else {
// 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');

View File

@ -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 {
// 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');

View File

@ -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 = [];

View File

@ -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.',

View File

@ -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…',

View File

@ -8,9 +8,7 @@ return [
'disconnected' => 'Disconnected',
'host_subtitle' => 'Containers on the Clusev host. A specific servers 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 hosts containers.',

View File

@ -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 servers 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 dont have permission to open this servers 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…',

View File

@ -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. --}}
<div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$pill" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}</p>
</div>
<x-status-pill :status="$pill" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill>
@if ($canOperate)
<div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0">
<x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn>
@if ($c['state'] === 'running')
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn>
<x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn>
@endif
</div>
@endif
</div>

View File

@ -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
<div class="space-y-4" @if (! $ready) wire:init="load" @endif>
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
@ -61,24 +51,7 @@
@else
<div class="divide-y divide-line">
@foreach ($containers as $c)
<div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}</p>
</div>
<x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill>
<div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0">
<x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn>
@if ($c['state'] === 'running')
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn>
<x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn>
@endif
</div>
</div>
<x-docker-container-row :container="$c" />
@endforeach
</div>
@endif

View File

@ -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
<div @if (! $ready) wire:init="load" @endif>
<x-panel :title="__('docker.containers_title')" :subtitle="$server->name" :padded="false">
@if (! $ready)
@ -39,26 +30,7 @@
@else
<div class="divide-y divide-line">
@foreach ($containers as $c)
<div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}</p>
</div>
<x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill>
<div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0">
@can('operate')
<x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn>
@if ($c['state'] === 'running')
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn>
<x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn>
@endif
@endcan
</div>
</div>
<x-docker-container-row :container="$c" :can-operate="(bool) auth()->user()?->can('operate')" />
@endforeach
</div>
@endif

View File

@ -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)

View File

@ -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();

View File

@ -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])