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:
parent
0061acaddf
commit
ac9eca3c05
11 changed files with 65 additions and 48 deletions
|
|
@ -107,6 +107,8 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
if ($containerStatus === 'restarting') {
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "restarting:$healthSuffix";
|
||||
} elseif ($containerStatus === 'exited') {
|
||||
// Keep as-is, no health suffix for exited containers
|
||||
} else {
|
||||
$healthSuffix = $containerHealth ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$healthSuffix";
|
||||
|
|
@ -332,7 +334,7 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
|
||||
if ($recentlyRestarted) {
|
||||
// Keep it as degraded if it was recently in a crash loop
|
||||
$application->update(['status' => 'degraded (unhealthy)']);
|
||||
$application->update(['status' => 'degraded:unhealthy']);
|
||||
} else {
|
||||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
|
|
|
|||
|
|
@ -20,11 +20,11 @@ public function handle(Application $application)
|
|||
$is_main_server = $application->destination->server->id === $server->id;
|
||||
if (! $server->isFunctional()) {
|
||||
if ($is_main_server) {
|
||||
$application->update(['status' => 'exited:unhealthy']);
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -49,11 +49,11 @@ public function handle(Application $application)
|
|||
}
|
||||
} else {
|
||||
if ($is_main_server) {
|
||||
$application->update(['status' => 'exited:unhealthy']);
|
||||
$application->update(['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
} else {
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited:unhealthy']);
|
||||
$application->additional_servers()->updateExistingPivot($server->id, ['status' => 'exited']);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,7 +148,10 @@ public function handle()
|
|||
$containerStatus = data_get($container, 'state', 'exited');
|
||||
$rawHealthStatus = data_get($container, 'health_status');
|
||||
$containerHealth = $rawHealthStatus ?? 'unknown';
|
||||
$containerStatus = "$containerStatus:$containerHealth";
|
||||
// Only append health status if container is not exited
|
||||
if ($containerStatus !== 'exited') {
|
||||
$containerStatus = "$containerStatus:$containerHealth";
|
||||
}
|
||||
$labels = collect(data_get($container, 'labels'));
|
||||
$coolify_managed = $labels->has('coolify.managed');
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,12 @@ public function getStatusAttribute()
|
|||
return 'unknown:unknown:excluded';
|
||||
}
|
||||
|
||||
return 'exited:unhealthy:excluded';
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// If health is null/empty, return just the status without trailing colon
|
||||
if ($complexHealth === null || $complexHealth === '') {
|
||||
return $complexStatus;
|
||||
}
|
||||
|
||||
return "{$complexStatus}:{$complexHealth}";
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
* 5. Dead/Removing → degraded:unhealthy
|
||||
* 6. Paused → paused:unknown
|
||||
* 7. Starting/Created → starting:unknown
|
||||
* 8. Exited → exited:unhealthy
|
||||
* 8. Exited → exited
|
||||
*/
|
||||
class ContainerStatusAggregator
|
||||
{
|
||||
|
|
@ -52,7 +52,7 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
|
|||
}
|
||||
|
||||
if ($containerStatuses->isEmpty()) {
|
||||
return 'exited:unhealthy';
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
|
|
@ -79,7 +79,6 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest
|
|||
}
|
||||
} elseif (str($status)->contains('exited')) {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif (str($status)->contains('created') || str($status)->contains('starting')) {
|
||||
$hasStarting = true;
|
||||
} elseif (str($status)->contains('paused')) {
|
||||
|
|
@ -128,7 +127,7 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
|
|||
}
|
||||
|
||||
if ($containers->isEmpty()) {
|
||||
return 'exited:unhealthy';
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// Initialize state flags
|
||||
|
|
@ -157,7 +156,6 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC
|
|||
}
|
||||
} elseif ($state === 'exited') {
|
||||
$hasExited = true;
|
||||
$hasUnhealthy = true;
|
||||
} elseif ($state === 'created' || $state === 'starting') {
|
||||
$hasStarting = true;
|
||||
} elseif ($state === 'paused') {
|
||||
|
|
@ -248,6 +246,6 @@ private function resolveStatus(
|
|||
}
|
||||
|
||||
// Priority 8: All containers exited (no restart count = truly stopped)
|
||||
return 'exited:unhealthy';
|
||||
return 'exited';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ private function appendExcludedSuffix(string $status): string
|
|||
}
|
||||
|
||||
if (str($status)->startsWith('exited')) {
|
||||
return 'exited:excluded';
|
||||
return 'exited';
|
||||
}
|
||||
|
||||
// For running states, keep the health status: "running:healthy:excluded"
|
||||
|
|
|
|||
|
|
@ -4,18 +4,30 @@
|
|||
])
|
||||
@php
|
||||
// Handle both colon format (backend) and parentheses format (from services.blade.php)
|
||||
// exited:unhealthy → Exited (unhealthy)
|
||||
// exited (unhealthy) → exited (unhealthy) (already formatted, display as-is)
|
||||
// For exited containers, health status is hidden (health checks don't run on stopped containers)
|
||||
// exited:unhealthy → Exited
|
||||
// exited (unhealthy) → Exited
|
||||
|
||||
if (str($status)->contains('(')) {
|
||||
// Already in parentheses format from services.blade.php - use as-is
|
||||
$displayStatus = $status;
|
||||
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
|
||||
|
||||
// Don't show health status for exited containers (health checks don't run on stopped containers)
|
||||
if (str($displayStatus)->lower()->contains('exited')) {
|
||||
$displayStatus = str($status)->before('(')->trim()->headline();
|
||||
$healthStatus = null;
|
||||
}
|
||||
} elseif (str($status)->contains(':')) {
|
||||
// Colon format from backend - transform it
|
||||
$parts = explode(':', $status);
|
||||
$displayStatus = str($parts[0])->headline();
|
||||
$healthStatus = $parts[1] ?? null;
|
||||
|
||||
// Don't show health status for exited containers (health checks don't run on stopped containers)
|
||||
if (str($displayStatus)->lower()->contains('exited')) {
|
||||
$healthStatus = null;
|
||||
}
|
||||
} else {
|
||||
// Simple status without health
|
||||
$displayStatus = str($status)->headline();
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@
|
|||
->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 @@
|
|||
->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 @@
|
|||
->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
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
});
|
||||
|
||||
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 @@
|
|||
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('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 @@
|
|||
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 @@
|
|||
|
||||
$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 @@
|
|||
// 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 @@
|
|||
// 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 @@
|
|||
$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 @@
|
|||
// Call without specifying maxRestartCount (should default to 0)
|
||||
$result = $this->aggregator->aggregateFromStrings($statuses);
|
||||
|
||||
expect($result)->toBe('exited:unhealthy');
|
||||
expect($result)->toBe('exited');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
->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 @@
|
|||
$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 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 () {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 @@ function makeResource(string $status, bool $excludeFromStatus = false): object
|
|||
$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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue