fix: don't show health status for exited containers

Exited containers don't run health checks, so showing "(unhealthy)" is
misleading. This fix ensures exited status displays without health
suffixes across all monitoring systems (SSH, Sentinel, services, etc.)
and at the UI layer for backward compatibility with existing data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2025-11-24 09:09:08 +01:00
parent 0061acaddf
commit ac9eca3c05
11 changed files with 65 additions and 48 deletions

View File

@@ -136,7 +136,7 @@ it('ensures excluded status format is consistent across all paths', function ()
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain("return 'exited';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded, running:unhealthy:excluded, etc.
});
@@ -179,7 +179,7 @@ it('ensures calculateExcludedStatus uses ContainerStatusAggregator', function ()
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain("return 'exited';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});
@@ -199,7 +199,7 @@ it('ensures calculateExcludedStatusFromStrings uses ContainerStatusAggregator',
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain("return 'exited';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});

View File

@@ -8,10 +8,10 @@ beforeEach(function () {
});
describe('aggregateFromStrings', function () {
test('returns exited:unhealthy for empty collection', function () {
test('returns exited for empty collection', function () {
$result = $this->aggregator->aggregateFromStrings(collect());
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
test('returns running:healthy for single healthy running container', function () {
@@ -78,12 +78,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('degraded:unhealthy');
});
test('returns exited:unhealthy for exited containers without restart count', function () {
test('returns exited for exited containers without restart count', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
test('returns degraded:unhealthy for dead container', function () {
@@ -200,10 +200,10 @@ describe('aggregateFromStrings', function () {
});
describe('aggregateFromContainers', function () {
test('returns exited:unhealthy for empty collection', function () {
test('returns exited for empty collection', function () {
$result = $this->aggregator->aggregateFromContainers(collect());
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
test('returns running:healthy for single healthy running container', function () {
@@ -299,7 +299,7 @@ describe('aggregateFromContainers', function () {
expect($result)->toBe('degraded:unhealthy');
});
test('returns exited:unhealthy for exited containers without restart count', function () {
test('returns exited for exited containers without restart count', function () {
$containers = collect([
(object) [
'State' => (object) [
@@ -310,7 +310,7 @@ describe('aggregateFromContainers', function () {
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 0);
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
test('returns degraded:unhealthy for dead container', function () {
@@ -473,8 +473,8 @@ describe('maxRestartCount validation', function () {
// With negative value, should be treated as 0 (no restarts)
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: -5);
// Should return exited:unhealthy (not degraded) since corrected to 0
expect($result)->toBe('exited:unhealthy');
// Should return exited (not degraded) since corrected to 0
expect($result)->toBe('exited');
});
test('negative maxRestartCount is corrected to 0 in aggregateFromContainers', function () {
@@ -493,8 +493,8 @@ describe('maxRestartCount validation', function () {
// With negative value, should be treated as 0 (no restarts)
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: -10);
// Should return exited:unhealthy (not degraded) since corrected to 0
expect($result)->toBe('exited:unhealthy');
// Should return exited (not degraded) since corrected to 0
expect($result)->toBe('exited');
});
test('zero maxRestartCount works correctly', function () {
@@ -503,7 +503,7 @@ describe('maxRestartCount validation', function () {
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
// Zero is valid default - no crash loop detection
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
test('positive maxRestartCount works correctly', function () {
@@ -535,6 +535,6 @@ describe('maxRestartCount validation', function () {
// Call without specifying maxRestartCount (should default to 0)
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('exited:unhealthy');
expect($result)->toBe('exited');
});
});

View File

@@ -28,7 +28,7 @@ it('ensures ComplexStatusCheck returns excluded status when all containers exclu
->toContain('$aggregator->aggregateFromContainers($excludedOnly)')
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'exited:excluded';")
->toContain("return 'exited';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});
@@ -47,7 +47,7 @@ it('ensures Service model returns unknown:unknown:excluded when no containers ex
$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:unknown:excluded' instead of 'exited:unhealthy:excluded'
// the Service model returns 'unknown:unknown:excluded' instead of 'exited'
// 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')
@@ -85,12 +85,9 @@ it('ensures exclude_from_hc flag is properly checked in GetContainersStatus', fu
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 transforms :excluded suffix to (excluded) for better display
// Verify that the status component uses formatContainerStatus helper to display status
expect($servicesStatusFile)
->toContain('$isExcluded = str($complexStatus)->endsWith(\':excluded\');')
->toContain('$parts = explode(\':\', $complexStatus);')
->toContain('// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)')
->toContain('// No health status: exited:excluded → Exited (excluded)');
->toContain('formatContainerStatus($complexStatus)');
});
it('ensures UI handles excluded status in service heading buttons', function () {

View File

@@ -73,7 +73,7 @@ describe('Service Excluded Status Calculation', function () {
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
$app2 = makeResource('exited', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
@@ -87,7 +87,7 @@ describe('Service Excluded Status Calculation', function () {
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: false);
$app2 = makeResource('exited', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
@@ -224,7 +224,7 @@ describe('Service Excluded Status Calculation', function () {
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy:excluded', excludeFromStatus: false);
$app2 = makeResource('exited:excluded', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
@@ -245,7 +245,7 @@ describe('Service Excluded Status Calculation', function () {
expect($service->status)->toBe('running:healthy:excluded');
});
it('returns exited:unhealthy:excluded when excluded containers have no valid status', function () {
it('returns exited when excluded containers have no valid status', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
@@ -254,7 +254,7 @@ describe('Service Excluded Status Calculation', function () {
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('exited:unhealthy:excluded');
expect($service->status)->toBe('exited');
});
it('handles all excluded containers with degraded state', function () {
@@ -262,7 +262,7 @@ describe('Service Excluded Status Calculation', function () {
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: true);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
$app2 = makeResource('exited', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
@@ -286,12 +286,12 @@ describe('Service Excluded Status Calculation', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('exited:unhealthy', excludeFromStatus: false);
$app1 = makeResource('exited', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('exited:unhealthy');
expect($service->status)->toBe('exited');
});
it('prefers running over starting status', function () {