diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index e86e30f04..b79709c5a 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -36,10 +36,11 @@ public function handle(Application $application, bool $previewDeployments = fals : getCurrentApplicationContainerStatus($server, $application->id, 0); $containersToStop = $containers->pluck('Names')->toArray(); + $timeout = $application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php index bf9fdee72..09de9b628 100644 --- a/app/Actions/Application/StopApplicationOneServer.php +++ b/app/Actions/Application/StopApplicationOneServer.php @@ -20,13 +20,15 @@ public function handle(Application $application, Server $server) } try { $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); + $timeout = $application->settings->stopGracePeriodSeconds(); + if ($containers->count() > 0) { foreach ($containers as $container) { $containerName = data_get($container, 'Names'); if ($containerName) { instant_remote_process( [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], $server diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 7bc9168b8..3a3eeaee6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -983,6 +983,9 @@ private function create_application(Request $request, $type) ], ], 422); } + $request->merge([ + 'custom_nginx_configuration' => $customNginxConfiguration, + ]); } $project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first(); @@ -2401,7 +2404,7 @@ public function update_by_uuid(Request $request) } } } - if ($request->has('custom_nginx_configuration')) { + if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) { if (! isBase64Encoded($request->custom_nginx_configuration)) { return response()->json([ 'message' => 'Validation failed.', @@ -2419,6 +2422,9 @@ public function update_by_uuid(Request $request) ], ], 422); } + $request->merge([ + 'custom_nginx_configuration' => $customNginxConfiguration, + ]); } $return = $this->validateDataApplications($request, $server); if ($return instanceof JsonResponse) { diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php index a8f388c85..e7b34248e 100644 --- a/app/Jobs/ApiTokenExpirationWarningJob.php +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -12,7 +12,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\RateLimiter; use Laravel\Horizon\Contracts\Silenced; class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced @@ -29,20 +28,36 @@ public function handle(): void ->whereNotNull('expires_at') ->where('expires_at', '>', now()) ->where('expires_at', '<=', now()->addDay()) + ->whereNull('api_token_expiration_warning_sent_at') ->where('tokenable_type', User::class) ->chunkById(100, function ($tokens) { foreach ($tokens as $token) { if (! $token->team_id) { continue; } - RateLimiter::attempt( - 'api-token-expiring:'.$token->id, - $maxAttempts = 0, - function () use ($token) { - Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token)); - }, - $decaySeconds = 7 * 24 * 3600, - ); + + $team = Team::find($token->team_id); + if (! $team) { + continue; + } + + $warningSentAt = now(); + + $team->notify(new ApiTokenExpiringNotification($token)); + + $markedAsSent = PersonalAccessToken::query() + ->whereKey($token->getKey()) + ->whereNotNull('expires_at') + ->where('expires_at', '>', now()) + ->where('expires_at', '<=', now()->addDay()) + ->whereNull('api_token_expiration_warning_sent_at') + ->update(['api_token_expiration_warning_sent_at' => $warningSentAt]); + + if ($markedAsSent !== 1) { + continue; + } + + $token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]); } }); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 4f9481794..2e43456b8 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -537,11 +537,6 @@ private function post_deployment() \Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage()); } - try { - $this->application->isConfigurationChanged(true); - } catch (Exception $e) { - \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage()); - } } private function deploy_simple_dockerfile() @@ -1154,12 +1149,15 @@ private function generate_image_names() $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; } } elseif ($this->pull_request_id !== 0) { + $previewImageTag = $this->previewImageTag(); + $previewBuildImageTag = $this->previewImageTag(build: true); + if ($this->application->docker_registry_image_name) { - $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; - $this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}"; + $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}"; + $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}"; } else { - $this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build"; - $this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}"; + $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}"; + $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}"; } } else { $this->dockerImageTag = str($this->commit)->substr(0, 128); @@ -1176,6 +1174,27 @@ private function generate_image_names() } } + private function previewImageTag(bool $build = false): string + { + $prefix = "pr-{$this->pull_request_id}-"; + $suffix = $build ? '-build' : ''; + $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix)); + $commitSource = ($this->commit === 'HEAD' || blank($this->commit)) + ? $this->deployment_uuid + : $this->commit; + + $commit = Str::of($commitSource) + ->replaceMatches('/[^A-Za-z0-9_.-]/', '-') + ->substr(0, $maxCommitLength) + ->toString(); + + if ($commit === '') { + $commit = 'HEAD'; + } + + return "{$prefix}{$commit}{$suffix}"; + } + private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); @@ -1214,8 +1233,9 @@ private function should_skip_build() return true; } - if (! $this->application->isConfigurationChanged()) { - $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $configurationDiff = $this->application->pendingDeploymentConfigurationDiff(); + if (! $configurationDiff->requiresBuild()) { + $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->skip_build = true; $this->generate_compose_file(); @@ -1227,7 +1247,7 @@ private function should_skip_build() return true; } else { - $this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.'); + $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } } else { $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); @@ -3766,14 +3786,15 @@ private function build_image() private function graceful_shutdown_container(string $containerName, bool $skipRemove = false) { try { - $timeout = isDev() ? 1 : 30; + $timeout = $this->application->settings->deploymentStopGracePeriodSeconds(); + if ($skipRemove) { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true] + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true] ); } else { $this->execute_remote_command( - ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true], + ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); } @@ -4713,6 +4734,12 @@ private function handleSuccessfulDeployment(): void 'last_restart_type' => null, ]); + try { + $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue); + } catch (Exception $e) { + \Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage()); + } + event(new ApplicationConfigurationChanged($this->application->team()->id)); if (! $this->only_this_server) { diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index cf7ef3e0b..947368a0d 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -4,6 +4,8 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -61,6 +63,9 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $gpuOptions = null; + #[Validate(['string', 'nullable'])] + public ?string $stopGracePeriod = null; + #[Validate(['boolean'])] public bool $isBuildServerEnabled = false; @@ -145,6 +150,10 @@ public function syncData(bool $toModel = false) $this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true; $this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false; } + + // Load stop_grace_period separately since it has its own save handler + // Convert null to empty string to prevent dirty detection issues + $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? ''; } private function resetDefaultLabels() @@ -210,6 +219,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Settings saved.'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -228,6 +238,7 @@ public function saveCustomName() if (is_null($this->customInternalName)) { $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); + $this->dispatch('configurationChanged'); return; } @@ -247,6 +258,32 @@ public function saveCustomName() } $this->syncData(true); $this->dispatch('success', 'Custom name saved.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function saveStopGracePeriod() + { + try { + $this->authorize('update', $this->application); + + $validated = Validator::make( + ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod], + ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]], + [], + ['stopGracePeriod' => 'stop grace period'] + )->validate(); + + $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null + ? null + : (int) $validated['stopGracePeriod']; + $this->application->settings->save(); + + $this->dispatch('success', 'Stop grace period updated.'); + } catch (ValidationException $e) { + throw $e; } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 38246715a..dc611b5af 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -345,10 +345,11 @@ public function addDockerImagePreview() private function stopContainers(array $containers, $server) { $containersToStop = collect($containers)->pluck('Names')->toArray(); + $timeout = $this->application->settings->stopGracePeriodSeconds(); foreach ($containersToStop as $containerName) { instant_remote_process(command: [ - "docker stop -t 30 $containerName", + "docker stop --time=$timeout $containerName", "docker rm -f $containerName", ], server: $server, throwError: false); } diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 422dd6b28..3ef5ccf7c 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -109,6 +109,7 @@ public function setPrivateKey(int $privateKeyId) $this->application->refresh(); $this->privateKeyName = $this->application->private_key->name; $this->dispatch('success', 'Private key updated!'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -124,6 +125,7 @@ public function submit() } $this->syncData(true); $this->dispatch('success', 'Application source updated!'); + $this->dispatch('configurationChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php index ce9ce7780..d583e74e6 100644 --- a/app/Livewire/Project/Shared/ConfigurationChecker.php +++ b/app/Livewire/Project/Shared/ConfigurationChecker.php @@ -12,15 +12,20 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Contracts\View\View; use Livewire\Component; class ConfigurationChecker extends Component { public bool $isConfigurationChanged = false; + public array $configurationDiff = []; + + public array $groupedConfigurationChanges = []; + public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource; - public function getListeners() + public function getListeners(): array { $teamId = auth()->user()->currentTeam()->id; @@ -30,18 +35,36 @@ public function getListeners() ]; } - public function mount() + public function mount(): void { $this->configurationChanged(); } - public function render() + public function render(): View { return view('livewire.project.shared.configuration-checker'); } - public function configurationChanged() + public function refreshConfigurationChanges(): void { + $this->configurationChanged(); + } + + public function configurationChanged(): void + { + $this->resource->refresh(); + + if ($this->resource instanceof Application) { + $diff = $this->resource->pendingDeploymentConfigurationDiff(); + $this->isConfigurationChanged = $diff->isChanged(); + $this->configurationDiff = $diff->toArray(); + $this->groupedConfigurationChanges = $diff->groupedChanges(); + + return; + } + $this->isConfigurationChanged = $this->resource->isConfigurationChanged(); + $this->configurationDiff = []; + $this->groupedConfigurationChanges = []; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 2a364e13f..97b257752 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,9 @@ use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot; +use App\Services\DeploymentConfiguration\ConfigurationDiff; +use App\Services\DeploymentConfiguration\ConfigurationDiffer; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasMetrics; @@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute return Attribute::make( set: function ($value) { if (is_null($value) || $value === '') { - return '/Dockerfile'; - } else { - if ($value !== '/') { - return Str::start(Str::replaceEnd('/', '', $value), '/'); - } - - return Str::start($value, '/'); + return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null; } + + if ($value !== '/') { + return Str::start(Str::replaceEnd('/', '', $value), '/'); + } + + return Str::start($value, '/'); } ); } @@ -886,8 +889,8 @@ public function status(): Attribute public function customNginxConfiguration(): Attribute { return Attribute::make( - set: fn ($value) => base64_encode($value), - get: fn ($value) => base64_decode($value), + set: fn ($value) => is_null($value) ? null : base64_encode($value), + get: fn ($value) => is_null($value) ? null : base64_decode($value), ); } @@ -1059,7 +1062,7 @@ public function isDeploymentInprogress() public function get_last_successful_deployment() { - return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first(); + return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first(); } public function get_last_days_deployments() @@ -1170,33 +1173,92 @@ public function isLogDrainEnabled() } public function isConfigurationChanged(bool $save = false) + { + $configurationDiff = $this->pendingDeploymentConfigurationDiff(); + + if ($save) { + $this->markDeploymentConfigurationApplied(); + } + + return $configurationDiff->isChanged(); + } + + public function pendingDeploymentConfigurationDiff(): ConfigurationDiff + { + $currentSnapshot = $this->deploymentConfigurationSnapshot(); + $lastDeployment = $this->get_last_successful_deployment(); + + if ($lastDeployment?->configuration_snapshot) { + return app(ConfigurationDiffer::class)->diff($lastDeployment->configuration_snapshot, $currentSnapshot); + } + + $oldConfigHash = data_get($this, 'config_hash'); + + if ($oldConfigHash === null) { + return ConfigurationDiff::legacy(true); + } + + return ConfigurationDiff::legacy($oldConfigHash !== $this->legacyConfigurationHash()); + } + + public function hasPendingDeploymentConfigurationChanges(): bool + { + return $this->pendingDeploymentConfigurationDiff()->isChanged(); + } + + public function deploymentConfigurationSnapshot(): array + { + return (new ApplicationConfigurationSnapshot($this))->toArray(); + } + + public function deploymentConfigurationHash(): string + { + return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot()); + } + + public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void + { + $this->refresh(); + + if (! $deployment) { + $this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save(); + + return; + } + + $snapshot = $this->deploymentConfigurationSnapshot(); + $hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot); + + $previousDeployment = ApplicationDeploymentQueue::query() + ->where('application_id', $this->id) + ->where('status', ApplicationDeploymentStatus::FINISHED->value) + ->where('pull_request_id', $deployment->pull_request_id ?? 0) + ->where('id', '!=', $deployment->id) + ->whereNotNull('configuration_snapshot') + ->latest() + ->first(); + + $deployment->update([ + 'configuration_hash' => $hash, + 'configuration_snapshot' => $snapshot, + 'configuration_diff' => $previousDeployment?->configuration_snapshot + ? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray() + : null, + ]); + + $this->forceFill(['config_hash' => $hash])->save(); + } + + private function legacyConfigurationHash(): string { $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } - $newConfigHash = md5($newConfigHash); - $oldConfigHash = data_get($this, 'config_hash'); - if ($oldConfigHash === null) { - if ($save) { - $this->config_hash = $newConfigHash; - $this->save(); - } - return true; - } - if ($oldConfigHash === $newConfigHash) { - return false; - } else { - if ($save) { - $this->config_hash = $newConfigHash; - $this->save(); - } - - return true; - } + return md5($newConfigHash); } public function customRepository() diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 67f28523c..afac89fa8 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -17,6 +17,9 @@ 'deployment_uuid' => ['type' => 'string'], 'pull_request_id' => ['type' => 'integer'], 'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true], + 'configuration_hash' => ['type' => 'string', 'nullable' => true], + 'configuration_snapshot' => ['type' => 'object', 'nullable' => true], + 'configuration_diff' => ['type' => 'object', 'nullable' => true], 'force_rebuild' => ['type' => 'boolean'], 'commit' => ['type' => 'string'], 'status' => ['type' => 'string'], @@ -45,6 +48,9 @@ class ApplicationDeploymentQueue extends Model 'deployment_uuid', 'pull_request_id', 'docker_registry_image_tag', + 'configuration_hash', + 'configuration_snapshot', + 'configuration_diff', 'force_rebuild', 'commit', 'status', @@ -71,6 +77,8 @@ class ApplicationDeploymentQueue extends Model protected $casts = [ 'pull_request_id' => 'integer', 'finished_at' => 'datetime', + 'configuration_snapshot' => 'array', + 'configuration_diff' => 'array', ]; public function application() diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 731a9b5da..ef09c0c48 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -26,6 +26,7 @@ class ApplicationSetting extends Model 'is_git_lfs_enabled' => 'boolean', 'is_git_shallow_clone_enabled' => 'boolean', 'docker_images_to_keep' => 'integer', + 'stop_grace_period' => 'integer', ]; protected $fillable = [ @@ -64,8 +65,30 @@ class ApplicationSetting extends Model 'inject_build_args_to_dockerfile', 'include_source_commit_in_build', 'docker_images_to_keep', + 'stop_grace_period', ]; + public function stopGracePeriodSeconds(): int + { + if ( + $this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS && + $this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS + ) { + return $this->stop_grace_period; + } + + return DEFAULT_STOP_GRACE_PERIOD_SECONDS; + } + + public function deploymentStopGracePeriodSeconds(): int + { + if (isDev() && $this->stop_grace_period === null) { + return MIN_STOP_GRACE_PERIOD_SECONDS; + } + + return $this->stopGracePeriodSeconds(); + } + public function isStatic(): Attribute { return Attribute::make( diff --git a/app/Models/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php index 398046a7c..503377bec 100644 --- a/app/Models/PersonalAccessToken.php +++ b/app/Models/PersonalAccessToken.php @@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken 'token', 'abilities', 'expires_at', + 'api_token_expiration_warning_sent_at', 'team_id', ]; + + protected function casts(): array + { + return [ + 'api_token_expiration_warning_sent_at' => 'datetime', + ]; + } } diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php new file mode 100644 index 000000000..676b22b6c --- /dev/null +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -0,0 +1,338 @@ + + */ + public function toArray(): array + { + $this->application->load('settings'); + + return [ + 'schema_version' => self::SCHEMA_VERSION, + 'resource_type' => Application::class, + 'resource_id' => $this->application->id, + 'sections' => [ + 'source' => [ + 'label' => 'Source', + 'items' => $this->sourceItems(), + ], + 'build' => [ + 'label' => 'Build', + 'items' => $this->buildItems(), + ], + 'runtime' => [ + 'label' => 'Runtime', + 'items' => $this->runtimeItems(), + ], + 'domains' => [ + 'label' => 'Domains & Proxy', + 'items' => $this->domainItems(), + ], + 'environment' => [ + 'label' => 'Environment Variables', + 'items' => $this->environmentItems(), + ], + ], + ]; + } + + public function hash(): string + { + return self::hashSnapshot($this->toArray()); + } + + /** + * @param array $snapshot + */ + public static function hashSnapshot(array $snapshot): string + { + return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR)); + } + + /** + * @param array $snapshot + * @return array + */ + public static function comparableSnapshot(array $snapshot): array + { + $sections = collect(data_get($snapshot, 'sections', [])) + ->mapWithKeys(function (array $section, string $sectionKey): array { + $items = collect(data_get($section, 'items', [])) + ->mapWithKeys(fn (array $item): array => [ + $item['key'] => [ + 'compare_value' => $item['compare_value'] ?? null, + 'impact' => $item['impact'] ?? 'redeploy', + ], + ]) + ->sortKeys() + ->all(); + + return [$sectionKey => $items]; + }) + ->sortKeys() + ->all(); + + return [ + 'schema_version' => data_get($snapshot, 'schema_version'), + 'sections' => $sections, + ]; + } + + /** + * @return array> + */ + private function sourceItems(): array + { + return [ + $this->item('git_repository', 'Repository', $this->application->git_repository, 'build'), + $this->item('git_branch', 'Branch', $this->application->git_branch, 'build'), + $this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'), + $this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'), + ]; + } + + /** + * @return array> + */ + private function buildItems(): array + { + return [ + $this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'), + $this->item('static_image', 'Static image', $this->application->static_image, 'build'), + $this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'), + $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'), + $this->item('install_command', 'Install command', $this->application->install_command, 'build'), + $this->item('build_command', 'Build command', $this->application->build_command, 'build'), + $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile)), + $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'), + $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'), + $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'), + $this->item('docker_compose', 'Docker Compose', $this->application->docker_compose, 'build', displayValue: $this->summarizeText($this->application->docker_compose)), + $this->item('docker_compose_raw', 'Raw Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw)), + $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'), + $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'), + $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'), + $this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'), + $this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'), + $this->item('disable_build_cache', 'Disable build cache', data_get($this->application, 'settings.disable_build_cache'), 'build'), + $this->item('is_build_server_enabled', 'Build server', data_get($this->application, 'settings.is_build_server_enabled'), 'build'), + ]; + } + + /** + * @return array> + */ + private function runtimeItems(): array + { + return [ + $this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'), + $this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'), + $this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'), + $this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'), + $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), + $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), + $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'), + $this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'), + $this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'), + $this->item('gpu_count', 'GPU count', data_get($this->application, 'settings.gpu_count'), 'redeploy'), + $this->item('gpu_device_ids', 'GPU device IDs', data_get($this->application, 'settings.gpu_device_ids'), 'redeploy'), + $this->item('gpu_options', 'GPU options', data_get($this->application, 'settings.gpu_options'), 'redeploy'), + ...$this->healthCheckItems(), + ...$this->limitItems(), + ]; + } + + /** + * @return array> + */ + private function domainItems(): array + { + return [ + $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'), + $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'), + $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->application->custom_labels)), + $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration)), + $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'), + $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'), + $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'), + $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'), + $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'), + $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true), + ]; + } + + /** + * @return array> + */ + private function environmentItems(): array + { + return $this->application->environment_variables() + ->get() + ->sortBy('key', SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->map(fn (EnvironmentVariable $environmentVariable): array => $this->environmentItem($environmentVariable)) + ->all(); + } + + /** + * @return array> + */ + private function healthCheckItems(): array + { + return collect([ + 'health_check_enabled' => 'Health check enabled', + 'health_check_path' => 'Health check path', + 'health_check_port' => 'Health check port', + 'health_check_host' => 'Health check host', + 'health_check_method' => 'Health check method', + 'health_check_return_code' => 'Health check return code', + 'health_check_scheme' => 'Health check scheme', + 'health_check_response_text' => 'Health check response text', + 'health_check_interval' => 'Health check interval', + 'health_check_timeout' => 'Health check timeout', + 'health_check_retries' => 'Health check retries', + 'health_check_start_period' => 'Health check start period', + 'health_check_type' => 'Health check type', + 'health_check_command' => 'Health check command', + ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all(); + } + + /** + * @return array> + */ + private function limitItems(): array + { + return collect([ + 'limits_memory' => 'Memory limit', + 'limits_memory_swap' => 'Memory swap limit', + 'limits_memory_swappiness' => 'Memory swappiness', + 'limits_memory_reservation' => 'Memory reservation', + 'limits_cpus' => 'CPU limit', + 'limits_cpuset' => 'CPU set', + 'limits_cpu_shares' => 'CPU shares', + 'swarm_replicas' => 'Swarm replicas', + 'swarm_placement_constraints' => 'Swarm placement constraints', + ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all(); + } + + /** + * @return array + */ + private function environmentItem(EnvironmentVariable $environmentVariable): array + { + $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy'; + $compareValue = [ + 'value_hash' => $this->sensitiveHash($environmentVariable->value), + 'is_multiline' => $environmentVariable->is_multiline, + 'is_literal' => $environmentVariable->is_literal, + 'is_buildtime' => $environmentVariable->is_buildtime, + 'is_runtime' => $environmentVariable->is_runtime, + ]; + + return $this->item( + key: (string) $environmentVariable->key, + label: (string) $environmentVariable->key, + value: $compareValue, + impact: $impact, + sensitive: true, + displayValue: $this->environmentDisplayValue($environmentVariable), + ); + } + + /** + * @return array + */ + private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null): array + { + $normalizedValue = $this->normalizeValue($value); + + return [ + 'key' => $key, + 'label' => $label, + 'impact' => $impact, + 'sensitive' => $sensitive, + 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue, + 'display_value' => $displayValue ?? $this->displayValue($normalizedValue), + ]; + } + + private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string + { + $flags = collect([ + $environmentVariable->is_buildtime ? 'build-time' : null, + $environmentVariable->is_runtime ? 'runtime' : null, + $environmentVariable->is_multiline ? 'multiline' : null, + $environmentVariable->is_literal ? 'literal' : null, + ])->filter()->implode(', '); + + return $flags ? "Hidden ({$flags})" : 'Hidden'; + } + + private function sensitiveHash(mixed $value): string + { + return hash_hmac('sha256', json_encode($value, JSON_THROW_ON_ERROR), (string) config('app.key', 'coolify')); + } + + private function normalizeValue(mixed $value): mixed + { + if ($value === '') { + return null; + } + + if (is_bool($value) || is_numeric($value) || $value === null || is_string($value)) { + return $value; + } + + if (is_array($value)) { + return Arr::sortRecursive($value); + } + + return (string) $value; + } + + private function displayValue(mixed $value): string + { + if ($value === null) { + return 'Not set'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_array($value)) { + return $this->summarizeText(json_encode($value, JSON_THROW_ON_ERROR)); + } + + return $this->summarizeText((string) $value); + } + + private function summarizeText(?string $value): string + { + if (blank($value)) { + return 'Not set'; + } + + $value = trim((string) $value); + $lines = substr_count($value, "\n") + 1; + + if ($lines > 1) { + return str($value)->limit(80)." ({$lines} lines)"; + } + + return str($value)->limit(120)->value(); + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php new file mode 100644 index 000000000..e8a206025 --- /dev/null +++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php @@ -0,0 +1,112 @@ +> $changes + */ + public function __construct( + protected array $changes = [], + protected bool $legacyFallback = false, + ) {} + + public static function unchanged(): self + { + return new self; + } + + public static function legacy(bool $changed): self + { + if (! $changed) { + return self::unchanged(); + } + + return new self([ + [ + 'key' => 'legacy.configuration', + 'section' => 'configuration', + 'section_label' => 'Configuration', + 'label' => 'Configuration', + 'type' => 'changed', + 'impact' => 'build', + 'sensitive' => false, + 'old_display_value' => 'Previously deployed configuration', + 'new_display_value' => 'Current configuration', + ], + ], true); + } + + /** + * @param array> $changes + */ + public static function fromChanges(array $changes): self + { + return new self(array_values($changes)); + } + + public function isChanged(): bool + { + return $this->changes !== []; + } + + public function isLegacyFallback(): bool + { + return $this->legacyFallback; + } + + public function count(): int + { + return count($this->changes); + } + + public function requiresBuild(): bool + { + return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build'); + } + + public function requiresRedeploy(): bool + { + return $this->isChanged(); + } + + /** + * @return array> + */ + public function changes(): array + { + return $this->changes; + } + + /** + * @return array>}> + */ + public function groupedChanges(): array + { + return collect($this->changes) + ->groupBy('section') + ->map(fn (Collection $changes): array => [ + 'label' => (string) data_get($changes->first(), 'section_label', str((string) $changes->keys()->first())->headline()), + 'changes' => $changes->values()->all(), + ]) + ->all(); + } + + /** + * @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array>} + */ + public function toArray(): array + { + return [ + 'changed' => $this->isChanged(), + 'count' => $this->count(), + 'requires_build' => $this->requiresBuild(), + 'requires_redeploy' => $this->requiresRedeploy(), + 'legacy_fallback' => $this->isLegacyFallback(), + 'changes' => $this->changes(), + ]; + } +} diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php new file mode 100644 index 000000000..27e8d4c3f --- /dev/null +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -0,0 +1,69 @@ + $previousSnapshot + * @param array $currentSnapshot + */ + public function diff(array $previousSnapshot, array $currentSnapshot): ConfigurationDiff + { + $previousItems = $this->flattenItems($previousSnapshot); + $currentItems = $this->flattenItems($currentSnapshot); + $keys = collect(array_keys($previousItems))->merge(array_keys($currentItems))->unique()->sort(); + $changes = []; + + foreach ($keys as $key) { + $previous = $previousItems[$key] ?? null; + $current = $currentItems[$key] ?? null; + + if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) { + continue; + } + + $item = $current ?? $previous; + $sensitive = (bool) data_get($item, 'sensitive', false); + $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed'); + $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null; + + $changes[] = [ + 'key' => $key, + 'section' => data_get($item, 'section'), + 'section_label' => data_get($item, 'section_label'), + 'label' => data_get($item, 'label'), + 'type' => $type, + 'impact' => data_get($item, 'impact', 'redeploy'), + 'sensitive' => $sensitive, + 'display_summary' => $displaySummary, + 'old_display_value' => $sensitive ? ($previous === null ? 'Not set' : 'Set') : data_get($previous, 'display_value', 'Not set'), + 'new_display_value' => $sensitive ? ($current === null ? 'Removed' : 'Set') : data_get($current, 'display_value', 'Not set'), + ]; + } + + return ConfigurationDiff::fromChanges($changes); + } + + /** + * @param array $snapshot + * @return array> + */ + private function flattenItems(array $snapshot): array + { + return collect(data_get($snapshot, 'sections', [])) + ->flatMap(function (array $section, string $sectionKey): array { + return collect(data_get($section, 'items', [])) + ->mapWithKeys(function (array $item) use ($section, $sectionKey): array { + $key = $sectionKey.'.'.$item['key']; + + return [$key => array_merge($item, [ + 'section' => $sectionKey, + 'section_label' => data_get($section, 'label', str($sectionKey)->headline()->value()), + ])]; + }) + ->all(); + }) + ->all(); + } +} diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 043a3346d..79049e8c7 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -35,6 +35,9 @@ '@yearly' => '0 0 1 1 *', ]; const RESTART_MODE = 'unless-stopped'; +const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30; +const MIN_STOP_GRACE_PERIOD_SECONDS = 1; +const MAX_STOP_GRACE_PERIOD_SECONDS = 3600; const DATABASE_DOCKER_IMAGES = [ 'bitnami/mariadb', diff --git a/composer.lock b/composer.lock index 36715d2c5..5947a1588 100644 --- a/composer.lock +++ b/composer.lock @@ -5229,16 +5229,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -5319,7 +5319,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -5335,7 +5335,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "phpstan/phpdoc-parser", diff --git a/config/constants.php b/config/constants.php index dedafa7d5..bd3e5b2aa 100644 --- a/config/constants.php +++ b/config/constants.php @@ -3,9 +3,9 @@ return [ 'coolify' => [ 'version' => '4.1.0', - 'helper_version' => '1.0.13', - 'realtime_version' => '1.0.14', - 'railpack_version' => '0.22.0', + 'helper_version' => '1.0.14', + 'realtime_version' => '1.0.15', + 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), 'autoupdate' => env('AUTOUPDATE'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php new file mode 100644 index 000000000..cc702ce5c --- /dev/null +++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php @@ -0,0 +1,31 @@ +integer('stop_grace_period') + ->nullable() + ->after('use_build_secrets') + ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('stop_grace_period'); + }); + } +}; diff --git a/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php new file mode 100644 index 000000000..6a173d058 --- /dev/null +++ b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php @@ -0,0 +1,28 @@ +string('configuration_hash')->nullable()->after('docker_registry_image_tag'); + $table->json('configuration_snapshot')->nullable()->after('configuration_hash'); + $table->json('configuration_diff')->nullable()->after('configuration_snapshot'); + }); + } + + public function down(): void + { + Schema::table('application_deployment_queues', function (Blueprint $table) { + $table->dropColumn([ + 'configuration_hash', + 'configuration_snapshot', + 'configuration_diff', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php b/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php new file mode 100644 index 000000000..728115482 --- /dev/null +++ b/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php @@ -0,0 +1,30 @@ +timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at'); + $table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('personal_access_tokens', function (Blueprint $table) { + $table->dropIndex('personal_access_tokens_expiration_warning_index'); + $table->dropColumn('api_token_expiration_warning_sent_at'); + }); + } +}; diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile index 263c5a311..6bea6ba1b 100644 --- a/docker/coolify-helper/Dockerfile +++ b/docker/coolify-helper/Dockerfile @@ -12,9 +12,9 @@ ARG PACK_VERSION=0.38.2 # https://github.com/railwayapp/nixpacks/releases ARG NIXPACKS_VERSION=1.41.0 # https://github.com/railwayapp/railpack/releases -ARG RAILPACK_VERSION=0.22.0 +ARG RAILPACK_VERSION=0.23.0 # https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt) -ARG MISE_VERSION=2026.3.12 +ARG MISE_VERSION=2026.3.17 # https://github.com/minio/mc/releases ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z diff --git a/docker/coolify-realtime/Dockerfile b/docker/coolify-realtime/Dockerfile index 325a30dcc..8395d6f87 100644 --- a/docker/coolify-realtime/Dockerfile +++ b/docker/coolify-realtime/Dockerfile @@ -12,8 +12,8 @@ ARG CLOUDFLARED_VERSION WORKDIR /terminal RUN apk upgrade --no-cache && \ apk add --no-cache openssh-client make g++ python3 curl -COPY docker/coolify-realtime/package.json ./ -RUN npm i +COPY docker/coolify-realtime/package*.json ./ +RUN npm ci RUN npm rebuild node-pty --update-binary COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json index eae81be6a..5c6fa94aa 100644 --- a/docker/coolify-realtime/package-lock.json +++ b/docker/coolify-realtime/package-lock.json @@ -7,7 +7,6 @@ "dependencies": { "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", - "axios": "1.15.0", "cookie": "1.1.1", "dotenv": "17.3.1", "node-pty": "1.1.0", @@ -29,48 +28,6 @@ "addons/*" ] }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -84,15 +41,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -105,228 +53,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -343,15 +69,6 @@ "node-addon-api": "^7.1.0" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json index 30bfbcef7..25bf786a8 100644 --- a/docker/coolify-realtime/package.json +++ b/docker/coolify-realtime/package.json @@ -5,9 +5,8 @@ "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", "cookie": "1.1.1", - "axios": "1.15.0", "dotenv": "17.3.1", "node-pty": "1.1.0", "ws": "8.19.0" } -} \ No newline at end of file +} diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index f5760279f..42ca7c81d 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -1,7 +1,6 @@ import { WebSocketServer } from 'ws'; import http from 'http'; import pty from 'node-pty'; -import axios from 'axios'; import cookie from 'cookie'; import 'dotenv/config'; import { @@ -12,9 +11,60 @@ import { isAuthorizedTargetHost, } from './terminal-utils.js'; +async function postToCoolify(path, headers) { + return new Promise((resolve, reject) => { + const request = http.request({ + hostname: 'coolify', + port: 8080, + path, + method: 'POST', + headers, + }, (response) => { + let responseText = ''; + + response.setEncoding('utf8'); + response.on('data', (chunk) => { + responseText += chunk; + }); + response.on('end', () => { + try { + resolve({ + status: response.statusCode ?? 0, + data: parseResponseData(response.headers['content-type'], responseText), + }); + } catch (error) { + reject(error); + } + }); + }); + + request.on('error', reject); + request.end(); + }); +} + +function parseResponseData(contentType = '', responseText = '') { + if (responseText === '') { + return null; + } + + if (contentType.includes('application/json')) { + return JSON.parse(responseText); + } + + return responseText; +} + +function createHttpError(response) { + const error = new Error(`Request failed with status code ${response.status}`); + error.response = response; + + return error; +} + const userSessions = new Map(); -const terminalDebugEnabled = ['local', 'development'].includes( - String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase() +const terminalDebugEnabled = ['1', 'true', 'yes'].includes( + String(process.env.TERMINAL_DEBUG || '').toLowerCase() ); function logTerminal(level, message, context = {}) { @@ -74,11 +124,9 @@ const verifyClient = async (info, callback) => { try { // Authenticate with Laravel backend - const response = await axios.post(`http://coolify:8080/terminal/auth`, null, { - headers: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }, + const response = await postToCoolify('/terminal/auth', { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken }); if (response.status === 200) { @@ -161,12 +209,15 @@ wss.on('connection', async (ws, req) => { } try { - const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, { - headers: { - 'Cookie': `${sessionCookieName}=${laravelSession}`, - 'X-XSRF-TOKEN': xsrfToken - }, + const response = await postToCoolify('/terminal/auth/ips', { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken }); + + if (response.status !== 200) { + throw createHttpError(response); + } + userSession.authorizedIPs = response.data.ipAddresses || []; logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', { ...connectionContext, diff --git a/openapi.json b/openapi.json index 25aada1e1..e83538f2b 100644 --- a/openapi.json +++ b/openapi.json @@ -12791,6 +12791,18 @@ "type": "string", "nullable": true }, + "configuration_hash": { + "type": "string", + "nullable": true + }, + "configuration_snapshot": { + "type": "object", + "nullable": true + }, + "configuration_diff": { + "type": "object", + "nullable": true + }, "force_rebuild": { "type": "boolean" }, diff --git a/openapi.yaml b/openapi.yaml index 4597b06f7..523d453ff 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8158,6 +8158,15 @@ components: docker_registry_image_tag: type: string nullable: true + configuration_hash: + type: string + nullable: true + configuration_snapshot: + type: object + nullable: true + configuration_diff: + type: object + nullable: true force_rebuild: type: boolean commit: diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml index 56c5b416b..3a9bfd501 100644 --- a/other/nightly/docker-compose.prod.yml +++ b/other/nightly/docker-compose.prod.yml @@ -60,7 +60,7 @@ services: retries: 10 timeout: 2s soketi: - image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14' + image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15' ports: - "${SOKETI_PORT:-6001}:6001" - "6002:6002" diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml index e1c09c64c..cc72d487b 100644 --- a/other/nightly/docker-compose.windows.yml +++ b/other/nightly/docker-compose.windows.yml @@ -96,7 +96,7 @@ services: retries: 10 timeout: 2s soketi: - image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14' + image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15' pull_policy: always container_name: coolify-realtime restart: always diff --git a/other/nightly/versions.json b/other/nightly/versions.json index f8b4ea890..368e1e379 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -10,7 +10,7 @@ "version": "1.0.13" }, "realtime": { - "version": "1.0.14" + "version": "1.0.15" }, "sentinel": { "version": "0.0.21" diff --git a/package-lock.json b/package-lock.json index 20aa0e822..dcca9c394 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "@vitejs/plugin-vue": "6.0.3", - "axios": "1.15.0", "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", @@ -1466,39 +1465,6 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1518,19 +1484,6 @@ "node": ">=0.10.0" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1567,16 +1520,6 @@ } } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -1596,21 +1539,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -1664,55 +1592,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1780,44 +1659,6 @@ } } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1833,68 +1674,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1902,48 +1681,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ioredis": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", @@ -2313,39 +2050,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mini-svg-data-uri": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", @@ -2500,16 +2204,6 @@ "react": ">=16.0.0" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pusher-js": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", diff --git a/package.json b/package.json index 3afefa833..6b0b58522 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.18", "@vitejs/plugin-vue": "6.0.3", - "axios": "1.15.0", "laravel-echo": "2.2.7", "laravel-vite-plugin": "2.0.1", "postcss": "8.5.6", diff --git a/resources/css/utilities.css b/resources/css/utilities.css index a0e4ee2ca..27af55e96 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -185,7 +185,7 @@ @utility menu-item { @apply flex gap-3 items-center px-2 py-1 w-full text-sm dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0; } @utility menu-item-icon { - @apply flex-shrink-0 w-6 h-6 dark:hover:text-white; + @apply shrink-0 size-4 dark:hover:text-white; } @utility menu-item-label { @@ -205,7 +205,7 @@ @utility sub-menu-item { } @utility sub-menu-item-icon { - @apply flex-shrink-0 w-4 h-4 dark:hover:text-white; + @apply shrink-0 size-4 dark:hover:text-white; } @utility heading-item-active { @@ -351,8 +351,12 @@ @utility log-info { @media (min-width: 1024px) { .sidebar-collapsed .menu-item { justify-content: center; + width: var(--button-h, 2rem); + height: var(--button-h, 2rem); + min-height: var(--button-h, 2rem); padding-left: 0; padding-right: 0; gap: 0; + margin-inline: auto; } } diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php new file mode 100644 index 000000000..ffc0cd34a --- /dev/null +++ b/resources/views/components/deployment/configuration-diff.blade.php @@ -0,0 +1,70 @@ +@props([ + 'diff' => null, + 'compact' => false, +]) + +@php + $changes = data_get($diff, 'changes', []); + $count = data_get($diff, 'count', count($changes)); + $requiresBuild = data_get($diff, 'requires_build', false); +@endphp + +@if ($count > 0) +
$compact, + 'text-sm' => ! $compact, + ])> +
+ {{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }} + $requiresBuild, + 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild, + ])> + {{ $requiresBuild ? 'Rebuild' : 'Redeploy' }} + +
+ + @unless ($compact) +
+ @foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges) +
+
+ {{ $sectionLabel }} +
+
+
+
+
Field
+
Type
+
From
+
+
To
+
+
+ @foreach ($sectionChanges as $change) +
+
+ {{ data_get($change, 'label') }} +
+
+ {{ data_get($change, 'type') }} +
+
+ {{ data_get($change, 'old_display_value') }} +
+
+
+ {{ data_get($change, 'new_display_value') }} +
+
+ @endforeach +
+
+
+
+ @endforeach +
+ @endunless +
+@endif diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index c5b076a71..3b21a81d5 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -1,5 +1,5 @@