diff --git a/CHANGELOG.md b/CHANGELOG.md index fa229f4c2..02536975d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ # Changelog ## [unreleased] +### 📚 Documentation + +- Update changelog + +## [4.0.0-beta.436] - 2025-10-17 + ### 🚀 Features - Use tags in update diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index a13cda0b8..f6a2de75b 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -7,9 +7,9 @@ class CleanupRedis extends Command { - protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}'; + protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}'; - protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)'; + protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)'; public function handle() { @@ -56,6 +56,13 @@ public function handle() $deletedCount += $overlappingCleaned; } + // Clean up stale cache locks (WithoutOverlapping middleware) + if ($this->option('clear-locks')) { + $this->info('Cleaning up stale cache locks...'); + $locksCleaned = $this->cleanupCacheLocks($dryRun); + $deletedCount += $locksCleaned; + } + if ($dryRun) { $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); } else { @@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun) return $cleanedCount; } + + private function cleanupCacheLocks(bool $dryRun): int + { + $cleanedCount = 0; + + // Use the default Redis connection (database 0) where cache locks are stored + $redis = Redis::connection('default'); + + // Get all keys matching WithoutOverlapping lock pattern + $allKeys = $redis->keys('*'); + $lockKeys = []; + + foreach ($allKeys as $key) { + // Match cache lock keys: they contain 'laravel-queue-overlap' + if (preg_match('/overlap/i', $key)) { + $lockKeys[] = $key; + } + } + if (empty($lockKeys)) { + $this->info(' No cache locks found.'); + + return 0; + } + + $this->info(' Found '.count($lockKeys).' cache lock(s)'); + + foreach ($lockKeys as $lockKey) { + // Check TTL to identify stale locks + $ttl = $redis->ttl($lockKey); + + // TTL = -1 means no expiration (stale lock!) + // TTL = -2 means key doesn't exist + // TTL > 0 means lock is valid and will expire + if ($ttl === -1) { + if ($dryRun) { + $this->warn(" Would delete STALE lock (no expiration): {$lockKey}"); + } else { + $redis->del($lockKey); + $this->info(" ✓ Deleted STALE lock: {$lockKey}"); + } + $cleanedCount++; + } elseif ($ttl > 0) { + $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}"); + } + } + + if ($cleanedCount === 0) { + $this->info(' No stale locks found (all locks have expiration set)'); + } + + return $cleanedCount; + } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 6e8d18f61..4bc818f0a 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -73,7 +73,7 @@ public function handle() $this->cleanupUnusedNetworkFromCoolifyProxy(); try { - $this->call('cleanup:redis'); + $this->call('cleanup:redis', ['--clear-locks' => true]); } catch (\Throwable $e) { echo "Error in cleanup:redis command: {$e->getMessage()}\n"; } diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 18ca0008c..9937444b8 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -52,7 +52,8 @@ public function middleware(): array { return [ (new WithoutOverlapping('scheduled-job-manager')) - ->releaseAfter(60), // Release the lock after 60 seconds if job fails + ->expireAfter(60) // Lock expires after 1 minute to prevent stale locks + ->dontRelease(), // Don't re-queue on lock conflict ]; } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 62c93611e..5231438e5 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -58,6 +58,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function force_deploy_without_cache() { $this->authorize('deploy', $this->application); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6a287f8cc..8d3d8e294 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -62,6 +62,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function mount() { $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4ad3b9b29..0afcf94e6 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -102,6 +102,36 @@ public function loadServices() : asset($default_logo), ] + (array) $service; })->all(); + + // Extract unique categories from services + $categories = collect($services) + ->pluck('category') + ->filter() + ->unique() + ->map(function ($category) { + // Handle multiple categories separated by comma + if (str_contains($category, ',')) { + return collect(explode(',', $category))->map(fn ($cat) => trim($cat)); + } + + return [$category]; + }) + ->flatten() + ->unique() + ->map(function ($category) { + // Format common acronyms to uppercase + $acronyms = ['ai', 'api', 'ci', 'cd', 'cms', 'crm', 'erp', 'iot', 'vpn', 'vps', 'dns', 'ssl', 'tls', 'ssh', 'ftp', 'http', 'https', 'smtp', 'imap', 'pop3', 'sql', 'nosql', 'json', 'xml', 'yaml', 'csv', 'pdf', 'sms', 'mfa', '2fa', 'oauth', 'saml', 'jwt', 'rest', 'soap', 'grpc', 'graphql', 'websocket', 'webrtc', 'p2p', 'b2b', 'b2c', 'seo', 'sem', 'ppc', 'roi', 'kpi', 'ui', 'ux', 'ide', 'sdk', 'api', 'cli', 'gui', 'cdn', 'ddos', 'dos', 'xss', 'csrf', 'sqli', 'rce', 'lfi', 'rfi', 'ssrf', 'xxe', 'idor', 'owasp', 'gdpr', 'hipaa', 'pci', 'dss', 'iso', 'nist', 'cve', 'cwe', 'cvss']; + $lower = strtolower($category); + + if (in_array($lower, $acronyms)) { + return strtoupper($category); + } + + return $category; + }) + ->sort(SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->all(); $gitBasedApplications = [ [ 'id' => 'public', @@ -147,14 +177,14 @@ public function loadServices() 'id' => 'postgresql', 'name' => 'PostgreSQL', 'description' => 'PostgreSQL is an object-relational database known for its robustness, advanced features, and strong standards compliance.', - 'logo' => ' + 'logo' => ' ', ], [ 'id' => 'mysql', 'name' => 'MySQL', 'description' => 'MySQL is an open-source relational database management system. ', - 'logo' => ' + 'logo' => ' @@ -165,43 +195,44 @@ public function loadServices() 'id' => 'mariadb', 'name' => 'MariaDB', 'description' => 'MariaDB is a community-developed, commercially supported fork of the MySQL relational database management system, intended to remain free and open-source.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'redis', 'name' => 'Redis', 'description' => 'Redis is a source-available, in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'keydb', 'name' => 'KeyDB', 'description' => 'KeyDB is a database that offers high performance, low latency, and scalability for various data structures and workloads.', - 'logo' => '
', + 'logo' => '
', ], [ 'id' => 'dragonfly', 'name' => 'Dragonfly', 'description' => 'Dragonfly DB is a drop-in Redis replacement that delivers 25x more throughput and 12x faster snapshotting than Redis.', - 'logo' => '
', + 'logo' => '
', ], [ 'id' => 'mongodb', 'name' => 'MongoDB', 'description' => 'MongoDB is a source-available, cross-platform, document-oriented database program.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'clickhouse', 'name' => 'ClickHouse', 'description' => 'ClickHouse is a column-oriented database that supports real-time analytics, business intelligence, observability, ML and GenAI, and more.', - 'logo' => '
', + 'logo' => '
', ], ]; return [ 'services' => $services, + 'categories' => $categories, 'gitBasedApplications' => $gitBasedApplications, 'dockerBasedApplications' => $dockerBasedApplications, 'databases' => $databases, diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 3492da324..c8a08d8f9 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -54,6 +54,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function serviceChecked() { try { diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 84d2e03b2..01ae50f6b 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -1299,9 +1301,32 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; - // Also update docker_compose_raw to remove content: from volumes - // This prevents content from being reapplied on subsequent deployments - $resource->docker_compose_raw = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // This keeps the original user input clean while preventing content reapplication + // Parse the original compose again to create a clean version without Coolify additions + try { + $originalYaml = Yaml::parse($originalCompose); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); @@ -1313,6 +1338,8 @@ function serviceParser(Service $resource): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -2226,9 +2253,32 @@ function serviceParser(Service $resource): Collection $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; - // Also update docker_compose_raw to remove content: from volumes - // This prevents content from being reapplied on subsequent deployments - $resource->docker_compose_raw = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // This keeps the original user input clean while preventing content reapplication + // Parse the original compose again to create a clean version without Coolify additions + try { + $originalYaml = Yaml::parse($originalCompose); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); diff --git a/resources/views/components/resource-view.blade.php b/resources/views/components/resource-view.blade.php index acde36336..ff8e99074 100644 --- a/resources/views/components/resource-view.blade.php +++ b/resources/views/components/resource-view.blade.php @@ -4,7 +4,9 @@ 'hover:border-l-red-500 cursor-not-allowed' => $upgrade, ])>
- {{ $logo }} +
+ {{ $logo }} +
{{ $title }} diff --git a/resources/views/components/status/index.blade.php b/resources/views/components/status/index.blade.php index 65beace65..d592cff79 100644 --- a/resources/views/components/status/index.blade.php +++ b/resources/views/components/status/index.blade.php @@ -13,14 +13,14 @@ @endif @if (!str($resource->status)->contains('exited') && $showRefreshButton) - -