feat(tests): add comprehensive tests for ContainerStatusAggregator and serverStatus accessor

- Introduced tests for ContainerStatusAggregator to validate status aggregation logic across various container states.
- Implemented tests to ensure serverStatus accessor correctly checks server infrastructure health without being affected by container status.
- Updated ExcludeFromHealthCheckTest to verify excluded status handling in various components.
- Removed obsolete PushServerUpdateJobStatusAggregationTest as its functionality is covered elsewhere.
- Updated version number for sentinel to 0.0.17 in versions.json.
This commit is contained in:
Andras Bacsai 2025-11-20 17:31:07 +01:00
parent 70fb4c6869
commit ae6eef3cdb
23 changed files with 1590 additions and 912 deletions

View File

@ -514,7 +514,7 @@ All status aggregation locations **MUST** follow the same priority:
### **Excluded Containers**
When containers have `exclude_from_hc: true` flag:
When containers have `exclude_from_hc: true` flag or `restart: no`:
**Behavior**:
- Status is still calculated from container state
@ -525,47 +525,80 @@ When containers have `exclude_from_hc: true` flag:
**Format**: `{actual-status}:excluded`
**Examples**: `running:unknown:excluded`, `degraded:excluded`, `exited:excluded`
**All-Excluded Scenario**:
When ALL containers are excluded from health checks:
- All three status update paths (PushServerUpdateJob, GetContainersStatus, ComplexStatusCheck) **MUST** calculate status from excluded containers
- Status is returned with `:excluded` suffix (e.g., `running:healthy:excluded`)
- **NEVER** skip status updates - always calculate from excluded containers
- This ensures consistent status regardless of which update mechanism runs
- Shared logic is in `app/Traits/CalculatesExcludedStatus.php`
### **Important Notes for Developers**
⚠️ **CRITICAL**: When modifying container status logic:
**Container Status Aggregation Service**:
1. **Update ALL four locations**:
- `GetContainersStatus.php` (SSH-based)
- `PushServerUpdateJob.php` (Sentinel-based)
- `ComplexStatusCheck.php` (multi-server)
- `Service.php` (service-level)
The container status aggregation logic is centralized in `App\Services\ContainerStatusAggregator`.
2. **Maintain consistent priority**:
- unhealthy > unknown > healthy
- Apply same logic across all paths
**Status Format Standard**:
- **Backend/Storage**: Colon format (`running:healthy`, `degraded:unhealthy`)
- **UI/Display**: Transform to human format (`Running (Healthy)`, `Degraded (Unhealthy)`)
1. **Using the ContainerStatusAggregator Service**:
- Import `App\Services\ContainerStatusAggregator` in any class needing status aggregation
- Two methods available:
- `aggregateFromStrings(Collection $statusStrings, int $maxRestartCount = 0)` - For pre-formatted status strings
- `aggregateFromContainers(Collection $containers, int $maxRestartCount = 0)` - For raw Docker container objects
- Returns colon format: `running:healthy`, `degraded:unhealthy`, etc.
- Automatically handles crash loop detection via `$maxRestartCount` parameter
2. **State Machine Priority** (handled by service):
- Restarting → `degraded:unhealthy` (highest priority)
- Crash loop (exited with restarts) → `degraded:unhealthy`
- Mixed state (running + exited) → `degraded:unhealthy`
- Running → `running:unhealthy` / `running:unknown` / `running:healthy`
- Dead/Removing → `degraded:unhealthy`
- Paused → `paused:unknown`
- Starting/Created → `starting:unknown`
- Exited → `exited:unhealthy` (lowest priority)
3. **Test both update paths**:
- Run unit tests: `./vendor/bin/pest tests/Unit/`
- Run unit tests: `./vendor/bin/pest tests/Unit/ContainerStatusAggregatorTest.php`
- Run integration tests: `./vendor/bin/pest tests/Unit/`
- Test SSH updates (manual refresh)
- Test Sentinel updates (wait 30 seconds)
4. **Handle edge cases**:
- All containers excluded (`exclude_from_hc: true`)
- Mixed excluded/non-excluded containers
- Unknown health states
- Container crash loops (restart count)
4. **Handle excluded containers**:
- All containers excluded (`exclude_from_hc: true`) - Use `CalculatesExcludedStatus` trait
- Mixed excluded/non-excluded containers - Filter then use `ContainerStatusAggregator`
- Containers with `restart: no` - Treated same as `exclude_from_hc: true`
5. **Use shared trait for excluded containers**:
- Import `App\Traits\CalculatesExcludedStatus` in status calculation classes
- Use `getExcludedContainersFromDockerCompose()` to parse exclusions
- Use `calculateExcludedStatus()` for full Docker inspect objects (ComplexStatusCheck)
- Use `calculateExcludedStatusFromStrings()` for status strings (PushServerUpdateJob, GetContainersStatus)
### **Related Tests**
- **[tests/Unit/ContainerHealthStatusTest.php](mdc:tests/Unit/ContainerHealthStatusTest.php)**: Health status aggregation
- **[tests/Unit/ContainerStatusAggregatorTest.php](mdc:tests/Unit/ContainerStatusAggregatorTest.php)**: Core state machine logic (42 comprehensive tests)
- **[tests/Unit/ContainerHealthStatusTest.php](mdc:tests/Unit/ContainerHealthStatusTest.php)**: Health status aggregation integration
- **[tests/Unit/PushServerUpdateJobStatusAggregationTest.php](mdc:tests/Unit/PushServerUpdateJobStatusAggregationTest.php)**: Sentinel update logic
- **[tests/Unit/ExcludeFromHealthCheckTest.php](mdc:tests/Unit/ExcludeFromHealthCheckTest.php)**: Excluded container handling
### **Common Bugs to Avoid**
**Bug**: Forgetting to track `$hasUnknown` flag
**Fix**: Initialize and check for "unknown" in all status aggregation
**Prevented by ContainerStatusAggregator Service**:
- ❌ **Old Bug**: Forgetting to track `$hasUnknown` flag → ✅ Now centralized in service
- ❌ **Old Bug**: Inconsistent priority across paths → ✅ Single source of truth
- ❌ **Old Bug**: Forgetting to update all 4 locations → ✅ Only one location to update
**Bug**: Using ternary operator instead of if-elseif-else
**Fix**: Use explicit if-elseif-else to handle 3-way priority
**Still Relevant**:
**Bug**: Updating only one path (SSH or Sentinel)
**Fix**: Always update all four status calculation locations
**Bug**: Forgetting to filter excluded containers before aggregation
**Fix**: Always use `CalculatesExcludedStatus` trait to filter before calling `ContainerStatusAggregator`
**Bug**: Not passing `$maxRestartCount` for crash loop detection
**Fix**: Calculate max restart count from containers and pass to `aggregateFromStrings()`/`aggregateFromContainers()`
**Bug**: Not handling excluded containers with `:excluded` suffix
**Fix**: Check for `:excluded` suffix in UI logic and button visibility

View File

@ -8,6 +8,8 @@ use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@ -16,6 +18,7 @@ use Lorisleiva\Actions\Concerns\AsAction;
class GetContainersStatus
{
use AsAction;
use CalculatesExcludedStatus;
public string $jobQueue = 'high';
@ -103,10 +106,10 @@ class GetContainersStatus
$containerHealth = data_get($container, 'State.Health.Status');
if ($containerStatus === 'restarting') {
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "restarting ($healthSuffix)";
$containerStatus = "restarting:$healthSuffix";
} else {
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "$containerStatus ($healthSuffix)";
$containerStatus = "$containerStatus:$healthSuffix";
}
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
@ -443,106 +446,22 @@ class GetContainersStatus
{
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
return null;
return $this->calculateExcludedStatusFromStrings($containerStatuses);
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
} 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')) {
$hasPaused = true;
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
$hasDead = true;
}
}
if ($hasRestarting) {
return 'degraded (unhealthy)';
}
// If container is exited but has restart count > 0, it's in a crash loop
if ($hasExited && $maxRestartCount > 0) {
return 'degraded (unhealthy)';
}
if ($hasRunning && $hasExited) {
return 'degraded (unhealthy)';
}
if ($hasRunning) {
if ($hasUnhealthy) {
return 'running (unhealthy)';
} elseif ($hasUnknown) {
return 'running (unknown)';
} else {
return 'running (healthy)';
}
}
if ($hasDead) {
return 'degraded (unhealthy)';
}
if ($hasPaused) {
return 'paused (unknown)';
}
if ($hasStarting) {
return 'starting (unknown)';
}
// All containers are exited with no restart count - truly stopped
return 'exited (unhealthy)';
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
}
private function aggregateServiceContainerStatuses($services)
@ -574,93 +493,31 @@ class GetContainersStatus
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$servicesInCompose = data_get($dockerCompose, 'services', []);
foreach ($servicesInCompose as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
}
continue;
}
// Aggregate status using same logic as applications
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
} 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')) {
$hasPaused = true;
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
$hasDead = true;
}
}
$aggregatedStatus = null;
if ($hasRestarting) {
$aggregatedStatus = 'degraded (unhealthy)';
} elseif ($hasRunning && $hasExited) {
$aggregatedStatus = 'degraded (unhealthy)';
} elseif ($hasRunning) {
if ($hasUnhealthy) {
$aggregatedStatus = 'running (unhealthy)';
} elseif ($hasUnknown) {
$aggregatedStatus = 'running (unknown)';
} else {
$aggregatedStatus = 'running (healthy)';
}
} elseif ($hasDead) {
$aggregatedStatus = 'degraded (unhealthy)';
} elseif ($hasPaused) {
$aggregatedStatus = 'paused (unknown)';
} elseif ($hasStarting) {
$aggregatedStatus = 'starting (unknown)';
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus) {

View File

@ -3,11 +3,14 @@
namespace App\Actions\Shared;
use App\Models\Application;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Lorisleiva\Actions\Concerns\AsAction;
class ComplexStatusCheck
{
use AsAction;
use CalculatesExcludedStatus;
public function handle(Application $application)
{
@ -61,179 +64,25 @@ class ComplexStatusCheck
private function aggregateContainerStatuses($application, $containers)
{
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
$relevantContainerCount = 0;
foreach ($containers as $container) {
// Filter non-excluded containers
$relevantContainers = collect($containers)->filter(function ($container) use ($excludedContainers) {
$labels = data_get($container, 'Config.Labels', []);
$serviceName = data_get($labels, 'com.docker.compose.service');
if ($serviceName && $excludedContainers->contains($serviceName)) {
continue;
}
$relevantContainerCount++;
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status');
if ($containerStatus === 'restarting') {
$hasRestarting = true;
$hasUnhealthy = true;
} elseif ($containerStatus === 'running') {
$hasRunning = true;
if ($containerHealth === 'unhealthy') {
$hasUnhealthy = true;
} elseif ($containerHealth === null) {
$hasUnknown = true;
}
} elseif ($containerStatus === 'exited') {
$hasExited = true;
$hasUnhealthy = true;
} elseif ($containerStatus === 'created' || $containerStatus === 'starting') {
$hasStarting = true;
} elseif ($containerStatus === 'paused') {
$hasPaused = true;
} elseif ($containerStatus === 'dead' || $containerStatus === 'removing') {
$hasDead = true;
}
}
return ! ($serviceName && $excludedContainers->contains($serviceName));
});
// If all containers are excluded, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if ($relevantContainerCount === 0) {
$excludedHasRunning = false;
$excludedHasRestarting = false;
$excludedHasUnhealthy = false;
$excludedHasUnknown = false;
$excludedHasExited = false;
$excludedHasStarting = false;
$excludedHasPaused = false;
$excludedHasDead = 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 === 'unhealthy') {
$excludedHasUnhealthy = true;
} elseif ($containerHealth === null) {
$excludedHasUnknown = true;
}
} elseif ($containerStatus === 'exited') {
$excludedHasExited = true;
$excludedHasUnhealthy = true;
} elseif ($containerStatus === 'created' || $containerStatus === 'starting') {
$excludedHasStarting = true;
} elseif ($containerStatus === 'paused') {
$excludedHasPaused = true;
} elseif ($containerStatus === 'dead' || $containerStatus === 'removing') {
$excludedHasDead = true;
}
}
if ($excludedHasRestarting) {
return 'degraded:excluded';
}
if ($excludedHasRunning && $excludedHasExited) {
return 'degraded:excluded';
}
if ($excludedHasRunning) {
if ($excludedHasUnhealthy) {
return 'running:unhealthy:excluded';
} elseif ($excludedHasUnknown) {
return 'running:unknown:excluded';
} else {
return 'running:healthy:excluded';
}
}
if ($excludedHasDead) {
return 'degraded:excluded';
}
if ($excludedHasPaused) {
return 'paused:excluded';
}
if ($excludedHasStarting) {
return 'starting:excluded';
}
return 'exited:excluded';
if ($relevantContainers->isEmpty()) {
return $this->calculateExcludedStatus($containers, $excludedContainers);
}
if ($hasRestarting) {
return 'degraded:unhealthy';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
if ($hasRunning) {
if ($hasUnhealthy) {
return 'running:unhealthy';
} elseif ($hasUnknown) {
return 'running:unknown';
} else {
return 'running:healthy';
}
}
if ($hasDead) {
return 'degraded:unhealthy';
}
if ($hasPaused) {
return 'paused:unknown';
}
if ($hasStarting) {
return 'starting:unknown';
}
return 'exited:unhealthy';
return $aggregator->aggregateFromContainers($relevantContainers);
}
}

View File

@ -13,6 +13,8 @@ use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -25,6 +27,7 @@ use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use CalculatesExcludedStatus;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
@ -145,7 +148,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$containerStatus = data_get($container, 'state', 'exited');
$rawHealthStatus = data_get($container, 'health_status');
$containerHealth = $rawHealthStatus ?? 'unknown';
$containerStatus = "$containerStatus ($containerHealth)";
$containerStatus = "$containerStatus:$containerHealth";
$labels = collect(data_get($container, 'labels'));
$coolify_managed = $labels->has('coolify.managed');
@ -153,81 +156,75 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
continue;
}
if ($coolify_managed) {
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
}
if ($labels->has('coolify.applicationId')) {
$applicationId = $labels->get('coolify.applicationId');
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
try {
if ($pullRequestId === '0') {
if ($this->allApplicationIds->contains($applicationId) && $this->isRunning($containerStatus)) {
$this->foundApplicationIds->push($applicationId);
}
// Store container status for aggregation
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} else {
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey) && $this->isRunning($containerStatus)) {
$this->foundApplicationPreviewsIds->push($previewKey);
}
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
}
} catch (\Exception $e) {
}
} elseif ($labels->has('coolify.serviceId')) {
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if ($subType === 'application') {
if ($this->isRunning($containerStatus)) {
$this->foundServiceApplicationIds->push($subId);
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
}
if ($labels->has('coolify.applicationId')) {
$applicationId = $labels->get('coolify.applicationId');
$pullRequestId = $labels->get('coolify.pullRequestId', '0');
try {
if ($pullRequestId === '0') {
if ($this->allApplicationIds->contains($applicationId)) {
$this->foundApplicationIds->push($applicationId);
}
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
if (! $this->applicationContainerStatuses->has($applicationId)) {
$this->applicationContainerStatuses->put($applicationId, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
}
} elseif ($subType === 'database') {
if ($this->isRunning($containerStatus)) {
$this->foundServiceDatabaseIds->push($subId);
}
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
}
} else {
$uuid = $labels->get('com.docker.compose.service');
$type = $labels->get('coolify.type');
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
}
$previewKey = $applicationId.':'.$pullRequestId;
if ($this->allApplicationPreviewsIds->contains($previewKey)) {
$this->foundApplicationPreviewsIds->push($previewKey);
}
$this->updateApplicationPreviewStatus($applicationId, $pullRequestId, $containerStatus);
}
} catch (\Exception $e) {
}
} elseif ($labels->has('coolify.serviceId')) {
$serviceId = $labels->get('coolify.serviceId');
$subType = $labels->get('coolify.service.subType');
$subId = $labels->get('coolify.service.subId');
if ($subType === 'application') {
$this->foundServiceApplicationIds->push($subId);
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
} elseif ($subType === 'database') {
$this->foundServiceDatabaseIds->push($subId);
// Store container status for aggregation
$key = $serviceId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = $labels->get('com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
}
} else {
$uuid = $labels->get('com.docker.compose.service');
$type = $labels->get('coolify.type');
if ($name === 'coolify-proxy' && $this->isRunning($containerStatus)) {
$this->foundProxy = true;
} elseif ($type === 'service' && $this->isRunning($containerStatus)) {
} else {
if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->foundDatabaseUuids->push($uuid);
if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true);
} else {
$this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: false);
}
}
}
@ -266,71 +263,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Parse docker compose to check for excluded containers
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
}
continue;
}
// Aggregate status: if any container is running, app is running
$hasRunning = false;
$hasUnhealthy = false;
$hasUnknown = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
if ($hasUnhealthy) {
$aggregatedStatus = 'running (unhealthy)';
} elseif ($hasUnknown) {
$aggregatedStatus = 'running (unknown)';
} else {
$aggregatedStatus = 'running (healthy)';
}
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
// Update application status with aggregated result
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
}
@ -366,67 +323,28 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$excludedContainers = collect();
if ($dockerComposeRaw) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
// Check if container should be excluded
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
}
$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);
// Filter out excluded containers
$relevantStatuses = $containerStatuses->filter(function ($status, $containerName) use ($excludedContainers) {
return ! $excludedContainers->contains($containerName);
});
// If all containers are excluded, don't update status
// If all containers are excluded, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
}
continue;
}
// Aggregate status: if any container is running, service is running
$hasRunning = false;
$hasUnhealthy = false;
$hasUnknown = false;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
if ($hasUnhealthy) {
$aggregatedStatus = 'running (unhealthy)';
} elseif ($hasUnknown) {
$aggregatedStatus = 'running (unknown)';
} else {
$aggregatedStatus = 'running (healthy)';
}
} else {
// All containers are exited
$aggregatedStatus = 'exited (unhealthy)';
}
// Use ContainerStatusAggregator service for state machine logic
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {

View File

@ -12,7 +12,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
@ -670,21 +669,23 @@ class Application extends BaseModel
{
return Attribute::make(
get: function () {
if (! $this->relationLoaded('additional_servers') || $this->additional_servers->count() === 0) {
return $this->destination?->server?->isFunctional() ?? false;
// Check main server infrastructure health
$main_server_functional = $this->destination?->server?->isFunctional() ?? false;
if (! $main_server_functional) {
return false;
}
$additional_servers_status = $this->additional_servers->pluck('pivot.status');
$main_server_status = $this->destination?->server?->isFunctional() ?? false;
foreach ($additional_servers_status as $status) {
$server_status = str($status)->before(':')->value();
if ($server_status !== 'running') {
return false;
// Check additional servers infrastructure health (not container status!)
if ($this->relationLoaded('additional_servers') && $this->additional_servers->count() > 0) {
foreach ($this->additional_servers as $server) {
if (! $server->isFunctional()) {
return false; // Real server infrastructure problem
}
}
}
return $main_server_status;
return true;
}
);
}
@ -703,13 +704,6 @@ class Application extends BaseModel
} else {
$status = $value;
$health = 'unhealthy';
Log::debug('[STATUS-DEBUG] Status set without health - defaulting to unhealthy', [
'source' => 'Application model accessor',
'app_id' => $this->id,
'app_name' => $this->name,
'raw_value' => $value,
'result' => "$status:$health",
]);
}
return "$status:$health";
@ -723,13 +717,6 @@ class Application extends BaseModel
} else {
$status = $value;
$health = 'unhealthy';
Log::debug('[STATUS-DEBUG] Status set without health (multi-server) - defaulting to unhealthy', [
'source' => 'Application model accessor',
'app_id' => $this->id,
'app_name' => $this->name,
'raw_value' => $value,
'result' => "$status:$health",
]);
}
return "$status:$health";

View File

@ -0,0 +1,222 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
/**
* Container Status Aggregator Service
*
* Centralized service for aggregating container statuses into a single status string.
* Uses a priority-based state machine to determine the overall status from multiple containers.
*
* Output Format: Colon-separated (e.g., "running:healthy", "degraded:unhealthy")
* This format is used throughout the backend for consistency and machine-readability.
* UI components transform this to human-readable format (e.g., "Running (Healthy)").
*
* State Priority (highest to lowest):
* 1. Restarting degraded:unhealthy
* 2. Crash Loop (exited with restarts) degraded:unhealthy
* 3. Mixed (running + exited) degraded:unhealthy
* 4. Running running:healthy/unhealthy/unknown
* 5. Dead/Removing degraded:unhealthy
* 6. Paused paused:unknown
* 7. Starting/Created starting:unknown
* 8. Exited exited:unhealthy
*/
class ContainerStatusAggregator
{
/**
* Aggregate container statuses from status strings into a single status.
*
* @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy")
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string
{
if ($containerStatuses->isEmpty()) {
return 'exited:unhealthy';
}
// Initialize state flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
// Parse each status string and set flags
foreach ($containerStatuses as $status) {
if (str($status)->contains('restarting')) {
$hasRestarting = true;
} elseif (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
if (str($status)->contains('unknown')) {
$hasUnknown = true;
}
} 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')) {
$hasPaused = true;
} elseif (str($status)->contains('dead') || str($status)->contains('removing')) {
$hasDead = true;
}
}
// Priority-based status resolution
return $this->resolveStatus(
$hasRunning,
$hasRestarting,
$hasUnhealthy,
$hasUnknown,
$hasExited,
$hasStarting,
$hasPaused,
$hasDead,
$maxRestartCount
);
}
/**
* Aggregate container statuses from Docker container objects.
*
* @param Collection $containers Collection of Docker container objects with State property
* @param int $maxRestartCount Maximum restart count across containers (for crash loop detection)
* @return string Aggregated status in colon format (e.g., "running:healthy")
*/
public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string
{
if ($containers->isEmpty()) {
return 'exited:unhealthy';
}
// Initialize state flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false;
$hasExited = false;
$hasStarting = false;
$hasPaused = false;
$hasDead = false;
// Parse each container object and set flags
foreach ($containers as $container) {
$state = data_get($container, 'State.Status', 'exited');
$health = data_get($container, 'State.Health.Status');
if ($state === 'restarting') {
$hasRestarting = true;
} elseif ($state === 'running') {
$hasRunning = true;
if ($health === 'unhealthy') {
$hasUnhealthy = true;
} elseif (is_null($health) || $health === 'starting') {
$hasUnknown = true;
}
} elseif ($state === 'exited') {
$hasExited = true;
$hasUnhealthy = true;
} elseif ($state === 'created' || $state === 'starting') {
$hasStarting = true;
} elseif ($state === 'paused') {
$hasPaused = true;
} elseif ($state === 'dead' || $state === 'removing') {
$hasDead = true;
}
}
// Priority-based status resolution
return $this->resolveStatus(
$hasRunning,
$hasRestarting,
$hasUnhealthy,
$hasUnknown,
$hasExited,
$hasStarting,
$hasPaused,
$hasDead,
$maxRestartCount
);
}
/**
* Resolve the aggregated status based on state flags (priority-based state machine).
*
* @param bool $hasRunning Has at least one running container
* @param bool $hasRestarting Has at least one restarting container
* @param bool $hasUnhealthy Has at least one unhealthy container
* @param bool $hasUnknown Has at least one container with unknown health
* @param bool $hasExited Has at least one exited container
* @param bool $hasStarting Has at least one starting/created container
* @param bool $hasPaused Has at least one paused container
* @param bool $hasDead Has at least one dead/removing container
* @param int $maxRestartCount Maximum restart count (for crash loop detection)
* @return string Status in colon format (e.g., "running:healthy")
*/
private function resolveStatus(
bool $hasRunning,
bool $hasRestarting,
bool $hasUnhealthy,
bool $hasUnknown,
bool $hasExited,
bool $hasStarting,
bool $hasPaused,
bool $hasDead,
int $maxRestartCount
): string {
// Priority 1: Restarting containers (degraded state)
if ($hasRestarting) {
return 'degraded:unhealthy';
}
// Priority 2: Crash loop detection (exited with restart count > 0)
if ($hasExited && $maxRestartCount > 0) {
return 'degraded:unhealthy';
}
// Priority 3: Mixed state (some running, some exited = degraded)
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
// Priority 4: Running containers (check health status)
if ($hasRunning) {
if ($hasUnhealthy) {
return 'running:unhealthy';
} elseif ($hasUnknown) {
return 'running:unknown';
} else {
return 'running:healthy';
}
}
// Priority 5: Dead or removing containers
if ($hasDead) {
return 'degraded:unhealthy';
}
// Priority 6: Paused containers
if ($hasPaused) {
return 'paused:unknown';
}
// Priority 7: Starting/created containers
if ($hasStarting) {
return 'starting:unknown';
}
// Priority 8: All containers exited (no restart count = truly stopped)
return 'exited:unhealthy';
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Traits;
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Collection;
trait CalculatesExcludedStatus
{
/**
* Calculate status for containers when all containers are excluded from health checks.
*
* This method processes excluded containers and returns a status with :excluded suffix
* to indicate that monitoring is disabled but still show the actual container state.
*
* @param Collection $containers Collection of container objects from Docker inspect
* @param Collection $excludedContainers Collection of container names that are excluded
* @return string Status string with :excluded suffix (e.g., 'running:unhealthy:excluded')
*/
protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string
{
// Filter to only excluded containers
$excludedOnly = $containers->filter(function ($container) use ($excludedContainers) {
$labels = data_get($container, 'Config.Labels', []);
$serviceName = data_get($labels, 'com.docker.compose.service');
return $serviceName && $excludedContainers->contains($serviceName);
});
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$status = $aggregator->aggregateFromContainers($excludedOnly);
// Append :excluded suffix
return $this->appendExcludedSuffix($status);
}
/**
* Calculate status for containers when all containers are excluded (simplified version).
*
* This version works with status strings (e.g., "running:healthy") instead of full
* container objects, suitable for Sentinel updates that don't have full container data.
*
* @param Collection $containerStatuses Collection of status strings keyed by container name
* @return string Status string with :excluded suffix
*/
protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string
{
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$status = $aggregator->aggregateFromStrings($containerStatuses);
// Append :excluded suffix
$finalStatus = $this->appendExcludedSuffix($status);
return $finalStatus;
}
/**
* Append :excluded suffix to a status string.
*
* Converts status formats like:
* - "running:healthy" "running:healthy:excluded"
* - "degraded:unhealthy" "degraded:excluded" (simplified)
* - "paused:unknown" "paused:excluded" (simplified)
*
* @param string $status The base status string
* @return string Status with :excluded suffix
*/
private function appendExcludedSuffix(string $status): string
{
// For degraded states, simplify to just "degraded:excluded"
if (str($status)->startsWith('degraded')) {
return 'degraded:excluded';
}
// For paused/starting/exited states, simplify to just "state:excluded"
if (str($status)->startsWith('paused')) {
return 'paused:excluded';
}
if (str($status)->startsWith('starting')) {
return 'starting:excluded';
}
if (str($status)->startsWith('exited')) {
return 'exited:excluded';
}
// For running states, keep the health status: "running:healthy:excluded"
return "$status:excluded";
}
/**
* Get excluded containers from docker-compose YAML.
*
* Containers are excluded if:
* - They have exclude_from_hc: true label
* - They have restart: no policy
*
* @param string|null $dockerComposeRaw The raw docker-compose YAML content
* @return Collection Collection of excluded container names
*/
protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection
{
$excludedContainers = collect();
if (! $dockerComposeRaw) {
return $excludedContainers;
}
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $serviceConfig) {
$excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false);
$restartPolicy = data_get($serviceConfig, 'restart', 'always');
if ($excludeFromHc || $restartPolicy === 'no') {
$excludedContainers->push($serviceName);
}
}
} catch (\Exception $e) {
// If we can't parse, treat all containers as included
}
return $excludedContainers;
}
}

View File

@ -1,15 +1,33 @@
@props([
'status' => 'Degraded',
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// degraded:unhealthy → Degraded (unhealthy)
// degraded (unhealthy) → degraded (unhealthy) (already formatted, display as-is)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
// Colon format from backend - transform it
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$healthStatus = null;
}
@endphp
<div class="flex items-center" >
<x-loading wire:loading.delay.longer />
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-warning"></div>
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">
{{ str($status)->before(':')->headline() }}
</div>
@if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
<div class="text-xs dark:text-warning">({{ str($status)->after(':') }})</div>
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning">{{ $displayStatus }}</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
@endif
</span>
</div>

View File

@ -5,9 +5,9 @@
])
@if (str($resource->status)->startsWith('running'))
<x-status.running :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
@elseif(str($resource->status)->startsWith('restarting') ||
str($resource->status)->startsWith('starting') ||
str($resource->status)->startsWith('degraded'))
@elseif(str($resource->status)->startsWith('degraded'))
<x-status.degraded :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
@elseif(str($resource->status)->startsWith('restarting') || str($resource->status)->startsWith('starting'))
<x-status.restarting :status="$resource->status" :title="$title" :lastDeploymentLink="$lastDeploymentLink" />
@else
<x-status.stopped :status="$resource->status" />

View File

@ -4,6 +4,26 @@
'lastDeploymentLink' => null,
'noLoading' => false,
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// starting:unknown → Starting (unknown)
// starting (unknown) → starting (unknown) (already formatted, display as-is)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':') && !str($status)->startsWith('Proxy')) {
// Colon format from backend - transform it
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$healthStatus = null;
}
@endphp
<div class="flex items-center">
@if (!$noLoading)
<x-loading wire:loading.delay.longer />
@ -13,14 +33,14 @@
<div class="pl-2 pr-1 text-xs font-bold dark:text-warning" @if($title) title="{{$title}}" @endif>
@if ($lastDeploymentLink)
<a href="{{ $lastDeploymentLink }}" target="_blank" class="underline cursor-pointer">
{{ str($status)->before(':')->headline() }}
{{ $displayStatus }}
</a>
@else
{{ str($status)->before(':')->headline() }}
{{ $displayStatus }}
@endif
</div>
@if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
<div class="text-xs dark:text-warning">({{ str($status)->after(':') }})</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs dark:text-warning">({{ $healthStatus }})</div>
@endif
</span>
</div>

View File

@ -1,21 +1,38 @@
@php
// Transform colon format to human-readable format for UI display
// running:healthy → Running (healthy)
// running:unhealthy:excluded → Running (unhealthy, excluded)
// exited:excluded → Exited (excluded)
$isExcluded = str($complexStatus)->endsWith(':excluded');
$displayStatus = $isExcluded ? str($complexStatus)->beforeLast(':excluded') : $complexStatus;
$parts = explode(':', $complexStatus);
if ($isExcluded) {
if (count($parts) === 3) {
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
$displayStatus = str($parts[0])->headline() . ' (' . $parts[1] . ', excluded)';
} else {
// No health status: exited:excluded → Exited (excluded)
$displayStatus = str($parts[0])->headline() . ' (excluded)';
}
} elseif (count($parts) >= 2 && !str($complexStatus)->startsWith('Proxy')) {
// Regular colon format: running:healthy → Running (healthy)
$displayStatus = str($parts[0])->headline() . ' (' . $parts[1] . ')';
} else {
// No transformation needed (simple status or already in parentheses format)
$displayStatus = str($complexStatus)->headline();
}
@endphp
@if (str($displayStatus)->contains('running'))
@if (str($displayStatus)->lower()->contains('running'))
<x-status.running :status="$displayStatus" />
@elseif(str($displayStatus)->contains('starting'))
@elseif(str($displayStatus)->lower()->contains('starting'))
<x-status.restarting :status="$displayStatus" />
@elseif(str($displayStatus)->contains('restarting'))
@elseif(str($displayStatus)->lower()->contains('restarting'))
<x-status.restarting :status="$displayStatus" />
@elseif(str($displayStatus)->contains('degraded'))
@elseif(str($displayStatus)->lower()->contains('degraded'))
<x-status.degraded :status="$displayStatus" />
@else
<x-status.stopped :status="$displayStatus" />
@endif
@if ($isExcluded)
<span class="text-xs text-neutral-500 dark:text-neutral-400" title="All containers excluded from health monitoring">(Monitoring Disabled)</span>
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">

View File

@ -2,12 +2,35 @@
'status' => 'Stopped',
'noLoading' => false,
])
@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)
if (str($status)->contains('(')) {
// Already in parentheses format from services.blade.php - use as-is
$displayStatus = $status;
$healthStatus = str($status)->after('(')->before(')')->trim()->value();
} elseif (str($status)->contains(':')) {
// Colon format from backend - transform it
$parts = explode(':', $status);
$displayStatus = str($parts[0])->headline();
$healthStatus = $parts[1] ?? null;
} else {
// Simple status without health
$displayStatus = str($status)->headline();
$healthStatus = null;
}
@endphp
<div class="flex items-center">
@if (!$noLoading)
<x-loading wire:loading.delay.longer />
@endif
<span wire:loading.remove.delay.longer class="flex items-center">
<div class="badge badge-error "></div>
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ str($status)->before(':')->headline() }}</div>
<div class="pl-2 pr-1 text-xs font-bold text-error">{{ $displayStatus }}</div>
@if ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs text-error">({{ $healthStatus }})</div>
@endif
</span>
</div>

View File

@ -30,15 +30,15 @@
@endif
<a class="menu-item flex items-center gap-2" wire:current.exact="menu-item-active"
href="{{ route('project.application.servers', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}">Servers
@if (str($application->status)->contains('degraded'))
<span title="Some servers are unavailable">
@if ($application->server_status == false)
<span title="One or more servers are unreachable or misconfigured.">
<svg class="w-4 h-4 text-error" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />
</svg>
</span>
@elseif ($application->server_status == false)
<span title="The underlying server(s) has problems.">
@elseif ($application->additional_servers()->exists() && str($application->status)->contains('degraded'))
<span title="Application is in degraded state across multiple servers.">
<svg class="w-4 h-4 text-error" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16" />

View File

@ -118,7 +118,7 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
</div>
</template>
</div>
@ -167,7 +167,7 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
</div>
</template>
</div>
@ -216,7 +216,7 @@
<div class="max-w-full px-4 truncate box-description" x-text="item.description"></div>
<div class="max-w-full px-4 truncate box-description" x-text="item.fqdn"></div>
<template x-if="item.server_status == false">
<div class="px-4 text-xs font-bold text-error">The underlying server has problems
<div class="px-4 text-xs font-bold text-error">Server is unreachable or misconfigured
</div>
</template>
</div>

View File

@ -90,7 +90,31 @@
@endcan
</span>
@endif
<div class="pt-2 text-xs">{{ $application->status }}</div>
@php
// Transform colon format to human-readable format
// running:healthy → Running (healthy)
// running:unhealthy:excluded → Running (unhealthy, excluded)
$appStatus = $application->status;
$isExcluded = str($appStatus)->endsWith(':excluded');
$parts = explode(':', $appStatus);
if ($isExcluded) {
if (count($parts) === 3) {
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
$appStatus = str($parts[0])->headline() . ' (' . $parts[1] . ', excluded)';
} else {
// No health status: exited:excluded → Exited (excluded)
$appStatus = str($parts[0])->headline() . ' (excluded)';
}
} elseif (count($parts) >= 2 && !str($appStatus)->startsWith('Proxy')) {
// Regular colon format: running:healthy → Running (healthy)
$appStatus = str($parts[0])->headline() . ' (' . $parts[1] . ')';
} else {
// Simple status or already in parentheses format
$appStatus = str($appStatus)->headline();
}
@endphp
<div class="pt-2 text-xs">{{ $appStatus }}</div>
</div>
<div class="flex items-center px-4">
<a class="mx-4 text-xs font-bold hover:underline"
@ -139,7 +163,31 @@
@if ($database->description)
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
@endif
<div class="text-xs">{{ $database->status }}</div>
@php
// Transform colon format to human-readable format
// running:healthy → Running (healthy)
// running:unhealthy:excluded → Running (unhealthy, excluded)
$dbStatus = $database->status;
$isExcluded = str($dbStatus)->endsWith(':excluded');
$parts = explode(':', $dbStatus);
if ($isExcluded) {
if (count($parts) === 3) {
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
$dbStatus = str($parts[0])->headline() . ' (' . $parts[1] . ', excluded)';
} else {
// No health status: exited:excluded → Exited (excluded)
$dbStatus = str($parts[0])->headline() . ' (excluded)';
}
} elseif (count($parts) >= 2 && !str($dbStatus)->startsWith('Proxy')) {
// Regular colon format: running:healthy → Running (healthy)
$dbStatus = str($parts[0])->headline() . ' (' . $parts[1] . ')';
} else {
// Simple status or already in parentheses format
$dbStatus = str($dbStatus)->headline();
}
@endphp
<div class="text-xs">{{ $dbStatus }}</div>
</div>
<div class="flex items-center px-4">
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)

View File

@ -14,7 +14,6 @@ use App\Http\Controllers\Api\TeamController;
use App\Http\Middleware\ApiAllowed;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::get('/health', [OtherController::class, 'healthcheck']);
@ -159,7 +158,6 @@ Route::group([
'prefix' => 'v1',
], function () {
Route::post('/sentinel/push', function () {
Log::info('Received Sentinel push request', ['ip' => request()->ip(), 'user_agent' => request()->header('User-Agent')]);
$token = request()->header('Authorization');
if (! $token) {
return response()->json(['message' => 'Unauthorized'], 401);

View File

@ -0,0 +1,223 @@
<?php
/**
* Unit tests to verify consistent handling of all-excluded containers
* across PushServerUpdateJob, GetContainersStatus, and ComplexStatusCheck.
*
* These tests verify the fix for issue where different code paths handled
* all-excluded containers inconsistently:
* - PushServerUpdateJob (Sentinel, ~30s) previously skipped updates
* - GetContainersStatus (SSH, ~1min) previously skipped updates
* - ComplexStatusCheck (Multi-server) correctly calculated :excluded status
*
* After this fix, all three paths now calculate and return :excluded status
* consistently, preventing status drift and UI inconsistencies.
*/
it('ensures CalculatesExcludedStatus trait exists with required methods', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify trait has both status calculation methods
expect($traitFile)
->toContain('trait CalculatesExcludedStatus')
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
->toContain('protected function getExcludedContainersFromDockerCompose(?string $dockerComposeRaw): Collection');
});
it('ensures ComplexStatusCheck uses CalculatesExcludedStatus trait', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
// Verify trait is used
expect($complexStatusCheckFile)
->toContain('use App\Traits\CalculatesExcludedStatus;')
->toContain('use CalculatesExcludedStatus;');
// Verify it uses the trait method instead of inline code
expect($complexStatusCheckFile)
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
// Verify it uses the trait helper for excluded containers
expect($complexStatusCheckFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
});
it('ensures PushServerUpdateJob uses CalculatesExcludedStatus trait', function () {
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify trait is used
expect($pushServerUpdateJobFile)
->toContain('use App\Traits\CalculatesExcludedStatus;')
->toContain('use CalculatesExcludedStatus;');
// Verify it calculates excluded status instead of skipping (old behavior: continue)
expect($pushServerUpdateJobFile)
->toContain('// If all containers are excluded, calculate status from excluded containers')
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);');
// Verify it uses the trait helper for excluded containers
expect($pushServerUpdateJobFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
});
it('ensures PushServerUpdateJob calculates excluded status for applications', function () {
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// In aggregateMultiContainerStatuses, verify the all-excluded scenario
// calculates status and updates the application
expect($pushServerUpdateJobFile)
->toContain('if ($relevantStatuses->isEmpty()) {')
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
->toContain('if ($aggregatedStatus && $application->status !== $aggregatedStatus) {')
->toContain('$application->status = $aggregatedStatus;')
->toContain('$application->save();');
});
it('ensures PushServerUpdateJob calculates excluded status for services', function () {
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Count occurrences - should appear twice (once for applications, once for services)
$calculateExcludedCount = substr_count(
$pushServerUpdateJobFile,
'$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);'
);
expect($calculateExcludedCount)->toBe(2, 'Should calculate excluded status for both applications and services');
});
it('ensures GetContainersStatus uses CalculatesExcludedStatus trait', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Verify trait is used
expect($getContainersStatusFile)
->toContain('use App\Traits\CalculatesExcludedStatus;')
->toContain('use CalculatesExcludedStatus;');
// Verify it calculates excluded status instead of returning null
expect($getContainersStatusFile)
->toContain('// If all containers are excluded, calculate status from excluded containers')
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
// Verify it uses the trait helper for excluded containers
expect($getContainersStatusFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
});
it('ensures GetContainersStatus calculates excluded status for applications', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// In aggregateApplicationStatus, verify the all-excluded scenario returns status
expect($getContainersStatusFile)
->toContain('if ($relevantStatuses->isEmpty()) {')
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
});
it('ensures GetContainersStatus calculates excluded status for services', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// In aggregateServiceContainerStatuses, verify the all-excluded scenario updates status
expect($getContainersStatusFile)
->toContain('$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);')
->toContain('if ($aggregatedStatus) {')
->toContain('$statusFromDb = $subResource->status;')
->toContain("if (\$statusFromDb !== \$aggregatedStatus) {\n \$subResource->update(['status' => \$aggregatedStatus]);");
});
it('ensures excluded status format is consistent across all paths', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Trait now delegates to ContainerStatusAggregator and uses appendExcludedSuffix helper
expect($traitFile)
->toContain('use App\\Services\\ContainerStatusAggregator;')
->toContain('$aggregator = new ContainerStatusAggregator;')
->toContain('private function appendExcludedSuffix(string $status): string');
// Check that appendExcludedSuffix returns consistent colon format with :excluded suffix
expect($traitFile)
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded, running:unhealthy:excluded, etc.
});
it('ensures all three paths check for exclude_from_hc flag consistently', function () {
// All three should use the trait helper method
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
expect($complexStatusCheckFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
expect($pushServerUpdateJobFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
expect($getContainersStatusFile)
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
// The trait method should check both exclude_from_hc and restart: no
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
expect($traitFile)
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
->toContain('$restartPolicy = data_get($serviceConfig, \'restart\', \'always\');')
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {');
});
it('ensures calculateExcludedStatus uses ContainerStatusAggregator', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
expect($traitFile)
->toContain('protected function calculateExcludedStatus(Collection $containers, Collection $excludedContainers): string')
->toContain('use App\Services\ContainerStatusAggregator;')
->toContain('$aggregator = new ContainerStatusAggregator;')
->toContain('$aggregator->aggregateFromContainers($excludedOnly)');
// Check that it has appendExcludedSuffix helper for all states
expect($traitFile)
->toContain('private function appendExcludedSuffix(string $status): string')
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});
it('ensures calculateExcludedStatusFromStrings uses ContainerStatusAggregator', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Check that the trait uses ContainerStatusAggregator service instead of duplicating logic
expect($traitFile)
->toContain('protected function calculateExcludedStatusFromStrings(Collection $containerStatuses): string')
->toContain('use App\Services\ContainerStatusAggregator;')
->toContain('$aggregator = new ContainerStatusAggregator;')
->toContain('$aggregator->aggregateFromStrings($containerStatuses)');
// Check that it has appendExcludedSuffix helper for all states
expect($traitFile)
->toContain('private function appendExcludedSuffix(string $status): string')
->toContain("return 'degraded:excluded';")
->toContain("return 'paused:excluded';")
->toContain("return 'starting:excluded';")
->toContain("return 'exited:excluded';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});
it('verifies no code path skips update when all containers excluded', function () {
$pushServerUpdateJobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// These patterns should NOT exist anymore (old behavior that caused drift)
expect($pushServerUpdateJobFile)
->not->toContain("// If all containers are excluded, don't update status");
expect($getContainersStatusFile)
->not->toContain("// If all containers are excluded, don't update status");
// Instead, both should calculate excluded status
expect($pushServerUpdateJobFile)
->toContain('// If all containers are excluded, calculate status from excluded containers');
expect($getContainersStatusFile)
->toContain('// If all containers are excluded, calculate status from excluded containers');
});

View File

@ -58,30 +58,25 @@ it('does not mark containers as unhealthy when health status is missing', functi
// 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');
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify the fix: health status should not default to 'unhealthy'
expect($complexStatusCheckFile)
expect($aggregatorFile)
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
->toContain("data_get(\$container, 'State.Health.Status')");
// Verify the health check logic for non-excluded containers
expect($complexStatusCheckFile)
->toContain('if ($containerHealth === \'unhealthy\') {');
// Verify the health check logic
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\') {');
});
it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// For non-excluded containers (line ~108)
expect($complexStatusCheckFile)
->toContain('if ($containerHealth === \'unhealthy\') {')
// Verify the service checks for explicit 'unhealthy' status
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\') {')
->toContain('$hasUnhealthy = true;');
// For excluded containers (line ~145)
expect($complexStatusCheckFile)
->toContain('if ($containerHealth === \'unhealthy\') {')
->toContain('$excludedHasUnhealthy = true;');
});
it('handles missing health status correctly in GetContainersStatus', function () {
@ -92,13 +87,14 @@ it('handles missing health status correctly in GetContainersStatus', function ()
->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
// Verify it uses 'unknown' when health status is missing (now using colon format)
expect($getContainersStatusFile)
->toContain('$healthSuffix = $containerHealth ?? \'unknown\';');
->toContain('$healthSuffix = $containerHealth ?? \'unknown\';')
->toContain('ContainerStatusAggregator'); // Uses the service
});
it('treats containers with running status and no healthcheck as not unhealthy', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// The logic should be:
// 1. Get health status (may be null)
@ -106,67 +102,65 @@ it('treats containers with running status and no healthcheck as not unhealthy',
// 3. Don't mark as unhealthy if health status is null/missing
// Verify the condition explicitly checks for unhealthy
expect($complexStatusCheckFile)
->toContain('if ($containerHealth === \'unhealthy\')');
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\')');
// Verify this check is done for running containers
expect($complexStatusCheckFile)
->toContain('} elseif ($containerStatus === \'running\') {')
expect($aggregatorFile)
->toContain('} elseif ($state === \'running\') {')
->toContain('$hasRunning = true;');
});
it('tracks unknown health state in aggregation', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify that $hasUnknown tracking variable exists
expect($getContainersStatusFile)
// Verify that $hasUnknown tracking variable exists in the service
expect($aggregatorFile)
->toContain('$hasUnknown = false;');
// Verify that unknown state is detected in status parsing
expect($getContainersStatusFile)
->toContain("if (str(\$status)->contains('unknown')) {")
expect($aggregatorFile)
->toContain("str(\$status)->contains('unknown')")
->toContain('$hasUnknown = true;');
});
it('preserves unknown health state in aggregated status with correct priority', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// State machine logic now in ContainerStatusAggregator service (using colon format)
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify three-way priority in aggregation:
// 1. Unhealthy (highest priority)
// 2. Unknown (medium priority)
// 3. Healthy (only when all explicitly healthy)
expect($getContainersStatusFile)
expect($aggregatorFile)
->toContain('if ($hasUnhealthy) {')
->toContain("return 'running (unhealthy)';")
->toContain("return 'running:unhealthy';")
->toContain('} elseif ($hasUnknown) {')
->toContain("return 'running (unknown)';")
->toContain("return 'running:unknown';")
->toContain('} else {')
->toContain("return 'running (healthy)';");
->toContain("return 'running:healthy';");
});
it('tracks unknown health state in ComplexStatusCheck for multi-server applications', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
it('tracks unknown health state in ContainerStatusAggregator for all applications', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify that $hasUnknown tracking variable exists
expect($complexStatusCheckFile)
expect($aggregatorFile)
->toContain('$hasUnknown = false;');
// Verify that unknown state is detected when containerHealth is null
expect($complexStatusCheckFile)
->toContain('} elseif ($containerHealth === null) {')
// Verify that unknown state is detected when health is null or 'starting'
expect($aggregatorFile)
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
->toContain('$hasUnknown = true;');
// Verify excluded containers also track unknown
expect($complexStatusCheckFile)
->toContain('$excludedHasUnknown = false;');
});
it('preserves unknown health state in ComplexStatusCheck aggregated status', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
it('preserves unknown health state in ContainerStatusAggregator aggregated status', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify three-way priority for non-excluded containers
expect($complexStatusCheckFile)
// Verify three-way priority for running containers in the service
expect($aggregatorFile)
->toContain('if ($hasUnhealthy) {')
->toContain("return 'running:unhealthy';")
->toContain('} elseif ($hasUnknown) {')
@ -174,114 +168,115 @@ it('preserves unknown health state in ComplexStatusCheck aggregated status', fun
->toContain('} else {')
->toContain("return 'running:healthy';");
// Verify three-way priority for excluded containers
// Verify ComplexStatusCheck delegates to the service
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
expect($complexStatusCheckFile)
->toContain('if ($excludedHasUnhealthy) {')
->toContain("return 'running:unhealthy:excluded';")
->toContain('} elseif ($excludedHasUnknown) {')
->toContain("return 'running:unknown:excluded';")
->toContain("return 'running:healthy:excluded';");
->toContain('use App\\Services\\ContainerStatusAggregator;')
->toContain('$aggregator = new ContainerStatusAggregator;')
->toContain('$aggregator->aggregateFromContainers($relevantContainers);');
});
it('preserves unknown health state in Service model aggregation', function () {
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
// Verify unknown is handled in non-excluded applications
// Verify unknown is handled correctly
expect($serviceFile)
->toContain("} elseif (\$health->value() === 'unknown') {")
->toContain("if (\$complexHealth !== 'unhealthy') {")
->toContain("\$complexHealth = 'unknown';");
->toContain("if (\$aggregateHealth !== 'unhealthy') {")
->toContain("\$aggregateHealth = 'unknown';");
// The pattern should appear 4 times (non-excluded apps, non-excluded databases,
// excluded apps, excluded databases)
// The pattern should appear at least once (Service model has different aggregation logic than ContainerStatusAggregator)
$unknownCount = substr_count($serviceFile, "} elseif (\$health->value() === 'unknown') {");
expect($unknownCount)->toBe(4);
expect($unknownCount)->toBeGreaterThan(0);
});
it('handles starting state (created/starting) in GetContainersStatus', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($getContainersStatusFile)
expect($aggregatorFile)
->toContain('$hasStarting = false;');
// Verify detection for created/starting states
expect($getContainersStatusFile)
->toContain("} elseif (str(\$status)->contains('created') || str(\$status)->contains('starting')) {")
expect($aggregatorFile)
->toContain("str(\$status)->contains('created') || str(\$status)->contains('starting')")
->toContain('$hasStarting = true;');
// Verify aggregation returns starting status
expect($getContainersStatusFile)
// Verify aggregation returns starting status (colon format)
expect($aggregatorFile)
->toContain('if ($hasStarting) {')
->toContain("return 'starting (unknown)';");
->toContain("return 'starting:unknown';");
});
it('handles paused state in GetContainersStatus', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($getContainersStatusFile)
expect($aggregatorFile)
->toContain('$hasPaused = false;');
// Verify detection for paused state
expect($getContainersStatusFile)
->toContain("} elseif (str(\$status)->contains('paused')) {")
expect($aggregatorFile)
->toContain("str(\$status)->contains('paused')")
->toContain('$hasPaused = true;');
// Verify aggregation returns paused status
expect($getContainersStatusFile)
// Verify aggregation returns paused status (colon format)
expect($aggregatorFile)
->toContain('if ($hasPaused) {')
->toContain("return 'paused (unknown)';");
->toContain("return 'paused:unknown';");
});
it('handles dead/removing states in GetContainersStatus', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($getContainersStatusFile)
expect($aggregatorFile)
->toContain('$hasDead = false;');
// Verify detection for dead/removing states
expect($getContainersStatusFile)
->toContain("} elseif (str(\$status)->contains('dead') || str(\$status)->contains('removing')) {")
expect($aggregatorFile)
->toContain("str(\$status)->contains('dead') || str(\$status)->contains('removing')")
->toContain('$hasDead = true;');
// Verify aggregation returns degraded status
expect($getContainersStatusFile)
// Verify aggregation returns degraded status (colon format)
expect($aggregatorFile)
->toContain('if ($hasDead) {')
->toContain("return 'degraded (unhealthy)';");
->toContain("return 'degraded:unhealthy';");
});
it('handles edge case states in ComplexStatusCheck for non-excluded containers', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
it('handles edge case states in ContainerStatusAggregator for all containers', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variables exist
expect($complexStatusCheckFile)
// Verify tracking variables exist in the service
expect($aggregatorFile)
->toContain('$hasStarting = false;')
->toContain('$hasPaused = false;')
->toContain('$hasDead = false;');
// Verify detection for created/starting
expect($complexStatusCheckFile)
->toContain("} elseif (\$containerStatus === 'created' || \$containerStatus === 'starting') {")
expect($aggregatorFile)
->toContain("} elseif (\$state === 'created' || \$state === 'starting') {")
->toContain('$hasStarting = true;');
// Verify detection for paused
expect($complexStatusCheckFile)
->toContain("} elseif (\$containerStatus === 'paused') {")
expect($aggregatorFile)
->toContain("} elseif (\$state === 'paused') {")
->toContain('$hasPaused = true;');
// Verify detection for dead/removing
expect($complexStatusCheckFile)
->toContain("} elseif (\$containerStatus === 'dead' || \$containerStatus === 'removing') {")
expect($aggregatorFile)
->toContain("} elseif (\$state === 'dead' || \$state === 'removing') {")
->toContain('$hasDead = true;');
});
it('handles edge case states in ComplexStatusCheck aggregation', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
it('handles edge case states in ContainerStatusAggregator aggregation', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify aggregation logic for edge cases
expect($complexStatusCheckFile)
// Verify aggregation logic for edge cases in the service
expect($aggregatorFile)
->toContain('if ($hasDead) {')
->toContain("return 'degraded:unhealthy';")
->toContain('if ($hasPaused) {')
@ -290,51 +285,58 @@ it('handles edge case states in ComplexStatusCheck aggregation', function () {
->toContain("return 'starting:unknown';");
});
it('handles edge case states in Service model for all 4 locations', function () {
it('handles edge case states in Service model', function () {
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
// Check for created/starting handling pattern
$createdStartingCount = substr_count($serviceFile, "\$status->startsWith('created') || \$status->startsWith('starting')");
expect($createdStartingCount)->toBe(4, 'created/starting handling should appear in all 4 locations');
expect($createdStartingCount)->toBeGreaterThan(0, 'created/starting handling should exist');
// Check for paused handling pattern
$pausedCount = substr_count($serviceFile, "\$status->startsWith('paused')");
expect($pausedCount)->toBe(4, 'paused handling should appear in all 4 locations');
expect($pausedCount)->toBeGreaterThan(0, 'paused handling should exist');
// Check for dead/removing handling pattern
$deadRemovingCount = substr_count($serviceFile, "\$status->startsWith('dead') || \$status->startsWith('removing')");
expect($deadRemovingCount)->toBe(4, 'dead/removing handling should appear in all 4 locations');
expect($deadRemovingCount)->toBeGreaterThan(0, 'dead/removing handling should exist');
});
it('appends :excluded suffix to excluded container statuses in GetContainersStatus', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Verify that we check for exclude_from_hc flag
// Verify that we use the trait for calculating excluded status
expect($getContainersStatusFile)
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);');
->toContain('CalculatesExcludedStatus');
// Verify that we append :excluded suffix
// Verify that we use the trait to calculate excluded status
expect($getContainersStatusFile)
->toContain('$containerStatus = str_replace(\')\', \':excluded)\', $containerStatus);');
->toContain('use CalculatesExcludedStatus;');
});
it('skips containers with :excluded suffix in Service model non-excluded sections', function () {
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
// Verify that we skip :excluded containers in non-excluded sections
// This should appear twice (once for applications, once for databases)
$skipExcludedCount = substr_count($serviceFile, "if (\$health->contains(':excluded')) {");
expect($skipExcludedCount)->toBeGreaterThanOrEqual(2, 'Should skip :excluded containers in non-excluded sections');
// Verify that we have exclude_from_status field handling
expect($serviceFile)
->toContain('exclude_from_status');
});
it('processes containers with :excluded suffix in Service model excluded sections', function () {
$serviceFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
// Verify that we process :excluded containers in excluded sections
$processExcludedCount = substr_count($serviceFile, "if (! \$health->contains(':excluded') && !");
expect($processExcludedCount)->toBeGreaterThanOrEqual(2, 'Should process :excluded containers in excluded sections');
// Verify that we strip :excluded suffix before health comparison
$stripExcludedCount = substr_count($serviceFile, "\$health = str(\$health)->replace(':excluded', '');");
expect($stripExcludedCount)->toBeGreaterThanOrEqual(2, 'Should strip :excluded suffix in excluded sections');
// Verify that we handle excluded status
expect($serviceFile)
->toContain(':excluded')
->toContain('exclude_from_status');
});
it('treats containers with starting health status as unknown in ContainerStatusAggregator', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify that 'starting' health status is treated the same as null (unknown)
// During Docker health check grace period, the health status is 'starting'
// This should be treated as 'unknown' rather than 'healthy'
expect($aggregatorFile)
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
->toContain('$hasUnknown = true;');
});

View File

@ -0,0 +1,463 @@
<?php
use App\Services\ContainerStatusAggregator;
beforeEach(function () {
$this->aggregator = new ContainerStatusAggregator;
});
describe('aggregateFromStrings', function () {
test('returns exited:unhealthy for empty collection', function () {
$result = $this->aggregator->aggregateFromStrings(collect());
expect($result)->toBe('exited:unhealthy');
});
test('returns running:healthy for single healthy running container', function () {
$statuses = collect(['running:healthy']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:healthy');
});
test('returns running:unhealthy for single unhealthy running container', function () {
$statuses = collect(['running:unhealthy']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unhealthy');
});
test('returns running:unknown for single running container with unknown health', function () {
$statuses = collect(['running:unknown']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unknown');
});
test('returns degraded:unhealthy for restarting container', function () {
$statuses = collect(['restarting']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('returns degraded:unhealthy for mixed running and exited containers', function () {
$statuses = collect(['running:healthy', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('returns running:unhealthy when one of multiple running containers is unhealthy', function () {
$statuses = collect(['running:healthy', 'running:unhealthy', 'running:healthy']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unhealthy');
});
test('returns running:unknown when running containers have unknown health', function () {
$statuses = collect(['running:unknown', 'running:healthy']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unknown');
});
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
expect($result)->toBe('degraded:unhealthy');
});
test('returns exited:unhealthy for exited containers without restart count', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
expect($result)->toBe('exited:unhealthy');
});
test('returns degraded:unhealthy for dead container', function () {
$statuses = collect(['dead']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('returns degraded:unhealthy for removing container', function () {
$statuses = collect(['removing']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('returns paused:unknown for paused container', function () {
$statuses = collect(['paused']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('paused:unknown');
});
test('returns starting:unknown for starting container', function () {
$statuses = collect(['starting']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('starting:unknown');
});
test('returns starting:unknown for created container', function () {
$statuses = collect(['created']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('starting:unknown');
});
test('handles parentheses format input (backward compatibility)', function () {
$statuses = collect(['running (healthy)', 'running (unhealthy)']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unhealthy');
});
test('handles mixed colon and parentheses formats', function () {
$statuses = collect(['running:healthy', 'running (unhealthy)', 'running:healthy']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unhealthy');
});
test('prioritizes restarting over all other states', function () {
$statuses = collect(['restarting', 'running:healthy', 'paused', 'starting']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes crash loop over running containers', function () {
$statuses = collect(['exited', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes mixed state over healthy running', function () {
$statuses = collect(['running:healthy', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes running over paused/starting/exited', function () {
$statuses = collect(['running:healthy', 'starting', 'paused']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:healthy');
});
test('prioritizes dead over paused/starting/exited', function () {
$statuses = collect(['dead', 'paused', 'starting']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes paused over starting/exited', function () {
$statuses = collect(['paused', 'starting', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('paused:unknown');
});
test('prioritizes starting over exited', function () {
$statuses = collect(['starting', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('starting:unknown');
});
});
describe('aggregateFromContainers', function () {
test('returns exited:unhealthy for empty collection', function () {
$result = $this->aggregator->aggregateFromContainers(collect());
expect($result)->toBe('exited:unhealthy');
});
test('returns running:healthy for single healthy running container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => (object) ['Status' => 'healthy'],
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('running:healthy');
});
test('returns running:unhealthy for single unhealthy running container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => (object) ['Status' => 'unhealthy'],
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('running:unhealthy');
});
test('returns running:unknown for running container without health check', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => null,
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('running:unknown');
});
test('returns degraded:unhealthy for restarting container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'restarting',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('degraded:unhealthy');
});
test('returns degraded:unhealthy for mixed running and exited containers', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => (object) ['Status' => 'healthy'],
],
],
(object) [
'State' => (object) [
'Status' => 'exited',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('degraded:unhealthy');
});
test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'exited',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
expect($result)->toBe('degraded:unhealthy');
});
test('returns exited:unhealthy for exited containers without restart count', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'exited',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 0);
expect($result)->toBe('exited:unhealthy');
});
test('returns degraded:unhealthy for dead container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'dead',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('degraded:unhealthy');
});
test('returns paused:unknown for paused container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'paused',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('paused:unknown');
});
test('returns starting:unknown for starting container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'starting',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('starting:unknown');
});
test('returns starting:unknown for created container', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'created',
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('starting:unknown');
});
test('handles multiple containers with various states', function () {
$containers = collect([
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => (object) ['Status' => 'healthy'],
],
],
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => (object) ['Status' => 'unhealthy'],
],
],
(object) [
'State' => (object) [
'Status' => 'running',
'Health' => null,
],
],
]);
$result = $this->aggregator->aggregateFromContainers($containers);
expect($result)->toBe('running:unhealthy');
});
});
describe('state priority enforcement', function () {
test('restarting has highest priority', function () {
$statuses = collect([
'restarting',
'running:healthy',
'dead',
'paused',
'starting',
'exited',
]);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('degraded:unhealthy');
});
test('crash loop has second highest priority', function () {
$statuses = collect([
'exited',
'running:healthy',
'paused',
'starting',
]);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1);
expect($result)->toBe('degraded:unhealthy');
});
test('mixed state (running + exited) has third priority', function () {
$statuses = collect([
'running:healthy',
'exited',
'paused',
'starting',
]);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
expect($result)->toBe('degraded:unhealthy');
});
test('running:unhealthy has priority over running:unknown', function () {
$statuses = collect([
'running:unknown',
'running:unhealthy',
'running:healthy',
]);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unhealthy');
});
test('running:unknown has priority over running:healthy', function () {
$statuses = collect([
'running:unknown',
'running:healthy',
]);
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('running:unknown');
});
});

View File

@ -13,17 +13,23 @@
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, the status calculation
// processes excluded containers and returns status with :excluded suffix
// Check that when all containers are excluded, ComplexStatusCheck uses the trait
expect($complexStatusCheckFile)
->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:unhealthy:excluded';")
->toContain("return 'running:unknown:excluded';")
->toContain("return 'running:healthy:excluded';")
->toContain('if ($relevantContainers->isEmpty()) {')
->toContain('return $this->calculateExcludedStatus($containers, $excludedContainers);');
// Check that the trait uses ContainerStatusAggregator and appends :excluded suffix
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
expect($traitFile)
->toContain('ContainerStatusAggregator')
->toContain('appendExcludedSuffix')
->toContain('$aggregator->aggregateFromContainers($excludedOnly)')
->toContain("return 'degraded:excluded';")
->toContain("return 'exited:excluded';");
->toContain("return 'paused:excluded';")
->toContain("return 'exited:excluded';")
->toContain('return "$status:excluded";'); // For running:healthy:excluded
});
it('ensures Service model returns excluded status when all services excluded', function () {
@ -32,64 +38,59 @@ it('ensures Service model returns excluded status when all services excluded', f
// Check that when all services are excluded from status checks,
// the Service model calculates real status and returns it with :excluded suffix
expect($serviceModelFile)
->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';");
->toContain('exclude_from_status')
->toContain(':excluded')
->toContain('CalculatesExcludedStatus');
});
it('ensures Service model returns unknown:excluded when no containers exist', function () {
it('ensures Service model returns unknown: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'
// the Service model returns 'unknown:unknown:excluded' instead of 'exited:unhealthy: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';");
->toContain("return 'unknown:unknown:excluded';");
});
it('ensures GetContainersStatus returns null when all containers excluded', function () {
it('ensures GetContainersStatus calculates excluded status 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
// method calculates and returns status with :excluded suffix
expect($getContainersStatusFile)
->toContain('// If all containers are excluded, don\'t update status')
->toContain("if (\$relevantStatuses->isEmpty()) {\n return null;\n }");
->toContain('// If all containers are excluded, calculate status from excluded containers')
->toContain('if ($relevantStatuses->isEmpty()) {')
->toContain('return $this->calculateExcludedStatusFromStrings($containerStatuses);');
});
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
// Verify that exclude_from_hc is parsed using trait helper
expect($complexStatusCheckFile)
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {')
->toContain('$excludedContainers->push($serviceName);');
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
});
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
// Verify that exclude_from_hc is parsed using trait helper
expect($getContainersStatusFile)
->toContain('$excludeFromHc = data_get($serviceConfig, \'exclude_from_hc\', false);')
->toContain('if ($excludeFromHc || $restartPolicy === \'no\') {')
->toContain('$excludedContainers->push($serviceName);');
->toContain('$excludedContainers = $this->getExcludedContainersFromDockerCompose($dockerComposeRaw);');
});
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
// Verify that the status component transforms :excluded suffix to (excluded) for better display
expect($servicesStatusFile)
->toContain('$isExcluded = str($complexStatus)->endsWith(\':excluded\');')
->toContain('$displayStatus = $isExcluded ? str($complexStatus)->beforeLast(\':excluded\') : $complexStatus;')
->toContain('(Monitoring Disabled)');
->toContain('$parts = explode(\':\', $complexStatus);')
->toContain('// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)')
->toContain('// No health status: exited:excluded → Exited (excluded)');
});
it('ensures UI handles excluded status in service heading buttons', function () {

View File

@ -1,184 +0,0 @@
<?php
/**
* Unit tests for PushServerUpdateJob status aggregation logic.
*
* These tests verify that the job correctly aggregates container statuses
* when processing Sentinel updates, with proper handling of:
* - running (healthy) - all containers running and healthy
* - running (unhealthy) - some containers unhealthy
* - running (unknown) - some containers with unknown health status
*
* The aggregation follows a priority system: unhealthy > unknown > healthy
*
* This ensures consistency with GetContainersStatus::aggregateApplicationStatus()
* and prevents the bug where "unknown" status was incorrectly converted to "healthy".
*/
it('aggregates status with unknown health state correctly', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify that hasUnknown tracking variable exists
expect($jobFile)
->toContain('$hasUnknown = false;')
->toContain('if (str($status)->contains(\'unknown\')) {')
->toContain('$hasUnknown = true;');
// Verify 3-way status priority logic (unhealthy > unknown > healthy)
expect($jobFile)
->toContain('if ($hasUnhealthy) {')
->toContain('$aggregatedStatus = \'running (unhealthy)\';')
->toContain('} elseif ($hasUnknown) {')
->toContain('$aggregatedStatus = \'running (unknown)\';')
->toContain('} else {')
->toContain('$aggregatedStatus = \'running (healthy)\';');
});
it('checks for unknown status alongside unhealthy status', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify unknown check is placed alongside unhealthy check
expect($jobFile)
->toContain('if (str($status)->contains(\'unhealthy\')) {')
->toContain('if (str($status)->contains(\'unknown\')) {');
});
it('follows same priority as GetContainersStatus', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$getContainersFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Both should track hasUnknown
expect($jobFile)->toContain('$hasUnknown = false;');
expect($getContainersFile)->toContain('$hasUnknown = false;');
// Both should check for 'unknown' in status strings
expect($jobFile)->toContain('if (str($status)->contains(\'unknown\')) {');
expect($getContainersFile)->toContain('if (str($status)->contains(\'unknown\')) {');
// Both should prioritize unhealthy over unknown over healthy
expect($jobFile)->toContain('} elseif ($hasUnknown) {');
expect($getContainersFile)->toContain('} elseif ($hasUnknown) {');
});
it('does not default unknown to healthy status', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// The old buggy code was:
// $aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
// This would make unknown -> healthy
// Verify we're NOT using ternary operator for status assignment
expect($jobFile)
->not->toContain('$aggregatedStatus = $hasUnhealthy ? \'running (unhealthy)\' : \'running (healthy)\';');
// Verify we ARE using if-elseif-else with proper unknown handling
expect($jobFile)
->toContain('if ($hasUnhealthy) {')
->toContain('} elseif ($hasUnknown) {')
->toContain('$aggregatedStatus = \'running (unknown)\';');
});
it('initializes all required status tracking variables', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify all three tracking variables are initialized together
$pattern = '/\$hasRunning\s*=\s*false;\s*\$hasUnhealthy\s*=\s*false;\s*\$hasUnknown\s*=\s*false;/s';
expect(preg_match($pattern, $jobFile))->toBe(1,
'All status tracking variables ($hasRunning, $hasUnhealthy, $hasUnknown) should be initialized together');
});
it('preserves unknown status through sentinel updates', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// The critical path: when a status contains 'running' AND 'unknown',
// both flags should be set
expect($jobFile)
->toContain('if (str($status)->contains(\'running\')) {')
->toContain('$hasRunning = true;')
->toContain('if (str($status)->contains(\'unhealthy\')) {')
->toContain('$hasUnhealthy = true;')
->toContain('if (str($status)->contains(\'unknown\')) {')
->toContain('$hasUnknown = true;');
// And then unknown should have priority over healthy in aggregation
expect($jobFile)
->toContain('} elseif ($hasUnknown) {')
->toContain('$aggregatedStatus = \'running (unknown)\';');
});
it('implements service multi-container aggregation', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify service container collection exists
expect($jobFile)
->toContain('public Collection $serviceContainerStatuses;')
->toContain('$this->serviceContainerStatuses = collect();');
// Verify aggregateServiceContainerStatuses method exists
expect($jobFile)
->toContain('private function aggregateServiceContainerStatuses()')
->toContain('$this->aggregateServiceContainerStatuses();');
// Verify service aggregation uses same logic as applications
expect($jobFile)
->toContain('$hasUnknown = false;');
});
it('services use same priority as applications', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Both aggregation methods should use the same priority logic
$applicationAggregation = <<<'PHP'
if ($hasUnhealthy) {
$aggregatedStatus = 'running (unhealthy)';
} elseif ($hasUnknown) {
$aggregatedStatus = 'running (unknown)';
} else {
$aggregatedStatus = 'running (healthy)';
}
PHP;
// Count occurrences - should appear twice (once for apps, once for services)
$occurrences = substr_count($jobFile, $applicationAggregation);
expect($occurrences)->toBeGreaterThanOrEqual(2, 'Priority logic should appear for both applications and services');
});
it('collects service containers before aggregating', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify service containers are collected, not immediately updated
expect($jobFile)
->toContain('$key = $serviceId.\':\'.$subType.\':\'.$subId;')
->toContain('$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);');
// Verify aggregation happens after collection
expect($jobFile)
->toContain('$this->aggregateMultiContainerStatuses();')
->toContain('$this->aggregateServiceContainerStatuses();');
});
it('defaults to unknown when health_status is missing from Sentinel data', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
// Verify we use null coalescing to default to 'unknown', not 'unhealthy'
// This is critical for containers without healthcheck defined
expect($jobFile)
->toContain('$rawHealthStatus = data_get($container, \'health_status\');')
->toContain('$containerHealth = $rawHealthStatus ?? \'unknown\';')
->not->toContain('data_get($container, \'health_status\', \'unhealthy\')');
});
it('matches SSH path default health status behavior', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$getContainersFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Both paths should default to 'unknown' when health status is missing
// Sentinel path: health_status field missing -> 'unknown'
expect($jobFile)->toContain('?? \'unknown\'');
// SSH path: State.Health.Status missing -> 'unknown'
expect($getContainersFile)->toContain('?? \'unknown\'');
// Neither should use 'unhealthy' as default for missing health status
expect($jobFile)->not->toContain('data_get($container, \'health_status\', \'unhealthy\')');
});

View File

@ -0,0 +1,53 @@
<?php
use App\Models\Application;
use App\Models\Server;
/**
* Test the Application::serverStatus() accessor
*
* This accessor determines if the underlying server infrastructure is functional.
* It should check Server::isFunctional() for the main server and all additional servers.
* It should NOT be affected by container/application health status (e.g., degraded:unhealthy).
*
* The bug that was fixed: Previously, it checked pivot.status and returned false
* when any additional server had status != 'running', including 'degraded:unhealthy'.
* This caused false "server has problems" warnings when the server was fine but
* containers were unhealthy.
*/
it('checks server infrastructure health not container status', function () {
// This is a documentation test to explain the fix
// The serverStatus accessor should:
// 1. Check if main server is functional (Server::isFunctional())
// 2. Check if each additional server is functional (Server::isFunctional())
// 3. NOT check pivot.status (that's application/container status, not server status)
//
// Before fix: Checked pivot.status !== 'running', causing false positives
// After fix: Only checks Server::isFunctional() for infrastructure health
expect(true)->toBeTrue();
})->note('The serverStatus accessor now correctly checks only server infrastructure health, not container status');
it('has correct logic in serverStatus accessor', function () {
// Read the actual code to verify the fix
$reflection = new ReflectionClass(Application::class);
$source = file_get_contents($reflection->getFileName());
// Extract just the serverStatus accessor method
preg_match('/protected function serverStatus\(\): Attribute\s*\{.*?^\s{4}\}/ms', $source, $matches);
$serverStatusCode = $matches[0] ?? '';
expect($serverStatusCode)->not->toBeEmpty('serverStatus accessor should exist');
// Check that the new logic exists (checks isFunctional on each server)
expect($serverStatusCode)
->toContain('$main_server_functional = $this->destination?->server?->isFunctional()')
->toContain('foreach ($this->additional_servers as $server)')
->toContain('if (! $server->isFunctional())');
// Check that the old buggy logic is removed from serverStatus accessor
expect($serverStatusCode)
->not->toContain('pluck(\'pivot.status\')')
->not->toContain('str($status)->before(\':\')')
->not->toContain('if ($server_status !== \'running\')');
})->note('Verifies that the serverStatus accessor uses the correct logic');

View File

@ -13,7 +13,7 @@
"version": "1.0.10"
},
"sentinel": {
"version": "0.0.16"
"version": "0.0.17"
}
},
"traefik": {