diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5afe00a30..dec20e9ac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,48 @@ -## Submit Checklist (REMOVE THIS SECTION BEFORE SUBMITTING) -- [ ] I have selected the `next` branch as the destination for my PR, not `main`. -- [ ] I have listed all changes in the `Changes` section. -- [ ] I have filled out the `Issues` section with the issue/discussion link(s) (if applicable). -- [ ] I have tested my changes. -- [ ] I have considered backwards compatibility. -- [ ] I have removed this checklist and any unused sections. + +### Changes + + +> +> -## Changes -- -## Issues -- fix # +### Issue + +> - + + +### Category + +> - [x] Bug fix +> - [x] New feature +> - [x] Adding new one click service +> - [x] Fixing or updating existing one click service + + +### Screenshots or Video (if applicable) + + + + +### AI Usage + + +> - [x] AI is used in the process of creating this PR +> - [x] AI is NOT used in the process of creating this PR + + +### Steps to Test + + +> - Step 1 – what to do first +> - Step 2 – next action +> - Step 3 – expected outcome +> - Step 4 – additional checks (if any) + + +### Contributor Agreement + +> [!IMPORTANT] + > - [x] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review. + > - [x] I have tested the changes thoroughly and am confident that they will work as expected without issues when the maintainer tests them + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..2b8d50c0d --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,37 @@ +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: write + pull-requests: write + issues: write + id-token: write + 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 }} + claude_args: '--model opus' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ba4d1876..9aec08420 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,33 +151,85 @@ After installing Docker (or Orbstack) and Spin, verify the installation: ## 7. Create a Pull Request +> [!IMPORTANT] +> Please read the [Pull Request Guidelines](#pull-request-guidelines) carefully before creating your PR. + 1. After making changes or adding a new service: - - Commit your changes to your forked repository. - - Push the changes to your GitHub account. + - Commit your changes to your forked repository. + - Push the changes to your GitHub account. 2. Creating the Pull Request (PR): - - Navigate to the main Coolify repository on GitHub. - - Click the "Pull requests" tab. - - Click the green "New pull request" button. - - Choose your fork and branch as the compare branch. - - Click "Create pull request". + - Navigate to the main Coolify repository on GitHub. + - Click the "Pull requests" tab. + - Click the green "New pull request" button. + - Choose your fork and `next` branch as the compare branch. + - Click "Create pull request". 3. Filling out the PR details: - - Give your PR a descriptive title. - - Use the Pull Request Template provided and fill in the details. + - Give your PR a descriptive title. + - Use the Pull Request Template provided and fill in the details. > [!IMPORTANT] -> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `main` branch. +> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `v4.x` branch. 4. Submit your PR: - - Review your changes one last time. - - Click "Create pull request" to submit. + - Review your changes one last time. + - Click "Create pull request" to submit. > [!NOTE] > Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers. After submission, maintainers will review your PR and may request changes or provide feedback. +#### Pull Request Guidelines +To maintain high-quality contributions and efficient review process: +- **Target Branch**: Always target the `next` branch, never `v4.x` or any other branch. PRs targeting incorrect branches will be closed without review. +- **Descriptive Titles**: Use clear, concise PR titles that describe the change (e.g., "fix: one click postgresql database stuck in restart loop" instead of "Fix database"). +- **PR Descriptions**: Provide detailed, meaningful descriptions. Avoid generic or AI-generated fluff. Include: + - What the change does + - Why it's needed + - How to test it + - Any breaking changes + - Screenshot or video recording of your changes working without any issues + - Links to related issues +- **Link to Issues**: All PRs must link to an existing GitHub issue. If no issue exists, create one first. Unrelated PRs may be closed. +- **Single Responsibility**: Each PR should address one issue or feature. Do not bundle unrelated changes. +- **Draft Mode**: Use draft PRs for work-in-progress. Convert to ready-for-review only when complete and tested. +- **Review Readiness**: Ensure your PR is ready for review within a reasonable timeframe (max 7 days in draft). Stale drafts may be closed. +- **Current Focus**: We are currently prioritizing stability and bug fixes over new features. PRs adding new features may not be reviewed, or may be closed without review to maintain focus. +- **Language Translations**: Coolify currently supports only English. Pull requests for new language translations will not be accepted. Multi-language support may be considered in the next major version (v5). +- **AI Usage Policy**: We are not against AI tools—we use them ourselves. However, AI discourse is mandatory: You must fully understand the changes in your PR and be able to explain them clearly. Many PRs using AI lack this understanding, leading to untested or incorrect submissions. If you use AI, ensure you can articulate what the code does, why it was changed, and how it was tested. + +#### Review Process +- **Response Time**: Maintainers will review PRs promptly, but complex changes may take time. Be patient and responsive to feedback. +- **Revisions**: Address all review comments. Unresolved feedback may lead to PR closure. +- **Merge Criteria**: PRs are merged only after: + - All tests pass (including CI) + - Code review approval +- **Closing PRs**: PRs may be closed for: + - Inactivity (>7 days without response) + - Failure to meet guidelines + - Duplicate or superseded work + - Security or quality concerns + +#### Code Quality, Testing, and Bounty Submissions +All contributions must adhere to the highest standards of code quality and testing: + +- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. +- **Local Verification**: Ensure your changes work in the development environment. Test all affected features thoroughly. +- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. +- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately. + +**For PRs that claim bounties:** + +- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards. +- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing. +- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs). +- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely. +- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors. +- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct. +- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff. + ## Development Notes When working on Coolify, keep the following in mind: 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 @@ class StopApplication 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/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index 12fd92792..c634f14ba 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -33,7 +33,6 @@ class StartDatabaseProxy $databaseType = $database->databaseType(); $network = $database->service->uuid; $server = data_get($database, 'service.destination.server'); - $proxyContainerName = "{$database->service->uuid}-proxy"; $containerName = "{$database->name}-{$database->service->uuid}"; } $internalPort = match ($databaseType) { 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 @@ namespace App\Actions\Database; 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 @@ class StartKeydb 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 @@ namespace App\Actions\Database; 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 @@ class StartRedis 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 @@ class StopDatabase $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/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index e12d83542..fd0282d96 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -218,7 +218,10 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $team = auth()->user()->currentTeam(); + $team = auth()->user()->teams->where('id', $teamId)->first(); + if (is_null($team)) { + return response()->json(['message' => 'Team not found.'], 404); + } return response()->json( $this->removeSensitiveData($team), @@ -263,7 +266,10 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $team = auth()->user()->currentTeam(); + $team = auth()->user()->teams->where('id', $teamId)->first(); + if (is_null($team)) { + return response()->json(['message' => 'Team not found.'], 404); + } $team->members->makeHidden([ 'pivot', 'email_change_code', diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 4d34a1000..93847589a 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -13,7 +13,8 @@ class UploadController extends BaseController { public function upload(Request $request) { - $resource = getResourceByUuid(request()->route('databaseUuid'), data_get(auth()->user()->currentTeam(), 'id')); + $databaseIdentifier = request()->route('databaseUuid'); + $resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id')); if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } @@ -26,7 +27,10 @@ class UploadController extends BaseController $save = $receiver->receive(); if ($save->isFinished()) { - return $this->saveFile($save->getFile(), $resource); + // Use the original identifier from the route to maintain path consistency + // For ServiceDatabase: {name}-{service_uuid} + // For standalone databases: {uuid} + return $this->saveFile($save->getFile(), $databaseIdentifier); } $handler = $save->handler(); @@ -57,10 +61,10 @@ class UploadController extends BaseController // 'mime_type' => $mime // ]); // } - protected function saveFile(UploadedFile $file, $resource) + protected function saveFile(UploadedFile $file, string $resourceIdentifier) { $mime = str_replace('/', '-', $file->getMimeType()); - $filePath = "upload/{$resource->uuid}"; + $filePath = "upload/{$resourceIdentifier}"; $finalPath = storage_path('app/'.$filePath); $file->move($finalPath, 'restore'); diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index 8b1c550df..dbf261f4d 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -18,14 +18,21 @@ class DecideWhatToDoWithUser } if (auth()?->user()?->currentTeam()) { refreshSession(auth()->user()->currentTeam()); + } elseif (auth()?->user()?->teams?->count() > 0) { + // User's session team is invalid (e.g., removed from team), switch to first available team + refreshSession(auth()->user()->teams->first()); } - if (! auth()->user() || ! isCloud() || isInstanceAdmin()) { + if (! auth()->user() || ! isCloud()) { if (! isCloud() && showBoarding() && ! in_array($request->path(), allowedPathsForBoardingAccounts())) { return redirect()->route('onboarding'); } return $next($request); } + // Instance admins can access settings and admin routes regardless of subscription + if (isInstanceAdmin() && ($request->routeIs('settings.*') || $request->path() === 'admin')) { + return $next($request); + } if (! auth()->user()->hasVerifiedEmail()) { if ($request->path() === 'verify' || in_array($request->path(), allowedPathsForInvalidAccounts()) || $request->routeIs('verify.verify')) { return $next($request); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 56a29276b..c8ca0ced6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2613,7 +2613,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $docker_compose['services'][$this->container_name]['labels'] = $labels; } - if ($this->server->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { + if ($this->original_server->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { $docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration(); } if ($this->application->settings->is_gpu_enabled) { 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 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit $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 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit 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 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit } } } + + // 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/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index bc310e715..370ff1eaa 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -79,8 +79,10 @@ class ActivityMonitor extends Component $causer_id = data_get($this->activity, 'causer_id'); $user = User::find($causer_id); if ($user) { - $teamId = $user->currentTeam()->id; - if (! self::$eventDispatched) { + $teamId = data_get($this->activity, 'properties.team_id') + ?? $user->currentTeam()?->id + ?? $user->teams->first()?->id; + if ($teamId && ! self::$eventDispatched) { if (filled($this->eventData)) { $this->eventToDispatch::dispatch($teamId, $this->eventData); } else { 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 @@ class Docker extends Component ]); } } - $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 @@ class GlobalSearch extends Component '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 @@ class GlobalSearch extends Component $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 @@ class NavbarDeleteTeam extends Component refreshSession(); - return redirect()->route('team.index'); + return redirectRoute($this, 'team.index'); } public function render() diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index c204a49f1..954670582 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -121,6 +121,25 @@ class Show extends Component return sanitizeLogsForExport($logs); } + public function downloadAllLogs(): string + { + $logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true) + ->map(function ($line) { + $prefix = ''; + if ($line['hidden']) { + $prefix = '[DEBUG] '; + } + if (isset($line['command']) && $line['command']) { + $prefix .= '[CMD]: '; + } + + return $line['timestamp'].' '.$prefix.trim($line['line']); + }) + ->join("\n"); + + return sanitizeLogsForExport($logs); + } + public function render() { return view('livewire.project.application.deployment.show'); 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 @@ class General extends Component $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 @@ class General extends Component ]); } - 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 @@ class Rollback extends Component 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/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index d70c52411..35262d7b0 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -8,7 +8,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; -use Spatie\Url\Url; class BackupEdit extends Component { @@ -184,13 +183,14 @@ class BackupEdit extends Component $this->backup->delete(); if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { - $previousUrl = url()->previous(); - $url = Url::fromString($previousUrl); - $url = $url->withoutQueryParameter('selectedBackupId'); - $url = $url->withFragment('backups'); - $url = $url->getPath()."#{$url->getFragment()}"; + $serviceDatabase = $this->backup->database; - return redirect($url); + return redirect()->route('project.service.database.backups', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $serviceDatabase->service->uuid, + 'stack_service_uuid' => $serviceDatabase->uuid, + ]); } else { return redirect()->route('project.database.backup.index', $this->parameters); } diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php index 26feb1a5e..2077ad66c 100644 --- a/app/Livewire/Project/Database/Import.php +++ b/app/Livewire/Project/Database/Import.php @@ -4,9 +4,11 @@ namespace App\Livewire\Project\Database; use App\Models\S3Storage; use App\Models\Server; +use App\Models\Service; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; +use Livewire\Attributes\Computed; use Livewire\Component; class Import extends Component @@ -101,11 +103,23 @@ class Import extends Component public bool $unsupported = false; - public $resource; + // Store IDs instead of models for proper Livewire serialization + public ?int $resourceId = null; - public $parameters; + public ?string $resourceType = null; - public $containers; + public ?int $serverId = null; + + // View-friendly properties to avoid computed property access in Blade + public string $resourceUuid = ''; + + public string $resourceStatus = ''; + + public string $resourceDbType = ''; + + public array $parameters = []; + + public array $containers = []; public bool $scpInProgress = false; @@ -121,8 +135,6 @@ class Import extends Component public bool $error = false; - public Server $server; - public string $container; public array $importCommands = []; @@ -144,7 +156,7 @@ class Import extends Component public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive='; // S3 Restore properties - public $availableS3Storages = []; + public array $availableS3Storages = []; public ?int $s3StorageId = null; @@ -152,6 +164,26 @@ class Import extends Component public ?int $s3FileSize = null; + #[Computed] + public function resource() + { + if ($this->resourceId === null || $this->resourceType === null) { + return null; + } + + return $this->resourceType::find($this->resourceId); + } + + #[Computed] + public function server() + { + if ($this->serverId === null) { + return null; + } + + return Server::find($this->serverId); + } + public function getListeners() { $userId = Auth::id(); @@ -176,8 +208,23 @@ class Import extends Component public function updatedDumpAll($value) { - switch ($this->resource->getMorphClass()) { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === \App\Models\ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } + } + + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: + case 'mariadb': if ($value === true) { $this->mariadbRestoreCommand = <<<'EOD' for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do @@ -193,6 +240,7 @@ EOD; } break; case \App\Models\StandaloneMysql::class: + case 'mysql': if ($value === true) { $this->mysqlRestoreCommand = <<<'EOD' for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do @@ -208,6 +256,7 @@ EOD; } break; case \App\Models\StandalonePostgresql::class: + case 'postgresql': if ($value === true) { $this->postgresqlRestoreCommand = <<<'EOD' psql -U $POSTGRES_USER -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \ @@ -225,30 +274,99 @@ EOD; public function getContainers() { - $this->containers = collect(); - if (! data_get($this->parameters, 'database_uuid')) { - abort(404); - } - $resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id')); - if (is_null($resource)) { + $this->containers = []; + $teamId = data_get(auth()->user()->currentTeam(), 'id'); + + // Try to find resource by route parameter + $databaseUuid = data_get($this->parameters, 'database_uuid'); + $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid'); + + $resource = null; + if ($databaseUuid) { + // Standalone database route + $resource = getResourceByUuid($databaseUuid, $teamId); + if (is_null($resource)) { + abort(404); + } + } elseif ($stackServiceUuid) { + // ServiceDatabase route - look up the service database + $serviceUuid = data_get($this->parameters, 'service_uuid'); + $service = Service::whereUuid($serviceUuid)->first(); + if (! $service) { + abort(404); + } + $resource = $service->databases()->whereUuid($stackServiceUuid)->first(); + if (is_null($resource)) { + abort(404); + } + } else { abort(404); } + $this->authorize('view', $resource); - $this->resource = $resource; - $this->server = $this->resource->destination->server; - $this->container = $this->resource->uuid; - if (str(data_get($this, 'resource.status'))->startsWith('running')) { - $this->containers->push($this->container); + + // Store IDs for Livewire serialization + $this->resourceId = $resource->id; + $this->resourceType = get_class($resource); + + // Store view-friendly properties + $this->resourceStatus = $resource->status ?? ''; + + // Handle ServiceDatabase server access differently + if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $server = $resource->service?->server; + if (! $server) { + abort(404, 'Server not found for this service database.'); + } + $this->serverId = $server->id; + $this->container = $resource->name.'-'.$resource->service->uuid; + $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID + + // Determine database type for ServiceDatabase + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'postgres')) { + $this->resourceDbType = 'standalone-postgresql'; + } elseif (str_contains($dbType, 'mysql')) { + $this->resourceDbType = 'standalone-mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $this->resourceDbType = 'standalone-mariadb'; + } elseif (str_contains($dbType, 'mongo')) { + $this->resourceDbType = 'standalone-mongodb'; + } else { + $this->resourceDbType = $dbType; + } + } else { + $server = $resource->destination?->server; + if (! $server) { + abort(404, 'Server not found for this database.'); + } + $this->serverId = $server->id; + $this->container = $resource->uuid; + $this->resourceUuid = $resource->uuid; + $this->resourceDbType = $resource->type(); + } + + if (str($resource->status)->startsWith('running')) { + $this->containers[] = $this->container; } if ( - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + $resource->getMorphClass() === \App\Models\StandaloneRedis::class || + $resource->getMorphClass() === \App\Models\StandaloneKeydb::class || + $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || + $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class ) { $this->unsupported = true; } + + // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.) + if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $dbType = $resource->databaseType(); + if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') || + str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) { + $this->unsupported = true; + } + } } public function checkFile() @@ -261,6 +379,12 @@ EOD; return; } + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $escapedPath = escapeshellarg($this->customLocation); $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false); @@ -286,15 +410,22 @@ EOD; return; } + + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $this->importRunning = true; $this->importCommands = []; - $backupFileName = "upload/{$this->resource->uuid}/restore"; + $backupFileName = "upload/{$this->resourceUuid}/restore"; // Check if an uploaded file exists first (takes priority over custom location) if (Storage::exists($backupFileName)) { $path = Storage::path($backupFileName); - $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid; + $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid; instant_scp($path, $tmpPath, $this->server); Storage::delete($backupFileName); $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}"; @@ -305,7 +436,7 @@ EOD; return; } - $tmpPath = '/tmp/restore_'.$this->resource->uuid; + $tmpPath = '/tmp/restore_'.$this->resourceUuid; $escapedCustomLocation = escapeshellarg($this->customLocation); $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}"; } else { @@ -315,7 +446,7 @@ EOD; } // Copy the restore command to a script file - $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; $restoreCommand = $this->buildRestoreCommand($tmpPath); @@ -355,9 +486,11 @@ EOD; try { $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description']) ->where('is_usable', true) - ->get(); + ->get() + ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description]) + ->toArray(); } catch (\Throwable $e) { - $this->availableS3Storages = collect(); + $this->availableS3Storages = []; } } @@ -460,6 +593,12 @@ EOD; return; } + if (! $this->server) { + $this->dispatch('error', 'Server not found. Please refresh the page.'); + + return; + } + try { $this->importRunning = true; @@ -493,14 +632,18 @@ EOD; $fullImageName = "{$helperImage}:{$latestVersion}"; // Get the database destination network - $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) { + $destinationNetwork = $this->resource->service->destination->network ?? 'coolify'; + } else { + $destinationNetwork = $this->resource->destination->network ?? 'coolify'; + } // Generate unique names for this operation - $containerName = "s3-restore-{$this->resource->uuid}"; + $containerName = "s3-restore-{$this->resourceUuid}"; $helperTmpPath = '/tmp/'.basename($cleanPath); - $serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath); - $containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath); - $scriptPath = "/tmp/restore_{$this->resource->uuid}.sh"; + $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath); + $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath); + $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh"; // Prepare all commands in sequence $commands = []; @@ -575,8 +718,25 @@ EOD; public function buildRestoreCommand(string $tmpPath): string { - switch ($this->resource->getMorphClass()) { + $morphClass = $this->resource->getMorphClass(); + + // Handle ServiceDatabase by checking the database type + if ($morphClass === \App\Models\ServiceDatabase::class) { + $dbType = $this->resource->databaseType(); + if (str_contains($dbType, 'mysql')) { + $morphClass = 'mysql'; + } elseif (str_contains($dbType, 'mariadb')) { + $morphClass = 'mariadb'; + } elseif (str_contains($dbType, 'postgres')) { + $morphClass = 'postgresql'; + } elseif (str_contains($dbType, 'mongo')) { + $morphClass = 'mongodb'; + } + } + + switch ($morphClass) { case \App\Models\StandaloneMariadb::class: + case 'mariadb': $restoreCommand = $this->mariadbRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD"; @@ -585,6 +745,7 @@ EOD; } break; case \App\Models\StandaloneMysql::class: + case 'mysql': $restoreCommand = $this->mysqlRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD"; @@ -593,6 +754,7 @@ EOD; } break; case \App\Models\StandalonePostgresql::class: + case 'postgresql': $restoreCommand = $this->postgresqlRestoreCommand; if ($this->dumpAll) { $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres"; @@ -601,6 +763,7 @@ EOD; } break; case \App\Models\StandaloneMongodb::class: + case 'mongodb': $restoreCommand = $this->mongodbRestoreCommand; if ($this->dumpAll === false) { $restoreCommand .= "{$tmpPath}"; 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 @@ class DeleteEnvironment extends Component 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 @@ class DeleteProject extends Component 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 @@ class EnvironmentEdit extends Component { 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 @@ class DockerImage extends Component '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 @@ class EmptyProject extends Component '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 deleted file mode 100644 index 1e183c6bc..000000000 --- a/app/Livewire/Project/Service/Database.php +++ /dev/null @@ -1,228 +0,0 @@ - 'nullable', - 'description' => 'nullable', - 'image' => 'required', - 'excludeFromStatus' => 'required|boolean', - 'publicPort' => 'nullable|integer', - 'isPublic' => 'required|boolean', - 'isLogDrainEnabled' => 'required|boolean', - ]; - - public function render() - { - return view('livewire.project.service.database'); - } - - public function mount() - { - try { - $this->parameters = get_route_parameters(); - $this->authorize('view', $this->database); - if ($this->database->is_public) { - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - } - $this->refreshFileStorages(); - $this->syncData(false); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - private function syncData(bool $toModel = false): void - { - if ($toModel) { - $this->database->human_name = $this->humanName; - $this->database->description = $this->description; - $this->database->image = $this->image; - $this->database->exclude_from_status = $this->excludeFromStatus; - $this->database->public_port = $this->publicPort; - $this->database->is_public = $this->isPublic; - $this->database->is_log_drain_enabled = $this->isLogDrainEnabled; - } else { - $this->humanName = $this->database->human_name; - $this->description = $this->database->description; - $this->image = $this->database->image; - $this->excludeFromStatus = $this->database->exclude_from_status ?? false; - $this->publicPort = $this->database->public_port; - $this->isPublic = $this->database->is_public ?? false; - $this->isLogDrainEnabled = $this->database->is_log_drain_enabled ?? false; - } - } - - public function delete($password) - { - try { - $this->authorize('delete', $this->database); - - if (! verifyPasswordConfirmation($password, $this)) { - return; - } - - $this->database->delete(); - $this->dispatch('success', 'Database deleted.'); - - return redirect()->route('project.service.configuration', $this->parameters); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveExclude() - { - try { - $this->authorize('update', $this->database); - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveLogDrain() - { - try { - $this->authorize('update', $this->database); - if (! $this->database->service->destination->server->isLogDrainEnabled()) { - $this->isLogDrainEnabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - - return; - } - $this->submit(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function convertToApplication() - { - try { - $this->authorize('update', $this->database); - $service = $this->database->service; - $serviceDatabase = $this->database; - - // Check if application with same name already exists - if ($service->applications()->where('name', $serviceDatabase->name)->exists()) { - throw new \Exception('An application with this name already exists.'); - } - - // Create new parameters removing database_uuid - $redirectParams = collect($this->parameters) - ->except('database_uuid') - ->all(); - - DB::transaction(function () use ($service, $serviceDatabase) { - $service->applications()->create([ - 'name' => $serviceDatabase->name, - 'human_name' => $serviceDatabase->human_name, - 'description' => $serviceDatabase->description, - 'exclude_from_status' => $serviceDatabase->exclude_from_status, - 'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled, - 'image' => $serviceDatabase->image, - 'service_id' => $service->id, - 'is_migrated' => true, - ]); - $serviceDatabase->delete(); - }); - - return redirect()->route('project.service.configuration', $redirectParams); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSave() - { - try { - $this->authorize('update', $this->database); - if ($this->isPublic && ! $this->publicPort) { - $this->dispatch('error', 'Public port is required.'); - $this->isPublic = false; - - return; - } - $this->syncData(true); - if ($this->database->is_public) { - if (! str($this->database->status)->startsWith('running')) { - $this->dispatch('error', 'Database must be started to be publicly accessible.'); - $this->isPublic = false; - $this->database->is_public = false; - - return; - } - StartDatabaseProxy::run($this->database); - $this->db_url_public = $this->database->getServiceDatabaseUrl(); - $this->dispatch('success', 'Database is now publicly accessible.'); - } else { - StopDatabaseProxy::run($this->database); - $this->db_url_public = null; - $this->dispatch('success', 'Database is no longer publicly accessible.'); - } - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function refreshFileStorages() - { - $this->fileStorages = $this->database->fileStorages()->get(); - } - - public function submit() - { - try { - $this->authorize('update', $this->database); - $this->validate(); - $this->syncData(true); - $this->database->save(); - $this->database->refresh(); - $this->syncData(false); - updateCompose($this->database); - $this->dispatch('success', 'Database saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } finally { - $this->dispatch('generateDockerCompose'); - } - } -} diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php new file mode 100644 index 000000000..826a6c1ff --- /dev/null +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -0,0 +1,64 @@ + '$refresh']; + + public function mount() + { + try { + $this->parameters = get_route_parameters(); + $this->query = request()->query(); + $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); + if (! $this->service) { + return redirect()->route('dashboard'); + } + $this->authorize('view', $this->service); + + $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); + if (! $this->serviceDatabase) { + return redirect()->route('project.service.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } + + // Check if backups are supported for this database + if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) { + return redirect()->route('project.service.index', $this->parameters); + } + + // Check if import is supported for this database type + $dbType = $this->serviceDatabase->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.service.database-backups'); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 8d37d3e31..360282911 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -2,12 +2,17 @@ namespace App\Livewire\Project\Service; +use App\Actions\Database\StartDatabaseProxy; +use App\Actions\Database\StopDatabaseProxy; +use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Component; +use Spatie\Url\Url; class Index extends Component { @@ -19,6 +24,10 @@ class Index extends Component public ?ServiceDatabase $serviceDatabase = null; + public ?string $resourceType = null; + + public ?string $currentRoute = null; + public array $parameters; public array $query; @@ -27,7 +36,67 @@ class Index extends Component public $s3s; - protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh']; + public ?Server $server = null; + + // Database-specific properties + public ?string $db_url_public = null; + + public $fileStorages; + + public ?string $humanName = null; + + public ?string $description = null; + + public ?string $image = null; + + public bool $excludeFromStatus = false; + + public ?int $publicPort = null; + + public bool $isPublic = false; + + public bool $isLogDrainEnabled = false; + + public bool $isImportSupported = false; + + // Application-specific properties + public $docker_cleanup = true; + + public $delete_volumes = true; + + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public $showPortWarningModal = false; + + public $forceRemovePort = false; + + public $requiredPort = null; + + public ?string $fqdn = null; + + public bool $isGzipEnabled = false; + + public bool $isStripprefixEnabled = false; + + protected $listeners = ['generateDockerCompose', 'refreshScheduledBackups' => '$refresh', 'refreshFileStorages']; + + protected $rules = [ + 'humanName' => 'nullable', + 'description' => 'nullable', + 'image' => 'required', + 'excludeFromStatus' => 'required|boolean', + 'publicPort' => 'nullable|integer', + 'isPublic' => 'required|boolean', + 'isLogDrainEnabled' => 'required|boolean', + // Application-specific rules + 'fqdn' => 'nullable', + 'isGzipEnabled' => 'nullable|boolean', + 'isStripprefixEnabled' => 'nullable|boolean', + ]; public function mount() { @@ -35,6 +104,7 @@ class Index extends Component $this->services = collect([]); $this->parameters = get_route_parameters(); $this->query = request()->query(); + $this->currentRoute = request()->route()->getName(); $this->service = Service::whereUuid($this->parameters['service_uuid'])->first(); if (! $this->service) { return redirect()->route('dashboard'); @@ -43,10 +113,21 @@ class Index extends Component $service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first(); if ($service) { $this->serviceApplication = $service; + $this->resourceType = 'application'; $this->serviceApplication->getFilesFromServer(); + $this->initializeApplicationProperties(); } else { $this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); + if (! $this->serviceDatabase) { + return redirect()->route('project.service.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } + $this->resourceType = 'database'; $this->serviceDatabase->getFilesFromServer(); + $this->initializeDatabaseProperties(); } $this->s3s = currentTeam()->s3s; } catch (\Throwable $e) { @@ -54,6 +135,42 @@ class Index extends Component } } + private function initializeDatabaseProperties(): void + { + $this->server = $this->serviceDatabase->service->destination->server; + if ($this->serviceDatabase->is_public) { + $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); + } + $this->refreshFileStorages(); + $this->syncDatabaseData(false); + + // Check if import is supported for this database type + $dbType = $this->serviceDatabase->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + } + + private function syncDatabaseData(bool $toModel = false): void + { + if ($toModel) { + $this->serviceDatabase->human_name = $this->humanName; + $this->serviceDatabase->description = $this->description; + $this->serviceDatabase->image = $this->image; + $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; + $this->serviceDatabase->public_port = $this->publicPort; + $this->serviceDatabase->is_public = $this->isPublic; + $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; + } else { + $this->humanName = $this->serviceDatabase->human_name; + $this->description = $this->serviceDatabase->description; + $this->image = $this->serviceDatabase->image; + $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; + $this->publicPort = $this->serviceDatabase->public_port; + $this->isPublic = $this->serviceDatabase->is_public ?? false; + $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; + } + } + public function generateDockerCompose() { try { @@ -64,6 +181,375 @@ class Index extends Component } } + // Database-specific methods + public function refreshFileStorages() + { + if ($this->serviceDatabase) { + $this->fileStorages = $this->serviceDatabase->fileStorages()->get(); + } + } + + public function deleteDatabase($password) + { + try { + $this->authorize('delete', $this->serviceDatabase); + + if (! verifyPasswordConfirmation($password, $this)) { + return; + } + + $this->serviceDatabase->delete(); + $this->dispatch('success', 'Database deleted.'); + + return redirectRoute($this, 'project.service.configuration', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveExclude() + { + try { + $this->authorize('update', $this->serviceDatabase); + $this->submitDatabase(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveLogDrain() + { + try { + $this->authorize('update', $this->serviceDatabase); + if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + + return; + } + $this->submitDatabase(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function convertToApplication() + { + try { + $this->authorize('update', $this->serviceDatabase); + $service = $this->serviceDatabase->service; + $serviceDatabase = $this->serviceDatabase; + + // Check if application with same name already exists + if ($service->applications()->where('name', $serviceDatabase->name)->exists()) { + throw new \Exception('An application with this name already exists.'); + } + + // Create new parameters removing database_uuid + $redirectParams = collect($this->parameters) + ->except('database_uuid') + ->all(); + + DB::transaction(function () use ($service, $serviceDatabase) { + $service->applications()->create([ + 'name' => $serviceDatabase->name, + 'human_name' => $serviceDatabase->human_name, + 'description' => $serviceDatabase->description, + 'exclude_from_status' => $serviceDatabase->exclude_from_status, + 'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled, + 'image' => $serviceDatabase->image, + 'service_id' => $service->id, + 'is_migrated' => true, + ]); + $serviceDatabase->delete(); + }); + + return redirectRoute($this, 'project.service.configuration', $redirectParams); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->authorize('update', $this->serviceDatabase); + if ($this->isPublic && ! $this->publicPort) { + $this->dispatch('error', 'Public port is required.'); + $this->isPublic = false; + + return; + } + $this->syncDatabaseData(true); + if ($this->serviceDatabase->is_public) { + if (! str($this->serviceDatabase->status)->startsWith('running')) { + $this->dispatch('error', 'Database must be started to be publicly accessible.'); + $this->isPublic = false; + $this->serviceDatabase->is_public = false; + + return; + } + StartDatabaseProxy::run($this->serviceDatabase); + $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); + $this->dispatch('success', 'Database is now publicly accessible.'); + } else { + StopDatabaseProxy::run($this->serviceDatabase); + $this->db_url_public = null; + $this->dispatch('success', 'Database is no longer publicly accessible.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function submitDatabase() + { + try { + $this->authorize('update', $this->serviceDatabase); + $this->validate(); + $this->syncDatabaseData(true); + $this->serviceDatabase->save(); + $this->serviceDatabase->refresh(); + $this->syncDatabaseData(false); + updateCompose($this->serviceDatabase); + $this->dispatch('success', 'Database saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } finally { + $this->dispatch('generateDockerCompose'); + } + } + + // Application-specific methods + private function initializeApplicationProperties(): void + { + $this->requiredPort = $this->serviceApplication->getRequiredPort(); + $this->syncApplicationData(false); + } + + private function syncApplicationData(bool $toModel = false): void + { + if ($toModel) { + $this->serviceApplication->human_name = $this->humanName; + $this->serviceApplication->description = $this->description; + $this->serviceApplication->fqdn = $this->fqdn; + $this->serviceApplication->image = $this->image; + $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; + $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; + $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; + $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + } else { + $this->humanName = $this->serviceApplication->human_name; + $this->description = $this->serviceApplication->description; + $this->fqdn = $this->serviceApplication->fqdn; + $this->image = $this->serviceApplication->image; + $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); + $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); + $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); + $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); + } + } + + public function instantSaveApplication() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->submitApplication(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveApplicationSettings() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; + $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; + $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; + $this->serviceApplication->save(); + $this->dispatch('success', 'Settings saved.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSaveApplicationAdvanced() + { + try { + $this->authorize('update', $this->serviceApplication); + if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) { + $this->isLogDrainEnabled = false; + $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); + + return; + } + $this->syncApplicationData(true); + $this->serviceApplication->save(); + $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function deleteApplication($password) + { + try { + $this->authorize('delete', $this->serviceApplication); + + if (! verifyPasswordConfirmation($password, $this)) { + return; + } + + $this->serviceApplication->delete(); + $this->dispatch('success', 'Application deleted.'); + + return redirect()->route('project.service.configuration', $this->parameters); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function convertToDatabase() + { + try { + $this->authorize('update', $this->serviceApplication); + $service = $this->serviceApplication->service; + $serviceApplication = $this->serviceApplication; + + if ($service->databases()->where('name', $serviceApplication->name)->exists()) { + throw new \Exception('A database with this name already exists.'); + } + + $redirectParams = collect($this->parameters) + ->except('database_uuid') + ->all(); + DB::transaction(function () use ($service, $serviceApplication) { + $service->databases()->create([ + 'name' => $serviceApplication->name, + 'human_name' => $serviceApplication->human_name, + 'description' => $serviceApplication->description, + 'exclude_from_status' => $serviceApplication->exclude_from_status, + 'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled, + 'image' => $serviceApplication->image, + 'service_id' => $service->id, + 'is_migrated' => true, + ]); + $serviceApplication->delete(); + }); + + return redirect()->route('project.service.configuration', $redirectParams); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submitApplication(); + } + + public function confirmRemovePort() + { + $this->forceRemovePort = true; + $this->showPortWarningModal = false; + $this->submitApplication(); + } + + public function cancelRemovePort() + { + $this->showPortWarningModal = false; + $this->syncApplicationData(false); + } + + public function submitApplication() + { + try { + $this->authorize('update', $this->serviceApplication); + $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); + $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); + $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); + Url::fromString($domain, ['http', 'https']); + + return str($domain)->lower(); + }); + $this->fqdn = $domains->unique()->implode(','); + $warning = sslipDomainWarning($this->fqdn); + if ($warning) { + $this->dispatch('warning', __('warning.sslipdomain')); + } + + $this->syncApplicationData(true); + + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->serviceApplication); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + $this->forceSaveDomains = false; + } + + if (! $this->forceRemovePort) { + $requiredPort = $this->serviceApplication->getRequiredPort(); + + if ($requiredPort !== null) { + $fqdns = str($this->fqdn)->trim()->explode(','); + $missingPort = false; + + foreach ($fqdns as $fqdn) { + $fqdn = trim($fqdn); + if (empty($fqdn)) { + continue; + } + + $port = ServiceApplication::extractPortFromUrl($fqdn); + if ($port === null) { + $missingPort = true; + break; + } + } + + if ($missingPort) { + $this->requiredPort = $requiredPort; + $this->showPortWarningModal = true; + + return; + } + } + } else { + $this->forceRemovePort = false; + } + + $this->validate(); + $this->serviceApplication->save(); + $this->serviceApplication->refresh(); + $this->syncApplicationData(false); + updateCompose($this->serviceApplication); + if (str($this->serviceApplication->fqdn)->contains(',')) { + $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); + } else { + ! $warning && $this->dispatch('success', 'Service saved.'); + } + $this->dispatch('generateDockerCompose'); + } catch (\Throwable $e) { + $originalFqdn = $this->serviceApplication->getOriginal('fqdn'); + if ($originalFqdn !== $this->serviceApplication->fqdn) { + $this->serviceApplication->fqdn = $originalFqdn; + $this->syncApplicationData(false); + } + + return handleError($e, $this); + } + } + public function render() { return view('livewire.project.service.index'); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php deleted file mode 100644 index 4302c05fb..000000000 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ /dev/null @@ -1,345 +0,0 @@ - 'nullable', - 'description' => 'nullable', - 'fqdn' => 'nullable', - 'image' => 'string|nullable', - 'excludeFromStatus' => 'required|boolean', - 'application.required_fqdn' => 'required|boolean', - 'isLogDrainEnabled' => 'nullable|boolean', - 'isGzipEnabled' => 'nullable|boolean', - 'isStripprefixEnabled' => 'nullable|boolean', - ]; - - public function instantSave() - { - try { - $this->authorize('update', $this->application); - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveSettings() - { - try { - $this->authorize('update', $this->application); - // Save checkbox states without port validation - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->save(); - $this->dispatch('success', 'Settings saved.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function instantSaveAdvanced() - { - try { - $this->authorize('update', $this->application); - if (! $this->application->service->destination->server->isLogDrainEnabled()) { - $this->isLogDrainEnabled = false; - $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); - - return; - } - // Sync component properties to model - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - $this->application->save(); - $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function delete($password) - { - try { - $this->authorize('delete', $this->application); - - if (! verifyPasswordConfirmation($password, $this)) { - return; - } - - $this->application->delete(); - $this->dispatch('success', 'Application deleted.'); - - return redirect()->route('project.service.configuration', $this->parameters); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function mount() - { - try { - $this->parameters = get_route_parameters(); - $this->authorize('view', $this->application); - $this->requiredPort = $this->application->getRequiredPort(); - $this->syncData(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function confirmRemovePort() - { - $this->forceRemovePort = true; - $this->showPortWarningModal = false; - $this->submit(); - } - - public function cancelRemovePort() - { - $this->showPortWarningModal = false; - $this->syncData(); // Reset to original FQDN - } - - public function syncData(bool $toModel = false): void - { - if ($toModel) { - $this->validate(); - - // Sync to model - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - - $this->application->save(); - } else { - // Sync from model - $this->humanName = $this->application->human_name; - $this->description = $this->application->description; - $this->fqdn = $this->application->fqdn; - $this->image = $this->application->image; - $this->excludeFromStatus = data_get($this->application, 'exclude_from_status', false); - $this->isLogDrainEnabled = data_get($this->application, 'is_log_drain_enabled', false); - $this->isGzipEnabled = data_get($this->application, 'is_gzip_enabled', true); - $this->isStripprefixEnabled = data_get($this->application, 'is_stripprefix_enabled', true); - } - } - - public function convertToDatabase() - { - try { - $this->authorize('update', $this->application); - $service = $this->application->service; - $serviceApplication = $this->application; - - // Check if database with same name already exists - if ($service->databases()->where('name', $serviceApplication->name)->exists()) { - throw new \Exception('A database with this name already exists.'); - } - - $redirectParams = collect($this->parameters) - ->except('database_uuid') - ->all(); - DB::transaction(function () use ($service, $serviceApplication) { - $service->databases()->create([ - 'name' => $serviceApplication->name, - 'human_name' => $serviceApplication->human_name, - 'description' => $serviceApplication->description, - 'exclude_from_status' => $serviceApplication->exclude_from_status, - 'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled, - 'image' => $serviceApplication->image, - 'service_id' => $service->id, - 'is_migrated' => true, - ]); - $serviceApplication->delete(); - }); - - return redirect()->route('project.service.configuration', $redirectParams); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - - public function confirmDomainUsage() - { - $this->forceSaveDomains = true; - $this->showDomainConflictModal = false; - $this->submit(); - } - - public function submit() - { - try { - $this->authorize('update', $this->application); - $this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); - $this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); - $domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { - $domain = trim($domain); - Url::fromString($domain, ['http', 'https']); - - return str($domain)->lower(); - }); - $this->fqdn = $domains->unique()->implode(','); - $warning = sslipDomainWarning($this->fqdn); - if ($warning) { - $this->dispatch('warning', __('warning.sslipdomain')); - } - // Sync to model for domain conflict check (without validation) - $this->application->human_name = $this->humanName; - $this->application->description = $this->description; - $this->application->fqdn = $this->fqdn; - $this->application->image = $this->image; - $this->application->exclude_from_status = $this->excludeFromStatus; - $this->application->is_log_drain_enabled = $this->isLogDrainEnabled; - $this->application->is_gzip_enabled = $this->isGzipEnabled; - $this->application->is_stripprefix_enabled = $this->isStripprefixEnabled; - // Check for domain conflicts if not forcing save - if (! $this->forceSaveDomains) { - $result = checkDomainUsage(resource: $this->application); - if ($result['hasConflicts']) { - $this->domainConflicts = $result['conflicts']; - $this->showDomainConflictModal = true; - - return; - } - } else { - // Reset the force flag after using it - $this->forceSaveDomains = false; - } - - // Check for required port - if (! $this->forceRemovePort) { - $requiredPort = $this->application->getRequiredPort(); - - if ($requiredPort !== null) { - // Check if all FQDNs have a port - $fqdns = str($this->fqdn)->trim()->explode(','); - $missingPort = false; - - foreach ($fqdns as $fqdn) { - $fqdn = trim($fqdn); - if (empty($fqdn)) { - continue; - } - - $port = ServiceApplication::extractPortFromUrl($fqdn); - if ($port === null) { - $missingPort = true; - break; - } - } - - if ($missingPort) { - $this->requiredPort = $requiredPort; - $this->showPortWarningModal = true; - - return; - } - } - } else { - // Reset the force flag after using it - $this->forceRemovePort = false; - } - - $this->validate(); - $this->application->save(); - $this->application->refresh(); - $this->syncData(); - updateCompose($this->application); - if (str($this->application->fqdn)->contains(',')) { - $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); - } else { - ! $warning && $this->dispatch('success', 'Service saved.'); - } - $this->dispatch('generateDockerCompose'); - } catch (\Throwable $e) { - $originalFqdn = $this->application->getOriginal('fqdn'); - if ($originalFqdn !== $this->application->fqdn) { - $this->application->fqdn = $originalFqdn; - $this->syncData(); - } - - return handleError($e, $this); - } - } - - public function render() - { - return view('livewire.project.service.service-application-view', [ - 'checkboxes' => [ - ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], - ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], - // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], - // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], - // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] - ], - ]); - } -} 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 @@ class Danger extends Component $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 @@ class Destination extends Component 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/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index 8fda35a4a..22605e1bb 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -21,6 +21,10 @@ use Livewire\Component; class GetLogs extends Component { + public const MAX_LOG_LINES = 50000; + + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB + public string $outputs = ''; public string $errors = ''; @@ -123,6 +127,9 @@ class GetLogs extends Component if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { $this->numberOfLines = 1000; } + if ($this->numberOfLines > self::MAX_LOG_LINES) { + $this->numberOfLines = self::MAX_LOG_LINES; + } if ($this->container) { if ($this->showTimeStamps) { if ($this->server->isSwarm()) { @@ -159,10 +166,12 @@ class GetLogs extends Component } // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) - $newOutputs = ''; - Process::run($sshCommand, function (string $type, string $output) use (&$newOutputs) { - $newOutputs .= removeAnsiColors($output); + // Use array accumulation + implode for O(n) instead of O(n²) string concatenation + $logChunks = []; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { + $logChunks[] = removeAnsiColors($output); }); + $newOutputs = implode('', $logChunks); if ($this->showTimeStamps) { $newOutputs = str($newOutputs)->split('/\n/')->sort(function ($a, $b) { @@ -183,6 +192,79 @@ class GetLogs extends Component return sanitizeLogsForExport($this->outputs); } + public function downloadAllLogs(): string + { + if (! $this->server->isFunctional() || ! $this->container) { + return ''; + } + + if ($this->showTimeStamps) { + if ($this->server->isSwarm()) { + $command = "docker service logs -t {$this->container}"; + } else { + $command = "docker logs -t {$this->container}"; + } + } else { + if ($this->server->isSwarm()) { + $command = "docker service logs {$this->container}"; + } else { + $command = "docker logs {$this->container}"; + } + } + + if ($this->server->isNonRoot()) { + $command = parseCommandsByLineForSudo(collect($command), $this->server); + $command = $command[0]; + } + + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + + // Use array accumulation + implode for O(n) instead of O(n²) string concatenation + // Enforce 50MB size limit to prevent memory exhaustion from large logs + $logChunks = []; + $accumulatedBytes = 0; + $truncated = false; + + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $output = removeAnsiColors($output); + $outputBytes = strlen($output); + + if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { + $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; + if ($remaining > 0) { + $logChunks[] = substr($output, 0, $remaining); + } + $truncated = true; + + return; + } + + $logChunks[] = $output; + $accumulatedBytes += $outputBytes; + }); + + $allLogs = implode('', $logChunks); + + if ($truncated) { + $allLogs .= "\n\n[... Output truncated at 50MB limit ...]"; + } + + if ($this->showTimeStamps) { + $allLogs = str($allLogs)->split('/\n/')->sort(function ($a, $b) { + $a = explode(' ', $a); + $b = explode(' ', $b); + + return $a[0] <=> $b[0]; + })->join("\n"); + } + + return sanitizeLogsForExport($allLogs); + } + public function render() { return view('livewire.project.shared.get-logs'); 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 @@ class Terminal extends Component $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 @@ class Show extends Component '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 @@ class Show extends Component 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 @@ class Create extends Component 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 @@ class Show extends Component $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 @@ class Delete extends Component $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 @@ class ByHetzner extends Component ]); 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 @@ class ByIp extends Component $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/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fb9c91263..16361ce79 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -3,16 +3,12 @@ namespace App\Livewire\Settings; use App\Models\InstanceSettings; -use App\Models\Server; use App\Rules\ValidIpOrCidr; use Livewire\Attributes\Validate; use Livewire\Component; class Advanced extends Component { - #[Validate('required')] - public Server $server; - public InstanceSettings $settings; #[Validate('boolean')] @@ -44,7 +40,6 @@ class Advanced extends Component public function rules() { return [ - 'server' => 'required', 'is_registration_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', @@ -62,7 +57,6 @@ class Advanced extends Component if (! isInstanceAdmin()) { return redirect()->route('dashboard'); } - $this->server = Server::findOrFail(0); $this->settings = instanceSettings(); $this->custom_dns_servers = $this->settings->custom_dns_servers; $this->allowed_ips = $this->settings->allowed_ips; diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 7a96eabb2..ded1c1a4d 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -12,7 +12,7 @@ class Index extends Component { public InstanceSettings $settings; - public Server $server; + public ?Server $server = null; #[Validate('nullable|string|max:255')] public ?string $fqdn = null; @@ -57,7 +57,9 @@ class Index extends Component return redirect()->route('dashboard'); } $this->settings = instanceSettings(); - $this->server = Server::findOrFail(0); + if (! isCloud()) { + $this->server = Server::findOrFail(0); + } $this->fqdn = $this->settings->fqdn; $this->public_port_min = $this->settings->public_port_min; $this->public_port_max = $this->settings->public_port_max; @@ -80,7 +82,7 @@ class Index extends Component public function instantSave($isSave = true) { $this->validate(); - $this->settings->fqdn = $this->fqdn; + $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_max = $this->public_port_max; $this->settings->instance_name = $this->instance_name; @@ -119,9 +121,15 @@ class Index extends Component return; } + + // Trim FQDN to remove leading/trailing whitespace before validation + if ($this->fqdn) { + $this->fqdn = trim($this->fqdn); + } + $this->validate(); - if ($this->settings->is_dns_validation_enabled && $this->fqdn) { + if ($this->settings->is_dns_validation_enabled && $this->fqdn && $this->server) { if (! validateDNSEntry($this->fqdn, $this->server)) { $this->dispatch('error', "Validating DNS failed.

Make sure you have added the DNS records correctly.

{$this->fqdn}->{$this->server->ip}

Check this documentation for further help."); $error_show = true; @@ -145,7 +153,9 @@ class Index extends Component $this->instantSave(isSave: false); $this->settings->save(); - $this->server->setupDynamicProxyConfiguration(); + if ($this->server) { + $this->server->setupDynamicProxyConfiguration(); + } if (! $error_show) { $this->dispatch('success', 'Instance settings updated successfully!'); } @@ -163,6 +173,12 @@ class Index extends Component return; } + if (! $this->server) { + $this->dispatch('error', 'Server not available.'); + + return; + } + $version = $this->dev_helper_version ?: config('constants.coolify.helper_version'); if (empty($version)) { $this->dispatch('error', 'Please specify a version to build.'); diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index fe20763b6..01a67c38c 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -12,7 +12,7 @@ class Updates extends Component { public InstanceSettings $settings; - public Server $server; + public ?Server $server = null; #[Validate('string')] public string $auto_update_frequency; @@ -25,7 +25,9 @@ class Updates extends Component public function mount() { - $this->server = Server::findOrFail(0); + if (! isCloud()) { + $this->server = Server::findOrFail(0); + } $this->settings = instanceSettings(); $this->auto_update_frequency = $this->settings->auto_update_frequency; @@ -76,7 +78,9 @@ class Updates extends Component } $this->instantSave(); - $this->server->setupDynamicProxyConfiguration(); + if ($this->server) { + $this->server->setupDynamicProxyConfiguration(); + } } catch (\Exception $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 @@ class Create extends Component 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 @@ class Create extends Component $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 @@ class Create extends Component 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/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 45af53950..ee6d535e9 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -48,7 +48,7 @@ class InviteLink extends Component // Prevent privilege escalation: users cannot invite someone with higher privileges $userRole = auth()->user()->role(); - if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) { + if (is_null($userRole) || ($userRole === 'member' && in_array($this->role, ['admin', 'owner']))) { throw new \Exception('Members cannot invite admins or owners.'); } if ($userRole === 'admin' && $this->role === 'owner') { diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php index 96c98c637..b1f692365 100644 --- a/app/Livewire/Team/Member.php +++ b/app/Livewire/Team/Member.php @@ -71,11 +71,11 @@ class Member extends Component || Role::from($this->getMemberRole())->gt(auth()->user()->role())) { throw new \Exception('You are not authorized to perform this action.'); } + $teamId = currentTeam()->id; $this->member->teams()->detach(currentTeam()); + // Clear cache for the removed user - both old and new key formats Cache::forget("team:{$this->member->id}"); - Cache::remember('team:'.$this->member->id, 3600, function () { - return $this->member->teams()->first(); - }); + Cache::forget("user:{$this->member->id}:team:{$teamId}"); $this->dispatch('reloadWindow'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); diff --git a/app/Models/Application.php b/app/Models/Application.php index 5006d0ff8..2eab8df20 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -6,6 +6,7 @@ use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -111,7 +112,7 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -1584,6 +1585,11 @@ class Application extends BaseModel 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 +1601,7 @@ class Application extends BaseModel } 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 +1647,11 @@ class Application extends BaseModel '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."); } } @@ -1969,54 +1978,6 @@ class Application extends BaseModel return []; } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - if ($server->isMetricsEnabled()) { - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - if ($server->isMetricsEnabled()) { - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - } - public function getLimits(): array { return [ diff --git a/app/Models/Server.php b/app/Models/Server.php index be39e3f8d..e5f935b04 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -16,6 +16,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -103,7 +104,7 @@ use Visus\Cuid2\Cuid2; class Server extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; @@ -271,7 +272,7 @@ class Server extends BaseModel public static function destinationsByServer(string $server_id) { - $server = Server::ownedByCurrentTeam()->get()->where('id', $server_id)->firstOrFail(); + $server = Server::ownedByCurrentTeam()->findOrFail($server_id); $standaloneDocker = collect($server->standaloneDockers->all()); $swarmDocker = collect($server->swarmDockers->all()); @@ -667,51 +668,6 @@ $schema://$host { CheckAndStartSentinelJob::dispatch($this); } - public function getCpuMetrics(int $mins = 5) - { - if ($this->isMetricsEnabled()) { - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $cpu = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/cpu/history?from=$from'"], $this, false); - if (str($cpu)->contains('error')) { - $error = json_decode($cpu, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $cpu = json_decode($cpu, true); - - return collect($cpu)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - } - } - - public function getMemoryMetrics(int $mins = 5) - { - if ($this->isMetricsEnabled()) { - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $memory = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$this->settings->sentinel_token}\" http://localhost:8888/api/memory/history?from=$from'"], $this, false); - if (str($memory)->contains('error')) { - $error = json_decode($memory, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $memory = json_decode($memory, true); - $parsedCollection = collect($memory)->map(function ($metric) { - $usedPercent = $metric['usedPercent'] ?? 0.0; - - return [(int) $metric['time'], (float) $usedPercent]; - }); - - return $parsedCollection->toArray(); - } - } - public function getDiskUsage(): ?string { return instant_remote_process(['df / --output=pcent | tr -cd 0-9'], $this, false); diff --git a/app/Models/Service.php b/app/Models/Service.php index 2daf9c39d..b4ee17b6a 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1044,6 +1044,31 @@ class Service extends BaseModel $fields->put('Strapi', $data->toArray()); break; + case $image->contains('marckohlbrugge/sessy'): + $data = collect([]); + $username = $this->environment_variables()->where('key', 'SERVICE_USER_SESSY')->first(); + $password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_SESSY')->first(); + if ($username) { + $data = $data->merge([ + 'HTTP Auth Username' => [ + 'key' => data_get($username, 'key'), + 'value' => data_get($username, 'value'), + 'rules' => 'required', + ], + ]); + } + if ($password) { + $data = $data->merge([ + 'HTTP Auth Password' => [ + 'key' => data_get($password, 'key'), + 'value' => data_get($password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Sessy', $data->toArray()); + break; default: $data = collect([]); $admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first(); diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index a76d55abb..ee8a27162 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -320,50 +321,6 @@ class StandaloneClickhouse extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return false; diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index f5337b1d5..051a95a80 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -316,50 +317,6 @@ class StandaloneDragonfly extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return false; diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index ab24cae2c..6cd51edce 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -316,50 +317,6 @@ class StandaloneKeydb extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return false; diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index e48cfc1e6..f312473be 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -319,50 +320,6 @@ class StandaloneMariadb extends BaseModel return $this->morphMany(SslCertificate::class, 'resource'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return true; diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index 9e271b19a..d2d8fef12 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -341,50 +342,6 @@ class StandaloneMongodb extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return true; diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 377765697..c27b2a679 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -320,50 +321,6 @@ class StandaloneMysql extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return true; diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index d9993426a..73b00742a 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -337,51 +338,4 @@ class StandalonePostgresql extends BaseModel { return true; } - - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [ - (int) $metric['time'], - (float) ($metric['percent'] ?? 0.0), - ]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } } diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 684bcaeb7..46e8da434 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\ClearsGlobalSearchCache; +use App\Traits\HasMetrics; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -332,50 +333,6 @@ class StandaloneRedis extends BaseModel return $this->morphMany(ScheduledDatabaseBackup::class, 'database'); } - public function getCpuMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/cpu/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['percent']]; - }); - - return $parsedCollection->toArray(); - } - - public function getMemoryMetrics(int $mins = 5) - { - $server = $this->destination->server; - $container_name = $this->uuid; - $from = now()->subMinutes($mins)->toIso8601ZuluString(); - $metrics = instant_remote_process(["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" http://localhost:8888/api/container/{$container_name}/memory/history?from=$from'"], $server, false); - if (str($metrics)->contains('error')) { - $error = json_decode($metrics, true); - $error = data_get($error, 'error', 'Something is not okay, are you okay?'); - if ($error === 'Unauthorized') { - $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; - } - throw new \Exception($error); - } - $metrics = json_decode($metrics, true); - $parsedCollection = collect($metrics)->map(function ($metric) { - return [(int) $metric['time'], (float) $metric['used']]; - }); - - return $parsedCollection->toArray(); - } - public function isBackupSolutionAvailable() { return false; diff --git a/app/Models/User.php b/app/Models/User.php index b790efcf1..4561cddb2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Jobs\UpdateStripeCustomerEmailJob; use App\Notifications\Channels\SendsEmail; use App\Notifications\TransactionalEmails\ResetPassword as TransactionalEmailsResetPassword; use App\Traits\DeletesUserSessions; @@ -295,9 +296,10 @@ class User extends Authenticatable implements SendsEmail public function isInstanceAdmin() { - $found_root_team = Auth::user()->teams->filter(function ($team) { + $found_root_team = $this->teams->filter(function ($team) { if ($team->id == 0) { - if (! Auth::user()->isAdmin()) { + $role = $team->pivot->role; + if ($role !== 'admin' && $role !== 'owner') { return false; } @@ -310,32 +312,51 @@ class User extends Authenticatable implements SendsEmail return $found_root_team->count() > 0; } - public function currentTeam() + public function currentTeam(): ?Team { - return Cache::remember('team:'.Auth::id(), 3600, function () { - if (is_null(data_get(session('currentTeam'), 'id')) && Auth::user()->teams->count() > 0) { - return Auth::user()->teams[0]; - } + $sessionTeamId = data_get(session('currentTeam'), 'id'); - return Team::find(session('currentTeam')->id); + if (is_null($sessionTeamId)) { + return null; + } + + // Check if user actually belongs to this team + if (! $this->teams->contains('id', $sessionTeamId)) { + session()->forget('currentTeam'); + Cache::forget('user:'.$this->id.':team:'.$sessionTeamId); + + return null; + } + + return Cache::remember('user:'.$this->id.':team:'.$sessionTeamId, 3600, function () use ($sessionTeamId) { + return Team::find($sessionTeamId); }); } - public function otherTeams() - { - return Auth::user()->teams->filter(function ($team) { - return $team->id != currentTeam()->id; - }); - } - - public function role() + public function role(): ?string { if (data_get($this, 'pivot')) { return $this->pivot->role; } - $user = Auth::user()->teams->where('id', currentTeam()->id)->first(); - return data_get($user, 'pivot.role'); + $current = $this->currentTeam(); + if (is_null($current)) { + return null; + } + + $team = $this->teams->where('id', $current->id)->first(); + + return data_get($team, 'pivot.role'); + } + + /** + * Get the user's role in a specific team + */ + public function roleInTeam(int $teamId): ?string + { + $team = $this->teams->where('id', $teamId)->first(); + + return data_get($team, 'pivot.role'); } /** @@ -415,9 +436,10 @@ class User extends Authenticatable implements SendsEmail ]); // For cloud users, dispatch job to update Stripe customer email asynchronously - if (isCloud() && $this->currentTeam()->subscription) { - dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob( - $this->currentTeam(), + $currentTeam = $this->currentTeam(); + if (isCloud() && $currentTeam?->subscription) { + dispatch(new UpdateStripeCustomerEmailJob( + $currentTeam, $this->id, $newEmail, $oldEmail diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php new file mode 100644 index 000000000..667d58441 --- /dev/null +++ b/app/Traits/HasMetrics.php @@ -0,0 +1,74 @@ +getMetrics('cpu', $mins, 'percent'); + } + + public function getMemoryMetrics(int $mins = 5): ?array + { + $field = $this->isServerMetrics() ? 'usedPercent' : 'used'; + + return $this->getMetrics('memory', $mins, $field); + } + + private function getMetrics(string $type, int $mins, string $valueField): ?array + { + $server = $this->getMetricsServer(); + if (! $server->isMetricsEnabled()) { + return null; + } + + $from = now()->subMinutes($mins)->toIso8601ZuluString(); + $endpoint = $this->getMetricsEndpoint($type, $from); + + $response = instant_remote_process( + ["docker exec coolify-sentinel sh -c 'curl -H \"Authorization: Bearer {$server->settings->sentinel_token}\" {$endpoint}'"], + $server, + false + ); + + if (str($response)->contains('error')) { + $error = json_decode($response, true); + $error = data_get($error, 'error', 'Something is not okay, are you okay?'); + if ($error === 'Unauthorized') { + $error = 'Unauthorized, please check your metrics token or restart Sentinel to set a new token.'; + } + throw new \Exception($error); + } + + $metrics = collect(json_decode($response, true))->map(function ($metric) use ($valueField) { + return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)]; + })->toArray(); + + if ($mins > 60 && count($metrics) > 1000) { + $metrics = downsampleLTTB($metrics, 1000); + } + + return $metrics; + } + + private function isServerMetrics(): bool + { + return $this instanceof \App\Models\Server; + } + + private function getMetricsServer(): \App\Models\Server + { + return $this->isServerMetrics() ? $this : $this->destination->server; + } + + private function getMetricsEndpoint(string $type, string $from): string + { + $base = 'http://localhost:8888/api'; + if ($this->isServerMetrics()) { + return "{$base}/{$type}/history?from={$from}"; + } + + return "{$base}/container/{$this->uuid}/{$type}/history?from={$from}"; + } +} diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index bbbe2bc05..30ca36f2e 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -72,7 +72,7 @@ const SUPPORTED_OS = [ const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [ 'pgadmin', - 'postgresus', + 'databasus', 'redis-insight', ]; const NEEDS_TO_DISABLE_GZIP = [ diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index fc70aa7da..217c82929 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -175,7 +175,7 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) throw new \RuntimeException($errorMessage, $exitCode); } -function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection +function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null, bool $includeAll = false): Collection { if (is_null($application_deployment_queue)) { return collect([]); @@ -216,7 +216,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d $seenCommands = collect(); $formatted = collect($decoded); - if (! $is_debug_enabled) { + if (! $is_debug_enabled && ! $includeAll) { $formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e73328474..f61790f52 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -182,8 +182,11 @@ function refreshSession(?Team $team = null): void $team = User::find(Auth::id())->teams->first(); } } + // Clear old cache key format for backwards compatibility Cache::forget('team:'.Auth::id()); - Cache::remember('team:'.Auth::id(), 3600, function () use ($team) { + // Use new cache key format that includes team ID + Cache::forget('user:'.Auth::id().':team:'.$team->id); + Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) { return $team; }); session(['currentTeam' => $team]); @@ -384,7 +387,7 @@ function base_url(bool $withPort = true): string function isSubscribed() { - return isSubscriptionActive() || auth()->user()->isInstanceAdmin(); + return isSubscriptionActive(); } function isProduction(): bool @@ -548,7 +551,21 @@ function getResourceByUuid(string $uuid, ?int $teamId = null) return null; } $resource = queryResourcesByUuid($uuid); - if (! is_null($resource) && $resource->environment->project->team_id === $teamId) { + if (is_null($resource)) { + return null; + } + + // ServiceDatabase has a different relationship path: service->environment->project->team_id + if ($resource instanceof \App\Models\ServiceDatabase) { + if ($resource->service?->environment?->project?->team_id === $teamId) { + return $resource; + } + + return null; + } + + // Standard resources: environment->project->team_id + if ($resource->environment->project->team_id === $teamId) { return $resource; } @@ -635,6 +652,12 @@ function queryResourcesByUuid(string $uuid) return $clickhouse; } + // Check for ServiceDatabase by its own UUID + $serviceDatabase = ServiceDatabase::whereUuid($uuid)->first(); + if ($serviceDatabase) { + return $serviceDatabase; + } + return $resource; } function generateTagDeployWebhook($tag_name) @@ -2934,6 +2957,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(); @@ -3399,3 +3439,81 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon return true; } + +/** + * 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 + */ +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; +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 1a0ae0fbd..4b84fb7f6 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -13,6 +13,10 @@ function isSubscriptionActive() if (! $team) { return false; } + // Root team (id=0) doesn't require subscription + if ($team->id === 0) { + return true; + } $subscription = $team?->subscription; if (is_null($subscription)) { diff --git a/config/constants.php b/config/constants.php index 07141bd17..499076e85 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.461', '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..c177b79ca 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.461" }, "nightly": { - "version": "4.0.0-beta.460" + "version": "4.0.0-beta.462" }, "helper": { "version": "1.0.12" diff --git a/public/svgs/databasus.svg b/public/svgs/databasus.svg new file mode 100644 index 000000000..f7fb1c849 --- /dev/null +++ b/public/svgs/databasus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/svgs/mage-ai.svg b/public/svgs/mage-ai.svg new file mode 100644 index 000000000..24f4783db --- /dev/null +++ b/public/svgs/mage-ai.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/svgs/postgresus.svg b/public/svgs/postgresus.svg deleted file mode 100644 index a45e81167..000000000 --- a/public/svgs/postgresus.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/svgs/sessy.svg b/public/svgs/sessy.svg new file mode 100644 index 000000000..b82ad1f31 --- /dev/null +++ b/public/svgs/sessy.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + 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/service-database/sidebar.blade.php b/resources/views/components/service-database/sidebar.blade.php new file mode 100644 index 000000000..728df3a7b --- /dev/null +++ b/resources/views/components/service-database/sidebar.blade.php @@ -0,0 +1,24 @@ +@props([ + 'parameters', + 'serviceDatabase', + 'isImportSupported' => false, +]) + +
+ + + + General + @if ($serviceDatabase?->isBackupSolutionAvailable() || $serviceDatabase?->is_migrated) + Backups + @endif + @if ($isImportSupported) + Import Backup + @endif +
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 @@