diff --git a/.ai/lessons.md b/.ai/lessons.md index a505578511..339fc7e60d 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -124,6 +124,7 @@ ## Cached and stored state - `StandaloneDocker`/`SwarmDocker` `server` uses `Server::findCached()`, a per-process identity map that is flushed only when the `servers` row updates. A `ServerSetting` save does not flush it, so pass the current `Server` instance (`setRelation('server', $server)`) when generated output depends on just-saved settings. - Application deployments use the stored `custom_labels` when they exist. When a server-level setting changes the generated labels, regenerate the stored labels of read-only (`is_container_label_readonly_enabled`) applications, or a redeploy keeps the old labels. +- Tailwind `text-base` is a colour utility here (`--color-base`, near-black), not a font size. Use `text-[16px]` for size. ## Keep browser form restore away from app state - Firefox session restore writes saved input values back and fires a trusted `input` event. Add `autocomplete="off"` to inputs that only mirror client state (for example an Alpine-bound color picker), or their handlers run without a user action. diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 6463b70cdf..86545e010f 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -81,7 +81,7 @@ class StartClickhouse data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 72f66a64ce..2602de63d1 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -130,7 +130,7 @@ class StartDragonfly } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 75827b4bac..dddb86e2c2 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -132,7 +132,7 @@ class StartKeydb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 5440ed8611..234583a538 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -123,7 +123,7 @@ class StartMariadb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 49cee8ea3f..0064b43e38 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -130,7 +130,7 @@ class StartMongodb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 6fb288b7b0..e8c24d5179 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -125,7 +125,7 @@ class StartMysql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index 05f9be02f9..88bb59a88b 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -131,7 +131,7 @@ class StartPostgresql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 3d447d91da..b0ac2e7001 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -133,7 +133,7 @@ class StartRedis } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index b85d4e73e3..b63598f998 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -69,7 +69,7 @@ class StartSqlite data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($persistent_storages) > 0) { $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 11bf160f57..4fce8b7168 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -23,9 +23,17 @@ class StartLogDrain $type = 'axiom'; } elseif ($server->settings->is_logdrain_custom_enabled) { $type = 'custom'; + } elseif ($server->settings->is_logdrain_cloudwatch_enabled) { + $type = 'cloudwatch'; } else { $type = 'none'; } + if ($type === 'cloudwatch') { + // Containers ship logs directly via Docker's awslogs driver; no Fluent Bit container is needed. + StopLogDrain::run($server); + + return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.'; + } if ($type !== 'none') { StopLogDrain::run($server); } @@ -230,7 +238,7 @@ Files: private function logDrainNetworkConnectCommands(Server $server): array { - if (! $server->isLogDrainEnabled()) { + if (! $server->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 4edeb9f9dd..6d938c9ac4 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -140,7 +140,7 @@ class StartService return []; } - if (! $service->destination?->server?->isLogDrainEnabled()) { + if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index 1cca69a663..fe38dbd1d5 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -45,6 +45,7 @@ class NotificationsController extends Controller 'use_instance_email_settings' => 'sometimes|boolean', 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', + 'deployment_commit_details_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index 8084070b82..af5aaa1dfd 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -23,6 +23,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -44,6 +48,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, 'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled, + 'is_logdrain_cloudwatch_enabled' => (bool) $settings->is_logdrain_cloudwatch_enabled, + 'logdrain_cloudwatch_region' => $settings->logdrain_cloudwatch_region, + 'logdrain_cloudwatch_group' => $settings->logdrain_cloudwatch_group, + 'logdrain_cloudwatch_stream_prefix' => $settings->logdrain_cloudwatch_stream_prefix, ]; if ($this->canReadSensitive()) { @@ -81,6 +89,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -130,6 +142,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -172,6 +188,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled' => 'boolean', 'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig], 'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)], + 'is_logdrain_cloudwatch_enabled' => 'boolean', + 'logdrain_cloudwatch_region' => ['nullable', 'string', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logdrain_cloudwatch_group' => ['nullable', 'string', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logdrain_cloudwatch_stream_prefix' => ['nullable', 'string', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -229,6 +249,27 @@ class ServerLogDrainsController extends Controller ], 422); } + if ($settings->is_logdrain_cloudwatch_enabled) { + if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled || $settings->is_logdrain_highlight_enabled) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_logdrain_cloudwatch_enabled' => ['Amazon CloudWatch Logs cannot be enabled together with another log drain.'], + ], + ], 422); + } + $errors = []; + if (blank($settings->logdrain_cloudwatch_region)) { + $errors['logdrain_cloudwatch_region'] = ['The CloudWatch region is required when Amazon CloudWatch Logs is enabled.']; + } + if (blank($settings->logdrain_cloudwatch_group)) { + $errors['logdrain_cloudwatch_group'] = ['The CloudWatch log group is required when Amazon CloudWatch Logs is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + $settings->save(); $server->refresh(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 25aec367bc..7e30184241 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -4262,7 +4262,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $docker_compose['services'][$this->container_name]['labels'] = $labels; } if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { - $docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer); } if ($this->application->settings->is_gpu_enabled) { $docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [ @@ -6060,9 +6060,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $unsuccessful = $deployments->first(fn (ApplicationDeploymentQueue $deployment) => $deployment->status !== ApplicationDeploymentStatus::FINISHED->value); + // Additional servers pull the image and do not clone, so only the main deployment has the commit message. + $parentDeployment = ApplicationDeploymentQueue::query()->where('deployment_uuid', $parentDeploymentUuid)->first(); $this->application->environment->project->team?->notify($unsuccessful - ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview) - : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview)); + ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview, $parentDeployment) + : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview, $parentDeployment)); } /** @@ -6071,7 +6073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function sendDeploymentNotification(string $notificationClass): void { $this->application->environment->project->team?->notify( - new $notificationClass($this->application, $this->deployment_uuid, $this->preview) + new $notificationClass($this->application, $this->deployment_uuid, $this->preview, $this->application_deployment_queue) ); } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 768ebddb22..ec1ac376f2 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -970,7 +970,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function checkLogDrainContainer() { - if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { + if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) { StartLogDrain::dispatch($this->server); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index a4fa961345..0ec1ce606f 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue CheckAndStartSentinelJob::dispatch($this->server); } - if ($this->server->isLogDrainEnabled()) { + if ($this->server->isFluentBitLogDrainEnabled()) { $this->checkLogDrainContainer(); } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 0a37c75d16..0913e93507 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -76,6 +76,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $deploymentFailureEmailNotifications = true; + #[Validate(['boolean'])] + public bool $deploymentCommitDetailsEmailNotifications = false; + #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; @@ -156,6 +159,7 @@ class Email extends Component $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; + $this->settings->deployment_commit_details_email_notifications = $this->deploymentCommitDetailsEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; @@ -197,6 +201,7 @@ class Email extends Component $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; @@ -231,6 +236,18 @@ class Email extends Component $this->dispatch('success', 'Email notifications settings updated.'); } + public function instantSaveDeploymentCommitDetails() + { + try { + $this->resetErrorBag(); + $this->saveModel(); + } catch (\Throwable $e) { + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; + + return handleError($e, $this); + } + } + public function instantSave(?string $type = null) { try { diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 01a89b721a..daa6754480 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -34,14 +34,33 @@ class Terminal extends Component public string $variant = 'default'; + /** + * Whether the selected container can be attached to (its main process keeps stdin open). + */ + public bool $attachAvailable = false; + + /** + * Active terminal mode: 'shell' (docker exec) or 'attach' (docker attach). + */ + public string $terminalMode = 'shell'; + + // Remembered target so the mode switch can reconnect without re-selecting a container. + public bool $currentIsContainer = false; + + public ?string $currentIdentifier = null; + + public ?string $currentServerUuid = null; + private function checkShellAvailability(Server $server, string $container): bool { $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; try { instant_remote_process([ - "docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". - "docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", - ], $server); + "{$sudo}docker exec {$escapedContainer} bash -c 'exit 0' 2>/dev/null || ". + "{$sudo}docker exec {$escapedContainer} sh -c 'exit 0' 2>/dev/null", + ], $server, no_sudo: true); return true; } catch (Throwable) { @@ -49,8 +68,40 @@ class Terminal extends Component } } + private function containerKeepsStdinOpen(Server $server, string $container): bool + { + $escapedContainer = escapeshellarg($container); + // Non-root SSH users need sudo to reach the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + try { + $output = instant_remote_process([ + "{$sudo}docker inspect --format '{{.Config.OpenStdin}}' {$escapedContainer} 2>/dev/null", + ], $server, false, no_sudo: true); + + return is_string($output) && str_starts_with(trim($output), 'true'); + } catch (Throwable) { + return false; + } + } + + /** + * Resolve the effective terminal mode. Falls back to shell whenever attach is not available. + */ + public function resolveMode(?string $requestedMode): string + { + if ($requestedMode === 'attach') { + return $this->attachAvailable ? 'attach' : 'shell'; + } + + if ($requestedMode === 'shell') { + return 'shell'; + } + + return $this->attachAvailable ? 'attach' : 'shell'; + } + #[On('send-terminal-command')] - public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService): void + public function sendTerminalCommand($isContainer, $identifier, $serverUuid, ?string $requestedMode = null): void { try { $this->authorize('canAccessTerminal'); @@ -69,6 +120,11 @@ class Terminal extends Component return; } + // Remember the target so the mode switch can reconnect without re-selecting. + $this->currentIsContainer = (bool) $isContainer; + $this->currentIdentifier = $identifier; + $this->currentServerUuid = $serverUuid; + if ($isContainer) { // Validate container identifier format (alphanumeric, dashes, and underscores only) if (! is_string($identifier) || ! ValidationPatterns::isValidContainerName($identifier)) { @@ -77,7 +133,7 @@ class Terminal extends Component return; } - // Verify container exists and belongs to the user's team + // Verify the container is running on the authorized server $status = getContainerStatus($server, $identifier); if ($status !== 'running') { $this->failTerminalSession('The container is not running.'); @@ -85,16 +141,25 @@ class Terminal extends Component return; } - // Check shell availability + // Check shell availability and whether the container can be attached to. $this->hasShell = $this->checkShellAvailability($server, $identifier); - if (! $this->hasShell) { + $this->attachAvailable = $this->containerKeepsStdinOpen($server, $identifier); + + $mode = $this->resolveMode($requestedMode); + $this->terminalMode = $mode; + + // Shell mode needs a shell; console (attach) mode does not. + if ($mode === 'shell' && ! $this->hasShell) { $this->failTerminalSession('No shell is available in this container.'); return; } + } else { + $this->attachAvailable = false; + $mode = $this->terminalMode = 'shell'; } - $token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null); + $token = app(TerminalSessionService::class)->issue(auth()->user(), $server, $isContainer ? $identifier : null, $mode); $this->dispatch('send-terminal-token', $token); } @@ -120,6 +185,23 @@ class Terminal extends Component $this->dispatch(self::SESSION_FAILED_EVENT, message: $message)->self(); } + /** + * Switch between shell and console (attach) mode and reconnect to the current container. + */ + #[On('set-terminal-mode')] + public function setMode(string $mode): void + { + if (! in_array($mode, ['shell', 'attach'], true)) { + return; + } + + if (! $this->currentIsContainer || $this->currentIdentifier === null || $this->currentServerUuid === null) { + return; + } + + $this->sendTerminalCommand(true, $this->currentIdentifier, $this->currentServerUuid, $mode); + } + #[On('terminalConnected')] public function markTerminalConnected(): void { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ef9addf231..c48816aa40 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -7,6 +7,7 @@ use App\Actions\Server\StopLogDrain; use App\Models\Server; use App\Rules\ValidFluentBitConfig; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -43,6 +44,18 @@ class LogDrains extends Component #[Validate(['string', 'nullable'])] public ?string $logDrainCustomConfigParser = null; + #[Validate(['boolean'])] + public bool $isLogDrainCloudwatchEnabled = false; + + #[Validate(['string', 'nullable', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'])] + public ?string $logDrainCloudwatchRegion = null; + + #[Validate(['string', 'nullable', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'])] + public ?string $logDrainCloudwatchGroup = null; + + #[Validate(['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'])] + public ?string $logDrainCloudwatchStreamPrefix = null; + public function mount(string $server_uuid) { try { @@ -100,6 +113,21 @@ class LogDrains extends Component } } + private function syncDataCloudwatch(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->is_logdrain_cloudwatch_enabled = $this->isLogDrainCloudwatchEnabled; + $this->server->settings->logdrain_cloudwatch_region = $this->logDrainCloudwatchRegion; + $this->server->settings->logdrain_cloudwatch_group = $this->logDrainCloudwatchGroup; + $this->server->settings->logdrain_cloudwatch_stream_prefix = $this->logDrainCloudwatchStreamPrefix; + } else { + $this->isLogDrainCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $this->logDrainCloudwatchRegion = $this->server->settings->logdrain_cloudwatch_region; + $this->logDrainCloudwatchGroup = $this->server->settings->logdrain_cloudwatch_group; + $this->logDrainCloudwatchStreamPrefix = $this->server->settings->logdrain_cloudwatch_stream_prefix; + } + } + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { @@ -110,10 +138,16 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); + } + if ($this->enabledLogDrainCount() > 0) { + $this->server->settings->is_logdrain_highlight_enabled = false; } $this->auditLogDrain('updated'); $this->server->settings->save(); @@ -124,16 +158,27 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); } } } public function customValidation() { + if ($this->enabledLogDrainCount() > 1) { + $this->syncData(); + + throw ValidationException::withMessages([ + 'logDrain' => 'Only one log drain can be enabled at a time. Disable the current log drain first.', + ]); + } + if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ @@ -165,6 +210,18 @@ class LogDrains extends Component } catch (\Throwable $e) { $this->isLogDrainCustomEnabled = false; + throw $e; + } + } elseif ($this->isLogDrainCloudwatchEnabled) { + try { + $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]); + } catch (\Throwable $e) { + $this->isLogDrainCloudwatchEnabled = false; + throw $e; } } @@ -193,6 +250,8 @@ class LogDrains extends Component $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + $previousCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $previousHighlightEnabled = $this->server->settings->is_logdrain_highlight_enabled; try { $this->authorize('update', $this->server); @@ -207,6 +266,7 @@ class LogDrains extends Component $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; $this->isLogDrainAxiomEnabled = $type === 'axiom'; $this->isLogDrainCustomEnabled = $type === 'custom'; + $this->isLogDrainCloudwatchEnabled = $type === 'cloudwatch'; } $this->syncData(true); @@ -225,6 +285,8 @@ class LogDrains extends Component $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->is_logdrain_cloudwatch_enabled = $previousCloudwatchEnabled; + $this->server->settings->is_logdrain_highlight_enabled = $previousHighlightEnabled; $this->server->settings->save(); $this->syncData(); @@ -248,12 +310,23 @@ class LogDrains extends Component return view('livewire.server.log-drains'); } + private function enabledLogDrainCount(): int + { + return count(array_filter([ + $this->isLogDrainNewRelicEnabled, + $this->isLogDrainAxiomEnabled, + $this->isLogDrainCustomEnabled, + $this->isLogDrainCloudwatchEnabled, + ])); + } + private function enabledProperty(string $type): string { return match ($type) { 'newrelic' => 'isLogDrainNewRelicEnabled', 'axiom' => 'isLogDrainAxiomEnabled', 'custom' => 'isLogDrainCustomEnabled', + 'cloudwatch' => 'isLogDrainCloudwatchEnabled', default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } @@ -283,6 +356,11 @@ class LogDrains extends Component 'logDrainCustomConfig' => ['required', new ValidFluentBitConfig], 'logDrainCustomConfigParser' => ['string', 'nullable', new ValidFluentBitConfig(parsersOnly: true)], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 94d0802d96..98e530ce84 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -33,6 +33,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', + 'deployment_commit_details_email_notifications', 'status_change_email_notifications', 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', @@ -76,6 +77,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', + 'deployment_commit_details_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', diff --git a/app/Models/Server.php b/app/Models/Server.php index 00b10235b3..178b180d22 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2057,6 +2057,14 @@ $siteAddress { } public function isLogDrainEnabled() + { + return $this->isFluentBitLogDrainEnabled() || $this->settings->is_logdrain_cloudwatch_enabled; + } + + /** + * Whether the active log drain relies on the coolify-log-drain Fluent Bit container. + */ + public function isFluentBitLogDrainEnabled(): bool { return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_highlight_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 6085320080..25b430b851 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -27,6 +27,7 @@ use OpenApi\Attributes as OA; 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], + 'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'], 'is_logdrain_custom_enabled' => ['type' => 'boolean'], 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], @@ -48,6 +49,9 @@ use OpenApi\Attributes as OA; 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], + 'logdrain_cloudwatch_region' => ['type' => 'string'], + 'logdrain_cloudwatch_group' => ['type' => 'string'], + 'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'], 'logdrain_custom_config' => ['type' => 'string'], 'logdrain_custom_config_parser' => ['type' => 'string'], 'logdrain_highlight_project_id' => ['type' => 'string'], @@ -114,6 +118,10 @@ class ServerSetting extends Model 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', 'concurrent_builds', 'dynamic_timeout', 'force_disabled', @@ -200,6 +208,7 @@ class ServerSetting extends Model 'patch_check_result' => 'array', 'patch_checked_at' => 'datetime', 'is_auto_security_updates_enabled' => 'boolean', + 'is_logdrain_cloudwatch_enabled' => 'boolean', ]; /** diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 8fff7f03bb..376f120c88 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentFailed extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentFailed extends CustomEmailNotification public ?string $fqdn = null; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentFailed extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentFailed extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_failure'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentFailed extends CustomEmailNotification $mail->subject('Coolify: Deployment failed of pull request #'.$this->preview->pull_request_id.' of '.$this->application_name.'.'); } $mail->view('emails.application-deployment-failed', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 415df5831d..07c3000fb5 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentSuccess extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentSuccess extends CustomEmailNotification public ?string $fqdn; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentSuccess extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentSuccess extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_success'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentSuccess extends CustomEmailNotification $mail->subject("Coolify: Pull request #{$pull_request_id} of {$this->application_name} deployed successfully"); } $mail->view('emails.application-deployment-success', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Services/TerminalSessionService.php b/app/Services/TerminalSessionService.php index eea260b5a5..b2fb4d09a2 100644 --- a/app/Services/TerminalSessionService.php +++ b/app/Services/TerminalSessionService.php @@ -14,7 +14,10 @@ class TerminalSessionService { private const TOKEN_TTL_SECONDS = 60; - public function issue(User $user, Server $server, ?string $container = null): string + /** + * @param 'shell'|'attach' $mode Container terminal mode: docker exec or docker attach. + */ + public function issue(User $user, Server $server, ?string $container = null, string $mode = 'shell'): string { $token = Str::random(64); @@ -23,6 +26,7 @@ class TerminalSessionService 'team_id' => $user->currentTeam()->id, 'server_uuid' => $server->uuid, 'container' => $container, + 'mode' => $mode === 'attach' ? 'attach' : 'shell', ], now()->addSeconds(self::TOKEN_TTL_SECONDS)); return $token; @@ -63,8 +67,7 @@ class TerminalSessionService throw new AccessDeniedHttpException('Terminal container is not authorized.'); } - $dockerCommand = 'docker exec -it '.escapeshellarg($container).' sh -c '.escapeshellarg($command); - $command = $server->isNonRoot() ? "sudo {$dockerCommand}" : $dockerCommand; + $command = $this->containerCommand($server, $container, data_get($payload, 'mode') === 'attach' ? 'attach' : 'shell'); } return SshMultiplexingHelper::generateSshCommand( @@ -74,6 +77,34 @@ class TerminalSessionService ); } + /** + * Build the docker command that runs on the remote server for the given mode. + * + * @param 'shell'|'attach' $mode + */ + public function containerCommand(Server $server, string $container, string $mode): string + { + $escapedContainer = escapeshellarg($container); + // Add sudo for non-root users to access the Docker socket. + $sudo = $server->isNonRoot() ? 'sudo ' : ''; + + if ($mode === 'attach') { + $detachKeys = config('constants.terminal.detach_keys'); + $historyLines = (int) config('constants.terminal.console_history_lines'); + + // Print recent output first so the console is not blank, then attach for live output. + $primeHistory = $historyLines > 0 + ? "{$sudo}docker logs --tail {$historyLines} {$escapedContainer} 2>&1; " + : ''; + + // Attach to the container's main process. --detach-keys lets the user leave with + // Ctrl-P, Ctrl-Q and --sig-proxy=false stops the client forwarding signals to the app. + return "{$primeHistory}exec {$sudo}docker attach --detach-keys=\"{$detachKeys}\" --sig-proxy=false {$escapedContainer}"; + } + + return "{$sudo}docker exec -it {$escapedContainer} sh -c ".escapeshellarg($this->shellCommand()); + } + private function shellCommand(): string { return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. diff --git a/app/Traits/IncludesDeploymentCommitDetails.php b/app/Traits/IncludesDeploymentCommitDetails.php new file mode 100644 index 0000000000..6248481266 --- /dev/null +++ b/app/Traits/IncludesDeploymentCommitDetails.php @@ -0,0 +1,79 @@ +commit); + if (preg_match('/^[0-9a-f]{7,64}$/i', $commit) === 1) { + $this->commit_sha = $commit; + $this->commit_url = $application->gitCommitLink($commit); + } + + $message = trim(mb_scrub((string) $deployment->commitMessage(), 'UTF-8')); + if ($message !== '') { + $this->commit_message = Str::limit($message, 1000); + } + + if (is_null($preview) && filled($application->git_branch)) { + $this->commit_branch = $application->git_branch; + } + } + + /** @return array{sha: string|null, short_sha: string|null, url: string|null, branch: string|null, message: string|null}|null */ + protected function deploymentCommitDetailsForMail(?object $notifiable): ?array + { + $enabled = (bool) data_get($notifiable, 'emailNotificationSettings.deployment_commit_details_email_notifications', false); + + if (! $enabled || (is_null($this->commit_sha) && is_null($this->commit_message))) { + return null; + } + + return [ + 'sha' => $this->commit_sha, + 'short_sha' => $this->commit_sha ? substr($this->commit_sha, 0, 7) : null, + 'url' => $this->commit_url, + 'branch' => $this->commit_branch ? self::escapeMailMarkdown($this->commit_branch) : null, + 'message' => $this->commit_message ? self::formatCommitMessageForMail($this->commit_message) : null, + ]; + } + + /** Escape Markdown in user-controlled text; Blade escapes the HTML. */ + private static function escapeMailMarkdown(string $text): string + { + return preg_replace('/([\\\\`*_{}\[\]()#+\-.!|~=])/u', '\\\\$1', $text); + } + + private static function formatCommitMessageForMail(string $message): string + { + $paragraphs = preg_split('/\R\s*\R/u', str_replace("\r\n", "\n", $message)); + + return collect($paragraphs) + ->map(fn (string $paragraph): string => collect(preg_split('/\R/u', $paragraph)) + ->map(fn (string $line): string => self::escapeMailMarkdown(trim($line))) + ->filter(fn (string $line): bool => $line !== '') + ->implode("\\\n")) + ->filter(fn (string $paragraph): bool => $paragraph !== '') + ->implode("\n\n"); + } +} diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index e4548c0aed..7606c09ad0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1770,6 +1770,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) } } + // Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by + // the long-option regex above. Map them to compose so a single-image app can keep STDIN + // open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an + // interactive main process, e.g. game-server consoles. + // Keep quoted option values together so flags inside an entrypoint are not options. + preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens); + foreach ($tokens[0] as $token) { + if ($token === '--interactive') { + $compose_options->put('stdin_open', true); + } elseif ($token === '--tty') { + $compose_options->put('tty', true); + } elseif (preg_match('/^-[dit]+$/', $token)) { + if (str_contains($token, 'i')) { + $compose_options->put('stdin_open', true); + } + if (str_contains($token, 't')) { + $compose_options->put('tty', true); + } + } + } + return $compose_options->toArray(); } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index aa9639f737..1a8f09c529 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1404,7 +1404,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($server->isLogDrainEnabled()) { if ($resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); @@ -2781,7 +2781,7 @@ function serviceParser(Service $resource): Collection if ($server->isLogDrainEnabled()) { if ($serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 89f408fae8..19049f6426 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3608,7 +3608,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($resource->server)); } if ($serviceLabels->count() > 0) { if ($resource->is_container_label_escape_enabled) { @@ -4429,7 +4429,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($server->isLogDrainEnabled()) { if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($server)); } } if ($serviceLabels->count() > 0) { @@ -4489,6 +4489,31 @@ function generate_fluentd_configuration(): array ]; } +function generate_log_drain_configuration(Server $server): array +{ + if ($server->isFluentBitLogDrainEnabled()) { + return generate_fluentd_configuration(); + } + + $settings = $server->settings; + $options = [ + 'awslogs-region' => $settings->logdrain_cloudwatch_region, + 'awslogs-group' => $settings->logdrain_cloudwatch_group, + 'awslogs-create-group' => 'true', + // Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container. + 'mode' => 'non-blocking', + // The stream is named after the container; awslogs-stream is never set because it would override this tag. + 'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}', + ]; + + return [ + 'driver' => 'awslogs', + 'options' => collect($options) + ->reject(fn ($value) => blank($value)) + ->all(), + ]; +} + function isAssociativeArray($array) { if ($array instanceof Collection) { diff --git a/config/constants.php b/config/constants.php index 7b06283559..728ba9c8ee 100644 --- a/config/constants.php +++ b/config/constants.php @@ -45,6 +45,12 @@ return [ 'host' => env('TERMINAL_HOST'), 'port' => env('TERMINAL_PORT'), 'command_timeout' => 0, + // Docker's default detach escape for `docker attach`. Pressing this + // sequence (Ctrl-P, Ctrl-Q) leaves the container's main process running. + 'detach_keys' => 'ctrl-p,ctrl-q', + // Recent log lines shown when a console (attach) session opens, so the + // screen is not blank until the next line of live output arrives. + 'console_history_lines' => 200, ], 'pusher' => [ diff --git a/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php new file mode 100644 index 0000000000..97fbd6eecc --- /dev/null +++ b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php @@ -0,0 +1,36 @@ +boolean('is_logdrain_cloudwatch_enabled')->default(false); + $table->string('logdrain_cloudwatch_region')->nullable(); + $table->string('logdrain_cloudwatch_group', 512)->nullable(); + $table->string('logdrain_cloudwatch_stream_prefix')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php new file mode 100644 index 0000000000..2f8e613206 --- /dev/null +++ b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php @@ -0,0 +1,26 @@ +boolean('deployment_commit_details_email_notifications')->default(false); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('email_notification_settings', 'deployment_commit_details_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('deployment_commit_details_email_notifications'); + }); + } + } +}; diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index 394cf5277d..b5675e53af 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -11,8 +11,10 @@ import { extractSshArgs, extractTargetHost, extractTimeout, + detachPtyProcess, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, isAuthorizedTargetHost, rejectTerminalSocket, sanitizeSshArgs, @@ -423,10 +425,13 @@ async function handleCommand(ws, command, userId) { userSession.ptyProcess = ptyProcess; userSession.isActive = true; + // Attach sessions share the container's main process, so they must be detached, not killed. + userSession.isAttach = isAttachCommand(hereDocContent); ws.send('pty-ready'); ptyProcess.onData((data) => { + if (userSession.ptyProcess !== ptyProcess) return; ws.send(data); }); @@ -437,6 +442,14 @@ async function handleCommand(ws, command, userId) { exitCode, signal, }); + + // A previous PTY can exit late (e.g. an attach session detaching during a mode + // switch) after its replacement is already spawned. Only mutate the shared session + // state when this exit belongs to the session's current PTY. + if (userSession.ptyProcess !== ptyProcess) { + return; + } + ws.send('pty-exited'); userSession.isActive = false; @@ -503,6 +516,10 @@ async function killPtyProcess(userId) { const session = userSessions.get(userId); if (!session?.ptyProcess) return false; + if (session.isAttach) { + return detachPtyProcess(session); + } + return new Promise((resolve) => { // Loop to ensure terminal is killed before continuing let killAttempts = 0; diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index a1207b028a..6acb9645a6 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -138,6 +138,12 @@ export function extractTimeout(commandString) { return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null; } +// A `docker attach` session shares the container's main process. On disconnect we +// must detach from it instead of killing it, otherwise we stop the running app. +export function isAttachCommand(commandString) { + return /(?:^|\s)docker\s+attach(?:\s|$)/.test(commandString || ''); +} + function normalizeShellArgument(argument) { if (!argument) { return argument; @@ -393,3 +399,53 @@ export function sanitizeSshArgs(sshArgs) { return sanitizedArgs; } + +// Wait for this PTY to exit, not for mutable session state to change. +export function detachPtyProcess(session) { + const ptyProcess = session.ptyProcess; + if (!ptyProcess || !session.isActive) return Promise.resolve(true); + + return new Promise((resolve) => { + let timer; + let attempts = 0; + let settled = false; + const finish = (exited) => { + if (settled) return; + settled = true; + clearTimeout(timer); + exitListener.dispose(); + if (exited && session.ptyProcess === ptyProcess) { + session.isActive = false; + session.ptyProcess = null; + clearTimeout(session.terminalSessionTimer); + session.terminalSessionTimer = null; + } + resolve(exited); + }; + const exitListener = ptyProcess.onExit(() => finish(true)); + const attemptDetach = () => { + attempts++; + try { + ptyProcess.write('\x10\x11'); + } catch (_) { + // An exit event is still required to confirm teardown. + } + if (settled) return; + timer = setTimeout(() => { + if (attempts < 3) { + attemptDetach(); + return; + } + // --sig-proxy=false prevents forwarding signals to the container. + // Refuse a replacement if killing the local SSH client does not exit. + timer = setTimeout(() => finish(false), 1000); + try { + ptyProcess.kill(); + } catch (_) { + // The bounded exit wait fails closed. + } + }, 500); + }; + attemptDetach(); + }); +} diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index fd6297fad4..b00dfa7d9f 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -9,6 +9,8 @@ import { extractTargetHost, getTerminalProcessEnv, getTerminalSessionTimeout, + isAttachCommand, + detachPtyProcess, isAuthorizedTargetHost, normalizeHostForAuthorization, rejectTerminalSocket, @@ -16,6 +18,16 @@ import { validateSshArgs, } from './terminal-utils.js'; +test('isAttachCommand detects a docker attach session', () => { + assert.equal(isAttachCommand('docker attach --detach-keys="ctrl-p,ctrl-q" --sig-proxy=false \'minecraft\''), true); + assert.equal(isAttachCommand('sudo docker attach --sig-proxy=false \'minecraft\''), true); +}); + +test('isAttachCommand does not match a docker exec shell session', () => { + assert.equal(isAttachCommand("docker exec -it 'web-1' sh -c 'exec $SHELL'"), false); + assert.equal(isAttachCommand(''), false); +}); + test('getTerminalProcessEnv preserves the PATH needed by SSH proxy commands', () => { assert.deepEqual(getTerminalProcessEnv({ PATH: '/usr/local/bin:/usr/bin:/bin', @@ -458,3 +470,63 @@ test('rejectTerminalSocket handles socket errors so unauthenticated clients cann assert.equal(ws.terminated, true); ws.peerClosed(); }); + +function attachSession() { + const listeners = new Set(); + const process = { + writes: [], + kills: 0, + write(data) { this.writes.push(data); }, + kill() { this.kills++; }, + onExit(listener) { + listeners.add(listener); + return { dispose: () => listeners.delete(listener) }; + }, + exit() { for (const listener of [...listeners]) listener({ exitCode: 0 }); }, + }; + return { ptyProcess: process, isActive: true, terminalSessionTimer: null }; +} + +test('attach teardown waits for delayed exit after the kill fallback', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + let resolved = false; + const completion = detachPtyProcess(session).then(result => { resolved = true; return result; }); + for (let i = 0; i < 3; i++) t.mock.timers.tick(500); + await Promise.resolve(); + assert.equal(process.kills, 1); + assert.equal(resolved, false); + assert.deepEqual(process.writes, ['\x10\x11', '\x10\x11', '\x10\x11']); + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, false); + assert.equal(session.ptyProcess, null); +}); + +test('late attach exit cannot clear a replacement session or its timer', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const process = session.ptyProcess; + const completion = detachPtyProcess(session); + const replacement = attachSession().ptyProcess; + session.ptyProcess = replacement; + const timer = setTimeout(() => {}, 10000); + session.terminalSessionTimer = timer; + process.exit(); + assert.equal(await completion, true); + assert.equal(session.isActive, true); + assert.equal(session.ptyProcess, replacement); + assert.equal(session.terminalSessionTimer, timer); + t.mock.timers.tick(3000); + assert.equal(replacement.writes.length, 0); +}); + +test('attach teardown fails closed if the PTY never exits', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const session = attachSession(); + const completion = detachPtyProcess(session); + for (let i = 0; i < 5; i++) t.mock.timers.tick(500); + assert.equal(await completion, false); + assert.equal(session.isActive, true); +}); diff --git a/openapi.json b/openapi.json index 8e3bda3333..271bc33fad 100644 --- a/openapi.json +++ b/openapi.json @@ -16973,6 +16973,28 @@ "logdrain_custom_config_parser": { "description": "Only present with read:sensitive.", "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -17046,6 +17068,28 @@ }, "logdrain_custom_config_parser": { "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -25252,6 +25296,9 @@ "is_logdrain_axiom_enabled": { "type": "boolean" }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, "is_logdrain_custom_enabled": { "type": "boolean" }, @@ -25322,6 +25369,15 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_cloudwatch_region": { + "type": "string" + }, + "logdrain_cloudwatch_group": { + "type": "string" + }, + "logdrain_cloudwatch_stream_prefix": { + "type": "string" + }, "logdrain_custom_config": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index 1dbe1c185c..e76b6f1e1a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10804,6 +10804,10 @@ paths: is_logdrain_custom_enabled: { type: boolean } logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } logdrain_custom_config_parser: { description: 'Only present with read:sensitive.', type: string } + is_logdrain_cloudwatch_enabled: { type: boolean } + logdrain_cloudwatch_region: { type: [string, 'null'] } + logdrain_cloudwatch_group: { type: [string, 'null'] } + logdrain_cloudwatch_stream_prefix: { description: 'Prepended to the container name to form the log stream name.', type: [string, 'null'] } type: object '401': $ref: '#/components/responses/401' @@ -10852,6 +10856,15 @@ paths: type: string logdrain_custom_config_parser: type: string + is_logdrain_cloudwatch_enabled: + type: boolean + logdrain_cloudwatch_region: + type: [string, 'null'] + logdrain_cloudwatch_group: + type: [string, 'null'] + logdrain_cloudwatch_stream_prefix: + description: 'Prepended to the container name to form the log stream name.' + type: [string, 'null'] type: object responses: '200': @@ -16175,6 +16188,8 @@ components: type: boolean is_logdrain_axiom_enabled: type: boolean + is_logdrain_cloudwatch_enabled: + type: boolean is_logdrain_custom_enabled: type: boolean is_logdrain_highlight_enabled: @@ -16223,6 +16238,12 @@ components: type: string logdrain_axiom_dataset_name: type: string + logdrain_cloudwatch_region: + type: string + logdrain_cloudwatch_group: + type: string + logdrain_cloudwatch_stream_prefix: + type: string logdrain_custom_config: type: string logdrain_custom_config_parser: diff --git a/resources/css/app.css b/resources/css/app.css index d9a36f40da..e21e9611ea 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1084,6 +1084,68 @@ html:not(.dark) .application-console-shell[data-console-theme="system"] .termina background: var(--coollabs-base); } +/* Console (attach) command box. Colours come from the active terminal theme's + foreground (--console-fg), so the input is readable on every console theme. */ +.terminal-console-input { + border-color: color-mix(in srgb, var(--console-fg, #fff) 10%, transparent); +} + +.console-command-row { + box-shadow: inset 0 0 0 1px transparent; + transition: + background-color 150ms ease, + box-shadow 150ms ease; +} + +.console-command-row:hover { + background-color: color-mix(in srgb, var(--console-fg, #fff) 3%, transparent); +} + +.console-command-row:focus-within { + background-color: color-mix(in srgb, var(--console-fg, #fff) 6%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--console-fg, #fff) 22%, transparent); +} + +.console-command-field { + color: var(--console-fg, #fff) !important; + caret-color: var(--console-fg, #fff); +} + +.console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); +} + +.console-command-row:focus-within .console-command-field::placeholder { + color: color-mix(in srgb, var(--console-fg, #fff) 28%, transparent); +} + +.console-command-prompt { + color: color-mix(in srgb, var(--console-fg, #fff) 40%, transparent); + transition: color 200ms ease; +} + +.console-command-row:focus-within .console-command-prompt { + color: var(--console-fg, #fff); +} + +.console-command-prompt[data-sent] { + color: #fcd452 !important; +} + +.console-command-pending { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send { + color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); + outline-color: color-mix(in srgb, var(--console-fg, #fff) 60%, transparent); +} + +.console-command-send:hover { + color: var(--console-fg, #fff); + background-color: color-mix(in srgb, var(--console-fg, #fff) 8%, transparent); +} + .terminal-target-list { scrollbar-width: thin; scrollbar-color: var(--terminal-scrollbar, rgb(255 255 255 / 0.3)) transparent; diff --git a/resources/js/terminal.browser.test.js b/resources/js/terminal.browser.test.js new file mode 100644 index 0000000000..29f607e82e --- /dev/null +++ b/resources/js/terminal.browser.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; + +test('pty-ready keeps the console input focused after the 500 ms retry', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://terminal.test/', route => route.fulfill({ contentType: 'text/html', body: '
' })); + await page.goto('http://terminal.test/'); + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + // terminal.js imports these helpers; load them as plain declarations. + const connectionSource = readFileSync(new URL('./terminal-connection.js', import.meta.url), 'utf8') + .replace(/^export /gm, ''); + await page.evaluate(([connectionSource, source]) => { + window.Alpine = { data: (_name, factory) => { window.terminalFactory = factory; } }; + window.applicationTerminalThemes = { system: {} }; + window.MAX_TERMINAL_SESSION_SECONDS = 28800; + window.logTerminal = () => {}; + // Register the actual Alpine component, without its Vite imports. + new Function(`${connectionSource}\nfunction initializeTerminalComponent${source}\ninitializeTerminalComponent();`)(); + const component = window.terminalFactory(); + component.$wire = { terminalMode: 'attach', dispatch() {} }; + component.$refs = { consoleInput: document.querySelector('#consoleInput') }; + component.term = { _initialized: true, focus: () => document.querySelector('.xterm-viewport').focus() }; + component.startTerminalSessionCountdown = () => {}; + component.resizeTerminal = () => {}; + component.handleSocketMessage({ data: 'pty-ready' }); + window.terminalComponent = component; + }, [connectionSource, source]); + await page.waitForTimeout(650); + assert.equal(await page.locator('#consoleInput').evaluate((input) => document.activeElement === input), true); + await page.screenshot({ path: '/tmp/pr11538-console-focus.png' }); + await page.evaluate(() => { + window.terminalComponent.$wire.terminalMode = 'shell'; + window.terminalComponent.handleSocketMessage({ data: 'pty-ready' }); + }); + await page.waitForTimeout(650); + assert.equal(await page.locator('.xterm-viewport').evaluate((terminal) => document.activeElement === terminal), true); + } finally { + await browser.close(); + } +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 420e20fb11..93d196286b 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -205,6 +205,15 @@ export function initializeTerminalComponent() { wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, terminalModifier: null, + // Console (attach) mode — line-oriented command input, like a game-server panel. + consoleCommand: '', + consoleHistory: [], + consoleHistoryIndex: -1, + // Lines from a multi-line paste, waiting for the user to confirm with Enter. + consolePendingLines: [], + consoleSentFlash: false, + consoleSentFlashTimeout: null, + consoleForegroundColor: '#ffffff', keyboardInset: 0, keyboardAnchorTop: 0, keyboardViewportHeight: 0, @@ -312,6 +321,7 @@ export function initializeTerminalComponent() { logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.'); return; } + this.applyConsoleMode(); this.beginTerminalSessionStart(); this.sendCommandWhenReady({ terminalToken: token }); }); @@ -330,7 +340,7 @@ export function initializeTerminalComponent() { // Wait for terminal to be ready, then focus const focusWhenReady = () => { if (this.terminalActive && this.term) { - this.term.focus(); + this.focusActiveInput(); } else { setTimeout(focusWhenReady, 100); } @@ -515,6 +525,7 @@ export function initializeTerminalComponent() { const cursorBlink = this.term.options.cursorBlink; this.term.options.cursorBlink = false; this.term.options.theme = { ...applicationTerminalThemes[themeName] }; + this.syncConsoleForeground(); this.term.refresh(0, Math.max(0, this.term.rows - 1)); requestAnimationFrame(() => { @@ -524,7 +535,7 @@ export function initializeTerminalComponent() { this.term.options.cursorBlink = cursorBlink; this.term.refresh(0, Math.max(0, this.term.rows - 1)); - this.term.focus(); + this.focusActiveInput(); logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName)); }); } @@ -580,7 +591,7 @@ export function initializeTerminalComponent() { // Force a refresh this.$nextTick(() => { this.resizeTerminal(); - this.term.focus(); + this.focusActiveInput(); }); } }, @@ -607,6 +618,7 @@ export function initializeTerminalComponent() { ? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system : undefined }); + this.syncConsoleForeground(); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.$nextTick(() => { @@ -851,6 +863,169 @@ export function initializeTerminalComponent() { } }, + isConsoleMode() { + return this.$wire?.terminalMode === 'attach'; + }, + + // Focus the console command box in attach mode, otherwise the xterm. + focusActiveInput() { + if (this.isConsoleMode()) { + this.$refs.consoleInput?.focus(); + } else { + this.term?.focus(); + } + }, + + // Keep the xterm read-only while in console mode and load its command history. + applyConsoleMode() { + const consoleMode = this.isConsoleMode(); + if (this.term) { + this.term.options.disableStdin = consoleMode; + this.term.options.cursorBlink = !consoleMode; + // The output cannot take input, so do not draw a cursor in it. + this.term.options.cursorInactiveStyle = consoleMode ? 'none' : 'outline'; + } + if (consoleMode) { + this.loadConsoleHistory(); + this.$nextTick(() => this.$refs.consoleInput?.focus()); + } + // Let the header mode selector reflect the container's current state. + window.dispatchEvent(new CustomEvent('terminal-mode-updated', { + detail: { + attachAvailable: Boolean(this.$wire?.attachAvailable), + hasShell: this.$wire?.hasShell ?? true, + mode: this.$wire?.terminalMode ?? 'shell', + }, + })); + }, + + loadConsoleHistory() { + // Keep commands in this connection only: names can be reused, and + // localStorage is shared by users and teams on the same browser. + this.consoleHistory = []; + this.consoleHistoryIndex = -1; + this.consoleCommand = ''; + this.consolePendingLines = []; + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (key?.startsWith('coolify-console-history:')) { + localStorage.removeItem(key); + } + } + } catch (_) { + // History does not depend on browser storage being available. + } + }, + + // Text colour for the command box: the active terminal theme's foreground. + syncConsoleForeground() { + this.consoleForegroundColor = this.term?.options.theme?.foreground || '#ffffff'; + }, + + canSubmitConsoleCommand() { + return this.consolePendingLines.length > 0 || this.consoleCommand.trim() !== ''; + }, + + // Send one printable command line to the process stdin, never terminal control bytes. + sendConsoleLine(command) { + if (command.trim() === '' || /[\x00-\x1f\x7f]/.test(command)) { + return false; + } + // Send the whole line plus a newline to the process stdin. + this.sendMessage({ message: `${command}\n` }); + + if (this.consoleHistory[this.consoleHistory.length - 1] !== command) { + this.consoleHistory.push(command); + this.consoleHistory = this.consoleHistory.slice(-50); + } + + return true; + }, + + submitConsoleCommand() { + if (!this.terminalActive || !this.isConsoleMode()) { + return; + } + + const lines = this.consolePendingLines.length > 0 ? this.consolePendingLines : [this.consoleCommand]; + const sent = lines.filter((line) => this.sendConsoleLine(line)).length; + + this.consolePendingLines = []; + this.consoleHistoryIndex = -1; + if (sent > 0) { + this.consoleCommand = ''; + this.flashConsoleSent(); + // Show the response even when the user scrolled up to read older output. + this.term?.scrollToBottom(); + } + // Clicking Send moves focus to the button; return it to the input. + this.$refs.consoleInput?.focus(); + }, + + flashConsoleSent() { + clearTimeout(this.consoleSentFlashTimeout); + this.consoleSentFlash = true; + this.consoleSentFlashTimeout = setTimeout(() => { + this.consoleSentFlash = false; + }, 300); + }, + + // A multi-line paste waits for confirmation, so pasted logs are not sent to the process by mistake. + pasteConsoleText(event) { + const text = event.clipboardData?.getData('text') ?? ''; + if (!/[\r\n]/.test(text)) { + return; + } + event.preventDefault(); + + const lines = text.split(/\r?\n|\r/).filter((line) => line.trim() !== ''); + if (lines.length === 1) { + this.consoleCommand += lines[0]; + return; + } + if (this.consoleCommand.trim() !== '') { + lines[0] = this.consoleCommand + lines[0]; + } + this.consolePendingLines = lines; + this.consoleCommand = ''; + }, + + // Esc cancels a pending paste first, then clears the input. + cancelConsoleInput() { + if (this.consolePendingLines.length > 0) { + this.consolePendingLines = []; + return; + } + this.consoleCommand = ''; + this.consoleHistoryIndex = -1; + }, + + // Ctrl+L clears the visible output only; nothing is sent to the process. + clearConsoleOutput() { + this.term?.clear(); + }, + + // Clicking the read-only output moves focus to the command box, unless the user is selecting text. + focusConsoleFromOutput() { + if (this.isConsoleMode() && !this.term?.hasSelection()) { + this.$refs.consoleInput?.focus(); + } + }, + + recallConsoleCommand(direction) { + if (this.consoleHistory.length === 0) { + return; + } + if (this.consoleHistoryIndex === -1) { + this.consoleHistoryIndex = this.consoleHistory.length; + } + let index = this.consoleHistoryIndex + direction; + index = Math.max(0, Math.min(this.consoleHistory.length, index)); + this.consoleHistoryIndex = index; + this.consoleCommand = index >= this.consoleHistory.length ? '' : this.consoleHistory[index]; + }, + handleSocketMessage(event) { // Handle pong responses if (event.data === 'pong') { @@ -882,7 +1057,9 @@ export function initializeTerminalComponent() { } this.terminalActive = true; this.startTerminalSessionCountdown(); - this.term.focus(); + // In console (attach) mode the user types into the command box, not the + // read-only xterm, so keep focus on that input instead of the terminal. + this.focusActiveInput(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); // Initial resize after terminal is ready @@ -893,13 +1070,13 @@ export function initializeTerminalComponent() { this.resizeTerminal(); }, 200); - // Ensure terminal gets focus after connection with multiple attempts + // Ensure focus after connection with multiple attempts setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 100); - + setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 500); // Notify parent component that terminal is connected @@ -972,6 +1149,11 @@ export function initializeTerminalComponent() { if (!this.term) return; this.term.onData((data) => { + // Console (attach) mode is read-only: commands go through the input box, + // so raw keystrokes (including Ctrl-C) are never sent to the process. + if (this.isConsoleMode()) { + return; + } this.sendMessage({ message: data }); if (data === '\r') { this.commandBuffer = ''; @@ -982,6 +1164,14 @@ export function initializeTerminalComponent() { // Copy and paste functionality this.term.attachCustomKeyEventHandler((arg) => { + // The console output is read-only: move typed characters to the command box. + if (this.isConsoleMode() && arg.type === 'keydown' && arg.key.length === 1 + && !arg.ctrlKey && !arg.metaKey && !arg.altKey) { + // Moving focus during keydown makes the browser type this key into the input. + this.$refs.consoleInput?.focus(); + return false; + } + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { return false; } @@ -1012,6 +1202,11 @@ export function initializeTerminalComponent() { return; } + // Console (attach) mode only accepts commands from the input box. + if (this.isConsoleMode()) { + return; + } + this.sendMessage({ message: data }); }, diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js index e0a4fb852d..ed6f9a696d 100644 --- a/resources/js/terminal.test.js +++ b/resources/js/terminal.test.js @@ -1,9 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; import { MAX_TERMINAL_SESSION_SECONDS, formatTerminalSessionRemainingTime, } from './terminal-session-timer.js'; +import * as terminalConnection from './terminal-connection.js'; test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); @@ -13,3 +16,78 @@ test('formatTerminalSessionRemainingTime formats the eight hour terminal limit c assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); }); + +function terminalComponent() { + let factory; + const storage = new Map([['coolify-console-history:server:container', '["secret"]']]); + const localStorage = { + get length() { return storage.size; }, + key: (index) => [...storage.keys()][index], + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + const componentSource = `function initializeTerminalComponent${source}`; + vm.runInNewContext(`${componentSource}\ninitializeTerminalComponent();`, { + window: { Alpine: { data: (_name, value) => { factory = value; } } }, + localStorage, console, + setTimeout, clearTimeout, + MAX_TERMINAL_SESSION_SECONDS, applicationTerminalThemes: { system: {} }, + ...terminalConnection, + }); + const component = factory(); + component.$wire = { terminalMode: 'attach', currentIdentifier: 'container', currentServerUuid: 'server', dispatch() {} }; + component.$refs = { consoleInput: { focus() {} } }; + component.$nextTick = (callback) => callback(); + component.term = { options: {}, focus() {} }; + component.terminalActive = true; + return { component, storage }; +} + +test('console history does not persist sensitive commands or load previous users history', (t) => { + const { component, storage } = terminalComponent(); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); + component.consoleCommand = 'secret command'; + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + component.submitConsoleCommand(); + assert.deepEqual(messages, ['secret command\n']); + assert.equal(component.consoleHistory.length, 1); + assert.equal(storage.size, 0); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); +}); + +test('console startup and theme focus stay on the command input', (t) => { + const { component } = terminalComponent(); + let consoleFocus = 0; + let terminalFocus = 0; + component.$refs.consoleInput.focus = () => { consoleFocus++; }; + component.term.focus = () => { terminalFocus++; }; + component.focusActiveInput(); + assert.equal(consoleFocus, 1); + assert.equal(terminalFocus, 0); + component.$wire.terminalMode = 'shell'; + component.focusActiveInput(); + assert.equal(terminalFocus, 1); +}); + +test('console input cannot forward control sequences or send into a shell session', () => { + const { component } = terminalComponent(); + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + for (const command of ['\x03', 'list\nstop', '\x10\x11']) { + component.consoleCommand = command; + component.submitConsoleCommand(); + } + component.$wire.terminalMode = 'shell'; + component.consoleCommand = 'stop'; + component.submitConsoleCommand(); + component.$wire.terminalMode = 'attach'; + component.terminalActive = false; + component.submitConsoleCommand(); + assert.equal(messages.length, 0); +}); diff --git a/resources/views/components/emails/deployment-commit.blade.php b/resources/views/components/emails/deployment-commit.blade.php new file mode 100644 index 0000000000..bf5c23fcf1 --- /dev/null +++ b/resources/views/components/emails/deployment-commit.blade.php @@ -0,0 +1,16 @@ +@props(['commit' => null]) +@if ($commit) +@php + $commitLine = null; + if ($commit['short_sha']) { + $shortSha = e($commit['short_sha']); + $commitLine = '**Commit:** '.($commit['url'] ? "[{$shortSha}](".e($commit['url']).')' : $shortSha); + if ($commit['branch']) { + $commitLine .= ' on '.e($commit['branch']); + } + } + $messageBlock = $commit['message'] ? "**Commit message:**\n\n".e($commit['message']) : null; +@endphp +{!! collect([$commitLine, $messageBlock])->filter()->implode("\n\n") !!} + +@endif diff --git a/resources/views/components/terminal/mode-selector.blade.php b/resources/views/components/terminal/mode-selector.blade.php new file mode 100644 index 0000000000..72513c4e07 --- /dev/null +++ b/resources/views/components/terminal/mode-selector.blade.php @@ -0,0 +1,49 @@ +@php + // Shell (docker exec) vs Console (docker attach). Shown only when the container is + // interactive (keeps stdin open). Alpine state lives on the parent terminal section: + // attachAvailable, terminalMode, modeOpen, setTerminalMode(). + $modes = [ + ['key' => 'shell', 'name' => 'Shell', 'description' => 'Interactive shell (docker exec)'], + ['key' => 'attach', 'name' => 'Console', 'description' => 'Attach to the app process (docker attach)'], + ]; + $modeNames = collect($modes)->pluck('name', 'key'); +@endphp + +
',
+ 'click me',
+ '---',
+ 'Co-authored-by: Jane Doe