- @if (str($service->status)->contains('running'))
+ @if (str($service->status)->contains('running') || (str($service->status)->startsWith('running:') && !str($service->status)->contains('exited')))
diff --git a/tests/Unit/ContainerHealthStatusTest.php b/tests/Unit/ContainerHealthStatusTest.php
new file mode 100644
index 000000000..98328630d
--- /dev/null
+++ b/tests/Unit/ContainerHealthStatusTest.php
@@ -0,0 +1,116 @@
+makePartial();
+ $server = Mockery::mock('App\Models\Server')->makePartial();
+ $destination = Mockery::mock('App\Models\StandaloneDocker')->makePartial();
+
+ $destination->shouldReceive('getAttribute')
+ ->with('server')
+ ->andReturn($server);
+
+ $application->shouldReceive('getAttribute')
+ ->with('destination')
+ ->andReturn($destination);
+
+ $application->shouldReceive('getAttribute')
+ ->with('additional_servers')
+ ->andReturn(collect());
+
+ $server->shouldReceive('getAttribute')
+ ->with('id')
+ ->andReturn(1);
+
+ $server->shouldReceive('isFunctional')
+ ->andReturn(true);
+
+ // Create a container without health check (State.Health.Status is null)
+ $containerWithoutHealthCheck = [
+ 'Config' => [
+ 'Labels' => [
+ 'com.docker.compose.service' => 'web',
+ ],
+ ],
+ 'State' => [
+ 'Status' => 'running',
+ // Note: State.Health.Status is intentionally missing
+ ],
+ ];
+
+ // Mock the remote process to return our container
+ $application->shouldReceive('getAttribute')
+ ->with('id')
+ ->andReturn(123);
+
+ // We can't easily test the private aggregateContainerStatuses method directly,
+ // but we can verify that the code doesn't default to 'unhealthy'
+ $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
+
+ // Verify the fix: health status should not default to 'unhealthy'
+ expect($complexStatusCheckFile)
+ ->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
+ ->toContain("data_get(\$container, 'State.Health.Status')");
+
+ // Verify the null check exists for non-excluded containers
+ expect($complexStatusCheckFile)
+ ->toContain('if ($containerHealth && $containerHealth === \'unhealthy\') {');
+});
+
+it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
+ $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
+
+ // For non-excluded containers (line ~107)
+ expect($complexStatusCheckFile)
+ ->toContain('if ($containerHealth && $containerHealth === \'unhealthy\') {')
+ ->toContain('$hasUnhealthy = true;');
+
+ // For excluded containers (line ~141)
+ expect($complexStatusCheckFile)
+ ->toContain('if ($containerHealth && $containerHealth === \'unhealthy\') {')
+ ->toContain('$excludedHasUnhealthy = true;');
+});
+
+it('handles missing health status correctly in GetContainersStatus', function () {
+ $getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
+
+ // Verify health status doesn't default to 'unhealthy'
+ expect($getContainersStatusFile)
+ ->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
+ ->toContain("data_get(\$container, 'State.Health.Status')");
+
+ // Verify it uses 'unknown' when health status is missing
+ expect($getContainersStatusFile)
+ ->toContain('$healthSuffix = $containerHealth ?? \'unknown\';');
+});
+
+it('treats containers with running status and no healthcheck as not unhealthy', function () {
+ $complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
+
+ // The logic should be:
+ // 1. Get health status (may be null)
+ // 2. Only mark as unhealthy if health status EXISTS and equals 'unhealthy'
+ // 3. Don't mark as unhealthy if health status is null/missing
+
+ // Verify the condition requires both health to exist AND be unhealthy
+ expect($complexStatusCheckFile)
+ ->toContain('if ($containerHealth && $containerHealth === \'unhealthy\')');
+
+ // Verify this check is done for running containers
+ expect($complexStatusCheckFile)
+ ->toContain('} elseif ($containerStatus === \'running\') {')
+ ->toContain('$hasRunning = true;');
+});
diff --git a/tests/Unit/ExcludeFromHealthCheckTest.php b/tests/Unit/ExcludeFromHealthCheckTest.php
index 56da2e6c5..2c58b0caa 100644
--- a/tests/Unit/ExcludeFromHealthCheckTest.php
+++ b/tests/Unit/ExcludeFromHealthCheckTest.php
@@ -5,27 +5,49 @@
* excluded from health checks (exclude_from_hc: true) show correct status.
*
* These tests verify the fix for the issue where services with all containers
- * excluded would show incorrect "running:healthy" or ":" status, causing
- * broken UI state with active start/stop buttons.
+ * excluded would show incorrect status, causing broken UI state.
+ *
+ * The fix now returns status with :excluded suffix to show real container state
+ * while indicating monitoring is disabled (e.g., "running:excluded").
*/
-it('ensures ComplexStatusCheck returns exited status when all containers excluded', function () {
+it('ensures ComplexStatusCheck returns excluded status when all containers excluded', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
- // Check that when all containers are excluded (relevantContainerCount === 0),
- // the status is set to 'exited:healthy' instead of 'running:healthy'
+ // Check that when all containers are excluded, the status calculation
+ // processes excluded containers and returns status with :excluded suffix
expect($complexStatusCheckFile)
- ->toContain("if (\$relevantContainerCount === 0) {\n return 'exited:healthy';\n }")
- ->not->toContain("if (\$relevantContainerCount === 0) {\n return 'running:healthy';\n }");
+ ->toContain('// If all containers are excluded, calculate status from excluded containers')
+ ->toContain('// but mark it with :excluded to indicate monitoring is disabled')
+ ->toContain('if ($relevantContainerCount === 0) {')
+ ->toContain("return 'running:excluded';")
+ ->toContain("return 'degraded:excluded';")
+ ->toContain("return 'exited:excluded';");
});
-it('ensures Service model returns exited status when all services excluded', function () {
+it('ensures Service model returns excluded 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)
+ // the Service model calculates real status and returns it with :excluded suffix
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 }");
+ ->toContain('// If all services are excluded from status checks, calculate status from excluded containers')
+ ->toContain('// but mark it with :excluded to indicate monitoring is disabled')
+ ->toContain('if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {')
+ ->toContain('// Calculate status from excluded containers')
+ ->toContain('return "{$excludedStatus}:excluded";')
+ ->toContain("return 'exited:excluded';");
+});
+
+it('ensures Service model returns unknown:excluded when no containers exist', function () {
+ $serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
+
+ // Check that when a service has no applications or databases at all,
+ // the Service model returns 'unknown:excluded' instead of 'exited:excluded'
+ // This prevents misleading status display when containers don't exist
+ expect($serviceModelFile)
+ ->toContain('// If no status was calculated at all (no containers exist), return unknown')
+ ->toContain('if ($excludedStatus === null && $excludedHealth === null) {')
+ ->toContain("return 'unknown:excluded';");
});
it('ensures GetContainersStatus returns null when all containers excluded', function () {
@@ -57,3 +79,25 @@
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {')
->toContain('$excludedContainers->push($serviceName);');
});
+
+it('ensures UI displays excluded status correctly in status component', function () {
+ $servicesStatusFile = file_get_contents(__DIR__.'/../../resources/views/components/status/services.blade.php');
+
+ // Verify that the status component detects :excluded suffix and shows monitoring disabled message
+ expect($servicesStatusFile)
+ ->toContain('$isExcluded = str($complexStatus)->endsWith(\':excluded\');')
+ ->toContain('$displayStatus = $isExcluded ? str($complexStatus)->beforeLast(\':excluded\') : $complexStatus;')
+ ->toContain('(Monitoring Disabled)');
+});
+
+it('ensures UI handles excluded status in service heading buttons', function () {
+ $headingFile = file_get_contents(__DIR__.'/../../resources/views/livewire/project/service/heading.blade.php');
+
+ // Verify that the heading properly handles running/degraded/exited status with :excluded suffix
+ // The logic should use contains() to match the base status (running, degraded, exited)
+ // which will work for both regular statuses and :excluded suffixed ones
+ expect($headingFile)
+ ->toContain('str($service->status)->contains(\'running\')')
+ ->toContain('str($service->status)->contains(\'degraded\')')
+ ->toContain('str($service->status)->contains(\'exited\')');
+});