fix: correct status for services with all containers excluded from health checks

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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-11-19 10:54:51 +01:00
parent f81640e316
commit 498b189286
7 changed files with 317 additions and 31 deletions

View File

@@ -0,0 +1,116 @@
<?php
use App\Models\Application;
use Mockery;
/**
* Unit tests to verify that containers without health checks are not
* incorrectly marked as unhealthy.
*
* This tests the fix for the issue where defaulting missing health status
* to 'unhealthy' would treat containers without healthchecks as unhealthy.
*
* The fix removes the 'unhealthy' default and only checks health status
* when it explicitly exists and equals 'unhealthy'.
*/
it('does not mark containers as unhealthy when health status is missing', function () {
// Mock an application with a server
$application = Mockery::mock(Application::class)->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;');
});

View File

@@ -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 @@ it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', fu
->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\')');
});