From e79230cf37555a2f927fc0c3f16f75b2480341a1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:17:44 +0200
Subject: [PATCH] fix(docker): replace deprecated stop flags with version-aware
ones
Store each server's Docker version and build stop commands via
dockerStopCommand() so newer Docker uses --timeout instead of
deprecated --time/-t. Also show Docker version in server details.
---
app/Actions/Application/StopApplication.php | 2 +-
.../Application/StopApplicationOneServer.php | 2 +-
app/Actions/Database/StartClickhouse.php | 2 +-
app/Actions/Database/StartDragonfly.php | 2 +-
app/Actions/Database/StartKeydb.php | 2 +-
app/Actions/Database/StartMariadb.php | 2 +-
app/Actions/Database/StartMongodb.php | 2 +-
app/Actions/Database/StartMysql.php | 2 +-
app/Actions/Database/StartPostgresql.php | 2 +-
app/Actions/Database/StartRedis.php | 2 +-
app/Actions/Database/StopDatabase.php | 2 +-
app/Actions/Proxy/StopProxy.php | 2 +-
app/Actions/Service/StopService.php | 2 +-
app/Jobs/ApplicationDeploymentJob.php | 5 +-
app/Jobs/DeleteResourceJob.php | 2 +-
app/Jobs/RestartProxyJob.php | 2 +-
app/Jobs/ServerConnectionCheckJob.php | 14 ++
app/Livewire/Project/Application/Previews.php | 2 +-
app/Livewire/Server/Show.php | 12 +-
app/Models/Server.php | 50 ++++-
app/Models/ServerSetting.php | 10 +
bootstrap/helpers/docker.php | 55 ++++++
config/constants.php | 1 +
...ocker_version_to_server_settings_table.php | 30 +++
.../partials/localhost-general.blade.php | 35 ++--
.../server/partials/server-details.blade.php | 23 +++
.../views/livewire/server/show.blade.php | 28 +--
tests/Feature/DockerVersionStorageTest.php | 112 +++++++++++
tests/Feature/LocalhostServerDetailsTest.php | 184 ++++++++++++++++++
tests/Feature/ServerMetadataTest.php | 62 ++++++
tests/Unit/DockerStopCommandTest.php | 42 ++++
tests/Unit/DockerStopDeprecatedFlagTest.php | 66 +++++++
32 files changed, 697 insertions(+), 64 deletions(-)
create mode 100644 database/migrations/2026_08_13_085439_add_docker_version_to_server_settings_table.php
create mode 100644 resources/views/livewire/server/partials/server-details.blade.php
create mode 100644 tests/Feature/DockerVersionStorageTest.php
create mode 100644 tests/Feature/LocalhostServerDetailsTest.php
create mode 100644 tests/Unit/DockerStopCommandTest.php
create mode 100644 tests/Unit/DockerStopDeprecatedFlagTest.php
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 @@
- @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)
-
+ @include('livewire.server.partials.server-details', ['server' => $server])
+ @else
+