diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php index cc1bf15b9..887fff35a 100644 --- a/app/Livewire/Project/Application/Configuration.php +++ b/app/Livewire/Project/Application/Configuration.php @@ -17,17 +17,10 @@ class Configuration extends Component public $servers; - public function getListeners() - { - $teamId = auth()->user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh', - 'buildPackUpdated' => '$refresh', - 'refresh' => '$refresh', - ]; - } + protected $listeners = [ + 'buildPackUpdated' => '$refresh', + 'refresh' => '$refresh', + ]; public function mount() { @@ -51,8 +44,6 @@ public function mount() $this->environment = $environment; $this->application = $application; - - if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') { return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]); } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index 2583c10ea..edcb31f5e 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -48,13 +48,26 @@ class General extends Component public function getListeners() { + $userId = Auth::id(); $teamId = Auth::user()->currentTeam()->id; return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Configuration.php b/app/Livewire/Project/Database/Configuration.php index 7c64a6eef..9f952ff2b 100644 --- a/app/Livewire/Project/Database/Configuration.php +++ b/app/Livewire/Project/Database/Configuration.php @@ -2,8 +2,9 @@ namespace App\Livewire\Project\Database; -use Auth; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\ItemNotFoundException; use Livewire\Component; class Configuration extends Component @@ -18,15 +19,6 @@ class Configuration extends Component public $environment; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => '$refresh', - ]; - } - public function mount() { try { @@ -55,10 +47,10 @@ public function mount() $this->dispatch('configurationChanged'); } } catch (\Throwable $e) { - if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) { + if ($e instanceof AuthorizationException) { return redirect()->route('dashboard'); } - if ($e instanceof \Illuminate\Support\ItemNotFoundException) { + if ($e instanceof ItemNotFoundException) { return redirect()->route('dashboard'); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 9e1ea0d10..ae8ec9476 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -57,11 +57,22 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 1cdc681cd..0c19709a5 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -5,9 +5,17 @@ use App\Models\S3Storage; use App\Models\Server; use App\Models\Service; +use App\Models\ServiceDatabase; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -192,15 +200,9 @@ public function server() return Server::ownedByCurrentTeam()->find($this->serverId); } - public function getListeners() - { - $userId = Auth::id(); - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', - 'slideOverClosed' => 'resetActivityId', - ]; - } + protected $listeners = [ + 'slideOverClosed' => 'resetActivityId', + ]; public function resetActivityId() { @@ -219,7 +221,7 @@ public function updatedDumpAll($value) $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -231,7 +233,7 @@ public function updatedDumpAll($value) } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' @@ -247,7 +249,7 @@ public function updatedDumpAll($value) $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE'; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' @@ -263,7 +265,7 @@ public function updatedDumpAll($value) $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE'; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' @@ -321,7 +323,7 @@ public function getContainers() $this->resourceStatus = $resource->status ?? ''; // Handle ServiceDatabase server access differently - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $server = $resource->service?->server; if (! $server) { abort(404, 'Server not found for this service database.'); @@ -359,16 +361,16 @@ public function getContainers() } if ( - $resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === StandaloneRedis::class || + $resource->getMorphClass() === StandaloneKeydb::class || + $resource->getMorphClass() === StandaloneDragonfly::class || + $resource->getMorphClass() === StandaloneClickhouse::class ) { $this->unsupported = true; } // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) - if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($resource->getMorphClass() === ServiceDatabase::class) { $dbType = $resource->databaseType(); if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { @@ -664,7 +666,7 @@ public function restoreFromS3(string $password = ''): bool|string $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->resource->getMorphClass() === ServiceDatabase::class) { $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; } else { $destinationNetwork = $this->resource->destination->network ?? 'coolify'; @@ -756,7 +758,7 @@ public function buildRestoreCommand(string $tmpPath): string $morphClass = $this->resource->getMorphClass(); // Handle ServiceDatabase by checking the database type - if ($morphClass === \App\Models\ServiceDatabase::class) { + if ($morphClass === ServiceDatabase::class) { $dbType = $this->resource->databaseType(); if (str_contains($dbType, 'mysql')) { $morphClass = 'mysql'; @@ -770,7 +772,7 @@ public function buildRestoreCommand(string $tmpPath): string } switch ($morphClass) { - case \App\Models\StandaloneMariadb::class: + case StandaloneMariadb::class: case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { @@ -779,7 +781,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandaloneMysql::class: + case StandaloneMysql::class: case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { @@ -788,7 +790,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " < {$tmpPath}"; } break; - case \App\Models\StandalonePostgresql::class: + case StandalonePostgresql::class: case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { @@ -797,7 +799,7 @@ public function buildRestoreCommand(string $tmpPath): string $restoreCommand .= " {$tmpPath}"; } break; - case \App\Models\StandaloneMongodb::class: + case StandaloneMongodb::class: case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 7c8808499..0511c9d04 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -59,11 +59,22 @@ public function getListeners() return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + public function mount() { try { diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index ea6d902e7..edd02eb95 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -64,11 +64,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 3af4b0b2a..1b5a62d2f 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -64,11 +64,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 34726bd0a..6e1e55b3f 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -66,11 +66,22 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index b5fb85483..1b36ac28a 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -74,13 +74,24 @@ public function getListeners() $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + // Broadcasts go to refreshStatus, which only writes display-only properties. + // Never wire status broadcasts to a handler that touches text-input properties — + // it clobbers in-progress typing every 10s. See coolify#6062 / #6354 / #9695. + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refreshStatus', + "echo-private:team.{$teamId},ServiceChecked" => 'refreshStatus', 'save_init_script', 'delete_init_script', ]; } + public function refreshStatus(): void + { + $this->database->refresh(); + $this->db_url = $this->database->internal_db_url; + $this->db_url_public = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + protected function rules(): array { return [ diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index c3cc43972..aff7b7afa 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -4,14 +4,11 @@ use App\Actions\Database\StartDatabaseProxy; use App\Actions\Database\StopDatabaseProxy; -use App\Helpers\SslHelper; use App\Models\Server; use App\Models\StandaloneRedis; use App\Support\ValidationPatterns; -use Carbon\Carbon; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class General extends Component @@ -48,25 +45,9 @@ class General extends Component public string $redisVersion; - public ?string $dbUrl = null; - - public ?string $dbUrlPublic = null; - - public bool $enableSsl = false; - - public ?Carbon $certificateValidUntil = null; - - public function getListeners() - { - $userId = Auth::id(); - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', - "echo-private:team.{$teamId},ServiceChecked" => 'refresh', - 'envsUpdated' => 'refresh', - ]; - } + protected $listeners = [ + 'envsUpdated' => 'refresh', + ]; protected function rules(): array { @@ -87,7 +68,6 @@ protected function rules(): array 'redisPassword' => ValidationPatterns::databasePasswordRules( enforcePattern: $this->redisPassword !== $this->database->redis_password, ), - 'enableSsl' => 'boolean', ]; } @@ -122,7 +102,6 @@ protected function messages(): array 'customDockerRunOptions' => 'Custom Docker Options', 'redisUsername' => 'Redis Username', 'redisPassword' => 'Redis Password', - 'enableSsl' => 'Enable SSL', ]; public function mount() @@ -136,12 +115,6 @@ public function mount() return; } - - $existingCert = $this->database->sslCertificates()->first(); - - if ($existingCert) { - $this->certificateValidUntil = $existingCert->valid_until; - } } catch (\Throwable $e) { return handleError($e, $this); } @@ -161,11 +134,7 @@ public function syncData(bool $toModel = false) $this->database->public_port_timeout = $this->publicPortTimeout ?: null; $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; $this->database->custom_docker_run_options = $this->customDockerRunOptions; - $this->database->enable_ssl = $this->enableSsl; $this->database->save(); - - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; } else { $this->name = $this->database->name; $this->description = $this->database->description; @@ -177,9 +146,6 @@ public function syncData(bool $toModel = false) $this->publicPortTimeout = $this->database->public_port_timeout; $this->isLogDrainEnabled = $this->database->is_log_drain_enabled; $this->customDockerRunOptions = $this->database->custom_docker_run_options; - $this->enableSsl = $this->database->enable_ssl; - $this->dbUrl = $this->database->internal_db_url; - $this->dbUrlPublic = $this->database->external_db_url; $this->redisVersion = $this->database->getRedisVersion(); $this->redisUsername = $this->database->redis_username; $this->redisPassword = $this->database->redis_password; @@ -227,6 +193,7 @@ public function submit() ); $this->dispatch('success', 'Database updated.'); + $this->dispatch('databaseUpdated'); } catch (Exception $e) { return handleError($e, $this); } finally { @@ -259,6 +226,7 @@ public function instantSave() StopDatabaseProxy::run($this->database); $this->dispatch('success', 'Database is no longer publicly accessible.'); } + $this->dispatch('databaseUpdated'); } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->syncData(true); @@ -267,63 +235,6 @@ public function instantSave() } } - public function instantSaveSSL() - { - try { - $this->authorize('update', $this->database); - - $this->syncData(true); - $this->dispatch('success', 'SSL configuration updated.'); - } catch (Exception $e) { - return handleError($e, $this); - } - } - - public function regenerateSslCertificate() - { - try { - $this->authorize('update', $this->database); - - $existingCert = $this->database->sslCertificates()->first(); - - if (! $existingCert) { - $this->dispatch('error', 'No existing SSL certificate found for this database.'); - - return; - } - - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - - if (! $caCert) { - $this->server->generateCaCertificate(); - $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first(); - } - - if (! $caCert) { - $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); - - return; - } - - SslHelper::generateSslCertificate( - commonName: $existingCert->common_name, - subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], - resourceType: $existingCert->resource_type, - resourceId: $existingCert->resource_id, - serverId: $existingCert->server_id, - caCert: $caCert->ssl_certificate, - caKey: $caCert->ssl_private_key, - configurationDir: $existingCert->configuration_dir, - mountPath: $existingCert->mount_path, - isPemKeyFileRequired: true, - ); - - $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); - } catch (Exception $e) { - handleError($e, $this); - } - } - public function refresh(): void { $this->database->refresh(); diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php new file mode 100644 index 000000000..183ed936f --- /dev/null +++ b/app/Livewire/Project/Database/Redis/StatusInfo.php @@ -0,0 +1,116 @@ +currentTeam()->id; + + return [ + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', + 'databaseUpdated' => 'refresh', + ]; + } + + public function mount(): void + { + $this->refresh(); + } + + public function refresh(): void + { + $this->database->refresh(); + $this->enableSsl = (bool) $this->database->enable_ssl; + $this->dbUrl = $this->database->internal_db_url; + $this->dbUrlPublic = $this->database->external_db_url; + $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until; + } + + public function instantSaveSSL(): void + { + try { + $this->authorize('update', $this->database); + $this->database->enable_ssl = $this->enableSsl; + $this->database->save(); + $this->dispatch('success', 'SSL configuration updated.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function regenerateSslCertificate(): void + { + try { + $this->authorize('update', $this->database); + + $existingCert = $this->database->sslCertificates()->first(); + + if (! $existingCert) { + $this->dispatch('error', 'No existing SSL certificate found for this database.'); + + return; + } + + $server = $this->database->destination->server; + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + + if (! $caCert) { + $server->generateCaCertificate(); + $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first(); + } + + if (! $caCert) { + $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.'); + + return; + } + + SslHelper::generateSslCertificate( + commonName: $existingCert->common_name, + subjectAlternativeNames: $existingCert->subject_alternative_names ?? [], + resourceType: $existingCert->resource_type, + resourceId: $existingCert->resource_id, + serverId: $existingCert->server_id, + caCert: $caCert->ssl_certificate, + caKey: $caCert->ssl_private_key, + configurationDir: $existingCert->configuration_dir, + mountPath: $existingCert->mount_path, + isPemKeyFileRequired: true, + ); + + $this->refresh(); + $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.'); + } catch (Exception $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.database.redis.status-info'); + } +} diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index 2d69ceb12..ac2b39bb8 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -4,7 +4,6 @@ use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class Configuration extends Component @@ -27,16 +26,10 @@ class Configuration extends Component public array $parameters; - public function getListeners() - { - $teamId = Auth::user()->currentTeam()->id; - - return [ - "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked', - 'refreshServices' => 'refreshServices', - 'refresh' => 'refreshServices', - ]; - } + protected $listeners = [ + 'refreshServices' => 'refreshServices', + 'refresh' => 'refreshServices', + ]; public function render() { @@ -105,18 +98,4 @@ public function restartDatabase($id) return handleError($e, $this); } } - - public function serviceChecked() - { - try { - $this->service->applications->each(function ($application) { - $application->refresh(); - }); - $this->service->databases->each(function ($database) { - $database->refresh(); - }); - } catch (\Exception $e) { - return handleError($e, $this); - } - } } diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index a4b35891b..06aebd8f8 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -93,7 +93,9 @@ public function handleSentinelRestarted($event) { if ($event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 3e05d9306..d7339dcdb 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -277,7 +277,9 @@ public function handleSentinelRestarted($event) // Only refresh if the event is for this server if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) { $this->server->refresh(); - $this->syncData(); + // Only refresh display-only state; never re-sync text-input properties + // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695). + $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->dispatch('success', 'Sentinel has been restarted successfully.'); } } @@ -457,12 +459,15 @@ public function handleServerValidated($event = null) return; } - // Refresh server data + // Refresh server data and only the display-only state that validation produces. + // Never re-sync text-input properties via syncData() — would clobber any + // unsaved typing (see coolify#6062 / #6354 / #9695). $this->server->refresh(); - $this->syncData(); - - // Update validation state + $this->server->settings->refresh(); $this->isValidating = $this->server->is_validating ?? false; + $this->validationLogs = $this->server->validation_logs; + $this->isReachable = $this->server->settings->is_reachable; + $this->isUsable = $this->server->settings->is_usable; // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); diff --git a/resources/views/livewire/project/database/redis/general.blade.php b/resources/views/livewire/project/database/redis/general.blade.php index 73ee5f0e5..b72b05ff6 100644 --- a/resources/views/livewire/project/database/redis/general.blade.php +++ b/resources/views/livewire/project/database/redis/general.blade.php @@ -60,56 +60,8 @@ helper="A comma separated list of ports you would like to map to the host system.
Example3000:5432,3002:5433" canGate="update" :canResource="$database" /> - - @if ($dbUrlPublic) - - @endif - -
-
-
-

SSL Configuration

- @if ($enableSsl && $certificateValidUntil) - - @endif -
-
- @if ($enableSsl && $certificateValidUntil) - Valid until: - @if (now()->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired - @elseif(now()->addDays(30)->gt($certificateValidUntil)) - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring - soon - @else - {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - @endif - - @endif -
-
- @if (str($database->status)->contains('exited')) - - @else - - @endif -
-
+
diff --git a/resources/views/livewire/project/database/redis/status-info.blade.php b/resources/views/livewire/project/database/redis/status-info.blade.php new file mode 100644 index 000000000..9f329504c --- /dev/null +++ b/resources/views/livewire/project/database/redis/status-info.blade.php @@ -0,0 +1,51 @@ +
+ + @if ($dbUrlPublic) + + @endif +
+
+
+

SSL Configuration

+ @if ($enableSsl && $certificateValidUntil) + + @endif +
+
+ @if ($enableSsl && $certificateValidUntil) + Valid until: + @if (now()->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired + @elseif(now()->addDays(30)->gt($certificateValidUntil)) + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring + soon + @else + {{ $certificateValidUntil->format('d.m.Y H:i:s') }} + @endif + + @endif +
+
+ @if (str($database->status)->contains('exited')) + + @else + + @endif +
+
+
+
diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php index e62ef48ad..b663213a5 100644 --- a/tests/Feature/DatabaseSslStatusRefreshTest.php +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -7,11 +7,16 @@ use App\Livewire\Project\Database\Mysql\General as MysqlGeneral; use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral; use App\Livewire\Project\Database\Redis\General as RedisGeneral; +use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo; +use App\Livewire\Server\Sentinel; +use App\Livewire\Server\Show; use App\Models\Environment; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -28,25 +33,75 @@ session(['currentTeam' => $this->team]); }); -dataset('ssl-aware-database-general-components', [ +dataset('database-general-forms-without-broadcasts', [ + // Redis splits status-derived display into a sibling component; the form itself + // takes no broadcast listeners. Other DBs use the narrower refreshStatus pattern below. + RedisGeneral::class, +]); + +dataset('database-general-forms-with-narrow-refresh', [ + // Form listens to status broadcasts but routes them to refreshStatus, which only + // writes display-only properties (URLs, cert expiry) — never input-bound text fields. + PostgresqlGeneral::class, MysqlGeneral::class, MariadbGeneral::class, MongodbGeneral::class, - RedisGeneral::class, - PostgresqlGeneral::class, KeydbGeneral::class, DragonflyGeneral::class, ]); -it('maps database status broadcasts to refresh for ssl-aware database general components', function (string $componentClass) { - $component = app($componentClass); - $listeners = $component->getListeners(); +dataset('database-status-info-components', [ + RedisStatusInfo::class, +]); - expect($listeners["echo-private:user.{$this->user->id},DatabaseStatusChanged"])->toBe('refresh') - ->and($listeners["echo-private:team.{$this->team->id},ServiceChecked"])->toBe('refresh'); -})->with('ssl-aware-database-general-components'); +it('does not subscribe the form to status broadcasts when display lives in a sibling', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // For DBs whose status-derived display moved into a sibling component, the form + // itself must not subscribe to status broadcasts at all. + $listeners = resolveLivewireListeners(app($componentClass)); -it('reloads the mysql database model when refreshing so ssl controls follow the latest status', function () { + expect($listeners) + ->not->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked") + ->not->toHaveKey("echo-private:team.{$this->team->id},ServiceStatusChanged"); +})->with('database-general-forms-without-broadcasts'); + +it('routes status broadcasts to refreshStatus, never to a handler that re-syncs inputs', function (string $componentClass) { + // Regression guard for coolify#6062 / #6354 / #9695: + // The form may listen to broadcasts, but only to a narrow handler (refreshStatus) + // that touches display-only properties. Routing to `refresh` or `$refresh` would + // re-sync every input property from the DB and wipe in-progress typing. + $listeners = resolveLivewireListeners(app($componentClass)); + + $databaseStatusKey = "echo-private:user.{$this->user->id},DatabaseStatusChanged"; + $serviceCheckedKey = "echo-private:team.{$this->team->id},ServiceChecked"; + + expect($listeners[$databaseStatusKey] ?? null)->toBe('refreshStatus') + ->and($listeners[$serviceCheckedKey] ?? null)->toBe('refreshStatus'); +})->with('database-general-forms-with-narrow-refresh'); + +function resolveLivewireListeners(object $component): array +{ + // Livewire's HandlesEvents trait declares getListeners() as protected, + // so subclasses that override it as public are callable directly, but + // subclasses that rely on $listeners are not. Reflection handles both. + $method = new ReflectionMethod($component, 'getListeners'); + $method->setAccessible(true); + + return (array) $method->invoke($component); +} + +it('auto-refreshes status-info sibling on database status broadcasts', function (string $componentClass) { + // Status-derived display (connection URLs, SSL gate hint, cert expiry) lives in a sibling + // Livewire component so it can re-render on broadcasts without touching the form's DOM. + $listeners = resolveLivewireListeners(app($componentClass)); + + expect($listeners) + ->toHaveKey("echo-private:user.{$this->user->id},DatabaseStatusChanged") + ->toHaveKey("echo-private:team.{$this->team->id},ServiceChecked"); +})->with('database-status-info-components'); + +it('reloads the mysql database model when refresh is called directly so ssl controls follow the latest status', function () { $server = Server::factory()->create(['team_id' => $this->team->id]); $destination = StandaloneDocker::where('server_id', $server->id)->first(); $project = Project::factory()->create(['team_id' => $this->team->id]); @@ -75,3 +130,91 @@ $component->call('refresh') ->assertSee('Database should be stopped to change this settings.'); }); + +it('does not clobber server form text inputs when sentinel restarts', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Sentinel::class, ['server_uuid' => $server->uuid]) + ->set('sentinelToken', 'user-was-typing-this-token'); + + $component->call('handleSentinelRestarted', ['serverUuid' => $server->uuid]); + + expect($component->get('sentinelToken'))->toBe('user-was-typing-this-token'); +}); + +it('does not clobber server form text inputs when server validation completes', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'name' => 'persisted-server-name', + ]); + + $component = Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->set('name', 'user-was-typing-here') + ->set('ip', '203.0.113.42'); + + $component->call('handleServerValidated', ['serverUuid' => $server->uuid]); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('ip'))->toBe('203.0.113.42'); +}); + +it('preserves typed input on the postgres form when refreshStatus runs', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandalonePostgresql::create([ + 'name' => 'persisted-name', + 'image' => 'postgres:16', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'status' => 'exited:unhealthy', + 'enable_ssl' => false, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(PostgresqlGeneral::class, ['database' => $database]) + ->set('name', 'user-was-typing-here') + ->set('portsMappings', '5433:5432'); + + $component->call('refreshStatus'); + + expect($component->get('name'))->toBe('user-was-typing-here') + ->and($component->get('portsMappings'))->toBe('5433:5432'); +}); + +it('shows the redis ssl gate hint after the sibling is refreshed', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneRedis::create([ + 'name' => 'test-redis', + 'image' => 'redis:7', + 'redis_password' => 'password', + 'redis_username' => 'default', + 'status' => 'exited:unhealthy', + 'enable_ssl' => true, + 'is_log_drain_enabled' => false, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $component = Livewire::test(RedisStatusInfo::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->fill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +});