fix(livewire): preserve wire:dirty across DB status broadcasts

The earlier refreshStatus fix kept user-typed values intact but Livewire still
absorbed deferred wire:model values into the snapshot on every broadcast-
triggered roundtrip, clearing the unsaved-changes indicator and making the form
look auto-saved. Move all status-derived display (DB URLs, SSL toggle/mode,
cert expiry) out of each DB General form into a sibling StatusInfo Livewire
component, so the form never roundtrips on broadcasts.

Shared scaffolding lives in App\Traits\HasDatabaseStatusInfo plus an x-database-
status-info Blade component, leaving each per-DB StatusInfo class as a ~20-50
line declaration of label, SSL mode options, and SSL save hooks. Parents
dispatch databaseUpdated from save methods so the sibling refreshes after writes.

Tests cover the architecture (no DB form subscribes to status broadcasts) and
the sibling's refresh-on-status-change behavior.
This commit is contained in:
Aditya Tripathi
2026-05-21 08:31:08 +00:00
parent 9aee01d5a0
commit e7e65831a7
27 changed files with 603 additions and 1308 deletions
@@ -40,34 +40,17 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false;
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 {
@@ -101,8 +84,6 @@ class General extends Component
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
];
}
@@ -142,9 +123,6 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$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;
@@ -157,8 +135,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -207,6 +183,7 @@ class General extends Component
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);
@@ -218,6 +195,7 @@ class General extends Component
public function databaseProxyStopped()
{
$this->syncData();
$this->dispatch('databaseUpdated');
}
public function submit()
@@ -233,6 +211,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Project\Database\Clickhouse;
use App\Models\StandaloneClickhouse;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneClickhouse $database;
protected function databaseLabel(): string
{
return 'Clickhouse';
}
protected function supportsSsl(): bool
{
return false;
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -40,39 +38,17 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
public bool $enable_ssl = false;
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;
$this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until;
}
public function mount()
{
try {
@@ -84,12 +60,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -109,10 +79,7 @@ class General extends Component
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'nullable|boolean',
];
}
@@ -148,11 +115,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$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;
@@ -164,9 +127,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -215,6 +175,7 @@ class General extends Component
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);
@@ -226,6 +187,7 @@ class General extends Component
public function databaseProxyStopped()
{
$this->syncData();
$this->dispatch('databaseUpdated');
}
public function submit()
@@ -241,6 +203,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -252,67 +215,6 @@ class General extends Component
}
}
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;
}
$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->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
} catch (Exception $e) {
handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Dragonfly;
use App\Models\StandaloneDragonfly;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneDragonfly $database;
protected function databaseLabel(): string
{
return 'Dragonfly';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}
+4 -102
View File
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -42,39 +40,17 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $isLogDrainEnabled = false;
public ?Carbon $certificateValidUntil = null;
public bool $enable_ssl = false;
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;
$this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until;
}
public function mount()
{
try {
@@ -86,12 +62,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -99,7 +69,7 @@ class General extends Component
protected function rules(): array
{
$baseRules = [
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
@@ -112,13 +82,8 @@ class General extends Component
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
'dbUrl' => 'nullable|string',
'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
'enable_ssl' => 'boolean',
];
return $baseRules;
}
protected function messages(): array
@@ -154,11 +119,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->enable_ssl = $this->enable_ssl;
$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;
@@ -171,9 +132,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->enable_ssl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -222,6 +180,7 @@ class General extends Component
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);
@@ -233,6 +192,7 @@ class General extends Component
public function databaseProxyStopped()
{
$this->syncData();
$this->dispatch('databaseUpdated');
}
public function submit()
@@ -248,6 +208,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -259,65 +220,6 @@ class General extends Component
}
}
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();
@@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Project\Database\Keydb;
use App\Models\StandaloneKeydb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneKeydb $database;
protected function databaseLabel(): string
{
return 'KeyDB';
}
protected function showPublicUrlPlaceholder(): bool
{
return true;
}
}
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mariadb;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMariadb;
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
@@ -50,36 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
// 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 [
@@ -105,7 +72,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
];
}
@@ -144,7 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -158,12 +123,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -187,11 +146,7 @@ class General extends Component
$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->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -207,9 +162,6 @@ class General extends Component
$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->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@@ -245,6 +197,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -281,6 +234,7 @@ class General extends Component
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);
@@ -289,63 +243,6 @@ class General extends Component
}
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
@@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Project\Database\Mariadb;
use App\Models\StandaloneMariadb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMariadb $database;
protected function databaseLabel(): string
{
return 'MariaDB';
}
}
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mongodb;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMongodb;
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,38 +45,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
// 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 [
@@ -102,8 +67,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@@ -123,7 +86,6 @@ class General extends Component
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
@@ -141,8 +103,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@@ -156,12 +116,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -184,12 +138,7 @@ class General extends Component
$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->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -204,10 +153,6 @@ class General extends Component
$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->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@@ -246,6 +191,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -282,6 +228,7 @@ class General extends Component
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);
@@ -290,68 +237,6 @@ class General extends Component
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
@@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mongodb;
use App\Models\StandaloneMongodb;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMongodb $database;
protected function databaseLabel(): string
{
return 'Mongo';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MongoDB connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}
+2 -117
View File
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMysql;
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
@@ -50,38 +47,6 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
// 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 [
@@ -107,8 +72,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@@ -129,7 +92,6 @@ class General extends Component
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
@@ -148,8 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@@ -163,12 +123,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -192,12 +146,7 @@ class General extends Component
$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->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -213,10 +162,6 @@ class General extends Component
$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->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@@ -252,6 +197,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -288,6 +234,7 @@ class General extends Component
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);
@@ -296,68 +243,6 @@ class General extends Component
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
@@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Project\Database\Mysql;
use App\Models\StandaloneMysql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneMysql $database;
protected function databaseLabel(): string
{
return 'MySQL';
}
protected function sslModeOptions(): array
{
return [
'PREFERRED' => ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for MySQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandalonePostgresql;
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
@@ -54,43 +51,14 @@ class General extends Component
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename;
public string $new_content;
public ?string $db_url = null;
public ?string $db_url_public = null;
public ?Carbon $certificateValidUntil = null;
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->currentTeam()->id;
return [
// 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 $listeners = [
'save_init_script',
'delete_init_script',
];
protected function rules(): array
{
@@ -117,8 +85,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@@ -138,7 +104,6 @@ class General extends Component
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
@@ -159,8 +124,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
@@ -174,12 +137,6 @@ class General extends Component
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -205,12 +162,7 @@ class General extends Component
$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->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -228,10 +180,6 @@ class General extends Component
$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->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@@ -254,68 +202,6 @@ class General extends Component
}
}
public function updatedSslMode()
{
$this->instantSaveSSL();
}
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 have been regenerated. Please restart the database for changes to take effect.');
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
@@ -341,6 +227,7 @@ class General extends Component
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);
@@ -504,6 +391,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Project\Database\Postgresql;
use App\Models\StandalonePostgresql;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandalonePostgresql $database;
protected function databaseLabel(): string
{
return 'Postgres';
}
protected function sslModeOptions(): array
{
return [
'allow' => ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
];
}
protected function sslModeHelper(): string
{
return 'Choose the SSL verification mode for PostgreSQL connections';
}
protected function afterRefresh(): void
{
$this->sslMode = $this->database->ssl_mode;
}
protected function applyExtraSslAttributes(): void
{
$this->database->ssl_mode = $this->sslMode;
}
public function updatedSslMode(): void
{
$this->instantSaveSSL();
}
}
@@ -2,115 +2,20 @@
namespace App\Livewire\Project\Database\Redis;
use App\Helpers\SslHelper;
use App\Models\StandaloneRedis;
use Carbon\Carbon;
use Exception;
use App\Traits\HasDatabaseStatusInfo;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class StatusInfo extends Component
{
use AuthorizesRequests;
use HasDatabaseStatusInfo;
public StandaloneRedis $database;
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public function getListeners()
protected function databaseLabel(): string
{
$userId = Auth::id();
$teamId = Auth::user()->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');
return 'Redis';
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace App\Traits;
use App\Helpers\SslHelper;
use Carbon\Carbon;
use Exception;
use Illuminate\Support\Facades\Auth;
/**
* Shared behavior for the per-database StatusInfo Livewire siblings.
*
* Lives on a child Livewire component so status broadcasts never trigger a
* roundtrip on the parent form preserving in-progress typing AND wire:dirty.
* See coolify#6062 / #6354 / #9695.
*
* Consumers must declare a typed `public Model $database` and implement
* databaseLabel(). All other hooks have sensible defaults.
*/
trait HasDatabaseStatusInfo
{
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?Carbon $certificateValidUntil = null;
abstract protected function databaseLabel(): string;
protected function supportsSsl(): bool
{
return true;
}
protected function sslModeOptions(): ?array
{
return null;
}
protected function sslModeHelper(): ?string
{
return null;
}
protected function showPublicUrlPlaceholder(): bool
{
return false;
}
public function getListeners()
{
$userId = Auth::id();
$teamId = Auth::user()->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->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
if ($this->supportsSsl()) {
$this->enableSsl = (bool) $this->database->enable_ssl;
$this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until;
$this->afterRefresh();
}
}
/**
* Hook for subclasses with extra status-derived properties (e.g. sslMode).
*/
protected function afterRefresh(): void {}
public function instantSaveSSL(): void
{
try {
$this->authorize('update', $this->database);
$this->database->enable_ssl = $this->enableSsl;
$this->applyExtraSslAttributes();
$this->database->save();
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
handleError($e, $this);
}
}
/**
* Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode).
*/
protected function applyExtraSslAttributes(): void {}
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.status-info', [
'label' => $this->databaseLabel(),
'supportsSsl' => $this->supportsSsl(),
'sslModeOptions' => $this->sslModeOptions(),
'sslModeHelper' => $this->sslModeHelper(),
'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(),
'isExited' => str($this->database->status)->contains('exited'),
]);
}
}
@@ -0,0 +1,94 @@
@props([
'database',
'label',
'dbUrl' => null,
'dbUrlPublic' => null,
'supportsSsl' => true,
'enableSsl' => false,
'sslMode' => null,
'sslModeOptions' => null,
'sslModeHelper' => null,
'certificateValidUntil' => null,
'isExited' => false,
'showPublicUrlPlaceholder' => false,
])
@php
$urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.';
@endphp
<div class="flex flex-col gap-2">
<x-forms.input :label="$label . ' URL (internal)'" :helper="$urlHelper" type="password" readonly
wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" type="password" readonly
wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@elseif ($showPublicUrlPlaceholder)
<x-forms.input :label="$label . ' URL (public)'" :helper="$urlHelper" readonly
value="Starting the database will generate this." canGate="update" :canResource="$database" />
@endif
@if ($supportsSsl)
<div class="flex flex-col gap-2 pt-4">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<div class="w-64" wire:key="enable_ssl">
@if ($isExited)
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
@if ($sslModeOptions && $enableSsl)
<div class="mx-2">
@if ($isExited)
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
instantSave="instantSaveSSL" :helper="$sslModeHelper" canGate="update"
:canResource="$database">
@foreach ($sslModeOptions as $value => $option)
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
@endforeach
</x-forms.select>
@else
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database">
@foreach ($sslModeOptions as $value => $option)
<option value="{{ $value }}" title="{{ $option['title'] ?? '' }}">{{ $option['label'] }}</option>
@endforeach
</x-forms.select>
@endif
</div>
@endif
</div>
</div>
@endif
</div>
@@ -41,19 +41,8 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="Clickhouse URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input label="Clickhouse URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@else
<x-forms.input label="Clickhouse URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
readonly value="Starting the database will generate this." canGate="update" :canResource="$database" />
@endif
</div>
<livewire:project.database.clickhouse.status-info :database="$database" />
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">
<div class="flex items-center">
@@ -37,60 +37,8 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="Dragonfly URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input label="Dragonfly URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@else
<x-forms.input label="Dragonfly URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
readonly value="Starting the database will generate this." canGate="update" :canResource="$database" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<div class="w-64">
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl"
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl"
instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
</div>
</div>
<livewire:project.database.dragonfly.status-info :database="$database" />
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">
@@ -38,59 +38,8 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="KeyDB URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input label="KeyDB URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@else
<x-forms.input label="KeyDB URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
readonly value="Starting the database will generate this." canGate="update" :canResource="$database" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($database->enable_ssl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($database->enable_ssl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<div class="w-64">
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl"
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enable_ssl" label="Enable SSL" wire:model.live="enable_ssl"
instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
</div>
</div>
<livewire:project.database.keydb.status-info :database="$database" />
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">
@@ -61,59 +61,9 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="MariaDB URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url" canGate="update" :canResource="$database" />
@if ($db_url_public)
<x-forms.input label="MariaDB URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url_public" canGate="update" :canResource="$database" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="w-64">
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" canGate="update"
:canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
</div>
</div>
<livewire:project.database.mariadb.status-info :database="$database" />
<div>
<div class="flex flex-col py-2 w-64">
@@ -50,85 +50,10 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="Mongo URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url" canGate="update" :canResource="$database" />
@if ($db_url_public)
<x-forms.input label="Mongo URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url_public" canGate="update" :canResource="$database" />
@endif
</div>
<livewire:project.database.mongodb.status-info :database="$database" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($enableSsl)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="w-64">
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" canGate="update"
:canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
@if ($enableSsl)
<div class="mx-2">
@if (str($database->status)->contains('exited'))
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
instantSave="instantSaveSSL"
helper="Choose the SSL verification mode for MongoDB connections" canGate="update"
:canResource="$database">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)
</option>
</x-forms.select>
@else
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL"
disabled helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)
</option>
</x-forms.select>
@endif
</div>
@endif
</div>
<div>
<div class="flex flex-col py-2 w-64">
<div class="flex items-center gap-2 pb-2">
@@ -56,81 +56,9 @@
<x-forms.input placeholder="3000:5432" id="portsMappings" label="Ports Mappings"
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433" canGate="update" :canResource="$database" />
</div>
<x-forms.input label="MySQL URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url" />
@if ($db_url_public)
<x-forms.input label="MySQL URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="w-64">
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." />
@endif
</div>
@if ($enableSsl)
<div class="mx-2">
@if (str($database->status)->contains('exited'))
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
instantSave="instantSaveSSL"
helper="Choose the SSL verification mode for MySQL connections" canGate="update" :canResource="$database">
<option value="PREFERRED" title="Prefer secure connections">Prefer (secure)</option>
<option value="REQUIRED" title="Require secure connections">Require (secure)</option>
<option value="VERIFY_CA" title="Verify CA certificate">Verify CA (secure)</option>
<option value="VERIFY_IDENTITY" title="Verify full certificate">Verify Full (secure)
</option>
</x-forms.select>
@else
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL"
disabled helper="Database should be stopped to change this settings.">
<option value="PREFERRED" title="Prefer secure connections">Prefer (secure)</option>
<option value="REQUIRED" title="Require secure connections">Require (secure)</option>
<option value="VERIFY_CA" title="Verify CA certificate">Verify CA (secure)</option>
<option value="VERIFY_IDENTITY" title="Verify full certificate">Verify Full (secure)
</option>
</x-forms.select>
@endif
</div>
@endif
</div>
</div>
<livewire:project.database.mysql.status-info :database="$database" />
<div>
<div class="flex flex-col py-2 w-64">
@@ -68,114 +68,38 @@
helper="A comma separated list of ports you would like to map to the host system.<br><span class='inline-block font-bold dark:text-warning'>Example</span>3000:5432,3002:5433"
canGate="update" :canResource="$database" />
</div>
<x-forms.input label="Postgres URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url" />
@if ($db_url_public)
<x-forms.input label="Postgres URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="db_url_public" />
@endif
</div>
<livewire:project.database.postgresql.status-info :database="$database" />
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 py-2">
<h3>SSL Configuration</h3>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates" buttonTitle="Regenerate SSL Certificates"
:actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]" submitAction="regenerateSslCertificate" :confirmWithText="false"
:confirmWithPassword="false" />
<h3>Proxy</h3>
<x-loading wire:loading wire:target="instantSave" />
@if (data_get($database, 'is_public'))
<x-slide-over fullScreen>
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button disabled="{{ !data_get($database, 'is_public') }}"
@click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2 w-64">
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
canGate="update" :canResource="$database" />
</div>
<div class="flex flex-col gap-2">
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="w-64" wire:key='enable_ssl'>
@if ($database->isExited())
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" canGate="update" :canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL" wire:model.live="enableSsl"
instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." />
@endif
</div>
@if ($enableSsl)
<div class="mx-2">
@if ($database->isExited())
<x-forms.select id="sslMode" label="SSL Mode" wire:model.live="sslMode"
instantSave="instantSaveSSL"
helper="Choose the SSL verification mode for PostgreSQL connections" canGate="update"
:canResource="$database">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-ca" title="Verify CA certificate">verify-ca (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)
</option>
</x-forms.select>
@else
<x-forms.select id="sslMode" label="SSL Mode" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings.">
<option value="allow" title="Allow insecure connections">allow (insecure)</option>
<option value="prefer" title="Prefer secure connections">prefer (secure)</option>
<option value="require" title="Require secure connections">require (secure)</option>
<option value="verify-ca" title="Verify CA certificate">verify-ca (secure)</option>
<option value="verify-full" title="Verify full certificate">verify-full (secure)
</option>
</x-forms.select>
@endif
</div>
@endif
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 py-2">
<h3>Proxy</h3>
<x-loading wire:loading wire:target="instantSave" />
@if (data_get($database, 'is_public'))
<x-slide-over fullScreen>
<x-slot:title>Proxy Logs</x-slot:title>
<x-slot:content>
<livewire:project.shared.get-logs :server="$server" :resource="$database"
container="{{ data_get($database, 'uuid') }}-proxy" :collapsible="false" lazy />
</x-slot:content>
<x-forms.button disabled="{{ !data_get($database, 'is_public') }}"
@click="slideOverOpen=true">Logs</x-forms.button>
</x-slide-over>
@endif
</div>
<div class="flex flex-col gap-2 w-64">
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
canGate="update" :canResource="$database" />
</div>
<div class="flex flex-col gap-2">
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
label="Public Port" canGate="update" :canResource="$database" />
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
</div>
</div>
<div class="flex flex-col gap-2">
<x-forms.textarea label="Custom PostgreSQL Configuration" rows="10" id="postgresConf"
canGate="update" :canResource="$database" />
</div>
</div>
<x-forms.textarea label="Custom PostgreSQL Configuration" rows="10" id="postgresConf"
canGate="update" :canResource="$database" />
</div>
</form>
@@ -1,51 +0,0 @@
<div class="flex flex-col gap-2">
<x-forms.input label="Redis URL (internal)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrl" canGate="update" :canResource="$database" />
@if ($dbUrlPublic)
<x-forms.input label="Redis URL (public)"
helper="If you change the user/password/port, this could be different. This is with the default values."
type="password" readonly wire:model="dbUrlPublic" canGate="update" :canResource="$database" />
@endif
<div class="flex flex-col gap-2 pt-4">
<div class="flex items-center justify-between py-2">
<div class="flex items-center justify-between w-full">
<h3>SSL Configuration</h3>
@if ($enableSsl && $certificateValidUntil)
<x-modal-confirmation title="Regenerate SSL Certificates"
buttonTitle="Regenerate SSL Certificates" :actions="[
'The SSL certificate of this database will be regenerated.',
'You must restart the database after regenerating the certificate to start using the new certificate.',
]"
submitAction="regenerateSslCertificate" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($enableSsl && $certificateValidUntil)
<span class="text-sm">Valid until:
@if (now()->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired</span>
@elseif(now()->addDays(30)->gt($certificateValidUntil))
<span class="text-red-500">{{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
soon</span>
@else
<span>{{ $certificateValidUntil->format('d.m.Y H:i:s') }}</span>
@endif
</span>
@endif
<div class="flex flex-col gap-2">
<div class="w-64" wire:key='enable_ssl'>
@if (str($database->status)->contains('exited'))
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" canGate="update"
:canResource="$database" />
@else
<x-forms.checkbox id="enableSsl" label="Enable SSL"
wire:model.live="enableSsl" instantSave="instantSaveSSL" disabled
helper="Database should be stopped to change this settings." canGate="update"
:canResource="$database" />
@endif
</div>
</div>
</div>
</div>
@@ -0,0 +1,6 @@
<div>
<x-database-status-info :database="$database" :label="$label" :db-url="$dbUrl" :db-url-public="$dbUrlPublic"
:enable-ssl="$enableSsl" :ssl-mode="$sslMode" :certificate-valid-until="$certificateValidUntil"
:supports-ssl="$supportsSsl" :ssl-mode-options="$sslModeOptions" :ssl-mode-helper="$sslModeHelper"
:show-public-url-placeholder="$showPublicUrlPlaceholder" :is-exited="$isExited" />
</div>
+24 -56
View File
@@ -1,11 +1,19 @@
<?php
use App\Livewire\Project\Database\Clickhouse\General as ClickhouseGeneral;
use App\Livewire\Project\Database\Clickhouse\StatusInfo as ClickhouseStatusInfo;
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Dragonfly\StatusInfo as DragonflyStatusInfo;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Keydb\StatusInfo as KeydbStatusInfo;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mariadb\StatusInfo as MariadbStatusInfo;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mongodb\StatusInfo as MongodbStatusInfo;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Mysql\StatusInfo as MysqlStatusInfo;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Postgresql\StatusInfo as PostgresqlStatusInfo;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Livewire\Project\Database\Redis\StatusInfo as RedisStatusInfo;
use App\Livewire\Server\Sentinel;
@@ -15,7 +23,6 @@ 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;
@@ -34,30 +41,35 @@ beforeEach(function () {
});
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.
// Status-derived display moved into a sibling StatusInfo component for each DB,
// so the form itself takes no broadcast listeners and cannot clobber wire:dirty
// by absorbing deferred wire:model values during a status-triggered roundtrip.
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,
KeydbGeneral::class,
DragonflyGeneral::class,
ClickhouseGeneral::class,
]);
dataset('database-status-info-components', [
RedisStatusInfo::class,
PostgresqlStatusInfo::class,
MysqlStatusInfo::class,
MariadbStatusInfo::class,
MongodbStatusInfo::class,
KeydbStatusInfo::class,
DragonflyStatusInfo::class,
ClickhouseStatusInfo::class,
]);
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.
// Status broadcasts on the form would trigger a Livewire roundtrip that absorbs
// deferred wire:model values into the snapshot — clobbering both the typed text
// (resolved by the earlier refreshStatus fix) and the wire:dirty indicator.
$listeners = resolveLivewireListeners(app($componentClass));
expect($listeners)
@@ -66,20 +78,6 @@ it('does not subscribe the form to status broadcasts when display lives in a sib
->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,
@@ -101,7 +99,7 @@ it('auto-refreshes status-info sibling on database status broadcasts', function
->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 () {
it('reloads the mysql status-info model when refresh is called 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]);
@@ -122,7 +120,7 @@ it('reloads the mysql database model when refresh is called directly so ssl cont
'destination_type' => $destination->getMorphClass(),
]);
$component = Livewire::test(MysqlGeneral::class, ['database' => $database])
$component = Livewire::test(MysqlStatusInfo::class, ['database' => $database])
->assertDontSee('Database should be stopped to change this settings.');
$database->fill(['status' => 'running:healthy'])->save();
@@ -161,36 +159,6 @@ it('does not clobber server form text inputs when server validation completes',
->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();