fix(terminal): list lazily loaded containers in the resource terminal picker

The container picker read its options from an Alpine x-data snapshot taken
on the first render, before wire:init loaded the containers. Livewire keeps
the Alpine state when it morphs, so resources with multiple containers
showed an empty picker. Publish the options as a Livewire property and
entangle them in Alpine.

Closes #12110
This commit is contained in:
Andras Bacsai
2026-10-07 17:59:29 +02:00
parent d6a36c068f
commit f412e70634
3 changed files with 79 additions and 5 deletions
@@ -31,6 +31,13 @@ class ExecuteContainerCommand extends Component
public bool $containersLoaded = false;
/**
* Container picker entries. Alpine reads them through entangle, because containers load after the first render.
*
* @var list<array{value: string, label: string}>
*/
public array $containerOptions = [];
protected $rules = [
'server' => 'required',
'container' => 'required',
@@ -149,6 +156,10 @@ class ExecuteContainerCommand extends Component
$this->containers = $this->containers->sortBy(function ($container) {
return data_get($container, 'container.Names');
});
$this->containerOptions = $this->containers->map(fn (array $container) => [
'value' => $this->containerTarget($container),
'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'),
])->values()->all();
if ($this->containers->count() === 1) {
$this->selected_container = $this->containerTarget($this->containers->first());
@@ -33,10 +33,6 @@
$consoleThemeKeys = collect($consoleThemes)->pluck('key')->values();
$consoleThemeNames = collect($consoleThemes)->pluck('name', 'key');
$consoleThemeAccents = collect($consoleThemes)->pluck('accent', 'key');
$containerOptions = $containers->map(fn ($container) => [
'value' => data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'),
'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'),
])->values();
@endphp
@if (in_array($type, ['application', 'database', 'service', 'server'], true))
@@ -78,7 +74,7 @@
containerOpen: false,
targetChosen: @js($selected_container !== 'default'),
selectedContainer: @entangle('selected_container').live,
containerOptions: @js($containerOptions),
containerOptions: @entangle('containerOptions'),
init() {
const savedTheme = localStorage.getItem('coolify-console-theme');
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
@@ -0,0 +1,67 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
config()->set('constants.ssh.mux_enabled', false);
$stack = seedBrowserResourceStack();
$stack['server']->settings->update(['is_terminal_enabled' => true]);
$this->application = createBrowserApplication($stack, ['uuid' => 'app-terminal-picker']);
$this->terminalPath = "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/application/{$this->application->uuid}/terminal";
Cache::flush();
// An open WebSocket that accepts the connection and waits (it only reacts to a client frame).
$this->terminalServer = new SymfonyProcess(['node', base_path('tests/Fixtures/terminal-websocket-rejection-server.mjs'), 'reject-token']);
$this->terminalServer->start();
$this->terminalServer->waitUntil(fn (string $type, string $output) => str_contains($output, "\n"));
config()->set('constants.terminal.protocol', 'ws');
config()->set('constants.terminal.host', '127.0.0.1');
config()->set('constants.terminal.port', trim($this->terminalServer->getOutput()));
});
afterEach(function () {
$this->terminalServer?->stop(0);
});
it('lists the lazily loaded containers when the user must choose one', function () {
Process::fake(function ($process) {
$command = (string) $process->command;
if (str_contains($command, 'docker ps -a')) {
return Process::result(output: collect(['app-terminal-picker-web', 'app-terminal-picker-worker'])
->map(fn (string $name) => json_encode(['ID' => md5($name), 'Names' => $name, 'State' => 'running', 'Labels' => '']))
->implode("\n"));
}
// The chosen container stops before the session starts, so no WebSocket session is needed.
if (str_contains($command, 'docker inspect')) {
return Process::result(output: json_encode(['State' => ['Status' => 'exited']]));
}
return Process::result();
});
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Dashboard')
->navigate($this->terminalPath)
->assertSee('Start a terminal session')
->assertSee('app-terminal-picker-web')
->assertSee('app-terminal-picker-worker')
->screenshot(filename: 'terminal-container-picker-list')
->click('[data-terminal-target-picker="launcher"] button:nth-of-type(2)')
->assertSee('The container is not running.')
->assertDontSee('Start a terminal session')
->assertSee('app-terminal-picker-worker · localhost');
$page->screenshot(filename: 'terminal-container-picker-selected');
});