fix(ui,docker): normalise oversized buttons + surface the real Docker error

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 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 21:22:49 +02:00
parent 65b92bc65c
commit 7535ee2d63
11 changed files with 75 additions and 36 deletions

View File

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

View File

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

View File

@ -112,7 +112,7 @@
</div>
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">{{ __('alerts.create') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('alerts.create') }}</x-btn>
</div>
</form>
</x-panel>
@ -169,7 +169,7 @@
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('alerts.webhooks_hint') }}</p>
</div>
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">{{ __('alerts.save') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('alerts.save') }}</x-btn>
</div>
</form>
</x-panel>

View File

@ -29,7 +29,7 @@
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('certs.add') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('certs.add') }}</x-btn>
</form>
</x-panel>

View File

@ -59,7 +59,7 @@
@endif
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">
<x-btn variant="primary" type="submit">
<x-icon name="command" class="h-3.5 w-3.5" /> {{ __('commands.run') }}
</x-btn>
</div>
@ -119,7 +119,7 @@
<input wire:model="runbookCommand" type="text" maxlength="4000" placeholder="{{ __('commands.runbook_command_label') }}" class="{{ $field }} font-mono placeholder:text-ink-4" />
@error('runbookCommand') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('commands.create') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('commands.create') }}</x-btn>
</form>
</div>
</x-panel>

View File

@ -30,6 +30,9 @@
<div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.unavailable_hint') }}</p>
@if ($error)
<pre class="mx-auto mt-3 max-w-xl overflow-auto rounded-md border border-offline/25 bg-offline/10 p-2.5 text-left font-mono text-[11px] text-offline">{{ $error }}</pre>
@endif
</div>
@elseif (empty($containers))
<div class="px-4 py-10 text-center sm:px-5">

View File

@ -41,7 +41,7 @@
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('health.add') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('health.add') }}</x-btn>
</form>
</x-panel>

View File

@ -45,12 +45,12 @@
@elseif (is_null($c['pending']))
<span class="font-mono text-[11px] text-ink-4">{{ __('patch.unknown') }}</span>
@elseif ($c['pending'] === 0)
<x-status-pill status="online">{{ __('patch.up_to_date') }}</x-status-pill>
<x-status-pill status="online" class="h-8">{{ __('patch.up_to_date') }}</x-status-pill>
@else
<div class="flex items-center gap-1.5">
<x-status-pill status="warning">{{ __('patch.pending_label', ['count' => $c['pending']]) }}</x-status-pill>
<x-status-pill status="warning" class="h-8">{{ __('patch.pending_label', ['count' => $c['pending']]) }}</x-status-pill>
@if (($c['security'] ?? 0) > 0)
<x-status-pill status="offline">{{ __('patch.security_label', ['count' => $c['security']]) }}</x-status-pill>
<x-status-pill status="offline" class="h-8">{{ __('patch.security_label', ['count' => $c['security']]) }}</x-status-pill>
@endif
@can('operate')
<x-modal-trigger variant="accent" action="patch('{{ $s->uuid }}')" wire:loading.attr="disabled">{{ __('patch.apply') }}</x-modal-trigger>

View File

@ -42,7 +42,7 @@
@endforeach
</div>
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('groups.create') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('groups.create') }}</x-btn>
</form>
</x-panel>

View File

@ -40,7 +40,7 @@
@if ($currentIpExempt) <span class="text-online">· {{ __('settings.lp_current_ip_exempt') }}</span> @endif
<button type="button" wire:click="whitelistMyIp" class="ml-2 text-accent-text hover:underline">{{ __('settings.lp_whitelist_my_ip') }}</button>
</p>
<x-btn variant="primary" size="lg" type="submit">{{ __('settings.lp_save') }}</x-btn>
<x-btn variant="primary" type="submit">{{ __('settings.lp_save') }}</x-btn>
</div>
</form>
</x-panel>

View File

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