diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 1b5cd0d44..8e31a7051 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -643,6 +643,7 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -679,7 +680,7 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -706,6 +707,7 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { @@ -880,6 +882,7 @@ class DatabasesController extends Controller 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'], + 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600], ], ), ) @@ -909,7 +912,7 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid']; + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -927,13 +930,14 @@ class DatabasesController extends Controller 'dump_all' => 'boolean', 's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable', 'databases_to_backup' => 'string|nullable', - 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'frequency' => 'string', 'database_backup_retention_amount_locally' => 'integer|min:0', 'database_backup_retention_days_locally' => 'integer|min:0', 'database_backup_retention_max_storage_locally' => 'integer|min:0', 'database_backup_retention_amount_s3' => 'integer|min:0', 'database_backup_retention_days_s3' => 'integer|min:0', 'database_backup_retention_max_storage_s3' => 'integer|min:0', + 'timeout' => 'integer|min:60|max:36000', ]); if ($validator->fails()) { return response()->json([ @@ -960,6 +964,17 @@ class DatabasesController extends Controller $this->authorize('update', $database); + // Validate frequency is a valid cron expression + if ($request->filled('frequency')) { + $isValid = validate_cron_expression($request->frequency); + if (! $isValid) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['frequency' => ['Invalid cron expression or frequency format.']], + ], 422); + } + } + if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) { return response()->json([ 'message' => 'Validation failed.', diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 2ef95ce8b..c13c6665c 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -598,6 +598,11 @@ class ServersController extends Controller 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], + 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], + 'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'], + 'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'], + 'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'], + 'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'], ], ), ), @@ -634,7 +639,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -655,6 +660,11 @@ class ServersController extends Controller 'is_build_server' => 'boolean|nullable', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', + 'concurrent_builds' => 'integer|min:1', + 'dynamic_timeout' => 'integer|min:1', + 'deployment_queue_limit' => 'integer|min:1', + 'server_disk_usage_notification_threshold' => 'integer|min:1|max:100', + 'server_disk_usage_check_frequency' => 'string', ]); $extraFields = array_diff(array_keys($request->all()), $allowedFields); @@ -691,6 +701,19 @@ class ServersController extends Controller 'is_build_server' => $request->is_build_server, ]); } + + if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']], + ], 422); + } + + $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']); + if (! empty($advancedSettings)) { + $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value))); + } + if ($request->instant_validate) { ValidateServer::dispatch($server); } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 847f10765..364163ff8 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -42,7 +42,7 @@ class Email extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = null; @@ -54,7 +54,7 @@ class Email extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 6fd063cf3..25ce82eb0 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -153,8 +153,8 @@ class General extends Component 'staticImage' => 'required', 'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)), 'publishDirectory' => ValidationPatterns::directoryPathRules(), - 'portsExposes' => 'required', - 'portsMappings' => 'nullable', + 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'], + 'portsMappings' => ValidationPatterns::portMappingRules(), 'customNetworkAliases' => 'nullable', 'dockerfile' => 'nullable', 'dockerRegistryImageName' => 'nullable', @@ -212,6 +212,8 @@ class General extends Component 'staticImage.required' => 'The Static Image field is required.', 'baseDirectory.required' => 'The Base Directory field is required.', 'portsExposes.required' => 'The Exposed Ports field is required.', + 'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).', + ...ValidationPatterns::portMappingMessages(), 'isStatic.required' => 'The Static setting is required.', 'isStatic.boolean' => 'The Static setting must be true or false.', 'isSpa.required' => 'The SPA setting is required.', @@ -756,6 +758,12 @@ class General extends Component $this->authorize('update', $this->application); $this->resetErrorBag(); + + $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString(); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } + $this->validate(); $oldPortsExposes = $this->application->ports_exposes; diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 0fff2bd03..a18022882 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -76,7 +76,7 @@ class BackupEdit extends Component public bool $dumpAll = false; #[Validate(['required', 'int', 'min:60', 'max:36000'])] - public int $timeout = 3600; + public int|string $timeout = 3600; public function mount() { diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index ffce8c9bd..e06629d10 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -79,7 +79,7 @@ class General extends Component 'clickhouseAdminUser' => 'required|string', 'clickhouseAdminPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -94,6 +94,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'clickhouseAdminUser.required' => 'The Admin User field is required.', 'clickhouseAdminUser.string' => 'The Admin User must be a string.', @@ -209,6 +210,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 2e6c9dca7..5176f5ff9 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -57,7 +57,8 @@ class General extends Component return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -90,7 +91,7 @@ class General extends Component 'description' => ValidationPatterns::descriptionRules(), 'dragonflyPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -106,6 +107,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'dragonflyPassword.required' => 'The Dragonfly Password field is required.', 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.', @@ -219,6 +221,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -295,4 +300,10 @@ class General extends Component handleError($e, $this); } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 235e34e20..b50f196a8 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -59,7 +59,8 @@ class General extends Component return [ "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped', - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -93,7 +94,7 @@ class General extends Component 'keydbConf' => 'nullable|string', 'keydbPassword' => 'required|string', 'image' => 'required|string', - 'portsMappings' => 'nullable|string', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -111,6 +112,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'keydbPassword.required' => 'The KeyDB Password field is required.', 'keydbPassword.string' => 'The KeyDB Password must be a string.', @@ -226,6 +228,9 @@ class General extends Component try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -300,4 +305,10 @@ class General extends Component handleError($e, $this); } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 47e0fd091..9a1a8bd68 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -61,9 +61,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -78,7 +80,7 @@ class General extends Component 'mariadbDatabase' => 'required', 'mariadbConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -92,6 +94,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mariadbRootPassword.required' => 'The Root Password field is required.', @@ -215,6 +218,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index 6a3726371..a21de744a 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -61,9 +61,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -77,7 +79,7 @@ class General extends Component 'mongoInitdbRootPassword' => 'required', 'mongoInitdbDatabase' => 'required', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -92,6 +94,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mongoInitdbRootUsername.required' => 'The Root Username field is required.', @@ -215,6 +218,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 750be4ce7..cacb4ac49 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -63,9 +63,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', ]; } @@ -80,7 +82,7 @@ class General extends Component 'mysqlDatabase' => 'required', 'mysqlConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -95,6 +97,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'mysqlRootPassword.required' => 'The Root Password field is required.', @@ -222,6 +225,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php index 8feb9bd22..22e350683 100644 --- a/app/Livewire/Project/Database/Postgresql/General.php +++ b/app/Livewire/Project/Database/Postgresql/General.php @@ -71,9 +71,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', 'save_init_script', 'delete_init_script', ]; @@ -92,7 +94,7 @@ class General extends Component 'postgresConf' => 'nullable', 'initScripts' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -107,6 +109,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'postgresUser.required' => 'The Postgres User field is required.', @@ -469,6 +472,9 @@ class General extends Component try { $this->authorize('update', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } if (str($this->publicPort)->isEmpty()) { $this->publicPort = null; } @@ -484,4 +490,10 @@ class General extends Component } } } + + public function refresh(): void + { + $this->database->refresh(); + $this->syncData(); + } } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index e131bc598..3c32a6192 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -59,9 +59,11 @@ class General extends Component public function getListeners() { $userId = Auth::id(); + $teamId = Auth::user()->currentTeam()->id; return [ - "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh', + "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh', + "echo-private:team.{$teamId},ServiceChecked" => 'refresh', 'envsUpdated' => 'refresh', ]; } @@ -73,7 +75,7 @@ class General extends Component 'description' => ValidationPatterns::descriptionRules(), 'redisConf' => 'nullable', 'image' => 'required', - 'portsMappings' => 'nullable', + 'portsMappings' => ValidationPatterns::portMappingRules(), 'isPublic' => 'nullable|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', @@ -89,6 +91,7 @@ class General extends Component { return array_merge( ValidationPatterns::combinedMessages(), + ValidationPatterns::portMappingMessages(), [ 'name.required' => 'The Name field is required.', 'image.required' => 'The Docker Image field is required.', @@ -203,6 +206,9 @@ class General extends Component try { $this->authorize('manageEnvironment', $this->database); + if ($this->portsMappings) { + $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString(); + } $this->syncData(true); if (version_compare($this->redisVersion, '6.0', '>=')) { diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 0b3840289..8a14dc10c 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Shared; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Component; class ResourceLimits extends Component @@ -16,24 +17,24 @@ class ResourceLimits extends Component public ?string $limitsCpuset = null; - public ?int $limitsCpuShares = null; + public mixed $limitsCpuShares = null; public string $limitsMemory; public string $limitsMemorySwap; - public int $limitsMemorySwappiness; + public mixed $limitsMemorySwappiness = 0; public string $limitsMemoryReservation; protected $rules = [ - 'limitsMemory' => 'required|string', - 'limitsMemorySwap' => 'required|string', + 'limitsMemory' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsMemorySwap' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', - 'limitsMemoryReservation' => 'required|string', - 'limitsCpus' => 'nullable', - 'limitsCpuset' => 'nullable', - 'limitsCpuShares' => 'nullable', + 'limitsMemoryReservation' => ['required', 'string', 'regex:/^(0|\d+[bBkKmMgG])$/'], + 'limitsCpus' => ['nullable', 'regex:/^\d*\.?\d+$/'], + 'limitsCpuset' => ['nullable', 'regex:/^\d+([,-]\d+)*$/'], + 'limitsCpuShares' => 'nullable|integer|min:0', ]; protected $validationAttributes = [ @@ -46,6 +47,19 @@ class ResourceLimits extends Component 'limitsCpuShares' => 'cpu shares', ]; + protected $messages = [ + 'limitsMemory.regex' => 'Maximum Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemorySwap.regex' => 'Maximum Swap Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsMemoryReservation.regex' => 'Soft Memory Limit must be a number followed by a unit (b, k, m, g). Example: 256m, 1g. Use 0 for unlimited.', + 'limitsCpus.regex' => 'Number of CPUs must be a number (integer or decimal). Example: 0.5, 2.', + 'limitsCpuset.regex' => 'CPU sets must be a comma-separated list of CPU numbers or ranges. Example: 0-2 or 0,1,3.', + 'limitsMemorySwappiness.integer' => 'Swappiness must be a whole number between 0 and 100.', + 'limitsMemorySwappiness.min' => 'Swappiness must be between 0 and 100.', + 'limitsMemorySwappiness.max' => 'Swappiness must be between 0 and 100.', + 'limitsCpuShares.integer' => 'CPU Weight must be a whole number.', + 'limitsCpuShares.min' => 'CPU Weight must be a positive number.', + ]; + /** * Sync data between component properties and model * @@ -57,10 +71,10 @@ class ResourceLimits extends Component // Sync TO model (before save) $this->resource->limits_cpus = $this->limitsCpus; $this->resource->limits_cpuset = $this->limitsCpuset; - $this->resource->limits_cpu_shares = $this->limitsCpuShares; + $this->resource->limits_cpu_shares = (int) $this->limitsCpuShares; $this->resource->limits_memory = $this->limitsMemory; $this->resource->limits_memory_swap = $this->limitsMemorySwap; - $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; + $this->resource->limits_memory_swappiness = (int) $this->limitsMemorySwappiness; $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; } else { // Sync FROM model (on load/refresh) @@ -91,7 +105,7 @@ class ResourceLimits extends Component if (! $this->limitsMemorySwap) { $this->limitsMemorySwap = '0'; } - if (is_null($this->limitsMemorySwappiness)) { + if ($this->limitsMemorySwappiness === '' || is_null($this->limitsMemorySwappiness)) { $this->limitsMemorySwappiness = 60; } if (! $this->limitsMemoryReservation) { @@ -103,7 +117,7 @@ class ResourceLimits extends Component if ($this->limitsCpuset === '') { $this->limitsCpuset = null; } - if (is_null($this->limitsCpuShares)) { + if ($this->limitsCpuShares === '' || is_null($this->limitsCpuShares)) { $this->limitsCpuShares = 1024; } @@ -112,6 +126,12 @@ class ResourceLimits extends Component $this->syncData(true); $this->resource->save(); $this->dispatch('success', 'Resource limits updated.'); + } catch (ValidationException $e) { + foreach ($e->validator->errors()->all() as $message) { + $this->dispatch('error', $message); + } + + return; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Advanced.php b/app/Livewire/Server/Advanced.php index dba1b4903..b39da5e5a 100644 --- a/app/Livewire/Server/Advanced.php +++ b/app/Livewire/Server/Advanced.php @@ -15,17 +15,17 @@ class Advanced extends Component #[Validate(['string'])] public string $serverDiskUsageCheckFrequency = '0 23 * * *'; - #[Validate(['integer', 'min:1', 'max:99'])] - public int $serverDiskUsageNotificationThreshold = 50; + #[Validate(['required', 'integer', 'min:1', 'max:99'])] + public int|string $serverDiskUsageNotificationThreshold = 50; - #[Validate(['integer', 'min:1'])] - public int $concurrentBuilds = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $concurrentBuilds = 1; - #[Validate(['integer', 'min:1'])] - public int $dynamicTimeout = 1; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $dynamicTimeout = 1; - #[Validate(['integer', 'min:1'])] - public int $deploymentQueueLimit = 25; + #[Validate(['required', 'integer', 'min:1'])] + public int|string $deploymentQueueLimit = 25; public function mount(string $server_uuid) { diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index d5f30fca0..c2d8205ef 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -6,6 +6,7 @@ use App\Actions\Proxy\GetProxyConfiguration; use App\Actions\Proxy\SaveProxyConfiguration; use App\Enums\ProxyTypes; use App\Models\Server; +use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -41,9 +42,13 @@ class Proxy extends Component ]; } - protected $rules = [ - 'generateExactLabels' => 'required|boolean', - ]; + protected function rules() + { + return [ + 'generateExactLabels' => 'required|boolean', + 'redirectUrl' => ['nullable', new SafeExternalUrl], + ]; + } public function mount() { @@ -147,6 +152,7 @@ class Proxy extends Component { try { $this->authorize('update', $this->server); + $this->validate(); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index dff379ae1..a4b35891b 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -25,13 +25,13 @@ class Sentinel extends Component public ?string $sentinelUpdatedAt = null; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsRefreshRateSeconds; + public int|string $sentinelMetricsRefreshRateSeconds; #[Validate(['required', 'integer', 'min:1'])] - public int $sentinelMetricsHistoryDays; + public int|string $sentinelMetricsHistoryDays; #[Validate(['required', 'integer', 'min:10'])] - public int $sentinelPushIntervalSeconds; + public int|string $sentinelPushIntervalSeconds; #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 9e4f94f8a..d31f68859 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; +use App\Rules\ValidDnsServers; use App\Rules\ValidIpOrCidr; use Livewire\Attributes\Validate; use Livewire\Component; @@ -20,7 +21,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_dns_validation_enabled; - #[Validate('nullable|string')] public ?string $custom_dns_servers = null; #[Validate('boolean')] @@ -43,7 +43,7 @@ class Advanced extends Component 'is_registration_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', - 'custom_dns_servers' => 'nullable|string', + 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], 'is_api_enabled' => 'boolean', 'allowed_ips' => ['nullable', 'string', new ValidIpOrCidr], 'is_sponsorship_popup_enabled' => 'boolean', diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index ca48e9b16..8c0e24400 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -33,7 +33,7 @@ class SettingsEmail extends Component public ?string $smtpHost = null; #[Validate(['nullable', 'numeric', 'min:1', 'max:65535'])] - public ?int $smtpPort = null; + public ?string $smtpPort = null; #[Validate(['nullable', 'string', 'in:starttls,tls,none'])] public ?string $smtpEncryption = 'starttls'; @@ -45,7 +45,7 @@ class SettingsEmail extends Component public ?string $smtpPassword = null; #[Validate(['nullable', 'numeric'])] - public ?int $smtpTimeout = null; + public ?string $smtpTimeout = null; #[Validate(['boolean'])] public bool $resendEnabled = false; diff --git a/app/Rules/ValidDnsServers.php b/app/Rules/ValidDnsServers.php new file mode 100644 index 000000000..e3bbd048f --- /dev/null +++ b/app/Rules/ValidDnsServers.php @@ -0,0 +1,35 @@ + 'Port mappings must be a comma-separated list of port pairs or ranges (e.g. 3000:3000,8080:80,8000-8010:8000-8010).', + ]; + } + /** * Check if a string is a valid Docker container name. */ diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index ceae64d84..e4feec692 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -237,10 +237,11 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'application_id' => $newApplication->id, ]); $newApplicationSettings->save(); + $newApplication->setRelation('settings', $newApplicationSettings->fresh()); } // Clone tags @@ -256,7 +257,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'application_id' => $newApplication->id, 'team_id' => currentTeam()->id, @@ -271,7 +272,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'uuid' => (string) new Cuid2, 'application_id' => $newApplication->id, 'status' => 'exited', @@ -303,7 +304,7 @@ function clone_application(Application $source, $destination, array $overrides = 'created_at', 'updated_at', 'uuid', - ])->fill([ + ])->forceFill([ 'name' => $newName, 'resource_id' => $newApplication->id, ]); @@ -339,7 +340,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resource_id' => $newApplication->id, ]); $newStorage->save(); @@ -353,7 +354,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $newApplication->id, 'resourceable_type' => $newApplication->getMorphClass(), 'is_preview' => false, @@ -370,7 +371,7 @@ function clone_application(Application $source, $destination, array $overrides = 'id', 'created_at', 'updated_at', - ])->fill([ + ])->forceFill([ 'resourceable_id' => $newApplication->id, 'resourceable_type' => $newApplication->getMorphClass(), 'is_preview' => true, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9..a43f2e340 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1919,7 +1919,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal // Create new serviceApplication or serviceDatabase if ($isDatabase) { if ($isNew) { - $savedService = ServiceDatabase::create([ + $savedService = ServiceDatabase::forceCreate([ 'name' => $serviceName, 'image' => $image, 'service_id' => $resource->id, @@ -1930,7 +1930,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'service_id' => $resource->id, ])->first(); if (is_null($savedService)) { - $savedService = ServiceDatabase::create([ + $savedService = ServiceDatabase::forceCreate([ 'name' => $serviceName, 'image' => $image, 'service_id' => $resource->id, @@ -1939,7 +1939,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } else { if ($isNew) { - $savedService = ServiceApplication::create([ + $savedService = ServiceApplication::forceCreate([ 'name' => $serviceName, 'image' => $image, 'service_id' => $resource->id, @@ -1950,7 +1950,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal 'service_id' => $resource->id, ])->first(); if (is_null($savedService)) { - $savedService = ServiceApplication::create([ + $savedService = ServiceApplication::forceCreate([ 'name' => $serviceName, 'image' => $image, 'service_id' => $resource->id, diff --git a/openapi.json b/openapi.json index ed8decb48..239068300 100644 --- a/openapi.json +++ b/openapi.json @@ -4331,6 +4331,11 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage (MB) for S3 backups" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 } }, "type": "object" @@ -4896,6 +4901,11 @@ "database_backup_retention_max_storage_s3": { "type": "integer", "description": "Max storage of the backup in S3" + }, + "timeout": { + "type": "integer", + "description": "Backup job timeout in seconds (min: 60, max: 36000)", + "default": 3600 } }, "type": "object" @@ -10451,6 +10461,26 @@ "none" ], "description": "The proxy type." + }, + "concurrent_builds": { + "type": "integer", + "description": "Number of concurrent builds." + }, + "dynamic_timeout": { + "type": "integer", + "description": "Deployment timeout in seconds." + }, + "deployment_queue_limit": { + "type": "integer", + "description": "Maximum number of queued deployments." + }, + "server_disk_usage_notification_threshold": { + "type": "integer", + "description": "Server disk usage notification threshold (%)." + }, + "server_disk_usage_check_frequency": { + "type": "string", + "description": "Cron expression for disk usage check frequency." } }, "type": "object" diff --git a/openapi.yaml b/openapi.yaml index 157cd9f69..5bf6059af 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2734,6 +2734,10 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage (MB) for S3 backups' + timeout: + type: integer + description: 'Backup job timeout in seconds (min: 60, max: 36000)' + default: 3600 type: object responses: '201': @@ -3125,6 +3129,10 @@ paths: database_backup_retention_max_storage_s3: type: integer description: 'Max storage of the backup in S3' + timeout: + type: integer + description: 'Backup job timeout in seconds (min: 60, max: 36000)' + default: 3600 type: object responses: '200': @@ -6669,6 +6677,21 @@ paths: type: string enum: [traefik, caddy, none] description: 'The proxy type.' + concurrent_builds: + type: integer + description: 'Number of concurrent builds.' + dynamic_timeout: + type: integer + description: 'Deployment timeout in seconds.' + deployment_queue_limit: + type: integer + description: 'Maximum number of queued deployments.' + server_disk_usage_notification_threshold: + type: integer + description: 'Server disk usage notification threshold (%).' + server_disk_usage_check_frequency: + type: string + description: 'Cron expression for disk usage check frequency.' type: object responses: '201': diff --git a/resources/css/app.css b/resources/css/app.css index 3cfa03dae..2c30baf64 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -14,7 +14,9 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: Inter, sans-serif; + --font-sans: 'Geist Sans', Inter, sans-serif; + --font-geist-sans: 'Geist Sans', Inter, sans-serif; + --font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; --color-base: #101010; --color-warning: #fcd452; @@ -96,7 +98,7 @@ body { } body { - @apply min-h-screen text-sm antialiased scrollbar overflow-x-hidden; + @apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden; } .coolify-monaco-editor { diff --git a/resources/css/fonts.css b/resources/css/fonts.css index c8c4448eb..e5c6a694d 100644 --- a/resources/css/fonts.css +++ b/resources/css/fonts.css @@ -70,3 +70,18 @@ src: url('../fonts/inter-v13-cyrillic_cyrillic-ext_greek_greek-ext_latin_latin-ext_vietnamese-regular.woff2') format('woff2'); } +@font-face { + font-display: swap; + font-family: 'Geist Mono'; + font-style: normal; + font-weight: 100 900; + src: url('../fonts/geist-mono-variable.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: 'Geist Sans'; + font-style: normal; + font-weight: 100 900; + src: url('../fonts/geist-sans-variable.woff2') format('woff2'); +} diff --git a/resources/fonts/geist-mono-variable.woff2 b/resources/fonts/geist-mono-variable.woff2 new file mode 100644 index 000000000..c8a7d8401 Binary files /dev/null and b/resources/fonts/geist-mono-variable.woff2 differ diff --git a/resources/fonts/geist-sans-variable.woff2 b/resources/fonts/geist-sans-variable.woff2 new file mode 100644 index 000000000..7ebce69dc Binary files /dev/null and b/resources/fonts/geist-sans-variable.woff2 differ diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 3c52edfa0..aa5f37353 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -186,7 +186,7 @@ export function initializeTerminalComponent() { this.term = new Terminal({ cols: 80, rows: 30, - fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', + fontFamily: '"Geist Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace, "Powerline Extra Symbols"', cursorBlink: true, rendererType: 'canvas', convertEol: true, diff --git a/resources/views/livewire/activity-monitor.blade.php b/resources/views/livewire/activity-monitor.blade.php index 290a91857..72b68edd0 100644 --- a/resources/views/livewire/activity-monitor.blade.php +++ b/resources/views/livewire/activity-monitor.blade.php @@ -52,7 +52,7 @@ 'flex-1 min-h-0' => $fullHeight, 'max-h-96' => !$fullHeight, ])> -
{{ RunRemoteProcess::decodeOutput($activity) }}
+
{{ RunRemoteProcess::decodeOutput($activity) }}
@else @if ($showWaiting) diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index 538851137..410703010 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -72,7 +72,7 @@
- + @@ -82,7 +82,7 @@
-
diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index 28872f4bc..c17cda55f 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -330,7 +330,7 @@
-
+
No matches found. @@ -356,7 +356,7 @@ ])>{{ $lineContent }}
@empty - No logs yet. + No logs yet. @endforelse
diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index bb5dcfc4d..d5c25916a 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -81,10 +81,10 @@ @endif
- + - + helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required /> +

Backup Retention Settings

@@ -101,13 +101,13 @@
+ helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." required /> + helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required /> + helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
@@ -117,13 +117,13 @@
+ helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." required /> + helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required /> + helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
@endif diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index ee5b65cf5..cb2dcfed1 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -480,7 +480,7 @@ @php $displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== ''); @endphp -
+
No matches found. @@ -518,7 +518,7 @@
@else
No logs yet.
+ class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet. @endif
diff --git a/resources/views/livewire/server/advanced.blade.php b/resources/views/livewire/server/advanced.blade.php index 33086aea1..f6610c1d5 100644 --- a/resources/views/livewire/server/advanced.blade.php +++ b/resources/views/livewire/server/advanced.blade.php @@ -22,6 +22,7 @@ id="serverDiskUsageCheckFrequency" label="Disk usage check frequency" required helper="Cron expression for disk usage check frequency.
You can use every_minute, hourly, daily, weekly, monthly, yearly.

Default is every night at 11:00 PM." /> @@ -31,12 +32,15 @@

Builds

diff --git a/resources/views/livewire/server/docker-cleanup-executions.blade.php b/resources/views/livewire/server/docker-cleanup-executions.blade.php index c59d53d26..d0b848cf1 100644 --- a/resources/views/livewire/server/docker-cleanup-executions.blade.php +++ b/resources/views/livewire/server/docker-cleanup-executions.blade.php @@ -100,7 +100,7 @@ - {{ data_get($result, 'command') }} + {{ data_get($result, 'command') }} @php $output = data_get($result, 'output'); @@ -108,7 +108,7 @@ @endphp
@if($hasOutput) -
{{ $output }}
+
{{ $output }}
@else

No output returned - command completed successfully diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php index 4016a30e4..5ca535cbc 100644 --- a/resources/views/livewire/server/sentinel.blade.php +++ b/resources/views/livewire/server/sentinel.blade.php @@ -91,13 +91,14 @@

- - -
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index c58ea189d..93abd628c 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -53,7 +53,7 @@ - +
diff --git a/tests/Feature/ClonePersistentVolumeUuidTest.php b/tests/Feature/ClonePersistentVolumeUuidTest.php index f1ae8dd26..3f99c5585 100644 --- a/tests/Feature/ClonePersistentVolumeUuidTest.php +++ b/tests/Feature/ClonePersistentVolumeUuidTest.php @@ -1,15 +1,18 @@ user = User::factory()->create(); @@ -17,7 +20,7 @@ beforeEach(function () { $this->user->teams()->attach($this->team, ['role' => 'owner']); $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); @@ -25,8 +28,13 @@ beforeEach(function () { 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), + 'redirect' => 'both', ]); + $this->application->settings->forceFill([ + 'is_container_label_readonly_enabled' => false, + ])->save(); + $this->actingAs($this->user); session(['currentTeam' => $this->team]); }); @@ -82,3 +90,71 @@ test('cloning application with multiple persistent volumes generates unique uuid expect($clonedUuids)->each->not->toBeIn($originalUuids); expect(array_unique($clonedUuids))->toHaveCount(2); }); + +test('cloning application reassigns settings to the cloned application', function () { + $this->application->settings->forceFill([ + 'is_static' => true, + 'is_spa' => true, + 'is_build_server_enabled' => true, + ])->save(); + + $newApp = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $sourceSettingsCount = ApplicationSetting::query() + ->where('application_id', $this->application->id) + ->count(); + $clonedSettings = ApplicationSetting::query() + ->where('application_id', $newApp->id) + ->first(); + + expect($sourceSettingsCount)->toBe(1) + ->and($clonedSettings)->not->toBeNull() + ->and($clonedSettings?->application_id)->toBe($newApp->id) + ->and($clonedSettings?->is_static)->toBeTrue() + ->and($clonedSettings?->is_spa)->toBeTrue() + ->and($clonedSettings?->is_build_server_enabled)->toBeTrue(); +}); + +test('cloning application reassigns scheduled tasks and previews to the cloned application', function () { + $scheduledTask = ScheduledTask::forceCreate([ + 'uuid' => 'scheduled-task-original', + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'name' => 'nightly-task', + 'command' => 'php artisan schedule:run', + 'frequency' => '* * * * *', + 'container' => 'app', + 'timeout' => 120, + ]); + + $preview = ApplicationPreview::forceCreate([ + 'uuid' => 'preview-original', + 'application_id' => $this->application->id, + 'pull_request_id' => 123, + 'pull_request_html_url' => 'https://example.com/pull/123', + 'fqdn' => 'https://preview.example.com', + 'status' => 'running', + ]); + + $newApp = clone_application($this->application, $this->destination, [ + 'environment_id' => $this->environment->id, + ]); + + $clonedTask = ScheduledTask::query() + ->where('application_id', $newApp->id) + ->first(); + $clonedPreview = ApplicationPreview::query() + ->where('application_id', $newApp->id) + ->first(); + + expect($clonedTask)->not->toBeNull() + ->and($clonedTask?->uuid)->not->toBe($scheduledTask->uuid) + ->and($clonedTask?->application_id)->toBe($newApp->id) + ->and($clonedTask?->team_id)->toBe($this->team->id) + ->and($clonedPreview)->not->toBeNull() + ->and($clonedPreview?->uuid)->not->toBe($preview->uuid) + ->and($clonedPreview?->application_id)->toBe($newApp->id) + ->and($clonedPreview?->status)->toBe('exited'); +}); diff --git a/tests/Feature/DatabaseSslStatusRefreshTest.php b/tests/Feature/DatabaseSslStatusRefreshTest.php new file mode 100644 index 000000000..eab2b08db --- /dev/null +++ b/tests/Feature/DatabaseSslStatusRefreshTest.php @@ -0,0 +1,77 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +dataset('ssl-aware-database-general-components', [ + 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(); + + 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('reloads the mysql database model when refreshing 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]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $database = StandaloneMysql::forceCreate([ + 'name' => 'test-mysql', + 'image' => 'mysql:8', + 'mysql_root_password' => 'password', + 'mysql_user' => 'coolify', + 'mysql_password' => 'password', + 'mysql_database' => 'coolify', + '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(MysqlGeneral::class, ['database' => $database]) + ->assertDontSee('Database should be stopped to change this settings.'); + + $database->forceFill(['status' => 'running:healthy'])->save(); + + $component->call('refresh') + ->assertSee('Database should be stopped to change this settings.'); +}); diff --git a/tests/Feature/LogFontStylingTest.php b/tests/Feature/LogFontStylingTest.php new file mode 100644 index 000000000..c7903fb45 --- /dev/null +++ b/tests/Feature/LogFontStylingTest.php @@ -0,0 +1,45 @@ +toContain("font-family: 'Geist Mono'") + ->toContain("url('../fonts/geist-mono-variable.woff2')") + ->toContain("font-family: 'Geist Sans'") + ->toContain("url('../fonts/geist-sans-variable.woff2')") + ->and($appCss) + ->toContain("--font-sans: 'Geist Sans', Inter, sans-serif") + ->toContain('@apply min-h-screen text-sm font-sans antialiased scrollbar overflow-x-hidden;') + ->toContain("--font-logs: 'Geist Mono'") + ->toContain("--font-geist-sans: 'Geist Sans'") + ->and($fontPath) + ->toBeFile() + ->and($geistSansPath) + ->toBeFile(); +}); + +it('uses geist mono for shared logs and terminal rendering', function () { + $sharedLogsView = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php')); + $deploymentLogsView = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php')); + $activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php')); + $dockerCleanupView = file_get_contents(resource_path('views/livewire/server/docker-cleanup-executions.blade.php')); + $terminalClient = file_get_contents(resource_path('js/terminal.js')); + + expect($sharedLogsView) + ->toContain('class="font-logs max-w-full cursor-default"') + ->toContain('class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400"') + ->and($deploymentLogsView) + ->toContain('class="flex flex-col font-logs"') + ->toContain('class="font-logs text-neutral-400 mb-2"') + ->and($activityMonitorView) + ->toContain('
and($dockerCleanupView)
+        ->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
+        ->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
+        ->and($terminalClient)
+        ->toContain('"Geist Mono"');
+});
diff --git a/tests/Unit/ServiceParserImageUpdateTest.php b/tests/Unit/ServiceParserImageUpdateTest.php
index 526505098..649795866 100644
--- a/tests/Unit/ServiceParserImageUpdateTest.php
+++ b/tests/Unit/ServiceParserImageUpdateTest.php
@@ -41,7 +41,8 @@ it('ensures parseDockerComposeFile does not create duplicates on null savedServi
     // The new code checks for null within the else block and creates only if needed
     expect($sharedFile)
         ->toContain('if (is_null($savedService)) {')
-        ->toContain('$savedService = ServiceDatabase::create([');
+        ->toContain('$savedService = ServiceDatabase::forceCreate([')
+        ->toContain('$savedService = ServiceApplication::forceCreate([');
 });
 
 it('verifies image update logic is present in parseDockerComposeFile', function () {