diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php index 3a37a7328b..98af978bbb 100644 --- a/app/Actions/Server/StartSentinel.php +++ b/app/Actions/Server/StartSentinel.php @@ -48,7 +48,11 @@ class StartSentinel } $dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments)); $dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels)); - $dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + $network = $server->isLocalhost() ? ' --network coolify' : ''; + $dockerCommand = "docker run -d$network $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image"; + + $server->sentinelHeartbeat(isReset: true); + $server->forceFill(['sentinel_waiting_since' => now()])->save(); instant_remote_process([ 'docker rm -f coolify-sentinel || true', @@ -60,7 +64,10 @@ class StartSentinel $server->settings->is_sentinel_enabled = true; $server->settings->save(); - $server->sentinelHeartbeat(); + $server->refresh(); + if ($server->sentinel_waiting_since !== null) { + $server->forceFill(['sentinel_waiting_since' => now()])->save(); + } // Dispatch event to notify UI components SentinelRestarted::dispatch($server, $version); diff --git a/app/Events/SentinelSynchronized.php b/app/Events/SentinelSynchronized.php new file mode 100644 index 0000000000..3c39e46f8d --- /dev/null +++ b/app/Events/SentinelSynchronized.php @@ -0,0 +1,36 @@ +teamId = $server->team_id; + $this->serverUuid = $server->uuid; + } + + public function broadcastOn(): array + { + if (is_null($this->teamId)) { + return []; + } + + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 81b932365d..3d3978d1c8 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Events\SentinelSynchronized; use App\Http\Controllers\Controller; use App\Jobs\PushServerUpdateJob; use App\Models\Server; @@ -92,9 +93,16 @@ class SentinelController extends Controller $data = $request->all(); + $wasSentinelLive = $server->sentinel_updated_at !== null && $server->isSentinelLive(); + // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. + $server->sentinel_waiting_since = null; $server->sentinelHeartbeat(); + if (! $wasSentinelLive) { + SentinelSynchronized::dispatch($server); + } + if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); } diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index e56b93c9e5..3ef5d2c127 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -22,6 +22,8 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 60; + private ?array $previousOutdatedInfo = null; + /** * Create a new job instance. */ @@ -36,6 +38,7 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { $this->server->refresh(); + $this->previousOutdatedInfo = $this->server->traefik_outdated_info; $this->clearOutdatedInfo(); if ($this->server->proxyType() !== ProxyTypes::TRAEFIK->value || $this->server->proxy->get('status') !== ProxyStatus::RUNNING->value) { @@ -106,12 +109,10 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue // Always check for newer branches first $newerBranchInfo = $this->getNewerBranchInfo($currentBranch); - if (version_compare($current, $latest, '<')) { - // Patch update available - $this->storeOutdatedInfo($current, $latest, 'patch_update', null, $newerBranchInfo); - } elseif ($newerBranchInfo) { - // Only newer branch available (no patch update) + if ($newerBranchInfo) { $this->storeOutdatedInfo($current, $newerBranchInfo['latest'], 'minor_upgrade', $newerBranchInfo['target']); + } elseif (version_compare($current, $latest, '<')) { + $this->storeOutdatedInfo($current, $latest, 'patch_update'); } else { // Fully up to date $this->server->update(['traefik_outdated_info' => null]); @@ -158,10 +159,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue } /** - * Store outdated information in database and send immediate notification. + * Store outdated information and notify for minor or major upgrades. */ - private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void + private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null): void { + $previousOutdatedInfo = $this->previousOutdatedInfo ?? $this->server->traefik_outdated_info; $outdatedInfo = [ 'current' => $current, 'latest' => $latest, @@ -174,16 +176,16 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue $outdatedInfo['upgrade_target'] = $upgradeTarget; } - // If there's a newer branch available (even for patch updates), include that info - if ($newerBranchInfo) { - $outdatedInfo['newer_branch_target'] = $newerBranchInfo['target']; - $outdatedInfo['newer_branch_latest'] = $newerBranchInfo['latest']; - } - $this->server->update(['traefik_outdated_info' => $outdatedInfo]); - // Send immediate notification to the team - $this->sendNotification($outdatedInfo); + $isRepeatedUpgrade = ($previousOutdatedInfo['type'] ?? null) === $type + && ($previousOutdatedInfo['upgrade_target'] ?? null) === $upgradeTarget; + + if ($type !== 'patch_update' && ! $isRepeatedUpgrade) { + $this->sendNotification($outdatedInfo); + } + + $this->previousOutdatedInfo = $outdatedInfo; } /** diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index d25bbab374..54562407aa 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -487,6 +487,9 @@ class General extends Component if ($this->isContainerLabelReadonlyEnabled) { $this->resetDefaultLabels(false); } + if ($oldPortsExposes !== $this->portsExposes) { + $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); + } $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); @@ -885,6 +888,9 @@ class General extends Component $this->application->save(); $this->application->refresh(); $this->syncData(); + if ($oldPortsExposes !== $this->portsExposes) { + $this->dispatch('applicationNetworkingUpdated')->to(InternalAccess::class); + } $showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!'); } catch (\Throwable $e) { $this->application->refresh(); diff --git a/app/Livewire/Project/Application/InternalAccess.php b/app/Livewire/Project/Application/InternalAccess.php index 827cafd250..9fc1db43a8 100644 --- a/app/Livewire/Project/Application/InternalAccess.php +++ b/app/Livewire/Project/Application/InternalAccess.php @@ -8,12 +8,21 @@ use Livewire\Component; class InternalAccess extends Component { + protected $listeners = [ + 'applicationNetworkingUpdated' => 'refreshApplicationNetworking', + ]; + public Application $application; public ?string $currentInternalHostname = null; public bool $currentInternalHostnameLoaded = false; + public function refreshApplicationNetworking(): void + { + $this->application->refresh(); + } + public function loadCurrentInternalHostname(): void { try { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index d9f70ea253..342349d50b 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -10,6 +10,7 @@ use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Carbon; use Livewire\Component; class Navbar extends Component @@ -34,14 +35,18 @@ class Navbar extends Component public array $serverSwitcherOptions = []; + public ?bool $sentinelWarningOverride = null; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; return [ 'refreshServerShow' => 'refreshServer', + 'sentinel-restart-requested' => 'hideSentinelWarning', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', "echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus', + "echo-private:team.{$teamId},SentinelSynchronized" => 'refreshSentinelStatus', ]; } @@ -247,6 +252,31 @@ class Navbar extends Component } $this->refreshServer(); + $this->sentinelWarningOverride = null; + $sentinelStatus = $this->server->sentinelStatus(); + $sentinelStatusStartedAt = $this->server->sentinel_waiting_since ?? Carbon::parse($this->server->sentinel_updated_at); + $sentinelTimeoutSeconds = $this->server->sentinel_waiting_since !== null + ? $this->server->firstSentinelReportTimeoutSeconds() + : $this->server->waitBeforeDoingSshCheck(); + $expiresInMilliseconds = max( + 0, + ($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000 + ); + $this->dispatch( + 'sentinel-status-changed', + outOfSync: $this->server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync', + expiresInMilliseconds: $expiresInMilliseconds, + ); + } + + public function hideSentinelWarning(): void + { + $this->sentinelWarningOverride = false; + } + + public function refreshAgentStatus(): void + { + $this->refreshSentinelStatus(); } /** diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index f07799fbe5..09b123dcc3 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -20,6 +20,10 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; + public string $sentinelStatus = 'out_of_sync'; + + public ?int $sentinelRestartRequestedAt = null; + #[Validate(['required', 'integer', 'min:1'])] public int|string $sentinelMetricsRefreshRateSeconds; @@ -42,6 +46,7 @@ class Sentinel extends Component return [ "echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted', + "echo-private:team.{$teamId},SentinelSynchronized" => 'handleSentinelSynchronized', ]; } @@ -71,6 +76,7 @@ class Sentinel extends Component $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = $this->server->sentinelStatus(); } } @@ -81,14 +87,52 @@ class Sentinel extends Component // Only refresh display-only state; never re-sync text-input properties // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = $this->server->sentinelStatus(); + $this->sentinelRestartRequestedAt = null; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } + public function handleSentinelSynchronized($event): void + { + if ($event['serverUuid'] === $this->server->uuid) { + $this->server->refresh(); + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = 'in_sync'; + $this->sentinelRestartRequestedAt = null; + } + } + + public function refreshSentinelStatus(): void + { + if ($this->sentinelStatus === 'restarting' + && $this->sentinelRestartRequestedAt !== null + && $this->sentinelRestartRequestedAt > now()->subSeconds($this->server->firstSentinelReportTimeoutSeconds())->timestamp) { + return; + } + + $this->server->refresh(); + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; + $this->sentinelStatus = $this->server->sentinelStatus(); + } + + private function setSentinelRestarting(): void + { + $this->sentinelStatus = 'restarting'; + $this->sentinelRestartRequestedAt = now()->timestamp; + $this->dispatch( + 'sentinel-status-changed', + outOfSync: false, + expiresInMilliseconds: $this->server->firstSentinelReportTimeoutSeconds() * 1000, + ); + $this->dispatch('sentinel-restart-requested'); + } + public function restartSentinel() { try { $this->authorize('manageSentinel', $this->server); + $this->setSentinelRestarting(); $customImage = isDev() ? $this->sentinelCustomDockerImage : null; $this->server->restartSentinel($customImage); $this->dispatch('info', 'Restarting Sentinel.'); @@ -101,6 +145,7 @@ class Sentinel extends Component { try { $this->authorize('manageSentinel', $this->server); + $this->setSentinelRestarting(); $this->server->settings->generateSentinelToken(); $this->dispatch('success', 'Token regenerated. Restarting Sentinel.'); } catch (\Throwable $e) { @@ -108,10 +153,29 @@ class Sentinel extends Component } } + public function restoreDefaultConfiguration(?string $password = null): void + { + try { + $this->authorize('manageSentinel', $this->server); + + $this->server->settings->restoreDefaultSentinelConfiguration(); + + $this->sentinelCustomDockerImage = null; + $this->syncData(); + $this->dispatch('sentinel-defaults-restored'); + $this->setSentinelRestarting(); + $this->server->restartSentinel(); + $this->dispatch('success', 'Default Sentinel configuration restored. Restarting Sentinel.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + public function submit() { try { $this->authorize('update', $this->server); + $this->setSentinelRestarting(); $this->syncData(true); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.'); } catch (\Throwable $e) { diff --git a/app/Models/Server.php b/app/Models/Server.php index 6795c4ac90..6db5368da9 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -264,6 +264,7 @@ class Server extends BaseModel 'unreachable_notification_sent' => 'boolean', 'is_build_server' => 'boolean', 'force_disabled' => 'boolean', + 'sentinel_waiting_since' => 'datetime', ]; /** @@ -967,11 +968,27 @@ $siteAddress { return $wait; } + public function firstSentinelReportTimeoutSeconds(): int + { + return max(30, $this->settings->sentinel_push_interval_seconds + 30); + } + public function isSentinelLive() { return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck())); } + public function sentinelStatus(): string + { + if ($this->sentinel_waiting_since !== null) { + return $this->sentinel_waiting_since->isAfter(now()->subSeconds($this->firstSentinelReportTimeoutSeconds())) + ? 'waiting' + : 'out_of_sync'; + } + + return $this->isSentinelLive() ? 'in_sync' : 'out_of_sync'; + } + public function isSentinelEnabled(): bool { return ! $this->isBuildServer() diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index c3fa8721c4..e3500b732d 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -60,6 +60,12 @@ use OpenApi\Attributes as OA; )] class ServerSetting extends Model { + public const int DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS = 10; + + public const int DEFAULT_SENTINEL_METRICS_HISTORY_DAYS = 7; + + public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60; + protected $fillable = [ 'server_id', 'is_swarm_manager', @@ -236,6 +242,10 @@ class ServerSetting extends Model { $url = $this->sentinel_custom_url; + if ($this->server->isLocalhost() && $url === 'http://host.docker.internal:8000') { + $url = null; + } + if (blank($url)) { $url = $this->generateSentinelUrl(ignoreEvent: true); } @@ -247,12 +257,22 @@ class ServerSetting extends Model return $url; } + public function restoreDefaultSentinelConfiguration(): void + { + $this->generateSentinelUrl(save: false, ignoreEvent: true); + $this->sentinel_metrics_refresh_rate_seconds = self::DEFAULT_SENTINEL_METRICS_REFRESH_RATE_SECONDS; + $this->sentinel_metrics_history_days = self::DEFAULT_SENTINEL_METRICS_HISTORY_DAYS; + $this->sentinel_push_interval_seconds = self::DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS; + $this->is_sentinel_debug_enabled = false; + $this->saveQuietly(); + } + public function generateSentinelUrl(bool $save = true, bool $ignoreEvent = false): ?string { $domain = null; $settings = InstanceSettings::get(); if ($this->server->isLocalhost()) { - $domain = 'http://host.docker.internal:8000'; + $domain = 'http://coolify:8080'; } elseif ($settings->fqdn) { $domain = $settings->fqdn; } elseif ($settings->public_ipv4) { diff --git a/config/constants.php b/config/constants.php index 6a37550e4f..f06e3d065a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.21', + 'version' => env('COOLIFY_VERSION') ?: '4.3.22', 'helper_version' => '1.0.17', 'realtime_version' => '1.0.19', 'railpack_version' => '0.23.0', diff --git a/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php b/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php new file mode 100644 index 0000000000..cf3bfe6e6f --- /dev/null +++ b/database/migrations/2026_09_16_102243_add_sentinel_waiting_since_to_servers_table.php @@ -0,0 +1,28 @@ +timestamp('sentinel_waiting_since')->nullable()->after('sentinel_updated_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('sentinel_waiting_since'); + }); + } +}; diff --git a/other/nightly/versions.json b/other/nightly/versions.json index e010c30695..ffc5266d26 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.21" + "version": "4.3.22" }, "nightly": { "version": "4.4-rc.1" diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 46e433e8a5..4b652bd51b 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -2,6 +2,15 @@ @php $serverRouteParameters = ['server_uuid' => $server->uuid]; + $sentinelStatus = $server->sentinelStatus(); + $sentinelStatusStartedAt = $server->sentinel_waiting_since ?? \Illuminate\Support\Carbon::parse($server->sentinel_updated_at); + $sentinelTimeoutSeconds = $server->sentinel_waiting_since !== null + ? $server->firstSentinelReportTimeoutSeconds() + : $server->waitBeforeDoingSshCheck(); + $sentinelExpiresInMilliseconds = max( + 0, + ($sentinelStatusStartedAt->copy()->addSeconds($sentinelTimeoutSeconds)->timestamp - now()->timestamp) * 1000, + ); $serverMenuItems = [ [ 'label' => 'General', @@ -70,7 +79,8 @@ 'icon' => 'shield-star', 'group' => 'Platform', 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server), - 'warning' => $server->isSentinelEnabled() && ! $server->isSentinelLive(), + 'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync', + 'tracks_sentinel_status' => true, 'children' => [ ['label' => 'Configuration', 'route' => 'server.sentinel', 'active' => request()->routeIs('server.sentinel'), 'icon' => 'settings'], ['label' => 'Logs', 'route' => 'server.sentinel.logs', 'active' => request()->routeIs('server.sentinel.logs'), 'icon' => 'file-content'], @@ -173,11 +183,24 @@