From f81640e316f3864bb0e40236c971d95e9aa9b04e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:24:11 +0100 Subject: [PATCH 01/17] fix: correct status for services with all containers excluded from health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all services in a Docker Compose file have `exclude_from_hc: true`, the status aggregation logic was returning invalid states causing broken UI. **Problems fixed:** - ComplexStatusCheck returned 'running:healthy' for apps with no monitored containers - Service model returned ':' (null status) when all services excluded - UI showed active start/stop buttons for non-running services **Changes:** - ComplexStatusCheck: Return 'exited:healthy' when relevantContainerCount is 0 - Service model: Return 'exited:healthy' when both status and health are null - Added comprehensive unit tests to verify the fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Actions/Shared/ComplexStatusCheck.php | 2 +- app/Models/Service.php | 5 ++ tests/Unit/ExcludeFromHealthCheckTest.php | 59 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/ExcludeFromHealthCheckTest.php diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index e06136e3c..fbaa8cae5 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -114,7 +114,7 @@ private function aggregateContainerStatuses($application, $containers) } if ($relevantContainerCount === 0) { - return 'running:healthy'; + return 'exited:healthy'; } if ($hasRestarting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index ef755d105..15ee2d1bc 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -244,6 +244,11 @@ public function getStatusAttribute() } } + // If all services are excluded from status checks, return a default exited status + if ($complexStatus === null && $complexHealth === null) { + return 'exited:healthy'; + } + return "{$complexStatus}:{$complexHealth}"; } diff --git a/tests/Unit/ExcludeFromHealthCheckTest.php b/tests/Unit/ExcludeFromHealthCheckTest.php new file mode 100644 index 000000000..56da2e6c5 --- /dev/null +++ b/tests/Unit/ExcludeFromHealthCheckTest.php @@ -0,0 +1,59 @@ +toContain("if (\$relevantContainerCount === 0) {\n return 'exited:healthy';\n }") + ->not->toContain("if (\$relevantContainerCount === 0) {\n return 'running:healthy';\n }"); +}); + +it('ensures Service model returns exited status when all services excluded', function () { + $serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php'); + + // Check that when all services are excluded from status checks, + // the Service model returns 'exited:healthy' instead of ':' (null:null) + expect($serviceModelFile) + ->toContain('// If all services are excluded from status checks, return a default exited status') + ->toContain("if (\$complexStatus === null && \$complexHealth === null) {\n return 'exited:healthy';\n }"); +}); + +it('ensures GetContainersStatus returns null when all containers excluded', function () { + $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Check that when all containers are excluded, the aggregateApplicationStatus + // method returns null to avoid updating status + expect($getContainersStatusFile) + ->toContain('// If all containers are excluded, don\'t update status') + ->toContain("if (\$relevantStatuses->isEmpty()) {\n return null;\n }"); +}); + +it('ensures exclude_from_hc flag is properly checked in ComplexStatusCheck', function () { + $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php'); + + // Verify that exclude_from_hc is properly parsed from docker-compose + expect($complexStatusCheckFile) + ->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);') + ->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {') + ->toContain('$excludedContainers->push($serviceName);'); +}); + +it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', function () { + $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php'); + + // Verify that exclude_from_hc is properly parsed from docker-compose + expect($getContainersStatusFile) + ->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);') + ->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {') + ->toContain('$excludedContainers->push($serviceName);'); +}); From 498b189286c0c2dacacf9d90f9a3e7d8d9d6b4d1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:54:51 +0100 Subject: [PATCH 02/17] fix: correct status for services with all containers excluded from health checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all containers are excluded from health checks, display their actual status with :excluded suffix instead of misleading hardcoded statuses. This prevents broken UI state with incorrect action buttons and provides clarity that monitoring is disabled. 🤖 Generated with Claude Code Co-Authored-By: Claude --- app/Actions/Docker/GetContainersStatus.php | 8 +- app/Actions/Shared/ComplexStatusCheck.php | 51 +++++++- app/Models/Service.php | 78 +++++++++++- .../components/status/services.blade.php | 25 ++-- .../project/service/heading.blade.php | 4 +- tests/Unit/ContainerHealthStatusTest.php | 116 ++++++++++++++++++ tests/Unit/ExcludeFromHealthCheckTest.php | 66 ++++++++-- 7 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/ContainerHealthStatusTest.php diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index c7f4055f0..ef5cc37aa 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -98,11 +98,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $labels = data_get($container, 'Config.Labels'); } $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containerHealth = data_get($container, 'State.Health.Status'); if ($containerStatus === 'restarting') { - $containerStatus = "restarting ($containerHealth)"; + $healthSuffix = $containerHealth ?? 'unknown'; + $containerStatus = "restarting ($healthSuffix)"; } else { - $containerStatus = "$containerStatus ($containerHealth)"; + $healthSuffix = $containerHealth ?? 'unknown'; + $containerStatus = "$containerStatus ($healthSuffix)"; } $labels = Arr::undot(format_docker_labels_to_json($labels)); $applicationId = data_get($labels, 'coolify.applicationId'); diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index fbaa8cae5..1013c73e0 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -97,14 +97,14 @@ private function aggregateContainerStatuses($application, $containers) $relevantContainerCount++; $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containerHealth = data_get($container, 'State.Health.Status'); if ($containerStatus === 'restarting') { $hasRestarting = true; $hasUnhealthy = true; } elseif ($containerStatus === 'running') { $hasRunning = true; - if ($containerHealth === 'unhealthy') { + if ($containerHealth && $containerHealth === 'unhealthy') { $hasUnhealthy = true; } } elseif ($containerStatus === 'exited') { @@ -113,8 +113,53 @@ private function aggregateContainerStatuses($application, $containers) } } + // If all containers are excluded, calculate status from excluded containers + // but mark it with :excluded to indicate monitoring is disabled if ($relevantContainerCount === 0) { - return 'exited:healthy'; + $excludedHasRunning = false; + $excludedHasRestarting = false; + $excludedHasUnhealthy = false; + $excludedHasExited = false; + + foreach ($containers as $container) { + $labels = data_get($container, 'Config.Labels', []); + $serviceName = data_get($labels, 'com.docker.compose.service'); + + // Only process excluded containers + if (! $serviceName || ! $excludedContainers->contains($serviceName)) { + continue; + } + + $containerStatus = data_get($container, 'State.Status'); + $containerHealth = data_get($container, 'State.Health.Status'); + + if ($containerStatus === 'restarting') { + $excludedHasRestarting = true; + $excludedHasUnhealthy = true; + } elseif ($containerStatus === 'running') { + $excludedHasRunning = true; + if ($containerHealth && $containerHealth === 'unhealthy') { + $excludedHasUnhealthy = true; + } + } elseif ($containerStatus === 'exited') { + $excludedHasExited = true; + $excludedHasUnhealthy = true; + } + } + + if ($excludedHasRestarting) { + return 'degraded:excluded'; + } + + if ($excludedHasRunning && $excludedHasExited) { + return 'degraded:excluded'; + } + + if ($excludedHasRunning) { + return 'running:excluded'; + } + + return 'exited:excluded'; } if ($hasRestarting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 15ee2d1bc..c98c20121 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -184,11 +184,13 @@ public function getStatusAttribute() $complexStatus = null; $complexHealth = null; + $hasNonExcluded = false; foreach ($applications as $application) { if ($application->exclude_from_status) { continue; } + $hasNonExcluded = true; $status = str($application->status)->before('(')->trim(); $health = str($application->status)->between('(', ')')->trim(); if ($complexStatus === 'degraded') { @@ -218,6 +220,7 @@ public function getStatusAttribute() if ($database->exclude_from_status) { continue; } + $hasNonExcluded = true; $status = str($database->status)->before('(')->trim(); $health = str($database->status)->between('(', ')')->trim(); if ($complexStatus === 'degraded') { @@ -244,9 +247,78 @@ public function getStatusAttribute() } } - // If all services are excluded from status checks, return a default exited status - if ($complexStatus === null && $complexHealth === null) { - return 'exited:healthy'; + // If all services are excluded from status checks, calculate status from excluded containers + // but mark it with :excluded to indicate monitoring is disabled + if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) { + $excludedStatus = null; + $excludedHealth = null; + + // Calculate status from excluded containers + foreach ($applications as $application) { + $status = str($application->status)->before('(')->trim(); + $health = str($application->status)->between('(', ')')->trim(); + if ($excludedStatus === 'degraded') { + continue; + } + if ($status->startsWith('running')) { + if ($excludedStatus === 'exited') { + $excludedStatus = 'degraded'; + } else { + $excludedStatus = 'running'; + } + } elseif ($status->startsWith('restarting')) { + $excludedStatus = 'degraded'; + } elseif ($status->startsWith('exited')) { + $excludedStatus = 'exited'; + } + if ($health->value() === 'healthy') { + if ($excludedHealth === 'unhealthy') { + continue; + } + $excludedHealth = 'healthy'; + } else { + $excludedHealth = 'unhealthy'; + } + } + + foreach ($databases as $database) { + $status = str($database->status)->before('(')->trim(); + $health = str($database->status)->between('(', ')')->trim(); + if ($excludedStatus === 'degraded') { + continue; + } + if ($status->startsWith('running')) { + if ($excludedStatus === 'exited') { + $excludedStatus = 'degraded'; + } else { + $excludedStatus = 'running'; + } + } elseif ($status->startsWith('restarting')) { + $excludedStatus = 'degraded'; + } elseif ($status->startsWith('exited')) { + $excludedStatus = 'exited'; + } + if ($health->value() === 'healthy') { + if ($excludedHealth === 'unhealthy') { + continue; + } + $excludedHealth = 'healthy'; + } else { + $excludedHealth = 'unhealthy'; + } + } + + // Return status with :excluded suffix to indicate monitoring is disabled + if ($excludedStatus && $excludedHealth) { + return "{$excludedStatus}:excluded"; + } + + // If no status was calculated at all (no containers exist), return unknown + if ($excludedStatus === null && $excludedHealth === null) { + return 'unknown:excluded'; + } + + return 'exited:excluded'; } return "{$complexStatus}:{$complexHealth}"; diff --git a/resources/views/components/status/services.blade.php b/resources/views/components/status/services.blade.php index 7ea55099f..87db0d64c 100644 --- a/resources/views/components/status/services.blade.php +++ b/resources/views/components/status/services.blade.php @@ -1,13 +1,20 @@ -@if (str($complexStatus)->contains('running')) - -@elseif(str($complexStatus)->contains('starting')) - -@elseif(str($complexStatus)->contains('restarting')) - -@elseif(str($complexStatus)->contains('degraded')) - +@php + $isExcluded = str($complexStatus)->endsWith(':excluded'); + $displayStatus = $isExcluded ? str($complexStatus)->beforeLast(':excluded') : $complexStatus; +@endphp +@if (str($displayStatus)->contains('running')) + +@elseif(str($displayStatus)->contains('starting')) + +@elseif(str($displayStatus)->contains('restarting')) + +@elseif(str($displayStatus)->contains('degraded')) + @else - + +@endif +@if ($isExcluded) + (Monitoring Disabled) @endif @if (!str($complexStatus)->contains('exited') && $showRefreshButton)