diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..d300267f1 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 94651a3c1..e86e30f04 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -55,6 +55,14 @@ public function handle(Application $application, bool $previewDeployments = fals return $e->getMessage(); } } + + // Reset restart tracking when application is manually stopped + $application->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + ServiceStatusChanged::dispatch($application->environment->project->team->id); } } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 863691e1e..58f3cda4e 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -5,7 +5,6 @@ use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneKeydb; -use Illuminate\Support\Facades\Storage; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -270,10 +269,9 @@ private function add_custom_keydb() return; } $filename = 'keydb.conf'; - Storage::disk('local')->put("tmp/keydb.conf_{$this->database->uuid}", $this->database->keydb_conf); - $path = Storage::path("tmp/keydb.conf_{$this->database->uuid}"); - instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server); - Storage::disk('local')->delete("tmp/keydb.conf_{$this->database->uuid}"); + $content = $this->database->keydb_conf; + $content_base64 = base64_encode($content); + $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null"; } private function buildStartCommand(): string diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 2eaf82fdd..4e4f3ce53 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -5,7 +5,6 @@ use App\Helpers\SslHelper; use App\Models\SslCertificate; use App\Models\StandaloneRedis; -use Illuminate\Support\Facades\Storage; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -316,9 +315,8 @@ private function add_custom_redis() return; } $filename = 'redis.conf'; - Storage::disk('local')->put("tmp/redis.conf_{$this->database->uuid}", $this->database->redis_conf); - $path = Storage::path("tmp/redis.conf_{$this->database->uuid}"); - instant_scp($path, "{$this->configuration_dir}/{$filename}", $this->database->destination->server); - Storage::disk('local')->delete("tmp/redis.conf_{$this->database->uuid}"); + $content = $this->database->redis_conf; + $content_base64 = base64_encode($content); + $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/{$filename} > /dev/null"; } } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index c024c14e1..4dde509ab 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -28,6 +28,13 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $this->stopContainer($database, $database->uuid, 30); + // Reset restart tracking when database is manually stopped + $database->update([ + 'restart_count' => 0, + 'last_restart_at' => null, + 'last_restart_type' => null, + ]); + if ($dockerCleanup) { CleanupDocker::dispatch($server, false, false); } diff --git a/app/Listeners/ProxyStatusChangedNotification.php b/app/Listeners/ProxyStatusChangedNotification.php index 1d99e7057..30ecb2d8d 100644 --- a/app/Listeners/ProxyStatusChangedNotification.php +++ b/app/Listeners/ProxyStatusChangedNotification.php @@ -29,7 +29,8 @@ public function handle(ProxyStatusChanged $event) $server->proxy->set('status', $status); $server->save(); - ProxyStatusChangedUI::dispatch($server->team_id); + $versionCheckDispatched = false; + if ($status === 'running') { $server->setupDefaultRedirect(); $server->setupDynamicProxyConfiguration(); @@ -40,7 +41,9 @@ public function handle(ProxyStatusChanged $event) if ($server->proxyType() === ProxyTypes::TRAEFIK->value) { $traefikVersions = get_traefik_versions(); if ($traefikVersions !== null) { + // Version check job will dispatch ProxyStatusChangedUI when complete CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + $versionCheckDispatched = true; } else { Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [ 'server_id' => $server->id, @@ -49,6 +52,13 @@ public function handle(ProxyStatusChanged $event) } } } + + // Only dispatch UI refresh if version check wasn't dispatched + // (version check job handles its own UI refresh with updated version data) + if (! $versionCheckDispatched) { + ProxyStatusChangedUI::dispatch($server->team_id); + } + if ($status === 'created') { instant_remote_process([ 'docker rm -f coolify-proxy', diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 819ac3ecd..70751fa03 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -95,7 +95,7 @@ public function submit() ]); } } - $this->redirect(route('destination.show', $docker->uuid)); + redirectRoute($this, 'destination.show', [$docker->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 237076acc..e4108668b 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1314,10 +1314,10 @@ private function completeResourceCreation() 'server_id' => $this->selectedServerId, ]; - $this->redirect(route('project.resource.create', [ + redirectRoute($this, 'project.resource.create', [ 'project_uuid' => $this->selectedProjectUuid, 'environment_uuid' => $this->selectedEnvironmentUuid, - ] + $queryParams)); + ] + $queryParams); } } @@ -1336,6 +1336,42 @@ public function cancelResourceSelection() $this->autoOpenResource = null; } + public function goBack() + { + // From Environment Selection → go back to Project (if multiple) or further + if ($this->selectedProjectUuid !== null) { + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + if (count($this->availableProjects) > 1) { + return; // Stop here - user can choose a project + } + } + + // From Project Selection → go back to Destination (if multiple) or further + if ($this->selectedDestinationUuid !== null) { + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + if (count($this->availableDestinations) > 1) { + return; // Stop here - user can choose a destination + } + } + + // From Destination Selection → go back to Server (if multiple) or cancel + if ($this->selectedServerId !== null) { + $this->selectedServerId = null; + $this->selectedDestinationUuid = null; + $this->selectedProjectUuid = null; + $this->selectedEnvironmentUuid = null; + if (count($this->availableServers) > 1) { + return; // Stop here - user can choose a server + } + } + + // All previous steps were auto-selected, cancel entirely + $this->cancelResourceSelection(); + } + public function getFilteredCreatableItemsProperty() { $query = strtolower(trim($this->searchQuery)); diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index 9508c2adc..a8c932912 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -37,7 +37,7 @@ public function delete($password) refreshSession(); - return redirect()->route('team.index'); + return redirectRoute($this, 'team.index'); } public function render() diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index c84de9d8d..dffe1ec67 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -558,8 +558,11 @@ public function loadComposeFile($isInit = false, $showToast = true, ?string $res $this->dispatch('refreshStorages'); $this->dispatch('refreshEnvs'); } catch (\Throwable $e) { - $this->application->docker_compose_location = $this->initialDockerComposeLocation; - $this->application->save(); + // Refresh model to get restored values from Application::loadComposeFile + $this->application->refresh(); + // Sync restored values back to component properties for UI update + + $this->syncData(); return handleError($e, $this); } finally { @@ -936,73 +939,6 @@ public function downloadConfig() ]); } - private function updateServiceEnvironmentVariables() - { - $domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]); - - foreach ($domains as $serviceName => $service) { - $serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_'); - $domain = data_get($service, 'domain'); - // Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed - $this->application->environment_variables()->where('resourceable_type', Application::class) - ->where('resourceable_id', $this->application->id) - ->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%") - ->delete(); - - $this->application->environment_variables()->where('resourceable_type', Application::class) - ->where('resourceable_id', $this->application->id) - ->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%") - ->delete(); - - if ($domain) { - // Create or update SERVICE_FQDN_ and SERVICE_URL_ variables - $fqdn = Url::fromString($domain); - $port = $fqdn->getPort(); - $path = $fqdn->getPath(); - $urlValue = $fqdn->getScheme().'://'.$fqdn->getHost(); - if ($path !== '/') { - $urlValue = $urlValue.$path; - } - $fqdnValue = str($domain)->after('://'); - if ($path !== '/') { - $fqdnValue = $fqdnValue.$path; - } - - // Create/update SERVICE_FQDN_ - $this->application->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNameFormatted}", - ], [ - 'value' => $fqdnValue, - 'is_preview' => false, - ]); - - // Create/update SERVICE_URL_ - $this->application->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceNameFormatted}", - ], [ - 'value' => $urlValue, - 'is_preview' => false, - ]); - // Create/update port-specific variables if port exists - if (filled($port)) { - $this->application->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}", - ], [ - 'value' => $fqdnValue, - 'is_preview' => false, - ]); - - $this->application->environment_variables()->updateOrCreate([ - 'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}", - ], [ - 'value' => $urlValue, - 'is_preview' => false, - ]); - } - } - } - } - public function getDetectedPortInfoProperty(): ?array { $detectedPort = $this->application->detectPortFromEnvironment(); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index e53784db5..e8edf72fa 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -66,7 +66,7 @@ public function rollbackImage($commit) return; } - return redirect()->route('project.application.deployment.show', [ + return redirectRoute($this, 'project.application.deployment.show', [ 'project_uuid' => $this->parameters['project_uuid'], 'application_uuid' => $this->parameters['application_uuid'], 'deployment_uuid' => $deployment_uuid, diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index e97206081..aa6e95975 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -39,7 +39,7 @@ public function delete() if ($environment->isEmpty()) { $environment->delete(); - return redirect()->route('project.show', parameters: ['project_uuid' => $this->parameters['project_uuid']]); + return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); } return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index 26b35b2e7..a018046fd 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -35,7 +35,7 @@ public function delete() if ($project->isEmpty()) { $project->delete(); - return redirect()->route('project.index'); + return redirectRoute($this, 'project.index'); } return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); diff --git a/app/Livewire/Project/EnvironmentEdit.php b/app/Livewire/Project/EnvironmentEdit.php index d57be2cc8..529b9d7b1 100644 --- a/app/Livewire/Project/EnvironmentEdit.php +++ b/app/Livewire/Project/EnvironmentEdit.php @@ -63,7 +63,7 @@ public function submit() { try { $this->syncData(true); - $this->redirectRoute('project.environment.edit', [ + redirectRoute($this, 'project.environment.edit', [ 'environment_uuid' => $this->environment->uuid, 'project_uuid' => $this->project->uuid, ]); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 9d04ca9a5..8aff83153 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -154,7 +154,7 @@ public function submit() 'fqdn' => $fqdn, ]); - return redirect()->route('project.application.configuration', [ + return redirectRoute($this, 'project.application.configuration', [ 'application_uuid' => $application->uuid, 'environment_uuid' => $environment->uuid, 'project_uuid' => $project->uuid, diff --git a/app/Livewire/Project/New/EmptyProject.php b/app/Livewire/Project/New/EmptyProject.php index 54cfc4b4d..0360365a9 100644 --- a/app/Livewire/Project/New/EmptyProject.php +++ b/app/Livewire/Project/New/EmptyProject.php @@ -16,6 +16,6 @@ public function createEmptyProject() 'uuid' => (string) new Cuid2, ]); - return redirect()->route('project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]); + return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]); } } diff --git a/app/Livewire/Project/Service/Database.php b/app/Livewire/Project/Service/Database.php index 1e183c6bc..301c39dee 100644 --- a/app/Livewire/Project/Service/Database.php +++ b/app/Livewire/Project/Service/Database.php @@ -100,7 +100,7 @@ public function delete($password) $this->database->delete(); $this->dispatch('success', 'Database deleted.'); - return redirect()->route('project.service.configuration', $this->parameters); + return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } @@ -164,7 +164,7 @@ public function convertToApplication() $serviceDatabase->delete(); }); - return redirect()->route('project.service.configuration', $redirectParams); + return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 8bf3c7438..1b15c6367 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -111,7 +111,7 @@ public function delete($password) $this->docker_cleanup ); - return redirect()->route('project.resource.index', [ + return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $this->projectUuid, 'environment_uuid' => $this->environmentUuid, ]); diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index ffd18b35c..7ab81b7d1 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -97,7 +97,7 @@ public function redeploy(int $network_id, int $server_id) return; } - return redirect()->route('project.application.deployment.show', [ + return redirectRoute($this, 'project.application.deployment.show', [ 'project_uuid' => data_get($this->resource, 'environment.project.uuid'), 'application_uuid' => data_get($this->resource, 'uuid'), 'deployment_uuid' => $deployment_uuid, diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 3c2abc84c..ae68b2354 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -57,7 +57,14 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid) $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; - $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'"); + + // Add sudo for non-root users to access Docker socket + $dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'"; + if ($server->isNonRoot()) { + $dockerCommand = "sudo {$dockerCommand}"; + } + + $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand); } else { $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index 7e828d14c..e884abb4e 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -48,7 +48,7 @@ public function submit() 'uuid' => (string) new Cuid2, ]); - return redirect()->route('project.resource.index', [ + return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $environment->uuid, ]); @@ -59,7 +59,7 @@ public function submit() public function navigateToEnvironment($projectUuid, $environmentUuid) { - return redirect()->route('project.resource.index', [ + return redirectRoute($this, 'project.resource.index', [ 'project_uuid' => $projectUuid, 'environment_uuid' => $environmentUuid, ]); diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 656e73958..8b7ba73dd 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -114,7 +114,7 @@ private function validatePrivateKey() private function redirectAfterCreation(PrivateKey $privateKey) { return $this->from === 'server' - ? redirect()->route('dashboard') - : redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + ? redirectRoute($this, 'dashboard') + : redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); } } diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index c292d14a3..6be190689 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -107,7 +107,7 @@ public function delete() $this->private_key->safeDelete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); - return redirect()->route('security.private-key.index'); + return redirectRoute($this, 'security.private-key.index'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 27a6e7aca..e7b64b805 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -46,7 +46,7 @@ public function delete($password) $this->server->team_id ); - return redirect()->route('server.index'); + return redirectRoute($this, 'server.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index f7d12dbc1..f1ffa60f2 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -567,10 +567,10 @@ public function submit() ]); refreshSession(); - return $this->redirect(route('server.show', $server->uuid)); + return redirectRoute($this, 'server.show', [$server->uuid]); } - return redirect()->route('server.show', $server->uuid); + return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 35526d59e..1f4cdf607 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -128,7 +128,7 @@ public function submit() $server->settings->is_build_server = $this->is_build_server; $server->settings->save(); - return redirect()->route('server.show', $server->uuid); + return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index 2f1482c89..4ece6a92f 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -58,7 +58,7 @@ public function createGitHubApp() session(['from' => session('from') + ['source_id' => $github_app->id]]); } - return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]); + return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php index 9efeb948c..eda20342b 100644 --- a/app/Livewire/Storage/Create.php +++ b/app/Livewire/Storage/Create.php @@ -116,7 +116,7 @@ public function submit() $this->storage->testConnection(); $this->storage->save(); - return redirect()->route('storage.show', $this->storage->uuid); + return redirectRoute($this, 'storage.show', [$this->storage->uuid]); } catch (\Throwable $e) { $this->dispatch('error', 'Failed to create storage.', $e->getMessage()); // return handleError($e, $this); diff --git a/app/Livewire/Team/Create.php b/app/Livewire/Team/Create.php index cd15be67d..0bcfb5631 100644 --- a/app/Livewire/Team/Create.php +++ b/app/Livewire/Team/Create.php @@ -37,7 +37,7 @@ public function submit() auth()->user()->teams()->attach($team, ['role' => 'admin']); refreshSession($team); - return redirect()->route('team.index'); + return redirectRoute($this, 'team.index'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Models/Application.php b/app/Models/Application.php index 5006d0ff8..40e41c2a7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1584,6 +1584,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = try { $composeFileContent = instant_remote_process($commands, $this->destination->server); } catch (\Exception $e) { + // Restore original values on failure only + $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; + $this->save(); + if (str($e->getMessage())->contains('No such file')) { throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } @@ -1595,9 +1600,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = } throw new \RuntimeException($e->getMessage()); } finally { - $this->docker_compose_location = $initialDockerComposeLocation; - $this->base_directory = $initialBaseDirectory; - $this->save(); + // Cleanup only - restoration happens in catch block $commands = collect([ "rm -rf /tmp/{$uuid}", ]); @@ -1643,6 +1646,11 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = 'initialDockerComposeLocation' => $this->docker_compose_location, ]; } else { + // Restore original values before throwing + $this->docker_compose_location = $initialDockerComposeLocation; + $this->base_directory = $initialBaseDirectory; + $this->save(); + throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index be39e3f8d..fd1ce3e69 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -682,9 +682,16 @@ public function getCpuMetrics(int $mins = 5) } $cpu = json_decode($cpu, true); - return collect($cpu)->map(function ($metric) { + $metrics = collect($cpu)->map(function ($metric) { return [(int) $metric['time'], (float) $metric['percent']]; - }); + })->toArray(); + + // Downsample for intervals > 60 minutes to prevent browser freeze + if ($mins > 60 && count($metrics) > 1000) { + $metrics = $this->downsampleLTTB($metrics, 1000); + } + + return collect($metrics); } } @@ -702,16 +709,99 @@ public function getMemoryMetrics(int $mins = 5) throw new \Exception($error); } $memory = json_decode($memory, true); - $parsedCollection = collect($memory)->map(function ($metric) { + $metrics = collect($memory)->map(function ($metric) { $usedPercent = $metric['usedPercent'] ?? 0.0; return [(int) $metric['time'], (float) $usedPercent]; - }); + })->toArray(); - return $parsedCollection->toArray(); + // Downsample for intervals > 60 minutes to prevent browser freeze + if ($mins > 60 && count($metrics) > 1000) { + $metrics = $this->downsampleLTTB($metrics, 1000); + } + + return collect($metrics); } } + /** + * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. + * This preserves the visual shape of the data better than simple averaging. + * + * @param array $data Array of [timestamp, value] pairs + * @param int $threshold Target number of points + * @return array Downsampled data + */ + private function downsampleLTTB(array $data, int $threshold): array + { + $dataLength = count($data); + + // Return unchanged if threshold >= data length, or if threshold <= 2 + // (threshold <= 2 would cause division by zero in bucket calculation) + if ($threshold >= $dataLength || $threshold <= 2) { + return $data; + } + + $sampled = []; + $sampled[] = $data[0]; // Always keep first point + + $bucketSize = ($dataLength - 2) / ($threshold - 2); + + $a = 0; // Index of previous selected point + + for ($i = 0; $i < $threshold - 2; $i++) { + // Calculate bucket range + $bucketStart = (int) floor(($i + 1) * $bucketSize) + 1; + $bucketEnd = (int) floor(($i + 2) * $bucketSize) + 1; + $bucketEnd = min($bucketEnd, $dataLength - 1); + + // Calculate average point for next bucket (used as reference) + $nextBucketStart = (int) floor(($i + 2) * $bucketSize) + 1; + $nextBucketEnd = (int) floor(($i + 3) * $bucketSize) + 1; + $nextBucketEnd = min($nextBucketEnd, $dataLength - 1); + + $avgX = 0; + $avgY = 0; + $nextBucketCount = $nextBucketEnd - $nextBucketStart + 1; + + if ($nextBucketCount > 0) { + for ($j = $nextBucketStart; $j <= $nextBucketEnd; $j++) { + $avgX += $data[$j][0]; + $avgY += $data[$j][1]; + } + $avgX /= $nextBucketCount; + $avgY /= $nextBucketCount; + } + + // Find point in current bucket with largest triangle area + $maxArea = -1; + $maxAreaIndex = $bucketStart; + + $pointAX = $data[$a][0]; + $pointAY = $data[$a][1]; + + for ($j = $bucketStart; $j <= $bucketEnd; $j++) { + // Triangle area calculation + $area = abs( + ($pointAX - $avgX) * ($data[$j][1] - $pointAY) - + ($pointAX - $data[$j][0]) * ($avgY - $pointAY) + ) * 0.5; + + if ($area > $maxArea) { + $maxArea = $area; + $maxAreaIndex = $j; + } + } + + $sampled[] = $data[$maxAreaIndex]; + $a = $maxAreaIndex; + } + + $sampled[] = $data[$dataLength - 1]; // Always keep last point + + return $sampled; + } + public function getDiskUsage(): ?string { return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e73328474..9fc1e6f1c 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2934,6 +2934,23 @@ function wireNavigate(): string } } +/** + * Redirect to a named route with SPA navigation support. + * Automatically uses wire:navigate when is_wire_navigate_enabled is true. + */ +function redirectRoute(Livewire\Component $component, string $name, array $parameters = []): mixed +{ + $navigate = true; + + try { + $navigate = instanceSettings()->is_wire_navigate_enabled ?? true; + } catch (\Exception $e) { + $navigate = true; + } + + return $component->redirectRoute($name, $parameters, navigate: $navigate); +} + function getHelperVersion(): string { $settings = instanceSettings(); diff --git a/config/constants.php b/config/constants.php index 07141bd17..930db2f61 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.459', + 'version' => '4.0.0-beta.460', 'helper_version' => '1.0.12', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 8a7441054..c6419600c 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.459" + "version": "4.0.0-beta.460" }, "nightly": { - "version": "4.0.0-beta.460" + "version": "4.0.0-beta.461" }, "helper": { "version": "1.0.12" diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 3f5bcafac..e485ec545 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -6,11 +6,6 @@ href="{{ route('server.advanced', ['server_uuid' => $server->uuid]) }}">Advanced @endif - @if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel) - Swarm - - @endif @if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer()) Sentinel @@ -20,7 +15,8 @@ href="{{ route('server.private-key', ['server_uuid' => $server->uuid]) }}">Private Key @if ($server->hetzner_server_id) - Hetzner Token @endif @@ -45,6 +41,11 @@ Metrics @endif + @if (!$server->isBuildServer() && !$server->settings->is_cloudflare_tunnel) + Swarm (experimental) + + @endif @if (!$server->isLocalhost()) Danger diff --git a/resources/views/components/upgrade-progress.blade.php b/resources/views/components/upgrade-progress.blade.php index 1418ca6c9..cc7d7d188 100644 --- a/resources/views/components/upgrade-progress.blade.php +++ b/resources/views/components/upgrade-progress.blade.php @@ -17,7 +17,7 @@