diff --git a/CLAUDE.md b/CLAUDE.md index 5cddb7fd0..4be85fcc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,42 @@ ## Project Overview Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization. +## Git Worktree Shared Dependencies + +This repository uses git worktrees for parallel development with **automatic shared dependency setup** via Conductor. + +### How It Works + +The `conductor.json` setup script (`scripts/conductor-setup.sh`) automatically: +1. Creates symlinks from worktree's `node_modules` and `vendor` to the main repository's directories +2. All worktrees share the same dependencies from the main repository +3. This happens automatically when Conductor creates a new worktree + +### Benefits + +- **Save disk space**: Only one copy of dependencies across all worktrees +- **Faster setup**: No need to run `npm install` or `composer install` for each worktree +- **Consistent versions**: All worktrees use the same dependency versions +- **Auto-configured**: Handled by Conductor's setup script +- **Simple**: Uses the main repo's existing directories, no extra folders + +### Manual Setup (If Needed) + +If you need to set up symlinks manually or for non-Conductor worktrees: + +```bash +# From the worktree directory +rm -rf node_modules vendor +ln -sf ../../node_modules node_modules +ln -sf ../../vendor vendor +``` + +### Important Notes + +- Dependencies are shared from the main repository (`$CONDUCTOR_ROOT_PATH`) +- Run `npm install` or `composer install` from the main repo or any worktree to update all +- If different branches need different dependency versions, this won't work - remove symlinks and use separate directories + ## Development Commands ### Frontend Development diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 6da5465c6..393906b9b 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database) ], 'labels' => defaultDatabaseLabels($this->database)->toArray(), 'healthcheck' => [ - 'test' => "clickhouse-client --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'", + 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'", 'interval' => '5s', 'timeout' => '5s', 'retries' => 10, @@ -152,12 +152,16 @@ private function generate_environment_variables() $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_USER'))->isEmpty()) { - $environment_variables->push("CLICKHOUSE_ADMIN_USER={$this->database->clickhouse_admin_user}"); + if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) { + $environment_variables->push("CLICKHOUSE_USER={$this->database->clickhouse_admin_user}"); } - if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_ADMIN_PASSWORD'))->isEmpty()) { - $environment_variables->push("CLICKHOUSE_ADMIN_PASSWORD={$this->database->clickhouse_admin_password}"); + if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_PASSWORD'))->isEmpty()) { + $environment_variables->push("CLICKHOUSE_PASSWORD={$this->database->clickhouse_admin_password}"); + } + + if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_DB'))->isEmpty()) { + $environment_variables->push("CLICKHOUSE_DB={$this->database->clickhouse_db}"); } add_coolify_default_environment_variables($this->database, $environment_variables, $environment_variables); diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index a1476e120..3631cca24 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -199,12 +199,26 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti $isPublic = data_get($database, 'is_public'); $foundDatabases[] = $database->id; $statusFromDb = $database->status; + + // Track restart count for databases (single-container) + $restartCount = data_get($container, 'RestartCount', 0); + $previousRestartCount = $database->restart_count ?? 0; + if ($statusFromDb !== $containerStatus) { - $database->update(['status' => $containerStatus]); + $updateData = ['status' => $containerStatus]; } else { - $database->update(['last_online_at' => now()]); + $updateData = ['last_online_at' => now()]; } + // Update restart tracking if restart count increased + if ($restartCount > $previousRestartCount) { + $updateData['restart_count'] = $restartCount; + $updateData['last_restart_at'] = now(); + $updateData['last_restart_type'] = 'crash'; + } + + $database->update($updateData); + if ($isPublic) { $foundTcpProxy = $this->containers->filter(function ($value, $key) use ($uuid) { if ($this->server->isSwarm()) { @@ -365,7 +379,13 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti if (str($database->status)->startsWith('exited')) { continue; } - $database->update(['status' => 'exited']); + // Reset restart tracking when database exits completely + $database->update([ + 'status' => 'exited', + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); $name = data_get($database, 'name'); $fqdn = data_get($database, 'fqdn'); diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 611e06b0b..e5a6e0c99 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -237,8 +237,9 @@ public function handle() $this->foundProxy = true; } elseif ($type === 'service' && $this->isRunning($containerStatus)) { } else { - if ($this->allDatabaseUuids->contains($uuid) && $this->isRunning($containerStatus)) { + if ($this->allDatabaseUuids->contains($uuid) && $this->isActiveOrTransient($containerStatus)) { $this->foundDatabaseUuids->push($uuid); + // TCP proxy should only be started/managed when database is actually running if ($this->allTcpProxyUuids->contains($uuid) && $this->isRunning($containerStatus)) { $this->updateDatabaseStatus($uuid, $containerStatus, tcpProxy: true); } else { @@ -503,20 +504,28 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta private function updateNotFoundDatabaseStatus() { $notFoundDatabaseUuids = $this->allDatabaseUuids->diff($this->foundDatabaseUuids); - if ($notFoundDatabaseUuids->isNotEmpty()) { - $notFoundDatabaseUuids->each(function ($databaseUuid) { - $database = $this->databases->where('uuid', $databaseUuid)->first(); - if ($database) { - if ($database->status !== 'exited') { - $database->status = 'exited'; - $database->save(); - } - if ($database->is_public) { - StopDatabaseProxy::dispatch($database); - } - } - }); + if ($notFoundDatabaseUuids->isEmpty()) { + return; } + + // Only protection: Verify we received any container data at all + // If containers collection is completely empty, Sentinel might have failed + if ($this->containers->isEmpty()) { + return; + } + + $notFoundDatabaseUuids->each(function ($databaseUuid) { + $database = $this->databases->where('uuid', $databaseUuid)->first(); + if ($database) { + if (! str($database->status)->startsWith('exited')) { + $database->status = 'exited'; + $database->save(); + } + if ($database->is_public) { + StopDatabaseProxy::dispatch($database); + } + } + }); } private function updateServiceSubStatus(string $serviceId, string $subType, string $subId, string $containerStatus) @@ -576,6 +585,23 @@ private function isRunning(string $containerStatus) return str($containerStatus)->contains('running'); } + /** + * Check if container is in an active or transient state. + * Active states: running + * Transient states: restarting, starting, created, paused + * + * These states indicate the container exists and should be tracked. + * Terminal states (exited, dead, removing) should NOT be tracked. + */ + private function isActiveOrTransient(string $containerStatus): bool + { + return str($containerStatus)->contains('running') || + str($containerStatus)->contains('restarting') || + str($containerStatus)->contains('starting') || + str($containerStatus)->contains('created') || + str($containerStatus)->contains('paused'); + } + private function checkLogDrainContainer() { if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index e1dd678ff..237076acc 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1314,11 +1314,6 @@ private function completeResourceCreation() 'server_id' => $this->selectedServerId, ]; - // PostgreSQL requires a database_image parameter - if ($this->selectedResourceType === 'postgresql') { - $queryParams['database_image'] = 'postgres:16-alpine'; - } - $this->redirect(route('project.resource.create', [ 'project_uuid' => $this->selectedProjectUuid, 'environment_uuid' => $this->selectedEnvironmentUuid, diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index 8c0ee1a3f..b0f5df0c8 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -117,6 +117,19 @@ public function getLogLinesProperty() }); } + public function copyLogs(): string + { + $logs = decode_remote_command_output($this->application_deployment_queue) + ->map(function ($line) { + return $line['timestamp'].' '. + (isset($line['command']) && $line['command'] ? '[CMD]: ' : ''). + trim($line['line']); + }) + ->join("\n"); + + return sanitizeLogsForExport($logs); + } + public function render() { return view('livewire.project.application.deployment.show'); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 0afcf94e6..c5dc13987 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -53,6 +53,8 @@ class Select extends Component protected $queryString = [ 'server_id', + 'type' => ['except' => ''], + 'destination_uuid' => ['except' => '', 'as' => 'destination'], ]; public function mount() @@ -66,6 +68,20 @@ public function mount() $project = Project::whereUuid($projectUuid)->firstOrFail(); $this->environments = $project->environments; $this->selectedEnvironment = $this->environments->where('uuid', data_get($this->parameters, 'environment_uuid'))->firstOrFail()->name; + + // Check if we have all required params for PostgreSQL type selection + // This handles navigation from global search + $queryType = request()->query('type'); + $queryServerId = request()->query('server_id'); + $queryDestination = request()->query('destination'); + + if ($queryType === 'postgresql' && $queryServerId !== null && $queryDestination) { + $this->type = $queryType; + $this->server_id = $queryServerId; + $this->destination_uuid = $queryDestination; + $this->server = Server::find($queryServerId); + $this->current_step = 'select-postgresql-type'; + } } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php index 1158fb3f7..966c66a14 100644 --- a/app/Livewire/Project/Resource/Create.php +++ b/app/Livewire/Project/Resource/Create.php @@ -35,6 +35,13 @@ public function mount() if (in_array($type, DATABASE_TYPES)) { if ($type->value() === 'postgresql') { + // PostgreSQL requires database_image to be explicitly set + // If not provided, fall through to Select component for version selection + if (! $database_image) { + $this->type = $type->value(); + + return; + } $database = create_standalone_postgresql( environmentId: $environment->id, destinationUuid: $destination_uuid, diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index f57563330..8fda35a4a 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -67,11 +67,6 @@ public function mount() } } - public function doSomethingWithThisChunkOfOutput($output) - { - $this->outputs .= removeAnsiColors($output); - } - public function instantSave() { if (! is_null($this->resource)) { @@ -162,23 +157,32 @@ public function getLogs($refresh = false) $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } - if ($refresh) { - $this->outputs = ''; - } - Process::run($sshCommand, function (string $type, string $output) { - $this->doSomethingWithThisChunkOfOutput($output); + // Collect new logs into temporary variable first to prevent flickering + // (avoids clearing output before new data is ready) + $newOutputs = ''; + Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) { + $newOutputs .= removeAnsiColors($output); }); + if ($this->showTimeStamps) { - $this->outputs = str($this->outputs)->split('/\n/')->sort(function ($a, $b) { + $newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) { $a = explode(' ', $a); $b = explode(' ', $b); return $a[0] <=> $b[0]; })->join("\n"); } + + // Only update outputs after new data is ready (atomic update prevents flicker) + $this->outputs = $newOutputs; } } + public function copyLogs(): string + { + return sanitizeLogsForExport($this->outputs); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index f549b43cb..a21b0372b 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -4,7 +4,6 @@ use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Collection; use Livewire\Component; class Resources extends Component @@ -15,7 +14,7 @@ class Resources extends Component public $parameters = []; - public Collection $containers; + public array $unmanagedContainers = []; public $activeTab = 'managed'; @@ -64,7 +63,7 @@ public function loadManagedContainers() { try { $this->activeTab = 'managed'; - $this->containers = $this->server->refresh()->definedResources(); + $this->server->refresh(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -74,7 +73,7 @@ public function loadUnmanagedContainers() { $this->activeTab = 'unmanaged'; try { - $this->containers = $this->server->loadUnmanagedContainers(); + $this->unmanagedContainers = $this->server->loadUnmanagedContainers()->toArray(); } catch (\Throwable $e) { return handleError($e, $this); } @@ -82,14 +81,12 @@ public function loadUnmanagedContainers() public function mount() { - $this->containers = collect(); $this->parameters = get_route_parameters(); try { $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->first(); if (is_null($this->server)) { return redirect()->route('server.index'); } - $this->loadManagedContainers(); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index b011d2dc1..fb9c91263 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -38,6 +38,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $disable_two_step_confirmation; + #[Validate('boolean')] + public bool $is_wire_navigate_enabled; + public function rules() { return [ @@ -50,6 +53,7 @@ public function rules() 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', 'disable_two_step_confirmation' => 'boolean', + 'is_wire_navigate_enabled' => 'boolean', ]; } @@ -68,6 +72,7 @@ public function mount() $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; + $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; } public function submit() @@ -146,6 +151,7 @@ public function instantSave() $this->settings->allowed_ips = $this->allowed_ips; $this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; + $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->save(); $this->dispatch('success', 'Settings updated!'); } catch (\Exception $e) { diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 36bee2a23..7948ad6a9 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -17,11 +17,14 @@ class Upgrade extends Component public string $currentVersion = ''; + public bool $devMode = false; + protected $listeners = ['updateAvailable' => 'checkUpdate']; public function mount() { $this->currentVersion = config('constants.coolify.version'); + $this->devMode = isDev(); } public function checkUpdate() diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 04ce6274a..7373fdb16 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -77,21 +77,21 @@ public function generate_preview_fqdn() if ($this->application->fqdn) { if (str($this->application->fqdn)->contains(',')) { $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); - $preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]); } else { $url = Url::fromString($this->application->fqdn); - if ($this->fqdn) { - $preview_fqdn = getFqdnWithoutPort($this->fqdn); - } } $template = $this->application->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); + $portInt = $url->getPort(); + $port = $portInt !== null ? ':'.$portInt : ''; + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; $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}{$path}"; $this->fqdn = $preview_fqdn; $this->save(); } @@ -147,11 +147,13 @@ public function generate_preview_fqdn_compose() $schema = $url->getScheme(); $portInt = $url->getPort(); $port = $portInt !== null ? ':'.$portInt : ''; + $urlPath = $url->getPath(); + $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; $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{$port}"; + $preview_fqdn = "$schema://$preview_fqdn{$port}{$path}"; $preview_domains[] = $preview_fqdn; } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 62b576012..376242ca0 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -29,6 +29,7 @@ class InstanceSettings extends Model 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', + 'is_wire_navigate_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index f598ef2ea..a76d55abb 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -18,6 +18,9 @@ class StandaloneClickhouse extends BaseModel protected $casts = [ 'clickhouse_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() @@ -25,7 +28,7 @@ protected static function booted() static::created(function ($database) { LocalPersistentVolume::create([ 'name' => 'clickhouse-data-'.$database->uuid, - 'mount_path' => '/bitnami/clickhouse', + 'mount_path' => '/var/lib/clickhouse', 'host_path' => null, 'resource_id' => $database->id, 'resource_type' => $database->getMorphClass(), @@ -246,8 +249,9 @@ protected function internalDbUrl(): Attribute get: function () { $encodedUser = rawurlencode($this->clickhouse_admin_user); $encodedPass = rawurlencode($this->clickhouse_admin_password); + $database = $this->clickhouse_db ?? 'default'; - return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$this->clickhouse_db}"; + return "clickhouse://{$encodedUser}:{$encodedPass}@{$this->uuid}:9000/{$database}"; }, ); } @@ -263,8 +267,9 @@ protected function externalDbUrl(): Attribute } $encodedUser = rawurlencode($this->clickhouse_admin_user); $encodedPass = rawurlencode($this->clickhouse_admin_password); + $database = $this->clickhouse_db ?? 'default'; - return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$this->clickhouse_db}"; + return "clickhouse://{$encodedUser}:{$encodedPass}@{$serverIp}:{$this->public_port}/{$database}"; } return null; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 47170056f..f5337b1d5 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -18,6 +18,9 @@ class StandaloneDragonfly extends BaseModel protected $casts = [ 'dragonfly_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 266110d0a..ab24cae2c 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -18,6 +18,9 @@ class StandaloneKeydb extends BaseModel protected $casts = [ 'keydb_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index aa7f2d31a..e48cfc1e6 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -19,6 +19,9 @@ class StandaloneMariadb extends BaseModel protected $casts = [ 'mariadb_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 9046ab013..9e271b19a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -16,6 +16,12 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + protected $casts = [ + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', + ]; + protected static function booted() { static::created(function ($database) { diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 719387b36..377765697 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -19,6 +19,9 @@ class StandaloneMysql extends BaseModel protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 03080fd3d..d9993426a 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -19,6 +19,9 @@ class StandalonePostgresql extends BaseModel protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', ]; protected static function booted() diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 6aca8af9a..684bcaeb7 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -16,6 +16,12 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + protected $casts = [ + 'restart_count' => 'integer', + 'last_restart_at' => 'datetime', + 'last_restart_type' => 'string', + ]; + protected static function booted() { static::created(function ($database) { diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index bdfbaba48..fc70aa7da 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -269,9 +269,41 @@ function remove_iip($text) // Ensure the input is valid UTF-8 before processing $text = sanitize_utf8_text($text); + // Git access tokens $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); - return preg_replace('/\x1b\[[0-9;]*m/', '', $text); + // ANSI color codes + $text = preg_replace('/\x1b\[[0-9;]*m/', '', $text); + + // Generic URLs with passwords (covers database URLs, ftp, amqp, ssh, etc.) + // (protocol://user:password@host → protocol://user:@host) + $text = preg_replace('/((?:postgres|mysql|mongodb|rediss?|mariadb|ftp|sftp|ssh|amqp|amqps|ldap|ldaps|s3):\/\/[^:]+:)[^@]+(@)/i', '$1'.REDACTED.'$2', $text); + + // Email addresses + $text = preg_replace('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', REDACTED, $text); + + // Bearer/JWT tokens + $text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text); + + // GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh) + $text = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{36,})\b/', REDACTED, $text); + + // GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token) + $text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text); + + // AWS credentials (Access Key ID starts with AKIA, ABIA, ACCA, ASIA) + $text = preg_replace('/\b(A(?:KIA|BIA|CCA|SIA)[A-Z0-9]{16})\b/', REDACTED, $text); + + // AWS Secret Access Key (40 character base64-ish string, typically follows access key) + $text = preg_replace('/(aws_secret_access_key|AWS_SECRET_ACCESS_KEY)[=:]\s*[\'"]?([A-Za-z0-9\/+=]{40})[\'"]?/i', '$1='.REDACTED, $text); + + // API keys (common patterns) + $text = preg_replace('/(api[_-]?key|apikey|api[_-]?secret|secret[_-]?key)[=:]\s*[\'"]?[A-Za-z0-9\-_]{16,}[\'"]?/i', '$1='.REDACTED, $text); + + // Private key blocks + $text = preg_replace('/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/', REDACTED, $text); + + return $text; } /** diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 670716164..e73328474 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -672,6 +672,12 @@ function removeAnsiColors($text) return preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $text); } +function sanitizeLogsForExport(string $text): string +{ + // All sanitization is now handled by remove_iip() + return remove_iip($text); +} + function getTopLevelNetworks(Service|Application $resource) { if ($resource->getMorphClass() === \App\Models\Service::class) { @@ -2916,6 +2922,18 @@ function instanceSettings() return InstanceSettings::get(); } +function wireNavigate(): string +{ + try { + $settings = instanceSettings(); + + // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled + return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : ''; + } catch (\Exception $e) { + return 'wire:navigate.hover'; + } +} + function getHelperVersion(): string { $settings = instanceSettings(); diff --git a/config/constants.php b/config/constants.php index d9734c48e..807dc88e0 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.455', + 'version' => '4.0.0-beta.456', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/database/migrations/2025_11_28_000001_migrate_clickhouse_to_official_image.php b/database/migrations/2025_11_28_000001_migrate_clickhouse_to_official_image.php new file mode 100644 index 000000000..56167496c --- /dev/null +++ b/database/migrations/2025_11_28_000001_migrate_clickhouse_to_official_image.php @@ -0,0 +1,69 @@ +string('clickhouse_db') + ->default('default') + ->after('clickhouse_admin_password'); + }); + } + + // Change the default value for the 'image' column to the official image + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('clickhouse/clickhouse-server:25.11')->change(); + }); + + // Update existing ClickHouse instances from Bitnami images to official image + StandaloneClickhouse::where(function ($query) { + $query->where('image', 'like', '%bitnami/clickhouse%') + ->orWhere('image', 'like', '%bitnamilegacy/clickhouse%'); + }) + ->update([ + 'image' => 'clickhouse/clickhouse-server:25.11', + 'clickhouse_db' => DB::raw("COALESCE(clickhouse_db, 'default')"), + ]); + + // Update volume mount paths from Bitnami to official image paths + LocalPersistentVolume::where('resource_type', StandaloneClickhouse::class) + ->where('mount_path', '/bitnami/clickhouse') + ->update(['mount_path' => '/var/lib/clickhouse']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Revert the default value for the 'image' column + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + + // Revert existing ClickHouse instances back to Bitnami image + StandaloneClickhouse::where('image', 'clickhouse/clickhouse-server:25.11') + ->update(['image' => 'bitnamilegacy/clickhouse']); + + // Revert volume mount paths + LocalPersistentVolume::where('resource_type', StandaloneClickhouse::class) + ->where('mount_path', '/var/lib/clickhouse') + ->update(['mount_path' => '/bitnami/clickhouse']); + } +}; diff --git a/database/migrations/2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table.php b/database/migrations/2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table.php new file mode 100644 index 000000000..9c89dab93 --- /dev/null +++ b/database/migrations/2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_wire_navigate_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_wire_navigate_enabled'); + }); + } +}; diff --git a/database/migrations/2025_12_17_000002_add_restart_tracking_to_standalone_databases.php b/database/migrations/2025_12_17_000002_add_restart_tracking_to_standalone_databases.php new file mode 100644 index 000000000..2798affd4 --- /dev/null +++ b/database/migrations/2025_12_17_000002_add_restart_tracking_to_standalone_databases.php @@ -0,0 +1,66 @@ +tables as $table) { + if (! Schema::hasColumn($table, 'restart_count')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->integer('restart_count')->default(0)->after('status'); + }); + } + + if (! Schema::hasColumn($table, 'last_restart_at')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->timestamp('last_restart_at')->nullable()->after('restart_count'); + }); + } + + if (! Schema::hasColumn($table, 'last_restart_type')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->string('last_restart_type', 10)->nullable()->after('last_restart_at'); + }); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $columns = ['restart_count', 'last_restart_at', 'last_restart_type']; + + foreach ($this->tables as $table) { + foreach ($columns as $column) { + if (Schema::hasColumn($table, $column)) { + Schema::table($table, function (Blueprint $blueprint) use ($column) { + $blueprint->dropColumn($column); + }); + } + } + } + } +}; diff --git a/other/nightly/install.sh b/other/nightly/install.sh index b037fe382..7d2a78541 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -29,9 +29,14 @@ if [ $EUID != 0 ]; then exit fi -echo -e "Welcome to Coolify Installer!" -echo -e "This script will install everything for you. Sit back and relax." -echo -e "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" +echo "" +echo "==========================================" +echo " Coolify Installation - ${DATE}" +echo "==========================================" +echo "" +echo "Welcome to Coolify Installer!" +echo "This script will install everything for you. Sit back and relax." +echo "Source code: https://github.com/coollabsio/coolify/blob/v4.x/scripts/install.sh" # Predefined root user ROOT_USERNAME=${ROOT_USERNAME:-} @@ -242,6 +247,29 @@ getAJoke() { fi } +# Helper function to log with timestamp +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +# Helper function to log section headers +log_section() { + echo "" + echo "============================================================" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" + echo "============================================================" +} + +# Helper function to check if all required packages are installed +all_packages_installed() { + for pkg in curl wget git jq openssl; do + if ! command -v "$pkg" >/dev/null 2>&1; then + return 1 + fi + done + return 0 +} + # Check if the OS is manjaro, if so, change it to arch if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then OS_TYPE="arch" @@ -288,9 +316,11 @@ if [ "$OS_TYPE" = 'amzn' ]; then dnf install -y findutils >/dev/null fi -LATEST_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',') -LATEST_HELPER_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',') -LATEST_REALTIME_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',') +# Fetch versions.json once and parse all values from it +VERSIONS_JSON=$(curl -L --silent $CDN/versions.json) +LATEST_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $2}' | tr -d ',') +LATEST_HELPER_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $6}' | tr -d ',') +LATEST_REALTIME_VERSION=$(echo "$VERSIONS_JSON" | grep -i version | xargs | awk '{print $8}' | tr -d ',') if [ -z "$LATEST_HELPER_VERSION" ]; then LATEST_HELPER_VERSION=latest @@ -315,7 +345,7 @@ if [ "$1" != "" ]; then LATEST_VERSION="${LATEST_VERSION#v}" fi -echo -e "---------------------------------------------" +echo "---------------------------------------------" echo "| Operating System | $OS_TYPE $OS_VERSION" echo "| Docker | $DOCKER_VERSION" echo "| Coolify | $LATEST_VERSION" @@ -323,46 +353,61 @@ echo "| Helper | $LATEST_HELPER_VERSION" echo "| Realtime | $LATEST_REALTIME_VERSION" echo "| Docker Pool | $DOCKER_ADDRESS_POOL_BASE (size $DOCKER_ADDRESS_POOL_SIZE)" echo "| Registry URL | $REGISTRY_URL" -echo -e "---------------------------------------------\n" -echo -e "1. Installing required packages (curl, wget, git, jq, openssl). " +echo "---------------------------------------------" +echo "" -case "$OS_TYPE" in -arch) - pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true - ;; -alpine) - sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories - apk update >/dev/null - apk add curl wget git jq openssl >/dev/null - ;; -ubuntu | debian | raspbian) - apt-get update -y >/dev/null - apt-get install -y curl wget git jq openssl >/dev/null - ;; -centos | fedora | rhel | ol | rocky | almalinux | amzn) - if [ "$OS_TYPE" = "amzn" ]; then - dnf install -y wget git jq openssl >/dev/null - else - if ! command -v dnf >/dev/null; then - yum install -y dnf >/dev/null - fi - if ! command -v curl >/dev/null; then - dnf install -y curl >/dev/null - fi - dnf install -y wget git jq openssl >/dev/null - fi - ;; -sles | opensuse-leap | opensuse-tumbleweed) - zypper refresh >/dev/null - zypper install -y curl wget git jq openssl >/dev/null - ;; -*) - echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now." - exit - ;; -esac +log_section "Step 1/9: Installing required packages" +echo "1/9 Installing required packages (curl, wget, git, jq, openssl)..." -echo -e "2. Check OpenSSH server configuration. " +# Track if apt-get update was run to avoid redundant calls later +APT_UPDATED=false + +if all_packages_installed; then + log "All required packages already installed, skipping installation" + echo " - All required packages already installed." +else + case "$OS_TYPE" in + arch) + pacman -Sy --noconfirm --needed curl wget git jq openssl >/dev/null || true + ;; + alpine) + sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories + apk update >/dev/null + apk add curl wget git jq openssl >/dev/null + ;; + ubuntu | debian | raspbian) + apt-get update -y >/dev/null + APT_UPDATED=true + apt-get install -y curl wget git jq openssl >/dev/null + ;; + centos | fedora | rhel | ol | rocky | almalinux | amzn) + if [ "$OS_TYPE" = "amzn" ]; then + dnf install -y wget git jq openssl >/dev/null + else + if ! command -v dnf >/dev/null; then + yum install -y dnf >/dev/null + fi + if ! command -v curl >/dev/null; then + dnf install -y curl >/dev/null + fi + dnf install -y wget git jq openssl >/dev/null + fi + ;; + sles | opensuse-leap | opensuse-tumbleweed) + zypper refresh >/dev/null + zypper install -y curl wget git jq openssl >/dev/null + ;; + *) + echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now." + exit + ;; + esac + log "Required packages installed successfully" +fi +echo " Done." + +log_section "Step 2/9: Checking OpenSSH server configuration" +echo "2/9 Checking OpenSSH server configuration..." # Detect OpenSSH server SSH_DETECTED=false @@ -398,7 +443,10 @@ if [ "$SSH_DETECTED" = "false" ]; then service sshd start >/dev/null 2>&1 ;; ubuntu | debian | raspbian) - apt-get update -y >/dev/null + if [ "$APT_UPDATED" = false ]; then + apt-get update -y >/dev/null + APT_UPDATED=true + fi apt-get install -y openssh-server >/dev/null systemctl enable ssh >/dev/null 2>&1 systemctl start ssh >/dev/null 2>&1 @@ -465,7 +513,10 @@ install_docker() { install_docker_manually() { case "$OS_TYPE" in "ubuntu" | "debian" | "raspbian") - apt-get update + if [ "$APT_UPDATED" = false ]; then + apt-get update + APT_UPDATED=true + fi apt-get install -y ca-certificates curl install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/$OS_TYPE/gpg -o /etc/apt/keyrings/docker.asc @@ -491,7 +542,8 @@ install_docker_manually() { echo "Docker installed successfully." fi } -echo -e "3. Check Docker Installation. " +log_section "Step 3/9: Checking Docker installation" +echo "3/9 Checking Docker installation..." if ! [ -x "$(command -v docker)" ]; then echo " - Docker is not installed. Installing Docker. It may take a while." getAJoke @@ -575,7 +627,8 @@ else echo " - Docker is installed." fi -echo -e "4. Check Docker Configuration. " +log_section "Step 4/9: Checking Docker configuration" +echo "4/9 Checking Docker configuration..." echo " - Network pool configuration: ${DOCKER_ADDRESS_POOL_BASE}/${DOCKER_ADDRESS_POOL_SIZE}" echo " - To override existing configuration: DOCKER_POOL_FORCE_OVERRIDE=true" @@ -704,13 +757,38 @@ else fi fi -echo -e "5. Download required files from CDN. " -curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml -curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml -curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production -curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh +log_section "Step 5/9: Downloading required files from CDN" +echo "5/9 Downloading required files from CDN..." +log "Downloading configuration files in parallel..." -echo -e "6. Setting up environment variable file" +# Download files in parallel for faster installation +curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml & +PID1=$! +curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml & +PID2=$! +curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production & +PID3=$! +curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh & +PID4=$! + +# Wait for all downloads to complete and check for errors +DOWNLOAD_FAILED=false +for PID in $PID1 $PID2 $PID3 $PID4; do + if ! wait $PID; then + DOWNLOAD_FAILED=true + fi +done + +if [ "$DOWNLOAD_FAILED" = true ]; then + echo " - ERROR: One or more downloads failed. Please check your network connection." + exit 1 +fi + +log "All configuration files downloaded successfully" +echo " Done." + +log_section "Step 6/9: Setting up environment variable file" +echo "6/9 Setting up environment variable file..." if [ -f "$ENV_FILE" ]; then # If .env exists, create backup @@ -725,8 +803,11 @@ else echo " - No .env file found, copying .env.production to .env" cp "/data/coolify/source/.env.production" "$ENV_FILE" fi +log "Environment file setup completed" +echo " Done." -echo -e "7. Checking and updating environment variables if necessary..." +log_section "Step 7/9: Checking and updating environment variables" +echo "7/9 Checking and updating environment variables..." update_env_var() { local key="$1" @@ -786,8 +867,11 @@ else update_env_var "DOCKER_ADDRESS_POOL_SIZE" "$DOCKER_ADDRESS_POOL_SIZE" fi fi +log "Environment variables check completed" +echo " Done." -echo -e "8. Checking for SSH key for localhost access." +log_section "Step 8/9: Checking SSH key for localhost access" +echo "8/9 Checking SSH key for localhost access..." if [ ! -f ~/.ssh/authorized_keys ]; then mkdir -p ~/.ssh chmod 700 ~/.ssh @@ -812,8 +896,11 @@ fi chown -R 9999:root /data/coolify chmod -R 700 /data/coolify +log "SSH key check completed" +echo " Done." -echo -e "9. Installing Coolify ($LATEST_VERSION)" +log_section "Step 9/9: Installing Coolify" +echo "9/9 Installing Coolify ($LATEST_VERSION)..." echo -e " - It could take a while based on your server's performance, network speed, stars, etc." echo -e " - Please wait." getAJoke @@ -824,11 +911,85 @@ else bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" fi echo " - Coolify installed successfully." +echo " - Waiting for Coolify to be ready..." -echo " - Waiting 20 seconds for Coolify database migrations to complete." -getAJoke +# Wait for upgrade.sh background process to complete +# upgrade.sh writes status to /data/coolify/source/.upgrade-status +# Status file format: step|message|timestamp +# Step 6 = "Upgrade complete", file deleted 10 seconds after +UPGRADE_STATUS_FILE="/data/coolify/source/.upgrade-status" +MAX_WAIT=180 +WAITED=0 +SEEN_STATUS_FILE=false -sleep 20 +while [ $WAITED -lt $MAX_WAIT ]; do + if [ -f "$UPGRADE_STATUS_FILE" ]; then + SEEN_STATUS_FILE=true + STATUS=$(cat "$UPGRADE_STATUS_FILE" 2>/dev/null | cut -d'|' -f1) + MESSAGE=$(cat "$UPGRADE_STATUS_FILE" 2>/dev/null | cut -d'|' -f2) + if [ "$STATUS" = "6" ]; then + log "Upgrade completed: $MESSAGE" + echo " - Upgrade complete!" + break + elif [ "$STATUS" = "error" ]; then + echo " - ERROR: Upgrade failed: $MESSAGE" + echo " - Please check the upgrade logs: /data/coolify/source/upgrade-*.log" + exit 1 + else + if [ $((WAITED % 10)) -eq 0 ]; then + echo " - Upgrade in progress: $MESSAGE (${WAITED}s)" + fi + fi + else + # Status file doesn't exist + if [ "$SEEN_STATUS_FILE" = true ]; then + # We saw the file before, now it's gone = upgrade completed and cleaned up + log "Upgrade status file cleaned up - upgrade complete" + echo " - Upgrade complete!" + break + fi + # Haven't seen status file yet - either very early or upgrade.sh hasn't started + if [ $((WAITED % 10)) -eq 0 ] && [ $WAITED -gt 0 ]; then + echo " - Waiting for upgrade process to start... (${WAITED}s)" + fi + fi + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ $WAITED -ge $MAX_WAIT ]; then + if [ "$SEEN_STATUS_FILE" = false ]; then + # Never saw status file - fallback to old behavior (wait 20s + health check) + log "Status file not found, using fallback wait" + echo " - Status file not found, waiting 20 seconds..." + sleep 20 + else + echo " - ERROR: Upgrade timed out after ${MAX_WAIT}s" + echo " - Please check the upgrade logs: /data/coolify/source/upgrade-*.log" + exit 1 + fi +fi + +# Final health verification - wait for container to be healthy +echo " - Verifying Coolify is healthy..." +HEALTH_WAIT=60 +HEALTH_WAITED=0 +while [ $HEALTH_WAITED -lt $HEALTH_WAIT ]; do + HEALTH=$(docker inspect --format='{{.State.Health.Status}}' coolify 2>/dev/null || echo "unknown") + if [ "$HEALTH" = "healthy" ]; then + log "Coolify container is healthy" + echo " - Coolify is ready!" + break + fi + sleep 2 + HEALTH_WAITED=$((HEALTH_WAITED + 2)) +done + +if [ "$HEALTH" != "healthy" ]; then + echo " - ERROR: Coolify container is not healthy after ${HEALTH_WAIT}s. Status: $HEALTH" + echo " - Please check: docker logs coolify" + exit 1 +fi echo -e "\033[0;35m ____ _ _ _ _ _ / ___|___ _ __ __ _ _ __ __ _| |_ _ _| | __ _| |_(_) ___ _ __ ___| | @@ -838,8 +999,18 @@ echo -e "\033[0;35m |___/ \033[0m" -IPV4_PUBLIC_IP=$(curl -4s https://ifconfig.io || true) -IPV6_PUBLIC_IP=$(curl -6s https://ifconfig.io || true) +# Fetch public IPs in parallel for faster completion +IPV4_TMP=$(mktemp) +IPV6_TMP=$(mktemp) +curl -4s --max-time 5 https://ifconfig.io > "$IPV4_TMP" 2>/dev/null & +IPV4_PID=$! +curl -6s --max-time 5 https://ifconfig.io > "$IPV6_TMP" 2>/dev/null & +IPV6_PID=$! +wait $IPV4_PID 2>/dev/null || true +wait $IPV6_PID 2>/dev/null || true +IPV4_PUBLIC_IP=$(cat "$IPV4_TMP" 2>/dev/null || true) +IPV6_PUBLIC_IP=$(cat "$IPV6_TMP" 2>/dev/null || true) +rm -f "$IPV4_TMP" "$IPV6_TMP" echo -e "\nYour instance is ready to use!\n" if [ -n "$IPV4_PUBLIC_IP" ]; then @@ -864,3 +1035,8 @@ if [ -n "$PRIVATE_IPS" ]; then fi echo -e "\nWARNING: It is highly recommended to backup your Environment variables file (/data/coolify/source/.env) to a safe location, outside of this server (e.g. into a Password Manager).\n" + +log_section "Installation Complete" +log "Coolify installation completed successfully" +log "Version: ${LATEST_VERSION}" +log "Log file: ${INSTALLATION_LOG_WITH_DATE}" diff --git a/other/nightly/upgrade.sh b/other/nightly/upgrade.sh index 0d3896647..a21d39e41 100644 --- a/other/nightly/upgrade.sh +++ b/other/nightly/upgrade.sh @@ -7,27 +7,101 @@ LATEST_HELPER_VERSION=${2:-latest} REGISTRY_URL=${3:-ghcr.io} SKIP_BACKUP=${4:-false} ENV_FILE="/data/coolify/source/.env" +STATUS_FILE="/data/coolify/source/.upgrade-status" DATE=$(date +%Y-%m-%d-%H-%M-%S) LOGFILE="/data/coolify/source/upgrade-${DATE}.log" -curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml -curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml -curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production +# Helper function to log with timestamp +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE" +} + +# Helper function to log section headers +log_section() { + echo "" >>"$LOGFILE" + echo "============================================================" >>"$LOGFILE" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE" + echo "============================================================" >>"$LOGFILE" +} + +# Helper function to write upgrade status for API polling +write_status() { + local step="$1" + local message="$2" + echo "${step}|${message}|$(date -Iseconds)" > "$STATUS_FILE" +} + +echo "" +echo "==========================================" +echo " Coolify Upgrade - ${DATE}" +echo "==========================================" +echo "" + +# Initialize log file with header +echo "============================================================" >>"$LOGFILE" +echo "Coolify Upgrade Log" >>"$LOGFILE" +echo "Started: $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOGFILE" +echo "Target Version: ${LATEST_IMAGE}" >>"$LOGFILE" +echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE" +echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE" +echo "============================================================" >>"$LOGFILE" + +log_section "Step 1/6: Downloading configuration files" +write_status "1" "Downloading configuration files" +echo "1/6 Downloading latest configuration files..." +log "Downloading docker-compose.yml from ${CDN}/docker-compose.yml" +curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml +log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml" +curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml +log "Downloading .env.production from ${CDN}/.env.production" +curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production +log "Configuration files downloaded successfully" +echo " Done." + +# Extract all images from docker-compose configuration +log "Extracting all images from docker-compose configuration..." +COMPOSE_FILES="-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml" + +# Check if custom compose file exists +if [ -f /data/coolify/source/docker-compose.custom.yml ]; then + COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml" + log "Including custom docker-compose.yml in image extraction" +fi + +# Get all unique images from docker compose config +# LATEST_IMAGE env var is needed for image substitution in compose files +IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) + +if [ -z "$IMAGES" ]; then + log "ERROR: Failed to extract images from docker-compose files" + write_status "error" "Failed to parse docker-compose configuration" + echo " ERROR: Failed to parse docker-compose configuration. Aborting upgrade." + exit 1 +fi + +log "Images to pull:" +echo "$IMAGES" | while read img; do log " - $img"; done # Backup existing .env file before making any changes if [ "$SKIP_BACKUP" != "true" ]; then if [ -f "$ENV_FILE" ]; then - echo "Creating backup of existing .env file to .env-$DATE" >>"$LOGFILE" + echo " Creating backup of .env file..." + log "Creating backup of .env file to .env-$DATE" cp "$ENV_FILE" "$ENV_FILE-$DATE" + log "Backup created: ${ENV_FILE}-${DATE}" else - echo "No existing .env file found to backup" >>"$LOGFILE" + log "WARNING: No existing .env file found to backup" fi fi -echo "Merging .env.production values into .env" >>"$LOGFILE" +log_section "Step 2/6: Updating environment configuration" +write_status "2" "Updating environment configuration" +echo "" +echo "2/6 Updating environment configuration..." +log "Merging .env.production values into .env" awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE" -echo ".env file merged successfully" >>"$LOGFILE" +log "Environment file merged successfully" update_env_var() { local key="$1" @@ -36,73 +110,173 @@ update_env_var() { # If variable "key=" exists but has no value, update the value of the existing line if grep -q "^${key}=$" "$ENV_FILE"; then sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE" - echo " - Updated value of ${key} as the current value was empty" >>"$LOGFILE" + log "Updated ${key} (was empty)" # If variable "key=" doesn't exist, append it to the file with value elif ! grep -q "^${key}=" "$ENV_FILE"; then printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE" - echo " - Added ${key} with default value as the variable was missing" >>"$LOGFILE" + log "Added ${key} (was missing)" fi } -echo "Checking and updating environment variables if necessary..." >>"$LOGFILE" +log "Checking environment variables..." update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" +log "Environment variables check complete" +echo " Done." # Make sure coolify network exists # It is created when starting Coolify with docker compose +log "Checking Docker network 'coolify'..." if ! docker network inspect coolify >/dev/null 2>&1; then + log "Network 'coolify' does not exist, creating..." if ! docker network create --attachable --ipv6 coolify 2>/dev/null; then - echo "Failed to create coolify network with ipv6. Trying without ipv6..." + log "Failed to create network with IPv6, trying without IPv6..." docker network create --attachable coolify 2>/dev/null + log "Network 'coolify' created without IPv6" + else + log "Network 'coolify' created with IPv6 support" fi +else + log "Network 'coolify' already exists" fi # Check if Docker config file exists DOCKER_CONFIG_MOUNT="" if [ -f /root/.docker/config.json ]; then DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json" + log "Docker config mount enabled: /root/.docker/config.json" fi -# Pull all required images before stopping containers -# This ensures we don't take down the system if image pull fails (rate limits, network issues, etc.) -echo "Pulling required Docker images..." >>"$LOGFILE" -docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify image. Aborting upgrade." >>"$LOGFILE"; exit 1; } -docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify helper image. Aborting upgrade." >>"$LOGFILE"; exit 1; } -docker pull postgres:15-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull PostgreSQL image. Aborting upgrade." >>"$LOGFILE"; exit 1; } -docker pull redis:7-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull Redis image. Aborting upgrade." >>"$LOGFILE"; exit 1; } -# Pull realtime image - version is hardcoded in docker-compose.prod.yml, extract it or use a known version -docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify realtime image. Aborting upgrade." >>"$LOGFILE"; exit 1; } -echo "All images pulled successfully." >>"$LOGFILE" +log_section "Step 3/6: Pulling Docker images" +write_status "3" "Pulling Docker images" +echo "" +echo "3/6 Pulling Docker images..." +echo " This may take a few minutes depending on your connection." -# Stop and remove existing Coolify containers to prevent conflicts -# This handles both old installations (project "source") and new ones (project "coolify") -# Use nohup to ensure the script continues even if SSH connection is lost -echo "Starting container restart sequence (detached)..." >>"$LOGFILE" +# Also pull the helper image (not in compose files but needed for upgrade) +HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" +echo " - Pulling $HELPER_IMAGE..." +log "Pulling image: $HELPER_IMAGE" +if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then + log "Successfully pulled $HELPER_IMAGE" +else + log "ERROR: Failed to pull $HELPER_IMAGE" + write_status "error" "Failed to pull $HELPER_IMAGE" + echo " ERROR: Failed to pull $HELPER_IMAGE. Aborting upgrade." + exit 1 +fi + +# Pull all images from compose config +# Using a for loop to avoid subshell issues with exit +for IMAGE in $IMAGES; do + if [ -n "$IMAGE" ]; then + echo " - Pulling $IMAGE..." + log "Pulling image: $IMAGE" + if docker pull "$IMAGE" >>"$LOGFILE" 2>&1; then + log "Successfully pulled $IMAGE" + else + log "ERROR: Failed to pull $IMAGE" + write_status "error" "Failed to pull $IMAGE" + echo " ERROR: Failed to pull $IMAGE. Aborting upgrade." + exit 1 + fi + fi +done + +log "All images pulled successfully" +echo " All images pulled successfully." + +log_section "Step 4/6: Stopping and restarting containers" +write_status "4" "Stopping containers" +echo "" +echo "4/6 Stopping containers and starting new ones..." +echo " This step will restart all Coolify containers." +echo " Check the log file for details: ${LOGFILE}" + +# From this point forward, we need to ensure the script continues even if +# the SSH connection is lost (which happens when coolify container stops) +# We use a subshell with nohup to ensure completion +log "Starting container restart sequence (detached)..." nohup bash -c " LOGFILE='$LOGFILE' + STATUS_FILE='$STATUS_FILE' DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT' REGISTRY_URL='$REGISTRY_URL' LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION' LATEST_IMAGE='$LATEST_IMAGE' + log() { + echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] \$1\" >>\"\$LOGFILE\" + } + + write_status() { + echo \"\$1|\$2|\$(date -Iseconds)\" > \"\$STATUS_FILE\" + } + # Stop and remove containers - echo 'Stopping existing Coolify containers...' >>\"\$LOGFILE\" for container in coolify coolify-db coolify-redis coolify-realtime; do if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then + log \"Stopping container: \${container}\" docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true + log \"Removing container: \${container}\" docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true - echo \" - Removed container: \$container\" >>\"\$LOGFILE\" + log \"Container \${container} stopped and removed\" + else + log \"Container \${container} not found (skipping)\" fi done + log \"Container cleanup complete\" # Start new containers + echo '' >>\"\$LOGFILE\" + echo '============================================================' >>\"\$LOGFILE\" + log 'Step 5/6: Starting new containers' + echo '============================================================' >>\"\$LOGFILE\" + write_status '5' 'Starting new containers' + if [ -f /data/coolify/source/docker-compose.custom.yml ]; then - echo 'docker-compose.custom.yml detected.' >>\"\$LOGFILE\" - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + log 'Using custom docker-compose.yml' + log 'Running docker compose up with custom configuration...' + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 else - docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 + log 'Using standard docker-compose configuration' + log 'Running docker compose up...' + docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 fi - echo 'Upgrade completed.' >>\"\$LOGFILE\" + log 'Docker compose up completed' + + # Final log entry + echo '' >>\"\$LOGFILE\" + echo '============================================================' >>\"\$LOGFILE\" + log 'Step 6/6: Upgrade complete' + echo '============================================================' >>\"\$LOGFILE\" + write_status '6' 'Upgrade complete' + log 'Coolify upgrade completed successfully' + log \"Version: \${LATEST_IMAGE}\" + echo '' >>\"\$LOGFILE\" + echo '============================================================' >>\"\$LOGFILE\" + echo \"Upgrade completed: \$(date '+%Y-%m-%d %H:%M:%S')\" >>\"\$LOGFILE\" + echo '============================================================' >>\"\$LOGFILE\" + + # Clean up status file after a short delay to allow frontend to read completion + sleep 10 + rm -f \"\$STATUS_FILE\" + log 'Status file cleaned up' " >>"$LOGFILE" 2>&1 & + +# Give the background process a moment to start +sleep 2 +log "Container restart sequence started in background (PID: $!)" +echo "" +echo "5/6 Containers are being restarted in the background..." +echo "6/6 Upgrade process initiated!" +echo "" +echo "==========================================" +echo " Coolify upgrade to ${LATEST_IMAGE} in progress" +echo "==========================================" +echo "" +echo " The upgrade will continue in the background." +echo " Coolify will be available again shortly." +echo " Log file: ${LOGFILE}" diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 94c23ede4..9a5570f41 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.455" + "version": "4.0.0-beta.456" }, "nightly": { - "version": "4.0.0-beta.456" + "version": "4.0.0-beta.457" }, "helper": { "version": "1.0.12" @@ -17,13 +17,13 @@ } }, "traefik": { - "v3.6": "3.6.1", + "v3.6": "3.6.5", "v3.5": "3.5.6", "v3.4": "3.4.5", "v3.3": "3.3.7", "v3.2": "3.2.5", "v3.1": "3.1.7", "v3.0": "3.0.4", - "v2.11": "2.11.31" + "v2.11": "2.11.32" } } \ No newline at end of file diff --git a/public/svgs/appflowy.svg b/public/svgs/appflowy.svg new file mode 100644 index 000000000..7853ed36e --- /dev/null +++ b/public/svgs/appflowy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/resources/css/utilities.css b/resources/css/utilities.css index abb177835..7978b2d19 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -292,3 +292,31 @@ @utility dz-button { @utility xterm { @apply p-2; } + +/* Log line optimization - uses content-visibility for lazy rendering of off-screen log lines */ +@utility log-line { + content-visibility: auto; + contain-intrinsic-size: auto 1.5em; +} + +/* Search highlight styling for logs */ +@utility log-highlight { + @apply bg-warning/40 dark:bg-warning/30; +} + +/* Log level color classes */ +@utility log-error { + @apply bg-red-500/10 dark:bg-red-500/15; +} + +@utility log-warning { + @apply bg-yellow-500/10 dark:bg-yellow-500/15; +} + +@utility log-debug { + @apply bg-purple-500/10 dark:bg-purple-500/15; +} + +@utility log-info { + @apply bg-blue-500/10 dark:bg-blue-500/15; +} diff --git a/resources/views/components/limit-reached.blade.php b/resources/views/components/limit-reached.blade.php index d53dae3f3..1fc26bbe0 100644 --- a/resources/views/components/limit-reached.blade.php +++ b/resources/views/components/limit-reached.blade.php @@ -1,6 +1,6 @@
You have reached the limit of {{ $name }} you can create. - Please upgrade your + Please upgrade your subscription to create more {{ $name }}.
diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index 84502872e..e351a4480 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -79,7 +79,7 @@ }">
@@ -105,7 +105,7 @@ class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400