diff --git a/.ai/README.md b/.ai/README.md
index da24b09dc..ea7812496 100644
--- a/.ai/README.md
+++ b/.ai/README.md
@@ -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
diff --git a/.ai/core/application-architecture.md b/.ai/core/application-architecture.md
index daaac0eaa..64038d139 100644
--- a/.ai/core/application-architecture.md
+++ b/.ai/core/application-architecture.md
@@ -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
diff --git a/.ai/core/deployment-architecture.md b/.ai/core/deployment-architecture.md
index ec19cd0cd..272f00e4c 100644
--- a/.ai/core/deployment-architecture.md
+++ b/.ai/core/deployment-architecture.md
@@ -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: |
+
+
+
+ ${DB_HOST}
+ ${DB_PORT}
+
+
+```
+
+**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
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index c7f4055f0..98302f98e 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -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)';
}
}
diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php
index e06136e3c..588cca4ac 100644
--- a/app/Actions/Shared/ComplexStatusCheck.php
+++ b/app/Actions/Shared/ComplexStatusCheck.php
@@ -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);
}
}
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index 7726c2c73..9f81155be 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -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();
diff --git a/app/Models/Application.php b/app/Models/Application.php
index c2ba6e773..821c69bca 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -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;
}
);
}
diff --git a/app/Models/Service.php b/app/Models/Service.php
index ef755d105..af27070c7 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -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([]);
diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php
new file mode 100644
index 000000000..402a1f202
--- /dev/null
+++ b/app/Services/ContainerStatusAggregator.php
@@ -0,0 +1,253 @@
+ $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';
+ }
+}
diff --git a/app/Traits/CalculatesExcludedStatus.php b/app/Traits/CalculatesExcludedStatus.php
new file mode 100644
index 000000000..9cbc6a86b
--- /dev/null
+++ b/app/Traits/CalculatesExcludedStatus.php
@@ -0,0 +1,166 @@
+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;
+ }
+}
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 256a2cb66..c4d77979f 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -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",
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 384b960ef..8a278476e 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -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();
+ }
+}
diff --git a/resources/views/components/status/degraded.blade.php b/resources/views/components/status/degraded.blade.php
index 9c0c35fa6..fbd068c72 100644
--- a/resources/views/components/status/degraded.blade.php
+++ b/resources/views/components/status/degraded.blade.php
@@ -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
-
- {{ str($status)->before(':')->headline() }}
-
- @if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
- ({{ str($status)->after(':') }})
+ {{ $displayStatus }}
+ @if ($healthStatus && !str($displayStatus)->contains('('))
+ ({{ $healthStatus }})
@endif
diff --git a/resources/views/components/status/index.blade.php b/resources/views/components/status/index.blade.php
index 57e5409c6..f0a7876ce 100644
--- a/resources/views/components/status/index.blade.php
+++ b/resources/views/components/status/index.blade.php
@@ -5,9 +5,9 @@
])
@if (str($resource->status)->startsWith('running'))
-@elseif(str($resource->status)->startsWith('restarting') ||
- str($resource->status)->startsWith('starting') ||
- str($resource->status)->startsWith('degraded'))
+@elseif(str($resource->status)->startsWith('degraded'))
+
+@elseif(str($resource->status)->startsWith('restarting') || str($resource->status)->startsWith('starting'))
@else
diff --git a/resources/views/components/status/restarting.blade.php b/resources/views/components/status/restarting.blade.php
index 4c73376b4..353bf1097 100644
--- a/resources/views/components/status/restarting.blade.php
+++ b/resources/views/components/status/restarting.blade.php
@@ -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
@if (!$noLoading)
@@ -13,14 +33,14 @@
@if ($lastDeploymentLink)
- {{ str($status)->before(':')->headline() }}
+ {{ $displayStatus }}
@else
- {{ str($status)->before(':')->headline() }}
+ {{ $displayStatus }}
@endif
- @if (!str($status)->startsWith('Proxy') && !str($status)->contains('('))
-
({{ str($status)->after(':') }})
+ @if ($healthStatus && !str($displayStatus)->contains('('))
+
({{ $healthStatus }})
@endif
diff --git a/resources/views/components/status/running.blade.php b/resources/views/components/status/running.blade.php
index 92a04d33d..eb2964a86 100644
--- a/resources/views/components/status/running.blade.php
+++ b/resources/views/components/status/running.blade.php
@@ -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
+ @if ($healthStatus && !str($displayStatus)->contains('('))
+
({{ $healthStatus }})
+ @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)
+
+ helper="No health check configured. The resource may be functioning normally. 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. More details in the documentation .">
@@ -36,6 +62,22 @@
+
+ @endif
+ @if ($showUnhealthyHelper)
+
@endif
diff --git a/resources/views/components/status/services.blade.php b/resources/views/components/status/services.blade.php
index 7ea55099f..9c7a870c7 100644
--- a/resources/views/components/status/services.blade.php
+++ b/resources/views/components/status/services.blade.php
@@ -1,13 +1,16 @@
-@if (str($complexStatus)->contains('running'))
-
-@elseif(str($complexStatus)->contains('starting'))
-
-@elseif(str($complexStatus)->contains('restarting'))
-
-@elseif(str($complexStatus)->contains('degraded'))
-
+@php
+ $displayStatus = formatContainerStatus($complexStatus);
+@endphp
+@if (str($displayStatus)->lower()->contains('running'))
+
+@elseif(str($displayStatus)->lower()->contains('starting'))
+
+@elseif(str($displayStatus)->lower()->contains('restarting'))
+
+@elseif(str($displayStatus)->lower()->contains('degraded'))
+
@else
-
+
@endif
@if (!str($complexStatus)->contains('exited') && $showRefreshButton)
'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
@if (!$noLoading)
@endif
- {{ str($status)->before(':')->headline() }}
+ {{ $displayStatus }}
+ @if ($healthStatus && !str($displayStatus)->contains('('))
+ ({{ $healthStatus }})
+ @endif
diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php
index bf169077c..54f181d0f 100644
--- a/resources/views/livewire/project/application/configuration.blade.php
+++ b/resources/views/livewire/project/application/configuration.blade.php
@@ -30,15 +30,15 @@
@endif