From 2e928d86d86cf0c3089438f0977acec1bae7111a Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:37:28 +0200
Subject: [PATCH 1/2] fix: show Traefik version warnings before detection
completes
---
app/Livewire/Server/Proxy.php | 33 +++++++-
.../views/livewire/server/proxy.blade.php | 19 +++--
tests/Feature/TraefikVersionStateTest.php | 82 +++++++++++++++++++
3 files changed, 124 insertions(+), 10 deletions(-)
diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php
index 0454d97049..a3f9be2db5 100644
--- a/app/Livewire/Server/Proxy.php
+++ b/app/Livewire/Server/Proxy.php
@@ -189,11 +189,30 @@ class Proxy extends Component
{
try {
$this->proxySettings = GetProxyConfiguration::run($this->server);
+ $this->clearAppliedTraefikBranchWarning();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ public function getTraefikVersionForWarningProperty(): ?string
+ {
+ if ($this->server->detected_traefik_version) {
+ return $this->server->detected_traefik_version;
+ }
+
+ if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
+ return null;
+ }
+
+ $configuration = $this->server->proxy->get('last_saved_proxy_configuration');
+ if (! is_string($configuration) || ! preg_match('/^\s*image:\s*[\'\"]?traefik:(v?\d+\.\d+(?:\.\d+)?|latest)[\'\"]?\s*$/mi', $configuration, $matches)) {
+ return null;
+ }
+
+ return $matches[1];
+ }
+
/**
* Get the latest Traefik version for this server's current branch.
*
@@ -211,7 +230,7 @@ class Proxy extends Component
}
// Get this server's current version
- $currentVersion = $this->server->detected_traefik_version;
+ $currentVersion = $this->traefikVersionForWarning;
// If we have a current version, try to find matching branch
if ($currentVersion && $currentVersion !== 'latest') {
@@ -244,7 +263,7 @@ class Proxy extends Component
return false;
}
- $currentVersion = $this->server->detected_traefik_version;
+ $currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return false;
}
@@ -273,7 +292,7 @@ class Proxy extends Component
}
// Get this server's current version
- $currentVersion = $this->server->detected_traefik_version;
+ $currentVersion = $this->traefikVersionForWarning;
if (! $currentVersion || $currentVersion === 'latest') {
return null;
}
@@ -335,6 +354,14 @@ class Proxy extends Component
}
}
+ public function getLatestNewerTraefikVersionProperty(): ?string
+ {
+ $branch = $this->newerTraefikBranchAvailable;
+ $version = $branch ? ($this->getTraefikVersions()[$branch] ?? null) : null;
+
+ return $version ? 'v'.ltrim($version, 'v') : null;
+ }
+
private function getConfiguredTraefikBranch(): ?string
{
if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) {
diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php
index 1d4033ea1a..5a6e514da6 100644
--- a/resources/views/livewire/server/proxy.blade.php
+++ b/resources/views/livewire/server/proxy.blade.php
@@ -110,21 +110,26 @@
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
- @if ($server->detected_traefik_version === 'latest')
+ @if ($this->traefikVersionForWarning === 'latest')
The proxy uses the latest tag. Pin
traefik:{{ $this->latestTraefikVersion }}
for predictable updates.
- @elseif($this->isTraefikOutdated)
+ @endif
+ @if ($this->isTraefikOutdated)
- Version {{ $this->latestTraefikVersion }} is available. Test the update before
- applying it to production servers.
+ {{ $server->detected_traefik_version ? 'Running version' : 'Configured image' }}
+ v{{ ltrim($this->traefikVersionForWarning, 'v') }}. The latest patch
+ for this branch is {{ $this->latestTraefikVersion }}. Test the update before applying it
+ to production servers.
- @elseif($this->newerTraefikBranchAvailable)
+ @endif
+ @if ($this->newerTraefikBranchAvailable)
- {{ $this->newerTraefikBranchAvailable }} is available. Review the Traefik
- changelog for breaking changes before upgrading.
+ {{ $this->newerTraefikBranchAvailable }} is available (latest patch:
+ {{ $this->latestNewerTraefikVersion }}). Review the Traefik changelog for breaking
+ changes before upgrading.
@endif
@endif
diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php
index de3cf8e17c..c3388e3a02 100644
--- a/tests/Feature/TraefikVersionStateTest.php
+++ b/tests/Feature/TraefikVersionStateTest.php
@@ -7,13 +7,94 @@ use App\Jobs\CheckTraefikVersionJob;
use App\Livewire\Server\Proxy;
use App\Models\Server;
use App\Models\Team;
+use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
+use Livewire\Livewire;
uses(RefreshDatabase::class);
+it('shows patch and minor upgrade warnings on the first proxy render', function () {
+ Cache::put('coolify:versions:all', [
+ 'traefik' => [
+ 'v3.7' => '3.7.13',
+ 'v3.6' => '3.6.25',
+ ],
+ ]);
+
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ session(['currentTeam' => $team]);
+ $this->actingAs($user);
+
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running'],
+ 'detected_traefik_version' => '3.6.1',
+ 'traefik_outdated_info' => [
+ 'current' => '3.6.1',
+ 'latest' => '3.7.13',
+ 'type' => 'minor_upgrade',
+ 'upgrade_target' => 'v3.7',
+ ],
+ ]);
+
+ Livewire::test(Proxy::class, ['server' => $server])
+ ->assertSee('Traefik patch update available')
+ ->assertSee('v3.6.25')
+ ->assertSee('New Traefik minor version available')
+ ->assertSee('v3.7.13');
+});
+
+it('shows a warning from the saved image before version detection finishes', function () {
+ Cache::put('coolify:versions:all', [
+ 'traefik' => ['v3.7' => '3.7.13', 'v3.6' => '3.6.25'],
+ ]);
+
+ $team = Team::factory()->create();
+ $user = User::factory()->create();
+ $team->members()->attach($user->id, ['role' => 'owner']);
+ session(['currentTeam' => $team]);
+ $this->actingAs($user);
+
+ $server = Server::factory()->create([
+ 'team_id' => $team->id,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => 'running',
+ 'last_saved_proxy_configuration' => "services:\n traefik:\n image: 'traefik:v3.6.5'",
+ ],
+ 'detected_traefik_version' => null,
+ ]);
+
+ Livewire::test(Proxy::class, ['server' => $server])
+ ->assertSee('Configured image')
+ ->assertSee('v3.6.5')
+ ->assertSee('v3.6.25')
+ ->assertSee('v3.7.13');
+});
+
+it('does not treat an unapplied image as the running Traefik version', function () {
+ $server = Server::factory()->make([
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => 'running',
+ 'last_saved_settings' => 'new',
+ 'last_applied_settings' => 'old',
+ 'last_saved_proxy_configuration' => "services:\n traefik:\n image: traefik:v3.6.5",
+ ],
+ 'detected_traefik_version' => null,
+ ]);
+
+ $component = new Proxy;
+ $component->server = $server;
+
+ expect($component->getTraefikVersionForWarningProperty())->toBeNull();
+});
+
it('ignores stale minor upgrade information for the detected Traefik version', function () {
Cache::put('coolify:versions:all', [
'traefik' => [
@@ -120,6 +201,7 @@ YAML,
$component = new Proxy;
$component->server = $server;
$component->mount();
+ $component->loadProxyConfiguration();
expect($server->refresh()->traefik_outdated_info)->toBeNull();
});
From 70631d2a11cdfe858e63163208f6df01615c288c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Thu, 24 Sep 2026 16:49:04 +0200
Subject: [PATCH 2/2] fix(storage): preserve and display legacy bind mount
source paths
Show existing source paths as read-only fields and label bind mounts in deployment configuration. Remove the action that could convert them to named volumes.
---
app/Livewire/Project/Service/Storage.php | 10 +-
app/Livewire/Project/Shared/Storages/All.php | 27 +---
.../ApplicationConfigurationSnapshot.php | 2 +-
resources/css/app.css | 15 ++-
.../project/shared/storages/all.blade.php | 44 ++++++-
.../PersistentStorageVolumesLayoutTest.php | 119 ++++++++++++++++--
6 files changed, 163 insertions(+), 54 deletions(-)
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 3bf5001f4d..470147a73d 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -24,8 +24,6 @@ class Storage extends Component
public string $mount_path = '';
- public ?string $host_path = null;
-
public string $file_storage_path = '';
public ?string $file_storage_content = null;
@@ -209,17 +207,14 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
- 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
- ], array_merge(ValidationPatterns::volumeNameMessages(), [
- 'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
- ]));
+ ], ValidationPatterns::volumeNameMessages());
$name = $this->resource->uuid.'-'.$this->name;
LocalPersistentVolume::create([
'name' => $name,
'mount_path' => $this->mount_path,
- 'host_path' => $this->host_path,
+ 'host_path' => null,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
@@ -347,7 +342,6 @@ class Storage extends Component
{
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
- $this->host_path = null;
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php
index 3dadfb46f4..ce564829fb 100644
--- a/app/Livewire/Project/Shared/Storages/All.php
+++ b/app/Livewire/Project/Shared/Storages/All.php
@@ -22,7 +22,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
- * @var array
+ * @var array
*/
public array $forms = [];
@@ -65,6 +65,7 @@ class All extends Component
public function refreshList(): void
{
+ $this->authorize('view', $this->resource);
$this->resource->refresh();
$this->resource->unsetRelation('persistentStorages');
$this->resource->load(['persistentStorages' => fn ($query) => $query->orderBy('id')]);
@@ -96,7 +97,6 @@ class All extends Component
$form = $this->forms[$storageId];
$storage->name = $form['name'];
$storage->mount_path = $form['mountPath'];
- $storage->host_path = $form['hostPath'] ?: null;
$storage->is_preview_suffix_enabled = (bool) $form['isPreviewSuffixEnabled'];
$storage->save();
@@ -108,25 +108,6 @@ class All extends Component
$this->submit($storageId);
}
- public function clearHostPath(int $storageId): void
- {
- $this->authorize('update', $this->resource);
-
- $storage = $this->findStorageOrFail($storageId);
- if ($storage->shouldBeReadOnlyInUI()) {
- $this->dispatch('error', 'This volume is read-only.');
-
- return;
- }
-
- $storage->host_path = null;
- $storage->save();
- $this->forms[$storageId]['hostPath'] = null;
-
- $this->dispatch('configurationChanged');
- $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
- }
-
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
@@ -212,7 +193,6 @@ class All extends Component
$forms[$storage->id] = [
'name' => $storage->name,
'mountPath' => $storage->mount_path,
- 'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
'canDeleteStale' => $this->canUpdate
@@ -301,18 +281,15 @@ class All extends Component
$this->validate([
"forms.{$storageId}.name" => ValidationPatterns::volumeNameRules(),
"forms.{$storageId}.mountPath" => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
- "forms.{$storageId}.hostPath" => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
"forms.{$storageId}.isPreviewSuffixEnabled" => 'required|boolean',
], array_merge(
ValidationPatterns::volumeNameMessages(),
[
"forms.{$storageId}.mountPath.regex" => 'Mount path must start with / and only contain safe path characters.',
- "forms.{$storageId}.hostPath.regex" => 'Host path must start with / and only contain safe path characters.',
]
), [
"forms.{$storageId}.name" => 'name',
"forms.{$storageId}.mountPath" => 'mount',
- "forms.{$storageId}.hostPath" => 'host',
]);
}
diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
index 184aa01eb3..b974e03db3 100644
--- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
+++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
@@ -237,7 +237,7 @@ class ApplicationConfigurationSnapshot
return $this->item(
key: 'volume_'.$volume->id,
- label: 'Volume mount',
+ label: filled($volume->host_path) ? 'Directory mount' : 'Volume mount',
value: ['source' => $source, 'destination' => $volume->mount_path],
impact: 'redeploy',
displayValue: "{$source} → {$volume->mount_path}",
diff --git a/resources/css/app.css b/resources/css/app.css
index ac7c4ab041..989ac99217 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -2996,7 +2996,7 @@ input[type="search"]::-webkit-search-results-decoration {
background: color-mix(in srgb, var(--coollabs-base) 98%, white);
}
-/* Persistent storage volumes: Name | Destination | [PR suffix] | Backup | [Actions] */
+/* Persistent storage volumes: Name | [Source] | Destination | [PR suffix] | Backup | [Actions] */
.volumes-table-grid-readonly {
grid-template-columns: minmax(10rem, 1.4fr) minmax(8rem, 1fr) 5rem;
}
@@ -3009,6 +3009,18 @@ input[type="search"]::-webkit-search-results-decoration {
grid-template-columns: minmax(9rem, 1.1fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
}
+.volumes-table-grid-readonly.has-source {
+ grid-template-columns: minmax(10rem, 1.2fr) minmax(10rem, 1.2fr) minmax(8rem, 1fr) 5rem;
+}
+
+.volumes-table-grid.has-source {
+ grid-template-columns: minmax(9rem, 1.1fr) minmax(14rem, 1.5fr) minmax(6rem, 1fr) 5rem 15rem;
+}
+
+.volumes-table-grid-with-pr.has-source {
+ grid-template-columns: minmax(9rem, 1fr) minmax(14rem, 1.4fr) minmax(6rem, 1fr) 8.5rem 5rem 15rem;
+}
+
.volumes-mobile-label {
display: none;
}
@@ -3084,6 +3096,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.volumes-cell-name,
+ .volumes-cell-source,
.volumes-col-backup,
.volumes-cell-dest,
.volumes-cell-actions {
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php
index c5e0953ac9..e731c7f2ee 100644
--- a/resources/views/livewire/project/shared/storages/all.blade.php
+++ b/resources/views/livewire/project/shared/storages/all.blade.php
@@ -1,4 +1,5 @@
@php
+ $hasSourcePaths = $resource->persistentStorages->contains(fn ($storage) => filled($storage->host_path));
$gridClass = match (true) {
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
$showActionsColumn => 'volumes-table-grid',
@@ -16,8 +17,11 @@
@if ($resource->persistentStorages->isNotEmpty())
-