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
parent
65b92bc65c
commit
7535ee2d63
|
|
@ -29,6 +29,9 @@ class Index extends Component
|
||||||
|
|
||||||
public bool $ready = false;
|
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
|
public function title(): string
|
||||||
{
|
{
|
||||||
return __('docker.title');
|
return __('docker.title');
|
||||||
|
|
@ -38,14 +41,16 @@ class Index extends Component
|
||||||
{
|
{
|
||||||
$this->containers = [];
|
$this->containers = [];
|
||||||
$this->connected = false;
|
$this->connected = false;
|
||||||
|
$this->error = null;
|
||||||
|
|
||||||
$active = $this->activeServer();
|
$active = $this->activeServer();
|
||||||
if ($active && $active->credential_exists) {
|
if ($active && $active->credential_exists) {
|
||||||
try {
|
try {
|
||||||
$this->containers = $docker->containers($active);
|
$this->containers = $docker->containers($active);
|
||||||
$this->connected = true;
|
$this->connected = true;
|
||||||
} catch (Throwable) {
|
} catch (Throwable $e) {
|
||||||
$this->connected = false;
|
$this->connected = false;
|
||||||
|
$this->error = mb_scrub(mb_strcut($e->getMessage(), 0, 300), 'UTF-8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
|
* 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
|
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']) {
|
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 = [];
|
$out = [];
|
||||||
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
|
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
|
||||||
$line = trim($line);
|
if (trim($line) === '') {
|
||||||
if ($line === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$row = json_decode($line, true);
|
|
||||||
if (! is_array($row)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$p = explode("\t", $line);
|
||||||
|
$status = trim($p[3] ?? '');
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'id' => (string) ($row['ID'] ?? ''),
|
'id' => trim($p[0] ?? ''),
|
||||||
'name' => (string) ($row['Names'] ?? ''),
|
'name' => trim($p[1] ?? ''),
|
||||||
'image' => (string) ($row['Image'] ?? ''),
|
'image' => trim($p[2] ?? ''),
|
||||||
'state' => strtolower((string) ($row['State'] ?? '')),
|
'state' => $this->stateFromStatus($status),
|
||||||
'status' => (string) ($row['Status'] ?? ''),
|
'status' => $status,
|
||||||
'ports' => (string) ($row['Ports'] ?? ''),
|
'ports' => trim($p[4] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $out;
|
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
|
public function containerAction(Server $server, string $id, string $op): array
|
||||||
{
|
{
|
||||||
if (! in_array($op, self::ACTIONS, true)) {
|
if (! in_array($op, self::ACTIONS, true)) {
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('alerts.webhooks_hint') }}</p>
|
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('alerts.webhooks_hint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
|
<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
|
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<x-btn variant="primary" size="lg" type="submit">{{ __('certs.add') }}</x-btn>
|
<x-btn variant="primary" type="submit">{{ __('certs.add') }}</x-btn>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<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-icon name="command" class="h-3.5 w-3.5" /> {{ __('commands.run') }}
|
||||||
</x-btn>
|
</x-btn>
|
||||||
</div>
|
</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" />
|
<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
|
@error('runbookCommand') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<x-btn variant="primary" size="lg" type="submit">{{ __('commands.create') }}</x-btn>
|
<x-btn variant="primary" type="submit">{{ __('commands.create') }}</x-btn>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
<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>
|
||||||
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.unavailable_hint') }}</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>
|
</div>
|
||||||
@elseif (empty($containers))
|
@elseif (empty($containers))
|
||||||
<div class="px-4 py-10 text-center sm:px-5">
|
<div class="px-4 py-10 text-center sm:px-5">
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
|
<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
|
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<x-btn variant="primary" size="lg" type="submit">{{ __('health.add') }}</x-btn>
|
<x-btn variant="primary" type="submit">{{ __('health.add') }}</x-btn>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,12 @@
|
||||||
@elseif (is_null($c['pending']))
|
@elseif (is_null($c['pending']))
|
||||||
<span class="font-mono text-[11px] text-ink-4">{{ __('patch.unknown') }}</span>
|
<span class="font-mono text-[11px] text-ink-4">{{ __('patch.unknown') }}</span>
|
||||||
@elseif ($c['pending'] === 0)
|
@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
|
@else
|
||||||
<div class="flex items-center gap-1.5">
|
<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)
|
@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
|
@endif
|
||||||
@can('operate')
|
@can('operate')
|
||||||
<x-modal-trigger variant="accent" action="patch('{{ $s->uuid }}')" wire:loading.attr="disabled">{{ __('patch.apply') }}</x-modal-trigger>
|
<x-modal-trigger variant="accent" action="patch('{{ $s->uuid }}')" wire:loading.attr="disabled">{{ __('patch.apply') }}</x-modal-trigger>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
@if ($currentIpExempt) <span class="text-online">· {{ __('settings.lp_current_ip_exempt') }}</span> @endif
|
@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>
|
<button type="button" wire:click="whitelistMyIp" class="ml-2 text-accent-text hover:underline">{{ __('settings.lp_whitelist_my_ip') }}</button>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
|
||||||
|
|
@ -33,27 +33,34 @@ class DockerServiceTest extends TestCase
|
||||||
return [new DockerService($fleet), $fleet];
|
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();
|
[$docker, $fleet] = $this->make();
|
||||||
$json = json_encode(['ID' => 'abc123', 'Names' => 'web', 'Image' => 'nginx', 'State' => 'running', 'Status' => 'Up 2h', 'Ports' => '80/tcp'])."\n"
|
// id \t names \t image \t status \t ports — state is derived from the Status text.
|
||||||
.json_encode(['ID' => 'def456', 'Names' => 'db', 'Image' => 'mariadb', 'State' => 'exited', 'Status' => 'Exited (0)', 'Ports' => '']);
|
$out = "abc123\tweb\tnginx\tUp 2 hours\t80/tcp\n"
|
||||||
$fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => $json]);
|
."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());
|
$rows = $docker->containers($this->server());
|
||||||
|
|
||||||
$this->assertCount(2, $rows);
|
$this->assertCount(3, $rows);
|
||||||
$this->assertSame('web', $rows[0]['name']);
|
$this->assertSame('web', $rows[0]['name']);
|
||||||
$this->assertSame('running', $rows[0]['state']);
|
$this->assertSame('running', $rows[0]['state']); // "Up ..."
|
||||||
$this->assertSame('exited', $rows[1]['state']);
|
$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();
|
[$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
|
public function test_container_action_runs_the_right_command_for_a_valid_ref(): void
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue