Merge branch 'main' into third-party-integration-tokens

This commit is contained in:
Andras Bacsai
2026-08-18 16:15:53 +02:00
185 changed files with 3283 additions and 593 deletions
+1
View File
@@ -179,6 +179,7 @@ Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentin
- Run `vendor/bin/pint --dirty --format agent` before finalizing changes - Run `vendor/bin/pint --dirty --format agent` before finalizing changes
- Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below) - Every change must have tests — write or update tests, then run them. For bug fixes, follow TDD: write a failing test first, then fix the bug (see Test Enforcement below)
- Check sibling files for conventions before creating new files - Check sibling files for conventions before creating new files
- When adding remote shell commands, account for servers using non-root SSH users: commands pass through `parseCommandsByLineForSudo()`, so test pipelines, redirects, substitutions, and `sh -c`/`bash -c` scripts with the non-root sudo parser.
## Git Workflow ## Git Workflow
@@ -54,6 +54,14 @@ class CleanupPreviewDeployment
$server $server
); );
if ($result['cancelled_deployments'] > 0) {
try {
next_after_cancel($server);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cleaning up preview for application {$application->id}: {$e->getMessage()}");
}
}
// Step 2: Stop and remove all running PR containers // Step 2: Stop and remove all running PR containers
$result['killed_containers'] = $this->stopRunningContainers( $result['killed_containers'] = $this->stopRunningContainers(
$application, $application,
@@ -98,13 +106,13 @@ class CleanupPreviewDeployment
$deployment->update([ $deployment->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]); ]);
$cancelled++;
// Add cancellation log entry // Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
// Try to kill helper container if it exists // Try to kill helper container if it exists
$this->killHelperContainer($deployment->deployment_uuid, $server); $this->killHelperContainer($deployment->deployment_uuid, $server);
$cancelled++;
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}");
} }
+3 -3
View File
@@ -208,11 +208,11 @@ class StartMariadb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -257,11 +257,11 @@ class StartMongodb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -209,11 +209,11 @@ class StartMysql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
+3 -3
View File
@@ -219,11 +219,11 @@ class StartPostgresql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md"; $this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'"; $this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null";
}
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true'; $this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true"; $this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d"; $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'"; $this->commands[] = "echo 'Database started.'";
+9 -1
View File
@@ -13,6 +13,8 @@ class GetProxyConfiguration
{ {
use AsAction; use AsAction;
public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public function handle(Server $server, bool $forceRegenerate = false): string public function handle(Server $server, bool $forceRegenerate = false): string
{ {
$proxyType = $server->proxyType(); $proxyType = $server->proxyType();
@@ -98,11 +100,17 @@ class GetProxyConfiguration
private function backfillFromDisk(Server $server): ?string private function backfillFromDisk(Server $server): ?string
{ {
$proxy_path = $server->proxyPath(); $proxy_path = $server->proxyPath();
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([ $result = instant_remote_process([
"mkdir -p $proxy_path", "mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null", "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
], $server, false); ], $server, false);
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}
if (! empty(trim($result ?? ''))) { if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result; $server->proxy->last_saved_proxy_configuration = $result;
$server->save(); $server->save();
+1 -1
View File
@@ -131,7 +131,7 @@ class CleanupDocker
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ". $commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
$grepCommands.' | '. $grepCommands.' | '.
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true"; "xargs -r -I {} sh -c 'docker inspect --format \"{{index .Config.Labels \\\"coolify.managed\\\"}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
return implode(' && ', $commands); return implode(' && ', $commands);
} }
@@ -88,6 +88,10 @@ class UpdateServiceApplicationFromApi
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
} }
if (array_key_exists('is_force_https_enabled', $payload)) {
$serviceApplication->is_force_https_enabled = filter_var($payload['is_force_https_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_log_drain_enabled', $payload)) { if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server; $server = $serviceApplication->service->destination->server;
+45 -23
View File
@@ -238,57 +238,71 @@ class DeployController extends Controller
ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::IN_PROGRESS->value,
]; ];
if (! in_array($deployment->status, $cancellableStatuses)) { if (! in_array($deployment->status, $cancellableStatuses, true)) {
return response()->json([ return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}", 'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400); ], 400);
} }
// Perform the cancellation // Perform the cancellation
$cancelled = false;
$deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id);
try { try {
$deployment_uuid = $deployment->deployment_uuid; $deployment_uuid = $deployment->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}"; $kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $deployment->build_server_id ?? $deployment->server_id; $build_server_id = $deployment->build_server_id ?? $deployment->server_id;
// Mark deployment as cancelled // Mark deployment as cancelled
$deployment->update([ $updated = ApplicationDeploymentQueue::whereKey($deployment->getKey())
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ->whereIn('status', $cancellableStatuses)
]); ->update(['status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value]);
if ($updated !== 1) {
$deployment->refresh();
return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400);
}
$deployment->status = ApplicationDeploymentStatus::CANCELLED_BY_USER->value;
$cancelled = true;
// Get the server // Get the server
$server = Server::whereTeamId($teamId)->find($build_server_id); $server = Server::whereTeamId($teamId)->find($build_server_id);
if ($server) { try {
// Add cancellation log entry if ($server) {
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr'); // Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
// Check if container exists and kill it // Check if container exists and kill it
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server); $containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process([$kill_command], $server); instant_remote_process([$kill_command], $server);
$deployment->addLogEntry('Deployment container stopped.'); $deployment->addLogEntry('Deployment container stopped.');
} else { } else {
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.'); $deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
} }
// Kill running process if process ID exists // Kill running process if process ID exists
if ($deployment->current_process_id) { if ($deployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$deployment->current_process_id}"; $processKillCommand = "kill -9 {$deployment->current_process_id}";
instant_remote_process([$processKillCommand], $server); instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
} }
} }
} catch (\Throwable $e) {
\Log::warning("Failed to clean up cancelled deployment {$deployment->id}: {$e->getMessage()}");
} }
auditLog('api.deployment.cancelled', [ auditLog('api.deployment.cancelled', [
'team_id' => $teamId, 'team_id' => $teamId,
'deployment_uuid' => $deployment->deployment_uuid, 'deployment_uuid' => $deployment->deployment_uuid,
'application_id' => $application?->id, 'application_id' => $deployment->application_id,
'application_uuid' => $application?->uuid, 'application_uuid' => $deployment->application?->uuid,
'server_id' => $deployment->server_id, 'server_id' => $deployment->server_id,
]); ]);
@@ -301,6 +315,14 @@ class DeployController extends Controller
return response()->json([ return response()->json([
'message' => 'Failed to cancel deployment: '.$e->getMessage(), 'message' => 'Failed to cancel deployment: '.$e->getMessage(),
], 500); ], 500);
} finally {
if ($cancelled) {
try {
next_after_cancel($deploymentServer);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}");
}
}
} }
} }
@@ -256,6 +256,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
'is_force_https_enabled' => new OA\Property(property: 'is_force_https_enabled', type: 'boolean', nullable: true),
] ]
) )
) )
@@ -328,6 +329,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled', 'is_log_drain_enabled',
'is_gzip_enabled', 'is_gzip_enabled',
'is_stripprefix_enabled', 'is_stripprefix_enabled',
'is_force_https_enabled',
]; ];
$validationRules = [ $validationRules = [
@@ -341,6 +343,7 @@ class ServiceApplicationsController extends Controller
'is_log_drain_enabled' => 'sometimes|boolean', 'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean', 'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean', 'is_stripprefix_enabled' => 'sometimes|boolean',
'is_force_https_enabled' => 'sometimes|boolean',
]; ];
$validator = Validator::make($payload, $validationRules); $validator = Validator::make($payload, $validationRules);
@@ -34,7 +34,7 @@ use RuntimeException;
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000), new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000),
], ],
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -261,7 +261,7 @@ class VolumeBackupsController extends Controller
string $resourceType, string $resourceType,
Model $resource, Model $resource,
): JsonResponse { ): JsonResponse {
$backup = $storage->scheduledBackups()->updateOrCreate([], [ $attributes = [
'team_id' => $teamId, 'team_id' => $teamId,
'frequency' => $request->string('frequency')->toString(), 'frequency' => $request->string('frequency')->toString(),
'enabled' => $request->boolean('enabled', true), 'enabled' => $request->boolean('enabled', true),
@@ -275,8 +275,12 @@ class VolumeBackupsController extends Controller
'retention_amount_s3' => $request->integer('retention_amount_s3', 7), 'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
'retention_days_s3' => $request->integer('retention_days_s3'), 'retention_days_s3' => $request->integer('retention_days_s3'),
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
'timeout' => $request->integer('timeout', 3600), ];
]); if ($request->has('timeout')) {
$attributes['timeout'] = $request->integer('timeout');
}
$backup = $storage->scheduledBackups()->updateOrCreate([], $attributes);
$created = $backup->wasRecentlyCreated; $created = $backup->wasRecentlyCreated;
auditLog('api.volume_backup.schedule_set', [ auditLog('api.volume_backup.schedule_set', [
+1
View File
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller class Gitlab extends Controller
{ {
+35 -5
View File
@@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
private const DOCKER_CLIENT_ENV_KEYS = [ private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST', 'BUILDKIT_HOST',
'BUILDX_BUILDER', 'BUILDX_BUILDER',
@@ -3977,15 +3979,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
); );
} else { } else {
$this->execute_remote_command( $this->execute_remote_command(
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
$this->removeContainerWithTimeout($containerName);
} }
} catch (Exception $error) { } catch (Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
} }
} }
private function removeContainerWithTimeout(string $containerName): void
{
$outputKey = 'container_remove_'.md5($containerName);
$this->execute_remote_command([
dockerRemoveCommandWithTimeout($containerName),
'hidden' => true,
'ignore_errors' => true,
'save' => $outputKey,
'append' => false,
]);
if (! isset($this->saved_outputs)) {
return;
}
$output = (string) $this->saved_outputs->get($outputKey, '');
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
return;
}
$this->application_deployment_queue->addLogEntry(
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
'stderr'
);
RemoveContainerJob::dispatch($this->server->id, $containerName)
->delay(now()->addMinutes(5));
}
private function stop_running_container(bool $force = false) private function stop_running_container(bool $force = false)
{ {
try { try {
@@ -5016,9 +5048,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// do not remove already running container for PR deployments // do not remove already running container for PR deployments
} else { } else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command( $this->removeContainerWithTimeout($this->container_name);
["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true]
);
} }
} }
} }
+7 -1
View File
@@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
$this->clearOutdatedInfo();
// Detect current version (makes SSH call) // Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server); $currentVersion = getTraefikVersionFromDockerCompose($this->server);
// Update detected version in database
$this->server->update(['detected_traefik_version' => $currentVersion]); $this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) { if (! $currentVersion) {
@@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
ProxyStatusChangedUI::dispatch($this->server->team_id); ProxyStatusChangedUI::dispatch($this->server->team_id);
} }
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
}
/** /**
* Get information about newer branches if available. * Get information about newer branches if available.
*/ */
+28 -27
View File
@@ -279,33 +279,10 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else { } else {
return; return;
} }
} else { }
if (str($databaseType)->contains('postgres')) { $databasesToBackup = $this->databasesToBackup($databaseType, $databasesToBackup);
// Format: db1,db2,db3 if ($databasesToBackup === []) {
$databasesToBackup = explode(',', $databasesToBackup); return;
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mongo')) {
// Format: db1:collection1,collection2|db2:collection3,collection4
// Only explode if it's a string, not if it's already an array
if (is_string($databasesToBackup)) {
$databasesToBackup = explode('|', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
}
} elseif (str($databaseType)->contains('mysql')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif (str($databaseType)->contains('mariadb')) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} elseif ($this->database instanceof StandaloneClickhouse) {
// Format: db1,db2,db3
$databasesToBackup = explode(',', $databasesToBackup);
$databasesToBackup = array_map('trim', $databasesToBackup);
} else {
return;
}
} }
$this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name; $this->backup_dir = backup_dir().'/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name;
if ($this->database->name === 'coolify-db') { if ($this->database->name === 'coolify-db') {
@@ -600,6 +577,30 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
/** @return array<int, string> */
private function databasesToBackup(string $databaseType, string|array $databases): array
{
$type = str($databaseType);
if ($this->backup->dump_all && $type->contains(['postgres', 'mysql', 'mariadb'])) {
return ['all'];
}
if (is_array($databases)) {
return $databases;
}
if ($type->contains('mongo')) {
return array_map('trim', explode('|', $databases));
}
if ($type->contains(['postgres', 'mysql', 'mariadb', 'clickhouse'])) {
return array_map('trim', explode(',', $databases));
}
return [];
}
private function backup_standalone_postgresql(string $database): void private function backup_standalone_postgresql(string $database): void
{ {
try { try {
+11
View File
@@ -158,12 +158,15 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
]) ])
->get(); ->get();
$cancelledDeployments = 0;
foreach ($activeDeployments as $activeDeployment) { foreach ($activeDeployments as $activeDeployment) {
try { try {
// Mark deployment as cancelled // Mark deployment as cancelled
$activeDeployment->update([ $activeDeployment->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]); ]);
$cancelledDeployments++;
// Add cancellation log entry // Add cancellation log entry
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
@@ -186,6 +189,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
if ($cancelledDeployments > 0) {
try {
next_after_cancel($server);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after deleting preview {$this->resource->id}: {$e->getMessage()}");
}
}
try { try {
if ($server->isSwarm()) { if ($server->isSwarm()) {
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RemoveContainerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 90;
public function __construct(public int $serverId, public string $containerName) {}
public function handle(): void
{
$server = Server::findOrFail($this->serverId);
instant_remote_process(
[dockerRemoveCommandWithTimeout($this->containerName)],
$server,
timeout: 75,
disableMultiplexing: true,
);
}
public function backoff(): array
{
return [300, 900];
}
public function failed(?\Throwable $exception): void
{
Log::warning('Deferred container removal failed', [
'server_id' => $this->serverId,
'container' => $this->containerName,
'error' => $exception?->getMessage(),
]);
}
}
+14 -2
View File
@@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;
/** /**
* The number of times the job may be attempted. * The number of times the job may be attempted.
*/ */
@@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
foreach ($this->containers as $containerName) { foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}"; $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736 // See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
$this->task_log->update([ $this->task_log->update([
'status' => 'success', 'status' => 'success',
'message' => $this->task_output, 'message' => $this->task_output,
@@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
private function boundedTaskCommand(string $command): string
{
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;
$readLimit = $maxOutputBytes + 1;
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}
/** /**
* Calculate the number of seconds to wait before retrying the job. * Calculate the number of seconds to wait before retrying the job.
*/ */
+2 -2
View File
@@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
public int $maxExceptions = 1; public int $maxExceptions = 1;
public int $timeout = 3600; public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
private ?ScheduledVolumeBackupExecution $execution = null; private ?ScheduledVolumeBackupExecution $execution = null;
public function __construct(public ScheduledVolumeBackup $backup) public function __construct(public ScheduledVolumeBackup $backup)
{ {
$this->onQueue(crons_queue()); $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600; $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT;
} }
public function middleware(): array public function middleware(): array
+14 -1
View File
@@ -29,7 +29,10 @@ class ActivityMonitor extends Component
public static $eventDispatched = false; public static $eventDispatched = false;
protected $listeners = ['activityMonitor' => 'newMonitorActivity']; protected $listeners = [
'activityMonitor' => 'newMonitorActivity',
'processDialogClosed' => 'clearActivity',
];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null) public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{ {
@@ -50,6 +53,16 @@ class ActivityMonitor extends Component
$this->isPollingActive = true; $this->isPollingActive = true;
} }
public function clearActivity(): void
{
$this->activityId = null;
$this->activity = null;
$this->isPollingActive = false;
$this->eventToDispatch = 'activityFinished';
$this->eventData = null;
self::$eventDispatched = false;
}
public function hydrateActivity() public function hydrateActivity()
{ {
if ($this->activityId === null) { if ($this->activityId === null) {
-6
View File
@@ -54,12 +54,6 @@ class DeploymentsIndicator extends Component
return $this->deployments->count(); return $this->deployments->count();
} }
#[Computed]
public function shouldReduceOpacity(): bool
{
return request()->routeIs('project.application.deployment.*');
}
public function toggleExpanded() public function toggleExpanded()
{ {
$this->expanded = ! $this->expanded; $this->expanded = ! $this->expanded;
@@ -82,7 +82,7 @@ class Create extends Component
'type' => 'Directory', 'type' => 'Directory',
'name' => $directory->fs_path, 'name' => $directory->fs_path,
]); ]);
$this->targets = $volumes->concat($directories)->values(); $this->targets = collect($volumes->concat($directories)->all())->values();
$this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key'); $this->targetKey = $this->selectedTargetKey ?? data_get($this->targets->first(), 'key');
$this->loadSelectedBackup(); $this->loadSelectedBackup();
} }
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker; use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application; use App\Models\Application;
use App\Models\Server; use App\Models\Server;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -22,6 +23,8 @@ class Domains extends Component
public string $redirect = 'both'; public string $redirect = 'both';
public bool $isForceHttpsEnabled = true;
/** /**
* Per compose-service www/non-www redirect direction. * Per compose-service www/non-www redirect direction.
* Keys are wire-safe (dots encoded) use serviceRedirectWireKey(). * Keys are wire-safe (dots encoded) use serviceRedirectWireKey().
@@ -35,12 +38,20 @@ class Domains extends Component
public string $newDomain = ''; public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?string $newDomainService = null; public ?string $newDomainService = null;
public ?int $editingIndex = null; public ?int $editingIndex = null;
public string $editingDomain = ''; public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?string $editingService = null; public ?string $editingService = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */ /** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool}> */
@@ -100,6 +111,7 @@ class Domains extends Component
'newDomain' => ValidationPatterns::applicationDomainRules(), 'newDomain' => ValidationPatterns::applicationDomainRules(),
'editingDomain' => ValidationPatterns::applicationDomainRules(), 'editingDomain' => ValidationPatterns::applicationDomainRules(),
'redirect' => 'string|required|in:both,www,non-www', 'redirect' => 'string|required|in:both,www,non-www',
'isForceHttpsEnabled' => 'boolean',
'serviceRedirects' => 'array', 'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www', 'serviceRedirects.*' => 'string|in:both,www,non-www',
]; ];
@@ -151,6 +163,18 @@ class Domains extends Component
$this->setRedirect(); $this->setRedirect();
} }
public function updateForceHttps(): void
{
$this->authorize('update', $this->application);
$this->validateOnly('isForceHttpsEnabled');
$this->application->settings->is_force_https_enabled = $this->isForceHttpsEnabled;
$this->application->settings->save();
$this->resetDefaultLabels();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'HTTP to HTTPS redirect updated.');
}
public function loadDomainState(): void public function loadDomainState(): void
{ {
$this->application->refresh(); $this->application->refresh();
@@ -159,6 +183,7 @@ class Domains extends Component
$this->isCompose = $this->application->build_pack === 'dockercompose'; $this->isCompose = $this->application->build_pack === 'dockercompose';
$this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false; $this->labelsAreWritable = $this->application->settings->is_container_label_readonly_enabled === false;
$this->redirect = $this->application->redirect ?? 'both'; $this->redirect = $this->application->redirect ?? 'both';
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
$settings = instanceSettings(); $settings = instanceSettings();
$this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true); $this->dnsValidationEnabled = (bool) data_get($settings, 'is_dns_validation_enabled', true);
@@ -662,6 +687,12 @@ class Domains extends Component
$this->resetAddDomainDnsGate(); $this->resetAddDomainDnsGate();
} }
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainService(): void public function updatedNewDomainService(): void
{ {
$this->resetAddDomainDnsGate(); $this->resetAddDomainDnsGate();
@@ -677,6 +708,8 @@ class Domains extends Component
public function resetAddDomainForm(): void public function resetAddDomainForm(): void
{ {
$this->newDomain = ''; $this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->resetAddDomainDnsGate(); $this->resetAddDomainDnsGate();
$this->resetErrorBag('newDomain'); $this->resetErrorBag('newDomain');
} }
@@ -743,6 +776,9 @@ class Domains extends Component
return; return;
} }
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain'); $this->validateOnly('newDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain); $normalized = ValidationPatterns::normalizeApplicationDomains($this->newDomain);
@@ -893,6 +929,12 @@ class Domains extends Component
$this->resetEditDomainDnsGate(); $this->resetEditDomainDnsGate();
} }
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->resetEditDomainDnsGate();
}
public function resetEditDomainDnsGate(): void public function resetEditDomainDnsGate(): void
{ {
$this->editDomainDnsFailed = false; $this->editDomainDnsFailed = false;
@@ -908,10 +950,13 @@ class Domains extends Component
$this->editingIndex = $index; $this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingService = $this->domainRows[$index]['service']; $this->editingService = $this->domainRows[$index]['service'];
$this->resetEditDomainDnsGate(); $this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain'); $this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true; $this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
} }
public function addSuggestedDomain(int $index): void public function addSuggestedDomain(int $index): void
@@ -990,6 +1035,8 @@ class Domains extends Component
$this->showEditDomainModal = false; $this->showEditDomainModal = false;
$this->editingIndex = null; $this->editingIndex = null;
$this->editingDomain = ''; $this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingService = null; $this->editingService = null;
$this->resetEditDomainDnsGate(); $this->resetEditDomainDnsGate();
$this->resetErrorBag('editingDomain'); $this->resetErrorBag('editingDomain');
@@ -1021,6 +1068,9 @@ class Domains extends Component
return; return;
} }
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain'); $this->validateOnly('editingDomain');
$normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain); $normalized = ValidationPatterns::normalizeApplicationDomains($this->editingDomain);
@@ -98,26 +98,34 @@ class BackupExecutions extends Component
return; return;
} }
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
? $execution->scheduledDatabaseBackup->database->service->destination->server
: $execution->scheduledDatabaseBackup->database->destination->server;
try { try {
if ($execution->filename) { $deleteFromS3 = in_array('delete_backup_s3', $selectedActions, true);
deleteBackupsLocally($execution->filename, $server);
if ($this->delete_backup_s3 && $execution->scheduledDatabaseBackup->s3) { if ($execution->filename && ! $execution->local_storage_deleted) {
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3); $server = $this->backup->server();
if (! $server) {
throw new \RuntimeException('The backup server is unavailable.');
} }
deleteBackupsLocally($execution->filename, $server, throwError: true);
}
if ($deleteFromS3 && $execution->s3_uploaded && ! $execution->s3_storage_deleted) {
if (! $execution->scheduledDatabaseBackup->s3) {
throw new \RuntimeException('The S3 storage is unavailable.');
}
deleteBackupsS3($execution->filename, $execution->scheduledDatabaseBackup->s3);
} }
$execution->delete(); $execution->delete();
$this->delete_backup_s3 = false;
$this->dispatch('success', 'Backup deleted.'); $this->dispatch('success', 'Backup deleted.');
$this->refreshBackupExecutions(); $this->refreshBackupExecutions();
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return true; return false;
} }
return true; return true;
@@ -209,11 +209,15 @@ class General extends Component
} }
} }
public function instantSave() public function instantSave(?bool $isPublic = null)
{ {
try { try {
$this->authorize('update', $this->database); $this->authorize('update', $this->database);
if ($isPublic !== null) {
$this->isPublic = $isPublic;
}
if ($this->isPublic && ! $this->publicPort) { if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.'); $this->dispatch('error', 'Public port is required.');
$this->isPublic = false; $this->isPublic = false;
@@ -134,8 +134,9 @@ class GithubPrivateRepository extends Component
public function loadBranches() public function loadBranches()
{ {
$this->selected_repository_owner = $this->repositories->where('id', $this->selected_repository_id)->first()['owner']['login']; $repository = $this->repositories->firstWhere('id', $this->selected_repository_id);
$this->selected_repository_repo = $this->repositories->where('id', $this->selected_repository_id)->first()['name']; $this->selected_repository_owner = data_get($repository, 'owner.login');
$this->selected_repository_repo = data_get($repository, 'name');
$this->branches = collect(); $this->branches = collect();
$this->page = 1; $this->page = 1;
$this->loadBranchByPage(); $this->loadBranchByPage();
@@ -146,7 +147,10 @@ class GithubPrivateRepository extends Component
} }
} }
$this->branches = sortBranchesByPriority($this->branches); $this->branches = sortBranchesByPriority($this->branches);
$this->selected_branch_name = data_get($this->branches, '0.name', 'main'); $defaultBranch = data_get($repository, 'default_branch', 'main');
$this->selected_branch_name = $this->branches->contains('name', $defaultBranch)
? $defaultBranch
: data_get($this->branches, '0.name', 'main');
} }
protected function loadBranchByPage() protected function loadBranchByPage()
+65
View File
@@ -33,6 +33,9 @@ class Domains extends Component
*/ */
public array $serviceRedirects = []; public array $serviceRedirects = [];
/** @var array<int|string, bool> */
public array $forceHttpsRedirects = [];
/** Service application id when a pending domain conflict belongs to setServiceRedirect. */ /** Service application id when a pending domain conflict belongs to setServiceRedirect. */
public ?int $pendingRedirectServiceApplicationId = null; public ?int $pendingRedirectServiceApplicationId = null;
@@ -43,10 +46,18 @@ class Domains extends Component
public string $newDomain = ''; public string $newDomain = '';
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $newDomainPartsChanged = false;
public ?int $editingIndex = null; public ?int $editingIndex = null;
public string $editingDomain = ''; public string $editingDomain = '';
public array $editingDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $editingDomainPartsChanged = false;
public ?int $editingServiceApplicationId = null; public ?int $editingServiceApplicationId = null;
public bool $showEditDomainModal = false; public bool $showEditDomainModal = false;
@@ -102,6 +113,8 @@ class Domains extends Component
'newServiceApplicationId' => 'nullable|integer', 'newServiceApplicationId' => 'nullable|integer',
'serviceRedirects' => 'array', 'serviceRedirects' => 'array',
'serviceRedirects.*' => 'string|in:both,www,non-www', 'serviceRedirects.*' => 'string|in:both,www,non-www',
'forceHttpsRedirects' => 'array',
'forceHttpsRedirects.*' => 'boolean',
]; ];
} }
@@ -135,6 +148,22 @@ class Domains extends Component
$this->dispatch('success', 'Search engine indexing updated.'); $this->dispatch('success', 'Search engine indexing updated.');
} }
public function updateForceHttps(int $serviceApplicationId, bool $enabled): void
{
$application = $this->service->applications()->findOrFail($serviceApplicationId);
$this->authorize('update', $application);
$this->forceHttpsRedirects[$serviceApplicationId] = $enabled;
$this->validateOnly("forceHttpsRedirects.{$serviceApplicationId}");
$application->is_force_https_enabled = $enabled;
$application->save();
$this->service->parse();
$this->refreshDomains();
$this->dispatch('configurationChanged')->to(ConfigurationChecker::class);
$this->dispatch('success', 'HTTP to HTTPS redirect updated.');
}
public function loadDomainState(): void public function loadDomainState(): void
{ {
$this->service->loadMissing(['applications', 'server']); $this->service->loadMissing(['applications', 'server']);
@@ -159,6 +188,10 @@ class Domains extends Component
$this->serverIpConfigured = null; $this->serverIpConfigured = null;
} }
$this->forceHttpsRedirects = $this->service->applications
->mapWithKeys(fn (ServiceApplication $app) => [$app->id => $app->isForceHttpsEnabled()])
->all();
$this->serviceApps = $this->service->applications $this->serviceApps = $this->service->applications
->sortBy(fn (ServiceApplication $app) => strtolower($app->human_name ?: $app->name)) ->sortBy(fn (ServiceApplication $app) => strtolower($app->human_name ?: $app->name))
->values() ->values()
@@ -509,6 +542,17 @@ class Domains extends Component
} }
public function updatedNewDomain(): void public function updatedNewDomain(): void
{
$this->resetAddDomainDnsGate();
}
public function updatedNewDomainParts(): void
{
$this->newDomainPartsChanged = true;
$this->resetAddDomainDnsGate();
}
public function resetAddDomainDnsGate(): void
{ {
$this->addDomainDnsFailed = false; $this->addDomainDnsFailed = false;
$this->addDomainDnsMessage = ''; $this->addDomainDnsMessage = '';
@@ -522,6 +566,12 @@ class Domains extends Component
$this->forceSaveEditDns = false; $this->forceSaveEditDns = false;
} }
public function updatedEditingDomainParts(): void
{
$this->editingDomainPartsChanged = true;
$this->updatedEditingDomain();
}
public function confirmAddDomainDespiteDns(): void public function confirmAddDomainDespiteDns(): void
{ {
$this->forceSaveDns = true; $this->forceSaveDns = true;
@@ -842,6 +892,9 @@ class Domains extends Component
{ {
try { try {
$this->authorize('update', $this->service); $this->authorize('update', $this->service);
if ($this->newDomainPartsChanged) {
$this->newDomain = DomainUrlParts::compose(...$this->newDomainParts);
}
$this->validateOnly('newDomain'); $this->validateOnly('newDomain');
$app = $this->findServiceApp($this->newServiceApplicationId); $app = $this->findServiceApp($this->newServiceApplicationId);
@@ -893,6 +946,8 @@ class Domains extends Component
} }
$this->newDomain = ''; $this->newDomain = '';
$this->newDomainParts = DomainUrlParts::empty();
$this->newDomainPartsChanged = false;
$this->addDomainDnsFailed = false; $this->addDomainDnsFailed = false;
$this->addDomainDnsMessage = ''; $this->addDomainDnsMessage = '';
$this->forceSaveDns = false; $this->forceSaveDns = false;
@@ -916,12 +971,15 @@ class Domains extends Component
$this->editingIndex = $index; $this->editingIndex = $index;
$this->editingDomain = $this->domainRows[$index]['url']; $this->editingDomain = $this->domainRows[$index]['url'];
$this->editingDomainParts = DomainUrlParts::split($this->editingDomain);
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id']; $this->editingServiceApplicationId = (int) $this->domainRows[$index]['service_application_id'];
$this->editDomainDnsFailed = false; $this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = ''; $this->editDomainDnsMessage = '';
$this->forceSaveEditDns = false; $this->forceSaveEditDns = false;
$this->resetErrorBag('editingDomain'); $this->resetErrorBag('editingDomain');
$this->showEditDomainModal = true; $this->showEditDomainModal = true;
$this->dispatch('open-edit-domain');
} }
public function cancelEdit(): void public function cancelEdit(): void
@@ -929,6 +987,8 @@ class Domains extends Component
$this->showEditDomainModal = false; $this->showEditDomainModal = false;
$this->editingIndex = null; $this->editingIndex = null;
$this->editingDomain = ''; $this->editingDomain = '';
$this->editingDomainParts = DomainUrlParts::empty();
$this->editingDomainPartsChanged = false;
$this->editingServiceApplicationId = null; $this->editingServiceApplicationId = null;
$this->editDomainDnsFailed = false; $this->editDomainDnsFailed = false;
$this->editDomainDnsMessage = ''; $this->editDomainDnsMessage = '';
@@ -945,6 +1005,9 @@ class Domains extends Component
return; return;
} }
if ($this->editingDomainPartsChanged) {
$this->editingDomain = DomainUrlParts::compose(...$this->editingDomainParts);
}
$this->validateOnly('editingDomain'); $this->validateOnly('editingDomain');
$app = $this->findServiceApp($this->editingServiceApplicationId); $app = $this->findServiceApp($this->editingServiceApplicationId);
@@ -1130,6 +1193,8 @@ class Domains extends Component
} }
$this->newDomain = $domain; $this->newDomain = $domain;
$this->newDomainParts = DomainUrlParts::split($domain);
$this->newDomainPartsChanged = true;
$this->updatedNewDomain(); $this->updatedNewDomain();
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
+34 -8
View File
@@ -25,6 +25,8 @@ class GetLogs extends Component
{ {
public const MAX_LOG_LINES = 50000; public const MAX_LOG_LINES = 50000;
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = ''; public string $outputs = '';
@@ -154,14 +156,12 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; $command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} else { } else {
if ($this->server->isSwarm()) { if ($this->server->isSwarm()) {
@@ -170,22 +170,39 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}"; $command = "docker logs -n {$this->numberOfLines} {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} }
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Collect new logs into temporary variable first to prevent flickering // Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready) // (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation // Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = []; $logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { $accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;
return;
}
$logChunks[] = removeAnsiColors($output); $logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
}); });
$newOutputs = implode('', $logChunks); $newOutputs = implode('', $logChunks);
@@ -198,6 +215,10 @@ class GetLogs extends Component
})->join("\n"); })->join("\n");
} }
if ($truncated) {
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
}
// Only update outputs after new data is ready (atomic update prevents flicker) // Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs; $this->outputs = $newOutputs;
} }
@@ -239,6 +260,7 @@ class GetLogs extends Component
$command = $command[0]; $command = $command[0];
} }
$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation // Use array accumulation + implode for O(n) instead of O(n²) string concatenation
@@ -252,20 +274,19 @@ class GetLogs extends Component
return; return;
} }
$output = removeAnsiColors($output);
$outputBytes = strlen($output); $outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) { if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining); $logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
} }
$truncated = true; $truncated = true;
return; return;
} }
$logChunks[] = $output; $logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes; $accumulatedBytes += $outputBytes;
}); });
@@ -287,6 +308,11 @@ class GetLogs extends Component
return sanitizeLogsForExport($allLogs); return sanitizeLogsForExport($allLogs);
} }
private function boundedLogCommand(string $command, int $maxBytes): string
{
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
}
public function render() public function render()
{ {
return view('livewire.project.shared.get-logs'); return view('livewire.project.shared.get-logs');
@@ -10,6 +10,7 @@ use Livewire\Component;
class Executions extends Component class Executions extends Component
{ {
#[Locked]
public ScheduledTask $task; public ScheduledTask $task;
#[Locked] #[Locked]
@@ -28,6 +29,7 @@ class Executions extends Component
public $logsPerPage = 100; public $logsPerPage = 100;
#[Locked]
public $selectedExecution = null; public $selectedExecution = null;
public $isPollingActive = false; public $isPollingActive = false;
@@ -45,7 +47,7 @@ class Executions extends Component
{ {
try { try {
$this->taskId = $taskId; $this->taskId = $taskId;
$this->task = ScheduledTask::findOrFail($taskId); $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
$this->executions = $this->task->executions()->take(20)->get(); $this->executions = $this->task->executions()->take(20)->get();
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
if (! $this->serverTimezone) { if (! $this->serverTimezone) {
@@ -15,8 +15,10 @@ class Show extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
#[Locked]
public Application|Service $resource; public Application|Service $resource;
#[Locked]
public ScheduledTask $task; public ScheduledTask $task;
#[Locked] #[Locked]
@@ -115,6 +117,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->isEnabled = ! $this->isEnabled; $this->isEnabled = ! $this->isEnabled;
$this->task->enabled = $this->isEnabled; $this->task->enabled = $this->isEnabled;
$this->task->save(); $this->task->save();
@@ -128,6 +131,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.'); $this->dispatch('success', 'Scheduled task updated.');
$this->refreshTasks(); $this->refreshTasks();
@@ -140,6 +144,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.'); $this->dispatch('success', 'Scheduled task updated.');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -160,6 +165,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('delete', $this->task);
$this->task->delete(); $this->task->delete();
if ($this->type === 'application') { if ($this->type === 'application') {
@@ -176,6 +182,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task); ScheduledTaskJob::dispatch($this->task);
$this->dispatch('success', 'Scheduled task executed.'); $this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -56,7 +56,7 @@ class VolumeBackups extends Component
public string $timezone = ''; public string $timezone = '';
public int $timeout = 3600; public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
public int $perPage = 10; public int $perPage = 10;
+16 -3
View File
@@ -163,6 +163,7 @@ class Navbar extends Component
$previousStatus = $this->proxyStatus; $previousStatus = $this->proxyStatus;
$this->server->refresh(); $this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown'; $this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->dispatchProxyConfigurationState();
// If event contains activityId, open activity monitor // If event contains activityId, open activity monitor
if ($event && isset($event['activityId'])) { if ($event && isset($event['activityId'])) {
@@ -227,6 +228,16 @@ class Navbar extends Component
{ {
$this->server->refresh(); $this->server->refresh();
$this->server->load('settings'); $this->server->load('settings');
$this->dispatchProxyConfigurationState();
}
private function dispatchProxyConfigurationState(): void
{
$this->dispatch(
'proxy-configuration-state-changed',
pending: $this->server->hasPendingProxyConfiguration(),
traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(),
);
} }
public function refreshSentinelStatus($event = null): void public function refreshSentinelStatus($event = null): void
@@ -248,10 +259,12 @@ class Navbar extends Component
return false; return false;
} }
// Check if server has outdated info stored return $this->server->hasCurrentTraefikOutdatedInfo();
$outdatedInfo = $this->server->traefik_outdated_info; }
return ! empty($outdatedInfo) && isset($outdatedInfo['type']); public function getHasPendingProxyConfigurationProperty(): bool
{
return $this->server->hasPendingProxyConfiguration();
} }
public function render() public function render()
+5 -1
View File
@@ -161,6 +161,7 @@ class Proxy extends Component
$this->server->proxy->redirect_url = $this->redirectUrl; $this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save(); $this->server->save();
$this->server->setupDefaultRedirect(); $this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.'); $this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -175,6 +176,7 @@ class Proxy extends Component
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings); SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save(); $this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.'); $this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -276,7 +278,9 @@ class Proxy extends Component
// Check if we have outdated info stored for this server (faster than computing) // Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info; $outdatedInfo = $this->server->traefik_outdated_info;
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') { $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6") // Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) { if (isset($outdatedInfo['upgrade_target'])) {
return str_starts_with($outdatedInfo['upgrade_target'], 'v') return str_starts_with($outdatedInfo['upgrade_target'], 'v')
@@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;
public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_CONFIGURATION_FILES = 100;
public ?Server $server = null; public ?Server $server = null;
public $parameters = []; public $parameters = [];
@@ -44,15 +50,36 @@ class DynamicConfigurations extends Component
return handleError($e, $this); return handleError($e, $this);
} }
$proxy_path = $this->server->proxyPath(); $proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); $fileLimit = self::MAX_CONFIGURATION_FILES + 1;
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
$files = $files->map(fn ($file) => trim($file)); $files = $files->map(fn ($file) => trim($file));
$files = $files->sort(); $files = $files->sort();
$contents = collect([]); $contents = collect([]);
foreach ($files as $file) { $skippedFiles = collect([]);
$totalBytes = 0;
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
$skippedFiles->push('additional files');
}
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
$without_extension = str_replace('.', '|', $file); $without_extension = str_replace('.', '|', $file);
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
$contents[$without_extension] = $content ?? ''; $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
$content = $content ?? '';
$contentBytes = strlen($content);
if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
$skippedFiles->push($file);
continue;
}
$contents[$without_extension] = $content;
$totalBytes += $contentBytes;
}
if ($skippedFiles->isNotEmpty()) {
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
} }
$this->contents = $contents; $this->contents = $contents;
$this->dispatch('$refresh'); $this->dispatch('$refresh');
+1 -1
View File
@@ -146,7 +146,7 @@ class Sentinel extends Component
{ {
try { try {
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated.'); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
+5
View File
@@ -20,6 +20,9 @@ class Index extends Component
#[Validate('nullable|string|max:255|url')] #[Validate('nullable|string|max:255|url')]
public ?string $fqdn = null; public ?string $fqdn = null;
#[Validate('boolean')]
public bool $is_dashboard_force_https_enabled = true;
#[Validate('required|integer|min:1025|max:65535')] #[Validate('required|integer|min:1025|max:65535')]
public int $public_port_min; public int $public_port_min;
@@ -68,6 +71,7 @@ class Index extends Component
$this->server = Server::findOrFail(0); $this->server = Server::findOrFail(0);
} }
$this->fqdn = $this->settings->fqdn; $this->fqdn = $this->settings->fqdn;
$this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled;
$this->public_port_min = $this->settings->public_port_min; $this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max; $this->public_port_max = $this->settings->public_port_max;
$this->instance_name = $this->settings->instance_name; $this->instance_name = $this->settings->instance_name;
@@ -91,6 +95,7 @@ class Index extends Component
$this->authorize('update', $this->settings); $this->authorize('update', $this->settings);
$this->validate(); $this->validate();
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled;
$this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max; $this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name; $this->settings->instance_name = $this->instance_name;
+13
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl; use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName; use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri; use Illuminate\Support\Uri;
@@ -28,6 +29,10 @@ class Create extends Component
public string $endpoint = ''; public string $endpoint = '';
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public S3Storage $storage; public S3Storage $storage;
protected function rules(): array protected function rules(): array
@@ -76,6 +81,9 @@ class Create extends Component
try { try {
$this->authorize('create', S3Storage::class); $this->authorize('create', S3Storage::class);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$this->endpoint = $this->normalizeEndpoint($this->endpoint); $this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate(); $this->validate();
$this->storage = new S3Storage; $this->storage = new S3Storage;
@@ -101,6 +109,11 @@ class Create extends Component
} }
} }
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
private function connectionErrorDescription(\Throwable $exception): string private function connectionErrorDescription(\Throwable $exception): string
{ {
$settingsUrl = route('settings.advanced').'#endpoint-section'; $settingsUrl = route('settings.advanced').'#endpoint-section';
+18
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl; use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName; use App\Rules\ValidS3BucketName;
use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -24,6 +25,10 @@ class Form extends Component
public string $endpoint; public string $endpoint;
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
public string $bucket; public string $bucket;
public string $region; public string $region;
@@ -101,6 +106,8 @@ class Form extends Component
$this->name = $this->storage->name; $this->name = $this->storage->name;
$this->description = $this->storage->description; $this->description = $this->storage->description;
$this->endpoint = $this->storage->endpoint; $this->endpoint = $this->storage->endpoint;
$this->endpointParts = DomainUrlParts::split($this->endpoint);
$this->endpointPartsChanged = false;
$this->bucket = $this->storage->bucket; $this->bucket = $this->storage->bucket;
$this->region = $this->storage->region; $this->region = $this->storage->region;
$this->key = $this->storage->key; $this->key = $this->storage->key;
@@ -126,6 +133,9 @@ class Form extends Component
try { try {
$this->authorize('validateConnection', $this->storage); $this->authorize('validateConnection', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
$testedStorage = new S3Storage; $testedStorage = new S3Storage;
$testedStorage->uuid = $this->storage->uuid; $testedStorage->uuid = $this->storage->uuid;
$testedStorage->team_id = $this->storage->team_id; $testedStorage->team_id = $this->storage->team_id;
@@ -166,6 +176,9 @@ class Form extends Component
{ {
try { try {
$this->authorize('update', $this->storage); $this->authorize('update', $this->storage);
if ($this->endpointPartsChanged) {
$this->endpoint = DomainUrlParts::compose(...$this->endpointParts);
}
DB::transaction(function () { DB::transaction(function () {
$this->validate(); $this->validate();
@@ -195,4 +208,9 @@ class Form extends Component
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
}
} }
+13
View File
@@ -47,6 +47,18 @@ trait BuildsResponse
// app/env secrets // app/env secrets
'value', 'real_value', 'http_basic_auth_password', 'value', 'real_value', 'http_basic_auth_password',
// free-form commands / configurations can embed credentials
'git_full_url',
'install_command', 'build_command', 'start_command',
'health_check_command', 'health_check_response_text',
'custom_docker_run_options', 'pre_deployment_command', 'post_deployment_command',
'docker_compose_custom_start_command', 'docker_compose_custom_build_command',
'custom_nginx_configuration',
// raw database configuration blobs
'postgres_conf', 'mysql_conf', 'mariadb_conf', 'mongo_conf',
'redis_conf', 'keydb_conf',
// database connection strings embed credentials // database connection strings embed credentials
'internal_db_url', 'external_db_url', 'init_scripts', 'internal_db_url', 'external_db_url', 'init_scripts',
@@ -58,6 +70,7 @@ trait BuildsResponse
// bulky / unsafe blobs // bulky / unsafe blobs
'dockerfile', 'docker_compose', 'docker_compose_raw', 'dockerfile', 'docker_compose', 'docker_compose_raw',
'last_saved_proxy_configuration',
'custom_labels', 'environment_variables', 'custom_labels', 'environment_variables',
'environment_variables_preview', 'validation_logs', 'environment_variables_preview', 'validation_logs',
'server_metadata', 'logs', 'configuration_snapshot', 'server_metadata', 'logs', 'configuration_snapshot',
+7
View File
@@ -104,6 +104,13 @@ class CancelDeployment extends Tool
'server_id' => $deployment->server_id, 'server_id' => $deployment->server_id,
]); ]);
try {
$deploymentServer = Server::whereTeamId($teamId)->find($deployment->server_id);
next_after_cancel($deploymentServer);
} catch (\Throwable $e) {
\Log::warning("Failed to advance deployment queue after cancelling deployment {$deployment->id}: {$e->getMessage()}");
}
return $this->mcpSuccess($request, $this->respond([ return $this->mcpSuccess($request, $this->respond([
'ok' => true, 'ok' => true,
'message' => 'Deployment cancelled successfully.', 'message' => 'Deployment cancelled successfully.',
+13 -2
View File
@@ -122,6 +122,8 @@ class Application extends BaseModel
{ {
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
private static $parserVersion = '5'; private static $parserVersion = '5';
protected $fillable = [ protected $fillable = [
@@ -2109,6 +2111,9 @@ class Application extends BaseModel
$workdir = rtrim($this->base_directory, '/'); $workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location; $composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]); $fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) { if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
@@ -2139,7 +2144,7 @@ class Application extends BaseModel
'git sparse-checkout init', 'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}", "git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD', 'git read-tree -mu HEAD',
"cat .$workdir$composeFile", $readComposeFile,
]); ]);
} else { } else {
$commands = collect([ $commands = collect([
@@ -2151,11 +2156,14 @@ class Application extends BaseModel
'git sparse-checkout init --cone', 'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}", "git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD', 'git read-tree -mu HEAD',
"cat .$workdir$composeFile", $readComposeFile,
]); ]);
} }
try { try {
$composeFileContent = instant_remote_process($commands, $this->destination->server); $composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
}
} catch (\Exception $e) { } catch (\Exception $e) {
// Restore original values on failure only // Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation; $this->docker_compose_location = $initialDockerComposeLocation;
@@ -2171,6 +2179,9 @@ class Application extends BaseModel
} }
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
} }
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally { } finally {
// Cleanup only - restoration happens in catch block // Cleanup only - restoration happens in catch block
+6
View File
@@ -9,6 +9,10 @@ use Spatie\Url\Url;
class InstanceSettings extends Model class InstanceSettings extends Model
{ {
protected $attributes = [
'is_dashboard_force_https_enabled' => true,
];
protected $fillable = [ protected $fillable = [
'public_ipv4', 'public_ipv4',
'public_ipv6', 'public_ipv6',
@@ -52,6 +56,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost', 'webhook_allow_localhost',
'avatar_storage_type', 'avatar_storage_type',
'avatar_s3_storage_id', 'avatar_s3_storage_id',
'is_dashboard_force_https_enabled',
]; ];
protected $hidden = [ protected $hidden = [
@@ -92,6 +97,7 @@ class InstanceSettings extends Model
'is_mcp_server_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array', 'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean', 'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
]; ];
protected static function booted(): void protected static function booted(): void
+24 -3
View File
@@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel
return; return;
} }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false); $content = $this->readRemoteFileContent($escapedPath, $server);
// Check if content contains binary data by looking for null bytes or non-printable characters // Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
$content = self::BINARY_PLACEHOLDER; $content = self::BINARY_PLACEHOLDER;
} }
$this->content = $content; $this->content = $content;
@@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel
return $size > self::MAX_CONTENT_SIZE; return $size > self::MAX_CONTENT_SIZE;
} }
/**
* Cap the remote read itself so a file that grows after the size check
* cannot be fully slurped into PHP memory.
*/
protected function readRemoteFileContent(string $escapedPath, $server): string
{
$readLimit = self::MAX_CONTENT_SIZE + 1;
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
return self::contentFromBoundedRead($content);
}
public static function contentFromBoundedRead(?string $content): string
{
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
return self::TOO_LARGE_PLACEHOLDER;
}
return (string) $content;
}
public function deleteStorageOnServer() public function deleteStorageOnServer()
{ {
if ($this->is_host_file) { if ($this->is_host_file) {
@@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel
if ($this->remoteFileExceedsLimit($escapedPath, $server)) { if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER; $this->content = self::TOO_LARGE_PLACEHOLDER;
} else { } else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); $this->content = $this->readRemoteFileContent($escapedPath, $server);
} }
$this->is_directory = false; $this->is_directory = false;
$this->save(); $this->save();
+1 -1
View File
@@ -198,7 +198,7 @@ class S3Storage extends BaseModel
try { try {
$mail = new MailMessage; $mail = new MailMessage;
$mail->subject('Coolify: S3 Storage Connection Error'); $mail->subject('Coolify: S3 Storage Connection Error');
$mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]); $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => base_url().'/storages/'.$this->uuid]);
// Load the team with its members and their roles explicitly // Load the team with its members and their roles explicitly
$team = $this->team()->with(['members' => function ($query) { $team = $this->team()->with(['members' => function ($query) {
+1
View File
@@ -11,6 +11,7 @@ class ScheduledDatabaseBackup extends BaseModel
protected function casts(): array protected function casts(): array
{ {
return [ return [
'dump_all' => 'boolean',
'database_backup_retention_max_storage_locally' => 'float', 'database_backup_retention_max_storage_locally' => 'float',
'database_backup_retention_max_storage_s3' => 'float', 'database_backup_retention_max_storage_s3' => 'float',
]; ];
+2
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledVolumeBackup extends BaseModel class ScheduledVolumeBackup extends BaseModel
{ {
public const int DEFAULT_TIMEOUT = 36000;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
'backupable_type', 'backupable_type',
+52 -4
View File
@@ -731,11 +731,12 @@ class Server extends BaseModel
]; ];
if ($schema === 'https') { if ($schema === 'https') {
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [ $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
0 => 'redirect-to-https',
];
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [ $traefik_dynamic_conf['http']['routers']['coolify-https'] = [
'middlewares' => [
0 => 'gzip',
],
'entryPoints' => [ 'entryPoints' => [
0 => 'https', 0 => 'https',
], ],
@@ -789,8 +790,10 @@ class Server extends BaseModel
$url = Url::fromString($settings->fqdn); $url = Url::fromString($settings->fqdn);
$host = $url->getHost(); $host = $url->getHost();
$schema = $url->getScheme(); $schema = $url->getScheme();
$siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host);
$caddy_file = " $caddy_file = "
$schema://$host { $siteAddress {
encode zstd gzip
handle /app/* { handle /app/* {
reverse_proxy coolify-realtime:6001 reverse_proxy coolify-realtime:6001
} }
@@ -815,6 +818,24 @@ $schema://$host {
], $this); ], $this);
} }
public function dashboardHttpMiddlewares(InstanceSettings $settings): array
{
if ($settings->is_dashboard_force_https_enabled) {
return ['redirect-to-https'];
}
return ['gzip'];
}
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
{
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
return "http://{$host}, https://{$host}";
}
return "{$schema}://{$host}";
}
public function proxyPath() public function proxyPath()
{ {
$base_path = config('constants.coolify.base_config_path'); $base_path = config('constants.coolify.base_config_path');
@@ -837,6 +858,33 @@ $schema://$host {
return data_get($this->proxy, 'type'); return data_get($this->proxy, 'type');
} }
public function hasPendingProxyConfiguration(): bool
{
if ($this->proxy->get('status') !== 'running') {
return false;
}
$savedSettings = $this->proxy->get('last_saved_settings');
$appliedSettings = $this->proxy->get('last_applied_settings');
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
}
public function hasCurrentTraefikOutdatedInfo(): bool
{
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$detectedVersion = ltrim((string) $this->detected_traefik_version, 'v');
$storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v');
$type = data_get($this->traefik_outdated_info, 'type');
return filled($detectedVersion)
&& $storedVersion === $detectedVersion
&& in_array($type, ['patch_update', 'minor_upgrade'], true);
}
public function scopeWithProxy(): Builder public function scopeWithProxy(): Builder
{ {
return $this->proxy->modelScope(); return $this->proxy->modelScope();
+11
View File
@@ -31,6 +31,7 @@ class ServiceApplication extends BaseModel
'is_include_timestamps', 'is_include_timestamps',
'is_gzip_enabled', 'is_gzip_enabled',
'is_stripprefix_enabled', 'is_stripprefix_enabled',
'is_force_https_enabled',
'last_online_at', 'last_online_at',
'is_migrated', 'is_migrated',
]; ];
@@ -44,11 +45,16 @@ class ServiceApplication extends BaseModel
'domain_dns_statuses', 'domain_dns_statuses',
]; ];
protected $attributes = [
'is_force_https_enabled' => true,
];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'domain_dns_statuses' => 'array', 'domain_dns_statuses' => 'array',
'noindex_domains' => 'array', 'noindex_domains' => 'array',
'is_force_https_enabled' => 'boolean',
]; ];
} }
@@ -124,6 +130,11 @@ class ServiceApplication extends BaseModel
return data_get($this, 'is_gzip_enabled', true); return data_get($this, 'is_gzip_enabled', true);
} }
public function isForceHttpsEnabled(): bool
{
return $this->is_force_https_enabled;
}
public function type() public function type()
{ {
return 'service'; return 'service';
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Policies;
use App\Models\ScheduledTask;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ScheduledTaskPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, ScheduledTask $scheduledTask): bool
{
return $user->teams->contains('id', $scheduledTask->team_id);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, ScheduledTask $scheduledTask): Response
{
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
}
return Response::allow();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, ScheduledTask $scheduledTask): bool
{
return $user->isAdminOfTeam($scheduledTask->team_id);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
}
+5
View File
@@ -20,6 +20,7 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\PushoverNotificationSettings; use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\ScheduledTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
@@ -58,6 +59,7 @@ use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy; use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy; use App\Policies\ResourceCreatePolicy;
use App\Policies\S3StoragePolicy; use App\Policies\S3StoragePolicy;
use App\Policies\ScheduledTaskPolicy;
use App\Policies\ServerPolicy; use App\Policies\ServerPolicy;
use App\Policies\ServiceApplicationPolicy; use App\Policies\ServiceApplicationPolicy;
use App\Policies\ServiceDatabasePolicy; use App\Policies\ServiceDatabasePolicy;
@@ -120,6 +122,9 @@ class AuthServiceProvider extends ServiceProvider
// S3 storage policy // S3 storage policy
S3Storage::class => S3StoragePolicy::class, S3Storage::class => S3StoragePolicy::class,
// Scheduled task policy
ScheduledTask::class => ScheduledTaskPolicy::class,
// Team policy // Team policy
Team::class => TeamPolicy::class, Team::class => TeamPolicy::class,
+1 -1
View File
@@ -71,7 +71,7 @@ class AvatarStorageService
protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
{ {
if ($storageType !== 's3') { if ($storageType !== 's3') {
return Storage::disk('local'); return Storage::disk('images');
} }
$storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first(); $storage = S3Storage::query()->whereKey($s3StorageId)->where('is_usable', true)->first();
+1 -1
View File
@@ -12,7 +12,7 @@ class Links extends Component
{ {
public Collection $links; public Collection $links;
public function __construct(public Service $service, public bool $fullWidth = false) public function __construct(public Service $service, public bool $fullWidth = false, public bool $compact = false)
{ {
$this->links = collect([]); $this->links = collect([]);
$service->applications()->get()->map(function ($application) { $service->applications()->get()->map(function ($application) {
+36 -17
View File
@@ -263,6 +263,15 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null
return $command; return $command;
} }
function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string
{
$container = escapeShellValue($container);
$script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code";
return 'bash -c '.escapeShellValue($script);
}
function escapeShellValue(string $value): string function escapeShellValue(string $value): string
{ {
return "'".str_replace("'", "'\\''", $value)."'"; return "'".str_replace("'", "'\\''", $value)."'";
@@ -518,6 +527,10 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
$path = $url->getPath(); $path = $url->getPath();
$host_without_www = str($host)->replace('www.', ''); $host_without_www = str($host)->replace('www.', '');
$schema = $url->getScheme(); $schema = $url->getScheme();
$siteAddress = "{$schema}://{$host}";
if ($schema === 'https' && ! $is_force_https_enabled) {
$siteAddress = "http://{$host}, https://{$host}";
}
$port = $url->getPort(); $port = $url->getPort();
$handle = 'handle_path'; $handle = 'handle_path';
if (! $is_stripprefix_enabled) { if (! $is_stripprefix_enabled) {
@@ -529,7 +542,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if (is_null($port) && $predefinedPort) { if (is_null($port) && $predefinedPort) {
$port = $predefinedPort; $port = $predefinedPort;
} }
$labels->push("caddy_{$loop}={$schema}://{$host}"); $labels->push("caddy_{$loop}={$siteAddress}");
if (isNoindexDomain($domain, $noindex_domains)) { if (isNoindexDomain($domain, $noindex_domains)) {
// Caddy's header directive takes either inline arguments or a block, // Caddy's header directive takes either inline arguments or a block,
// never both, so -Server has to move into the block alongside it. // never both, so -Server has to move into the block alongside it.
@@ -549,11 +562,12 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
if ($is_gzip_enabled) { if ($is_gzip_enabled) {
$labels->push("caddy_{$loop}.encode=zstd gzip"); $labels->push("caddy_{$loop}.encode=zstd gzip");
} }
$redirect_schema = $is_force_https_enabled ? $schema : '{scheme}';
if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) {
$labels->push("caddy_{$loop}.redir={$schema}://www.{$host}{uri}"); $labels->push("caddy_{$loop}.redir={$redirect_schema}://www.{$host}{uri}");
} }
if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) {
$labels->push("caddy_{$loop}.redir={$schema}://{$host_without_www}{uri}"); $labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}");
} }
if ($is_http_basic_auth_enabled) { if ($is_http_basic_auth_enabled) {
$labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\""); $labels->push("caddy_{$loop}.basicauth.{$http_basic_auth_username}=\"{$hashedPassword}\"");
@@ -563,7 +577,7 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
return $labels->sort(); return $labels->sort();
} }
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null) function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true)
{ {
$labels = collect([]); $labels = collect([]);
$labels->push('traefik.enable=true'); $labels->push('traefik.enable=true');
@@ -646,14 +660,15 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$to_www_name = "{$loop}-{$uuid}-to-www"; $to_www_name = "{$loop}-{$uuid}-to-www";
$to_non_www_name = "{$loop}-{$uuid}-to-non-www"; $to_non_www_name = "{$loop}-{$uuid}-to-non-www";
$redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$';
$redirect_to_non_www = [ $redirect_to_non_www = [
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement=\$\${1}://\$\${2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}",
"traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent=false",
]; ];
$redirect_to_www = [ $redirect_to_www = [
"traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)",
"traefik.http.middlewares.{$to_www_name}.redirectregex.replacement=\$\${1}://www.\$\${2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}",
"traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent=false",
]; ];
if ($schema === 'https') { if ($schema === 'https') {
@@ -695,8 +710,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$middlewares->push($middleware_name); $middlewares->push($middleware_name);
}); });
if ($middlewares->isNotEmpty()) { if ($middlewares->isNotEmpty()) {
$middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
} }
} else { } else {
$middlewares = collect([]); $middlewares = collect([]);
@@ -724,8 +738,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$middlewares->push($middleware_name); $middlewares->push($middleware_name);
}); });
if ($middlewares->isNotEmpty()) { if ($middlewares->isNotEmpty()) {
$middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}");
$labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares}");
} }
} }
$labels->push("traefik.http.routers.{$https_label}.tls=true"); $labels->push("traefik.http.routers.{$https_label}.tls=true");
@@ -738,15 +751,17 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
$labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port");
$labels->push("traefik.http.routers.{$http_label}.service={$http_label}"); $labels->push("traefik.http.routers.{$http_label}.service={$http_label}");
} }
$middlewares = collect([]);
if ($is_noindex) {
$middlewares->push($noindex_name);
}
if ($is_force_https_enabled) { if ($is_force_https_enabled) {
$middlewares->push('redirect-to-https'); $httpMiddlewares = collect([]);
if ($is_noindex) {
$httpMiddlewares->push($noindex_name);
}
$httpMiddlewares->push('redirect-to-https');
} else {
$httpMiddlewares = $middlewares;
} }
if ($middlewares->isNotEmpty()) { if ($httpMiddlewares->isNotEmpty()) {
$labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares->join(',')}"); $labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}");
} }
} else { } else {
// Set labels for http // Set labels for http
@@ -876,6 +891,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains, noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
)); ));
break; break;
} }
@@ -892,6 +908,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains, noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
)); ));
$labels = $labels->merge(fqdnLabelsForCaddy( $labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network, network: $application->destination->network,
@@ -932,6 +949,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains, noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
)); ));
break; break;
case ProxyTypes::CADDY->value: case ProxyTypes::CADDY->value:
@@ -962,6 +980,7 @@ function generateLabelsApplication(Application $application, ?ApplicationPreview
http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_username: $application->http_basic_auth_username,
http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_password: $application->http_basic_auth_password,
noindex_domains: $noindexDomains, noindex_domains: $noindexDomains,
escape_redirect_replacement_for_compose: false,
)); ));
$labels = $labels->merge(fqdnLabelsForCaddy( $labels = $labels->merge(fqdnLabelsForCaddy(
network: $application->destination->network, network: $application->destination->network,
+4 -4
View File
@@ -2645,7 +2645,7 @@ function serviceParser(Service $resource): Collection
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid, uuid: $uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(), is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2660,7 +2660,7 @@ function serviceParser(Service $resource): Collection
network: $network, network: $network,
uuid: $uuid, uuid: $uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(), is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2676,7 +2676,7 @@ function serviceParser(Service $resource): Collection
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid, uuid: $uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(), is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
@@ -2689,7 +2689,7 @@ function serviceParser(Service $resource): Collection
network: $network, network: $network,
uuid: $uuid, uuid: $uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(), is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(), is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
+12 -11
View File
@@ -107,8 +107,8 @@ function collectDockerNetworksByServer(Server $server)
} }
function connectProxyToNetworks(Server $server) function connectProxyToNetworks(Server $server)
{ {
['networks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) { if ($server->isSwarm()) {
['networks' => $networks] = collectDockerNetworksByServer($server);
$commands = $networks->map(function ($network) { $commands = $networks->map(function ($network) {
$safe = escapeshellarg($network); $safe = escapeshellarg($network);
@@ -118,19 +118,20 @@ function connectProxyToNetworks(Server $server)
"echo 'Successfully connected coolify-proxy to {$safe} network.'", "echo 'Successfully connected coolify-proxy to {$safe} network.'",
]; ];
}); });
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
return [ return $commands->flatten();
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
"echo 'Successfully connected coolify-proxy to {$safe} network.'",
];
});
} }
return $commands->flatten(); return collect([
'for network in $(docker inspect $(docker ps --filter label=coolify.managed=true --format "{{.ID}}") --format=\'{{range $network, $_ := .NetworkSettings.Networks}}{{println $network}}{{end}}\' 2>/dev/null | sort -u); do',
' if [ -z "$network" ] || [ "$network" = "bridge" ] || [ "$network" = "host" ] || [ "$network" = "none" ] || [ "$network" = "default" ]; then',
' continue',
' fi',
' if docker network inspect "$network" >/dev/null 2>&1; then',
' docker network connect "$network" coolify-proxy >/dev/null 2>&1 || true',
' fi',
'done',
]);
} }
/** /**
+3 -5
View File
@@ -167,13 +167,11 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli
$isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server);
if ($isFile === 'OK') { if ($isFile === 'OK') {
// If its a file & exists
$filesystemContent = instant_remote_process(["cat $fileLocation"], $server);
if ($fileVolume->is_based_on_git) {
$fileVolume->content = $filesystemContent;
}
$fileVolume->is_directory = false; $fileVolume->is_directory = false;
$fileVolume->save(); $fileVolume->save();
if ($fileVolume->is_based_on_git) {
$fileVolume->loadStorageOnServer();
}
} elseif ($isDir === 'OK') { } elseif ($isDir === 'OK') {
// If its a directory & exists // If its a directory & exists
$fileVolume->content = null; $fileVolume->content = null;
+4 -4
View File
@@ -3050,7 +3050,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $resource->uuid, uuid: $resource->uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(), is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3065,7 +3065,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
network: $resource->destination->network, network: $resource->destination->network,
uuid: $resource->uuid, uuid: $resource->uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(), is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3080,7 +3080,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik( $serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $resource->uuid, uuid: $resource->uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(), is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
@@ -3093,7 +3093,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
network: $resource->destination->network, network: $resource->destination->network,
uuid: $resource->uuid, uuid: $resource->uuid,
domains: $fqdns, domains: $fqdns,
is_force_https_enabled: true, is_force_https_enabled: $savedService->isForceHttpsEnabled(),
serviceLabels: $serviceLabels, serviceLabels: $serviceLabels,
is_gzip_enabled: $savedService->isGzipEnabled(), is_gzip_enabled: $savedService->isGzipEnabled(),
is_stripprefix_enabled: $savedService->isStripprefixEnabled(), is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
+1
View File
@@ -95,6 +95,7 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
$isComplexPipeCommand = ( $isComplexPipeCommand = (
$line->contains(' | sh') || $line->contains(' | sh') ||
$line->contains(' | bash') || $line->contains(' | bash') ||
$line->contains(' sh -c ') ||
($line->contains(' | ') && ($line->contains('||') || $line->contains('&&'))) ($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
); );
+1 -1
View File
@@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => env('COOLIFY_VERSION') ?: '4.3.6', 'version' => env('COOLIFY_VERSION') ?: '4.3.9',
'helper_version' => '1.0.15', 'helper_version' => '1.0.15',
'realtime_version' => '1.0.17', 'realtime_version' => '1.0.17',
'railpack_version' => '0.23.0', 'railpack_version' => '0.23.0',
+7
View File
@@ -35,6 +35,13 @@ return [
'throw' => false, 'throw' => false,
], ],
'images' => [
'driver' => 'local',
'root' => storage_path('app/images'),
'visibility' => 'private',
'throw' => false,
],
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
+5 -1
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Models\ScheduledVolumeBackup;
use Illuminate\Support\Str; use Illuminate\Support\Str;
return [ return [
@@ -202,7 +203,10 @@ return [
'tries' => 1, 'tries' => 1,
'nice' => 0, 'nice' => 0,
'sleep' => 3, 'sleep' => 3,
'timeout' => env('HORIZON_TIMEOUT', 36000), 'timeout' => min(
max((int) env('HORIZON_TIMEOUT', 39600), ScheduledVolumeBackup::DEFAULT_TIMEOUT + 600),
85800,
),
], ],
], ],
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_volume_backups', function (Blueprint $table) {
$table->unsignedInteger('timeout')->default(36000)->change();
});
}
public function down(): void
{
Schema::table('scheduled_volume_backups', function (Blueprint $table) {
$table->unsignedInteger('timeout')->default(3600)->change();
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->boolean('is_force_https_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->dropColumn('is_force_https_enabled');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_dashboard_force_https_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_dashboard_force_https_enabled');
});
}
};
+1
View File
@@ -11,6 +11,7 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups - /data/coolify/backups:/var/www/html/storage/app/backups
- /data/coolify/images:/var/www/html/storage/app/images
environment: environment:
- APP_ENV=${APP_ENV:-production} - APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
+1
View File
@@ -25,6 +25,7 @@ services:
- ./databases:/var/www/html/storage/app/databases - ./databases:/var/www/html/storage/app/databases
- ./services:/var/www/html/storage/app/services - ./services:/var/www/html/storage/app/services
- ./backups:/var/www/html/storage/app/backups - ./backups:/var/www/html/storage/app/backups
- ./images:/var/www/html/storage/app/images
env_file: env_file:
- .env - .env
environment: environment:
@@ -0,0 +1,90 @@
# External TLS HTTP Redirect Design
## Problem
The Cloudflare Tunnel all-resource setup sends public HTTPS requests to Coolify's proxy through `http://localhost:80`. When a resource domain is stored as `https://` and Coolify redirects HTTP traffic to HTTPS, the tunneled request repeatedly returns to the HTTP entrypoint and causes `TOO_MANY_REDIRECTS`.
The current documentation avoids the loop by telling users to store the public domain as `http://`. That misrepresents the public URL and can produce incorrect secure cookies, OAuth callback URLs, and canonical links. Applications can already disable forced HTTPS in advanced settings, but the control is not near domain configuration. Service applications always enable the redirect in generated proxy configuration.
## Goals
- Store the externally visible URL accurately as `https://`.
- Let an upstream proxy such as Cloudflare handle the HTTP-to-HTTPS redirect.
- Apply the behavior consistently to applications and service applications.
- Keep existing resources secure and behaviorally unchanged by default.
- Keep the feature generic rather than coupling it to Cloudflare or a server-wide tunnel mode.
## Non-goals
- Detect Cloudflare automatically.
- Add a server-wide all-resource tunnel mode.
- Configure trusted forwarded-header networks.
- Replace the end-to-end origin TLS workflow.
- Change the default redirect behavior of existing or new resources.
## User Experience
The Domains page shows a boolean control named **Redirect HTTP to HTTPS** when a resource has at least one `https://` domain.
The control defaults to enabled. Its help text explains:
> Disable this when HTTPS and redirects are handled by Cloudflare Tunnel or another reverse proxy that connects to Coolify over HTTP.
A Cloudflare Tunnel user configures `https://app.example.com` and disables the control. A directly exposed resource leaves it enabled.
For regular and Docker Compose applications, the control edits the existing `ApplicationSetting::is_force_https_enabled` value. The existing Advanced-page control must not become an independent source of truth; it should either be removed from that page or remain bound to the same setting with the clearer label.
For service applications, the Domains page provides the same control for each application service. Database-only service entries do not expose it.
## Data Model
Add `is_force_https_enabled` to service applications as a non-null boolean with a default of `true`. Existing service applications therefore keep their current behavior after migration.
Regular applications continue using the existing application setting. No Cloudflare-specific state is stored.
## Proxy Configuration
Domain scheme and redirect policy remain independent:
- An `https://` domain continues generating the HTTPS router/listener.
- Its HTTP router/listener is also generated.
- When redirect is enabled, the HTTP router applies the HTTPS redirect middleware.
- When redirect is disabled, the HTTP router forwards the request to the resource without that middleware.
The stored service-application setting replaces the currently hardcoded `true` passed into Traefik and Caddy label generation. Existing path stripping, gzip, authentication, noindex, and www/non-www middleware behavior remains unchanged.
Preview deployments inherit the parent application's existing redirect setting, matching current application behavior.
## Validation and Authorization
The new service-application value is validated as a boolean. Updating it uses the same authorization checks as other service domain settings. Changing the value marks proxy configuration as changed and follows the existing save/redeploy flow used by domain configuration.
The control is relevant only when an HTTPS domain exists. Hiding it for HTTP-only resources does not reset the stored value.
## Documentation
Update the Cloudflare all-resource guide to instruct users to:
1. Store the public resource domain using `https://`.
2. Disable **Redirect HTTP to HTTPS** for that resource.
3. Let Cloudflare perform the public redirect and TLS termination.
The guide should retain the full TLS guide as the alternative for users who want TLS between cloudflared and Coolify's HTTPS entrypoint.
## Testing
Automated tests must cover:
- Application HTTPS domains with redirects enabled and disabled.
- Service-application HTTPS domains with redirects enabled and disabled.
- The service-application default remains enabled.
- Traefik and Caddy omit only the redirect behavior when disabled.
- Other middleware remains present when the redirect is disabled.
- HTTP-only resources do not show an irrelevant control.
- The Domains UI persists changes with existing authorization rules.
A manual smoke test should route a Cloudflare Tunnel hostname to `http://localhost:80`, save the Coolify resource as `https://`, disable the redirect, and verify the public HTTPS URL loads without a redirect loop.
## Compatibility
The database default of `true` preserves service behavior. Existing application values are unchanged. No automatic migration attempts to infer which resources are behind Cloudflare.
+1
View File
@@ -11,6 +11,7 @@ services:
- /data/coolify/databases:/var/www/html/storage/app/databases - /data/coolify/databases:/var/www/html/storage/app/databases
- /data/coolify/services:/var/www/html/storage/app/services - /data/coolify/services:/var/www/html/storage/app/services
- /data/coolify/backups:/var/www/html/storage/app/backups - /data/coolify/backups:/var/www/html/storage/app/backups
- /data/coolify/images:/var/www/html/storage/app/images
environment: environment:
- APP_ENV=${APP_ENV:-production} - APP_ENV=${APP_ENV:-production}
- PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M} - PHP_MEMORY_LIMIT=${PHP_MEMORY_LIMIT:-256M}
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.3.6" "version": "4.3.9"
}, },
"nightly": { "nightly": {
"version": "4.3.7" "version": "4.3.10"
}, },
"helper": { "helper": {
"version": "1.0.15" "version": "1.0.15"
+13 -5
View File
@@ -398,9 +398,12 @@ html[data-theme="custom"] .animate-spin {
color: var(--theme-bright-color) !important; color: var(--theme-bright-color) !important;
} }
/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */ /* Opt out of the brand spinner when the surrounding surface is a selected/neutral control
or a highlighted button, whose accent surface would camouflage a brand-colored spinner. */
.dark .animate-spin.spinner-current, .dark .animate-spin.spinner-current,
html[data-theme="custom"] .animate-spin.spinner-current { html[data-theme="custom"] .animate-spin.spinner-current,
html[data-theme="custom"] .button-highlighted .animate-spin,
html[data-theme="custom"] button[isHighlighted] .animate-spin {
color: inherit !important; color: inherit !important;
} }
@@ -993,8 +996,7 @@ html[data-theme="custom"] {
} }
html[data-theme="custom"] .control-selected, html[data-theme="custom"] .control-selected,
html[data-theme="custom"] .logs-viewer-btn-active, html[data-theme="custom"] .logs-viewer-btn-active {
html[data-theme="custom"] .button-highlighted:hover {
color: var(--color-accent-foreground); color: var(--color-accent-foreground);
} }
@@ -1905,7 +1907,13 @@ html[data-theme="custom"] textarea:disabled {
.listbox-trigger:disabled { .listbox-trigger:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; background-color: var(--color-neutral-100);
color: var(--color-neutral-400);
}
.dark .listbox-trigger:disabled {
background-color: color-mix(in oklab, var(--color-white) 3%, transparent);
color: var(--color-fg-faint);
} }
.listbox-trigger:focus-visible { .listbox-trigger:focus-visible {
+2 -3
View File
@@ -126,12 +126,11 @@
} }
@utility button { @utility button {
/* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */ @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
@apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent;
} }
@utility button-highlighted { @utility button-highlighted {
@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!; @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!;
} }
@utility control-selected { @utility control-selected {
@@ -2,35 +2,13 @@
<x-auth.shell title="Coolify" description="Verify your identity to finish signing in."> <x-auth.shell title="Coolify" description="Verify your identity to finish signing in.">
<div class="flex flex-col gap-4" x-data="{ <div class="flex flex-col gap-4" x-data="{
showRecovery: false, showRecovery: false,
digits: ['', '', '', '', '', ''], submitAuthenticatorCode(event) {
code: '', event.target.value = event.target.value.replace(/\D/g, '').slice(0, 6);
focusNext(event) {
const nextInput = event.target.nextElementSibling;
if (nextInput?.tagName === 'INPUT') nextInput.focus();
},
focusPrevious(event) {
if (event.key !== 'Backspace' || event.target.value) return;
const previousInput = event.target.previousElementSibling; if (event.target.value.length === 6) {
if (previousInput?.tagName === 'INPUT') previousInput.focus();
},
updateCode() {
this.code = this.digits.join('');
if (this.code.length === 6) {
this.$nextTick(() => this.$refs.challengeForm.requestSubmit()); this.$nextTick(() => this.$refs.challengeForm.requestSubmit());
} }
}, },
pasteCode(event) {
event.preventDefault();
const pastedDigits = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6).split('');
const inputs = event.currentTarget.querySelectorAll('input[type=text]');
pastedDigits.forEach((digit, index) => this.digits[index] = digit);
this.updateCode();
inputs[Math.min(pastedDigits.length, 6) - 1]?.focus();
},
}"> }">
@if (session('status')) @if (session('status'))
<x-auth.alert type="success">{{ session('status') }}</x-auth.alert> <x-auth.alert type="success">{{ session('status') }}</x-auth.alert>
@@ -56,17 +34,11 @@
@csrf @csrf
<div x-show="!showRecovery" class="flex flex-col gap-3"> <div x-show="!showRecovery" class="flex flex-col gap-3">
<input type="hidden" name="code" x-model="code" :disabled="showRecovery"> <input x-ref="authenticatorCode" type="text" name="code" inputmode="numeric"
<div class="flex justify-center gap-2" aria-label="Two-factor authentication code" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" autofocus
@paste="pasteCode($event)"> aria-label="Two-factor authentication code" :disabled="showRecovery"
<template x-for="(digit, index) in digits" :key="index"> @input="submitAuthenticatorCode($event)"
<input type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" class="mx-auto h-14 w-64 rounded-md border border-neutral-300 bg-white px-4 text-center text-xl font-semibold tracking-[0.5em] text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white" />
x-model="digits[index]" :aria-label="`Digit ${index + 1}`"
@input="focusNext($event); updateCode()" @keydown="focusPrevious($event)"
class="h-12 w-11 rounded-md border border-neutral-300 bg-white text-center text-lg font-semibold text-neutral-900 transition-colors focus:border-warning focus:outline-none focus:ring-1 focus:ring-warning dark:border-white/10 dark:bg-coolgray-100 dark:text-white sm:h-14 sm:w-12 sm:text-xl"
autocomplete="one-time-code" />
</template>
</div>
<button type="button" class="auth-text-link self-center" <button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())"> x-on:click="showRecovery = true; $nextTick(() => $refs.recoveryCode.focus())">
Use a recovery code Use a recovery code
@@ -77,7 +49,7 @@
<x-forms.input x-ref="recoveryCode" name="recovery_code" autocomplete="one-time-code" <x-forms.input x-ref="recoveryCode" name="recovery_code" autocomplete="one-time-code"
x-bind:disabled="!showRecovery" label="{{ __('input.recovery_code') }}" /> x-bind:disabled="!showRecovery" label="{{ __('input.recovery_code') }}" />
<button type="button" class="auth-text-link self-center" <button type="button" class="auth-text-link self-center"
x-on:click="showRecovery = false; $nextTick(() => $el.closest('form').querySelector('input[type=text]').focus())"> x-on:click="showRecovery = false; $nextTick(() => $refs.authenticatorCode.focus())">
Use an authenticator code Use an authenticator code
</button> </button>
</div> </div>
@@ -65,7 +65,7 @@
</div> </div>
@endif @endif
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="enableSsl" label="SSL" <x-forms.listbox canGate="update" :canResource="$database" id="enableSsl" label="SSL"
onChange="instantSaveSSL" onChange="instantSaveSSL"
:disabled="! $isExited || ! auth()->user()?->can('update', $database)" :disabled="! $isExited || ! auth()->user()?->can('update', $database)"
:options="[ :options="[
@@ -73,7 +73,7 @@
['value' => false, 'label' => 'Disabled'], ['value' => false, 'label' => 'Disabled'],
]" /> ]" />
@if ($sslModeOptions) @if ($sslModeOptions)
<x-forms.listbox id="sslMode" label="SSL mode" :helper="$sslModeHelper" <x-forms.listbox canGate="update" :canResource="$database" id="sslMode" label="SSL mode" :helper="$sslModeHelper"
onChange="instantSaveSSL" onChange="instantSaveSSL"
:disabled="! $enableSsl || ! $isExited || ! auth()->user()?->can('update', $database)" :disabled="! $enableSsl || ! $isExited || ! auth()->user()?->can('update', $database)"
:options="collect($sslModeOptions)->map(fn ($option, $value) => [ :options="collect($sslModeOptions)->map(fn ($option, $value) => [
@@ -1,21 +1,18 @@
@props(['text', 'label' => null]) @props(['text', 'label' => null])
<div class="w-full" <div class="w-full" x-data="{ copied: false }">
x-data="{ copied: false, canCopy: window.isSecureContext && typeof navigator.clipboard?.writeText === 'function' }">
@if ($label) @if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label> <label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
@endif @endif
<div class="relative"> <div class="relative">
<input type="text" value="{{ $text }}" <input type="text" value="{{ $text }}"
class="input bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white" class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
x-bind:class="{ 'input-with-copy-button': canCopy }"
readonly readonly
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent @keydown.prevent @paste.prevent @cut.prevent @drop.prevent
@focus="$event.target.select()"> @focus="$event.target.select()">
<button <button
x-show="canCopy"
type="button" type="button"
@click.prevent="copied = true; navigator.clipboard.writeText({{ Js::from($text) }}); setTimeout(() => copied = false, 1000)" @click.prevent="await window.copyToClipboard({{ Js::from($text) }}); copied = true; setTimeout(() => copied = false, 1000)"
class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base" class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
title="Copy to clipboard" title="Copy to clipboard"
aria-label="Copy to clipboard"> aria-label="Copy to clipboard">
@@ -1,68 +1,27 @@
@props([ @props([
'id', 'id',
'wire' => true,
'value' => '',
'errorId' => null, 'errorId' => null,
'hostLabel' => 'Domain', 'hostLabel' => 'Domain',
'hostPlaceholder' => 'app.example.com', 'hostPlaceholder' => 'app.example.com',
]) ])
<div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]" x-data="{ <div class="grid gap-4 sm:grid-cols-[8rem_minmax(0,1fr)_8rem]">
value: @if ($wire) @entangle($id) @else @js($value) @endif,
scheme: 'https',
host: '',
port: '',
path: '',
syncing: false,
init() {
this.read(this.value);
this.$watch('value', value => {
if (!this.syncing) this.read(value);
});
['scheme', 'host', 'port', 'path'].forEach(part => this.$watch(part, () => this.write()));
},
read(value) {
if (!value) return;
try {
const url = new URL(value);
const authority = value.match(/^[a-z][a-z0-9+.-]*:\/\/(?:\[[^\]]+\]|[^\/:?#]+)(?::(\d+))?/i);
this.syncing = true;
this.scheme = url.protocol.replace(':', '') === 'http' ? 'http' : 'https';
this.host = url.hostname;
this.port = authority?.[1] || url.port;
this.path = `${url.pathname === '/' ? '' : url.pathname}${url.search}${url.hash}`;
this.$nextTick(() => this.syncing = false);
} catch (_) {}
},
write() {
if (this.syncing) return;
const path = this.path.trim();
const normalizedPath = path && !['/', '?', '#'].includes(path[0]) ? `/${path}` : path;
const next = `${this.scheme}://${this.host.trim()}${this.port ? `:${this.port}` : ''}${normalizedPath}`;
if (this.value !== next) {
this.syncing = true;
this.value = next;
this.$nextTick(() => this.syncing = false);
}
},
}" x-modelable="value" {{ $attributes->whereStartsWith('x-model') }}>
<div class="min-w-0"> <div class="min-w-0">
<x-forms.listbox id="{{ $id }}-protocol" label="Protocol" :wire="false" value="https" <x-forms.listbox id="{{ $id }}.scheme" htmlId="{{ $id }}-protocol" label="Protocol" portal :options="[
x-model="scheme" portal :options="[ ['value' => 'https', 'label' => 'https'],
['value' => 'https', 'label' => 'https'], ['value' => 'http', 'label' => 'http'],
['value' => 'http', 'label' => 'http'], ]" />
]" />
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5"> <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}" class="mb-0! flex items-center gap-1.5 leading-4"> <label for="{{ $id }}-host" class="mb-0! flex items-center gap-1.5 leading-4">
{{ $hostLabel }} <x-highlighted text="*" /> {{ $hostLabel }} <x-highlighted text="*" />
</label> </label>
</div> </div>
<input id="{{ $id }}" type="text" class="input" x-model="host" placeholder="{{ $hostPlaceholder }}" <input id="{{ $id }}-host" type="text" class="input" wire:model="{{ $id }}.host"
autocomplete="off" required /> placeholder="{{ $hostPlaceholder }}" autocomplete="off" required />
@error($errorId ?? $id) @error($errorId ?? "{$id}.host")
@php @php
preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches); preg_match('/(https?:\/\/\S+)$/', $message, $validationLinkMatches);
$validationLink = $validationLinkMatches[1] ?? null; $validationLink = $validationLinkMatches[1] ?? null;
@@ -82,16 +41,16 @@
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5"> <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-port" class="mb-0! flex items-center gap-1.5 leading-4">Port</label> <label for="{{ $id }}-port" class="mb-0! flex items-center gap-1.5 leading-4">Port</label>
</div> </div>
<input id="{{ $id }}-port" type="number" class="input" x-model="port" placeholder="3000" <input id="{{ $id }}-port" type="number" class="input" wire:model="{{ $id }}.port"
min="1" max="65535" inputmode="numeric" /> placeholder="3000" min="1" max="65535" inputmode="numeric" />
</div> </div>
<div class="min-w-0 sm:col-span-3"> <div class="min-w-0 sm:col-span-3">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5"> <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label for="{{ $id }}-path" class="mb-0! flex items-center gap-1.5 leading-4">Path</label> <label for="{{ $id }}-path" class="mb-0! flex items-center gap-1.5 leading-4">Path</label>
</div> </div>
<input id="{{ $id }}-path" type="text" class="input" x-model="path" placeholder="/api/v3" <input id="{{ $id }}-path" type="text" class="input" wire:model="{{ $id }}.path"
autocomplete="off" /> placeholder="/api/v3" autocomplete="off" />
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim"> <p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Optional path, query, or fragment appended after the domain and port. Optional path, query, or fragment appended after the domain and port.
</p> </p>
@@ -16,9 +16,16 @@
'tooltip' => true, 'tooltip' => true,
'portal' => false, 'portal' => false,
'preserveValue' => false, 'preserveValue' => false,
'canGate' => null,
'canResource' => null,
'autoDisable' => true,
]) ])
@php @php
if ($canGate && $canResource && $autoDisable && ! Illuminate\Support\Facades\Gate::allows($canGate, $canResource)) {
$disabled = true;
}
$triggerId = ($htmlId ?? $id).'-trigger'; $triggerId = ($htmlId ?? $id).'-trigger';
$panelId = ($htmlId ?? $id).'-panel'; $panelId = ($htmlId ?? $id).'-panel';
@endphp @endphp
@@ -90,6 +97,9 @@
const gap = 4; const gap = 4;
const edge = 12; const edge = 12;
const triggerRect = trigger.getBoundingClientRect(); const triggerRect = trigger.getBoundingClientRect();
panel.style.width = 'max-content';
panel.style.minWidth = `${triggerRect.width}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
const panelWidth = Math.min( const panelWidth = Math.min(
Math.max(triggerRect.width, panel.offsetWidth), Math.max(triggerRect.width, panel.offsetWidth),
window.innerWidth - (edge * 2), window.innerWidth - (edge * 2),
@@ -107,8 +117,6 @@
panel.style.top = `${top}px`; panel.style.top = `${top}px`;
panel.style.left = `${left}px`; panel.style.left = `${left}px`;
panel.style.width = `${panelWidth}px`; panel.style.width = `${panelWidth}px`;
panel.style.maxWidth = `${window.innerWidth - (edge * 2)}px`;
panel.style.minWidth = `${triggerRect.width}px`;
this.positioned = true; this.positioned = true;
}, },
}" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }" }" x-modelable="value" :class="{ 'pointer-events-none opacity-70': saving }"
@@ -111,7 +111,7 @@
@click.stop> @click.stop>
<div class="searchable-listbox-search"> <div class="searchable-listbox-search">
<x-reicon name="search" <x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" /> class="pointer-events-none absolute top-1/2 left-3 size-3 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-ref="search" type="search" x-model="query" autocomplete="off" <input x-ref="search" type="search" x-model="query" autocomplete="off"
placeholder="{{ $searchPlaceholder }}" placeholder="{{ $searchPlaceholder }}"
class="searchable-listbox-search-input" class="searchable-listbox-search-input"
@@ -236,7 +236,6 @@
@foreach ($checkboxes as $index => $checkbox) @foreach ($checkboxes as $index => $checkbox)
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<x-forms.checkbox fullWidth :label="$checkbox['label']" :id="$checkbox['id']" <x-forms.checkbox fullWidth :label="$checkbox['label']" :id="$checkbox['id']"
:wire:model="$checkbox['id']"
x-on:change="toggleAction('{{ $checkbox['id'] }}')" :checked="$this->{$checkbox['id']}" x-on:change="toggleAction('{{ $checkbox['id'] }}')" :checked="$this->{$checkbox['id']}"
x-bind:checked="selectedActions.includes('{{ $checkbox['id'] }}')" /> x-bind:checked="selectedActions.includes('{{ $checkbox['id'] }}')" />
</div> </div>
@@ -0,0 +1,37 @@
@props(['canRestart' => false])
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" aria-label="Proxy configuration changes not applied" aria-haspopup="dialog"
:aria-expanded="open" @click="open = !open"
class="flex h-8 items-center justify-center gap-1.5 rounded-lg px-2 text-amber-700 transition-colors hover:bg-amber-100 dark:text-warning dark:hover:bg-warning/10">
<x-reicon name="alert-triangle" class="size-4" />
<span class="hidden text-xs font-medium lg:inline">Changes pending</span>
</button>
<div x-show="open" x-cloak x-transition.opacity role="dialog"
class="fixed top-14 left-1/2 z-[1100] w-[calc(100vw-2rem)] max-w-sm -translate-x-1/2 rounded-lg p-3 lg:absolute lg:top-full lg:right-0 lg:left-auto lg:mt-2 lg:translate-x-0"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
<div class="flex items-start gap-2.5">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100 text-amber-700 dark:bg-warning/10 dark:text-warning">
<x-reicon name="alert-triangle" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="text-[13px] leading-4 font-semibold text-neutral-950 dark:text-fg">
The saved proxy configuration has not been applied
</p>
<p class="mt-0.5 text-[11px] leading-4 text-neutral-600 dark:text-fg-dim">
Restart the proxy to apply these changes.
@if ($canRestart)
<button type="button"
class="ml-0.5 inline-flex items-center gap-0.5 font-semibold text-coollabs transition-colors hover:text-coollabs-100 dark:text-warning dark:hover:text-warning/80"
@click="open = false; document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
Restart proxy
<x-reicon name="arrow-right" class="size-2.5" />
</button>
@endif
</p>
</div>
</div>
</div>
</div>
@@ -55,6 +55,8 @@
'icon' => 'network', 'icon' => 'network',
'group' => 'Platform', 'group' => 'Platform',
'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server,
'warning' => $server->hasCurrentTraefikOutdatedInfo(),
'tracks_proxy_configuration' => true,
'children' => [ 'children' => [
['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'],
['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()],
@@ -167,7 +169,15 @@
$groupedServerMenuItems = $serverMenuItems->groupBy('group'); $groupedServerMenuItems = $serverMenuItems->groupBy('group');
@endphp @endphp
<aside class="application-settings-navigation min-w-0 xl:self-start"> <aside class="application-settings-navigation min-w-0 xl:self-start"
x-data="{
proxyConfigurationPending: @js($server->hasPendingProxyConfiguration()),
traefikOutdated: @js($server->hasCurrentTraefikOutdatedInfo())
}"
@proxy-configuration-state-changed.window="
proxyConfigurationPending = $event.detail.pending;
traefikOutdated = $event.detail.traefikOutdated;
">
<nav aria-label="Server configuration sections" <nav aria-label="Server configuration sections"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]"> class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedServerMenuItems as $groupLabel => $groupItems) @foreach ($groupedServerMenuItems as $groupLabel => $groupItems)
@@ -186,6 +196,14 @@
href="{{ route($menuItem['route'], $serverRouteParameters) }}"> href="{{ route($menuItem['route'], $serverRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" /> <x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span> <span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['tracks_proxy_configuration'] ?? false)
<x-reicon name="alert-triangle" x-cloak
x-show="proxyConfigurationPending || traefikOutdated"
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
@elseif ($menuItem['warning'] ?? false)
<x-reicon name="alert-triangle"
class="ml-auto size-3.5 shrink-0 text-orange-500 dark:text-warning" />
@endif
</a> </a>
@if ($menuItem['active'] && isset($menuItem['children'])) @if ($menuItem['active'] && isset($menuItem['children']))
<div class="col-span-full grid grid-cols-2 gap-0.5 border-l border-neutral-200 pl-2 sm:grid-cols-3 xl:grid-cols-1 dark:border-white/[0.08]"> <div class="col-span-full grid grid-cols-2 gap-0.5 border-l border-neutral-200 pl-2 sm:grid-cols-3 xl:grid-cols-1 dark:border-white/[0.08]">
@@ -2,15 +2,22 @@
$linkItemClasses = 'listbox-option justify-start! gap-2.5!'; $linkItemClasses = 'listbox-option justify-start! gap-2.5!';
@endphp @endphp
<div @class(['relative', 'w-full' => $fullWidth]) x-data="{ open: false }" <div @class([
'relative' => !$compact,
'static' => $compact,
'w-full' => $fullWidth,
]) x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })" @keydown.escape.window="open = false"> x-effect="$dispatch('resource-actions-toggled', { open })" @keydown.escape.window="open = false">
<button type="button" @click="open = !open" @click.outside="open = false" title="Open service links" <button type="button" @click="open = !open" @click.outside="open = false" title="Open service links"
@class([ @class([
'app-tab shrink-0 gap-1' => !$fullWidth, 'app-tab shrink-0 gap-1' => !$fullWidth && !$compact,
'button w-full justify-between' => $fullWidth, 'button w-full justify-between' => $fullWidth,
'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-100 px-2 text-xs font-medium leading-none text-neutral-700 dark:border-white/[0.12] dark:bg-white/[0.07] dark:text-white' => $compact,
])> ])>
<span class="inline-flex items-center gap-2"> <span class="inline-flex items-center gap-2">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" /> @unless ($compact)
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
@endunless
Links Links
</span> </span>
<span class="inline-flex transition-transform" :class="open && 'rotate-180'"> <span class="inline-flex transition-transform" :class="open && 'rotate-180'">
@@ -21,7 +28,8 @@
@class([ @class([
'listbox-panel top-full! mt-1! max-h-80! overflow-y-auto!', 'listbox-panel top-full! mt-1! max-h-80! overflow-y-auto!',
'left-0! right-0! w-full! min-w-0! max-w-none!' => $fullWidth, 'left-0! right-0! w-full! min-w-0! max-w-none!' => $fullWidth,
'right-0! left-auto! min-w-60! max-w-96!' => !$fullWidth, 'left-1/2! right-auto! w-[calc(100vw-2rem)]! max-w-md! min-w-0! -translate-x-1/2' => $compact,
'right-0! left-auto! min-w-60! max-w-96!' => !$fullWidth && !$compact,
])> ])>
@forelse ($links as $link) @forelse ($links as $link)
<a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}"> <a class="{{ $linkItemClasses }}" target="_blank" href="{{ $link }}">
@@ -4,13 +4,39 @@
'multiselectable' => false, 'multiselectable' => false,
]) ])
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false"> <div class="relative" x-data="{
<div @click="open = !open"> open: false,
panelStyle: 'position: fixed; min-width: 0; visibility: hidden;',
toggle() {
if (this.open) {
this.open = false;
return;
}
this.panelStyle = 'position: fixed; min-width: 0; visibility: hidden;';
this.open = true;
this.$nextTick(() => this.updatePosition());
},
updatePosition() {
const trigger = this.$refs.trigger.getBoundingClientRect();
const panel = this.$refs.panel.getBoundingClientRect();
const viewportPadding = 8;
const left = Math.max(viewportPadding, Math.min(trigger.right - panel.width, window.innerWidth - panel.width - viewportPadding));
const spaceBelow = window.innerHeight - trigger.bottom - viewportPadding;
const top = spaceBelow >= panel.height
? trigger.bottom + 4
: Math.max(viewportPadding, trigger.top - panel.height - 4);
this.panelStyle = `position: fixed; left: ${left}px; top: ${top}px; min-width: 0;`;
}
}" @click.outside="open = false" @keydown.escape.window="open = false"
x-on:resize.window="if (open) updatePosition()" x-on:scroll.window="if (open) updatePosition()">
<div x-ref="trigger" @click="toggle()">
{{ $trigger }} {{ $trigger }}
</div> </div>
<div x-show="open" x-cloak <div x-ref="panel" x-show="open" x-cloak :style="panelStyle"
class="listbox-panel absolute top-full! right-0! left-auto! mt-1! {{ $panelClass }}" role="{{ $role }}" class="listbox-panel fixed! right-auto! bottom-auto! z-[90]! mt-0! {{ $panelClass }}" role="{{ $role }}"
@if ($multiselectable) aria-multiselectable="true" @endif> @if ($multiselectable) aria-multiselectable="true" @endif>
{{ $slot }} {{ $slot }}
</div> </div>
@@ -67,7 +67,7 @@
class="button-highlighted flex h-8 items-center gap-2 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]"> class="button-highlighted flex h-8 items-center gap-2 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]">
<span>Save changes</span> <span>Save changes</span>
<kbd <kbd
class="rounded border border-coollabs/20 bg-coollabs/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-coollabs-200 dark:border-white/20 dark:bg-white/10 dark:text-white/75">Enter</kbd> class="rounded border border-current/20 bg-current/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-current">Enter</kbd>
</button> </button>
</div> </div>
</div> </div>
+14 -8
View File
@@ -196,14 +196,9 @@
:key="'dashboard-server-metrics-'.$server->uuid" /> :key="'dashboard-server-metrics-'.$server->uuid" />
@endif @endif
<div class="pointer-events-none relative z-10 flex min-w-0 items-start gap-3"> <div class="relative z-10 flex min-w-0 items-start gap-3">
<div title="{{ $serverStatus }}" aria-label="Server status: {{ $serverStatus }}" <div
@class([ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.04] dark:text-fg-dim">
'flex size-8 shrink-0 items-center justify-center rounded-lg border bg-neutral-50 text-neutral-500 dark:bg-white/[0.04] dark:text-fg-dim',
'border-emerald-500/70' => $serverStatusType === 'success',
'border-amber-500/70' => $serverStatusType === 'warning',
'border-red-500/70' => $serverStatusType === 'error',
])>
<x-reicon name="servers" class="size-4" /> <x-reicon name="servers" class="size-4" />
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
@@ -215,6 +210,17 @@
{{ $server->description ?: 'No description' }} {{ $server->description ?: 'No description' }}
</p> </p>
</div> </div>
@if ($serverStatusType !== 'success')
<span data-tooltip="{{ $serverStatus }}"
aria-label="Server status: {{ $serverStatus }}"
@class([
'flex size-6 shrink-0 items-center justify-center rounded-md',
'text-orange-500 dark:text-warning' => $serverStatusType === 'warning',
'text-red-500 dark:text-red-400' => $serverStatusType === 'error',
])>
<x-reicon name="alert-triangle" class="size-4" />
</span>
@endif
</div> </div>
</a> </a>
@endforeach @endforeach
@@ -1,18 +1,16 @@
<div wire:poll.3000ms x-on:livewire:navigated.window=" <div wire:poll.3000ms x-on:livewire:navigated.window="
$wire.updateShouldShowFromPath(window.location.pathname || '/') $wire.updateShouldShowFromPath(window.location.pathname || '/')
" x-data="{ " x-data="{
expanded: @entangle('expanded'), expanded: @entangle('expanded')
reduceOpacity: @js($this->shouldReduceOpacity)
}" class="fixed bottom-0 left-0 z-60 mb-4 ml-4 transition-[left] duration-200" }" class="fixed bottom-0 left-0 z-60 mb-4 ml-4 transition-[left] duration-200"
:class="collapsed ? 'lg:left-16' : 'lg:left-56'"> :class="collapsed ? 'lg:left-16' : 'lg:left-56'">
@if ($this->shouldShow && $this->deploymentCount > 0) @if ($this->shouldShow && $this->deploymentCount > 0)
<div class="relative transition-opacity duration-200" <div class="relative">
:class="{ 'opacity-100': expanded || !reduceOpacity, 'opacity-60 hover:opacity-100': reduceOpacity && !expanded }">
{{-- Expanded deployment list (above the pill) --}} {{-- Expanded deployment list (above the pill) --}}
<div x-show="expanded" x-transition:enter="transition ease-out duration-200" <div x-show="expanded" x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0" x-transition:enter-start="translate-y-2" x-transition:enter-end="translate-y-0"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="translate-y-0"
x-transition:leave-end="opacity-0 translate-y-2" x-cloak x-transition:leave-end="translate-y-2" x-cloak
class="absolute bottom-full mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl" class="absolute bottom-full mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl"
style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);"> style="background: var(--coollabs-elevated); box-shadow: 0 0 0 1px var(--coollabs-line), var(--shadow-modal);">
<div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar"> <div class="max-h-96 space-y-1 overflow-y-auto p-2 scrollbar">
@@ -26,9 +24,9 @@
@endphp @endphp
<a wire:key="indicator-deployment-{{ $deployment->id }}" <a wire:key="indicator-deployment-{{ $deployment->id }}"
href="{{ $deployment->deployment_url }}" {{ wireNavigate() }} href="{{ $deployment->deployment_url }}" {{ wireNavigate() }}
class="flex items-start gap-3 rounded-lg border border-transparent p-3 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:no-underline dark:hover:border-white/[0.08] dark:hover:bg-white/[0.04]"> class="flex items-start gap-3 rounded-lg border border-transparent p-3 transition-colors hover:border-neutral-200 hover:bg-neutral-50 hover:no-underline dark:border-coolgray-300 dark:hover:border-coolgray-400 dark:hover:bg-raised">
<div <div
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-coollabs dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-warning"> class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-coollabs dark:border-coolgray-300 dark:bg-raised dark:text-warning">
@if ($deployment->status === 'in_progress') @if ($deployment->status === 'in_progress')
<svg class="size-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" <svg class="size-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" aria-hidden="true"> viewBox="0 0 24 24" aria-hidden="true">
@@ -61,7 +59,7 @@
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint"> <p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $deployment->server_name ?: '-' }} {{ $deployment->server_name ?: '-' }}
@if ($deployment->pull_request_id) @if ($deployment->pull_request_id)
<span class="px-1 text-neutral-300 dark:text-white/15">·</span> <span class="px-1 text-neutral-300 dark:text-fg-faint">·</span>
PR #{{ $deployment->pull_request_id }} PR #{{ $deployment->pull_request_id }}
@endif @endif
</p> </p>
@@ -73,7 +71,7 @@
{{-- Collapsed pill --}} {{-- Collapsed pill --}}
<button type="button" @click="expanded = !expanded" <button type="button" @click="expanded = !expanded"
class="flex items-center gap-2 rounded-xl border border-neutral-200 bg-white px-3.5 py-2 text-sm font-medium text-neutral-800 transition-colors hover:bg-neutral-50 dark:border-white/[0.08] dark:bg-surface dark:text-fg dark:hover:bg-white/[0.04]" class="flex items-center gap-2 rounded-xl border border-neutral-200 bg-white px-3.5 py-2 text-sm font-medium text-neutral-800 transition-colors hover:bg-neutral-50 dark:border-coolgray-300 dark:bg-surface dark:text-fg dark:hover:bg-raised"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal);" style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal);"
:aria-expanded="expanded.toString()" aria-label="Active deployments"> :aria-expanded="expanded.toString()" aria-label="Active deployments">
<svg class="loading-indicator size-3.5 shrink-0 animate-spin" <svg class="loading-indicator size-3.5 shrink-0 animate-spin"
@@ -15,7 +15,7 @@
</x-slot:actions> </x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="discordPingEnabled" label="Critical event mention" <x-forms.listbox canGate="update" :canResource="$settings" id="discordPingEnabled" label="Critical event mention"
helper="Mention @here when a critical event occurs." helper="Mention @here when a critical event occurs."
onChange="instantSaveDiscordPingEnabled" onChange="instantSaveDiscordPingEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
@@ -41,7 +41,7 @@
<div class="lg:col-span-2"> <div class="lg:col-span-2">
@if (isCloud()) @if (isCloud())
<div class="w-full sm:w-72"> <div class="w-full sm:w-72">
<x-forms.listbox id="useInstanceEmailSettings" label="Email service" <x-forms.listbox canGate="update" :canResource="$settings" id="useInstanceEmailSettings" label="Email service"
onChange="instantSave" onChange="instantSave"
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Use hosted email service'], ['value' => true, 'label' => 'Use hosted email service'],
@@ -50,7 +50,7 @@
</div> </div>
@else @else
<div class="w-full sm:w-72"> <div class="w-full sm:w-72">
<x-forms.listbox id="useInstanceEmailSettings" label="Email service" <x-forms.listbox canGate="update" :canResource="$settings" id="useInstanceEmailSettings" label="Email service"
onChange="instantSave" onChange="instantSave"
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Use system-wide settings'], ['value' => true, 'label' => 'Use system-wide settings'],
@@ -85,7 +85,7 @@
<div class="grid gap-4 lg:grid-cols-3"> <div class="grid gap-4 lg:grid-cols-3">
<div class="lg:col-span-3"> <div class="lg:col-span-3">
<div class="w-full sm:w-72"> <div class="w-full sm:w-72">
<x-forms.listbox id="smtpEnabled" label="SMTP delivery" <x-forms.listbox canGate="update" :canResource="$settings" id="smtpEnabled" label="SMTP delivery"
onChange="submitSmtp" onChange="submitSmtp"
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'], ['value' => true, 'label' => 'Enabled'],
@@ -97,7 +97,7 @@
placeholder="smtp.mailgun.org" label="Host" /> placeholder="smtp.mailgun.org" label="Host" />
<x-forms.input canGate="update" :canResource="$settings" required id="smtpPort" <x-forms.input canGate="update" :canResource="$settings" required id="smtpPort"
type="number" placeholder="587" label="Port" /> type="number" placeholder="587" label="Port" />
<x-forms.listbox id="smtpEncryption" label="Encryption" required <x-forms.listbox canGate="update" :canResource="$settings" id="smtpEncryption" label="Encryption" required
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => 'starttls', 'label' => 'StartTLS'], ['value' => 'starttls', 'label' => 'StartTLS'],
['value' => 'tls', 'label' => 'TLS / SSL'], ['value' => 'tls', 'label' => 'TLS / SSL'],
@@ -120,7 +120,7 @@
<div class="application-settings-form"> <div class="application-settings-form">
<x-application.settings-section title="Resend"> <x-application.settings-section title="Resend">
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="resendEnabled" label="Resend delivery" <x-forms.listbox canGate="update" :canResource="$settings" id="resendEnabled" label="Resend delivery"
onChange="submitResend" onChange="submitResend"
:disabled="!auth()->user()->can('update', $settings)" :options="[ :disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'], ['value' => true, 'label' => 'Enabled'],
@@ -8,6 +8,9 @@
$helperText = $isCompose $helperText = $isCompose
? 'Manage domains for every service in this Docker Compose application.' ? 'Manage domains for every service in this Docker Compose application.'
: 'Manage domains for this application.'; : 'Manage domains for this application.';
$hasHttpsDomains = collect($domainRows)->contains(
fn ($row) => ! ($row['is_suggested'] ?? false) && str_starts_with(strtolower($row['url']), 'https://')
);
@endphp @endphp
<div class="flex flex-col gap-4" <div class="flex flex-col gap-4"
@@ -15,30 +18,14 @@
domainSearch: '', domainSearch: '',
modalOpen: @js($showEditDomainModal || $editDomainDnsFailed), modalOpen: @js($showEditDomainModal || $editDomainDnsFailed),
editingServiceLabel: @js($editingService ?? ''), editingServiceLabel: @js($editingService ?? ''),
localEditingIndex: @js($editingIndex), openEditDomain() {
localEditingDomain: @js($editingDomain), this.editingServiceLabel = $wire.editingService || '';
localEditingService: @js($editingService),
openEditDomain(index, url, service) {
this.localEditingIndex = index;
this.localEditingDomain = url;
this.localEditingService = service;
this.editingServiceLabel = service || '';
this.modalOpen = true; this.modalOpen = true;
this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.()); this.$nextTick(() => document.getElementById('editingDomainLocal')?.focus?.());
}, },
closeEditDomain() { closeEditDomain() {
this.modalOpen = false; this.modalOpen = false;
this.editingServiceLabel = ''; this.editingServiceLabel = '';
this.localEditingIndex = null;
this.localEditingDomain = '';
this.localEditingService = null;
},
prepareEditSubmit() {
// Sync Alpine → Livewire only when the user actually saves (one request).
$wire.editingIndex = this.localEditingIndex;
$wire.editingDomain = this.localEditingDomain;
$wire.editingService = this.localEditingService;
$wire.showEditDomainModal = true;
}, },
matchesDomainSearch(value) { matchesDomainSearch(value) {
return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase()); return !this.domainSearch.trim() || value.toLowerCase().includes(this.domainSearch.trim().toLowerCase());
@@ -47,7 +34,7 @@
return values.some((value) => this.matchesDomainSearch(value)); return values.some((value) => this.matchesDomainSearch(value));
}, },
}" }"
@open-edit-domain.window="openEditDomain($event.detail.index, $event.detail.url, $event.detail.service)" @open-edit-domain.window="openEditDomain()"
@edit-domain-saved.window="closeEditDomain()"> @edit-domain-saved.window="closeEditDomain()">
<x-application.settings-section id="domains-section" title="Domains"> <x-application.settings-section id="domains-section" title="Domains">
@can('update', $application) @can('update', $application)
@@ -82,6 +69,18 @@
{{ $helperText }} {{ $helperText }}
</p> </p>
@if ($hasHttpsDomains && ! $labelsAreWritable)
<div class="mt-4 max-w-md">
<x-forms.listbox canGate="update" :canResource="$application" id="isForceHttpsEnabled" label="Redirect HTTP to HTTPS"
onChange="updateForceHttps"
helper="Disable only when Cloudflare Tunnel or another proxy connects to Coolify over HTTP. Keep enabled when Cloudflare uses Full or Full (Strict) SSL."
:options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" :disabled="! auth()->user()->can('update', $application)" />
</div>
@endif
</x-application.settings-section> </x-application.settings-section>
{{-- Toolbar --}} {{-- Toolbar --}}
@@ -120,7 +119,7 @@
</x-slot:content> </x-slot:content>
<form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4"> <form wire:submit="addDomain" class="application-settings-form flex flex-col gap-4">
@if ($isCompose && count($composeServices) > 0) @if ($isCompose && count($composeServices) > 0)
<x-forms.listbox label="Service" id="newDomainService" required <x-forms.listbox canGate="update" :canResource="$application" label="Service" id="newDomainService" required
:options="collect($composeServices)->map(fn ($serviceName) => [ :options="collect($composeServices)->map(fn ($serviceName) => [
'value' => $serviceName, 'value' => $serviceName,
'label' => $serviceName, 'label' => $serviceName,
@@ -128,7 +127,7 @@
:disabled="! auth()->user()->can('update', $application)" /> :disabled="! auth()->user()->can('update', $application)" />
@endif @endif
<x-forms.domain-input id="newDomain" /> <x-forms.domain-input id="newDomainParts" errorId="newDomain" />
@if ($addDomainDnsFailed) @if ($addDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP"> <x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -320,7 +319,7 @@
</header> </header>
<div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto" <div class="application-settings-section-body relative min-h-0 flex-1 overflow-y-auto"
style="-webkit-overflow-scrolling: touch;"> style="-webkit-overflow-scrolling: touch;">
<form @submit.prevent="prepareEditSubmit(); $wire.updateDomain()" class="flex flex-col gap-4"> <form wire:submit="updateDomain" class="flex flex-col gap-4">
<div x-show="editingServiceLabel" x-cloak class="w-full"> <div x-show="editingServiceLabel" x-cloak class="w-full">
<div class="mb-1.5 flex h-4 w-full items-center gap-1.5"> <div class="mb-1.5 flex h-4 w-full items-center gap-1.5">
<label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label> <label class="mb-0! flex items-center gap-1 text-sm font-medium leading-4">Service</label>
@@ -328,8 +327,7 @@
<input type="text" class="input" readonly x-bind:value="editingServiceLabel" /> <input type="text" class="input" readonly x-bind:value="editingServiceLabel" />
</div> </div>
<x-forms.domain-input id="editingDomainLocal" errorId="editingDomain" :wire="false" <x-forms.domain-input id="editingDomainParts" errorId="editingDomain" />
x-model="localEditingDomain" />
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-callout type="danger" title="DNS is not pointing to the right IP"> <x-callout type="danger" title="DNS is not pointing to the right IP">
@@ -345,7 +343,7 @@
<div class="flex flex-wrap items-center justify-end gap-2 pt-2"> <div class="flex flex-wrap items-center justify-end gap-2 pt-2">
@if ($editDomainDnsFailed) @if ($editDomainDnsFailed)
<x-forms.button type="button" isError <x-forms.button type="button" isError
@click="prepareEditSubmit(); $wire.forceSaveEditDns = true; $wire.confirmUpdateDomainDespiteDns()"> wire:click="confirmUpdateDomainDespiteDns">
Continue Continue
</x-forms.button> </x-forms.button>
@else @else
@@ -39,6 +39,7 @@
<div class="w-full xl:hidden"> <div class="w-full xl:hidden">
@if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw))) @if (!($application->build_pack === 'dockercompose' && is_null($application->docker_compose_raw)))
@can('deploy', $application)
<div id="application-mobile-actions" class="relative mb-3" <div id="application-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false"> @keydown.escape.window="open = false">
@@ -149,6 +150,7 @@
@endif @endif
</div> </div>
</div> </div>
@endcan
@endif @endif
<div class="hidden" aria-hidden="true"> <div class="hidden" aria-hidden="true">
<x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop" <x-modal-confirmation title="Confirm Application Stopping?" buttonTitle="Stop"
@@ -185,6 +187,7 @@
<div class="resource-heading-menus shrink-0"> <div class="resource-heading-menus shrink-0">
<x-applications.links :application="$application" /> <x-applications.links :application="$application" />
</div> </div>
@can('deploy', $application)
<div id="application-desktop-actions" class="relative" x-data="{ open: false }" <div id="application-desktop-actions" class="relative" x-data="{ open: false }"
x-effect="$dispatch('resource-actions-toggled', { open })" x-effect="$dispatch('resource-actions-toggled', { open })"
@click.outside="open = false" @keydown.escape.window="open = false"> @click.outside="open = false" @keydown.escape.window="open = false">
@@ -279,6 +282,7 @@
@endif @endif
</div> </div>
</div> </div>
@endcan
@endif @endif
</div> </div>
</div> </div>
@@ -178,12 +178,7 @@
</x-forms.button> </x-forms.button>
@endif @endif
@else @else
<button type="button" <button type="button" wire:click="startEdit({{ $index }})"
@click="$dispatch('open-edit-domain', {
index: {{ $index }},
url: @js($row['url']),
service: @js($row['service'] ?? null),
})"
class="icon-button shrink-0" class="icon-button shrink-0"
title="Edit domain" aria-label="Edit domain"> title="Edit domain" aria-label="Edit domain">
<x-reicon name="settings" class="size-3.5" /> <x-reicon name="settings" class="size-3.5" />
@@ -14,7 +14,7 @@
<div class="mt-4 grid gap-4 lg:grid-cols-2"> <div class="mt-4 grid gap-4 lg:grid-cols-2">
<x-forms.input id="swarmReplicas" label="Replicas" required canGate="update" <x-forms.input id="swarmReplicas" label="Replicas" required canGate="update"
:canResource="$application" /> :canResource="$application" />
<x-forms.listbox id="isSwarmOnlyWorkerNodes" label="Node placement" live onChange="instantSave" <x-forms.listbox canGate="update" :canResource="$application" id="isSwarmOnlyWorkerNodes" label="Node placement" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $application)" :options="[ :disabled="! auth()->user()->can('update', $application)" :options="[
['value' => true, 'label' => 'Worker nodes only'], ['value' => true, 'label' => 'Worker nodes only'],
['value' => false, 'label' => 'Manager and worker nodes'], ['value' => false, 'label' => 'Manager and worker nodes'],
@@ -1,4 +1,4 @@
<div> <div class="flex flex-col gap-6">
@if ($backup->database_id === 0) @if ($backup->database_id === 0)
@include('livewire.project.database.backup-edit.general') @include('livewire.project.database.backup-edit.general')
@include('livewire.project.database.backup-edit.s3') @include('livewire.project.database.backup-edit.s3')
@@ -79,7 +79,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -94,7 +94,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -80,7 +80,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -95,7 +95,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -69,6 +69,7 @@
<div class="w-full xl:hidden"> <div class="w-full xl:hidden">
@if ($database->destination->server->isFunctional()) @if ($database->destination->server->isFunctional())
@can('manage', $database)
<div id="database-mobile-actions" class="relative mb-3" <div id="database-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false" x-data="{ open: false }" @click.outside="open = false"
@keydown.escape.window="open = false"> @keydown.escape.window="open = false">
@@ -127,6 +128,7 @@
@endif @endif
</div> </div>
</div> </div>
@endcan
@endif @endif
</div> </div>
@@ -137,6 +139,7 @@
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible"> class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5"> <div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional()) @if ($database->destination->server->isFunctional())
@can('manage', $database)
<div id="database-desktop-actions" class="flex items-center gap-0.5"> <div id="database-desktop-actions" class="flex items-center gap-0.5">
@if (! $databaseStatus->startsWith('exited')) @if (! $databaseStatus->startsWith('exited'))
<button type="button" class="button button-highlighted" <button type="button" class="button button-highlighted"
@@ -156,6 +159,7 @@
</x-forms.button> </x-forms.button>
@endif @endif
</div> </div>
@endcan
@else @else
<x-status-badge status="Server unavailable" type="error" /> <x-status-badge status="Server unavailable" type="error" />
@endif @endif
@@ -81,7 +81,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -104,7 +104,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -86,7 +86,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -107,7 +107,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -83,7 +83,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -104,7 +104,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],
@@ -86,7 +86,7 @@
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">
<div wire:key="public-access-{{ $publicPort ?: 'unset' }}"> <div wire:key="public-access-{{ $publicPort ?: 'unset' }}">
<x-forms.listbox id="isPublic" label="Access" live onChange="instantSave" <x-forms.listbox id="isPublic" label="Access" live onChange="instantSave"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" canGate="update" :canResource="$database" :options="[
['value' => false, 'label' => 'Private'], ['value' => false, 'label' => 'Private'],
['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)], ['value' => true, 'label' => blank($publicPort) ? 'Public through TCP proxy (set public port first)' : 'Public through TCP proxy', 'disabled' => blank($publicPort)],
]" /> ]" />
@@ -107,7 +107,7 @@
<x-application.settings-section title="Log delivery" <x-application.settings-section title="Log delivery"
description="Forward container logs to the drain configured on the server."> description="Forward container logs to the drain configured on the server.">
<x-forms.listbox id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced" <x-forms.listbox canGate="update" :canResource="$database" id="isLogDrainEnabled" label="Log drain" live onChange="instantSaveAdvanced"
:disabled="! auth()->user()->can('update', $database)" :options="[ :disabled="! auth()->user()->can('update', $database)" :options="[
['value' => false, 'label' => 'Do not forward logs'], ['value' => false, 'label' => 'Do not forward logs'],
['value' => true, 'label' => 'Forward logs to the server drain'], ['value' => true, 'label' => 'Forward logs to the server drain'],

Some files were not shown because too many files have changed in this diff Show More