diff --git a/.ai/core/application-architecture.md b/.ai/core/application-architecture.md index 64038d139..c1fe7c470 100644 --- a/.ai/core/application-architecture.md +++ b/.ai/core/application-architecture.md @@ -283,14 +283,22 @@ ### **Polymorphic Relationships** ### **Team-Based Soft Scoping** -All major resources include team-based query scoping: +All major resources include team-based query scoping with request-level caching: ```php -// Automatic team filtering -$applications = Application::ownedByCurrentTeam()->get(); -$servers = Server::ownedByCurrentTeam()->get(); +// ✅ CORRECT - Use cached methods (request-level cache via once()) +$applications = Application::ownedByCurrentTeamCached(); +$servers = Server::ownedByCurrentTeamCached(); + +// ✅ CORRECT - Filter cached collection in memory +$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true); + +// Only use query builder when you need eager loading or fresh data +$projects = Project::ownedByCurrentTeam()->with('environments')->get(); ``` +See [Database Patterns](.ai/patterns/database-patterns.md#request-level-caching-with-ownedbycurrentteamcached) for full documentation. + ### **Configuration Inheritance** Environment variables cascade from: diff --git a/.ai/patterns/database-patterns.md b/.ai/patterns/database-patterns.md index 1e40ea152..5a9d16f71 100644 --- a/.ai/patterns/database-patterns.md +++ b/.ai/patterns/database-patterns.md @@ -243,6 +243,59 @@ ### Database Indexes - **Composite indexes** for common queries - **Unique constraints** for business rules +### Request-Level Caching with ownedByCurrentTeamCached() + +Many models have both `ownedByCurrentTeam()` (returns query builder) and `ownedByCurrentTeamCached()` (returns cached collection). **Always prefer the cached version** to avoid duplicate database queries within the same request. + +**Models with cached methods available:** +- `Server`, `PrivateKey`, `Project` +- `Application` +- `StandalonePostgresql`, `StandaloneMysql`, `StandaloneRedis`, `StandaloneMariadb`, `StandaloneMongodb`, `StandaloneKeydb`, `StandaloneDragonfly`, `StandaloneClickhouse` +- `Service`, `ServiceApplication`, `ServiceDatabase` + +**Usage patterns:** +```php +// ✅ CORRECT - Uses request-level cache (via Laravel's once() helper) +$servers = Server::ownedByCurrentTeamCached(); + +// ❌ AVOID - Makes a new database query each time +$servers = Server::ownedByCurrentTeam()->get(); + +// ✅ CORRECT - Filter cached collection in memory +$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true); +$server = Server::ownedByCurrentTeamCached()->firstWhere('id', $serverId); +$serverIds = Server::ownedByCurrentTeamCached()->pluck('id'); + +// ❌ AVOID - Making filtered database queries when data is already cached +$activeServers = Server::ownedByCurrentTeam()->where('is_active', true)->get(); +``` + +**When to use which:** +- `ownedByCurrentTeamCached()` - **Default choice** for reading team data +- `ownedByCurrentTeam()` - Only when you need to chain query builder methods that can't be done on collections (like `with()` for eager loading), or when you explicitly need a fresh database query + +**Implementation pattern for new models:** +```php +/** + * Get query builder for resources owned by current team. + * If you need all resources without further query chaining, use ownedByCurrentTeamCached() instead. + */ +public static function ownedByCurrentTeam() +{ + return self::whereTeamId(currentTeam()->id); +} + +/** + * Get all resources owned by current team (cached for request duration). + */ +public static function ownedByCurrentTeamCached() +{ + return once(function () { + return self::ownedByCurrentTeam()->get(); + }); +} +``` + ## Data Consistency Patterns ### Database Transactions diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index fec54d54a..2e50abbe7 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -86,8 +86,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 0c9996ec8..ed6fc3bcb 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -85,8 +85,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index 21871b103..477274751 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -51,8 +51,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -91,8 +91,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index 7ab4dcc42..8937ea27d 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index 5efe445c5..d8784dd50 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -48,8 +48,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version @@ -90,8 +90,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Get Version id: version diff --git a/.github/workflows/coolify-staging-build.yml b/.github/workflows/coolify-staging-build.yml index 67b7b03e8..494ef6939 100644 --- a/.github/workflows/coolify-staging-build.yml +++ b/.github/workflows/coolify-staging-build.yml @@ -64,8 +64,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -110,8 +110,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index 24133887a..0c1371573 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Image (${{ matrix.arch }}) uses: docker/build-push-action@v6 @@ -81,8 +81,8 @@ jobs: uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2980c7401..5660f2569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5389,7 +5389,6 @@ ### 🚀 Features - Add static ipv4 ipv6 support - Server disabled by overflow - Preview deployment logs -- Collect webhooks during maintenance - Logs and execute commands with several servers ### 🐛 Bug Fixes diff --git a/CLAUDE.md b/CLAUDE.md index b7c496e42..5cddb7fd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,6 +222,7 @@ ### Performance Considerations - Queue heavy operations - Optimize database queries with proper indexes - Use chunking for large data operations +- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()` ### Code Style - Follow PSR-12 coding standards @@ -317,4 +318,5 @@ ### Livewire & Frontend Random other things you should remember: -- App\Models\Application::team must return a relationship instance., always use team() \ No newline at end of file +- App\Models\Application::team must return a relationship instance., always use team() +- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries \ No newline at end of file diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php new file mode 100644 index 000000000..74e2ff615 --- /dev/null +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -0,0 +1,176 @@ + 0, + 'killed_containers' => 0, + 'status' => 'success', + ]; + + $server = $application->destination->server; + + if (! $server->isFunctional()) { + return [ + ...$result, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]; + } + + // Step 1: Cancel all active deployments for this PR and kill helper containers + $result['cancelled_deployments'] = $this->cancelActiveDeployments( + $application, + $pull_request_id, + $server + ); + + // Step 2: Stop and remove all running PR containers + $result['killed_containers'] = $this->stopRunningContainers( + $application, + $pull_request_id, + $server + ); + + // Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup + if (! $preview) { + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + } + + if ($preview) { + DeleteResourceJob::dispatch($preview); + } + + return $result; + } + + /** + * Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR. + */ + private function cancelActiveDeployments( + Application $application, + int $pull_request_id, + $server + ): int { + $activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->whereIn('status', [ + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, + ]) + ->get(); + + $cancelled = 0; + foreach ($activeDeployments as $deployment) { + try { + // Mark deployment as cancelled + $deployment->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); + + // Try to kill helper container if it exists + $this->killHelperContainer($deployment->deployment_uuid, $server); + $cancelled++; + } catch (\Throwable $e) { + \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); + } + } + + return $cancelled; + } + + /** + * Kill the helper container used during deployment. + */ + private function killHelperContainer(string $deployment_uuid, $server): void + { + try { + $escapedUuid = escapeshellarg($deployment_uuid); + $checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$escapedUuid}"], $server); + } + } catch (\Throwable $e) { + // Silently handle - container may already be gone + } + } + + /** + * Stop and remove all running containers for this PR. + */ + private function stopRunningContainers( + Application $application, + int $pull_request_id, + $server + ): int { + $killed = 0; + + try { + if ($server->isSwarm()) { + $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); + instant_remote_process(["docker stack rm {$escapedStackName}"], $server); + $killed++; + } else { + $containers = getCurrentApplicationContainerStatus( + $server, + $application->id, + $pull_request_id + ); + + if ($containers->isNotEmpty()) { + foreach ($containers as $container) { + $containerName = data_get($container, 'Names'); + if ($containerName) { + $escapedContainerName = escapeshellarg($containerName); + instant_remote_process( + ["docker rm -f {$escapedContainerName}"], + $server + ); + $killed++; + } + } + } + } + } catch (\Throwable $e) { + \Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}"); + } + + return $killed; + } +} diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index 61a3c4615..a1476e120 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount); + return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true); } private function aggregateServiceContainerStatuses($services) @@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services) } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus) { diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 6823dfb92..f90e00708 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -13,6 +13,9 @@ class CheckUpdates public function handle(Server $server) { + $osId = 'unknown'; + $packageManager = null; + try { if ($server->serverStatus() === false) { return [ @@ -93,6 +96,16 @@ public function handle(Server $server) $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'pacman': + // Sync database first, then check for updates + // Using -Sy to refresh package database before querying available updates + instant_remote_process(['pacman -Sy'], $server); + $output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server); + $out = $this->parsePacmanOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -219,4 +232,45 @@ private function parseAptOutput(string $output): array 'updates' => $updates, ]; } + + private function parsePacmanOutput(string $output): array + { + $updates = []; + $unparsedLines = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + if (empty($line)) { + continue; + } + // Format: package current_version -> new_version + if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'current_version' => $matches[2], + 'new_version' => $matches[3], + 'architecture' => 'unknown', + 'repository' => 'unknown', + ]; + } else { + // Log unmatched lines for debugging purposes + $unparsedLines[] = $line; + } + } + + $result = [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + + // Include unparsed lines in the result for debugging if any exist + if (! empty($unparsedLines)) { + $result['unparsed_lines'] = $unparsedLines; + \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + 'unparsed_lines' => $unparsedLines, + ]); + } + + return $result; + } } diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 6bf094c32..076f7d0c5 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -13,7 +13,6 @@ class CleanupDocker public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) { - $settings = instanceSettings(); $realtimeImage = config('constants.coolify.realtime_image'); $realtimeImageVersion = config('constants.coolify.realtime_version'); $realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion"; @@ -26,9 +25,25 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; + $cleanupLog = []; + + // Get all application image repositories to exclude from prune + $applications = $server->applications(); + $applicationImageRepos = collect($applications)->map(function ($app) { + return $app->docker_registry_image_name ?? $app->uuid; + })->unique()->values(); + + // Clean up old application images while preserving N most recent for rollback + $applicationCleanupLog = $this->cleanupApplicationImages($server, $applications); + $cleanupLog = array_merge($cleanupLog, $applicationCleanupLog); + + // Build image prune command that excludes application images + // This ensures we clean up non-Coolify images while preserving rollback images + $imagePruneCmd = $this->buildImagePruneCommand($applicationImageRepos); + $commands = [ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', - 'docker image prune -af --filter "label!=coolify.managed=true"', + $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f", @@ -44,7 +59,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ $commands[] = 'docker network prune -f'; } - $cleanupLog = []; foreach ($commands as $command) { $commandOutput = instant_remote_process([$command], $server, false); if ($commandOutput !== null) { @@ -57,4 +71,122 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ return $cleanupLog; } + + /** + * Build a docker image prune command that excludes application image repositories. + * + * Since docker image prune doesn't support excluding by repository name directly, + * we use a shell script approach to delete unused images while preserving application images. + */ + private function buildImagePruneCommand($applicationImageRepos): string + { + // Step 1: Always prune dangling images (untagged) + $commands = ['docker image prune -f']; + + if ($applicationImageRepos->isEmpty()) { + // No applications, add original prune command for all unused images + $commands[] = 'docker image prune -af --filter "label!=coolify.managed=true"'; + } else { + // Build grep pattern to exclude application image repositories + $excludePatterns = $applicationImageRepos->map(function ($repo) { + // Escape special characters for grep extended regex (ERE) + // ERE special chars: . \ + * ? [ ^ ] $ ( ) { } | + return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo); + })->implode('|'); + + // Delete unused images that: + // - Are not application images (don't match app repos) + // - Don't have coolify.managed=true label + // Images in use by containers will fail silently with docker rmi + // Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". + "grep -v -E '^({$excludePatterns})[_:].+' | ". + "grep -v '' | ". + "xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; + } + + return implode(' && ', $commands); + } + + private function cleanupApplicationImages(Server $server, $applications = null): array + { + $cleanupLog = []; + + if ($applications === null) { + $applications = $server->applications(); + } + + $disableRetention = $server->settings->disable_application_image_retention ?? false; + + foreach ($applications as $application) { + $imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2); + $imageRepository = $application->docker_registry_image_name ?? $application->uuid; + + // Get the currently running image tag + $currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true"; + $currentTag = instant_remote_process([$currentTagCommand], $server, false); + $currentTag = trim($currentTag ?? ''); + + // List all images for this application with their creation timestamps + // Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build) + $listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true"; + $output = instant_remote_process([$listCommand], $server, false); + + if (empty($output)) { + continue; + } + + $images = collect(explode("\n", trim($output))) + ->filter() + ->map(function ($line) { + $parts = explode('#', $line); + $imageRef = $parts[0] ?? ''; + $tagParts = explode(':', $imageRef); + + return [ + 'repository' => $tagParts[0] ?? '', + 'tag' => $tagParts[1] ?? '', + 'created_at' => $parts[1] ?? '', + 'image_ref' => $imageRef, + ]; + }) + ->filter(fn ($image) => ! empty($image['tag'])); + + // Separate images into categories + // PR images (pr-*) and build images (*-build) are excluded from retention + // Build images will be cleaned up by docker image prune -af + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + // Always delete all PR images + foreach ($prImages as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'PR image removed or was in use', + ]; + } + + // Filter out current running image from regular images and sort by creation date + $sortedRegularImages = $regularImages + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // Keep only N images (imagesToKeep), delete the rest + $imagesToDelete = $sortedRegularImages->skip($imagesToKeep); + + foreach ($imagesToDelete as $image) { + $deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true"; + $deleteOutput = instant_remote_process([$deleteCommand], $server, false); + $cleanupLog[] = [ + 'command' => $deleteCommand, + 'output' => $deleteOutput ?? 'Image removed or was in use', + ]; + } + } + + return $cleanupLog; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 36c540950..5caae6afc 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -78,6 +78,8 @@ public function handle(Server $server) $command = $command->merge([$this->getRhelDockerInstallCommand()]); } elseif ($supported_os_type->contains('sles')) { $command = $command->merge([$this->getSuseDockerInstallCommand()]); + } elseif ($supported_os_type->contains('arch')) { + $command = $command->merge([$this->getArchDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -146,8 +148,27 @@ private function getSuseDockerInstallCommand(): string ')'; } + private function getArchDockerInstallCommand(): string + { + return 'pacman -Syyy --noconfirm && '. + 'pacman -S docker docker-compose --noconfirm && '. + 'systemctl start docker && '. + 'systemctl enable docker'; + } + private function getGenericDockerInstallCommand(): string { return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}"; } + + private function getArchDockerInstallCommand(): string + { + // Use -Syu to perform full system upgrade before installing Docker + // Partial upgrades (-Sy without -u) are discouraged on Arch Linux + // as they can lead to broken dependencies and system instability + // Use --needed to skip reinstalling packages that are already up-to-date (idempotent) + return 'pacman -Syu --noconfirm --needed docker docker-compose && '. + 'systemctl enable docker.service && '. + 'systemctl start docker.service'; + } } diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 1a7d3bbd9..84be7f206 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -46,6 +46,13 @@ public function handle(Server $server) 'command -v git >/dev/null || zypper install -y git', 'command -v jq >/dev/null || zypper install -y jq', ]); + } elseif ($supported_os_type->contains('arch')) { + // Use -Syu for full system upgrade to avoid partial upgrade issues on Arch Linux + // --needed flag skips packages that are already installed and up-to-date + $command = $command->merge([ + "echo 'Installing Prerequisites for Arch Linux...'", + 'pacman -Syu --noconfirm --needed curl wget git jq', + ]); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 75d931f93..ab0ca9494 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -20,18 +20,43 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s 'error' => 'Server is not reachable or not ready.', ]; } + + // Validate that package name is provided when not updating all packages + if (! $all && ($package === null || $package === '')) { + return [ + 'error' => "Package name required when 'all' is false.", + ]; + } + + // Sanitize package name to prevent command injection + // Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons + // These are valid characters in package names across most package managers + $sanitizedPackage = ''; + if ($package !== null && ! $all) { + if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) { + return [ + 'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.', + ]; + } + $sanitizedPackage = escapeshellarg($package); + } + switch ($packageManager) { case 'zypper': $commandAll = 'zypper update -y'; - $commandInstall = 'zypper install -y '.$package; + $commandInstall = 'zypper install -y '.$sanitizedPackage; break; case 'dnf': $commandAll = 'dnf update -y'; - $commandInstall = 'dnf update -y '.$package; + $commandInstall = 'dnf update -y '.$sanitizedPackage; break; case 'apt': $commandAll = 'apt update && apt upgrade -y'; - $commandInstall = 'apt install -y '.$package; + $commandInstall = 'apt install -y '.$sanitizedPackage; + break; + case 'pacman': + $commandAll = 'pacman -Syu --noconfirm'; + $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; default: return [ diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 23b41e3f2..675f0f955 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -3,10 +3,12 @@ namespace App\Actions\Service; use App\Actions\Server\CleanupDocker; +use App\Enums\ProcessStatus; use App\Events\ServiceStatusChanged; use App\Models\Server; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; +use Spatie\Activitylog\Models\Activity; class StopService { @@ -17,6 +19,17 @@ class StopService public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true) { try { + // Cancel any in-progress deployment activities so status doesn't stay stuck at "starting" + Activity::where('properties->type_uuid', $service->uuid) + ->where(function ($q) { + $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) + ->orWhere('properties->status', ProcessStatus::QUEUED->value); + }) + ->each(function ($activity) { + $activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value); + $activity->save(); + }); + $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 832bed5ae..8687104e0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,11 +2,11 @@ namespace App\Console; -use App\Jobs\CheckAndStartSentinelJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; +use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\PullChangelog; use App\Jobs\PullTemplatesFromCDN; use App\Jobs\RegenerateSslCertJob; @@ -18,7 +18,6 @@ use App\Models\Team; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; -use Illuminate\Support\Facades\Log; class Kernel extends ConsoleKernel { @@ -88,6 +87,9 @@ protected function schedule(Schedule $schedule): void $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); + + // Cleanup orphaned PR preview containers daily + $this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer(); } } @@ -100,17 +102,7 @@ private function pullImages(): void } else { $servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get(); } - foreach ($servers as $server) { - try { - if ($server->isSentinelEnabled()) { - $this->scheduleInstance->job(function () use ($server) { - CheckAndStartSentinelJob::dispatch($server); - })->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer(); - } - } catch (\Exception $e) { - Log::error('Error pulling images: '.$e->getMessage()); - } - } + // Sentinel update checks are now handled by ServerManagerJob $this->scheduleInstance->job(new CheckHelperImageJob) ->cron($this->updateCheckFrequency) ->timezone($this->instanceTimezone) diff --git a/app/Events/ProxyStatusChangedUI.php b/app/Events/ProxyStatusChangedUI.php index bd99a0f3c..3994dc0f8 100644 --- a/app/Events/ProxyStatusChangedUI.php +++ b/app/Events/ProxyStatusChangedUI.php @@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast public ?int $teamId = null; - public function __construct(?int $teamId = null) + public ?int $activityId = null; + + public function __construct(?int $teamId = null, ?int $activityId = null) { if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) { $teamId = auth()->user()->currentTeam()->id; } $this->teamId = $teamId; + $this->activityId = $activityId; } public function broadcastOn(): array diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index f847f33cc..723c6d4a5 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -149,7 +149,7 @@ public static function generateScpCommand(Server $server, string $source, string return $scp_command; } - public static function generateSshCommand(Server $server, string $command) + public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false) { if ($server->settings->force_disabled) { throw new \RuntimeException('Server is disabled.'); @@ -168,7 +168,7 @@ public static function generateSshCommand(Server $server, string $command) $ssh_command = "timeout $timeout ssh "; $multiplexingSuccessful = false; - if (self::isMultiplexingEnabled()) { + if (! $disableMultiplexing && self::isMultiplexingEnabled()) { try { $multiplexingSuccessful = self::ensureMultiplexedConnection($server); if ($multiplexingSuccessful) { diff --git a/app/Http/Controllers/MagicController.php b/app/Http/Controllers/MagicController.php deleted file mode 100644 index 59c9b8b94..000000000 --- a/app/Http/Controllers/MagicController.php +++ /dev/null @@ -1,84 +0,0 @@ -json([ - 'servers' => Server::isUsable()->get(), - ]); - } - - public function destinations() - { - return response()->json([ - 'destinations' => Server::destinationsByServer(request()->query('server_id'))->sortBy('name'), - ]); - } - - public function projects() - { - return response()->json([ - 'projects' => Project::ownedByCurrentTeam()->get(), - ]); - } - - public function environments() - { - $project = Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->first(); - if (! $project) { - return response()->json([ - 'environments' => [], - ]); - } - - return response()->json([ - 'environments' => $project->environments, - ]); - } - - public function newProject() - { - $project = Project::firstOrCreate( - ['name' => request()->query('name') ?? generate_random_name()], - ['team_id' => currentTeam()->id] - ); - - return response()->json([ - 'project_uuid' => $project->uuid, - ]); - } - - public function newEnvironment() - { - $environment = Environment::firstOrCreate( - ['name' => request()->query('name') ?? generate_random_name()], - ['project_id' => Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->firstOrFail()->id] - ); - - return response()->json([ - 'environment_name' => $environment->name, - ]); - } - - public function newTeam() - { - $team = Team::create( - [ - 'name' => request()->query('name') ?? generate_random_name(), - 'personal_team' => false, - ], - ); - auth()->user()->teams()->attach($team, ['role' => 'admin']); - refreshSession(); - - return redirect(request()->header('Referer')); - } -} diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 078494f82..d322452d3 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; -use App\Livewire\Project\Service\Storage; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; @@ -15,23 +15,6 @@ class Bitbucket extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json); - - return; - } $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); @@ -185,9 +168,10 @@ public function manual(Request $request) if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 3e0c5a0b6..f85d14089 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -18,30 +18,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_gitea_delivery = request()->header('X-Gitea-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) { - return Str::contains($file, $x_gitea_delivery); - })->first(); - if ($gitea_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json); - - return; - } $x_gitea_event = Str::lower($request->header('X-Gitea-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -217,9 +193,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index a1fcaa7f5..93f225773 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; -use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -14,7 +14,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -25,30 +24,6 @@ public function manual(Request $request) try { $return_payloads = collect([]); $x_github_delivery = request()->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); $content_type = $request->header('Content-Type'); @@ -246,41 +221,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - DeleteResourceJob::dispatch($found); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -310,30 +254,6 @@ public function normal(Request $request) $return_payloads = collect([]); $id = null; $x_github_delivery = $request->header('X-GitHub-Delivery'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $files = Storage::disk('webhooks-during-maintenance')->files(); - $github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) { - return Str::contains($file, $x_github_delivery); - })->first(); - if ($github_delivery_found) { - return; - } - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json); - - return; - } $x_github_event = Str::lower($request->header('X-GitHub-Event')); $x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id'); $x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256='); @@ -515,53 +435,12 @@ public function normal(Request $request) if ($action === 'closed' || $action === 'close') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); - - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - // Clean up any deployed containers - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - + // Delete the PR comment on GitHub (GitHub-specific feature) ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - DeleteResourceJob::dispatch($found); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); $return_payloads->push([ 'application' => $application->name, @@ -624,23 +503,6 @@ public function install(Request $request) { try { $installation_id = $request->get('installation_id'); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json); - - return; - } $source = $request->get('source'); $setup_action = $request->get('setup_action'); $github_app = GithubApp::where('uuid', $source)->firstOrFail(); diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 3187663d4..9062d2875 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -16,24 +16,6 @@ class Gitlab extends Controller public function manual(Request $request) { try { - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json); - - return; - } - $return_payloads = collect([]); $payload = $request->collect(); $headers = $request->headers->all(); @@ -243,22 +225,22 @@ public function manual(Request $request) } elseif ($action === 'closed' || $action === 'close' || $action === 'merge') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', - 'message' => 'Preview Deployment closed', + 'message' => 'Preview deployment closed.', + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); - - return response($return_payloads); } - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No Preview Deployment found', - ]); } else { $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php index ae50aac42..d59adf0ca 100644 --- a/app/Http/Controllers/Webhook/Stripe.php +++ b/app/Http/Controllers/Webhook/Stripe.php @@ -6,7 +6,6 @@ use App\Jobs\StripeProcessJob; use Exception; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Storage; class Stripe extends Controller { @@ -20,23 +19,6 @@ public function events(Request $request) $signature, $webhookSecret ); - if (app()->isDownForMaintenance()) { - $epoch = now()->valueOf(); - $data = [ - 'attributes' => $request->attributes->all(), - 'request' => $request->request->all(), - 'query' => $request->query->all(), - 'server' => $request->server->all(), - 'files' => $request->files->all(), - 'cookies' => $request->cookies->all(), - 'headers' => $request->headers->all(), - 'content' => $request->getContent(), - ]; - $json = json_encode($data); - Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json); - - return response('Webhook received. Cool cool cool cool cool.', 200); - } StripeProcessJob::dispatch($event); return response('Webhook received. Cool cool cool cool cool.', 200); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3c428cf5f..6b13d2cb7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -620,7 +620,7 @@ private function deploy_docker_compose_buildpack() $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { - $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); + $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit); // Always add .env file to services $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -670,13 +670,20 @@ private function deploy_docker_compose_buildpack() $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; } - // Append build arguments if not using build secrets (matching default behavior) + // Inject build arguments after build subcommand if not using build secrets if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { $build_args_string = $this->build_args->implode(' '); // Escape single quotes for bash -c context used by executeInDocker $build_args_string = str_replace("'", "'\\''", $build_args_string); - $build_command .= " {$build_args_string}"; - $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + + // Inject build args right after 'build' subcommand (not at the end) + $original_command = $build_command; + $build_command = injectDockerComposeBuildArgs($build_command, $build_args_string); + + // Only log if build args were actually injected (command was modified) + if ($build_command !== $original_command) { + $this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.'); + } } $this->execute_remote_command( @@ -1806,9 +1813,9 @@ private function health_check() $this->application->update(['status' => 'running']); $this->application_deployment_queue->addLogEntry('New container is healthy.'); break; - } - if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { + } elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') { $this->newVersionIsHealthy = false; + $this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error'); $this->query_logs(); break; } @@ -2274,13 +2281,13 @@ private function generate_nixpacks_env_variables() $this->env_nixpacks_args = collect([]); if ($this->pull_request_id === 0) { foreach ($this->application->nixpacks_environment_variables as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } } else { foreach ($this->application->nixpacks_environment_variables_preview as $env) { - if (! is_null($env->real_value)) { + if (! is_null($env->real_value) && $env->real_value !== '') { $this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}"); } } @@ -2289,7 +2296,10 @@ private function generate_nixpacks_env_variables() // Add COOLIFY_* environment variables to Nixpacks build context $coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true); $coolify_envs->each(function ($value, $key) { - $this->env_nixpacks_args->push("--env {$key}={$value}"); + // Only add environment variables with non-null and non-empty values + if (! is_null($value) && $value !== '') { + $this->env_nixpacks_args->push("--env {$key}={$value}"); + } }); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); @@ -3180,6 +3190,18 @@ private function stop_running_container(bool $force = false) $this->graceful_shutdown_container($this->container_name); } } catch (Exception $e) { + // If new version is healthy, this is just cleanup - don't fail the deployment + if ($this->newVersionIsHealthy || $force) { + $this->application_deployment_queue->addLogEntry( + "Warning: Could not remove old container: {$e->getMessage()}", + 'stderr', + hidden: true + ); + + return; // Don't re-throw - cleanup failures shouldn't fail successful deployments + } + + // Only re-throw if deployment hasn't succeeded yet throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e); } } diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 88484bcce..92ec4cbd4 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; use Illuminate\Bus\Queueable; @@ -38,6 +39,8 @@ public function handle(): void $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -48,16 +51,22 @@ public function handle(): void // Handle empty/null response from SSH command if (empty(trim($imageTag))) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } if (str_contains(strtolower(trim($imageTag)), ':latest')) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } // Parse current version to extract major.minor.patch $current = ltrim($currentVersion, 'v'); if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) { + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -77,6 +86,8 @@ public function handle(): void $this->server->update(['traefik_outdated_info' => null]); } + ProxyStatusChangedUI::dispatch($this->server->team_id); + return; } @@ -96,6 +107,9 @@ public function handle(): void // Fully up to date $this->server->update(['traefik_outdated_info' => null]); } + + // Dispatch UI update event so warning state refreshes in real-time + ProxyStatusChangedUI::dispatch($this->server->team_id); } /** diff --git a/app/Jobs/CleanupOrphanedPreviewContainersJob.php b/app/Jobs/CleanupOrphanedPreviewContainersJob.php new file mode 100644 index 000000000..5d3bed457 --- /dev/null +++ b/app/Jobs/CleanupOrphanedPreviewContainersJob.php @@ -0,0 +1,214 @@ +expireAfter(600)->dontRelease()]; + } + + public function handle(): void + { + try { + $servers = $this->getServersToCheck(); + + foreach ($servers as $server) { + $this->cleanupOrphanedContainersOnServer($server); + } + } catch (\Throwable $e) { + Log::error('CleanupOrphanedPreviewContainersJob failed: '.$e->getMessage()); + send_internal_notification('CleanupOrphanedPreviewContainersJob failed with error: '.$e->getMessage()); + } + } + + /** + * Get all functional servers to check for orphaned containers. + */ + private function getServersToCheck(): \Illuminate\Support\Collection + { + $query = Server::whereRelation('settings', 'is_usable', true) + ->whereRelation('settings', 'is_reachable', true) + ->where('ip', '!=', '1.2.3.4'); + + if (isCloud()) { + $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true); + } + + return $query->get()->filter(fn ($server) => $server->isFunctional()); + } + + /** + * Find and clean up orphaned PR containers on a specific server. + */ + private function cleanupOrphanedContainersOnServer(Server $server): void + { + try { + $prContainers = $this->getPRContainersOnServer($server); + + if ($prContainers->isEmpty()) { + return; + } + + $orphanedCount = 0; + foreach ($prContainers as $container) { + if ($this->isOrphanedContainer($container)) { + $this->removeContainer($container, $server); + $orphanedCount++; + } + } + + if ($orphanedCount > 0) { + Log::info("CleanupOrphanedPreviewContainersJob - Removed {$orphanedCount} orphaned PR containers", [ + 'server' => $server->name, + ]); + } + } catch (\Throwable $e) { + Log::warning("CleanupOrphanedPreviewContainersJob - Error on server {$server->name}: {$e->getMessage()}"); + } + } + + /** + * Get all PR containers on a server (containers with pullRequestId > 0). + */ + private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection + { + try { + $output = instant_remote_process([ + "docker ps -a --filter 'label=coolify.pullRequestId' --format '{{json .}}'", + ], $server, false); + + if (empty($output)) { + return collect(); + } + + return format_docker_command_output_to_json($output) + ->filter(function ($container) { + // Only include PR containers (pullRequestId > 0) + $prId = $this->extractPullRequestId($container); + + return $prId !== null && $prId > 0; + }); + } catch (\Throwable $e) { + Log::debug("Failed to get PR containers on server {$server->name}: {$e->getMessage()}"); + + return collect(); + } + } + + /** + * Extract pull request ID from container labels. + */ + private function extractPullRequestId($container): ?int + { + $labels = data_get($container, 'Labels', ''); + if (preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Extract application ID from container labels. + */ + private function extractApplicationId($container): ?int + { + $labels = data_get($container, 'Labels', ''); + if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Check if a container is orphaned (no corresponding ApplicationPreview record). + */ + private function isOrphanedContainer($container): bool + { + $applicationId = $this->extractApplicationId($container); + $pullRequestId = $this->extractPullRequestId($container); + + if ($applicationId === null || $pullRequestId === null) { + return false; + } + + // Check if ApplicationPreview record exists (including soft-deleted) + $previewExists = ApplicationPreview::withTrashed() + ->where('application_id', $applicationId) + ->where('pull_request_id', $pullRequestId) + ->exists(); + + // If preview exists (even soft-deleted), container should be handled by DeleteResourceJob + // If preview doesn't exist at all, it's truly orphaned + return ! $previewExists; + } + + /** + * Remove an orphaned container from the server. + */ + private function removeContainer($container, Server $server): void + { + $containerName = data_get($container, 'Names'); + + if (empty($containerName)) { + Log::warning('CleanupOrphanedPreviewContainersJob - Cannot remove container: missing container name', [ + 'container_data' => $container, + 'server' => $server->name, + ]); + + return; + } + + $applicationId = $this->extractApplicationId($container); + $pullRequestId = $this->extractPullRequestId($container); + + Log::info('CleanupOrphanedPreviewContainersJob - Removing orphaned container', [ + 'container' => $containerName, + 'application_id' => $applicationId, + 'pull_request_id' => $pullRequestId, + 'server' => $server->name, + ]); + + $escapedContainerName = escapeshellarg($containerName); + + try { + instant_remote_process( + ["docker rm -f {$escapedContainerName}"], + $server, + false + ); + } catch (\Throwable $e) { + Log::warning("Failed to remove orphaned container {$containerName}: {$e->getMessage()}"); + } + } +} diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 6917de6d5..a585baa69 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -121,7 +121,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep POSTGRES_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $user = $envs->filter(function ($env) { @@ -152,7 +152,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep MYSQL_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { @@ -175,7 +175,7 @@ public function handle(): void $this->container_name = "{$this->database->name}-$serviceUuid"; $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); $rootPassword = $envs->filter(function ($env) { return str($env)->startsWith('MARIADB_ROOT_PASSWORD='); @@ -217,7 +217,7 @@ public function handle(): void try { $commands = []; $commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_"; - $envs = instant_remote_process($commands, $this->server); + $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); if (filled($envs)) { $envs = str($envs)->explode("\n"); @@ -508,7 +508,7 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi } } } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -537,7 +537,7 @@ private function backup_standalone_postgresql(string $database): void } $commands[] = $backupCommand; - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -560,7 +560,7 @@ private function backup_standalone_mysql(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -583,7 +583,7 @@ private function backup_standalone_mariadb(string $database): void $escapedDatabase = escapeshellarg($database); $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location"; } - $this->backup_output = instant_remote_process($commands, $this->server); + $this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->backup_output = trim($this->backup_output); if ($this->backup_output === '') { $this->backup_output = null; @@ -614,7 +614,7 @@ private function add_to_error_output($output): void private function calculate_size() { - return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false); + return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false, false, null, disableMultiplexing: true); } private function upload_to_s3(): void @@ -637,9 +637,9 @@ private function upload_to_s3(): void $fullImageName = $this->getFullImageName(); - $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false); + $containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); if (filled($containerExists)) { - instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false); + instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true); } if (isDev()) { @@ -661,7 +661,7 @@ private function upload_to_s3(): void $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/"; - instant_remote_process($commands, $this->server); + instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $this->s3_uploaded = true; } catch (\Throwable $e) { @@ -670,7 +670,7 @@ private function upload_to_s3(): void throw $e; } finally { $command = "docker rm -f backup-of-{$this->backup_log_uuid}"; - instant_remote_process([$command], $this->server); + instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 9d44e08f9..e6c64ada7 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses() } // Use ContainerStatusAggregator service for state machine logic + // Use preserveRestarting: true so applications show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update application status with aggregated result if ($aggregatedStatus && $application->status !== $aggregatedStatus) { @@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses() // Use ContainerStatusAggregator service for state machine logic // NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0 + // Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded" $aggregator = new ContainerStatusAggregator; - $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0); + $aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true); // Update service sub-resource status with aggregated result if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) { diff --git a/app/Jobs/RestartProxyJob.php b/app/Jobs/RestartProxyJob.php index e3e809c8d..2815c73bc 100644 --- a/app/Jobs/RestartProxyJob.php +++ b/app/Jobs/RestartProxyJob.php @@ -2,9 +2,12 @@ namespace App\Jobs; -use App\Actions\Proxy\StartProxy; -use App\Actions\Proxy\StopProxy; +use App\Actions\Proxy\GetProxyConfiguration; +use App\Actions\Proxy\SaveProxyConfiguration; +use App\Enums\ProxyTypes; +use App\Events\ProxyStatusChangedUI; use App\Models\Server; +use App\Services\ProxyDashboardCacheService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue public $tries = 1; - public $timeout = 60; + public $timeout = 120; + + public ?int $activity_id = null; public function middleware(): array { - return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()]; + return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()]; } public function __construct(public Server $server) {} @@ -31,15 +36,125 @@ public function __construct(public Server $server) {} public function handle() { try { - StopProxy::run($this->server, restarting: true); - + // Set status to restarting + $this->server->proxy->status = 'restarting'; $this->server->proxy->force_stop = false; $this->server->save(); - StartProxy::run($this->server, force: true, restarting: true); + // Build combined stop + start commands for a single activity + $commands = $this->buildRestartCommands(); + + // Create activity and dispatch immediately - returns Activity right away + // The remote_process runs asynchronously, so UI gets activity ID instantly + $activity = remote_process( + $commands, + $this->server, + callEventOnFinish: 'ProxyStatusChanged', + callEventData: $this->server->id + ); + + // Store activity ID and notify UI immediately with it + $this->activity_id = $activity->id; + ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id); } catch (\Throwable $e) { + // Set error status + $this->server->proxy->status = 'error'; + $this->server->save(); + + // Notify UI of error + ProxyStatusChangedUI::dispatch($this->server->team_id); + + // Clear dashboard cache on error + ProxyDashboardCacheService::clearCache($this->server); + return handleError($e); } } + + /** + * Build combined stop + start commands for proxy restart. + * This creates a single command sequence that shows all logs in one activity. + */ + private function buildRestartCommands(): array + { + $proxyType = $this->server->proxyType(); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $proxy_path = $this->server->proxyPath(); + $stopTimeout = 30; + + // Get proxy configuration + $configuration = GetProxyConfiguration::run($this->server); + if (! $configuration) { + throw new \Exception('Configuration is not synced'); + } + SaveProxyConfiguration::run($this->server, $configuration); + $docker_compose_yml_base64 = base64_encode($configuration); + $this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value(); + $this->server->save(); + + $commands = collect([]); + + // === STOP PHASE === + $commands = $commands->merge([ + "echo 'Stopping proxy...'", + "docker stop -t=$stopTimeout $containerName 2>/dev/null || true", + "docker rm -f $containerName 2>/dev/null || true", + '# Wait for container to be fully removed', + 'for i in {1..15}; do', + " if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container removed successfully.'", + ' break', + ' fi', + ' echo "Waiting for container to be removed... ($i/15)"', + ' sleep 1', + ' # Force remove on each iteration in case it got stuck', + " docker rm -f $containerName 2>/dev/null || true", + 'done', + '# Final verification and force cleanup', + "if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then", + " echo 'Container still exists after wait, forcing removal...'", + " docker rm -f $containerName 2>/dev/null || true", + ' sleep 2', + 'fi', + "echo 'Proxy stopped successfully.'", + ]); + + // === START PHASE === + if ($this->server->isSwarmManager()) { + $commands = $commands->merge([ + "echo 'Starting proxy (Swarm mode)...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo 'Creating required Docker Compose file.'", + "echo 'Starting coolify-proxy.'", + 'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy', + "echo 'Successfully started coolify-proxy.'", + ]); + } else { + if (isDev() && $proxyType === ProxyTypes::CADDY->value) { + $proxy_path = '/data/coolify/proxy/caddy'; + } + $caddyfile = 'import /dynamic/*.caddy'; + $commands = $commands->merge([ + "echo 'Starting proxy...'", + "mkdir -p $proxy_path/dynamic", + "cd $proxy_path", + "echo '$caddyfile' > $proxy_path/dynamic/Caddyfile", + "echo 'Creating required Docker Compose file.'", + "echo 'Pulling docker image.'", + 'docker compose pull', + ]); + // Ensure required networks exist BEFORE docker compose up + $commands = $commands->merge(ensureProxyNetworksExist($this->server)); + $commands = $commands->merge([ + "echo 'Starting coolify-proxy.'", + 'docker compose up -d --wait --remove-orphans', + "echo 'Successfully started coolify-proxy.'", + ]); + $commands = $commands->merge(connectProxyToNetworks($this->server)); + } + + return $commands->toArray(); + } } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index e55db5440..b21bc11a1 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -139,7 +139,9 @@ public function handle(): void if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; - $this->task_output = instant_remote_process([$exec], $this->server, true); + // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently + // See: https://github.com/coollabsio/coolify/issues/6736 + $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 45ab1dde8..20dc9987e 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): void private function processServerTasks(Server $server): void { + // Get server timezone (used for all scheduled tasks) + $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); + if (validate_timezone($serverTimezone) === false) { + $serverTimezone = config('app.timezone'); + } + // Check if we should run sentinel-based checks $lastSentinelUpdate = $server->sentinel_updated_at; $waitTime = $server->waitBeforeDoingSshCheck(); - $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime)); + $sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime)); if ($sentinelOutOfSync) { - // Dispatch jobs if Sentinel is out of sync - if ($this->shouldRunNow($this->checkFrequency)) { + // Dispatch ServerCheckJob if Sentinel is out of sync + if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) { ServerCheckJob::dispatch($server); } + } - // Dispatch ServerStorageCheckJob if due - $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *'); + $isSentinelEnabled = $server->isSentinelEnabled(); + $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) + + if ($shouldRestartSentinel) { + dispatch(function () use ($server) { + $server->restartContainer('coolify-sentinel'); + }); + } + + // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) + // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data + if ($sentinelOutOfSync) { + $serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'); if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) { $serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency]; } - $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency); + $shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone); if ($shouldRunStorageCheck) { ServerStorageCheckJob::dispatch($server); } } - $serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone); - if (validate_timezone($serverTimezone) === false) { - $serverTimezone = config('app.timezone'); - } - // Dispatch ServerPatchCheckJob if due (weekly) $shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone); @@ -146,14 +160,13 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone); + // Check for sentinel updates hourly (independent of user-configurable update_check_frequency) + if ($server->isSentinelEnabled()) { + $shouldCheckSentinel = $this->shouldRunNow('0 * * * *', $serverTimezone); - if ($shouldRestartSentinel) { - dispatch(function () use ($server) { - $server->restartContainer('coolify-sentinel'); - }); + if ($shouldCheckSentinel) { + CheckAndStartSentinelJob::dispatch($server); + } } } diff --git a/app/Listeners/MaintenanceModeDisabledNotification.php b/app/Listeners/MaintenanceModeDisabledNotification.php deleted file mode 100644 index 6c3ab83d8..000000000 --- a/app/Listeners/MaintenanceModeDisabledNotification.php +++ /dev/null @@ -1,48 +0,0 @@ -files(); - $files = collect($files); - $files = $files->sort(); - foreach ($files as $file) { - $content = Storage::disk('webhooks-during-maintenance')->get($file); - $data = json_decode($content, true); - $symfonyRequest = new SymfonyRequest( - $data['query'], - $data['request'], - $data['attributes'], - $data['cookies'], - $data['files'], - $data['server'], - $data['content'] - ); - - foreach ($data['headers'] as $key => $value) { - $symfonyRequest->headers->set($key, $value); - } - $request = Request::createFromBase($symfonyRequest); - $endpoint = str($file)->after('_')->beforeLast('_')->value(); - $class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value()); - $method = str($endpoint)->after('::')->value(); - try { - $instance = new $class; - $instance->$method($request); - } catch (\Throwable $th) { - } finally { - Storage::disk('webhooks-during-maintenance')->delete($file); - } - } - } -} diff --git a/app/Listeners/MaintenanceModeEnabledNotification.php b/app/Listeners/MaintenanceModeEnabledNotification.php deleted file mode 100644 index 5aab248ea..000000000 --- a/app/Listeners/MaintenanceModeEnabledNotification.php +++ /dev/null @@ -1,21 +0,0 @@ -setupDynamicProxyConfiguration(); $server->proxy->force_stop = false; $server->save(); + + // Check Traefik version after proxy is running + if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { + $traefikVersions = get_traefik_versions(); + if ($traefikVersions !== null) { + CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + } else { + Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [ + 'server_id' => $server->id, + 'server_name' => $server->name, + ]); + } + } } if ($status === 'created') { instant_remote_process([ diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 57ecaa8a2..8c2be9ab6 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -18,9 +18,9 @@ class Dashboard extends Component public function mount() { - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); - $this->servers = Server::ownedByCurrentTeam()->get(); - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + $this->servers = Server::ownedByCurrentTeamCached(); + $this->projects = Project::ownedByCurrentTeam()->with('environments')->get(); } public function render() diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index ac9cfd1c2..268aed152 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -14,7 +14,7 @@ class DeploymentsIndicator extends Component #[Computed] public function deployments() { - $servers = Server::ownedByCurrentTeam()->get(); + $servers = Server::ownedByCurrentTeamCached(); return ApplicationDeploymentQueue::with(['application.environment.project']) ->whereIn('status', ['in_progress', 'queued']) diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index cdac47d3d..87f7cff8a 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -18,6 +18,8 @@ class Show extends Component public $isKeepAliveOn = true; + public bool $is_debug_enabled = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -56,9 +58,23 @@ public function mount() $this->application_deployment_queue = $application_deployment_queue; $this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus(); $this->deployment_uuid = $deploymentUuid; + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; $this->isKeepAliveOn(); } + public function toggleDebug() + { + try { + $this->authorize('update', $this->application); + $this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled; + $this->application->settings->save(); + $this->is_debug_enabled = $this->application->settings->is_debug_enabled; + $this->application_deployment_queue->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshQueue() { $this->application_deployment_queue->refresh(); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 71ca9720e..c84de9d8d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -521,7 +521,7 @@ public function instantSave() } } - public function loadComposeFile($isInit = false, $showToast = true) + public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { try { $this->authorize('update', $this->application); @@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true) return; } - ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); + ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation); if (is_null($this->parsedServices)) { $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); @@ -606,13 +606,6 @@ public function generateDomain(string $serviceName) } } - public function updatedBaseDirectory() - { - if ($this->buildPack === 'dockercompose') { - $this->loadComposeFile(); - } - } - public function updatedIsStatic($value) { if ($value) { @@ -786,11 +779,13 @@ public function submit($showToaster = true) try { $this->authorize('update', $this->application); + $this->resetErrorBag(); $this->validate(); $oldPortsExposes = $this->application->ports_exposes; $oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled; $oldDockerComposeLocation = $this->initialDockerComposeLocation; + $oldBaseDirectory = $this->application->base_directory; // Process FQDN with intermediate variable to avoid Collection/string confusion $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); @@ -821,6 +816,42 @@ public function submit($showToaster = true) return; // Stop if there are conflicts and user hasn't confirmed } + // Normalize paths BEFORE validation + if ($this->baseDirectory && $this->baseDirectory !== '/') { + $this->baseDirectory = rtrim($this->baseDirectory, '/'); + $this->application->base_directory = $this->baseDirectory; + } + if ($this->publishDirectory && $this->publishDirectory !== '/') { + $this->publishDirectory = rtrim($this->publishDirectory, '/'); + $this->application->publish_directory = $this->publishDirectory; + } + + // Validate docker compose file path BEFORE saving to database + // This prevents invalid paths from being persisted when validation fails + if ($this->buildPack === 'dockercompose' && + ($oldDockerComposeLocation !== $this->dockerComposeLocation || + $oldBaseDirectory !== $this->baseDirectory)) { + // Pass original values to loadComposeFile so it can restore them on failure + // The finally block in Application::loadComposeFile will save these original + // values if validation fails, preventing invalid paths from being persisted + $compose_return = $this->loadComposeFile( + isInit: false, + showToast: false, + restoreBaseDirectory: $oldBaseDirectory, + restoreDockerComposeLocation: $oldDockerComposeLocation + ); + if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { + // Validation failed - restore original values to component properties + $this->baseDirectory = $oldBaseDirectory; + $this->dockerComposeLocation = $oldDockerComposeLocation; + // The model was saved by loadComposeFile's finally block with original values + // Refresh to sync component with database state + $this->application->refresh(); + + return; + } + } + $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { $this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n"); @@ -828,13 +859,6 @@ public function submit($showToaster = true) $this->application->save(); } - if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) { - $compose_return = $this->loadComposeFile(showToast: false); - if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) { - return; - } - } - if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) { $this->resetDefaultLabels(); } @@ -855,14 +879,6 @@ public function submit($showToaster = true) $this->application->ports_exposes = $port; } } - if ($this->baseDirectory && $this->baseDirectory !== '/') { - $this->baseDirectory = rtrim($this->baseDirectory, '/'); - $this->application->base_directory = $this->baseDirectory; - } - if ($this->publishDirectory && $this->publishDirectory !== '/') { - $this->publishDirectory = rtrim($this->publishDirectory, '/'); - $this->application->publish_directory = $this->publishDirectory; - } if ($this->buildPack === 'dockercompose') { $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); if ($this->application->isDirty('docker_compose_domains')) { @@ -1018,11 +1034,27 @@ public function getDockerComposeBuildCommandPreviewProperty(): string // Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml) // Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location} // Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth - return injectDockerComposeFlags( + $command = injectDockerComposeFlags( $this->dockerComposeCustomBuildCommand, ".{$normalizedBase}{$this->dockerComposeLocation}", \App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH ); + + // Inject build args if not using build secrets + if (! $this->application->settings->use_build_secrets) { + $buildTimeEnvs = $this->application->environment_variables() + ->where('is_buildtime', true) + ->get(); + + if ($buildTimeEnvs->isNotEmpty()) { + $buildArgs = generateDockerBuildArgs($buildTimeEnvs); + $buildArgsString = $buildArgs->implode(' '); + + $command = injectDockerComposeBuildArgs($command, $buildArgsString); + } + } + + return $command; } public function getDockerComposeStartCommandPreviewProperty(): string diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 942dfeb37..85ba2328e 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -96,8 +96,7 @@ public function generate() $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); - $preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn); - $preview_fqdns[] = "$schema://$preview_fqdn"; + $preview_fqdns[] = "$schema://$preview_fqdn{$port}"; } $preview_fqdn = implode(',', $preview_fqdns); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index da67a5707..d6b490c79 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -4,6 +4,7 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Attributes\Validate; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -19,9 +20,30 @@ class Rollback extends Component public array $parameters; + #[Validate(['integer', 'min:0', 'max:100'])] + public int $dockerImagesToKeep = 2; + + public bool $serverRetentionDisabled = false; + public function mount() { $this->parameters = get_route_parameters(); + $this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2; + $server = $this->application->destination->server; + $this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false; + } + + public function saveSettings() + { + try { + $this->authorize('update', $this->application); + $this->validate(); + $this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep; + $this->application->settings->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function rollbackImage($commit) @@ -66,14 +88,12 @@ public function loadImages($showToast = false) return str($item)->contains($image); })->map(function ($item) { $item = str($item)->explode('#'); - if ($item[1] === $this->current) { - // $is_current = true; - } + $is_current = $item[1] === $this->current; return [ 'tag' => $item[1], 'created_at' => $item[2], - 'is_current' => $is_current ?? null, + 'is_current' => $is_current, ]; })->toArray(); } diff --git a/app/Livewire/Project/Index.php b/app/Livewire/Project/Index.php index 0e4f15a5c..7aa8dfc49 100644 --- a/app/Livewire/Project/Index.php +++ b/app/Livewire/Project/Index.php @@ -17,9 +17,9 @@ class Index extends Component public function mount() { - $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); - $this->projects = Project::ownedByCurrentTeam()->get(); - $this->servers = Server::ownedByCurrentTeam()->count(); + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); + $this->projects = Project::ownedByCurrentTeamCached(); + $this->servers = Server::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 27ecacb99..5dd508c29 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -75,16 +75,6 @@ public function mount() $this->github_apps = GithubApp::private(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 89814ee7f..2fffff6b9 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -107,26 +107,6 @@ public function mount() $this->query = request()->query(); } - public function updatedBaseDirectory() - { - if ($this->base_directory) { - $this->base_directory = rtrim($this->base_directory, '/'); - if (! str($this->base_directory)->startsWith('/')) { - $this->base_directory = '/'.$this->base_directory; - } - } - } - - public function updatedDockerComposeLocation() - { - if ($this->docker_compose_location) { - $this->docker_compose_location = rtrim($this->docker_compose_location, '/'); - if (! str($this->docker_compose_location)->startsWith('/')) { - $this->docker_compose_location = '/'.$this->docker_compose_location; - } - } - } - public function updatedBuildPack() { if ($this->build_pack === 'nixpacks') { diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 259b9dbec..68544f1ab 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -82,6 +82,21 @@ public function instantSave() } } + public function instantSaveSettings() + { + try { + $this->authorize('update', $this->application); + // Save checkbox states without port validation + $this->application->is_gzip_enabled = $this->isGzipEnabled; + $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->application->exclude_from_status = $this->excludeFromStatus; + $this->application->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function instantSaveAdvanced() { try { diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 3ed2befba..f57563330 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -39,10 +39,14 @@ class GetLogs extends Component public ?bool $streamLogs = false; - public ?bool $showTimeStamps = false; + public ?bool $showTimeStamps = true; public ?int $numberOfLines = 100; + public bool $expandByDefault = false; + + public bool $collapsible = true; + public function mount() { if (! is_null($this->resource)) { @@ -92,12 +96,33 @@ public function instantSave() } } + public function toggleTimestamps() + { + $previousValue = $this->showTimeStamps; + $this->showTimeStamps = ! $this->showTimeStamps; + + try { + $this->instantSave(); + $this->getLogs(true); + } catch (\Throwable $e) { + // Revert the flag to its previous value on failure + $this->showTimeStamps = $previousValue; + + return handleError($e, $this); + } + } + + public function toggleStreamLogs() + { + $this->streamLogs = ! $this->streamLogs; + } + public function getLogs($refresh = false) { if (! $this->server->isFunctional()) { return; } - if (! $refresh && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { + if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === \App\Models\Service::class || str($this->container)->contains('-pr-'))) { return; } if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 47b3534a2..4ba961dfd 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -36,7 +36,7 @@ public function mount() $parameters = get_route_parameters(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); $this->servers = currentTeam()->servers->filter(fn ($server) => ! $server->isBuildServer()); } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index d7210c15d..2d6b76c25 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -41,7 +41,7 @@ class Add extends Component 'command' => 'required|string', 'frequency' => 'required|string', 'container' => 'nullable|string', - 'timeout' => 'required|integer|min:60|max:3600', + 'timeout' => 'required|integer|min:60|max:36000', ]; protected $validationAttributes = [ diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 088de0a76..b1b34dd71 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -40,7 +40,7 @@ class Show extends Component #[Validate(['string', 'nullable'])] public ?string $container = null; - #[Validate(['integer', 'required', 'min:60', 'max:3600'])] + #[Validate(['integer', 'required', 'min:60', 'max:36000'])] public $timeout = 300; #[Locked] @@ -72,7 +72,7 @@ public function mount(string $task_uuid, string $project_uuid, string $environme } elseif ($service_uuid) { $this->type = 'service'; $this->service_uuid = $service_uuid; - $this->resource = Service::ownedByCurrentTeam()->where('uuid', $service_uuid)->firstOrFail(); + $this->resource = Service::ownedByCurrentTeamCached()->where('uuid', $service_uuid)->firstOrFail(); } $this->parameters = [ 'environment_uuid' => $environment_uuid, diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index de2deeed4..3c2abc84c 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -11,20 +11,6 @@ class Terminal extends Component { public bool $hasShell = true; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ApplicationStatusChanged" => 'closeTerminal', - ]; - } - - public function closeTerminal() - { - $this->dispatch('reloadWindow'); - } - private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); diff --git a/app/Livewire/Server/Create.php b/app/Livewire/Server/Create.php index cf77664fe..5fd2ea4f7 100644 --- a/app/Livewire/Server/Create.php +++ b/app/Livewire/Server/Create.php @@ -17,7 +17,7 @@ class Create extends Component public function mount() { - $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); + $this->private_keys = PrivateKey::ownedByCurrentTeamCached(); if (! isCloud()) { $this->limit_reached = false; diff --git a/app/Livewire/Server/DockerCleanup.php b/app/Livewire/Server/DockerCleanup.php index 764e583cd..92094c950 100644 --- a/app/Livewire/Server/DockerCleanup.php +++ b/app/Livewire/Server/DockerCleanup.php @@ -31,6 +31,9 @@ class DockerCleanup extends Component #[Validate('boolean')] public bool $deleteUnusedNetworks = false; + #[Validate('boolean')] + public bool $disableApplicationImageRetention = false; + public function mount(string $server_uuid) { try { @@ -52,6 +55,7 @@ public function syncData(bool $toModel = false) $this->server->settings->docker_cleanup_threshold = $this->dockerCleanupThreshold; $this->server->settings->delete_unused_volumes = $this->deleteUnusedVolumes; $this->server->settings->delete_unused_networks = $this->deleteUnusedNetworks; + $this->server->settings->disable_application_image_retention = $this->disableApplicationImageRetention; $this->server->settings->save(); } else { $this->forceDockerCleanup = $this->server->settings->force_docker_cleanup; @@ -59,6 +63,7 @@ public function syncData(bool $toModel = false) $this->dockerCleanupThreshold = $this->server->settings->docker_cleanup_threshold; $this->deleteUnusedVolumes = $this->server->settings->delete_unused_volumes; $this->deleteUnusedNetworks = $this->server->settings->delete_unused_networks; + $this->disableApplicationImageRetention = $this->server->settings->disable_application_image_retention; } } diff --git a/app/Livewire/Server/Index.php b/app/Livewire/Server/Index.php index 74764960a..eb832d72f 100644 --- a/app/Livewire/Server/Index.php +++ b/app/Livewire/Server/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->servers = Server::ownedByCurrentTeam()->get(); + $this->servers = Server::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 6725e5d0a..cd9cfcba6 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -6,11 +6,10 @@ use App\Actions\Proxy\StartProxy; use App\Actions\Proxy\StopProxy; use App\Enums\ProxyTypes; -use App\Jobs\CheckTraefikVersionForServerJob; +use App\Jobs\RestartProxyJob; use App\Models\Server; use App\Services\ProxyDashboardCacheService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Log; use Livewire\Component; class Navbar extends Component @@ -29,6 +28,10 @@ class Navbar extends Component public ?string $proxyStatus = 'unknown'; + public ?string $lastNotifiedStatus = null; + + public bool $restartInitiated = false; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -63,27 +66,19 @@ public function restart() { try { $this->authorize('manageProxy', $this->server); - StopProxy::run($this->server, restarting: true); - $this->server->proxy->force_stop = false; - $this->server->save(); - - $activity = StartProxy::run($this->server, force: true, restarting: true); - $this->dispatch('activityMonitor', $activity->id); - - // Check Traefik version after restart to provide immediate feedback - if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) { - $traefikVersions = get_traefik_versions(); - if ($traefikVersions !== null) { - CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions); - } else { - Log::warning('Traefik version check skipped: versions.json data unavailable', [ - 'server_id' => $this->server->id, - 'server_name' => $this->server->name, - ]); - } + // Prevent duplicate restart calls + if ($this->restartInitiated) { + return; } + $this->restartInitiated = true; + + // Always use background job for all servers + RestartProxyJob::dispatch($this->server); + } catch (\Throwable $e) { + $this->restartInitiated = false; + return handleError($e, $this); } } @@ -137,12 +132,27 @@ public function checkProxyStatus() } } - public function showNotification() + public function showNotification($event = null) { $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + // If event contains activityId, open activity monitor + if ($event && isset($event['activityId'])) { + $this->dispatch('activityMonitor', $event['activityId']); + } + + // Reset restart flag when proxy reaches a stable state + if (in_array($this->proxyStatus, ['running', 'exited', 'error'])) { + $this->restartInitiated = false; + } + + // Skip notification if we already notified about this status (prevents duplicates) + if ($this->lastNotifiedStatus === $this->proxyStatus) { + return; + } + switch ($this->proxyStatus) { case 'running': $this->loadProxyConfiguration(); @@ -150,6 +160,7 @@ public function showNotification() // Don't show during normal start/restart flows (starting, restarting, stopping) if (in_array($previousStatus, ['exited', 'stopped', 'unknown', null])) { $this->dispatch('success', 'Proxy is running.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'exited': @@ -157,19 +168,30 @@ public function showNotification() // Don't show during normal stop/restart flows (stopping, restarting) if (in_array($previousStatus, ['running'])) { $this->dispatch('info', 'Proxy has exited.'); + $this->lastNotifiedStatus = $this->proxyStatus; } break; case 'stopping': - $this->dispatch('info', 'Proxy is stopping.'); + // $this->dispatch('info', 'Proxy is stopping.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'starting': - $this->dispatch('info', 'Proxy is starting.'); + // $this->dispatch('info', 'Proxy is starting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'restarting': + // $this->dispatch('info', 'Proxy is restarting.'); + $this->lastNotifiedStatus = $this->proxyStatus; + break; + case 'error': + $this->dispatch('error', 'Proxy restart failed. Check logs.'); + $this->lastNotifiedStatus = $this->proxyStatus; break; case 'unknown': - $this->dispatch('info', 'Proxy status is unknown.'); + // Don't notify for unknown status - too noisy break; default: - $this->dispatch('info', 'Proxy status updated.'); + // Don't notify for other statuses break; } diff --git a/app/Livewire/SharedVariables/Environment/Index.php b/app/Livewire/SharedVariables/Environment/Index.php index 3673a3882..6685c5c99 100644 --- a/app/Livewire/SharedVariables/Environment/Index.php +++ b/app/Livewire/SharedVariables/Environment/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/SharedVariables/Project/Index.php b/app/Livewire/SharedVariables/Project/Index.php index 570da74d3..58929bade 100644 --- a/app/Livewire/SharedVariables/Project/Index.php +++ b/app/Livewire/SharedVariables/Project/Index.php @@ -12,7 +12,7 @@ class Index extends Component public function mount() { - $this->projects = Project::ownedByCurrentTeam()->get(); + $this->projects = Project::ownedByCurrentTeamCached(); } public function render() diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 4bd0b798a..0a38e6088 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -196,7 +196,7 @@ public function mount() $github_app_uuid = request()->github_app_uuid; $this->github_app = GithubApp::ownedByCurrentTeam()->whereUuid($github_app_uuid)->firstOrFail(); $this->github_app->makeVisible(['client_secret', 'webhook_secret']); - $this->privateKeys = PrivateKey::ownedByCurrentTeam()->get(); + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); $this->applications = $this->github_app->applications; $settings = instanceSettings(); diff --git a/app/Models/Application.php b/app/Models/Application.php index 6e920f8e6..5006d0ff8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -338,11 +338,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for applications owned by current team. + * If you need all applications without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Application::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all applications owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Application::ownedByCurrentTeam()->get(); + }); + } + public function getContainersToStop(Server $server, bool $previewDeployments = false): array { $containers = $previewDeployments @@ -1500,10 +1514,10 @@ public function oldRawParser() instant_remote_process($commands, $this->destination->server, false); } - public function parse(int $pull_request_id = 0, ?int $preview_id = null) + public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null) { if ((int) $this->compose_parsing_version >= 3) { - return applicationParser($this, $pull_request_id, $preview_id); + return applicationParser($this, $pull_request_id, $preview_id, $commit); } elseif ($this->docker_compose_raw) { return parseDockerComposeFile(resource: $this, isNew: false, pull_request_id: $pull_request_id, preview_id: $preview_id); } else { @@ -1511,9 +1525,11 @@ public function parse(int $pull_request_id = 0, ?int $preview_id = null) } } - public function loadComposeFile($isInit = false) + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { - $initialDockerComposeLocation = $this->docker_compose_location; + // Use provided restore values or capture current values as fallback + $initialDockerComposeLocation = $restoreDockerComposeLocation ?? $this->docker_compose_location; + $initialBaseDirectory = $restoreBaseDirectory ?? $this->base_directory; if ($isInit && $this->docker_compose_raw) { return; } @@ -1580,6 +1596,7 @@ public function loadComposeFile($isInit = false) throw new \RuntimeException($e->getMessage()); } finally { $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; $this->save(); $commands = collect([ "rm -rf /tmp/{$uuid}", diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 721b22216..04ce6274a 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -145,11 +145,13 @@ public function generate_preview_fqdn_compose() $template = $this->application->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; $random = new Cuid2; $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + $preview_fqdn = "$schema://$preview_fqdn{$port}"; $preview_domains[] = $preview_fqdn; } diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index de545e9bb..f40977b3e 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -25,6 +25,7 @@ class ApplicationSetting extends Model 'is_git_submodules_enabled' => 'boolean', 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', + 'docker_images_to_keep' => 'integer', ]; protected $guarded = []; diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 843f01e59..895dc1c43 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -65,6 +65,8 @@ protected static function booted() 'value' => $environment_variable->value, 'is_multiline' => $environment_variable->is_multiline ?? false, 'is_literal' => $environment_variable->is_literal ?? false, + 'is_runtime' => $environment_variable->is_runtime ?? false, + 'is_buildtime' => $environment_variable->is_buildtime ?? false, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, 'is_preview' => true, diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 46531ed34..bb76d5ed6 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -80,6 +80,10 @@ public function getPublicKey() return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key'; } + /** + * Get query builder for private keys owned by current team. + * If you need all private keys without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam(array $select = ['*']) { $teamId = currentTeam()->id; @@ -88,6 +92,16 @@ public static function ownedByCurrentTeam(array $select = ['*']) return self::whereTeamId($teamId)->select($selectArray->all()); } + /** + * Get all private keys owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return PrivateKey::ownedByCurrentTeam()->get(); + }); + } + public static function ownedAndOnlySShKeys(array $select = ['*']) { $teamId = currentTeam()->id; diff --git a/app/Models/Project.php b/app/Models/Project.php index a9bf76803..8b26672f0 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -30,11 +30,25 @@ class Project extends BaseModel protected $guarded = []; + /** + * Get query builder for projects owned by current team. + * If you need all projects without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Project::whereTeamId(currentTeam()->id)->orderByRaw('LOWER(name)'); } + /** + * Get all projects owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Project::ownedByCurrentTeam()->get(); + }); + } + protected static function booted() { static::created(function ($project) { diff --git a/app/Models/Server.php b/app/Models/Server.php index 8b153c8ac..82ee6721d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -242,6 +242,10 @@ public static function isReachable() return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true); } + /** + * Get query builder for servers owned by current team. + * If you need all servers without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam(array $select = ['*']) { $teamId = currentTeam()->id; @@ -250,6 +254,16 @@ public static function ownedByCurrentTeam(array $select = ['*']) return Server::whereTeamId($teamId)->with('settings', 'swarmDockers', 'standaloneDockers')->select($selectArray->all())->orderBy('name'); } + /** + * Get all servers owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Server::ownedByCurrentTeam()->get(); + }); + } + public static function isUsable() { return Server::ownedByCurrentTeam()->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_swarm_worker', false)->whereRelation('settings', 'is_build_server', false)->whereRelation('settings', 'force_disabled', false); diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6da4dd4c6..4b33df300 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -61,6 +61,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_terminal_enabled' => 'boolean', + 'disable_application_image_retention' => 'boolean', ]; protected static function booted() diff --git a/app/Models/Service.php b/app/Models/Service.php index 2f8a64464..2daf9c39d 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -153,11 +153,25 @@ public function tags() return $this->morphToMany(Tag::class, 'taggable'); } + /** + * Get query builder for services owned by current team. + * If you need all services without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return Service::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all services owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return Service::ownedByCurrentTeam()->get(); + }); + } + public function deleteConfigurations() { $server = data_get($this, 'destination.server'); @@ -712,6 +726,84 @@ public function extraFields() $fields->put('MinIO', $data->toArray()); break; + case $image->contains('garage'): + $data = collect([]); + $s3_api_url = $this->environment_variables()->where('key', 'GARAGE_S3_API_URL')->first(); + $web_url = $this->environment_variables()->where('key', 'GARAGE_WEB_URL')->first(); + $admin_url = $this->environment_variables()->where('key', 'GARAGE_ADMIN_URL')->first(); + $admin_token = $this->environment_variables()->where('key', 'GARAGE_ADMIN_TOKEN')->first(); + if (is_null($admin_token)) { + $admin_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGE')->first(); + } + $rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first(); + if (is_null($rpc_secret)) { + $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first(); + } + $metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first(); + if (is_null($metrics_token)) { + $metrics_token = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GARAGEMETRICS')->first(); + } + + if ($s3_api_url) { + $data = $data->merge([ + 'S3 API URL' => [ + 'key' => data_get($s3_api_url, 'key'), + 'value' => data_get($s3_api_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($web_url) { + $data = $data->merge([ + 'Web URL' => [ + 'key' => data_get($web_url, 'key'), + 'value' => data_get($web_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_url) { + $data = $data->merge([ + 'Admin URL' => [ + 'key' => data_get($admin_url, 'key'), + 'value' => data_get($admin_url, 'value'), + 'rules' => 'required|url', + ], + ]); + } + if ($admin_token) { + $data = $data->merge([ + 'Admin Token' => [ + 'key' => data_get($admin_token, 'key'), + 'value' => data_get($admin_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($rpc_secret) { + $data = $data->merge([ + 'RPC Secret' => [ + 'key' => data_get($rpc_secret, 'key'), + 'value' => data_get($rpc_secret, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + if ($metrics_token) { + $data = $data->merge([ + 'Metrics Token' => [ + 'key' => data_get($metrics_token, 'key'), + 'value' => data_get($metrics_token, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + + $fields->put('Garage', $data->toArray()); + break; case $image->contains('weblate'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first(); diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index aef74b402..7b8b46812 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -37,11 +37,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return ServiceApplication::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for service applications owned by current team. + * If you need all service applications without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return ServiceApplication::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all service applications owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return ServiceApplication::ownedByCurrentTeam()->get(); + }); + } + public function isRunning() { return str($this->status)->contains('running'); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 3a249059c..f6a39cfe4 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -30,11 +30,25 @@ public static function ownedByCurrentTeamAPI(int $teamId) return ServiceDatabase::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); } + /** + * Get query builder for service databases owned by current team. + * If you need all service databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return ServiceDatabase::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all service databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return ServiceDatabase::ownedByCurrentTeam()->get(); + }); + } + public function restart() { $container_id = $this->name.'-'.$this->service->uuid; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 6ac685618..f598ef2ea 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for ClickHouse databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all ClickHouse databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneClickhouse::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 2d004246c..47170056f 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for Dragonfly databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all Dragonfly databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneDragonfly::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 131e5bb3f..266110d0a 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -44,11 +44,25 @@ protected static function booted() }); } + /** + * Get query builder for KeyDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all KeyDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneKeydb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 675c7987f..aa7f2d31a 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for MariaDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MariaDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMariadb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 7b70988f6..9046ab013 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -47,11 +47,25 @@ protected static function booted() }); } + /** + * Get query builder for MongoDB databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MongoDB databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMongodb::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 6f79241af..719387b36 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for MySQL databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all MySQL databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneMysql::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 2dc5616a2..03080fd3d 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -45,11 +45,25 @@ protected static function booted() }); } + /** + * Get query builder for PostgreSQL databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all PostgreSQL databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandalonePostgresql::ownedByCurrentTeam()->get(); + }); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index c0223304a..6aca8af9a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -46,11 +46,25 @@ protected static function booted() }); } + /** + * Get query builder for Redis databases owned by current team. + * If you need all databases without further query chaining, use ownedByCurrentTeamCached() instead. + */ public static function ownedByCurrentTeam() { return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); } + /** + * Get all Redis databases owned by current team (cached for request duration). + */ + public static function ownedByCurrentTeamCached() + { + return once(function () { + return StandaloneRedis::ownedByCurrentTeam()->get(); + }); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index 09ef4257d..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -43,9 +43,19 @@ public function toMail($notifiable = null): MailMessage $mail = new MailMessage; $count = $this->servers->count(); + // Transform servers to include URLs + $serversWithUrls = $this->servers->map(function ($server) { + return [ + 'name' => $server->name, + 'uuid' => $server->uuid, + 'url' => base_url().'/server/'.$server->uuid.'/proxy', + 'outdatedInfo' => $server->outdatedInfo ?? [], + ]; + }); + $mail->subject("Coolify: Traefik proxy outdated on {$count} server(s)"); $mail->view('emails.traefik-version-outdated', [ - 'servers' => $this->servers, + 'servers' => $serversWithUrls, 'count' => $count, ]); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 2d9910add..9163d595c 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,6 @@ namespace App\Providers; -use App\Listeners\MaintenanceModeDisabledNotification; -use App\Listeners\MaintenanceModeEnabledNotification; -use Illuminate\Foundation\Events\MaintenanceModeDisabled; -use Illuminate\Foundation\Events\MaintenanceModeEnabled; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite; @@ -19,12 +15,6 @@ class EventServiceProvider extends ServiceProvider { protected $listen = [ - MaintenanceModeEnabled::class => [ - MaintenanceModeEnabledNotification::class, - ], - MaintenanceModeDisabled::class => [ - MaintenanceModeDisabledNotification::class, - ], SocialiteWasCalled::class => [ AzureExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle', diff --git a/app/Services/ContainerStatusAggregator.php b/app/Services/ContainerStatusAggregator.php index 4a17ecdd6..2be36d905 100644 --- a/app/Services/ContainerStatusAggregator.php +++ b/app/Services/ContainerStatusAggregator.php @@ -16,14 +16,23 @@ * UI components transform this to human-readable format (e.g., "Running (Healthy)"). * * State Priority (highest to lowest): - * 1. Restarting → degraded:unhealthy - * 2. Crash Loop (exited with restarts) → degraded:unhealthy - * 3. Mixed (running + exited) → degraded:unhealthy - * 4. Running → running:healthy/unhealthy/unknown - * 5. Dead/Removing → degraded:unhealthy - * 6. Paused → paused:unknown - * 7. Starting/Created → starting:unknown - * 8. Exited → exited + * 1. Degraded (from sub-resources) → degraded:unhealthy + * 2. Restarting → degraded:unhealthy (or restarting:unknown if preserveRestarting=true) + * 3. Crash Loop (exited with restarts) → degraded:unhealthy + * 4. Mixed (running + exited) → degraded:unhealthy + * 5. Mixed (running + starting) → starting:unknown + * 6. Running → running:healthy/unhealthy/unknown + * 7. Dead/Removing → degraded:unhealthy + * 8. Paused → paused:unknown + * 9. Starting/Created → starting:unknown + * 10. Exited → exited + * + * The $preserveRestarting parameter controls whether "restarting" containers should be + * reported as "restarting:unknown" (true) or "degraded:unhealthy" (false, default). + * - Use preserveRestarting=true for individual sub-resources (ServiceApplication/ServiceDatabase) + * so they show "Restarting" in the UI. + * - Use preserveRestarting=false for overall Service status aggregation where any restarting + * container should mark the entire service as "Degraded". */ class ContainerStatusAggregator { @@ -32,9 +41,10 @@ class ContainerStatusAggregator * * @param Collection $containerStatuses Collection of status strings (e.g., "running (healthy)", "running:healthy") * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0): string + public function aggregateFromStrings(Collection $containerStatuses, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -64,10 +74,16 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting = false; $hasPaused = false; $hasDead = false; + $hasDegraded = false; // Parse each status string and set flags foreach ($containerStatuses as $status) { - if (str($status)->contains('restarting')) { + if (str($status)->contains('degraded')) { + $hasDegraded = true; + if (str($status)->contains('unhealthy')) { + $hasUnhealthy = true; + } + } elseif (str($status)->contains('restarting')) { $hasRestarting = true; } elseif (str($status)->contains('running')) { $hasRunning = true; @@ -98,7 +114,9 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + $hasDegraded, + $maxRestartCount, + $preserveRestarting ); } @@ -107,9 +125,10 @@ public function aggregateFromStrings(Collection $containerStatuses, int $maxRest * * @param Collection $containers Collection of Docker container objects with State property * @param int $maxRestartCount Maximum restart count across containers (for crash loop detection) + * @param bool $preserveRestarting If true, "restarting" containers return "restarting:unknown" instead of "degraded:unhealthy" * @return string Aggregated status in colon format (e.g., "running:healthy") */ - public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0): string + public function aggregateFromContainers(Collection $containers, int $maxRestartCount = 0, bool $preserveRestarting = false): string { // Validate maxRestartCount parameter if ($maxRestartCount < 0) { @@ -175,7 +194,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC $hasStarting, $hasPaused, $hasDead, - $maxRestartCount + false, // $hasDegraded - not applicable for container objects, only for status strings + $maxRestartCount, + $preserveRestarting ); } @@ -190,7 +211,9 @@ public function aggregateFromContainers(Collection $containers, int $maxRestartC * @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 bool $hasDegraded Has at least one degraded container * @param int $maxRestartCount Maximum restart count (for crash loop detection) + * @param bool $preserveRestarting If true, return "restarting:unknown" instead of "degraded:unhealthy" for restarting containers * @return string Status in colon format (e.g., "running:healthy") */ private function resolveStatus( @@ -202,24 +225,40 @@ private function resolveStatus( bool $hasStarting, bool $hasPaused, bool $hasDead, - int $maxRestartCount + bool $hasDegraded, + int $maxRestartCount, + bool $preserveRestarting = false ): string { - // Priority 1: Restarting containers (degraded state) - if ($hasRestarting) { + // Priority 1: Degraded containers from sub-resources (highest priority) + // If any service/application within a service stack is degraded, the entire stack is degraded + if ($hasDegraded) { return 'degraded:unhealthy'; } - // Priority 2: Crash loop detection (exited with restart count > 0) + // Priority 2: Restarting containers + // When preserveRestarting is true (for individual sub-resources), keep as "restarting" + // When false (for overall service status), mark as "degraded" + if ($hasRestarting) { + return $preserveRestarting ? 'restarting:unknown' : 'degraded:unhealthy'; + } + + // Priority 3: Crash loop detection (exited with restart count > 0) if ($hasExited && $maxRestartCount > 0) { return 'degraded:unhealthy'; } - // Priority 3: Mixed state (some running, some exited = degraded) + // Priority 4: Mixed state (some running, some exited = degraded) if ($hasRunning && $hasExited) { return 'degraded:unhealthy'; } - // Priority 4: Running containers (check health status) + // Priority 5: Mixed state (some running, some starting = still starting) + // If any component is still starting, the entire service stack is not fully ready + if ($hasRunning && $hasStarting) { + return 'starting:unknown'; + } + + // Priority 6: Running containers (check health status) if ($hasRunning) { if ($hasUnhealthy) { return 'running:unhealthy'; @@ -230,22 +269,22 @@ private function resolveStatus( } } - // Priority 5: Dead or removing containers + // Priority 7: Dead or removing containers if ($hasDead) { return 'degraded:unhealthy'; } - // Priority 6: Paused containers + // Priority 8: Paused containers if ($hasPaused) { return 'paused:unknown'; } - // Priority 7: Starting/created containers + // Priority 9: Starting/created containers if ($hasStarting) { return 'starting:unknown'; } - // Priority 8: All containers exited (no restart count = truly stopped) + // Priority 10: All containers exited (no restart count = truly stopped) return 'exited'; } } diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index db7767c1e..7a36c4b63 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -68,10 +68,16 @@ function queue_application_deployment(Application $application, string $deployme ]); if ($no_questions_asked) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); } elseif (next_queuable($server_id, $application_id, $commit, $pull_request_id)) { + $deployment->update([ + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); ApplicationDeploymentJob::dispatch( application_deployment_queue_id: $deployment->id, ); diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 178876b89..114c4bb98 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -48,6 +48,8 @@ 'influxdb', 'clickhouse/clickhouse-server', 'timescaledb/timescaledb', + 'timescaledb', // Matches timescale/timescaledb + 'timescaledb-ha', // Matches timescale/timescaledb-ha 'pgvector/pgvector', ]; const SPECIFIC_SERVICES = [ @@ -56,6 +58,7 @@ 'ghcr.io/coollabsio/minio', 'coollabsio/minio', 'svhd/logto', + 'dxflrs/garage', ]; // Based on /etc/os-release diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 4a0faaec1..a0f810480 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -312,6 +312,36 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) $LOGTO_ADMIN_ENDPOINT->value.':3002', ]); break; + case $type?->contains('garage'): + $GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first(); + $GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first(); + $GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first(); + + if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) { + return collect([]); + } + + if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) { + $GARAGE_S3_API_URL->update([ + 'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) { + $GARAGE_WEB_URL->update([ + 'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true), + ]); + } + if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) { + $GARAGE_ADMIN_URL->update([ + 'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true), + ]); + } + $payload = collect([ + $GARAGE_S3_API_URL->value.':3900', + $GARAGE_WEB_URL->value.':3902', + $GARAGE_ADMIN_URL->value.':3903', + ]); + break; } return $payload; @@ -770,10 +800,26 @@ function isDatabaseImage(?string $image = null, ?array $serviceConfig = null) } $imageName = $image->before(':'); - // First check if it's a known database image + // Extract base image name (ignore registry prefix) + // Examples: + // docker.io/library/postgres -> postgres + // ghcr.io/postgrest/postgrest -> postgrest + // postgres -> postgres + // postgrest/postgrest -> postgrest + $baseImageName = $imageName; + if (str($imageName)->contains('/')) { + $baseImageName = str($imageName)->afterLast('/'); + } + + // Check if base image name exactly matches a known database image $isKnownDatabase = false; foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) { - if (str($imageName)->contains($database_docker_image)) { + // Extract base name from database pattern for comparison + $databaseBaseName = str($database_docker_image)->contains('/') + ? str($database_docker_image)->afterLast('/') + : $database_docker_image; + + if ($baseImageName == $databaseBaseName) { $isKnownDatabase = true; break; } @@ -1376,3 +1422,62 @@ function injectDockerComposeFlags(string $command, string $composeFilePath, stri // Replace only first occurrence to avoid modifying comments/strings/chained commands return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1); } + +/** + * Inject build arguments right after build-related subcommands in docker/docker compose commands. + * This ensures build args are only applied to build operations, not to push, pull, up, etc. + * + * Supports: + * - docker compose build + * - docker buildx build + * - docker builder build + * - docker build (legacy) + * + * Examples: + * - Input: "docker compose -f file.yml build" + * Output: "docker compose -f file.yml build --build-arg X --build-arg Y" + * + * - Input: "docker buildx build --platform linux/amd64" + * Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64" + * + * - Input: "docker builder build --tag myimage:latest" + * Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest" + * + * - Input: "docker compose build && docker compose push" + * Output: "docker compose build --build-arg X --build-arg Y && docker compose push" + * + * - Input: "docker compose push" + * Output: "docker compose push" (unchanged - no build command found) + * + * @param string $command The docker command + * @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y") + * @return string The modified command with build args injected after build subcommand + */ +function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string +{ + // Early return if no build args to inject + if (empty(trim($buildArgsString))) { + return $command; + } + + // Match build-related commands: + // - ' builder build' (docker builder build) + // - ' buildx build' (docker buildx build) + // - ' build' (docker compose build, docker build) + // Followed by either: + // - whitespace (allowing service names, flags, or any valid arguments) + // - end of string ($) + // This regex ensures we match build subcommands, not "build" in other contexts + // IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build' + $pattern = '/( builder build| buildx build| build)(?=\s|$)/'; + + // Replace the first occurrence of build command with build command + build-args + $modifiedCommand = preg_replace( + $pattern, + '$1 '.$buildArgsString, + $command, + 1 // Only replace first occurrence + ); + + return $modifiedCommand ?? $command; +} diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e7d875777..43ba58e59 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,7 +358,7 @@ function parseDockerVolumeString(string $volumeString): array ]; } -function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection +function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); @@ -1145,11 +1145,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $template = $resource->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; $random = new Cuid2; $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); - $preview_fqdn = "$schema://$preview_fqdn"; + $preview_fqdn = "$schema://$preview_fqdn{$port}"; $preview->fqdn = $preview_fqdn; $preview->save(); @@ -1324,6 +1326,20 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ->values(); $payload['env_file'] = $envFiles; + + // Inject commit-based image tag for services with build directive (for rollback support) + // Only inject if service has build but no explicit image defined + $hasBuild = data_get($service, 'build') !== null; + $hasImage = data_get($service, 'image') !== null; + if ($hasBuild && ! $hasImage && $commit) { + $imageTag = str($commit)->substr(0, 128)->value(); + if ($isPullRequest) { + $imageTag = "pr-{$pullRequestId}"; + } + $imageRepo = "{$uuid}_{$serviceName}"; + $payload['image'] = "{$imageRepo}:{$imageTag}"; + } + if ($isPullRequest) { $serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId); } diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 3218bf878..bdfbaba48 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -118,7 +118,7 @@ function () use ($server, $command_string) { ); } -function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string +function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false, ?int $timeout = null, bool $disableMultiplexing = false): ?string { $command = $command instanceof Collection ? $command->toArray() : $command; @@ -126,11 +126,12 @@ function instant_remote_process(Collection|array $command, Server $server, bool $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); + $effectiveTimeout = $timeout ?? config('constants.ssh.command_timeout'); return \App\Helpers\SshRetryHandler::retry( - function () use ($server, $command_string) { - $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); - $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) { + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing); + $process = Process::timeout($effectiveTimeout)->run($sshCommand); $output = trim($process->output()); $exitCode = $process->exitCode(); diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 48c3a62c3..1a0ae0fbd 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -5,39 +5,44 @@ function isSubscriptionActive() { - if (! isCloud()) { - return false; - } - $team = currentTeam(); - if (! $team) { - return false; - } - $subscription = $team?->subscription; + return once(function () { + if (! isCloud()) { + return false; + } + $team = currentTeam(); + if (! $team) { + return false; + } + $subscription = $team?->subscription; - if (is_null($subscription)) { - return false; - } - if (isStripe()) { - return $subscription->stripe_invoice_paid === true; - } + if (is_null($subscription)) { + return false; + } + if (isStripe()) { + return $subscription->stripe_invoice_paid === true; + } - return false; + return false; + }); } + function isSubscriptionOnGracePeriod() { - $team = currentTeam(); - if (! $team) { - return false; - } - $subscription = $team?->subscription; - if (! $subscription) { - return false; - } - if (isStripe()) { - return $subscription->stripe_cancel_at_period_end; - } + return once(function () { + $team = currentTeam(); + if (! $team) { + return false; + } + $subscription = $team?->subscription; + if (! $subscription) { + return false; + } + if (isStripe()) { + return $subscription->stripe_cancel_at_period_end; + } - return false; + return false; + }); } function subscriptionProvider() { diff --git a/config/constants.php b/config/constants.php index 9b1dd5f68..c55bec981 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.452', + 'version' => '4.0.0-beta.453', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/config/filesystems.php b/config/filesystems.php index c2df26c84..ba0921a79 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -35,13 +35,6 @@ 'throw' => false, ], - 'webhooks-during-maintenance' => [ - 'driver' => 'local', - 'root' => storage_path('app/webhooks-during-maintenance'), - 'visibility' => 'private', - 'throw' => false, - ], - 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/config/session.php b/config/session.php index 44ca7ded9..c7b176a5a 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php new file mode 100644 index 000000000..97547ac45 --- /dev/null +++ b/database/migrations/2025_12_05_000000_add_docker_images_to_keep_to_application_settings.php @@ -0,0 +1,22 @@ +integer('docker_images_to_keep')->default(2); + }); + } + + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('docker_images_to_keep'); + }); + } +}; diff --git a/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php new file mode 100644 index 000000000..a2433e5c9 --- /dev/null +++ b/database/migrations/2025_12_05_100000_add_disable_application_image_retention_to_server_settings.php @@ -0,0 +1,22 @@ +boolean('disable_application_image_retention')->default(false); + }); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('disable_application_image_retention'); + }); + } +}; diff --git a/database/migrations/2025_12_08_135600_add_performance_indexes.php b/database/migrations/2025_12_08_135600_add_performance_indexes.php new file mode 100644 index 000000000..680c4b4f7 --- /dev/null +++ b/database/migrations/2025_12_08_135600_add_performance_indexes.php @@ -0,0 +1,49 @@ +indexes as [$table, $columns, $indexName]) { + if (! $this->indexExists($indexName)) { + $columnList = implode(', ', array_map(fn ($col) => "\"$col\"", $columns)); + DB::statement("CREATE INDEX \"{$indexName}\" ON \"{$table}\" ({$columnList})"); + } + } + } + + public function down(): void + { + foreach ($this->indexes as [, , $indexName]) { + DB::statement("DROP INDEX IF EXISTS \"{$indexName}\""); + } + } + + private function indexExists(string $indexName): bool + { + $result = DB::selectOne( + 'SELECT 1 FROM pg_indexes WHERE indexname = ?', + [$indexName] + ); + + return $result !== null; + } +}; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index cd4a307aa..3116a4185 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index b90f126a2..46e0e88e5 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -11,7 +11,6 @@ services: - /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/backups:/var/www/html/storage/app/backups - - /data/coolify/webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance environment: - APP_ENV=${APP_ENV:-production} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index 09ce3ead3..6306ab381 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -25,7 +25,6 @@ services: - ./databases:/var/www/html/storage/app/databases - ./services:/var/www/html/storage/app/services - ./backups:/var/www/html/storage/app/backups - - ./webhooks-during-maintenance:/var/www/html/storage/app/webhooks-during-maintenance env_file: - .env environment: @@ -75,13 +74,7 @@ services: POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_DB: "${DB_DATABASE:-coolify}" healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USERNAME}", - "-d", - "${DB_DATABASE:-coolify}" - ] + test: [ "CMD-SHELL", "pg_isready -U ${DB_USERNAME}", "-d", "${DB_DATABASE:-coolify}" ] interval: 5s retries: 10 timeout: 2s @@ -121,7 +114,7 @@ services: SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}" SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ] interval: 5s retries: 10 timeout: 2s diff --git a/other/nightly/install.sh b/other/nightly/install.sh index ac4e3caff..b037fe382 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 577fdfe18..6d3f90371 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.452" + "version": "4.0.0-beta.453" }, "nightly": { - "version": "4.0.0-beta.453" + "version": "4.0.0-beta.454" }, "helper": { "version": "1.0.12" diff --git a/public/svgs/fizzy.png b/public/svgs/fizzy.png new file mode 100644 index 000000000..44efbd781 Binary files /dev/null and b/public/svgs/fizzy.png differ diff --git a/public/svgs/garage.svg b/public/svgs/garage.svg new file mode 100644 index 000000000..18aedeaaf --- /dev/null +++ b/public/svgs/garage.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/rustfs.png b/public/svgs/rustfs.png new file mode 100644 index 000000000..927b8c5c4 Binary files /dev/null and b/public/svgs/rustfs.png differ diff --git a/public/svgs/rustfs.svg b/public/svgs/rustfs.svg new file mode 100644 index 000000000..18e9b8418 --- /dev/null +++ b/public/svgs/rustfs.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/svgs/unsend.svg b/public/svgs/unsend.svg deleted file mode 100644 index f5ff6fabc..000000000 --- a/public/svgs/unsend.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/svgs/usesend.svg b/public/svgs/usesend.svg new file mode 100644 index 000000000..067a3f569 --- /dev/null +++ b/public/svgs/usesend.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/app.css b/resources/css/app.css index 931e3fe19..30371d307 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -185,4 +185,15 @@ .input[type="password"] { .lds-heart { animation: lds-heart 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.log-highlight { + background-color: rgba(234, 179, 8, 0.4); + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.dark .log-highlight { + background-color: rgba(234, 179, 8, 0.3); } \ No newline at end of file diff --git a/resources/css/utilities.css b/resources/css/utilities.css index fbfb98e9f..abb177835 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -230,7 +230,7 @@ @utility box-without-bg-without-border { } @utility coolbox { - @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded-lg border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]; + @apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]; } @utility on-box { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index b49aad9cf..6707bec98 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -33,6 +33,9 @@ export function initializeTerminalComponent() { // Resize handling resizeObserver: null, resizeTimeout: null, + // Visibility handling - prevent disconnects when tab loses focus + isDocumentVisible: true, + wasConnectedBeforeHidden: false, init() { this.setupTerminal(); @@ -92,6 +95,11 @@ export function initializeTerminalComponent() { }, { once: true }); }); + // Handle visibility changes to prevent disconnects when tab loses focus + document.addEventListener('visibilitychange', () => { + this.handleVisibilityChange(); + }); + window.onresize = () => { this.resizeTerminal() }; @@ -451,6 +459,11 @@ export function initializeTerminalComponent() { }, keepAlive() { + // Skip keepalive when document is hidden to prevent unnecessary disconnects + if (!this.isDocumentVisible) { + return; + } + if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.sendMessage({ ping: true }); } else if (this.connectionState === 'disconnected') { @@ -459,6 +472,35 @@ export function initializeTerminalComponent() { } }, + handleVisibilityChange() { + const wasVisible = this.isDocumentVisible; + this.isDocumentVisible = !document.hidden; + + if (!this.isDocumentVisible) { + // Tab is now hidden - pause heartbeat monitoring to prevent false disconnects + this.wasConnectedBeforeHidden = this.connectionState === 'connected'; + if (this.pingTimeoutId) { + clearTimeout(this.pingTimeoutId); + this.pingTimeoutId = null; + } + console.log('[Terminal] Tab hidden, pausing heartbeat monitoring'); + } else if (wasVisible === false) { + // Tab is now visible again + console.log('[Terminal] Tab visible, resuming connection management'); + + if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) { + // Send immediate ping to verify connection is still alive + this.heartbeatMissed = 0; + this.sendMessage({ ping: true }); + this.resetPingTimeout(); + } else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') { + // Was connected before but now disconnected - attempt reconnection + this.reconnectAttempts = 0; + this.initializeWebSocket(); + } + } + }, + resetPingTimeout() { if (this.pingTimeoutId) { clearTimeout(this.pingTimeoutId); diff --git a/resources/views/emails/traefik-version-outdated.blade.php b/resources/views/emails/traefik-version-outdated.blade.php index 28effabf3..91c627a73 100644 --- a/resources/views/emails/traefik-version-outdated.blade.php +++ b/resources/views/emails/traefik-version-outdated.blade.php @@ -5,10 +5,12 @@ @foreach ($servers as $server) @php - $info = $server->outdatedInfo ?? []; - $current = $info['current'] ?? 'unknown'; - $latest = $info['latest'] ?? 'unknown'; - $isPatch = ($info['type'] ?? 'patch_update') === 'patch_update'; + $serverName = data_get($server, 'name', 'Unknown Server'); + $serverUrl = data_get($server, 'url', '#'); + $info = data_get($server, 'outdatedInfo', []); + $current = data_get($info, 'current', 'unknown'); + $latest = data_get($info, 'latest', 'unknown'); + $isPatch = (data_get($info, 'type', 'patch_update') === 'patch_update'); $hasNewerBranch = isset($info['newer_branch_target']); $hasUpgrades = $hasUpgrades ?? false; if (!$isPatch || $hasNewerBranch) { @@ -19,8 +21,9 @@ $latest = str_starts_with($latest, 'v') ? $latest : "v{$latest}"; // For minor upgrades, use the upgrade_target (e.g., "v3.6") - if (!$isPatch && isset($info['upgrade_target'])) { - $upgradeTarget = str_starts_with($info['upgrade_target'], 'v') ? $info['upgrade_target'] : "v{$info['upgrade_target']}"; + if (!$isPatch && data_get($info, 'upgrade_target')) { + $upgradeTarget = data_get($info, 'upgrade_target'); + $upgradeTarget = str_starts_with($upgradeTarget, 'v') ? $upgradeTarget : "v{$upgradeTarget}"; } else { // For patch updates, show the full version $upgradeTarget = $latest; @@ -28,22 +31,23 @@ // Get newer branch info if available if ($hasNewerBranch) { - $newerBranchTarget = $info['newer_branch_target']; - $newerBranchLatest = str_starts_with($info['newer_branch_latest'], 'v') ? $info['newer_branch_latest'] : "v{$info['newer_branch_latest']}"; + $newerBranchTarget = data_get($info, 'newer_branch_target', 'unknown'); + $newerBranchLatest = data_get($info, 'newer_branch_latest', 'unknown'); + $newerBranchLatest = str_starts_with($newerBranchLatest, 'v') ? $newerBranchLatest : "v{$newerBranchLatest}"; } @endphp @if ($isPatch && $hasNewerBranch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) | Also available: {{ $newerBranchTarget }} (latest patch: {{ $newerBranchLatest }}) - new minor version @elseif ($isPatch) -- **{{ $server->name }}**: {{ $current }} → {{ $upgradeTarget }} (patch update available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} → {{ $upgradeTarget }} (patch update available) @else -- **{{ $server->name }}**: {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) +- [**{{ $serverName }}**]({{ $serverUrl }}): {{ $current }} (latest patch: {{ $latest }}) → {{ $upgradeTarget }} (new minor version available) @endif @endforeach ## Recommendation -It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration through your [Coolify Dashboard]({{ config('app.url') }}). +It is recommended to test the new Traefik version before switching it in production environments. You can update your proxy configuration by clicking on any server name above. @if ($hasUpgrades ?? false) **Important for minor version upgrades:** Before upgrading to a new minor version, please read the [Traefik changelog](https://github.com/traefik/traefik/releases) to understand breaking changes and new features. @@ -58,5 +62,5 @@ --- -You can manage your server proxy settings in your Coolify Dashboard. +Click on any server name above to manage its proxy settings. diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 7bb366cd4..2b4ca6054 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -83,7 +83,7 @@ if (!html) return ''; const URL_RE = /^(https?:|mailto:)/i; const config = { - ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong', + ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'mark', 'p', 'pre', 's', 'span', 'strong', 'u' ], ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'], diff --git a/resources/views/livewire/project/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php index 60c660bf7..8d0fc18fb 100644 --- a/resources/views/livewire/project/application/deployment-navbar.blade.php +++ b/resources/views/livewire/project/application/deployment-navbar.blade.php @@ -1,18 +1,12 @@

Deployment Log

- @if ($is_debug_enabled) - Hide Debug Logs - @else - Show Debug Logs - @endif - @if (isDev()) - Copy Logs - @endif @if (data_get($application_deployment_queue, 'status') === 'queued') Force Start @endif - @if (data_get($application_deployment_queue, 'status') === 'in_progress' || - data_get($application_deployment_queue, 'status') === 'queued') + @if ( + data_get($application_deployment_queue, 'status') === 'in_progress' || + data_get($application_deployment_queue, 'status') === 'queued' + ) Cancel @endif -
+ \ No newline at end of file diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index b52a6eaf1..e5d1ce8e6 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -1,15 +1,18 @@
{{ data_get_str($application, 'name')->limit(10) }} > Deployment | Coolify - -

Deployment

- - -
Deployment + + +
- - @if (data_get($application_deployment_queue, 'status') === 'in_progress') -
Deployment is -
- {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}. -
- -
- {{--
Logs will be updated automatically.
--}} - @else -
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }}. -
- @endif -
-
-
-
- - - - - + + @if (data_get($application_deployment_queue, 'status') === 'in_progress') +
Deployment is +
+ {{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
+
- -
- @forelse ($this->logLines as $line) -
isset($line['command']) && $line['command'], - 'flex gap-2 dark:hover:bg-coolgray-500 hover:bg-gray-100', - ])> - {{ $line['timestamp'] }} - $line['hidden'], - 'text-red-500' => $line['stderr'], - 'font-bold' => isset($line['command']) && $line['command'], - 'whitespace-pre-wrap', - ])>{!! (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']) !!} + {{--
Logs will be updated automatically.
--}} + @else +
Deployment is {{ Str::headline(data_get($application_deployment_queue, 'status')) }}. +
+ @endif +
+
+
+ + +
+
+ + + + + +
+ + + + + +
- @empty - No logs yet. - @endforelse +
+
+
+
+ No matches found. +
+ @forelse ($this->logLines as $line) + @php + $lineContent = (isset($line['command']) && $line['command'] ? '[CMD]: ' : '') . trim($line['line']); + $searchableContent = $line['timestamp'] . ' ' . $lineContent; + @endphp +
isset($line['command']) && $line['command'], + 'flex gap-2', + ])> + {{ $line['timestamp'] }} + $line['hidden'], + 'text-red-500' => $line['stderr'], + 'font-bold' => isset($line['command']) && $line['command'], + 'whitespace-pre-wrap', + ]) + x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"> +
+ @empty + No logs yet. + @endforelse +
+
-
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d1a331d1a..8cf46d2f3 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -241,12 +241,32 @@ @else
@endcan -
- +
+ + wire:model.defer="dockerComposeLocation" label="Docker Compose Location" + helper="It is calculated together with the Base Directory:
{{ Str::start($application->base_directory . $application->docker_compose_location, '/') }}" + x-model="composeLocation" @blur="normalizeComposeLocation()" />
@else -
- +
+ @if ($application->build_pack === 'dockerfile' && !$application->dockerfile) - + x-bind:disabled="!canUpdate" x-model="dockerfileLocation" @blur="normalizeDockerfileLocation()" /> @endif @if ($application->build_pack === 'dockerfile') diff --git a/resources/views/livewire/project/application/previews-compose.blade.php b/resources/views/livewire/project/application/previews-compose.blade.php index ae8d70243..8d653d950 100644 --- a/resources/views/livewire/project/application/previews-compose.blade.php +++ b/resources/views/livewire/project/application/previews-compose.blade.php @@ -1,7 +1,7 @@
- + Save Generate Domain -
\ No newline at end of file + diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index e0b1465dc..476712842 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -5,25 +5,51 @@ Reload Available Images @endcan
-
You can easily rollback to a previously built (local) images - quickly.
+
You can easily rollback to a previously built (local) images quickly.
+ + @if($serverRetentionDisabled) + + Image retention is disabled at the server level. This setting has no effect until the server administrator enables it. + + @endif + +
+
+ + Save + +
@forelse ($images as $image)
-
+
+ @php + $tag = data_get($image, 'tag'); + $date = data_get($image, 'created_at'); + $interval = \Illuminate\Support\Carbon::parse($date); + // Check if tag looks like a commit SHA (hex string) or PR tag (pr-N) + $isCommitSha = preg_match('/^[0-9a-f]{7,128}$/i', $tag); + $isPrTag = preg_match('/^pr-\d+$/', $tag); + $isRollbackable = $isCommitSha || $isPrTag; + @endphp
@if (data_get($image, 'is_current')) LIVE | @endif - SHA: {{ data_get($image, 'tag') }} + @if ($isCommitSha) + SHA: {{ $tag }} + @elseif ($isPrTag) + PR: {{ $tag }} + @else + Tag: {{ $tag }} + @endif
- @php - $date = data_get($image, 'created_at'); - $interval = \Illuminate\Support\Carbon::parse($date); - @endphp
{{ $interval->diffForHumans() }}
{{ $date }}
@@ -33,9 +59,13 @@ Rollback + @elseif (!$isRollbackable) + + Rollback + @else + wire:click="rollbackImage('{{ $tag }}')"> Rollback @endif @@ -49,4 +79,4 @@
Loading available docker images...
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/database/clickhouse/general.blade.php b/resources/views/livewire/project/database/clickhouse/general.blade.php index 9017f7c09..2010e0afc 100644 --- a/resources/views/livewire/project/database/clickhouse/general.blade.php +++ b/resources/views/livewire/project/database/clickhouse/general.blade.php @@ -66,7 +66,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/dragonfly/general.blade.php b/resources/views/livewire/project/database/dragonfly/general.blade.php index 855a2ecdc..2b2e5d355 100644 --- a/resources/views/livewire/project/database/dragonfly/general.blade.php +++ b/resources/views/livewire/project/database/dragonfly/general.blade.php @@ -103,7 +103,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/keydb/general.blade.php b/resources/views/livewire/project/database/keydb/general.blade.php index a4b0eb471..e1a121b4e 100644 --- a/resources/views/livewire/project/database/keydb/general.blade.php +++ b/resources/views/livewire/project/database/keydb/general.blade.php @@ -103,7 +103,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mariadb/general.blade.php b/resources/views/livewire/project/database/mariadb/general.blade.php index b428c3144..eba0deafa 100644 --- a/resources/views/livewire/project/database/mariadb/general.blade.php +++ b/resources/views/livewire/project/database/mariadb/general.blade.php @@ -127,7 +127,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mongodb/general.blade.php b/resources/views/livewire/project/database/mongodb/general.blade.php index 871ac55c4..6cdd7b81f 100644 --- a/resources/views/livewire/project/database/mongodb/general.blade.php +++ b/resources/views/livewire/project/database/mongodb/general.blade.php @@ -141,7 +141,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/mysql/general.blade.php b/resources/views/livewire/project/database/mysql/general.blade.php index 512a3eb1b..04af62d27 100644 --- a/resources/views/livewire/project/database/mysql/general.blade.php +++ b/resources/views/livewire/project/database/mysql/general.blade.php @@ -144,7 +144,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 9c378a33f..509efe993 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -152,7 +152,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 7ffc8f218..39af4709e 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -122,7 +122,7 @@ Proxy Logs + container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy /> Logs diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 6d644ba2c..43b54db9c 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -7,7 +7,7 @@
@forelse ($private_keys as $key) @if ($private_key_id == $key->id) -
@@ -20,7 +20,7 @@ class="loading loading-xs dark:text-warning loading-spinner">
@else -
@@ -61,12 +61,33 @@ class="loading loading-xs dark:text-warning loading-spinner"> @endif
@if ($build_pack === 'dockercompose') -
- - + + + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository: @foreach ($github_apps as $ghapp)
-
@@ -95,15 +95,35 @@ @endif
@if ($build_pack === 'dockercompose') -
- + + helper="Directory to use as root. Useful for monorepos." x-model="baseDir" + @blur="normalizeBaseDir()" /> + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository: @if ($build_pack === 'dockercompose') -
- - + + + x-model="composeLocation" @blur="normalizeComposeLocation()" />
Compose file location in your repository:
-
@@ -460,17 +460,18 @@ function searchResources() { PostgreSQL is a powerful, open-source object-relational database system (no extensions).
-
- - + + + + +
-
@@ -480,16 +481,18 @@ function searchResources() { Supabase is a modern, open-source alternative to PostgreSQL with lots of extensions.
-
- + + + + +
-
@@ -499,16 +502,18 @@ function searchResources() { PostGIS is a PostgreSQL extension for geographic objects.
-
- + + + + +
-
@@ -518,15 +523,16 @@ function searchResources() { PGVector is a PostgreSQL extension for vector data types.
-
- - + + + + +
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 9b81e4bec..7379ca706 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -37,6 +37,16 @@

Services

+ @if($applications->isEmpty() && $databases->isEmpty()) +
+ No services defined in this Docker Compose file. +
+ @elseif($applications->isEmpty()) +
+ No applications with domains defined. Only database services are available. +
+ @endif + @foreach ($applications as $application)
str( diff --git a/resources/views/livewire/project/service/service-application-view.blade.php b/resources/views/livewire/project/service/service-application-view.blade.php index 5fb4a62d0..f04e33817 100644 --- a/resources/views/livewire/project/service/service-application-view.blade.php +++ b/resources/views/livewire/project/service/service-application-view.blade.php @@ -56,18 +56,18 @@

Advanced

@if (str($application->image)->contains('pocketbase')) - @else - @endif - - -
+
{ - const screen = document.getElementById('screen'); - const logs = document.getElementById('logs'); - if (screen.scrollTop !== logs.scrollHeight) { - screen.scrollTop = logs.scrollHeight; + const logsContainer = document.getElementById('logsContainer'); + if (logsContainer) { + this.isScrolling = true; + logsContainer.scrollTop = logsContainer.scrollHeight; + setTimeout(() => { this.isScrolling = false; }, 50); } }, 100); } else { @@ -26,63 +34,258 @@ this.intervalId = null; } }, - goTop() { - this.alwaysScroll = false; - clearInterval(this.intervalId); - const screen = document.getElementById('screen'); - screen.scrollTop = 0; + handleScroll(event) { + if (!this.alwaysScroll || this.isScrolling) return; + const el = event.target; + // Check if user scrolled away from the bottom + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceFromBottom > 50) { + this.alwaysScroll = false; + clearInterval(this.intervalId); + this.intervalId = null; + } + }, + toggleColorLogs() { + this.colorLogs = !this.colorLogs; + localStorage.setItem('coolify-color-logs', this.colorLogs); + }, + getLogLevel(text) { + const lowerText = text.toLowerCase(); + // Error detection (highest priority) + if (/\b(error|err|failed|failure|exception|fatal|panic|critical)\b/.test(lowerText)) { + return 'error'; + } + // Warning detection + if (/\b(warn|warning|wrn|caution)\b/.test(lowerText)) { + return 'warning'; + } + // Debug detection + if (/\b(debug|dbg|trace|verbose)\b/.test(lowerText)) { + return 'debug'; + } + // Info detection + if (/\b(info|inf|notice)\b/.test(lowerText)) { + return 'info'; + } + return null; + }, + matchesSearch(line) { + if (!this.searchQuery.trim()) return true; + return line.toLowerCase().includes(this.searchQuery.toLowerCase()); + }, + decodeHtml(text) { + // Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS + let decoded = text; + let prev = ''; + let iterations = 0; + const maxIterations = 3; // Prevent DoS from deeply nested HTML entities + + while (decoded !== prev && iterations < maxIterations) { + prev = decoded; + const doc = new DOMParser().parseFromString(decoded, 'text/html'); + decoded = doc.documentElement.textContent; + iterations++; + } + return decoded; + }, + renderHighlightedLog(el, text) { + const decoded = this.decodeHtml(text); + el.textContent = ''; + + if (!this.searchQuery.trim()) { + el.textContent = decoded; + return; + } + + const query = this.searchQuery.toLowerCase(); + const lowerText = decoded.toLowerCase(); + let lastIndex = 0; + + let index = lowerText.indexOf(query, lastIndex); + while (index !== -1) { + // Add text before match + if (index > lastIndex) { + el.appendChild(document.createTextNode(decoded.substring(lastIndex, index))); + } + // Add highlighted match + const mark = document.createElement('span'); + mark.className = 'log-highlight'; + mark.textContent = decoded.substring(index, index + this.searchQuery.length); + el.appendChild(mark); + + lastIndex = index + this.searchQuery.length; + index = lowerText.indexOf(query, lastIndex); + } + + // Add remaining text + if (lastIndex < decoded.length) { + el.appendChild(document.createTextNode(decoded.substring(lastIndex))); + } + }, + getMatchCount() { + if (!this.searchQuery.trim()) return 0; + const logs = document.getElementById('logs'); + if (!logs) return 0; + const lines = logs.querySelectorAll('[data-log-line]'); + let count = 0; + lines.forEach(line => { + if (line.textContent.toLowerCase().includes(this.searchQuery.toLowerCase())) { + count++; + } + }); + return count; + }, + downloadLogs() { + const logs = document.getElementById('logs'); + if (!logs) return; + const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)'); + let content = ''; + visibleLines.forEach(line => { + const text = line.textContent.replace(/\s+/g, ' ').trim(); + if (text) { + content += text + String.fromCharCode(10); + } + }); + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().slice(0,19).replace(/[T:]/g, '-'); + a.download = this.containerName + '-logs-' + timestamp + '.txt'; + a.click(); + URL.revokeObjectURL(url); + }, + init() { + if (this.expanded) { + this.$wire.getLogs(); + this.logsLoaded = true; + } + // Re-render logs after Livewire updates + Livewire.hook('commit', ({ succeed }) => { + succeed(() => { + this.$nextTick(() => { this.renderTrigger++; }); + }); + }); } }"> -
- @if ($displayName) -

{{ $displayName }}

- @elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone')) -

{{ $container }}

- @else -

{{ str($container)->beforeLast('-')->headline() }}

- @endif - @if ($pull_request) -
({{ $pull_request }})
- @endif - @if ($streamLogs) - - @endif -
-
-
- + @if ($collapsible) +
+ + + + @if ($displayName) +

{{ $displayName }}

+ @elseif ($resource?->type() === 'application' || str($resource?->type())->startsWith('standalone')) +

{{ $container }}

+ @else +

{{ str($container)->beforeLast('-')->headline() }}

+ @endif + @if ($pull_request) +
({{ $pull_request }})
+ @endif + @if ($streamLogs) + + @endif
-
- Refresh - - -
- -
-
-
-
- {{-- +
+ -
- @if ($outputs) -
- @foreach (explode("\n", trim($outputs)) as $line) - @if (!empty(trim($line))) +
+ @if ($outputs) +
+
+ No matches found. +
+ @foreach (explode("\n", $outputs) as $line) @php - $lowerLine = strtolower($line); - $isError = - str_contains($lowerLine, 'error') || - str_contains($lowerLine, 'err') || - str_contains($lowerLine, 'failed') || - str_contains($lowerLine, 'exception'); - $isWarning = - str_contains($lowerLine, 'warn') || - str_contains($lowerLine, 'warning') || - str_contains($lowerLine, 'wrn'); - $isDebug = - str_contains($lowerLine, 'debug') || - str_contains($lowerLine, 'dbg') || - str_contains($lowerLine, 'trace'); - $barColor = $isError - ? 'bg-red-500 dark:bg-red-400' - : ($isWarning - ? 'bg-warning-500 dark:bg-warning-400' - : ($isDebug - ? 'bg-purple-500 dark:bg-purple-400' - : 'bg-blue-500 dark:bg-blue-400')); - $bgColor = $isError - ? 'bg-red-50/50 dark:bg-red-900/20 hover:bg-red-100/50 dark:hover:bg-red-800/30' - : ($isWarning - ? 'bg-warning-50/50 dark:bg-warning-900/20 hover:bg-warning-100/50 dark:hover:bg-warning-800/30' - : ($isDebug - ? 'bg-purple-50/50 dark:bg-purple-900/20 hover:bg-purple-100/50 dark:hover:bg-purple-800/30' - : 'bg-blue-50/50 dark:bg-blue-900/20 hover:bg-blue-100/50 dark:hover:bg-blue-800/30')); + // Skip empty lines + if (trim($line) === '') { + continue; + } + + // Parse timestamp from log line (ISO 8601 format: 2025-12-04T11:48:39.136764033Z) + $timestamp = ''; + $logContent = $line; + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z?\s*(.*)$/', $line, $matches)) { + $year = $matches[1]; + $month = $matches[2]; + $day = $matches[3]; + $time = $matches[4]; + $microseconds = isset($matches[5]) ? substr($matches[5], 0, 6) : '000000'; + $logContent = $matches[6]; + + // Convert month number to abbreviated name + $monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + $monthName = $monthNames[(int)$month - 1] ?? $month; + + // Format: 2025-Dec-04 09:44:58.198879 + $timestamp = "{$year}-{$monthName}-{$day} {$time}.{$microseconds}"; + } - // Check for timestamp at the beginning (ISO 8601 format) - $timestampPattern = '/^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)\s+/'; - $hasTimestamp = preg_match($timestampPattern, $line, $matches); - $timestamp = $hasTimestamp ? $matches[1] : null; - $logContent = $hasTimestamp ? preg_replace($timestampPattern, '', $line) : $line; @endphp -
-
-
- @if ($hasTimestamp) - {{ $timestamp }} - {{ $logContent }} - @else - {{ $line }} - @endif -
+
+ @if ($timestamp && $showTimeStamps) + {{ $timestamp }} + @endif +
- @endif - @endforeach -
- @else -
- Refresh to get the logs... -
- @endif + @endforeach +
+ @else +
Refresh to get the logs...
+ @endif +
-
+
\ No newline at end of file diff --git a/resources/views/livewire/project/shared/logs.blade.php b/resources/views/livewire/project/shared/logs.blade.php index 87bb1a6b6..3a1afaa1c 100644 --- a/resources/views/livewire/project/shared/logs.blade.php +++ b/resources/views/livewire/project/shared/logs.blade.php @@ -17,13 +17,17 @@
@forelse ($servers as $server)
-

Server: {{ $server->name }}

+

Server: {{ $server->name }}

@if ($server->isFunctional()) @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) + @php + $totalContainers = collect($serverContainers)->flatten(1)->count(); + @endphp @foreach ($serverContainers[$server->id] as $container) + :resource="$resource" :container="data_get($container, 'Names')" + :expandByDefault="$totalContainers === 1" /> @endforeach @else
No containers are running on server: {{ $server->name }}
@@ -53,7 +57,8 @@ @forelse ($containers as $container) @if (data_get($servers, '0')) + :resource="$resource" :container="$container" + :expandByDefault="count($containers) === 1" /> @else
No functional server found for the database.
@endif @@ -77,7 +82,8 @@ @forelse ($containers as $container) @if (data_get($servers, '0')) + :resource="$resource" :container="$container" + :expandByDefault="count($containers) === 1" /> @else
No functional server found for the service.
@endif diff --git a/resources/views/livewire/server/docker-cleanup.blade.php b/resources/views/livewire/server/docker-cleanup.blade.php index 8e96bc963..251137fa7 100644 --- a/resources/views/livewire/server/docker-cleanup.blade.php +++ b/resources/views/livewire/server/docker-cleanup.blade.php @@ -78,6 +78,10 @@
  • Networks not attached to running containers will be permanently deleted (networks used by stopped containers are affected).
  • Containers may lose connectivity if required networks are removed.
  • " /> +
    diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 8525f5d60..4f43ef7e2 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -2,6 +2,13 @@ Proxy Startup Logs + @if ($server->id === 0) +
    + Note: This is the localhost server where Coolify runs. + During proxy restart, the connection may be temporarily lost. + If logs stop updating, please refresh the browser after a few minutes. +
    + @endif
    @@ -174,6 +181,7 @@ class="flex items-center gap-6 overflow-x-scroll sm:overflow-x-hidden scrollbar } }); $wire.$on('restartEvent', () => { + if ($wire.restartInitiated) return; window.dispatchEvent(new CustomEvent('startproxy')) $wire.$call('restart'); }); diff --git a/resources/views/livewire/server/proxy/logs.blade.php b/resources/views/livewire/server/proxy/logs.blade.php index 17fe65f4c..8b72d78f1 100644 --- a/resources/views/livewire/server/proxy/logs.blade.php +++ b/resources/views/livewire/server/proxy/logs.blade.php @@ -7,7 +7,7 @@

    Logs

    - +
    diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index b3284ab51..f9311bb83 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -337,7 +337,8 @@ class="w-full input opacity-50 cursor-not-allowed" Sentinel Logs + container="coolify-sentinel" displayName="Sentinel" :collapsible="false" + lazy /> Logs @@ -353,7 +354,8 @@ class="w-full input opacity-50 cursor-not-allowed" Sentinel Logs + container="coolify-sentinel" displayName="Sentinel" :collapsible="false" + lazy /> Logs diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index c47c2cfef..7d714a409 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -18,28 +18,28 @@ class="flex flex-col h-full gap-8 sm:flex-row">

    DNS Settings

    API Settings

    + helper="If enabled, authenticated requests to Coolify's REST API will be allowed. Configure API tokens in Security > API Tokens." />

    Confirmation Settings

    + helper="Show monthly sponsorship reminders to support Coolify development. Disable to hide these messages permanently." />
    diff --git a/scripts/install.sh b/scripts/install.sh index c8b791185..c06b26eff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -223,7 +223,7 @@ if [ "$WARNING_SPACE" = true ]; then sleep 5 fi -mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,sentinel} +mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel} mkdir -p /data/coolify/ssh/{keys,mux} mkdir -p /data/coolify/proxy/dynamic diff --git a/templates/compose/fizzy.yaml b/templates/compose/fizzy.yaml new file mode 100644 index 000000000..8265d09be --- /dev/null +++ b/templates/compose/fizzy.yaml @@ -0,0 +1,30 @@ +# documentation: https://github.com/basecamp/fizzy +# slogan: Kanban tracking tool for issues and ideas by 37signals +# category: productivity +# tags: kanban, project management, issues, rails, ruby, basecamp, 37signals +# logo: svgs/fizzy.png +# port: 80 + +services: + fizzy: + image: ghcr.io/basecamp/fizzy:main + environment: + - SERVICE_FQDN_FIZZY_80 + - SECRET_KEY_BASE=$SERVICE_PASSWORD_FIZZY + - RAILS_MASTER_KEY=$SERVICE_PASSWORD_64_MASTERKEY + - RAILS_ENV=production + - RAILS_LOG_TO_STDOUT=true + - RAILS_SERVE_STATIC_FILES=true + - DATABASE_ADAPTER=sqlite + - SOLID_QUEUE_CONNECTS_TO=false + - VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY + - VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY + volumes: + - fizzy-data:/rails/db + - fizzy-storage:/rails/storage + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:80/up"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s diff --git a/templates/compose/garage.yaml b/templates/compose/garage.yaml new file mode 100644 index 000000000..493ff53bf --- /dev/null +++ b/templates/compose/garage.yaml @@ -0,0 +1,59 @@ +# ignore: true +# documentation: https://garagehq.deuxfleurs.fr/documentation/ +# slogan: Garage is an S3-compatible distributed object storage service designed for self-hosting. +# category: storage +# tags: object, storage, server, s3, api, distributed +# logo: svgs/garage.svg +# port: 3900 + +services: + garage: + image: dxflrs/garage:v2.1.0 + environment: + - GARAGE_S3_API_URL=$GARAGE_S3_API_URL + - GARAGE_WEB_URL=$GARAGE_WEB_URL + - GARAGE_ADMIN_URL=$GARAGE_ADMIN_URL + - GARAGE_RPC_SECRET=${SERVICE_HEX_32_RPCSECRET} + - GARAGE_ADMIN_TOKEN=$SERVICE_PASSWORD_GARAGE + - GARAGE_METRICS_TOKEN=$SERVICE_PASSWORD_GARAGEMETRICS + - GARAGE_ALLOW_WORLD_READABLE_SECRETS=true + - RUST_LOG=${RUST_LOG:-garage=info} + volumes: + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + - type: bind + source: ./garage.toml + target: /etc/garage.toml + content: | + metadata_dir = "/var/lib/garage/meta" + data_dir = "/var/lib/garage/data" + db_engine = "lmdb" + + replication_factor = 1 + consistency_mode = "consistent" + + compression_level = 1 + block_size = "1M" + + rpc_bind_addr = "[::]:3901" + rpc_secret_file = "env:GARAGE_RPC_SECRET" + bootstrap_peers = [] + + [s3_api] + s3_region = "garage" + api_bind_addr = "[::]:3900" + root_domain = ".s3.garage.localhost" + + [s3_web] + bind_addr = "[::]:3902" + root_domain = ".web.garage.localhost" + + [admin] + api_bind_addr = "[::]:3903" + admin_token_file = "env:GARAGE_ADMIN_TOKEN" + metrics_token_file = "env:GARAGE_METRICS_TOKEN" + healthcheck: + test: ["CMD", "/garage", "stats", "-a"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/templates/compose/rustfs.yaml b/templates/compose/rustfs.yaml new file mode 100644 index 000000000..0ae4a14db --- /dev/null +++ b/templates/compose/rustfs.yaml @@ -0,0 +1,35 @@ +# ignore: true +# documentation: https://docs.rustfs.com/installation/docker/ +# slogan: RustFS is a high-performance distributed storage system built with Rust, compatible with Amazon S3 APIs. +# category: storage +# tags: object, storage, server, s3, api, rust +# logo: svgs/rustfs.png + +services: + rustfs: + image: rustfs/rustfs:latest + command: /data + environment: + - RUSTFS_SERVER_URL=$RUSTFS_SERVER_URL + - RUSTFS_BROWSER_REDIRECT_URL=$RUSTFS_BROWSER_REDIRECT_URL + - RUSTFS_ADDRESS=${RUSTFS_ADDRESS:-0.0.0.0:9000} + - RUSTFS_CONSOLE_ADDRESS=${RUSTFS_CONSOLE_ADDRESS:-0.0.0.0:9001} + - RUSTFS_CORS_ALLOWED_ORIGINS=${RUSTFS_CORS_ALLOWED_ORIGINS:-*} + - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=${RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS:-*} + - RUSTFS_ACCESS_KEY=$SERVICE_USER_RUSTFS + - RUSTFS_SECRET_KEY=$SERVICE_PASSWORD_RUSTFS + - RUSTFS_CONSOLE_ENABLE=${RUSTFS_CONSOLE_ENABLE:-true} + - RUSTFS_SERVER_DOMAINS=${RUSTFS_SERVER_DOMAINS} + - RUSTFS_EXTERNAL_ADDRESS=${RUSTFS_EXTERNAL_ADDRESS} + volumes: + - rustfs-data:/data + healthcheck: + test: + [ + "CMD", + "sh", "-c", + "curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health" + ] + interval: 5s + timeout: 20s + retries: 10 \ No newline at end of file diff --git a/templates/compose/unsend.yaml b/templates/compose/usesend.yaml similarity index 67% rename from templates/compose/unsend.yaml rename to templates/compose/usesend.yaml index 00efbc644..9ced822f3 100644 --- a/templates/compose/unsend.yaml +++ b/templates/compose/usesend.yaml @@ -1,8 +1,8 @@ -# documentation: https://docs.unsend.dev/get-started/self-hosting -# slogan: Unsend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc. +# documentation: https://docs.usesend.com/self-hosting/overview +# slogan: Usesend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc. # category: messaging # tags: resend, mailer, marketing emails, transaction emails, self-hosting, postmark -# logo: svgs/unsend.svg +# logo: svgs/usesend.svg # port: 3000 services: @@ -11,19 +11,19 @@ services: environment: - POSTGRES_USER=${SERVICE_USER_POSTGRES} - POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES} - - POSTGRES_DB=${SERVICE_DB_POSTGRES:-unsend} + - POSTGRES_DB=${SERVICE_DB_POSTGRES:-usesend} healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 20s retries: 10 volumes: - - unsend-postgres-data:/var/lib/postgresql/data + - usesend-postgres-data:/var/lib/postgresql/data redis: image: redis:7 volumes: - - unsend-redis-data:/data + - usesend-redis-data:/data command: ["redis-server", "--maxmemory-policy", "noeviction"] healthcheck: test: @@ -34,20 +34,20 @@ services: timeout: 10s retries: 20 - unsend: - image: unsend/unsend:latest + usesend: + image: usesend/usesend:latest expose: - 3000 environment: - - SERVICE_URL_UNSEND_3000 - - DATABASE_URL=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${SERVICE_DB_POSTGRES:-unsend} - - NEXTAUTH_URL=${SERVICE_URL_UNSEND} + - SERVICE_URL_USESEND_3000 + - DATABASE_URL=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${SERVICE_DB_POSTGRES:-usesend} + - NEXTAUTH_URL=${SERVICE_URL_USESEND} - NEXTAUTH_SECRET=${SERVICE_BASE64_64_NEXTAUTHSECRET} - AWS_ACCESS_KEY=${AWS_ACCESS_KEY:?} - AWS_SECRET_KEY=${AWS_SECRET_KEY:?} - AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION:?} - - GITHUB_ID=${GITHUB_ID} - - GITHUB_SECRET=${GITHUB_SECRET} + - GITHUB_ID=${GITHUB_ID:?} + - GITHUB_SECRET=${GITHUB_SECRET:?} - REDIS_URL=redis://redis:6379 - NEXT_PUBLIC_IS_CLOUD=${NEXT_PUBLIC_IS_CLOUD:-false} - API_RATE_LIMIT=${API_RATE_LIMIT:-1} @@ -58,7 +58,7 @@ services: redis: condition: service_healthy healthcheck: - test: [ "CMD-SHELL", "wget -qO- http://unsend:3000 || exit 1" ] + test: ["CMD-SHELL", "wget -qO- http://usesend:3000 || exit 1"] interval: 5s retries: 10 timeout: 2s diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 063556a14..db9c040ff 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1146,6 +1146,24 @@ "minversion": "0.0.0", "port": "5800" }, + "fizzy": { + "documentation": "https://github.com/basecamp/fizzy?utm_source=coolify.io", + "slogan": "Kanban tracking tool for issues and ideas by 37signals", + "compose": "c2VydmljZXM6CiAgZml6enk6CiAgICBpbWFnZTogJ2doY3IuaW8vYmFzZWNhbXAvZml6enk6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GSVpaWV84MAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9GSVpaWQogICAgICAtIFJBSUxTX01BU1RFUl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfTUFTVEVSS0VZCiAgICAgIC0gUkFJTFNfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBSQUlMU19MT0dfVE9fU1RET1VUPXRydWUKICAgICAgLSBSQUlMU19TRVJWRV9TVEFUSUNfRklMRVM9dHJ1ZQogICAgICAtIERBVEFCQVNFX0FEQVBURVI9c3FsaXRlCiAgICAgIC0gU09MSURfUVVFVUVfQ09OTkVDVFNfVE89ZmFsc2UKICAgICAgLSBWQVBJRF9QUklWQVRFX0tFWT0kVkFQSURfUFJJVkFURV9LRVkKICAgICAgLSBWQVBJRF9QVUJMSUNfS0VZPSRWQVBJRF9QVUJMSUNfS0VZCiAgICB2b2x1bWVzOgogICAgICAtICdmaXp6eS1kYXRhOi9yYWlscy9kYicKICAgICAgLSAnZml6enktc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC91cCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "kanban", + "project management", + "issues", + "rails", + "ruby", + "basecamp", + "37signals" + ], + "category": "productivity", + "logo": "svgs/fizzy.png", + "minversion": "0.0.0", + "port": "80" + }, "flipt": { "documentation": "https://docs.flipt.io/cloud/overview?utm_source=coolify.io", "slogan": "Flipt is a fully managed feature flag solution that enables you to keep your feature flags and remote config next to your code in Git.", @@ -4280,23 +4298,6 @@ "minversion": "0.0.0", "port": "4242" }, - "unsend": { - "documentation": "https://docs.unsend.dev/get-started/self-hosting?utm_source=coolify.io", - "slogan": "Unsend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfUE9TVEdSRVM6LXVuc2VuZH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICd1bnNlbmQtcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICd1bnNlbmQtcmVkaXMtZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tbWF4bWVtb3J5LXBvbGljeScKICAgICAgLSBub2V2aWN0aW9uCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICB1bnNlbmQ6CiAgICBpbWFnZTogJ3Vuc2VuZC91bnNlbmQ6bGF0ZXN0JwogICAgZXhwb3NlOgogICAgICAtIDMwMDAKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1VOU0VORF8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1NFUlZJQ0VfREJfUE9TVEdSRVM6LXVuc2VuZH0nCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfVVJMX1VOU0VORH0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVk9JHtBV1NfQUNDRVNTX0tFWTo/fScKICAgICAgLSAnQVdTX1NFQ1JFVF9LRVk9JHtBV1NfU0VDUkVUX0tFWTo/fScKICAgICAgLSAnQVdTX0RFRkFVTFRfUkVHSU9OPSR7QVdTX0RFRkFVTFRfUkVHSU9OOj99JwogICAgICAtICdHSVRIVUJfSUQ9JHtHSVRIVUJfSUR9JwogICAgICAtICdHSVRIVUJfU0VDUkVUPSR7R0lUSFVCX1NFQ1JFVH0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ05FWFRfUFVCTElDX0lTX0NMT1VEPSR7TkVYVF9QVUJMSUNfSVNfQ0xPVUQ6LWZhbHNlfScKICAgICAgLSAnQVBJX1JBVEVfTElNSVQ9JHtBUElfUkFURV9MSU1JVDotMX0nCiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly91bnNlbmQ6MzAwMCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICB0aW1lb3V0OiAycwo=", - "tags": [ - "resend", - "mailer", - "marketing emails", - "transaction emails", - "self-hosting", - "postmark" - ], - "category": "messaging", - "logo": "svgs/unsend.svg", - "minversion": "0.0.0", - "port": "3000" - }, "unstructured": { "documentation": "https://github.com/Unstructured-IO/unstructured-api?tab=readme-ov-file#--general-pre-processing-pipeline-for-documents?utm_source=coolify.io", "slogan": "Unstructured provides a platform and tools to ingest and process unstructured documents for Retrieval Augmented Generation (RAG) and model fine-tuning.", @@ -4337,6 +4338,23 @@ "minversion": "0.0.0", "port": "3001" }, + "usesend": { + "documentation": "https://docs.usesend.com/self-hosting/overview?utm_source=coolify.io", + "slogan": "Usesend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfUE9TVEdSRVM6LXVzZXNlbmR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAndXNlc2VuZC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VzZXNlbmQtcmVkaXMtZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tbWF4bWVtb3J5LXBvbGljeScKICAgICAgLSBub2V2aWN0aW9uCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICB1c2VzZW5kOgogICAgaW1hZ2U6ICd1c2VzZW5kL3VzZXNlbmQ6bGF0ZXN0JwogICAgZXhwb3NlOgogICAgICAtIDMwMDAKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1VTRVNFTkRfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtTRVJWSUNFX0RCX1BPU1RHUkVTOi11c2VzZW5kfScKICAgICAgLSAnTkVYVEFVVEhfVVJMPSR7U0VSVklDRV9VUkxfVVNFU0VORH0nCiAgICAgIC0gJ05FWFRBVVRIX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0XzY0X05FWFRBVVRIU0VDUkVUfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVk9JHtBV1NfQUNDRVNTX0tFWTo/fScKICAgICAgLSAnQVdTX1NFQ1JFVF9LRVk9JHtBV1NfU0VDUkVUX0tFWTo/fScKICAgICAgLSAnQVdTX0RFRkFVTFRfUkVHSU9OPSR7QVdTX0RFRkFVTFRfUkVHSU9OOj99JwogICAgICAtICdHSVRIVUJfSUQ9JHtHSVRIVUJfSUQ6P30nCiAgICAgIC0gJ0dJVEhVQl9TRUNSRVQ9JHtHSVRIVUJfU0VDUkVUOj99JwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9yZWRpczo2Mzc5JwogICAgICAtICdORVhUX1BVQkxJQ19JU19DTE9VRD0ke05FWFRfUFVCTElDX0lTX0NMT1VEOi1mYWxzZX0nCiAgICAgIC0gJ0FQSV9SQVRFX0xJTUlUPSR7QVBJX1JBVEVfTElNSVQ6LTF9JwogICAgICAtIEhPU1ROQU1FPTAuMC4wLjAKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vdXNlc2VuZDozMDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHRpbWVvdXQ6IDJzCg==", + "tags": [ + "resend", + "mailer", + "marketing emails", + "transaction emails", + "self-hosting", + "postmark" + ], + "category": "messaging", + "logo": "svgs/usesend.svg", + "minversion": "0.0.0", + "port": "3000" + }, "vaultwarden": { "documentation": "https://github.com/dani-garcia/vaultwarden?utm_source=coolify.io", "slogan": "Vaultwarden is a password manager that allows you to securely store and manage your passwords.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 398a23e42..0fa619192 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1146,6 +1146,24 @@ "minversion": "0.0.0", "port": "5800" }, + "fizzy": { + "documentation": "https://github.com/basecamp/fizzy?utm_source=coolify.io", + "slogan": "Kanban tracking tool for issues and ideas by 37signals", + "compose": "c2VydmljZXM6CiAgZml6enk6CiAgICBpbWFnZTogJ2doY3IuaW8vYmFzZWNhbXAvZml6enk6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GSVpaWV84MAogICAgICAtIFNFQ1JFVF9LRVlfQkFTRT0kU0VSVklDRV9QQVNTV09SRF9GSVpaWQogICAgICAtIFJBSUxTX01BU1RFUl9LRVk9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfTUFTVEVSS0VZCiAgICAgIC0gUkFJTFNfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBSQUlMU19MT0dfVE9fU1RET1VUPXRydWUKICAgICAgLSBSQUlMU19TRVJWRV9TVEFUSUNfRklMRVM9dHJ1ZQogICAgICAtIERBVEFCQVNFX0FEQVBURVI9c3FsaXRlCiAgICAgIC0gU09MSURfUVVFVUVfQ09OTkVDVFNfVE89ZmFsc2UKICAgICAgLSBWQVBJRF9QUklWQVRFX0tFWT0kVkFQSURfUFJJVkFURV9LRVkKICAgICAgLSBWQVBJRF9QVUJMSUNfS0VZPSRWQVBJRF9QVUJMSUNfS0VZCiAgICB2b2x1bWVzOgogICAgICAtICdmaXp6eS1kYXRhOi9yYWlscy9kYicKICAgICAgLSAnZml6enktc3RvcmFnZTovcmFpbHMvc3RvcmFnZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC91cCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", + "tags": [ + "kanban", + "project management", + "issues", + "rails", + "ruby", + "basecamp", + "37signals" + ], + "category": "productivity", + "logo": "svgs/fizzy.png", + "minversion": "0.0.0", + "port": "80" + }, "flipt": { "documentation": "https://docs.flipt.io/cloud/overview?utm_source=coolify.io", "slogan": "Flipt is a fully managed feature flag solution that enables you to keep your feature flags and remote config next to your code in Git.", @@ -4280,23 +4298,6 @@ "minversion": "0.0.0", "port": "4242" }, - "unsend": { - "documentation": "https://docs.unsend.dev/get-started/self-hosting?utm_source=coolify.io", - "slogan": "Unsend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc.", - "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfUE9TVEdSRVM6LXVuc2VuZH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICd1bnNlbmQtcG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICB2b2x1bWVzOgogICAgICAtICd1bnNlbmQtcmVkaXMtZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tbWF4bWVtb3J5LXBvbGljeScKICAgICAgLSBub2V2aWN0aW9uCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICB1bnNlbmQ6CiAgICBpbWFnZTogJ3Vuc2VuZC91bnNlbmQ6bGF0ZXN0JwogICAgZXhwb3NlOgogICAgICAtIDMwMDAKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9VTlNFTkRfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtTRVJWSUNFX0RCX1BPU1RHUkVTOi11bnNlbmR9JwogICAgICAtICdORVhUQVVUSF9VUkw9JHtTRVJWSUNFX0ZRRE5fVU5TRU5EfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWT0ke0FXU19BQ0NFU1NfS0VZOj99JwogICAgICAtICdBV1NfU0VDUkVUX0tFWT0ke0FXU19TRUNSRVRfS0VZOj99JwogICAgICAtICdBV1NfREVGQVVMVF9SRUdJT049JHtBV1NfREVGQVVMVF9SRUdJT046P30nCiAgICAgIC0gJ0dJVEhVQl9JRD0ke0dJVEhVQl9JRH0nCiAgICAgIC0gJ0dJVEhVQl9TRUNSRVQ9JHtHSVRIVUJfU0VDUkVUfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vcmVkaXM6NjM3OScKICAgICAgLSAnTkVYVF9QVUJMSUNfSVNfQ0xPVUQ9JHtORVhUX1BVQkxJQ19JU19DTE9VRDotZmFsc2V9JwogICAgICAtICdBUElfUkFURV9MSU1JVD0ke0FQSV9SQVRFX0xJTUlUOi0xfScKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC1xTy0gaHR0cDovL3Vuc2VuZDozMDAwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHRpbWVvdXQ6IDJzCg==", - "tags": [ - "resend", - "mailer", - "marketing emails", - "transaction emails", - "self-hosting", - "postmark" - ], - "category": "messaging", - "logo": "svgs/unsend.svg", - "minversion": "0.0.0", - "port": "3000" - }, "unstructured": { "documentation": "https://github.com/Unstructured-IO/unstructured-api?tab=readme-ov-file#--general-pre-processing-pipeline-for-documents?utm_source=coolify.io", "slogan": "Unstructured provides a platform and tools to ingest and process unstructured documents for Retrieval Augmented Generation (RAG) and model fine-tuning.", @@ -4337,6 +4338,23 @@ "minversion": "0.0.0", "port": "3001" }, + "usesend": { + "documentation": "https://docs.usesend.com/self-hosting/overview?utm_source=coolify.io", + "slogan": "Usesend is an open-source alternative to Resend, Sendgrid, Mailgun and Postmark etc.", + "compose": "c2VydmljZXM6CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1NFUlZJQ0VfREJfUE9TVEdSRVM6LXVzZXNlbmR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAndXNlc2VuZC1wb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3VzZXNlbmQtcmVkaXMtZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tbWF4bWVtb3J5LXBvbGljeScKICAgICAgLSBub2V2aWN0aW9uCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAKICB1c2VzZW5kOgogICAgaW1hZ2U6ICd1c2VzZW5kL3VzZXNlbmQ6bGF0ZXN0JwogICAgZXhwb3NlOgogICAgICAtIDMwMDAKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9VU0VTRU5EXzMwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7U0VSVklDRV9EQl9QT1NUR1JFUzotdXNlc2VuZH0nCiAgICAgIC0gJ05FWFRBVVRIX1VSTD0ke1NFUlZJQ0VfRlFETl9VU0VTRU5EfScKICAgICAgLSAnTkVYVEFVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfTkVYVEFVVEhTRUNSRVR9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWT0ke0FXU19BQ0NFU1NfS0VZOj99JwogICAgICAtICdBV1NfU0VDUkVUX0tFWT0ke0FXU19TRUNSRVRfS0VZOj99JwogICAgICAtICdBV1NfREVGQVVMVF9SRUdJT049JHtBV1NfREVGQVVMVF9SRUdJT046P30nCiAgICAgIC0gJ0dJVEhVQl9JRD0ke0dJVEhVQl9JRDo/fScKICAgICAgLSAnR0lUSFVCX1NFQ1JFVD0ke0dJVEhVQl9TRUNSRVQ6P30nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL3JlZGlzOjYzNzknCiAgICAgIC0gJ05FWFRfUFVCTElDX0lTX0NMT1VEPSR7TkVYVF9QVUJMSUNfSVNfQ0xPVUQ6LWZhbHNlfScKICAgICAgLSAnQVBJX1JBVEVfTElNSVQ9JHtBUElfUkFURV9MSU1JVDotMX0nCiAgICAgIC0gSE9TVE5BTUU9MC4wLjAuMAogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly91c2VzZW5kOjMwMDAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgdGltZW91dDogMnMK", + "tags": [ + "resend", + "mailer", + "marketing emails", + "transaction emails", + "self-hosting", + "postmark" + ], + "category": "messaging", + "logo": "svgs/usesend.svg", + "minversion": "0.0.0", + "port": "3000" + }, "vaultwarden": { "documentation": "https://github.com/dani-garcia/vaultwarden?utm_source=coolify.io", "slogan": "Vaultwarden is a password manager that allows you to securely store and manage your passwords.", diff --git a/tests/Feature/CheckTraefikVersionJobTest.php b/tests/Feature/CheckTraefikVersionJobTest.php index b7c5dd50d..cee156485 100644 --- a/tests/Feature/CheckTraefikVersionJobTest.php +++ b/tests/Feature/CheckTraefikVersionJobTest.php @@ -214,3 +214,90 @@ expect($notification->servers)->toHaveCount(1); expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); }); + +it('notification generates correct server proxy URLs', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'name' => 'Test Server', + 'team_id' => $team->id, + 'uuid' => 'test-uuid-123', + ]); + + $server->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $notification = new TraefikVersionOutdated(collect([$server])); + $mail = $notification->toMail($team); + + // Verify the mail has the transformed servers with URLs + expect($mail->viewData['servers'])->toHaveCount(1); + expect($mail->viewData['servers'][0]['name'])->toBe('Test Server'); + expect($mail->viewData['servers'][0]['uuid'])->toBe('test-uuid-123'); + expect($mail->viewData['servers'][0]['url'])->toBe(base_url().'/server/test-uuid-123/proxy'); + expect($mail->viewData['servers'][0]['outdatedInfo'])->toBeArray(); +}); + +it('notification transforms multiple servers with URLs correctly', function () { + $team = Team::factory()->create(); + $server1 = Server::factory()->create([ + 'name' => 'Server 1', + 'team_id' => $team->id, + 'uuid' => 'uuid-1', + ]); + $server1->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $server2 = Server::factory()->create([ + 'name' => 'Server 2', + 'team_id' => $team->id, + 'uuid' => 'uuid-2', + ]); + $server2->outdatedInfo = [ + 'current' => '3.4.0', + 'latest' => '3.6.0', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.6', + ]; + + $servers = collect([$server1, $server2]); + $notification = new TraefikVersionOutdated($servers); + $mail = $notification->toMail($team); + + // Verify both servers have URLs + expect($mail->viewData['servers'])->toHaveCount(2); + + expect($mail->viewData['servers'][0]['name'])->toBe('Server 1'); + expect($mail->viewData['servers'][0]['url'])->toBe(base_url().'/server/uuid-1/proxy'); + + expect($mail->viewData['servers'][1]['name'])->toBe('Server 2'); + expect($mail->viewData['servers'][1]['url'])->toBe(base_url().'/server/uuid-2/proxy'); +}); + +it('notification uses base_url helper not config app.url', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'name' => 'Test Server', + 'team_id' => $team->id, + 'uuid' => 'test-uuid', + ]); + + $server->outdatedInfo = [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + ]; + + $notification = new TraefikVersionOutdated(collect([$server])); + $mail = $notification->toMail($team); + + // Verify URL starts with base_url() not config('app.url') + $generatedUrl = $mail->viewData['servers'][0]['url']; + expect($generatedUrl)->toStartWith(base_url()); + expect($generatedUrl)->not->toContain('localhost'); +}); diff --git a/tests/Feature/Proxy/RestartProxyTest.php b/tests/Feature/Proxy/RestartProxyTest.php new file mode 100644 index 000000000..5771a58f7 --- /dev/null +++ b/tests/Feature/Proxy/RestartProxyTest.php @@ -0,0 +1,139 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(['name' => 'Test Team']); + $this->user->teams()->attach($this->team); + + // Create test server + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'Test Server', + 'ip' => '192.168.1.100', + ]); + + // Authenticate user + $this->actingAs($this->user); + } + + public function test_restart_dispatches_job_for_all_servers() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + // Assert job was dispatched + Queue::assertPushed(RestartProxyJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); + } + + public function test_restart_dispatches_job_for_localhost_server() + { + Queue::fake(); + + // Create localhost server (id = 0) + $localhostServer = Server::factory()->create([ + 'id' => 0, + 'team_id' => $this->team->id, + 'name' => 'Localhost', + 'ip' => 'host.docker.internal', + ]); + + Livewire::test('server.navbar', ['server' => $localhostServer]) + ->call('restart'); + + // Assert job was dispatched + Queue::assertPushed(RestartProxyJob::class, function ($job) use ($localhostServer) { + return $job->server->id === $localhostServer->id; + }); + } + + public function test_restart_shows_info_message() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart') + ->assertDispatched('info', 'Proxy restart initiated. Monitor progress in activity logs.'); + } + + public function test_unauthorized_user_cannot_restart_proxy() + { + Queue::fake(); + + // Create another user without access + $unauthorizedUser = User::factory()->create(); + $this->actingAs($unauthorizedUser); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart') + ->assertForbidden(); + + // Assert job was NOT dispatched + Queue::assertNotPushed(RestartProxyJob::class); + } + + public function test_restart_prevents_concurrent_jobs_via_without_overlapping() + { + Queue::fake(); + + // Dispatch job twice + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + // Assert job was pushed twice (WithoutOverlapping middleware will handle deduplication) + Queue::assertPushed(RestartProxyJob::class, 2); + + // Get the jobs + $jobs = Queue::pushed(RestartProxyJob::class); + + // Verify both jobs have WithoutOverlapping middleware + foreach ($jobs as $job) { + $middleware = $job['job']->middleware(); + $this->assertCount(1, $middleware); + $this->assertInstanceOf(\Illuminate\Queue\Middleware\WithoutOverlapping::class, $middleware[0]); + } + } + + public function test_restart_uses_server_team_id() + { + Queue::fake(); + + Livewire::test('server.navbar', ['server' => $this->server]) + ->call('restart'); + + Queue::assertPushed(RestartProxyJob::class, function ($job) { + return $job->server->team_id === $this->team->id; + }); + } +} diff --git a/tests/Feature/SentinelUpdateCheckIndependenceTest.php b/tests/Feature/SentinelUpdateCheckIndependenceTest.php new file mode 100644 index 000000000..080a3ee7c --- /dev/null +++ b/tests/Feature/SentinelUpdateCheckIndependenceTest.php @@ -0,0 +1,187 @@ +create(); + $this->team = $user->teams()->first(); + + // Create server with sentinel enabled + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + + // Enable sentinel on the server + $this->server->settings->update([ + 'is_sentinel_enabled' => true, + 'server_timezone' => 'UTC', + ]); + + $this->server->refresh(); +}); + +afterEach(function () { + Carbon::setTestNow(); // Reset frozen time +}); + +it('dispatches sentinel check hourly regardless of instance update_check_frequency setting', function () { + // Set instance update_check_frequency to yearly (most infrequent option) + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 0 1 1 *', // Yearly - January 1st at midnight + 'instance_timezone' => 'UTC', + ]); + + // Set time to top of any hour (sentinel should check every hour) + Carbon::setTestNow('2025-06-15 14:00:00'); // Random hour, not January 1st + + // Run ServerManagerJob + $job = new ServerManagerJob; + $job->handle(); + + // Assert that CheckAndStartSentinelJob was dispatched despite yearly update check frequency + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); +}); + +it('does not dispatch sentinel check when not at top of hour', function () { + // Set instance update_check_frequency to hourly (most frequent) + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 * * * *', // Hourly + 'instance_timezone' => 'UTC', + ]); + + // Set time to middle of the hour (sentinel check cron won't match) + Carbon::setTestNow('2025-06-15 14:30:00'); // 30 minutes past the hour + + // Run ServerManagerJob + $job = new ServerManagerJob; + $job->handle(); + + // Assert that CheckAndStartSentinelJob was NOT dispatched (not top of hour) + Queue::assertNotPushed(CheckAndStartSentinelJob::class); +}); + +it('dispatches sentinel check at every hour mark throughout the day', function () { + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 0 1 1 *', // Yearly + 'instance_timezone' => 'UTC', + ]); + + // Test multiple hours throughout a day + $hoursToTest = [0, 6, 12, 18, 23]; // Various hours of the day + + foreach ($hoursToTest as $hour) { + Queue::fake(); // Reset queue for each test + + Carbon::setTestNow("2025-06-15 {$hour}:00:00"); + + $job = new ServerManagerJob; + $job->handle(); + + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }, "Failed to dispatch sentinel check at hour {$hour}"); + } +}); + +it('respects server timezone when checking sentinel updates', function () { + // Update server timezone to America/New_York + $this->server->settings->update([ + 'server_timezone' => 'America/New_York', + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'instance_timezone' => 'UTC', + ]); + + // Set time to 17:00 UTC which is 12:00 PM EST (top of hour in server's timezone) + Carbon::setTestNow('2025-01-15 17:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should dispatch because it's top of hour in server's timezone (America/New_York) + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); +}); + +it('does not dispatch sentinel check for servers without sentinel enabled', function () { + // Disable sentinel + $this->server->settings->update([ + 'is_sentinel_enabled' => false, + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'update_check_frequency' => '0 * * * *', + 'instance_timezone' => 'UTC', + ]); + + Carbon::setTestNow('2025-06-15 14:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should NOT dispatch because sentinel is disabled + Queue::assertNotPushed(CheckAndStartSentinelJob::class); +}); + +it('handles multiple servers with different sentinel configurations', function () { + // Create a second server with sentinel disabled + $server2 = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + $server2->settings->update([ + 'is_sentinel_enabled' => false, + 'server_timezone' => 'UTC', + ]); + + // Create a third server with sentinel enabled + $server3 = Server::factory()->create([ + 'team_id' => $this->team->id, + ]); + $server3->settings->update([ + 'is_sentinel_enabled' => true, + 'server_timezone' => 'UTC', + ]); + + $instanceSettings = InstanceSettings::first(); + $instanceSettings->update([ + 'instance_timezone' => 'UTC', + ]); + + Carbon::setTestNow('2025-06-15 14:00:00'); + + $job = new ServerManagerJob; + $job->handle(); + + // Should dispatch for server1 (sentinel enabled) and server3 (sentinel enabled) + Queue::assertPushed(CheckAndStartSentinelJob::class, 2); + + // Verify it was dispatched for the correct servers + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) { + return $job->server->id === $this->server->id; + }); + + Queue::assertPushed(CheckAndStartSentinelJob::class, function ($job) use ($server3) { + return $job->server->id === $server3->id; + }); +}); diff --git a/tests/Feature/ServerStorageCheckIndependenceTest.php b/tests/Feature/ServerStorageCheckIndependenceTest.php new file mode 100644 index 000000000..57b392e2f --- /dev/null +++ b/tests/Feature/ServerStorageCheckIndependenceTest.php @@ -0,0 +1,186 @@ +create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now(), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should NOT be dispatched (Sentinel handles it via PushServerUpdateJob) + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); + +it('dispatches storage check when sentinel is out of sync', function () { + // When: ServerManagerJob runs at 11 PM + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with Sentinel out of sync (last update 10 minutes ago) + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: Both ServerCheckJob and ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerCheckJob::class); + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('dispatches storage check when sentinel is disabled', function () { + // When: ServerManagerJob runs at 11 PM + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with Sentinel disabled + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subHours(24), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + 'is_metrics_enabled' => false, + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('respects custom hourly storage check frequency when sentinel is out of sync', function () { + // When: ServerManagerJob runs at the top of the hour (23:00) + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with hourly storage check frequency and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 * * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('handles VALID_CRON_STRINGS mapping correctly when sentinel is out of sync', function () { + // When: ServerManagerJob runs at the top of the hour + Carbon::setTestNow(Carbon::parse('2025-01-15 23:00:00', 'UTC')); + + // Given: A server with 'hourly' string (should be converted to '0 * * * *') and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => 'hourly', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched (hourly was converted to cron) + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('respects server timezone for storage checks when sentinel is out of sync', function () { + // When: ServerManagerJob runs at 11 PM New York time (4 AM UTC next day) + Carbon::setTestNow(Carbon::parse('2025-01-16 04:00:00', 'UTC')); + + // Given: A server in America/New_York timezone (UTC-5) configured for 11 PM local time and Sentinel out of sync + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now()->subMinutes(10), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'America/New_York', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should be dispatched + Queue::assertPushed(ServerStorageCheckJob::class, function ($job) use ($server) { + return $job->server->id === $server->id; + }); +}); + +it('does not dispatch storage check outside schedule', function () { + // When: ServerManagerJob runs at 10 PM (not 11 PM) + Carbon::setTestNow(Carbon::parse('2025-01-15 22:00:00', 'UTC')); + + // Given: A server with daily storage check at 11 PM + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'sentinel_updated_at' => now(), + ]); + + $server->settings->update([ + 'server_disk_usage_check_frequency' => '0 23 * * *', + 'server_timezone' => 'UTC', + ]); + $job = new ServerManagerJob; + $job->handle(); + + // Then: ServerStorageCheckJob should NOT be dispatched + Queue::assertNotPushed(ServerStorageCheckJob::class); +}); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php new file mode 100644 index 000000000..ebf73da06 --- /dev/null +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -0,0 +1,253 @@ + 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], + ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], + ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:pr-123'], + ['repository' => 'app-uuid', 'tag' => 'pr-456', 'created_at' => '2024-01-04', 'image_ref' => 'app-uuid:pr-456'], + ['repository' => 'app-uuid', 'tag' => 'abc123-build', 'created_at' => '2024-01-05', 'image_ref' => 'app-uuid:abc123-build'], + ['repository' => 'app-uuid', 'tag' => 'def456-build', 'created_at' => '2024-01-06', 'image_ref' => 'app-uuid:def456-build'], + ]); + + // PR images (tags starting with 'pr-') + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + expect($prImages)->toHaveCount(2); + expect($prImages->pluck('tag')->toArray())->toContain('pr-123', 'pr-456'); + + // Regular images (neither PR nor build) - these are subject to retention policy + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + expect($regularImages)->toHaveCount(2); + expect($regularImages->pluck('tag')->toArray())->toContain('abc123', 'def456'); +}); + +it('filters out currently running image from deletion candidates', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'abc123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:abc123'], + ['repository' => 'app-uuid', 'tag' => 'def456', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:def456'], + ['repository' => 'app-uuid', 'tag' => 'ghi789', 'created_at' => '2024-01-03', 'image_ref' => 'app-uuid:ghi789'], + ]); + + $currentTag = 'def456'; + + $deletionCandidates = $images->filter(fn ($image) => $image['tag'] !== $currentTag); + + expect($deletionCandidates)->toHaveCount(2); + expect($deletionCandidates->pluck('tag')->toArray())->not->toContain('def456'); + expect($deletionCandidates->pluck('tag')->toArray())->toContain('abc123', 'ghi789'); +}); + +it('keeps the correct number of images based on docker_images_to_keep setting', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ['repository' => 'app-uuid', 'tag' => 'commit4', 'created_at' => '2024-01-04 10:00:00', 'image_ref' => 'app-uuid:commit4'], + ['repository' => 'app-uuid', 'tag' => 'commit5', 'created_at' => '2024-01-05 10:00:00', 'image_ref' => 'app-uuid:commit5'], + ]); + + $currentTag = 'commit5'; + $imagesToKeep = 2; + + // Filter out current, sort by date descending, keep N + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should delete commit1, commit2 (oldest 2 after keeping 2 newest: commit4, commit3) + expect($imagesToDelete)->toHaveCount(2); + expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); +}); + +it('deletes all images when docker_images_to_keep is 0', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ]); + + $currentTag = 'commit3'; + $imagesToKeep = 0; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should delete all images except the currently running one + expect($imagesToDelete)->toHaveCount(2); + expect($imagesToDelete->pluck('tag')->toArray())->toContain('commit1', 'commit2'); +}); + +it('does not delete any images when there are fewer than images_to_keep', function () { + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ]); + + $currentTag = 'commit2'; + $imagesToKeep = 5; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Should not delete anything - we have fewer images than the keep limit + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles images with custom registry names', function () { + // Test that the logic works regardless of repository name format + $images = collect([ + ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.0.0', 'created_at' => '2024-01-01', 'image_ref' => 'registry.example.com/my-app:v1.0.0'], + ['repository' => 'registry.example.com/my-app', 'tag' => 'v1.1.0', 'created_at' => '2024-01-02', 'image_ref' => 'registry.example.com/my-app:v1.1.0'], + ['repository' => 'registry.example.com/my-app', 'tag' => 'pr-99', 'created_at' => '2024-01-03', 'image_ref' => 'registry.example.com/my-app:pr-99'], + ]); + + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + expect($prImages)->toHaveCount(1); + expect($regularImages)->toHaveCount(2); +}); + +it('correctly identifies PR build images as PR images', function () { + // PR build images have tags like 'pr-123-build' + // They are identified as PR images (start with 'pr-') and will be deleted + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'pr-123', 'created_at' => '2024-01-01', 'image_ref' => 'app-uuid:pr-123'], + ['repository' => 'app-uuid', 'tag' => 'pr-123-build', 'created_at' => '2024-01-02', 'image_ref' => 'app-uuid:pr-123-build'], + ]); + + // PR images include both pr-123 and pr-123-build (both start with 'pr-') + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + + expect($prImages)->toHaveCount(2); +}); + +it('defaults to keeping 2 images when setting is null', function () { + $defaultValue = 2; + + // Simulate the null coalescing behavior + $dockerImagesToKeep = null ?? $defaultValue; + + expect($dockerImagesToKeep)->toBe(2); +}); + +it('does not delete images when count equals images_to_keep', function () { + // Scenario: User has 3 images, 1 is running, 2 remain, keep limit is 2 + // Expected: No images should be deleted + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ['repository' => 'app-uuid', 'tag' => 'commit3', 'created_at' => '2024-01-03 10:00:00', 'image_ref' => 'app-uuid:commit3'], + ]); + + $currentTag = 'commit3'; // This is running + $imagesToKeep = 2; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // After filtering out running image, we have 2 images + expect($sortedImages)->toHaveCount(2); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Skip 2, leaving 0 to delete + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles scenario where no container is running', function () { + // Scenario: 2 images exist, none running, keep limit is 2 + // Expected: No images should be deleted (keep all 2) + $images = collect([ + ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'], + ['repository' => 'app-uuid', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid:commit2'], + ]); + + $currentTag = ''; // No container running, empty tag + $imagesToKeep = 2; + + $sortedImages = $images + ->filter(fn ($image) => $image['tag'] !== $currentTag) + ->sortByDesc('created_at') + ->values(); + + // All images remain since none match the empty current tag + expect($sortedImages)->toHaveCount(2); + + $imagesToDelete = $sortedImages->skip($imagesToKeep); + + // Skip 2, leaving 0 to delete + expect($imagesToDelete)->toHaveCount(0); +}); + +it('handles Docker Compose service images with uuid_servicename pattern', function () { + // Docker Compose with build: directive creates images like uuid_servicename:tag + $images = collect([ + ['repository' => 'app-uuid_web', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_web:commit1'], + ['repository' => 'app-uuid_web', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_web:commit2'], + ['repository' => 'app-uuid_worker', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid_worker:commit1'], + ['repository' => 'app-uuid_worker', 'tag' => 'commit2', 'created_at' => '2024-01-02 10:00:00', 'image_ref' => 'app-uuid_worker:commit2'], + ]); + + // All images should be categorized as regular images (not PR, not build) + $prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-')); + $regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build')); + + expect($prImages)->toHaveCount(0); + expect($regularImages)->toHaveCount(4); +}); + +it('correctly excludes Docker Compose images from general prune', function () { + // Test the grep pattern that excludes application images + // Pattern should match both uuid:tag and uuid_servicename:tag + $appUuid = 'abc123def456'; + $excludePattern = preg_quote($appUuid, '/'); + + // Images that should be EXCLUDED (protected) + $protectedImages = [ + "{$appUuid}:commit1", // Standard app image + "{$appUuid}_web:commit1", // Docker Compose service + "{$appUuid}_worker:commit2", // Docker Compose service + ]; + + // Images that should be INCLUDED (deleted) + $deletableImages = [ + 'other-app:latest', + 'nginx:alpine', + 'postgres:15', + ]; + + // Test the regex pattern used in buildImagePruneCommand + $pattern = "/^({$excludePattern})[_:].+/"; + + foreach ($protectedImages as $image) { + expect(preg_match($pattern, $image))->toBe(1, "Image {$image} should be protected"); + } + + foreach ($deletableImages as $image) { + expect(preg_match($pattern, $image))->toBe(0, "Image {$image} should be deletable"); + } +}); diff --git a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php index fc29f19c3..3dd18ee6c 100644 --- a/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php +++ b/tests/Unit/ApplicationDeploymentCustomBuildCommandTest.php @@ -615,3 +615,58 @@ expect($path)->not->toContain('//', "Double slash found for baseDir: {$case['baseDir']}"); } }); + +// Tests for injectDockerComposeBuildArgs() helper function +it('injects build args when building specific service', function () { + $command = 'docker compose build web'; + $buildArgs = '--build-arg ENV=prod'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg ENV=prod web'); +}); + +it('injects build args with service name containing hyphens', function () { + $command = 'docker compose build my-service-name'; + $buildArgs = '--build-arg TEST=value'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg TEST=value my-service-name'); +}); + +it('injects build args with service name containing underscores', function () { + $command = 'docker compose build my_service_name'; + $buildArgs = '--build-arg TEST=value'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg TEST=value my_service_name'); +}); + +it('injects build args before service name and existing flags', function () { + $command = 'docker compose build backend --no-cache'; + $buildArgs = '--build-arg FOO=bar'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg FOO=bar backend --no-cache'); +}); + +it('handles buildx with target and flags', function () { + $command = 'docker buildx build --platform linux/amd64 -t myimage:latest .'; + $buildArgs = '--build-arg VERSION=1.0'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker buildx build --build-arg VERSION=1.0 --platform linux/amd64 -t myimage:latest .'); +}); + +it('handles docker compose build with no arguments', function () { + $command = 'docker compose build'; + $buildArgs = '--build-arg FOO=bar'; + + $result = injectDockerComposeBuildArgs($command, $buildArgs); + + expect($result)->toBe('docker compose build --build-arg FOO=bar'); +}); diff --git a/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php new file mode 100644 index 000000000..bd925444a --- /dev/null +++ b/tests/Unit/ApplicationDeploymentNixpacksNullEnvTest.php @@ -0,0 +1,299 @@ +shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables - some with null/empty values + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'VALID_VAR'; + $envVar1->real_value = 'valid_value'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'NULL_VAR'; + $envVar2->real_value = null; + + $envVar3 = Mockery::mock(EnvironmentVariable::class); + $envVar3->key = 'EMPTY_VAR'; + $envVar3->real_value = ''; + + $envVar4 = Mockery::mock(EnvironmentVariable::class); + $envVar4->key = 'ANOTHER_VALID_VAR'; + $envVar4->real_value = 'another_value'; + + $nixpacksEnvVars = collect([$envVar1, $envVar2, $envVar3, $envVar4]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables to return some values including null + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_FQDN' => 'example.com', + 'COOLIFY_URL' => null, // null value that should be filtered + 'COOLIFY_BRANCH' => '', // empty value that should be filtered + 'SOURCE_COMMIT' => 'abc123', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that only valid environment variables are included + expect($envArgs)->toContain('--env VALID_VAR=valid_value'); + expect($envArgs)->toContain('--env ANOTHER_VALID_VAR=another_value'); + expect($envArgs)->toContain('--env COOLIFY_FQDN=example.com'); + expect($envArgs)->toContain('--env SOURCE_COMMIT=abc123'); + + // Verify that null and empty environment variables are filtered out + expect($envArgs)->not->toContain('NULL_VAR'); + expect($envArgs)->not->toContain('EMPTY_VAR'); + expect($envArgs)->not->toContain('COOLIFY_URL'); + expect($envArgs)->not->toContain('COOLIFY_BRANCH'); + + // Verify no environment variables end with just '=' (which indicates null/empty value) + expect($envArgs)->not->toMatch('/--env [A-Z_]+=$/'); + expect($envArgs)->not->toMatch('/--env [A-Z_]+= /'); +}); + +it('filters out null environment variables from nixpacks preview deployments', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock preview environment variables - some with null/empty values + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'PREVIEW_VAR'; + $envVar1->real_value = 'preview_value'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'NULL_PREVIEW_VAR'; + $envVar2->real_value = null; + + $previewEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables_preview') + ->andReturn($previewEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 123); // Non-zero for preview deployment + + // Mock generate_coolify_env_variables + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_FQDN' => 'preview.example.com', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that only valid environment variables are included + expect($envArgs)->toContain('--env PREVIEW_VAR=preview_value'); + expect($envArgs)->toContain('--env COOLIFY_FQDN=preview.example.com'); + + // Verify that null environment variables are filtered out + expect($envArgs)->not->toContain('NULL_PREVIEW_VAR'); +}); + +it('handles all environment variables being null or empty', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables - all null or empty + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'NULL_VAR'; + $envVar1->real_value = null; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'EMPTY_VAR'; + $envVar2->real_value = ''; + + $nixpacksEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables to return all null/empty values + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([ + 'COOLIFY_URL' => null, + 'COOLIFY_BRANCH' => '', + ])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that the result is empty or contains no environment variables + expect($envArgs)->toBe(''); +}); + +it('preserves environment variables with zero values', function () { + // Mock application with nixpacks build pack + $mockApplication = Mockery::mock(Application::class); + $mockApplication->shouldReceive('getAttribute') + ->with('build_pack') + ->andReturn('nixpacks'); + $mockApplication->build_pack = 'nixpacks'; + + // Mock environment variables with zero values (which should NOT be filtered) + $envVar1 = Mockery::mock(EnvironmentVariable::class); + $envVar1->key = 'ZERO_VALUE'; + $envVar1->real_value = '0'; + + $envVar2 = Mockery::mock(EnvironmentVariable::class); + $envVar2->key = 'FALSE_VALUE'; + $envVar2->real_value = 'false'; + + $nixpacksEnvVars = collect([$envVar1, $envVar2]); + + $mockApplication->shouldReceive('getAttribute') + ->with('nixpacks_environment_variables') + ->andReturn($nixpacksEnvVars); + + // Mock application deployment queue + $mockQueue = Mockery::mock(ApplicationDeploymentQueue::class); + $mockQueue->shouldReceive('getAttribute')->with('application_id')->andReturn(1); + $mockQueue->application_id = 1; + + // Mock the job + $job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial(); + $job->shouldAllowMockingProtectedMethods(); + + $reflection = new \ReflectionClass(ApplicationDeploymentJob::class); + + // Set private properties + $applicationProperty = $reflection->getProperty('application'); + $applicationProperty->setAccessible(true); + $applicationProperty->setValue($job, $mockApplication); + + $pullRequestProperty = $reflection->getProperty('pull_request_id'); + $pullRequestProperty->setAccessible(true); + $pullRequestProperty->setValue($job, 0); + + // Mock generate_coolify_env_variables + $job->shouldReceive('generate_coolify_env_variables') + ->andReturn(collect([])); + + // Call the private method + $method = $reflection->getMethod('generate_nixpacks_env_variables'); + $method->setAccessible(true); + $method->invoke($job); + + // Get the generated env_nixpacks_args + $envArgsProperty = $reflection->getProperty('env_nixpacks_args'); + $envArgsProperty->setAccessible(true); + $envArgs = $envArgsProperty->getValue($job); + + // Verify that zero and false string values are preserved + expect($envArgs)->toContain('--env ZERO_VALUE=0'); + expect($envArgs)->toContain('--env FALSE_VALUE=false'); +}); diff --git a/tests/Unit/ContainerStatusAggregatorTest.php b/tests/Unit/ContainerStatusAggregatorTest.php index 353d6a948..71425a21c 100644 --- a/tests/Unit/ContainerStatusAggregatorTest.php +++ b/tests/Unit/ContainerStatusAggregatorTest.php @@ -126,6 +126,70 @@ expect($result)->toBe('starting:unknown'); }); + test('returns degraded:unhealthy for single degraded container', function () { + $statuses = collect(['degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy when mixing degraded with running healthy', function () { + $statuses = collect(['degraded:unhealthy', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy when mixing running healthy with degraded', function () { + $statuses = collect(['running:healthy', 'degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns degraded:unhealthy for multiple degraded containers', function () { + $statuses = collect(['degraded:unhealthy', 'degraded:unhealthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('degraded status overrides all other non-critical states', function () { + $statuses = collect(['degraded:unhealthy', 'running:healthy', 'starting', 'paused']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('returns starting:unknown when mixing starting with running healthy (service not fully ready)', function () { + $statuses = collect(['starting:unknown', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('returns starting:unknown when mixing created with running healthy', function () { + $statuses = collect(['created', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('returns starting:unknown for multiple starting containers with some running', function () { + $statuses = collect(['starting:unknown', 'starting:unknown', 'running:healthy']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + test('handles parentheses format input (backward compatibility)', function () { $statuses = collect(['running (healthy)', 'running (unhealthy)']); @@ -166,8 +230,16 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('prioritizes running over paused/starting/exited', function () { - $statuses = collect(['running:healthy', 'starting', 'paused']); + test('mixed running and starting returns starting', function () { + $statuses = collect(['running:healthy', 'starting']); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + + test('prioritizes running over paused/exited when no starting', function () { + $statuses = collect(['running:healthy', 'paused', 'exited']); $result = $this->aggregator->aggregateFromStrings($statuses); @@ -398,7 +470,23 @@ }); describe('state priority enforcement', function () { - test('restarting has highest priority', function () { + test('degraded from sub-resources has highest priority', function () { + $statuses = collect([ + 'degraded:unhealthy', + 'restarting', + 'running:healthy', + 'dead', + 'paused', + 'starting', + 'exited', + ]); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('degraded:unhealthy'); + }); + + test('restarting has second highest priority', function () { $statuses = collect([ 'restarting', 'running:healthy', @@ -413,7 +501,7 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('crash loop has second highest priority', function () { + test('crash loop has third highest priority', function () { $statuses = collect([ 'exited', 'running:healthy', @@ -426,7 +514,7 @@ expect($result)->toBe('degraded:unhealthy'); }); - test('mixed state (running + exited) has third priority', function () { + test('mixed state (running + exited) has fourth priority', function () { $statuses = collect([ 'running:healthy', 'exited', @@ -439,6 +527,18 @@ expect($result)->toBe('degraded:unhealthy'); }); + test('mixed state (running + starting) has fifth priority', function () { + $statuses = collect([ + 'running:healthy', + 'starting', + 'paused', + ]); + + $result = $this->aggregator->aggregateFromStrings($statuses); + + expect($result)->toBe('starting:unknown'); + }); + test('running:unhealthy has priority over running:unknown', function () { $statuses = collect([ 'running:unknown', diff --git a/tests/Unit/Jobs/RestartProxyJobTest.php b/tests/Unit/Jobs/RestartProxyJobTest.php new file mode 100644 index 000000000..422abd940 --- /dev/null +++ b/tests/Unit/Jobs/RestartProxyJobTest.php @@ -0,0 +1,58 @@ +shouldReceive('getSchemalessAttributes')->andReturn([]); + $server->shouldReceive('getAttribute')->with('uuid')->andReturn('test-uuid'); + + $job = new RestartProxyJob($server); + $middleware = $job->middleware(); + + $this->assertCount(1, $middleware); + $this->assertInstanceOf(WithoutOverlapping::class, $middleware[0]); + } + + public function test_job_has_correct_configuration() + { + $server = Mockery::mock(Server::class); + + $job = new RestartProxyJob($server); + + $this->assertEquals(1, $job->tries); + $this->assertEquals(120, $job->timeout); + $this->assertNull($job->activity_id); + } + + public function test_job_stores_server() + { + $server = Mockery::mock(Server::class); + + $job = new RestartProxyJob($server); + + $this->assertSame($server, $job->server); + } +} diff --git a/tests/Unit/LogViewerXssSecurityTest.php b/tests/Unit/LogViewerXssSecurityTest.php new file mode 100644 index 000000000..98c5df3f1 --- /dev/null +++ b/tests/Unit/LogViewerXssSecurityTest.php @@ -0,0 +1,427 @@ +alert("XSS")'; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain('">'; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<iframe'); + expect($escaped)->toContain('data:'); + expect($escaped)->not->toContain('test
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('style'); + expect($escaped)->not->toContain('
    test
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('x-html'); + expect($escaped)->not->toContain('
    toBe('<>&"''); + }); + + it('preserves legitimate text content', function () { + $legitimateLog = 'INFO: Application started successfully'; + $escaped = htmlspecialchars($legitimateLog); + + expect($escaped)->toBe($legitimateLog); + }); + + it('handles ANSI color codes after escaping', function () { + $logWithAnsi = "\e[31mERROR:\e[0m Something went wrong"; + $escaped = htmlspecialchars($logWithAnsi); + + // ANSI codes should be preserved in escaped form + expect($escaped)->toContain('ERROR'); + expect($escaped)->toContain('Something went wrong'); + }); + + it('escapes complex nested HTML structures', function () { + $maliciousLog = '
    '; + $escaped = htmlspecialchars($maliciousLog); + + expect($escaped)->toContain('<div'); + expect($escaped)->toContain('<img'); + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain('not->toContain('not->toContain(''; + $escaped = htmlspecialchars($contentWithHtml); + + // When stored in data attribute and rendered with x-text: + // 1. Server escapes to: <script>alert("XSS")</script> + // 2. Browser decodes the attribute value to: + // 3. x-text renders it as textContent (plain text), NOT innerHTML + // 4. Result: User sees "" as text, script never executes + + expect($escaped)->toContain('<script>'); + expect($escaped)->not->toContain(''; + + // Step 1: Server-side escaping (PHP) + $escaped = htmlspecialchars($rawLog); + expect($escaped)->toBe('<script>alert("XSS")</script>'); + + // Step 2: Stored in data-log-content attribute + //
    + + // Step 3: Client-side getDisplayText() decodes HTML entities + // const decoded = doc.documentElement.textContent; + // Result: '' (as text string) + + // Step 4: x-text renders as textContent (NOT innerHTML) + // Alpine.js sets element.textContent = decoded + // Result: Browser displays '' as visible text + // The script tag is never parsed or executed - it's just text + + // Step 5: Highlighting via CSS class + // If search query matches, 'log-highlight' class is added + // Visual feedback is provided through CSS, not HTML injection + }); + + it('documents search highlighting with CSS classes', function () { + $legitimateLog = '2024-01-01T12:00:00.000Z ERROR: Database connection failed'; + + // Server-side: Escape and store + $escaped = htmlspecialchars($legitimateLog); + expect($escaped)->toBe($legitimateLog); // No special chars + + // Client-side: If user searches for "ERROR" + // 1. splitTextForHighlight() divides the text into parts: + // - Part 1: "2024-01-01T12:00:00.000Z " (highlight: false) + // - Part 2: "ERROR" (highlight: true) <- This part gets highlighted + // - Part 3: ": Database connection failed" (highlight: false) + // 2. Each part is rendered as a with x-text (safe) + // 3. Only Part 2 gets the 'log-highlight' class via :class binding + // 4. CSS provides yellow/warning background color on "ERROR" only + // 5. No HTML injection occurs - just multiple safe text spans + + expect($legitimateLog)->toContain('ERROR'); + }); + + it('verifies no HTML injection occurs during search', function () { + $logWithHtml = 'User input: '; + $escaped = htmlspecialchars($logWithHtml); + + // Even if log contains malicious HTML: + // 1. Server escapes it + // 2. x-text renders as plain text + // 3. Search highlighting uses CSS class, not HTML tags + // 4. User sees the literal text with highlight background + // 5. No script execution possible + + expect($escaped)->toContain('<img'); + expect($escaped)->toContain('onerror'); + expect($escaped)->not->toContain('toContain('