Merge remote-tracking branch 'origin/next' into feat/refresh-repos

This commit is contained in:
Andras Bacsai 2026-03-31 12:37:38 +02:00
commit 25c0c88eb2
43 changed files with 563 additions and 95 deletions

View file

@ -643,6 +643,7 @@ public function update_by_uuid(Request $request)
'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 @@ public function update_by_uuid(Request $request)
)]
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 @@ public function create_backup(Request $request)
'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 @@ public function create_backup(Request $request)
'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 @@ public function create_backup(Request $request)
)]
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 @@ public function update_backup(Request $request)
'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 @@ public function update_backup(Request $request)
$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.',

View file

@ -598,6 +598,11 @@ public function create_server(Request $request)
'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 @@ public function create_server(Request $request)
)]
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 @@ public function update_server(Request $request)
'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 @@ public function update_server(Request $request)
'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);
}

View file

@ -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;

View file

@ -153,8 +153,8 @@ protected function rules(): array
'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 @@ protected function messages(): array
'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 @@ public function submit($showToaster = true)
$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;

View file

@ -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()
{

View file

@ -79,7 +79,7 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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;
}

View file

@ -57,7 +57,8 @@ public function getListeners()
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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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 @@ public function regenerateSslCertificate()
handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -59,7 +59,8 @@ public function getListeners()
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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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 @@ public function regenerateSslCertificate()
handleError($e, $this);
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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;
}

View file

@ -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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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;
}

View file

@ -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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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;
}

View file

@ -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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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 @@ public function submit()
}
}
}
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
}

View file

@ -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 @@ protected function rules(): array
'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 @@ protected function messages(): array
{
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 @@ public function submit()
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', '>=')) {

View file

@ -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 @@ private function syncData(bool $toModel = false): void
// 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 @@ public function submit()
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 @@ public function submit()
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 @@ public function submit()
$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);
}

View file

@ -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)
{

View file

@ -6,6 +6,7 @@
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 @@ public function getListeners()
];
}
protected $rules = [
'generateExactLabels' => 'required|boolean',
];
protected function rules()
{
return [
'generateExactLabels' => 'required|boolean',
'redirectUrl' => ['nullable', new SafeExternalUrl],
];
}
public function mount()
{
@ -147,6 +152,7 @@ public function submit()
{
try {
$this->authorize('update', $this->server);
$this->validate();
SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save();

View file

@ -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;

View file

@ -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 @@ public function rules()
'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',

View file

@ -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;

View file

@ -0,0 +1,35 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidDnsServers implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (empty($value)) {
return;
}
$entries = explode(',', $value);
$invalidEntries = [];
foreach ($entries as $entry) {
$entry = trim($entry);
if (empty($entry)) {
continue;
}
if (! filter_var($entry, FILTER_VALIDATE_IP)) {
$invalidEntries[] = $entry;
}
}
if (! empty($invalidEntries)) {
$fail('The following entries are not valid DNS server IP addresses: '.implode(', ', $invalidEntries));
}
}
}

View file

@ -201,6 +201,12 @@ public static function volumeNameMessages(string $field = 'name'): array
];
}
/**
* Pattern for port mappings (e.g. 3000:3000, 8080:80, 8000-8010:8000-8010)
* Each entry requires host:container format, where each side can be a number or a range (number-number)
*/
public const PORT_MAPPINGS_PATTERN = '/^(\d+(-\d+)?:\d+(-\d+)?)(,\d+(-\d+)?:\d+(-\d+)?)*$/';
/**
* Get validation rules for container name fields
*/
@ -209,6 +215,24 @@ public static function containerNameRules(int $maxLength = 255): array
return ['string', 'max:'.$maxLength, 'regex:'.self::CONTAINER_NAME_PATTERN];
}
/**
* Get validation rules for port mapping fields
*/
public static function portMappingRules(): array
{
return ['nullable', 'string', 'regex:'.self::PORT_MAPPINGS_PATTERN];
}
/**
* Get validation messages for port mapping fields
*/
public static function portMappingMessages(string $field = 'portsMappings'): array
{
return [
"{$field}.regex" => '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.
*/

View file

@ -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,

View file

@ -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,

View file

@ -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"

View file

@ -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':

View file

@ -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 {

View file

@ -70,3 +70,18 @@ @font-face {
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');
}

Binary file not shown.

Binary file not shown.

View file

@ -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,

View file

@ -52,7 +52,7 @@
'flex-1 min-h-0' => $fullHeight,
'max-h-96' => !$fullHeight,
])>
<pre class="font-mono whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
<pre class="font-logs whitespace-pre-wrap" @if ($isPollingActive) wire:poll.1000ms="polling" @endif>{{ RunRemoteProcess::decodeOutput($activity) }}</pre>
</div>
@else
@if ($showWaiting)

View file

@ -72,7 +72,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full gap-2 xl:flex-row">
<x-forms.input canGate="update" :canResource="$settings" required id="smtpHost" placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort" placeholder="587" label="Port" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort" type="number" placeholder="587" label="Port" />
<x-forms.select canGate="update" :canResource="$settings" required id="smtpEncryption" label="Encryption">
<option value="starttls">StartTLS</option>
<option value="tls">TLS/SSL</option>
@ -82,7 +82,7 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl
<div class="flex flex-col w-full gap-2 xl:flex-row">
<x-forms.input canGate="update" :canResource="$settings" id="smtpUsername" label="SMTP Username" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpPassword" type="password" label="SMTP Password" />
<x-forms.input canGate="update" :canResource="$settings" id="smtpTimeout" helper="Timeout value for sending emails."
<x-forms.input canGate="update" :canResource="$settings" id="smtpTimeout" type="number" helper="Timeout value for sending emails."
label="Timeout" />
</div>
</div>

View file

@ -330,7 +330,7 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
<div id="logsContainer"
class="flex flex-col overflow-y-auto p-2 px-4 min-h-4 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[30rem]'">
<div id="logs" class="flex flex-col font-mono">
<div id="logs" class="flex flex-col font-logs">
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
@ -356,7 +356,7 @@ class="shrink-0 text-gray-500">{{ $line['timestamp'] }}</span>
])>{{ $lineContent }}</span>
</div>
@empty
<span class="font-mono text-neutral-400 mb-2">No logs yet.</span>
<span class="font-logs text-neutral-400 mb-2">No logs yet.</span>
@endforelse
</div>
</div>

View file

@ -81,10 +81,10 @@
@endif
</div>
<div class="flex gap-2">
<x-forms.input label="Frequency" id="frequency" />
<x-forms.input label="Frequency" id="frequency" required />
<x-forms.input label="Timezone" id="timezone" disabled
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" />
<x-forms.input label="Timeout" id="timeout" helper="The timeout of the backup job in seconds." />
helper="The timezone of the server where the backup is scheduled to run (if not set, the instance timezone will be used)" required />
<x-forms.input label="Timeout" id="timeout" type="number" min="60" helper="The timeout of the backup job in seconds." required />
</div>
<h3 class="mt-6 mb-2 text-lg font-medium">Backup Retention Settings</h3>
@ -101,13 +101,13 @@
<div class="flex gap-2">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountLocally"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." />
helper="Keeps only the specified number of most recent backups on the server. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysLocally" type="number"
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." />
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0"
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." />
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 />
</div>
</div>
@ -117,13 +117,13 @@
<div class="flex gap-2">
<x-forms.input label="Number of backups to keep" id="databaseBackupRetentionAmountS3"
type="number" min="0"
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." />
helper="Keeps only the specified number of most recent backups on S3 storage. Set to 0 for unlimited backups." required />
<x-forms.input label="Days to keep backups" id="databaseBackupRetentionDaysS3" type="number"
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." />
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0"
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." />
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 />
</div>
</div>
@endif

View file

@ -480,7 +480,7 @@ class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0
@php
$displayLines = collect(explode("\n", $outputs))->filter(fn($line) => trim($line) !== '');
@endphp
<div id="logs" class="font-mono max-w-full cursor-default">
<div id="logs" class="font-logs max-w-full cursor-default">
<div x-show="searchQuery.trim() && matchCount === 0"
class="text-gray-500 dark:text-gray-400 py-2">
No matches found.
@ -518,7 +518,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
</div>
@else
<pre id="logs"
class="font-mono whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
class="font-logs whitespace-pre-wrap break-all max-w-full text-neutral-400">No logs yet.</pre>
@endif
</div>
</div>

View file

@ -22,6 +22,7 @@
id="serverDiskUsageCheckFrequency" label="Disk usage check frequency" required
helper="Cron expression for disk usage check frequency.<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every night at 11:00 PM." />
<x-forms.input canGate="update" :canResource="$server" id="serverDiskUsageNotificationThreshold"
type="number" min="1" max="99"
label="Server disk usage notification threshold (%)" required
helper="If the server disk usage exceeds this threshold, Coolify will send a notification to the team members." />
</div>
@ -31,12 +32,15 @@
<h3>Builds</h3>
<div class="flex flex-wrap gap-2 sm:flex-nowrap pt-4">
<x-forms.input canGate="update" :canResource="$server" id="concurrentBuilds"
type="number" min="1"
label="Number of concurrent builds" required
helper="You can specify the number of simultaneous build processes/deployments that should run concurrently." />
<x-forms.input canGate="update" :canResource="$server" id="dynamicTimeout"
type="number" min="1"
label="Deployment timeout (seconds)" required
helper="You can define the maximum duration for a deployment to run before timing it out." />
<x-forms.input canGate="update" :canResource="$server" id="deploymentQueueLimit"
type="number" min="1"
label="Deployment queue limit" required
helper="Maximum number of queued deployments allowed. New deployments will be rejected with a 429 status when the limit is reached." />
</div>

View file

@ -100,7 +100,7 @@
<svg class="h-5 w-5 shrink-0 text-gray-500 dark:text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<code class="flex-1 text-sm font-mono text-gray-700 dark:text-gray-300">{{ data_get($result, 'command') }}</code>
<code class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300">{{ data_get($result, 'command') }}</code>
</div>
@php
$output = data_get($result, 'output');
@ -108,7 +108,7 @@
@endphp
<div class="p-4">
@if($hasOutput)
<pre class="font-mono text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ $output }}</pre>
<pre class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ $output }}</pre>
@else
<p class="text-sm text-gray-500 dark:text-gray-400 italic">
No output returned - command completed successfully

View file

@ -91,13 +91,14 @@
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 sm:flex-nowrap">
<x-forms.input canGate="update" :canResource="$server"
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsRefreshRateSeconds" label="Metrics rate (seconds)" required
helper="Interval used for gathering metrics. Lower values result in more disk space usage." />
<x-forms.input canGate="update" :canResource="$server" id="sentinelMetricsHistoryDays"
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
id="sentinelMetricsHistoryDays"
label="Metrics history (days)" required
helper="Number of days to retain metrics data for." />
<x-forms.input canGate="update" :canResource="$server"
<x-forms.input canGate="update" :canResource="$server" type="number" min="10"
id="sentinelPushIntervalSeconds" label="Push interval (seconds)" required
helper="Interval at which metrics data is sent to the collector." />
</div>

View file

@ -53,7 +53,7 @@
<x-forms.input id="smtpUsername" label="SMTP Username" />
<x-forms.input id="smtpPassword" type="password" label="SMTP Password"
autocomplete="new-password" />
<x-forms.input id="smtpTimeout" helper="Timeout value for sending emails." label="Timeout" />
<x-forms.input id="smtpTimeout" type="number" helper="Timeout value for sending emails." label="Timeout" />
</div>
</div>
</form>

View file

@ -1,15 +1,18 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
@ -17,7 +20,7 @@
$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 @@
'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 @@
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');
});

View file

@ -0,0 +1,77 @@
<?php
use App\Livewire\Project\Database\Dragonfly\General as DragonflyGeneral;
use App\Livewire\Project\Database\Keydb\General as KeydbGeneral;
use App\Livewire\Project\Database\Mariadb\General as MariadbGeneral;
use App\Livewire\Project\Database\Mongodb\General as MongodbGeneral;
use App\Livewire\Project\Database\Mysql\General as MysqlGeneral;
use App\Livewire\Project\Database\Postgresql\General as PostgresqlGeneral;
use App\Livewire\Project\Database\Redis\General as RedisGeneral;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMysql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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.');
});

View file

@ -0,0 +1,45 @@
<?php
it('registers geist mono from a local asset for log surfaces', function () {
$fontsCss = file_get_contents(resource_path('css/fonts.css'));
$appCss = file_get_contents(resource_path('css/app.css'));
$fontPath = resource_path('fonts/geist-mono-variable.woff2');
$geistSansPath = resource_path('fonts/geist-sans-variable.woff2');
expect($fontsCss)
->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('<pre class="font-logs whitespace-pre-wrap"')
->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"');
});

View file

@ -41,7 +41,8 @@
// 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 () {