diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index bfad20ccf..81148e3d8 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -40,7 +40,7 @@ class StopApplication foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", + dockerStopCommand($timeout, $containerName, $server), "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index 09de9b628..10f5b85f2 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -28,7 +28,7 @@ class StopApplicationOneServer if ($containerName) { instant_remote_process( [ - "docker stop --time=$timeout $containerName", + dockerStopCommand($timeout, $containerName, $server), "docker rm -f $containerName", ], $server diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 525e736c3..b256eb225 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -104,7 +104,7 @@ class StartClickhouse $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index b78a0987d..ddd930f27 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -191,7 +191,7 @@ class StartDragonfly if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 89258fe24..cc017e351 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -209,7 +209,7 @@ class StartKeydb if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) { $this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf"; } - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 2e8faea9a..f7bac49da 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -208,7 +208,7 @@ class StartMariadb $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 80ec812a1..784bebffc 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -257,7 +257,7 @@ class StartMongodb $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 0445bddcd..39fcd391c 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -209,7 +209,7 @@ class StartMysql $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index ae7ae9860..8963ed26f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -219,7 +219,7 @@ class StartPostgresql $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; if ($this->database->enable_ssl) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 64b434821..8d65453f7 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -204,7 +204,7 @@ class StartRedis if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) { $this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf"; } - $this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true"; + $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "echo 'Database started.'"; diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index 4dde509ab..5bbef7d7d 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -56,7 +56,7 @@ class StopDatabase { $server = $database->destination->server; instant_remote_process(command: [ - "docker stop -t $timeout $containerName", + dockerStopCommand($timeout, $containerName, $server), "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Proxy/StopProxy.php b/app/Actions/Proxy/StopProxy.php index 04d031ec6..b9029d655 100644 --- a/app/Actions/Proxy/StopProxy.php +++ b/app/Actions/Proxy/StopProxy.php @@ -24,7 +24,7 @@ class StopProxy } instant_remote_process(command: [ - "docker stop -t=$timeout $containerName 2>/dev/null || true", + dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true', "docker rm -f $containerName 2>/dev/null || true", '# Wait for container to be fully removed', 'for i in {1..10}; do', diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 675f0f955..9817bf32d 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -67,7 +67,7 @@ class StopService $timeout = count($containersToStop) > 5 ? 10 : 30; $commands = []; $containerList = implode(' ', $containersToStop); - $commands[] = "docker stop -t $timeout $containerList"; + $commands[] = dockerStopCommand($timeout, $containerList, $server); $commands[] = "docker rm -f $containerList"; instant_remote_process( command: $commands, diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 34db05d6d..3047bcc01 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -431,6 +431,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ["docker version --format '{{.Server.Version}}'"], $serverToCheck ); + $serverToCheck->rememberDockerVersion($dockerVersion); $versionParts = explode('.', $dockerVersion); $majorVersion = (int) $versionParts[0]; @@ -3972,11 +3973,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($skipRemove) { $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index 656e5c409..436bf788b 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -216,7 +216,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $containerList = implode(' ', array_map('escapeshellarg', $containerNames)); $commands = [ - "docker stop -t $timeout $containerList", + dockerStopCommand($timeout, $containerList, $server), "docker rm -f $containerList", ]; instant_remote_process( diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index 2815c73bc..c5eb8c7fc 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -98,7 +98,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue // === STOP PHASE === $commands = $commands->merge([ "echo 'Stopping proxy...'", - "docker stop -t=$stopTimeout $containerName 2>/dev/null || true", + dockerStopCommand($stopTimeout, $containerName, $this->server).' 2>/dev/null || true', "docker rm -f $containerName 2>/dev/null || true", '# Wait for container to be fully removed', 'for i in {1..15}; do', diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index f86686df7..fe7a20972 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -194,6 +194,20 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue $output = trim($output); if (! empty($output)) { $dockerInfo = json_decode($output, true); + $dockerVersion = dockerEngineVersionFromJson($output); + if ($dockerVersion !== null) { + $this->server->rememberDockerVersion($dockerVersion); + } + + $composeOutput = instant_remote_process_with_timeout( + ['docker compose version --short'], + $this->server, + false + ); + $composeVersion = parseDockerEngineVersion($composeOutput); + if ($composeVersion !== null) { + $this->server->rememberComposeVersion($composeVersion); + } return isset($dockerInfo['Server']['Version']); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 1fc214a56..e70636628 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -354,7 +354,7 @@ class Previews extends Component foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop --time=$timeout $containerName", + dockerStopCommand($timeout, $containerName, $server), "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 9678cb8d7..017beb371 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -371,6 +371,8 @@ class Show extends Component $this->server->settings->is_usable = $this->isUsable = true; $this->server->settings->save(); ServerReachabilityChanged::dispatch($this->server); + $this->server->gatherServerMetadata(); + $this->server->refresh(); } else { $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); @@ -671,12 +673,18 @@ class Show extends Component { try { $this->authorize('update', $this->server); + if (! $this->server->isFunctional()) { + $this->dispatch('error', 'Validate the server connection before fetching details.'); + + return; + } + $result = $this->server->gatherServerMetadata(); if ($result) { - $this->server->refresh(); + $this->server->refresh()->load('settings'); $this->dispatch('success', 'Server details refreshed.'); } else { - $this->dispatch('error', 'Could not fetch server details. Is the server reachable?'); + $this->dispatch('error', 'Could not collect server details. Check the application logs for the remote command output.'); } } catch (\Throwable $e) { handleError($e, $this); diff --git a/app/Models/Server.php b/app/Models/Server.php index 619382dfd..1acc4e45f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -961,7 +961,7 @@ $schema://$host { public function stopUnmanaged($id) { - return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this); + return instant_remote_process([dockerStopCommand(0, escapeshellarg($id), $this)], $this); } public function restartUnmanaged($id) @@ -1351,7 +1351,7 @@ $schema://$host { try { $output = instant_remote_process([ - 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s', + 'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s && echo "---DOCKER---" && (docker version --format \'{{.Server.Version}}\' 2>/dev/null || true) && echo "---COMPOSE---" && (docker compose version --short 2>/dev/null || true)', ], $this, false); if (! $output) { @@ -1381,6 +1381,23 @@ $schema://$host { $this->update(['server_metadata' => $metadata]); + try { + $detectedDockerVersion = parseDockerEngineVersion($sections['DOCKER'] ?? null); + if ($detectedDockerVersion !== null) { + $this->rememberDockerVersion($detectedDockerVersion); + } + + $detectedComposeVersion = parseDockerEngineVersion($sections['COMPOSE'] ?? null); + if ($detectedComposeVersion !== null) { + $this->rememberComposeVersion($detectedComposeVersion); + } + } catch (\Throwable $e) { + Log::debug('Failed to store server runtime versions', [ + 'server_id' => $this->id, + 'error' => $e->getMessage(), + ]); + } + return $metadata; } catch (\Throwable $e) { Log::debug('Failed to gather server metadata', [ @@ -1604,11 +1621,40 @@ $schema://$host { return true; } + public function dockerVersion(): ?string + { + return $this->settings?->docker_version; + } + + public function rememberDockerVersion(?string $version): void + { + $this->settings->update([ + 'docker_version' => parseDockerEngineVersion($version), + 'docker_version_checked_at' => now(), + ]); + } + + public function composeVersion(): ?string + { + return $this->settings?->compose_version; + } + + public function rememberComposeVersion(?string $version): void + { + $this->settings->update([ + 'compose_version' => parseDockerEngineVersion($version), + 'compose_version_checked_at' => now(), + ]); + } + public function validateDockerEngineVersion() { $dockerVersionRaw = instant_remote_process(['docker version --format json'], $this, false); $dockerVersionJson = json_decode($dockerVersionRaw, true); $dockerVersion = data_get($dockerVersionJson, 'Server.Version', '0.0.0'); + $this->rememberDockerVersion(is_string($dockerVersion) ? $dockerVersion : null); + $composeVersionRaw = instant_remote_process(['docker compose version --short'], $this, false); + $this->rememberComposeVersion(is_string($composeVersionRaw) ? $composeVersionRaw : null); $dockerVersion = checkMinimumDockerEngineVersion($dockerVersion); if (is_null($dockerVersion)) { $this->settings->is_usable = false; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 93f040e4a..15ad3a95c 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -51,6 +51,10 @@ use OpenApi\Attributes as OA; 'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'], 'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'], 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'], + 'docker_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Engine version on the server.'], + 'docker_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Engine version was last detected.'], + 'compose_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Compose plugin version on the server.'], + 'compose_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Compose version was last detected.'], ] )] class ServerSetting extends Model @@ -100,6 +104,10 @@ class ServerSetting extends Model 'deployment_queue_limit', 'disable_application_image_retention', 'connection_timeout', + 'docker_version', + 'docker_version_checked_at', + 'compose_version', + 'compose_version_checked_at', ]; protected $casts = [ @@ -113,6 +121,8 @@ class ServerSetting extends Model 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', 'connection_timeout' => 'integer', + 'docker_version_checked_at' => 'datetime', + 'compose_version_checked_at' => 'datetime', ]; /** diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 258914625..ea00f9352 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -198,6 +198,61 @@ function checkMinimumDockerEngineVersion($dockerVersion) return $dockerVersion; } + +function parseDockerEngineVersion(?string $rawVersion): ?string +{ + if ($rawVersion === null || trim($rawVersion) === '') { + return null; + } + + if (preg_match('/\d+\.\d+(?:\.\d+)?/', $rawVersion, $matches) !== 1) { + return null; + } + + $parts = explode('.', $matches[0]); + + return sprintf('%d.%d.%d', (int) $parts[0], (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)); +} + +function dockerEngineVersionFromJson(?string $raw): ?string +{ + if ($raw === null || trim($raw) === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return null; + } + + $version = $decoded['Server']['Version'] ?? null; + + return is_string($version) ? parseDockerEngineVersion($version) : null; +} + +function dockerStopTimeoutOption(?string $dockerVersion): string +{ + $normalized = parseDockerEngineVersion($dockerVersion); + if ($normalized !== null && version_compare($normalized, '28.0.0', '>=')) { + return '--timeout'; + } + + return '--time'; +} + +function dockerStopCommand(int $timeout, string $containers, Server|string|null $dockerVersion = null): string +{ + $version = $dockerVersion instanceof Server + ? $dockerVersion->dockerVersion() + : $dockerVersion; + + $option = dockerStopTimeoutOption($version); + $flag = $option === '--timeout' + ? "--timeout={$timeout}" + : "--time={$timeout}"; + + return "docker stop {$flag} {$containers}"; +} function escapeShellValue(string $value): string { return "'".str_replace("'", "'\\''", $value)."'"; diff --git a/config/constants.php b/config/constants.php index 49af41480..e2d96d0d5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -63,6 +63,7 @@ return [ 'docker' => [ 'minimum_required_version' => '24.0', + 'stop_timeout_flag_since' => '28.0.0', ], 'ssh' => [ diff --git a/database/migrations/2026_08_13_085439_add_docker_version_to_server_settings_table.php b/database/migrations/2026_08_13_085439_add_docker_version_to_server_settings_table.php new file mode 100644 index 000000000..95b4ef375 --- /dev/null +++ b/database/migrations/2026_08_13_085439_add_docker_version_to_server_settings_table.php @@ -0,0 +1,30 @@ +string('docker_version')->nullable(); + $table->timestamp('docker_version_checked_at')->nullable(); + $table->string('compose_version')->nullable(); + $table->timestamp('compose_version_checked_at')->nullable(); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'docker_version', + 'docker_version_checked_at', + 'compose_version', + 'compose_version_checked_at', + ]); + }); + } +}; diff --git a/resources/views/livewire/server/partials/localhost-general.blade.php b/resources/views/livewire/server/partials/localhost-general.blade.php index d2e8037eb..fef160999 100644 --- a/resources/views/livewire/server/partials/localhost-general.blade.php +++ b/resources/views/livewire/server/partials/localhost-general.blade.php @@ -3,6 +3,12 @@ + @if ($server->server_metadata) + + + + @endif @@ -25,27 +31,14 @@ @if ($server->server_metadata) - @php($meta = $server->server_metadata) -
- @foreach ([ - 'Operating system' => $meta['os'] ?? 'N/A', - 'Architecture' => $meta['arch'] ?? 'N/A', - 'Kernel' => $meta['kernel'] ?? 'N/A', - 'CPU cores' => $meta['cpus'] ?? 'N/A', - 'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A', - 'Up since' => $meta['uptime_since'] ?? 'N/A', - ] as $detailLabel => $detailValue) -
-
- {{ $detailLabel }} -
-
- {{ $detailValue }} -
-
- @endforeach -
+ @include('livewire.server.partials.server-details', ['server' => $server]) + @else +
+ + + Fetch server details + +
@endif
diff --git a/resources/views/livewire/server/partials/server-details.blade.php b/resources/views/livewire/server/partials/server-details.blade.php new file mode 100644 index 000000000..be8d0236a --- /dev/null +++ b/resources/views/livewire/server/partials/server-details.blade.php @@ -0,0 +1,23 @@ +@php($meta = $server->server_metadata) +
+ @foreach ([ + 'Operating system' => $meta['os'] ?? 'N/A', + 'Architecture' => $meta['arch'] ?? 'N/A', + 'Kernel' => $meta['kernel'] ?? 'N/A', + 'CPU cores' => $meta['cpus'] ?? 'N/A', + 'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A', + 'Docker version' => $server->dockerVersion() ?? 'N/A', + 'Compose version' => $server->composeVersion() ?? 'N/A', + 'Up since' => $meta['uptime_since'] ?? 'N/A', + ] as $detailLabel => $detailValue) +
+
+ {{ $detailLabel }} +
+
+ {{ $detailValue }} +
+
+ @endforeach +
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 3bf29a918..513021628 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -89,6 +89,12 @@ @endif @endif + @if ($server->server_metadata) + + + + @endif @if ($server->isTransferredAway()) @else @@ -119,27 +125,7 @@ @if ($server->server_metadata) - @php($meta = $server->server_metadata) -
- @foreach ([ - 'Operating system' => $meta['os'] ?? 'N/A', - 'Architecture' => $meta['arch'] ?? 'N/A', - 'Kernel' => $meta['kernel'] ?? 'N/A', - 'CPU cores' => $meta['cpus'] ?? 'N/A', - 'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A', - 'Up since' => $meta['uptime_since'] ?? 'N/A', - ] as $detailLabel => $detailValue) -
-
- {{ $detailLabel }} -
-
- {{ $detailValue }} -
-
- @endforeach -
+ @include('livewire.server.partials.server-details', ['server' => $server]) @else
diff --git a/tests/Feature/DockerVersionStorageTest.php b/tests/Feature/DockerVersionStorageTest.php new file mode 100644 index 000000000..469384693 --- /dev/null +++ b/tests/Feature/DockerVersionStorageTest.php @@ -0,0 +1,112 @@ +create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.10', + ]); +} + +beforeEach(function () { + Storage::fake('ssh-keys'); + Carbon::setTestNow('2026-08-13 12:00:00'); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +it('has docker and compose version columns on server settings', function () { + expect(Schema::hasColumns('server_settings', [ + 'docker_version', + 'docker_version_checked_at', + 'compose_version', + 'compose_version_checked_at', + ]))->toBeTrue(); +}); + +it('stores a parsed docker version on the server settings', function () { + $server = createServerForDockerVersionStorageTest(); + + $server->rememberDockerVersion('29.4.3-ce'); + + $settings = $server->settings->fresh(); + + expect($settings->docker_version)->toBe('29.4.3') + ->and($settings->docker_version_checked_at?->equalTo(Carbon::parse('2026-08-13 12:00:00')))->toBeTrue() + ->and($server->dockerVersion())->toBe('29.4.3'); +}); + +it('updates the stored docker version when the engine changes', function () { + $server = createServerForDockerVersionStorageTest(); + $server->rememberDockerVersion('27.5.1'); + + Carbon::setTestNow('2026-08-13 13:00:00'); + $server->rememberDockerVersion('29.4.3'); + + $settings = $server->settings->fresh(); + + expect($settings->docker_version)->toBe('29.4.3') + ->and($settings->docker_version_checked_at?->equalTo(Carbon::parse('2026-08-13 13:00:00')))->toBeTrue(); +}); + +it('builds stop commands from the stored server docker version', function (?string $storedVersion, string $expectedCommand) { + $server = createServerForDockerVersionStorageTest(); + if ($storedVersion !== null) { + $server->rememberDockerVersion($storedVersion); + } + + expect(dockerStopCommand(30, 'app-1', $server->fresh()))->toBe($expectedCommand); +})->with([ + 'unset' => [null, 'docker stop --time=30 app-1'], + 'docker 28+' => ['29.4.3', 'docker stop --timeout=30 app-1'], + 'docker 27' => ['27.5.1', 'docker stop --time=30 app-1'], +]); + +it('stores a parsed compose version on the server settings', function () { + $server = createServerForDockerVersionStorageTest(); + + $server->rememberComposeVersion('v2.32.4'); + + $settings = $server->settings->fresh(); + + expect($settings->compose_version)->toBe('2.32.4') + ->and($settings->compose_version_checked_at?->equalTo(Carbon::parse('2026-08-13 12:00:00')))->toBeTrue() + ->and($server->composeVersion())->toBe('2.32.4'); +}); + +it('records docker and compose versions during connection checks', function () { + $server = createServerForDockerVersionStorageTest(); + + Process::fake([ + '*compose*' => Process::result(output: 'v2.32.4', exitCode: 0), + '*' => Process::result( + output: '{"Server":{"Version":"29.4.3"}}', + exitCode: 0, + ), + ]); + + (new ServerConnectionCheckJob($server, disableMux: false))->handle(); + + $settings = $server->settings->fresh(); + + expect($settings->docker_version)->toBe('29.4.3') + ->and($settings->compose_version)->toBe('2.32.4'); +}); diff --git a/tests/Feature/LocalhostServerDetailsTest.php b/tests/Feature/LocalhostServerDetailsTest.php new file mode 100644 index 000000000..f2fe735ec --- /dev/null +++ b/tests/Feature/LocalhostServerDetailsTest.php @@ -0,0 +1,184 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'id' => 0, + 'uuid' => 'localhost', + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'name' => 'localhost', + 'ip' => 'host.docker.internal', + 'user' => 'root', + 'port' => 22, + ]); +}); + +function localhostMetadataProcessOutput(): string +{ + return implode("\n", [ + '---PRETTY_NAME---', + 'Ubuntu 22.04.3 LTS', + '---ARCH---', + 'x86_64', + '---KERNEL---', + '5.15.0-91-generic', + '---CPUS---', + '4', + '---MEMORY---', + '8589934592', + '---UPTIME_SINCE---', + '2024-01-15 10:30:00', + '---DOCKER---', + '29.4.3-ce', + '---COMPOSE---', + 'v2.32.4', + ]); +} + +it('shows a fetch server details action on localhost when metadata is missing', function () { + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Fetch server details') + ->assertDontSee('Ubuntu 22.04.3 LTS'); +}); + +it('renders stored localhost server details instead of the fetch action', function () { + $this->server->update([ + 'server_metadata' => [ + 'os' => 'Ubuntu 22.04.3 LTS', + 'arch' => 'x86_64', + 'kernel' => '5.15.0-91-generic', + 'cpus' => 4, + 'memory_bytes' => 8589934592, + 'uptime_since' => '2024-01-15 10:30:00', + 'collected_at' => now()->toIso8601String(), + ], + ]); + + $this->server->rememberDockerVersion('29.4.3-ce'); + $this->server->rememberComposeVersion('v2.32.4'); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Operating system') + ->assertSee('Ubuntu 22.04.3 LTS') + ->assertSee('x86_64') + ->assertSee('Docker version') + ->assertSee('29.4.3') + ->assertSee('Compose version') + ->assertSee('2.32.4') + ->assertSee('Refresh server details') + ->assertDontSee('Fetch server details'); +}); + +it('fetches localhost server details from the overview action', function () { + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Process::fake([ + '*' => Process::result(output: localhostMetadataProcessOutput(), exitCode: 0), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('refreshServerMetadata') + ->assertSee('Operating system') + ->assertSee('Ubuntu 22.04.3 LTS') + ->assertSee('Docker version') + ->assertSee('29.4.3') + ->assertSee('Compose version') + ->assertSee('2.32.4'); + + $server = $this->server->fresh(); + + expect($server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS') + ->and($server->dockerVersion())->toBe('29.4.3') + ->and($server->composeVersion())->toBe('2.32.4'); +}); + +it('refetches localhost compose version from the overview refresh action', function () { + $this->server->update([ + 'server_metadata' => [ + 'os' => 'Ubuntu 22.04.3 LTS', + 'arch' => 'x86_64', + 'kernel' => '5.15.0-91-generic', + 'cpus' => 4, + 'memory_bytes' => 8589934592, + 'uptime_since' => '2024-01-15 10:30:00', + 'collected_at' => now()->toIso8601String(), + ], + ]); + $this->server->rememberDockerVersion('29.4.3'); + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Process::fake([ + '*' => Process::result(output: localhostMetadataProcessOutput(), exitCode: 0), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Compose version') + ->assertSee('N/A') + ->call('refreshServerMetadata') + ->assertSee('2.32.4'); + + expect($this->server->fresh()->composeVersion())->toBe('2.32.4'); +}); + +it('collects localhost server details after a successful connection check', function () { + $this->server->settings()->update([ + 'is_reachable' => false, + 'is_usable' => false, + 'server_timezone' => 'UTC', + 'connection_timeout' => 10, + ]); + + Process::fake([ + '*' => Process::sequence([ + Process::result(output: 'bin', exitCode: 0), + Process::result(output: localhostMetadataProcessOutput(), exitCode: 0), + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('checkLocalhostConnection'); + + $server = $this->server->fresh(); + + expect($server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS') + ->and($server->dockerVersion())->toBe('29.4.3') + ->and($server->composeVersion())->toBe('2.32.4') + ->and($server->settings->is_reachable)->toBeTrue() + ->and($server->settings->is_usable)->toBeTrue(); +}); diff --git a/tests/Feature/ServerMetadataTest.php b/tests/Feature/ServerMetadataTest.php index 204ae456d..1c5fe8259 100644 --- a/tests/Feature/ServerMetadataTest.php +++ b/tests/Feature/ServerMetadataTest.php @@ -1,10 +1,13 @@ actingAs($user); session(['currentTeam' => $this->team]); + $this->privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + $this->server = Server::factory()->create([ 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, ]); }); @@ -84,6 +92,60 @@ it('returns null from gatherServerMetadata when server is not functional', funct expect($this->server->gatherServerMetadata())->toBeNull(); }); +it('stores a parsed docker version when gathering server metadata', function () { + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Process::fake([ + '*' => Process::result(output: implode("\n", [ + '---PRETTY_NAME---', + 'Debian GNU/Linux 12 (bookworm)', + '---ARCH---', + 'aarch64', + '---KERNEL---', + '6.1.0-17-arm64', + '---CPUS---', + '8', + '---MEMORY---', + '17179869184', + '---UPTIME_SINCE---', + '2024-03-01 08:00:00', + '---DOCKER---', + '29.4.3-ce', + '---COMPOSE---', + 'v2.32.4', + ]), exitCode: 0), + ]); + + expect($this->server->gatherServerMetadata()['os'])->toBe('Debian GNU/Linux 12 (bookworm)') + ->and($this->server->fresh()->dockerVersion())->toBe('29.4.3') + ->and($this->server->fresh()->composeVersion())->toBe('2.32.4'); +}); + +it('shows the stored docker version in remote server details', function () { + $this->server->update([ + 'server_metadata' => [ + 'os' => 'Debian GNU/Linux 12 (bookworm)', + 'arch' => 'aarch64', + 'kernel' => '6.1.0-17-arm64', + 'cpus' => 8, + 'memory_bytes' => 17179869184, + 'uptime_since' => '2024-03-01 08:00:00', + 'collected_at' => now()->toIso8601String(), + ], + ]); + $this->server->rememberDockerVersion('27.5.1'); + $this->server->rememberComposeVersion('2.29.7'); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Docker version') + ->assertSee('27.5.1') + ->assertSee('Compose version') + ->assertSee('2.29.7'); +}); + it('can overwrite server_metadata with new values', function () { $this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]); $this->server->refresh(); diff --git a/tests/Unit/DockerStopCommandTest.php b/tests/Unit/DockerStopCommandTest.php new file mode 100644 index 000000000..6f8a38486 --- /dev/null +++ b/tests/Unit/DockerStopCommandTest.php @@ -0,0 +1,42 @@ +toBe($expected); +})->with([ + 'semver' => ['29.4.3', '29.4.3'], + 'two-part' => ['28.0', '28.0.0'], + 'suffix' => ['28.0.1-ce', '28.0.1'], + 'build metadata' => ['29.4.3+azure', '29.4.3'], + 'null' => [null, null], + 'empty' => ['', null], + 'garbage' => ['not-a-version', null], +]); + +it('extracts the server version from docker version json', function () { + expect(dockerEngineVersionFromJson('{"Server":{"Version":"29.4.3"}}'))->toBe('29.4.3') + ->and(dockerEngineVersionFromJson('{"Client":{"Version":"29.4.3"}}'))->toBeNull() + ->and(dockerEngineVersionFromJson('not-json'))->toBeNull(); +}); + +it('uses --timeout on docker 28 and newer', function (string $version) { + expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --timeout=30 app-1'); +})->with(['28.0.0', '28.0.1', '28.0', '29.4.3', '29.4.3-ce']); + +it('uses --time on docker older than 28', function (string $version) { + expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --time=30 app-1'); +})->with(['24.0.0', '26.1.4', '27.5.1']); + +it('uses the historical --time flag when the docker version is unknown', function (?string $version) { + expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --time=30 app-1'); +})->with([ + 'null' => [null], + 'empty' => [''], + 'garbage' => ['unknown'], +]); diff --git a/tests/Unit/DockerStopDeprecatedFlagTest.php b/tests/Unit/DockerStopDeprecatedFlagTest.php new file mode 100644 index 000000000..7163d0352 --- /dev/null +++ b/tests/Unit/DockerStopDeprecatedFlagTest.php @@ -0,0 +1,66 @@ +reject(fn (string $file): bool => $file === $helperFile) + ->mapWithKeys(function (string $file) use ($projectRoot) { + preg_match_all('/docker\s+(?:stop|restart)\s+--time\b/', file_get_contents($file), $matches); + + return [str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $file) => $matches[0]]; + }) + ->filter(fn (array $matches): bool => $matches !== []) + ->map(fn (array $matches, string $file): string => $file.': '.implode(', ', array_unique($matches))); + + expect($offenders->values()->implode("\n"))->toBe(''); +}); + +it('builds application container stop commands from the stored docker version', function (string $relativePath) { + $contents = file_get_contents(dockerStopDeprecatedFlagProjectRoot().DIRECTORY_SEPARATOR.$relativePath); + + expect($contents) + ->toContain('dockerStopCommand(') + ->not->toContain('docker stop --time=') + ->not->toContain('docker stop -t $timeout'); +})->with([ + 'deployment graceful shutdown' => ['app/Jobs/ApplicationDeploymentJob.php'], + 'stop application' => ['app/Actions/Application/StopApplication.php'], + 'stop application on one server' => ['app/Actions/Application/StopApplicationOneServer.php'], + 'stop preview containers' => ['app/Livewire/Project/Application/Previews.php'], +]); + +function dockerStopDeprecatedFlagProjectRoot(): string +{ + return dirname(__DIR__, 2); +} + +/** + * @return list + */ +function dockerStopDeprecatedFlagPhpFiles(string $directory): array +{ + $files = []; + + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)) as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; +}