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
@@ -12,21 +32,27 @@ @if ($title) title="{{ $title }}" @endif> @if ($lastDeploymentLink) - {{ str($status)->before(':')->headline() }} + {{ $displayStatus }} @else - {{ str($status)->before(':')->headline() }} + {{ $displayStatus }} @endif
+ @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.">
+
+ @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)