fix: correct status for excluded health check containers (#7283)

This commit is contained in:
Andras Bacsai 2025-11-21 09:17:26 +01:00 committed by GitHub
commit 355dcc186c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 3720 additions and 344 deletions

View file

@ -16,7 +16,7 @@ ### 📚 Core Documentation
- **[Technology Stack](core/technology-stack.md)** - All versions, packages, and dependencies (Laravel 12.4.1, PHP 8.4.7, etc.)
- **[Project Overview](core/project-overview.md)** - What Coolify is and how it works
- **[Application Architecture](core/application-architecture.md)** - System design and component relationships
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end
- **[Deployment Architecture](core/deployment-architecture.md)** - How deployments work end-to-end, including Coolify Docker Compose extensions (custom fields)
### 💻 Development
Day-to-day development practices:
@ -85,6 +85,13 @@ ### Laravel-Specific Questions
- Pest testing patterns
- Laravel conventions
### Docker Compose Extensions
→ [core/deployment-architecture.md](core/deployment-architecture.md#coolify-docker-compose-extensions)
- Custom fields: `exclude_from_hc`, `content`, `isDirectory`
- How to use inline file content
- Health check exclusion patterns
- Volume creation control
### Version Numbers
→ [core/technology-stack.md](core/technology-stack.md)
- **Single source of truth** for all version numbers

View file

@ -361,3 +361,244 @@ ### **Background Processing**
- **Queue Workers**: Horizon-managed job processing
- **Job Batching**: Related job grouping
- **Failed Job Handling**: Automatic retry logic
## Container Status Monitoring System
### **Overview**
Container health status is monitored and updated through **multiple independent paths**. When modifying status logic, **ALL paths must be updated** to ensure consistency.
### **Critical Implementation Locations**
#### **1. SSH-Based Status Updates (Scheduled)**
**File**: [app/Actions/Docker/GetContainersStatus.php](mdc:app/Actions/Docker/GetContainersStatus.php)
**Method**: `aggregateApplicationStatus()` (lines 487-540)
**Trigger**: Scheduled job or manual refresh
**Frequency**: Every minute (via `ServerCheckJob`)
**Status Aggregation Logic**:
```php
// Tracks multiple status flags
$hasRunning = false;
$hasRestarting = false;
$hasUnhealthy = false;
$hasUnknown = false; // ⚠️ CRITICAL: Must track unknown
$hasExited = false;
// ... more states
// Priority: restarting > degraded > running (unhealthy > unknown > healthy)
if ($hasRunning) {
if ($hasUnhealthy) return 'running (unhealthy)';
elseif ($hasUnknown) return 'running (unknown)';
else return 'running (healthy)';
}
```
#### **2. Sentinel-Based Status Updates (Real-time)**
**File**: [app/Jobs/PushServerUpdateJob.php](mdc:app/Jobs/PushServerUpdateJob.php)
**Method**: `aggregateMultiContainerStatuses()` (lines 269-298)
**Trigger**: Sentinel push updates from remote servers
**Frequency**: Every ~30 seconds (real-time)
**Status Aggregation Logic**:
```php
// ⚠️ MUST match GetContainersStatus logic
$hasRunning = false;
$hasUnhealthy = false;
$hasUnknown = false; // ⚠️ CRITICAL: Added to fix bug
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; // ⚠️ CRITICAL
}
}
// Priority: unhealthy > unknown > healthy
if ($hasRunning) {
if ($hasUnhealthy) $aggregatedStatus = 'running (unhealthy)';
elseif ($hasUnknown) $aggregatedStatus = 'running (unknown)';
else $aggregatedStatus = 'running (healthy)';
}
```
#### **3. Multi-Server Status Aggregation**
**File**: [app/Actions/Shared/ComplexStatusCheck.php](mdc:app/Actions/Shared/ComplexStatusCheck.php)
**Method**: `resource()` (lines 48-210)
**Purpose**: Aggregates status across multiple servers for applications
**Used by**: Applications with multiple destinations
**Key Features**:
- Aggregates statuses from main + additional servers
- Handles excluded containers (`:excluded` suffix)
- Calculates overall application health from all containers
**Status Format with Excluded Containers**:
```php
// When all containers excluded from health checks:
return 'running:unhealthy:excluded'; // Container running but unhealthy, monitoring disabled
return 'running:unknown:excluded'; // Container running, health unknown, monitoring disabled
return 'running:healthy:excluded'; // Container running and healthy, monitoring disabled
return 'degraded:excluded'; // Some containers down, monitoring disabled
return 'exited:excluded'; // All containers stopped, monitoring disabled
```
#### **4. Service-Level Status Aggregation**
**File**: [app/Models/Service.php](mdc:app/Models/Service.php)
**Method**: `complexStatus()` (lines 176-288)
**Purpose**: Aggregates status for multi-container services
**Used by**: Docker Compose services
**Status Calculation**:
```php
// Aggregates status from all service applications and databases
// Handles excluded containers separately
// Returns status with :excluded suffix when all containers excluded
if (!$hasNonExcluded && $complexStatus === null && $complexHealth === null) {
// All services excluded - calculate from excluded containers
return "{$excludedStatus}:excluded";
}
```
### **Status Flow Diagram**
```
┌─────────────────────────────────────────────────────────────┐
│ Container Status Sources │
└─────────────────────────────────────────────────────────────┘
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌─────────────────┐ ┌──────────────┐
│ SSH-Based │ │ Sentinel-Based │ │ Multi-Server │
│ (Scheduled) │ │ (Real-time) │ │ Aggregation │
├───────────────┤ ├─────────────────┤ ├──────────────┤
│ ServerCheck │ │ PushServerUp- │ │ ComplexStatus│
│ Job │ │ dateJob │ │ Check │
│ │ │ │ │ │
│ Every ~1min │ │ Every ~30sec │ │ On demand │
└───────┬───────┘ └────────┬────────┘ └──────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
┌───────────────────────┐
│ Application/Service │
│ Status Property │
└───────────────────────┘
┌───────────────────────┐
│ UI Display (Livewire) │
└───────────────────────┘
```
### **Status Priority System**
All status aggregation locations **MUST** follow the same priority:
**For Running Containers**:
1. **unhealthy** - Container has failing health checks
2. **unknown** - Container health status cannot be determined
3. **healthy** - Container is healthy
**For Non-Running States**:
1. **restarting**`degraded (unhealthy)`
2. **running + exited**`degraded (unhealthy)`
3. **dead/removing**`degraded (unhealthy)`
4. **paused**`paused`
5. **created/starting**`starting`
6. **exited**`exited (unhealthy)`
### **Excluded Containers**
When containers have `exclude_from_hc: true` flag or `restart: no`:
**Behavior**:
- Status is still calculated from container state
- `:excluded` suffix is appended to indicate monitoring disabled
- UI shows "(Monitoring Disabled)" badge
- Action buttons respect the actual container state
**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**
**Container Status Aggregation Service**:
The container status aggregation logic is centralized in `App\Services\ContainerStatusAggregator`.
**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/ContainerStatusAggregatorTest.php`
- Run integration tests: `./vendor/bin/pest tests/Unit/`
- Test SSH updates (manual refresh)
- Test Sentinel updates (wait 30 seconds)
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/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**
**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
**Still Relevant**:
**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

@ -303,3 +303,286 @@ ### External Services
- **External database** connections
- **Third-party monitoring** tools
- **Custom notification** channels
---
## Coolify Docker Compose Extensions
Coolify extends standard Docker Compose with custom fields (often called "magic fields") that provide Coolify-specific functionality. These extensions are processed during deployment and stripped before sending the final compose file to Docker, maintaining full compatibility with Docker's compose specification.
### Overview
**Why Custom Fields?**
- Enable Coolify-specific features without breaking Docker Compose compatibility
- Simplify configuration by embedding content directly in compose files
- Allow fine-grained control over health check monitoring
- Reduce external file dependencies
**Processing Flow:**
1. User defines compose file with custom fields
2. Coolify parses and processes custom fields (creates files, stores settings)
3. Custom fields are stripped from final compose sent to Docker
4. Docker receives standard, valid compose file
### Service-Level Extensions
#### `exclude_from_hc`
**Type:** Boolean
**Default:** `false`
**Purpose:** Exclude specific services from health check monitoring while still showing their status
**Example Usage:**
```yaml
services:
watchtower:
image: containrrr/watchtower
exclude_from_hc: true # Don't monitor this service's health
backup:
image: postgres:16
exclude_from_hc: true # Backup containers don't need monitoring
restart: always
```
**Behavior:**
- Container status is still calculated from Docker state (running, exited, etc.)
- Status displays with `:excluded` suffix (e.g., `running:healthy:excluded`)
- UI shows "Monitoring Disabled" indicator
- Functionally equivalent to `restart: no` for health check purposes
- See [Container Status with All Excluded](application-architecture.md#container-status-when-all-containers-excluded) for detailed status handling
**Use Cases:**
- Sidecar containers (watchtower, log collectors)
- Backup/maintenance containers
- One-time initialization containers
- Containers that intentionally restart frequently
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php`
- Status logic: `app/Traits/CalculatesExcludedStatus.php`
- Validation: `tests/Unit/ExcludeFromHealthCheckTest.php`
### Volume-Level Extensions
Volume extensions only work with **long syntax** (array/object format), not short syntax (string format).
#### `content`
**Type:** String (supports multiline with `|` or `>`)
**Purpose:** Embed file content directly in compose file for automatic creation during deployment
**Example Usage:**
```yaml
services:
app:
image: node:20
volumes:
# Inline entrypoint script
- type: bind
source: ./entrypoint.sh
target: /app/entrypoint.sh
content: |
#!/bin/sh
set -e
echo "Starting application..."
npm run migrate
exec "$@"
# Configuration file with environment variables
- type: bind
source: ./config.xml
target: /etc/app/config.xml
content: |
<?xml version='1.0' encoding='UTF-8'?>
<config>
<database>
<host>${DB_HOST}</host>
<port>${DB_PORT}</port>
</database>
</config>
```
**Behavior:**
- Content is written to the host at `source` path before container starts
- File is created with mode `644` (readable by all, writable by owner)
- Environment variables in content are interpolated at deployment time
- Content is stored in `LocalFileVolume` model (encrypted at rest)
- Original `docker_compose_raw` retains content for editing
**Use Cases:**
- Entrypoint scripts
- Configuration files
- Environment-specific settings
- Small initialization scripts
- Templates that require dynamic content
**Limitations:**
- Not suitable for large files (use git repo or external storage instead)
- Binary files not supported
- Changes require redeployment
**Real-World Examples:**
- `templates/compose/traccar.yaml` - XML configuration file
- `templates/compose/supabase.yaml` - Multiple config files
- `templates/compose/chaskiq.yaml` - Entrypoint script
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` (line 717)
- Storage: `app/Models/LocalFileVolume.php`
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
#### `is_directory` / `isDirectory`
**Type:** Boolean
**Default:** `true` (if neither `content` nor explicit flag provided)
**Purpose:** Indicate whether bind mount source should be created as directory or file
**Example Usage:**
```yaml
services:
app:
volumes:
# Explicit file
- type: bind
source: ./config.json
target: /app/config.json
is_directory: false # Create as file
# Explicit directory
- type: bind
source: ./logs
target: /var/log/app
is_directory: true # Create as directory
# Auto-detected as file (has content)
- type: bind
source: ./script.sh
target: /entrypoint.sh
content: |
#!/bin/sh
echo "Hello"
# is_directory: false implied by content presence
```
**Behavior:**
- If `is_directory: true` → Creates directory with `mkdir -p`
- If `is_directory: false` → Creates empty file with `touch`
- If `content` provided → Implies `is_directory: false`
- If neither specified → Defaults to `true` (directory)
**Naming Conventions:**
- `is_directory` (snake_case) - **Preferred**, consistent with PHP/Laravel conventions
- `isDirectory` (camelCase) - **Legacy support**, both work identically
**Use Cases:**
- Disambiguating files vs directories when no content provided
- Ensuring correct bind mount type for Docker
- Pre-creating mount points before container starts
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` (line 718)
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
### Custom Field Stripping
**Function:** `stripCoolifyCustomFields()` in `bootstrap/helpers/docker.php`
All custom fields are removed before the compose file is sent to Docker. This happens in two contexts:
**1. Validation (User-Triggered)**
```php
// In validateComposeFile() - Edit Docker Compose modal
$yaml_compose = Yaml::parse($compose);
$yaml_compose = stripCoolifyCustomFields($yaml_compose); // Strip custom fields
// Send to docker compose config for validation
```
**2. Deployment (Automatic)**
```php
// In Service::parse() - During deployment
$docker_compose = parseCompose($docker_compose_raw);
// Custom fields are processed and then stripped
// Final compose sent to Docker has no custom fields
```
**What Gets Stripped:**
- Service-level: `exclude_from_hc`
- Volume-level: `content`, `isDirectory`, `is_directory`
**What's Preserved:**
- All standard Docker Compose fields
- Environment variables
- Standard volume definitions (after custom fields removed)
### Important Notes
#### Long vs Short Volume Syntax
**✅ Long Syntax (Works with Custom Fields):**
```yaml
volumes:
- type: bind
source: ./data
target: /app/data
content: "Hello" # ✅ Custom fields work here
```
**❌ Short Syntax (Custom Fields Ignored):**
```yaml
volumes:
- "./data:/app/data" # ❌ Cannot add custom fields to strings
```
#### Docker Compose Compatibility
Custom fields are **Coolify-specific** and won't work with standalone `docker compose` CLI:
```bash
# ❌ Won't work - Docker doesn't recognize custom fields
docker compose -f compose.yaml up
# ✅ Works - Use Coolify's deployment (strips custom fields first)
# Deploy through Coolify UI or API
```
#### Editing Custom Fields
When editing in "Edit Docker Compose" modal:
- Custom fields are preserved in the editor
- "Validate" button strips them temporarily for Docker validation
- "Save" button preserves them in `docker_compose_raw`
- They're processed again on next deployment
### Template Examples
See these templates for real-world usage:
**Service Exclusions:**
- `templates/compose/budibase.yaml` - Excludes watchtower from monitoring
- `templates/compose/pgbackweb.yaml` - Excludes backup service
- `templates/compose/elasticsearch-with-kibana.yaml` - Excludes elasticsearch
**Inline Content:**
- `templates/compose/traccar.yaml` - XML configuration (multiline)
- `templates/compose/supabase.yaml` - Multiple config files
- `templates/compose/searxng.yaml` - Settings file
- `templates/compose/invoice-ninja.yaml` - Nginx config
**Directory Flags:**
- `templates/compose/paperless.yaml` - Explicit directory creation
### Testing
**Unit Tests:**
- `tests/Unit/StripCoolifyCustomFieldsTest.php` - Custom field stripping logic
- `tests/Unit/ExcludeFromHealthCheckTest.php` - Health check exclusion behavior
- `tests/Unit/ContainerStatusAggregatorTest.php` - Status aggregation with exclusions
**Test Coverage:**
- ✅ All custom fields (exclude_from_hc, content, isDirectory, is_directory)
- ✅ Multiline content (YAML `|` syntax)
- ✅ Short vs long volume syntax
- ✅ Field stripping without data loss
- ✅ Standard Docker Compose field preservation

View file

@ -8,6 +8,8 @@
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 @@
class GetContainersStatus
{
use AsAction;
use CalculatesExcludedStatus;
public string $jobQueue = 'high';
@ -31,6 +34,8 @@ class GetContainersStatus
protected ?Collection $applicationContainerRestartCounts;
protected ?Collection $serviceContainerStatuses;
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
{
$this->containers = $containers;
@ -98,11 +103,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
$labels = data_get($container, 'Config.Labels');
}
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
$containerHealth = data_get($container, 'State.Health.Status');
if ($containerStatus === 'restarting') {
$containerStatus = "restarting ($containerHealth)";
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "restarting:$healthSuffix";
} else {
$containerStatus = "$containerStatus ($containerHealth)";
$healthSuffix = $containerHealth ?? 'unknown';
$containerStatus = "$containerStatus:$healthSuffix";
}
$labels = Arr::undot(format_docker_labels_to_json($labels));
$applicationId = data_get($labels, 'coolify.applicationId');
@ -222,23 +229,34 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
if ($serviceLabelId) {
$subType = data_get($labels, 'coolify.service.subType');
$subId = data_get($labels, 'coolify.service.subId');
$service = $services->where('id', $serviceLabelId)->first();
if (! $service) {
$parentService = $services->where('id', $serviceLabelId)->first();
if (! $parentService) {
continue;
}
// Store container status for aggregation
if (! isset($this->serviceContainerStatuses)) {
$this->serviceContainerStatuses = collect();
}
$key = $serviceLabelId.':'.$subType.':'.$subId;
if (! $this->serviceContainerStatuses->has($key)) {
$this->serviceContainerStatuses->put($key, collect());
}
$containerName = data_get($labels, 'com.docker.compose.service');
if ($containerName) {
$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);
}
// Mark service as found
if ($subType === 'application') {
$service = $service->applications()->where('id', $subId)->first();
$service = $parentService->applications()->where('id', $subId)->first();
} else {
$service = $service->databases()->where('id', $subId)->first();
$service = $parentService->databases()->where('id', $subId)->first();
}
if ($service) {
$foundServices[] = "$service->id-$service->name";
$statusFromDb = $service->status;
if ($statusFromDb !== $containerStatus) {
$service->update(['status' => $containerStatus]);
} else {
$service->update(['last_online_at' => now()]);
}
}
}
}
@ -418,6 +436,9 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
}
}
// Aggregate multi-container service statuses
$this->aggregateServiceContainerStatuses($services);
ServiceChecked::dispatch($this->server->team->id);
}
@ -425,74 +446,88 @@ private function aggregateApplicationStatus($application, Collection $containerS
{
// 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;
$hasExited = 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;
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
}
private function aggregateServiceContainerStatuses($services)
{
if (! isset($this->serviceContainerStatuses) || $this->serviceContainerStatuses->isEmpty()) {
return;
}
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
$service = $services->where('id', $serviceId)->first();
if (! $service) {
continue;
}
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
}
if (! $subResource) {
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$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, 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;
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
// Update service sub-resource status with aggregated result
if ($aggregatedStatus) {
$statusFromDb = $subResource->status;
if ($statusFromDb !== $aggregatedStatus) {
$subResource->update(['status' => $aggregatedStatus]);
} else {
$subResource->update(['last_online_at' => now()]);
}
} elseif (str($status)->contains('exited')) {
$hasExited = true;
$hasUnhealthy = 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) {
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
}
// All containers are exited with no restart count - truly stopped
return 'exited (unhealthy)';
}
}

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,74 +64,25 @@ public function handle(Application $application)
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;
$hasExited = 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;
}
return ! ($serviceName && $excludedContainers->contains($serviceName));
});
$relevantContainerCount++;
$containerStatus = data_get($container, 'State.Status');
$containerHealth = data_get($container, 'State.Health.Status', 'unhealthy');
if ($containerStatus === 'restarting') {
$hasRestarting = true;
$hasUnhealthy = true;
} elseif ($containerStatus === 'running') {
$hasRunning = true;
if ($containerHealth === 'unhealthy') {
$hasUnhealthy = true;
}
} elseif ($containerStatus === 'exited') {
$hasExited = true;
$hasUnhealthy = true;
}
// If all containers are excluded, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if ($relevantContainers->isEmpty()) {
return $this->calculateExcludedStatus($containers, $excludedContainers);
}
if ($relevantContainerCount === 0) {
return 'running:healthy';
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
if ($hasRestarting) {
return 'degraded:unhealthy';
}
if ($hasRunning && $hasExited) {
return 'degraded:unhealthy';
}
if ($hasRunning) {
return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy';
}
return 'exited:unhealthy';
return $aggregator->aggregateFromContainers($relevantContainers);
}
}

View file

@ -13,6 +13,8 @@
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 @@
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use CalculatesExcludedStatus;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
@ -67,6 +70,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $applicationContainerStatuses;
public Collection $serviceContainerStatuses;
public bool $foundProxy = false;
public bool $foundLogDrainContainer = false;
@ -90,6 +95,7 @@ public function __construct(public Server $server, public $data)
$this->foundApplicationPreviewsIds = collect();
$this->foundServiceDatabaseIds = collect();
$this->applicationContainerStatuses = collect();
$this->serviceContainerStatuses = collect();
$this->allApplicationIds = collect();
$this->allDatabaseUuids = collect();
$this->allTcpProxyUuids = collect();
@ -108,7 +114,6 @@ public function handle()
$this->server->sentinelHeartbeat();
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
@ -141,65 +146,85 @@ public function handle()
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
$containerHealth = data_get($container, 'health_status', 'unhealthy');
$containerStatus = "$containerStatus ($containerHealth)";
$rawHealthStatus = data_get($container, 'health_status');
$containerHealth = $rawHealthStatus ?? 'unknown';
$containerStatus = "$containerStatus:$containerHealth";
$labels = collect(data_get($container, 'labels'));
$coolify_managed = $labels->has('coolify.managed');
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);
if (! $coolify_managed) {
continue;
}
$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
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);
}
} 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->isRunning($containerStatus)) {
$this->foundServiceApplicationIds->push($subId);
$this->updateServiceSubStatus($serviceId, $subType, $subId, $containerStatus);
} elseif ($subType === 'database' && $this->isRunning($containerStatus)) {
$this->foundServiceDatabaseIds->push($subId);
$this->updateServiceSubStatus($serviceId, $subType, $subId, $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);
}
}
}
@ -218,6 +243,9 @@ public function handle()
// Aggregate multi-container application statuses
$this->aggregateMultiContainerStatuses();
// Aggregate multi-container service statuses
$this->aggregateServiceContainerStatuses();
$this->checkLogDrainContainer();
}
@ -235,57 +263,28 @@ private function aggregateMultiContainerStatuses()
// 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;
foreach ($relevantStatuses as $status) {
if (str($status)->contains('running')) {
$hasRunning = true;
if (str($status)->contains('unhealthy')) {
$hasUnhealthy = true;
}
}
}
$aggregatedStatus = null;
if ($hasRunning) {
$aggregatedStatus = $hasUnhealthy ? 'running (unhealthy)' : '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) {
@ -295,6 +294,66 @@ private function aggregateMultiContainerStatuses()
}
}
private function aggregateServiceContainerStatuses()
{
if ($this->serviceContainerStatuses->isEmpty()) {
return;
}
foreach ($this->serviceContainerStatuses as $key => $containerStatuses) {
// Parse key: serviceId:subType:subId
[$serviceId, $subType, $subId] = explode(':', $key);
$service = $this->services->where('id', $serviceId)->first();
if (! $service) {
continue;
}
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
$subResource = $service->applications()->where('id', $subId)->first();
} elseif ($subType === 'database') {
$subResource = $service->databases()->where('id', $subId)->first();
}
if (! $subResource) {
continue;
}
// Parse docker compose from service to check for excluded containers
$dockerComposeRaw = data_get($service, 'docker_compose_raw');
$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, calculate status from excluded containers
if ($relevantStatuses->isEmpty()) {
$aggregatedStatus = $this->calculateExcludedStatusFromStrings($containerStatuses);
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
}
continue;
}
// 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) {
$subResource->status = $aggregatedStatus;
$subResource->save();
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
$application = $this->applications->where('id', $applicationId)->first();

View file

@ -669,21 +669,23 @@ protected function serverStatus(): Attribute
{
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;
}
);
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -173,6 +174,21 @@ public function deleteConnectedNetworks()
instant_remote_process(["docker network rm {$this->uuid}"], $server, false);
}
/**
* Calculate the service's aggregate status from its applications and databases.
*
* This method aggregates status from Eloquent model relationships (not Docker containers).
* It differs from the CalculatesExcludedStatus trait which works with Docker container objects
* during container inspection. This accessor runs on-demand for UI display and works with
* already-stored status strings from the database.
*
* Status format: "{status}:{health}" or "{status}:{health}:excluded"
* - Status values: running, exited, degraded, starting, paused, restarting
* - Health values: healthy, unhealthy, unknown
* - :excluded suffix: Indicates all containers are excluded from health monitoring
*
* @return string The aggregate status in format "status:health" or "status:health:excluded"
*/
public function getStatusAttribute()
{
if ($this->isStarting()) {
@ -182,71 +198,97 @@ public function getStatusAttribute()
$applications = $this->applications;
$databases = $this->databases;
$complexStatus = null;
$complexHealth = null;
[$complexStatus, $complexHealth, $hasNonExcluded] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: false
);
foreach ($applications as $application) {
if ($application->exclude_from_status) {
continue;
// If all services are excluded from status checks, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {
[$excludedStatus, $excludedHealth] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: true
);
// Return status with :excluded suffix to indicate monitoring is disabled
if ($excludedStatus && $excludedHealth) {
return "{$excludedStatus}:{$excludedHealth}:excluded";
}
$status = str($application->status)->before('(')->trim();
$health = str($application->status)->between('(', ')')->trim();
if ($complexStatus === 'degraded') {
continue;
}
if ($status->startsWith('running')) {
if ($complexStatus === 'exited') {
$complexStatus = 'degraded';
} else {
$complexStatus = 'running';
}
} elseif ($status->startsWith('restarting')) {
$complexStatus = 'degraded';
} elseif ($status->startsWith('exited')) {
$complexStatus = 'exited';
}
if ($health->value() === 'healthy') {
if ($complexHealth === 'unhealthy') {
continue;
}
$complexHealth = 'healthy';
} else {
$complexHealth = 'unhealthy';
}
}
foreach ($databases as $database) {
if ($database->exclude_from_status) {
continue;
}
$status = str($database->status)->before('(')->trim();
$health = str($database->status)->between('(', ')')->trim();
if ($complexStatus === 'degraded') {
continue;
}
if ($status->startsWith('running')) {
if ($complexStatus === 'exited') {
$complexStatus = 'degraded';
} else {
$complexStatus = 'running';
}
} elseif ($status->startsWith('restarting')) {
$complexStatus = 'degraded';
} elseif ($status->startsWith('exited')) {
$complexStatus = 'exited';
}
if ($health->value() === 'healthy') {
if ($complexHealth === 'unhealthy') {
continue;
}
$complexHealth = 'healthy';
} else {
$complexHealth = 'unhealthy';
// If no status was calculated at all (no containers exist), return unknown
if ($excludedStatus === null && $excludedHealth === null) {
return 'unknown:unknown:excluded';
}
return 'exited:unhealthy:excluded';
}
return "{$complexStatus}:{$complexHealth}";
}
/**
* Aggregate status and health from collections of applications and databases.
*
* This helper method consolidates status aggregation logic using ContainerStatusAggregator.
* It processes container status strings stored in the database (not live Docker data).
*
* @param \Illuminate\Database\Eloquent\Collection $applications Collection of Application models
* @param \Illuminate\Database\Eloquent\Collection $databases Collection of Database models
* @param bool $excludedOnly If true, only process excluded containers; if false, only process non-excluded
* @return array{0: string|null, 1: string|null, 2?: bool} [status, health, hasNonExcluded (only when excludedOnly=false)]
*/
private function aggregateResourceStatuses($applications, $databases, bool $excludedOnly = false): array
{
$hasNonExcluded = false;
$statusStrings = collect();
// Process both applications and databases using the same logic
$resources = $applications->concat($databases);
foreach ($resources as $resource) {
$isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded');
// Filter based on excludedOnly flag
if ($excludedOnly && ! $isExcluded) {
continue;
}
if (! $excludedOnly && $isExcluded) {
continue;
}
if (! $excludedOnly) {
$hasNonExcluded = true;
}
// Strip :excluded suffix before aggregation (it's in the 3rd part of "status:health:excluded")
$status = str($resource->status)->before(':excluded')->toString();
$statusStrings->push($status);
}
// If no status strings collected, return nulls
if ($statusStrings->isEmpty()) {
return $excludedOnly ? [null, null] : [null, null, $hasNonExcluded];
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($statusStrings);
// Parse the aggregated "status:health" string
$parts = explode(':', $aggregatedStatus);
$status = $parts[0] ?? null;
$health = $parts[1] ?? null;
if ($excludedOnly) {
return [$status, $health];
}
return [$status, $health, $hasNonExcluded];
}
public function extraFields()
{
$fields = collect([]);

View file

@ -0,0 +1,253 @@
<?php
namespace App\Services;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* 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
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
Log::warning('Negative maxRestartCount corrected to 0', [
'original_value' => $maxRestartCount,
]);
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containerStatuses->count(),
]);
}
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
{
// Validate maxRestartCount parameter
if ($maxRestartCount < 0) {
Log::warning('Negative maxRestartCount corrected to 0', [
'original_value' => $maxRestartCount,
]);
$maxRestartCount = 0;
}
if ($maxRestartCount > 1000) {
Log::warning('High maxRestartCount detected', [
'maxRestartCount' => $maxRestartCount,
'containers' => $containers->count(),
]);
}
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,166 @@
<?php
namespace App\Traits;
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Exception\ParseException;
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);
// Validate structure
if (! is_array($dockerCompose)) {
Log::warning('Docker Compose YAML did not parse to array', [
'yaml_length' => strlen($dockerComposeRaw),
'parsed_type' => gettype($dockerCompose),
]);
return $excludedContainers;
}
$services = data_get($dockerCompose, 'services', []);
if (! is_array($services)) {
Log::warning('Docker Compose services is not an array', [
'services_type' => gettype($services),
]);
return $excludedContainers;
}
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 (ParseException $e) {
// Specific YAML parsing errors
Log::warning('Failed to parse Docker Compose YAML for health check exclusions', [
'error' => $e->getMessage(),
'line' => $e->getParsedLine(),
'snippet' => $e->getSnippet(),
]);
return $excludedContainers;
} catch (\Exception $e) {
// Unexpected errors
Log::error('Unexpected error parsing Docker Compose YAML', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return $excludedContainers;
}
return $excludedContainers;
}
}

View file

@ -1083,6 +1083,44 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker
return $docker_compose;
}
/**
* Remove Coolify's custom Docker Compose fields from parsed YAML array
*
* Coolify extends Docker Compose with custom fields that are processed during
* parsing and deployment but must be removed before sending to Docker.
*
* Custom fields:
* - exclude_from_hc (service-level): Exclude service from health check monitoring
* - content (volume-level): Auto-create file with specified content during init
* - isDirectory / is_directory (volume-level): Mark bind mount as directory
*
* @param array $yamlCompose Parsed Docker Compose array
* @return array Cleaned Docker Compose array with custom fields removed
*/
function stripCoolifyCustomFields(array $yamlCompose): array
{
foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) {
// Remove service-level custom fields
unset($yamlCompose['services'][$serviceName]['exclude_from_hc']);
// Remove volume-level custom fields (only for long syntax - arrays)
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $volumeName => $volume) {
// Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data')
if (! is_array($volume)) {
continue;
}
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']);
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']);
unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']);
}
}
}
return $yamlCompose;
}
function validateComposeFile(string $compose, int $server_id): string|Throwable
{
$uuid = Str::random(18);
@ -1092,16 +1130,10 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
throw new \Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
foreach ($yaml_compose['services'] as $service_name => $service) {
if (! isset($service['volumes'])) {
continue;
}
foreach ($service['volumes'] as $volume_name => $volume) {
if (data_get($volume, 'type') === 'bind' && data_get($volume, 'content')) {
unset($yaml_compose['services'][$service_name]['volumes'][$volume_name]['content']);
}
}
}
// Remove Coolify's custom fields before Docker validation
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
$base64_compose = base64_encode(Yaml::dump($yaml_compose));
instant_remote_process([
"echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null",

View file

@ -3153,3 +3153,46 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
return $collection;
}
/**
* Transform colon-delimited status format to human-readable parentheses format.
*
* Handles Docker container status formats with optional health check status and exclusion modifiers.
*
* Examples:
* - running:healthy Running (healthy)
* - running:unhealthy:excluded Running (unhealthy, excluded)
* - exited:excluded Exited (excluded)
* - Proxy:running Proxy:running (preserved as-is for headline formatting)
* - running Running
*
* @param string $status The status string to format
* @return string The formatted status string
*/
function formatContainerStatus(string $status): string
{
// Preserve Proxy statuses as-is (they follow different format)
if (str($status)->startsWith('Proxy')) {
return str($status)->headline()->value();
}
// Check for :excluded suffix
$isExcluded = str($status)->endsWith(':excluded');
$parts = explode(':', $status);
if ($isExcluded) {
if (count($parts) === 3) {
// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)
return str($parts[0])->headline().' ('.$parts[1].', excluded)';
} else {
// No health status: exited:excluded → Exited (excluded)
return str($parts[0])->headline().' (excluded)';
}
} elseif (count($parts) >= 2) {
// Regular colon format: running:healthy → Running (healthy)
return str($parts[0])->headline().' ('.$parts[1].')';
} else {
// Simple status: running → Running
return str($status)->headline()->value();
}
}

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

@ -4,6 +4,26 @@
'lastDeploymentLink' => null,
'noLoading' => false,
])
@php
// Handle both colon format (backend) and parentheses format (from services.blade.php)
// running:healthy → Running (healthy)
// running (healthy) → running (healthy) (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">
<div class="flex items-center">
<div wire:loading.delay.longer wire:target="checkProxy(true)" class="badge badge-warning"></div>
@ -12,21 +32,27 @@
@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 ($healthStatus && !str($displayStatus)->contains('('))
<div class="text-xs text-success">({{ $healthStatus }})</div>
@endif
@php
$showUnknownHelper =
!str($status)->startsWith('Proxy') &&
(str($status)->contains('unknown') || str($healthStatus)->contains('unknown'));
$showUnhealthyHelper =
!str($status)->startsWith('Proxy') &&
!str($status)->contains('(') &&
str($status)->contains('unhealthy');
(str($status)->contains('unhealthy') || str($healthStatus)->contains('unhealthy'));
@endphp
@if ($showUnhealthyHelper)
@if ($showUnknownHelper)
<div class="px-2">
<x-helper
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>This doesn't mean that the resource is malfunctioning.</span><br><br>- If the resource is accessible, it indicates that no health check is configured - it is not mandatory.<br>- If the resource is not accessible (returning 404 or 503), it may indicate that a health check is needed and has not passed. <span class='dark:text-warning text-coollabs'>Your action is required.</span><br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
helper="No health check configured. <span class='dark:text-warning text-coollabs'>The resource may be functioning normally.</span><br><br>Traefik and Caddy will route traffic to this container even without a health check. However, configuring a health check is recommended to ensure the resource is ready before receiving traffic.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
<x-slot:icon>
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg">
@ -36,6 +62,22 @@
</svg>
</x-slot:icon>
</x-helper>
</div>
@endif
@if ($showUnhealthyHelper)
<div class="px-2">
<x-helper
helper="Unhealthy state. <span class='dark:text-warning text-coollabs'>The health check is failing.</span><br><br>This resource will <span class='dark:text-warning text-coollabs'>NOT work with Traefik</span> as it expects a healthy state. Your action is required to fix the health check or the underlying issue causing it to fail.<br><br>More details in the <a href='https://coolify.io/docs/knowledge-base/proxy/traefik/healthchecks' class='underline dark:text-warning text-coollabs' target='_blank'>documentation</a>.">
<x-slot:icon>
<svg class="hidden w-4 h-4 dark:text-warning lg:block" 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">
</path>
</svg>
</x-slot:icon>
</x-helper>
</div>
@endif
</div>

View file

@ -1,13 +1,16 @@
@if (str($complexStatus)->contains('running'))
<x-status.running :status="$complexStatus" />
@elseif(str($complexStatus)->contains('starting'))
<x-status.restarting :status="$complexStatus" />
@elseif(str($complexStatus)->contains('restarting'))
<x-status.restarting :status="$complexStatus" />
@elseif(str($complexStatus)->contains('degraded'))
<x-status.degraded :status="$complexStatus" />
@php
$displayStatus = formatContainerStatus($complexStatus);
@endphp
@if (str($displayStatus)->lower()->contains('running'))
<x-status.running :status="$displayStatus" />
@elseif(str($displayStatus)->lower()->contains('starting'))
<x-status.restarting :status="$displayStatus" />
@elseif(str($displayStatus)->lower()->contains('restarting'))
<x-status.restarting :status="$displayStatus" />
@elseif(str($displayStatus)->lower()->contains('degraded'))
<x-status.degraded :status="$displayStatus" />
@else
<x-status.stopped :status="$complexStatus" />
<x-status.stopped :status="$displayStatus" />
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'

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 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<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 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<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 @@ class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<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,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@endcan
</span>
@endif
<div class="pt-2 text-xs">{{ $application->status }}</div>
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
</div>
<div class="flex items-center px-4">
<a class="mx-4 text-xs font-bold hover:underline"
@ -139,7 +139,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@if ($database->description)
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
@endif
<div class="text-xs">{{ $database->status }}</div>
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
</div>
<div class="flex items-center px-4">
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)

View file

@ -34,7 +34,7 @@
<svg class="w-5 h-5 dark:text-warning" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2">
<path d="M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M19.933 13.041 a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747" />
<path d="M20 4v5h-5" />
</g>
</svg>

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

@ -0,0 +1,342 @@
<?php
use App\Models\Application;
use Mockery;
/**
* Unit tests to verify that containers without health checks are not
* incorrectly marked as unhealthy.
*
* This tests the fix for the issue where defaulting missing health status
* to 'unhealthy' would treat containers without healthchecks as unhealthy.
*
* The fix removes the 'unhealthy' default and only checks health status
* when it explicitly exists and equals 'unhealthy'.
*/
it('does not mark containers as unhealthy when health status is missing', function () {
// Mock an application with a server
$application = Mockery::mock(Application::class)->makePartial();
$server = Mockery::mock('App\Models\Server')->makePartial();
$destination = Mockery::mock('App\Models\StandaloneDocker')->makePartial();
$destination->shouldReceive('getAttribute')
->with('server')
->andReturn($server);
$application->shouldReceive('getAttribute')
->with('destination')
->andReturn($destination);
$application->shouldReceive('getAttribute')
->with('additional_servers')
->andReturn(collect());
$server->shouldReceive('getAttribute')
->with('id')
->andReturn(1);
$server->shouldReceive('isFunctional')
->andReturn(true);
// Create a container without health check (State.Health.Status is null)
$containerWithoutHealthCheck = [
'Config' => [
'Labels' => [
'com.docker.compose.service' => 'web',
],
],
'State' => [
'Status' => 'running',
// Note: State.Health.Status is intentionally missing
],
];
// Mock the remote process to return our container
$application->shouldReceive('getAttribute')
->with('id')
->andReturn(123);
// We can't easily test the private aggregateContainerStatuses method directly,
// but we can verify that the code doesn't default to 'unhealthy'
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify the fix: health status should not default to 'unhealthy'
expect($aggregatorFile)
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
->toContain("data_get(\$container, 'State.Health.Status')");
// Verify the health check logic
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\') {');
});
it('only marks containers as unhealthy when health status explicitly equals unhealthy', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify the service checks for explicit 'unhealthy' status
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\') {')
->toContain('$hasUnhealthy = true;');
});
it('handles missing health status correctly in GetContainersStatus', function () {
$getContainersStatusFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Verify health status doesn't default to 'unhealthy'
expect($getContainersStatusFile)
->not->toContain("data_get(\$container, 'State.Health.Status', 'unhealthy')")
->toContain("data_get(\$container, 'State.Health.Status')");
// Verify it uses 'unknown' when health status is missing (now using colon format)
expect($getContainersStatusFile)
->toContain('$healthSuffix = $containerHealth ?? \'unknown\';')
->toContain('ContainerStatusAggregator'); // Uses the service
});
it('treats containers with running status and no healthcheck as not unhealthy', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// The logic should be:
// 1. Get health status (may be null)
// 2. Only mark as unhealthy if health status EXISTS and equals 'unhealthy'
// 3. Don't mark as unhealthy if health status is null/missing
// Verify the condition explicitly checks for unhealthy
expect($aggregatorFile)
->toContain('if ($health === \'unhealthy\')');
// Verify this check is done for running containers
expect($aggregatorFile)
->toContain('} elseif ($state === \'running\') {')
->toContain('$hasRunning = true;');
});
it('tracks unknown health state in aggregation', function () {
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify that $hasUnknown tracking variable exists in the service
expect($aggregatorFile)
->toContain('$hasUnknown = false;');
// Verify that unknown state is detected in status parsing
expect($aggregatorFile)
->toContain("str(\$status)->contains('unknown')")
->toContain('$hasUnknown = true;');
});
it('preserves unknown health state in aggregated status with correct priority', function () {
// 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($aggregatorFile)
->toContain('if ($hasUnhealthy) {')
->toContain("return 'running:unhealthy';")
->toContain('} elseif ($hasUnknown) {')
->toContain("return 'running:unknown';")
->toContain('} else {')
->toContain("return 'running:healthy';");
});
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($aggregatorFile)
->toContain('$hasUnknown = false;');
// Verify that unknown state is detected when health is null or 'starting'
expect($aggregatorFile)
->toContain('} elseif (is_null($health) || $health === \'starting\') {')
->toContain('$hasUnknown = true;');
});
it('preserves unknown health state in ContainerStatusAggregator aggregated status', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify three-way priority for running containers in the service
expect($aggregatorFile)
->toContain('if ($hasUnhealthy) {')
->toContain("return 'running:unhealthy';")
->toContain('} elseif ($hasUnknown) {')
->toContain("return 'running:unknown';")
->toContain('} else {')
->toContain("return 'running:healthy';");
// Verify ComplexStatusCheck delegates to the service
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
expect($complexStatusCheckFile)
->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 correctly
expect($serviceFile)
->toContain("} elseif (\$health->value() === 'unknown') {")
->toContain("if (\$aggregateHealth !== 'unhealthy') {")
->toContain("\$aggregateHealth = 'unknown';");
// 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)->toBeGreaterThan(0);
});
it('handles starting state (created/starting) in GetContainersStatus', function () {
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($aggregatorFile)
->toContain('$hasStarting = false;');
// Verify detection for created/starting states
expect($aggregatorFile)
->toContain("str(\$status)->contains('created') || str(\$status)->contains('starting')")
->toContain('$hasStarting = true;');
// Verify aggregation returns starting status (colon format)
expect($aggregatorFile)
->toContain('if ($hasStarting) {')
->toContain("return 'starting:unknown';");
});
it('handles paused state in GetContainersStatus', function () {
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($aggregatorFile)
->toContain('$hasPaused = false;');
// Verify detection for paused state
expect($aggregatorFile)
->toContain("str(\$status)->contains('paused')")
->toContain('$hasPaused = true;');
// Verify aggregation returns paused status (colon format)
expect($aggregatorFile)
->toContain('if ($hasPaused) {')
->toContain("return 'paused:unknown';");
});
it('handles dead/removing states in GetContainersStatus', function () {
// State machine logic now in ContainerStatusAggregator service
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variable exists
expect($aggregatorFile)
->toContain('$hasDead = false;');
// Verify detection for dead/removing states
expect($aggregatorFile)
->toContain("str(\$status)->contains('dead') || str(\$status)->contains('removing')")
->toContain('$hasDead = true;');
// Verify aggregation returns degraded status (colon format)
expect($aggregatorFile)
->toContain('if ($hasDead) {')
->toContain("return 'degraded:unhealthy';");
});
it('handles edge case states in ContainerStatusAggregator for all containers', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify tracking variables exist in the service
expect($aggregatorFile)
->toContain('$hasStarting = false;')
->toContain('$hasPaused = false;')
->toContain('$hasDead = false;');
// Verify detection for created/starting
expect($aggregatorFile)
->toContain("} elseif (\$state === 'created' || \$state === 'starting') {")
->toContain('$hasStarting = true;');
// Verify detection for paused
expect($aggregatorFile)
->toContain("} elseif (\$state === 'paused') {")
->toContain('$hasPaused = true;');
// Verify detection for dead/removing
expect($aggregatorFile)
->toContain("} elseif (\$state === 'dead' || \$state === 'removing') {")
->toContain('$hasDead = true;');
});
it('handles edge case states in ContainerStatusAggregator aggregation', function () {
$aggregatorFile = file_get_contents(__DIR__.'/../../app/Services/ContainerStatusAggregator.php');
// Verify aggregation logic for edge cases in the service
expect($aggregatorFile)
->toContain('if ($hasDead) {')
->toContain("return 'degraded:unhealthy';")
->toContain('if ($hasPaused) {')
->toContain("return 'paused:unknown';")
->toContain('if ($hasStarting) {')
->toContain("return 'starting:unknown';");
});
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)->toBeGreaterThan(0, 'created/starting handling should exist');
// Check for paused handling pattern
$pausedCount = substr_count($serviceFile, "\$status->startsWith('paused')");
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)->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 use the trait for calculating excluded status
expect($getContainersStatusFile)
->toContain('CalculatesExcludedStatus');
// Verify that we use the trait to calculate excluded status
expect($getContainersStatusFile)
->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 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 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,540 @@
<?php
use App\Services\ContainerStatusAggregator;
use Illuminate\Support\Facades\Log;
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');
});
});
describe('maxRestartCount validation', function () {
test('negative maxRestartCount is corrected to 0 in aggregateFromStrings', function () {
// Mock the Log facade to avoid "facade root not set" error in unit tests
Log::shouldReceive('warning')->once();
$statuses = collect(['exited']);
// With negative value, should be treated as 0 (no restarts)
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: -5);
// Should return exited:unhealthy (not degraded) since corrected to 0
expect($result)->toBe('exited:unhealthy');
});
test('negative maxRestartCount is corrected to 0 in aggregateFromContainers', function () {
// Mock the Log facade to avoid "facade root not set" error in unit tests
Log::shouldReceive('warning')->once();
$containers = collect([
[
'State' => [
'Status' => 'exited',
'ExitCode' => 1,
],
],
]);
// With negative value, should be treated as 0 (no restarts)
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: -10);
// Should return exited:unhealthy (not degraded) since corrected to 0
expect($result)->toBe('exited:unhealthy');
});
test('zero maxRestartCount works correctly', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
// Zero is valid default - no crash loop detection
expect($result)->toBe('exited:unhealthy');
});
test('positive maxRestartCount works correctly', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
// Positive value enables crash loop detection
expect($result)->toBe('degraded:unhealthy');
});
test('crash loop detection still functions after validation', function () {
$statuses = collect(['exited']);
// Test with various positive restart counts
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
->toBe('degraded:unhealthy');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
->toBe('degraded:unhealthy');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
->toBe('degraded:unhealthy');
});
test('default maxRestartCount parameter works', function () {
$statuses = collect(['exited']);
// Call without specifying maxRestartCount (should default to 0)
$result = $this->aggregator->aggregateFromStrings($statuses);
expect($result)->toBe('exited:unhealthy');
});
});

View file

@ -0,0 +1,154 @@
<?php
/**
* Unit tests to verify that applications and services with all containers
* excluded from health checks (exclude_from_hc: true) show correct status.
*
* These tests verify the fix for the issue where services with all containers
* excluded would show incorrect status, causing broken UI state.
*
* The fix now returns status with :excluded suffix to show real container state
* while indicating monitoring is disabled (e.g., "running:excluded").
*/
it('ensures ComplexStatusCheck returns excluded status when all containers excluded', function () {
$complexStatusCheckFile = file_get_contents(__DIR__.'/../../app/Actions/Shared/ComplexStatusCheck.php');
// 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 ($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 '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 () {
$serviceModelFile = file_get_contents(__DIR__.'/../../app/Models/Service.php');
// 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('exclude_from_status')
->toContain(':excluded')
->toContain('CalculatesExcludedStatus');
});
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: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:unknown:excluded';");
});
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 calculates and returns status with :excluded suffix
expect($getContainersStatusFile)
->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 parsed using trait helper
expect($complexStatusCheckFile)
->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 parsed using trait helper
expect($getContainersStatusFile)
->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 transforms :excluded suffix to (excluded) for better display
expect($servicesStatusFile)
->toContain('$isExcluded = str($complexStatus)->endsWith(\':excluded\');')
->toContain('$parts = explode(\':\', $complexStatus);')
->toContain('// Has health status: running:unhealthy:excluded → Running (unhealthy, excluded)')
->toContain('// No health status: exited:excluded → Exited (excluded)');
});
it('ensures UI handles excluded status in service heading buttons', function () {
$headingFile = file_get_contents(__DIR__.'/../../resources/views/livewire/project/service/heading.blade.php');
// Verify that the heading properly handles running/degraded/exited status with :excluded suffix
// The logic should use contains() to match the base status (running, degraded, exited)
// which will work for both regular statuses and :excluded suffixed ones
expect($headingFile)
->toContain('str($service->status)->contains(\'running\')')
->toContain('str($service->status)->contains(\'degraded\')')
->toContain('str($service->status)->contains(\'exited\')');
});
/**
* Unit tests for YAML validation in CalculatesExcludedStatus trait
*/
it('ensures YAML validation has proper exception handling for parse errors', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify that ParseException is imported and caught separately from generic Exception
expect($traitFile)
->toContain('use Symfony\Component\Yaml\Exception\ParseException')
->toContain('use Illuminate\Support\Facades\Log')
->toContain('} catch (ParseException $e) {')
->toContain('} catch (\Exception $e) {');
});
it('ensures YAML validation logs parse errors with context', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify that parse errors are logged with useful context (error message, line, snippet)
expect($traitFile)
->toContain('Log::warning(\'Failed to parse Docker Compose YAML for health check exclusions\'')
->toContain('\'error\' => $e->getMessage()')
->toContain('\'line\' => $e->getParsedLine()')
->toContain('\'snippet\' => $e->getSnippet()');
});
it('ensures YAML validation logs unexpected errors', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify that unexpected errors are logged with error level
expect($traitFile)
->toContain('Log::error(\'Unexpected error parsing Docker Compose YAML\'')
->toContain('\'trace\' => $e->getTraceAsString()');
});
it('ensures YAML validation checks structure after parsing', function () {
$traitFile = file_get_contents(__DIR__.'/../../app/Traits/CalculatesExcludedStatus.php');
// Verify that parsed result is validated to be an array
expect($traitFile)
->toContain('if (! is_array($dockerCompose)) {')
->toContain('Log::warning(\'Docker Compose YAML did not parse to array\'');
// Verify that services is validated to be an array
expect($traitFile)
->toContain('if (! is_array($services)) {')
->toContain('Log::warning(\'Docker Compose services is not an array\'');
});

View file

@ -0,0 +1,201 @@
<?php
describe('formatContainerStatus helper', function () {
describe('colon-delimited format parsing', function () {
it('transforms running:healthy to Running (healthy)', function () {
$result = formatContainerStatus('running:healthy');
expect($result)->toBe('Running (healthy)');
});
it('transforms running:unhealthy to Running (unhealthy)', function () {
$result = formatContainerStatus('running:unhealthy');
expect($result)->toBe('Running (unhealthy)');
});
it('transforms exited:0 to Exited (0)', function () {
$result = formatContainerStatus('exited:0');
expect($result)->toBe('Exited (0)');
});
it('transforms restarting:starting to Restarting (starting)', function () {
$result = formatContainerStatus('restarting:starting');
expect($result)->toBe('Restarting (starting)');
});
});
describe('excluded suffix handling', function () {
it('transforms running:unhealthy:excluded to Running (unhealthy, excluded)', function () {
$result = formatContainerStatus('running:unhealthy:excluded');
expect($result)->toBe('Running (unhealthy, excluded)');
});
it('transforms running:healthy:excluded to Running (healthy, excluded)', function () {
$result = formatContainerStatus('running:healthy:excluded');
expect($result)->toBe('Running (healthy, excluded)');
});
it('transforms exited:excluded to Exited (excluded)', function () {
$result = formatContainerStatus('exited:excluded');
expect($result)->toBe('Exited (excluded)');
});
it('transforms stopped:excluded to Stopped (excluded)', function () {
$result = formatContainerStatus('stopped:excluded');
expect($result)->toBe('Stopped (excluded)');
});
});
describe('simple status format', function () {
it('transforms running to Running', function () {
$result = formatContainerStatus('running');
expect($result)->toBe('Running');
});
it('transforms exited to Exited', function () {
$result = formatContainerStatus('exited');
expect($result)->toBe('Exited');
});
it('transforms stopped to Stopped', function () {
$result = formatContainerStatus('stopped');
expect($result)->toBe('Stopped');
});
it('transforms restarting to Restarting', function () {
$result = formatContainerStatus('restarting');
expect($result)->toBe('Restarting');
});
it('transforms degraded to Degraded', function () {
$result = formatContainerStatus('degraded');
expect($result)->toBe('Degraded');
});
});
describe('Proxy status preservation', function () {
it('preserves Proxy:running without parsing colons', function () {
$result = formatContainerStatus('Proxy:running');
expect($result)->toBe('Proxy:running');
});
it('preserves Proxy:exited without parsing colons', function () {
$result = formatContainerStatus('Proxy:exited');
expect($result)->toBe('Proxy:exited');
});
it('preserves Proxy:healthy without parsing colons', function () {
$result = formatContainerStatus('Proxy:healthy');
expect($result)->toBe('Proxy:healthy');
});
it('applies headline formatting to Proxy statuses', function () {
$result = formatContainerStatus('proxy:running');
expect($result)->toBe('Proxy (running)');
});
});
describe('headline transformation', function () {
it('applies headline to simple lowercase status', function () {
$result = formatContainerStatus('running');
expect($result)->toBe('Running');
});
it('applies headline to uppercase status', function () {
// headline() adds spaces between capital letters
$result = formatContainerStatus('RUNNING');
expect($result)->toBe('R U N N I N G');
});
it('applies headline to mixed case status', function () {
// headline() adds spaces between capital letters
$result = formatContainerStatus('RuNnInG');
expect($result)->toBe('Ru Nn In G');
});
it('applies headline to first part of colon format', function () {
// headline() adds spaces between capital letters
$result = formatContainerStatus('RUNNING:healthy');
expect($result)->toBe('R U N N I N G (healthy)');
});
});
describe('edge cases', function () {
it('handles empty string gracefully', function () {
$result = formatContainerStatus('');
expect($result)->toBe('');
});
it('handles multiple colons beyond expected format', function () {
// Only first two parts should be used (or three with :excluded)
$result = formatContainerStatus('running:healthy:extra:data');
expect($result)->toBe('Running (healthy)');
});
it('handles status with spaces in health part', function () {
$result = formatContainerStatus('running:health check failed');
expect($result)->toBe('Running (health check failed)');
});
it('handles single colon with empty second part', function () {
$result = formatContainerStatus('running:');
expect($result)->toBe('Running ()');
});
});
describe('real-world scenarios', function () {
it('handles typical running healthy container', function () {
$result = formatContainerStatus('running:healthy');
expect($result)->toBe('Running (healthy)');
});
it('handles degraded container with health issues', function () {
$result = formatContainerStatus('degraded:unhealthy');
expect($result)->toBe('Degraded (unhealthy)');
});
it('handles excluded unhealthy container', function () {
$result = formatContainerStatus('running:unhealthy:excluded');
expect($result)->toBe('Running (unhealthy, excluded)');
});
it('handles proxy container status', function () {
$result = formatContainerStatus('Proxy:running');
expect($result)->toBe('Proxy:running');
});
it('handles stopped container', function () {
$result = formatContainerStatus('stopped');
expect($result)->toBe('Stopped');
});
});
});

View file

@ -0,0 +1,90 @@
<?php
/**
* Unit tests for GetContainersStatus service aggregation logic (SSH path).
*
* These tests verify that the SSH-based status updates (GetContainersStatus)
* correctly aggregates container statuses for services with multiple containers,
* using the same logic as PushServerUpdateJob (Sentinel path).
*
* This ensures consistency across both status update paths and prevents
* race conditions where the last container processed wins.
*/
it('implements service multi-container aggregation in SSH path', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Verify service container collection property exists
expect($actionFile)
->toContain('protected ?Collection $serviceContainerStatuses;');
// Verify aggregateServiceContainerStatuses method exists
expect($actionFile)
->toContain('private function aggregateServiceContainerStatuses($services)')
->toContain('$this->aggregateServiceContainerStatuses($services);');
// Verify service aggregation uses same logic as applications
expect($actionFile)
->toContain('$hasUnknown = false;');
});
it('services use same priority as applications in SSH path', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Both aggregation methods should use the same priority logic
$priorityLogic = <<<'PHP'
if ($hasUnhealthy) {
$aggregatedStatus = 'running (unhealthy)';
} elseif ($hasUnknown) {
$aggregatedStatus = 'running (unknown)';
} else {
$aggregatedStatus = 'running (healthy)';
}
PHP;
// Should appear in service aggregation
expect($actionFile)->toContain($priorityLogic);
});
it('collects service containers before aggregating in SSH path', function () {
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Verify service containers are collected, not immediately updated
expect($actionFile)
->toContain('$key = $serviceLabelId.\':\'.$subType.\':\'.$subId;')
->toContain('$this->serviceContainerStatuses->get($key)->put($containerName, $containerStatus);');
// Verify aggregation happens before ServiceChecked dispatch
expect($actionFile)
->toContain('$this->aggregateServiceContainerStatuses($services);')
->toContain('ServiceChecked::dispatch($this->server->team->id);');
});
it('SSH and Sentinel paths use identical service aggregation logic', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Both should track the same status flags
expect($jobFile)->toContain('$hasUnknown = false;');
expect($actionFile)->toContain('$hasUnknown = false;');
// Both should check for unknown status
expect($jobFile)->toContain('if (str($status)->contains(\'unknown\')) {');
expect($actionFile)->toContain('if (str($status)->contains(\'unknown\')) {');
// Both should have elseif for unknown priority
expect($jobFile)->toContain('} elseif ($hasUnknown) {');
expect($actionFile)->toContain('} elseif ($hasUnknown) {');
});
it('handles service status updates consistently', function () {
$jobFile = file_get_contents(__DIR__.'/../../app/Jobs/PushServerUpdateJob.php');
$actionFile = file_get_contents(__DIR__.'/../../app/Actions/Docker/GetContainersStatus.php');
// Both should parse service key with same format
expect($jobFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
expect($actionFile)->toContain('[$serviceId, $subType, $subId] = explode(\':\', $key);');
// Both should handle excluded containers
expect($jobFile)->toContain('$excludedContainers = collect();');
expect($actionFile)->toContain('$excludedContainers = collect();');
});

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

@ -0,0 +1,321 @@
<?php
use App\Models\Service;
/**
* Test suite for Service model's excluded status calculation.
*
* These tests verify the Service model's aggregateResourceStatuses() method
* and getStatusAttribute() accessor, which aggregate status from applications
* and databases. This is separate from the CalculatesExcludedStatus trait
* because Service works with Eloquent model relationships (database records)
* rather than Docker container objects.
*/
/**
* Helper to create a mock resource (application or database) with status.
*/
function makeResource(string $status, bool $excludeFromStatus = false): object
{
$resource = new stdClass;
$resource->status = $status;
$resource->exclude_from_status = $excludeFromStatus;
return $resource;
}
describe('Service Excluded Status Calculation', function () {
it('returns starting status when service is starting', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('starting:unhealthy');
});
it('aggregates status from non-excluded applications', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('running:healthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:healthy');
});
it('returns excluded status when all containers are excluded', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:healthy:excluded');
});
it('returns unknown status when no containers exist', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect());
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('unknown:unknown:excluded');
});
it('handles mixed excluded and non-excluded containers', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
// Should only consider non-excluded containers
expect($service->status)->toBe('running:healthy');
});
it('detects degraded status with mixed running and exited containers', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('degraded:unhealthy');
});
it('handles unknown health state', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:unknown', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:unknown');
});
it('prioritizes unhealthy over unknown health', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:unknown', excludeFromStatus: false);
$app2 = makeResource('running:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:unhealthy');
});
it('prioritizes unknown over healthy health', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running (healthy)', excludeFromStatus: false);
$app2 = makeResource('running (unknown)', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:unknown');
});
it('handles restarting status as degraded', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('restarting:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('degraded:unhealthy');
});
it('handles paused status', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('paused:unknown', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('paused:unknown');
});
it('handles dead status as degraded', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('dead:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('degraded:unhealthy');
});
it('handles removing status as degraded', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('removing:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('degraded:unhealthy');
});
it('handles created status', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('created:unknown', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('starting:unknown');
});
it('aggregates status from both applications and databases', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$db1 = makeResource('running:healthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
expect($service->status)->toBe('running:healthy');
});
it('detects unhealthy when database is unhealthy', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$db1 = makeResource('running:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect([$db1]));
expect($service->status)->toBe('running:unhealthy');
});
it('skips containers with :excluded suffix in non-excluded aggregation', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: false);
$app2 = makeResource('exited:unhealthy:excluded', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
// Should skip app2 because it has :excluded suffix
expect($service->status)->toBe('running:healthy');
});
it('strips :excluded suffix when processing excluded containers', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy:excluded', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:healthy:excluded');
});
it('returns exited:unhealthy:excluded when excluded containers have no valid status', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('exited:unhealthy:excluded');
});
it('handles all excluded containers with degraded state', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:healthy', excludeFromStatus: true);
$app2 = makeResource('exited:unhealthy', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('degraded:unhealthy:excluded');
});
it('handles all excluded containers with unknown health', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:unknown', excludeFromStatus: true);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:unknown:excluded');
});
it('handles exited containers correctly', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('exited:unhealthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('exited:unhealthy');
});
it('prefers running over starting status', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('starting:unknown', excludeFromStatus: false);
$app2 = makeResource('running:healthy', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1, $app2]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:healthy');
});
it('treats empty health as healthy', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('isStarting')->andReturn(false);
$app1 = makeResource('running:', excludeFromStatus: false);
$service->shouldReceive('getAttribute')->with('applications')->andReturn(collect([$app1]));
$service->shouldReceive('getAttribute')->with('databases')->andReturn(collect());
expect($service->status)->toBe('running:healthy');
});
});

View file

@ -0,0 +1,229 @@
<?php
use function PHPUnit\Framework\assertEquals;
test('removes exclude_from_hc from service level', function () {
$yaml = [
'services' => [
'web' => [
'image' => 'nginx:latest',
'exclude_from_hc' => true,
'ports' => ['80:80'],
],
],
];
$result = stripCoolifyCustomFields($yaml);
assertEquals('nginx:latest', $result['services']['web']['image']);
assertEquals(['80:80'], $result['services']['web']['ports']);
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
});
test('removes content from volume level', function () {
$yaml = [
'services' => [
'app' => [
'image' => 'php:8.4',
'volumes' => [
[
'type' => 'bind',
'source' => './config.xml',
'target' => '/app/config.xml',
'content' => '<?xml version="1.0"?><config></config>',
],
],
],
],
];
$result = stripCoolifyCustomFields($yaml);
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
expect($result['services']['app']['volumes'][0])->not->toHaveKey('content');
});
test('removes isDirectory from volume level', function () {
$yaml = [
'services' => [
'app' => [
'image' => 'node:20',
'volumes' => [
[
'type' => 'bind',
'source' => './data',
'target' => '/app/data',
'isDirectory' => true,
],
],
],
],
];
$result = stripCoolifyCustomFields($yaml);
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
expect($result['services']['app']['volumes'][0])->not->toHaveKey('isDirectory');
});
test('removes is_directory from volume level', function () {
$yaml = [
'services' => [
'app' => [
'image' => 'python:3.12',
'volumes' => [
[
'type' => 'bind',
'source' => './logs',
'target' => '/var/log/app',
'is_directory' => true,
],
],
],
],
];
$result = stripCoolifyCustomFields($yaml);
expect($result['services']['app']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
expect($result['services']['app']['volumes'][0])->not->toHaveKey('is_directory');
});
test('removes all custom fields together', function () {
$yaml = [
'services' => [
'web' => [
'image' => 'nginx:latest',
'exclude_from_hc' => true,
'volumes' => [
[
'type' => 'bind',
'source' => './config.xml',
'target' => '/etc/nginx/config.xml',
'content' => '<config></config>',
'isDirectory' => false,
],
[
'type' => 'bind',
'source' => './data',
'target' => '/var/www/data',
'is_directory' => true,
],
],
],
'worker' => [
'image' => 'worker:latest',
'exclude_from_hc' => true,
],
],
];
$result = stripCoolifyCustomFields($yaml);
// Verify service-level custom fields removed
expect($result['services']['web'])->not->toHaveKey('exclude_from_hc');
expect($result['services']['worker'])->not->toHaveKey('exclude_from_hc');
// Verify volume-level custom fields removed
expect($result['services']['web']['volumes'][0])->not->toHaveKey('content');
expect($result['services']['web']['volumes'][0])->not->toHaveKey('isDirectory');
expect($result['services']['web']['volumes'][1])->not->toHaveKey('is_directory');
// Verify standard fields preserved
assertEquals('nginx:latest', $result['services']['web']['image']);
assertEquals('worker:latest', $result['services']['worker']['image']);
});
test('preserves standard Docker Compose fields', function () {
$yaml = [
'services' => [
'db' => [
'image' => 'postgres:16',
'environment' => [
'POSTGRES_DB' => 'mydb',
'POSTGRES_USER' => 'user',
],
'ports' => ['5432:5432'],
'volumes' => [
'db-data:/var/lib/postgresql/data',
],
'healthcheck' => [
'test' => ['CMD', 'pg_isready'],
'interval' => '5s',
],
'restart' => 'unless-stopped',
'networks' => ['backend'],
],
],
'networks' => [
'backend' => [
'driver' => 'bridge',
],
],
'volumes' => [
'db-data' => null,
],
];
$result = stripCoolifyCustomFields($yaml);
// All standard fields should be preserved
expect($result)->toHaveKeys(['services', 'networks', 'volumes']);
expect($result['services']['db'])->toHaveKeys([
'image', 'environment', 'ports', 'volumes',
'healthcheck', 'restart', 'networks',
]);
assertEquals('postgres:16', $result['services']['db']['image']);
assertEquals(['5432:5432'], $result['services']['db']['ports']);
});
test('handles missing services gracefully', function () {
$yaml = [
'version' => '3.8',
];
$result = stripCoolifyCustomFields($yaml);
expect($result)->toBe($yaml);
});
test('handles missing volumes in service gracefully', function () {
$yaml = [
'services' => [
'app' => [
'image' => 'nginx:latest',
'exclude_from_hc' => true,
],
],
];
$result = stripCoolifyCustomFields($yaml);
expect($result['services']['app'])->not->toHaveKey('exclude_from_hc');
expect($result['services']['app'])->not->toHaveKey('volumes');
assertEquals('nginx:latest', $result['services']['app']['image']);
});
test('handles traccar.yaml example with multiline content', function () {
$yaml = [
'services' => [
'traccar' => [
'image' => 'traccar/traccar:latest',
'volumes' => [
[
'type' => 'bind',
'source' => './srv/traccar/conf/traccar.xml',
'target' => '/opt/traccar/conf/traccar.xml',
'content' => "<?xml version='1.0' encoding='UTF-8'?>\n<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>\n<properties>\n <entry key='config.default'>./conf/default.xml</entry>\n</properties>",
],
],
],
],
];
$result = stripCoolifyCustomFields($yaml);
expect($result['services']['traccar']['volumes'][0])->toHaveKeys(['type', 'source', 'target']);
expect($result['services']['traccar']['volumes'][0])->not->toHaveKey('content');
assertEquals('./srv/traccar/conf/traccar.xml', $result['services']['traccar']['volumes'][0]['source']);
});

View file

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