Files
coolify/app/Livewire/Project/Shared/ExecuteContainerCommand.php
T
Andras BacsaiandClaude Opus 5.5 0d20a14c5c fix(terminal): show an error instead of an endless connecting spinner
When the terminal started automatically, the session timeout started
only after a token arrived. If the server returned no token (container
not running, no shell, access denied, terminal disabled), the spinner
stayed on "connecting…" until the page was reloaded.

- Every early return now sends terminal-session-failed with a short
  message; denied and unknown servers get the same text.
- Auto-start starts the session timeout in init(), so a missing token
  always ends in a visible error with "Reload page".
- With more than one container, auto-start is cancelled, so the
  container picker shows no false timeout error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:27:28 +02:00

276 lines
11 KiB
PHP

<?php
namespace App\Livewire\Project\Shared;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\On;
use Livewire\Component;
class ExecuteContainerCommand extends Component
{
use AuthorizesRequests;
public $selected_container = 'default';
public Collection $containers;
public $parameters;
public $resource;
public string $type;
public Collection $servers;
public bool $isConnecting = false;
public bool $containersLoaded = false;
protected $rules = [
'server' => 'required',
'container' => 'required',
'command' => 'required',
];
public function mount(): void
{
$this->parameters = get_route_parameters();
$this->containers = collect();
$this->servers = collect();
if (data_get($this->parameters, 'application_uuid')) {
$this->type = 'application';
$this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
foreach ($this->resource->additional_servers as $server) {
if ($server->isFunctional()) {
$this->servers = $this->servers->push($server);
}
}
} elseif (data_get($this->parameters, 'database_uuid')) {
$this->type = 'database';
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
abort(404);
}
$this->resource = $resource;
$this->authorize('view', $this->resource);
if ($this->resource->destination->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->destination->server);
}
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->type = 'service';
$this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
if (! $this->resource->isRunning()) {
$this->containersLoaded = true;
}
if ($this->resource->server->isFunctional()) {
$this->servers = $this->servers->push($this->resource->server);
}
} elseif (data_get($this->parameters, 'server_uuid')) {
$this->type = 'server';
$this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail();
$this->authorize('view', $this->resource);
$this->servers = $this->servers->push($this->resource);
$this->containersLoaded = true;
}
$this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled());
}
public function loadContainers(): void
{
if ($this->containersLoaded) {
return;
}
foreach ($this->servers as $server) {
if (data_get($this->parameters, 'application_uuid')) {
if ($server->isSwarm()) {
$containers = collect([
[
'Names' => $this->resource->uuid.'_'.$this->resource->uuid,
],
]);
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
}
foreach ($containers as $container) {
// if container state is running
if (data_get($container, 'State') === 'running' && $server->isTerminalEnabled()) {
$payload = [
'server' => $server,
'container' => $container,
];
$this->containers = $this->containers->push($payload);
}
}
} elseif (data_get($this->parameters, 'database_uuid')) {
if ($this->resource->isRunning() && $server->isTerminalEnabled()) {
$this->containers = $this->containers->push([
'server' => $server,
'container' => [
'Names' => $this->resource->uuid,
],
]);
}
} elseif (data_get($this->parameters, 'service_uuid')) {
$this->resource->applications()->get()->each(function ($application) {
if ($application->isRunning() && $this->resource->server->isTerminalEnabled()) {
$this->containers->push([
'server' => $this->resource->server,
'container' => [
'Names' => data_get($application, 'name').'-'.data_get($this->resource, 'uuid'),
],
]);
}
});
$this->resource->databases()->get()->each(function ($database) {
if ($database->isRunning()) {
$this->containers->push([
'server' => $this->resource->server,
'container' => [
'Names' => data_get($database, 'name').'-'.data_get($this->resource, 'uuid'),
],
]);
}
});
}
}
// Sort containers alphabetically by name
$this->containers = $this->containers->sortBy(function ($container) {
return data_get($container, 'container.Names');
});
if ($this->containers->count() === 1) {
$this->selected_container = $this->containerTarget($this->containers->first());
$this->connectToContainer();
} elseif ($this->containers->count() > 1) {
// The terminal was rendered with auto-start before the containers were known.
$this->dispatch(Terminal::AUTO_START_CANCELLED_EVENT)->to(Terminal::class);
}
$this->containersLoaded = true;
}
private function containerTarget(array $container): string
{
return data_get($container, 'server.uuid').':'.data_get($container, 'container.Names');
}
public function updatedSelectedContainer()
{
if ($this->selected_container !== 'default') {
$this->connectToContainer();
}
}
#[On('connectToServer')]
public function connectToServer()
{
try {
$this->authorize('canAccessTerminal');
$server = $this->servers->first();
$this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
$this->dispatch(
'send-terminal-command',
false,
data_get($server, 'name'),
data_get($server, 'uuid')
);
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) {
$this->failTerminalSession(Terminal::sessionFailureMessage($e));
} finally {
$this->isConnecting = false;
}
}
#[On('connectToContainer')]
public function connectToContainer()
{
if ($this->selected_container === 'default') {
$this->failTerminalSession('Please select a container.');
return;
}
try {
$this->authorize('canAccessTerminal');
// Validate container name format
if (! ValidationPatterns::isValidContainerName(str($this->selected_container)->after(':')->value())) {
throw new \InvalidArgumentException('Invalid container name format');
}
// Verify container exists in our allowed list
$container = $this->containers->first(fn ($candidate) => $this->containerTarget($candidate) === $this->selected_container);
if (is_null($container)) {
throw new \RuntimeException('Container not found.');
}
// Verify server ownership and status
$server = data_get($container, 'server');
if (! $server || ! $server instanceof Server) {
throw new \RuntimeException('Invalid server configuration.');
}
$this->authorize('view', $server);
if ($server->isForceDisabled()) {
throw new \RuntimeException('Server is disabled.');
}
// Additional ownership verification based on resource type
$resourceServer = match ($this->type) {
'application' => $this->resource->destination->server,
'database' => $this->resource->destination->server,
'service' => $this->resource->server,
default => throw new \RuntimeException('Invalid resource type.')
};
if ($server->id !== $resourceServer->id && ! $this->resource->additional_servers->contains('id', $server->id)) {
throw new \RuntimeException('Server ownership verification failed.');
}
$this->dispatch(
'send-terminal-command',
true,
data_get($container, 'container.Names'),
data_get($container, 'server.uuid')
);
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) {
$this->failTerminalSession(Terminal::sessionFailureMessage($e));
} finally {
$this->isConnecting = false;
}
}
/**
* Stop the terminal "connecting…" state and show why. The terminal also shows the toast.
*/
private function failTerminalSession(string $message): void
{
$this->dispatch(Terminal::SESSION_FAILED_EVENT, message: $message)->to(Terminal::class);
}
public function render()
{
return view('livewire.project.shared.execute-container-command');
}
}