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
parent
4d349eebb9
commit
b0c46f2b18
|
|
@ -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 {}
|
||||
|
|
@ -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 {
|
||||
$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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Reference in New Issue