diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a3bb31cee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization. + +## Development Commands + +### Frontend Development +- `npm run dev` - Start Vite development server for frontend assets +- `npm run build` - Build frontend assets for production + +### Backend Development +- `php artisan serve` - Start Laravel development server +- `php artisan migrate` - Run database migrations +- `php artisan queue:work` - Start queue worker for background jobs +- `php artisan horizon` - Start Laravel Horizon for queue monitoring +- `php artisan tinker` - Start interactive PHP REPL + +### Code Quality +- `./vendor/bin/pint` - Run Laravel Pint for code formatting +- `./vendor/bin/phpstan` - Run PHPStan for static analysis +- `./vendor/bin/pest` - Run Pest tests + +## Architecture Overview + +### Technology Stack +- **Backend**: Laravel 12 (PHP 8.4) +- **Frontend**: Livewire + Alpine.js + Tailwind CSS +- **Database**: PostgreSQL 15 +- **Cache/Queue**: Redis 7 +- **Real-time**: Soketi (WebSocket server) +- **Containerization**: Docker & Docker Compose + +### Key Components + +#### Core Models +- `Application` - Deployed applications with Git integration +- `Server` - Remote servers managed by Coolify +- `Service` - Docker Compose services +- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.) +- `Team` - Multi-tenancy support +- `Project` - Grouping of environments and resources + +#### Job System +- Uses Laravel Horizon for queue management +- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob` +- `ScheduledJobManager` and `ServerResourceManager` handle job scheduling + +#### Deployment Flow +1. Git webhook triggers deployment +2. `ApplicationDeploymentJob` handles build and deployment +3. Docker containers are managed on target servers +4. Proxy configuration (Nginx/Traefik) is updated + +#### Server Management +- SSH-based server communication via `ExecuteRemoteCommand` trait +- Docker installation and management +- Proxy configuration generation +- Resource monitoring and cleanup + +### Directory Structure +- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.) +- `app/Jobs/` - Background queue jobs +- `app/Livewire/` - Frontend components (full-stack with Livewire) +- `app/Models/` - Eloquent models +- `bootstrap/helpers/` - Helper functions for various domains +- `database/migrations/` - Database schema evolution + +## Development Guidelines + +### Code Organization +- Use Actions pattern for complex business logic +- Livewire components handle UI and user interactions +- Jobs handle asynchronous operations +- Traits provide shared functionality (e.g., `ExecuteRemoteCommand`) + +### Testing +- Uses Pest for testing framework +- Tests located in `tests/` directory + +### Deployment and Docker +- Applications are deployed using Docker containers +- Configuration generated dynamically based on application settings +- Supports multiple deployment targets and proxy configurations \ No newline at end of file diff --git a/app/Livewire/Project/Application/Deployment/Index.php b/app/Livewire/Project/Application/Deployment/Index.php index c957615ac..5b621cb95 100644 --- a/app/Livewire/Project/Application/Deployment/Index.php +++ b/app/Livewire/Project/Application/Deployment/Index.php @@ -18,11 +18,13 @@ class Index extends Component public int $skip = 0; - public int $default_take = 10; + public int $defaultTake = 10; - public bool $show_next = false; + public bool $showNext = false; - public bool $show_prev = false; + public bool $showPrev = false; + + public int $currentPage = 1; public ?string $pull_request_id = null; @@ -51,68 +53,111 @@ class Index extends Component if (! $application) { return redirect()->route('dashboard'); } - ['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->default_take); + // Validate pull request ID from URL parameters + if ($this->pull_request_id !== null && $this->pull_request_id !== '') { + if (! is_numeric($this->pull_request_id) || (float) $this->pull_request_id <= 0 || (float) $this->pull_request_id != (int) $this->pull_request_id) { + $this->pull_request_id = null; + $this->dispatch('error', 'Invalid Pull Request ID in URL. Filter cleared.'); + } else { + // Ensure it's stored as a string representation of a positive integer + $this->pull_request_id = (string) (int) $this->pull_request_id; + } + } + + ['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id); $this->application = $application; $this->deployments = $deployments; $this->deployments_count = $count; $this->current_url = url()->current(); - $this->show_pull_request_only(); - $this->show_more(); + $this->updateCurrentPage(); + $this->showMore(); } - private function show_pull_request_only() - { - if ($this->pull_request_id) { - $this->deployments = $this->deployments->where('pull_request_id', $this->pull_request_id); - } - } - - private function show_more() + private function showMore() { if ($this->deployments->count() !== 0) { - $this->show_next = true; - if ($this->deployments->count() < $this->default_take) { - $this->show_next = false; + $this->showNext = true; + if ($this->deployments->count() < $this->defaultTake) { + $this->showNext = false; } return; } } - public function reload_deployments() + public function reloadDeployments() { - $this->load_deployments(); + $this->loadDeployments(); } - public function previous_page(?int $take = null) + public function previousPage(?int $take = null) { if ($take) { $this->skip = $this->skip - $take; } - $this->skip = $this->skip - $this->default_take; + $this->skip = $this->skip - $this->defaultTake; if ($this->skip < 0) { - $this->show_prev = false; + $this->showPrev = false; $this->skip = 0; } - $this->load_deployments(); + $this->updateCurrentPage(); + $this->loadDeployments(); } - public function next_page(?int $take = null) + public function nextPage(?int $take = null) { if ($take) { $this->skip = $this->skip + $take; } - $this->show_prev = true; - $this->load_deployments(); + $this->showPrev = true; + $this->updateCurrentPage(); + $this->loadDeployments(); } - public function load_deployments() + public function loadDeployments() { - ['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->default_take); + ['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id); $this->deployments = $deployments; $this->deployments_count = $count; - $this->show_pull_request_only(); - $this->show_more(); + $this->showMore(); + } + + public function updatedPullRequestId($value) + { + // Sanitize and validate the pull request ID + if ($value !== null && $value !== '') { + // Check if it's numeric and positive + if (! is_numeric($value) || (float) $value <= 0 || (float) $value != (int) $value) { + $this->pull_request_id = null; + $this->dispatch('error', 'Invalid Pull Request ID. Please enter a valid positive number.'); + + return; + } + // Ensure it's stored as a string representation of a positive integer + $this->pull_request_id = (string) (int) $value; + } else { + $this->pull_request_id = null; + } + + // Reset pagination when filter changes + $this->skip = 0; + $this->showPrev = false; + $this->updateCurrentPage(); + $this->loadDeployments(); + } + + public function clearFilter() + { + $this->pull_request_id = null; + $this->skip = 0; + $this->showPrev = false; + $this->updateCurrentPage(); + $this->loadDeployments(); + } + + private function updateCurrentPage() + { + $this->currentPage = intval($this->skip / $this->defaultTake) + 1; } public function render() diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index f96ca9a6a..2f3aae8cf 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -4,6 +4,7 @@ namespace App\Livewire\Project\Database; use App\Models\InstanceSettings; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; @@ -14,7 +15,19 @@ class BackupExecutions extends Component public $database; - public $executions = []; + public ?Collection $executions; + + public int $executions_count = 0; + + public int $skip = 0; + + public int $defaultTake = 10; + + public bool $showNext = false; + + public bool $showPrev = false; + + public int $currentPage = 1; public $setDeletableBackup; @@ -40,6 +53,20 @@ class BackupExecutions extends Component } } + public function cleanupDeleted() + { + if ($this->backup) { + $deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count(); + if ($deletedCount > 0) { + $this->backup->executions()->where('local_storage_deleted', true)->delete(); + $this->refreshBackupExecutions(); + $this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage."); + } else { + $this->dispatch('info', 'No backup entries found that are deleted from local storage.'); + } + } + } + public function deleteBackup($executionId, $password) { if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { @@ -85,18 +112,74 @@ class BackupExecutions extends Component public function refreshBackupExecutions(): void { - if ($this->backup && $this->backup->exists) { - $this->executions = $this->backup->executions()->get()->toArray(); - } else { - $this->executions = []; + $this->loadExecutions(); + } + + public function reloadExecutions() + { + $this->loadExecutions(); + } + + public function previousPage(?int $take = null) + { + if ($take) { + $this->skip = $this->skip - $take; } + $this->skip = $this->skip - $this->defaultTake; + if ($this->skip < 0) { + $this->showPrev = false; + $this->skip = 0; + } + $this->updateCurrentPage(); + $this->loadExecutions(); + } + + public function nextPage(?int $take = null) + { + if ($take) { + $this->skip = $this->skip + $take; + } + $this->showPrev = true; + $this->updateCurrentPage(); + $this->loadExecutions(); + } + + private function loadExecutions() + { + if ($this->backup && $this->backup->exists) { + ['executions' => $executions, 'count' => $count] = $this->backup->executionsPaginated($this->skip, $this->defaultTake); + $this->executions = $executions; + $this->executions_count = $count; + } else { + $this->executions = collect([]); + $this->executions_count = 0; + } + $this->showMore(); + } + + private function showMore() + { + if ($this->executions->count() !== 0) { + $this->showNext = true; + if ($this->executions->count() < $this->defaultTake) { + $this->showNext = false; + } + + return; + } + } + + private function updateCurrentPage() + { + $this->currentPage = intval($this->skip / $this->defaultTake) + 1; } public function mount(ScheduledDatabaseBackup $backup) { $this->backup = $backup; $this->database = $backup->database; - $this->refreshBackupExecutions(); + $this->updateCurrentPage(); + $this->loadExecutions(); } public function server() diff --git a/app/Models/Application.php b/app/Models/Application.php index f3f063d19..2464429e2 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -836,9 +836,14 @@ class Application extends BaseModel return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get(); } - public function deployments(int $skip = 0, int $take = 10) + public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null) { $deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc'); + + if ($pullRequestId) { + $deployments = $deployments->where('pull_request_id', $pullRequestId); + } + $count = $deployments->count(); $deployments = $deployments->skip($skip)->take($take)->get(); diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 473fc7b4b..90204d8df 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -36,6 +36,18 @@ class ScheduledDatabaseBackup extends BaseModel return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); } + public function executionsPaginated(int $skip = 0, int $take = 10) + { + $executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc'); + $count = $executions->count(); + $executions = $executions->skip($skip)->take($take)->get(); + + return [ + 'count' => $count, + 'executions' => $executions, + ]; + } + public function server() { if ($this->database) { diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 096af2878..f8881b736 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -3,31 +3,36 @@

Deployments

-
+

Deployments ({{ $deployments_count }})

@if ($deployments_count > 0) - - - - - - - - - - +
+ + + + + + + Page {{ $currentPage }} of {{ ceil($deployments_count / $defaultTake) }} + + + + + + +
@endif
- @if ($deployments_count > 0) -
- - Filter -
- @endif +
+ + Filter + @if ($pull_request_id) + Clear + @endif +
@forelse ($deployments as $deployment)
@isset($backup)
-

Executions

+

Executions ({{ $executions_count }})

+ @if ($executions_count > 0) +
+ + + + + + + Page {{ $currentPage }} of {{ ceil($executions_count / $defaultTake) }} + + + + + + +
+ @endif Cleanup Failed Backups +
-
+
@forelse($executions as $execution)
scheduledBackups as $backup) @if ($type == 'database') - + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'running', + 'border-error' => + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'failed', + 'border-success' => + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'success', + 'border-gray-200 dark:border-coolgray-300' => !$backup->latest_log, + ]) href="{{ route('project.database.backup.execution', [...$parameters, 'backup_uuid' => $backup->uuid]) }}"> -
-
Frequency: {{ $backup->frequency }} - ({{ data_get($backup->server(), 'settings.server_timezone', 'Instance timezone') }}) + @if ($backup->latest_log && data_get($backup->latest_log, 'status') === 'running') +
+
-
Last backup: {{ data_get($backup->latest_log, 'status', 'No backup yet') }}
+ @endif +
+ @if ($backup->latest_log) + + data_get($backup->latest_log, 'status') === 'running', + 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200 dark:shadow-red-900/5' => + data_get($backup->latest_log, 'status') === 'failed', + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 dark:shadow-green-900/5' => + data_get($backup->latest_log, 'status') === 'success', + ])> + @php + $statusText = match (data_get($backup->latest_log, 'status')) { + 'success' => 'Success', + 'running' => 'In Progress', + 'failed' => 'Failed', + default => ucfirst(data_get($backup->latest_log, 'status')), + }; + @endphp + {{ $statusText }} + + @else + + No executions yet + + @endif +

{{ $backup->frequency }}

+
+
+ @if ($backup->latest_log) + Started: + {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} + @if (data_get($backup->latest_log, 'status') !== 'running') +
Ended: + {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }} +
Duration: + {{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }} +
Finished + {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} + @endif + @if ($backup->save_s3) +
S3 Storage: Enabled + @endif + @if (data_get($backup->latest_log, 'status') === 'success') + @php + $size = data_get($backup->latest_log, 'size', 0); + $sizeFormatted = + $size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown'; + @endphp +
Last Backup Size: {{ $sizeFormatted }} + @endif + @else + Last Run: Never +
Total Executions: 0 + @if ($backup->save_s3) +
S3 Storage: Enabled + @endif + @endif
@else -
-
- data_get($backup, 'id') === data_get($selectedBackup, 'id'), - 'flex flex-col border-l-2 border-transparent', - ])> -
Frequency: {{ $backup->frequency }} - ({{ data_get($backup->server(), 'settings.server_timezone', 'Instance timezone') }}) +
+ data_get($backup, 'id') === data_get($selectedBackup, 'id'), + 'border-blue-500/50 border-dashed' => + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'running', + 'border-error' => + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'failed', + 'border-success' => + $backup->latest_log && + data_get($backup->latest_log, 'status') === 'success', + 'border-gray-200 dark:border-coolgray-300' => !$backup->latest_log, + 'border-coollabs' => + data_get($backup, 'id') === data_get($selectedBackup, 'id'), + ]) wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')"> + @if ($backup->latest_log && data_get($backup->latest_log, 'status') === 'running') +
+
-
Last backup: {{ data_get($backup->latest_log, 'status', 'No backup yet') }}
+ @endif +
+ @if ($backup->latest_log) + + data_get($backup->latest_log, 'status') === 'running', + 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200 dark:shadow-red-900/5' => + data_get($backup->latest_log, 'status') === 'failed', + 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200 dark:shadow-green-900/5' => + data_get($backup->latest_log, 'status') === 'success', + ])> + @php + $statusText = match (data_get($backup->latest_log, 'status')) { + 'success' => 'Success', + 'running' => 'In Progress', + 'failed' => 'Failed', + default => ucfirst(data_get($backup->latest_log, 'status')), + }; + @endphp + {{ $statusText }} + + @else + + No executions yet + + @endif +

{{ $backup->frequency }} Backup

+
+
+ @if ($backup->latest_log) + Started: + {{ formatDateInServerTimezone(data_get($backup->latest_log, 'created_at'), $backup->server()) }} + @if (data_get($backup->latest_log, 'status') !== 'running') +
Ended: + {{ formatDateInServerTimezone(data_get($backup->latest_log, 'finished_at'), $backup->server()) }} +
Duration: + {{ calculateDuration(data_get($backup->latest_log, 'created_at'), data_get($backup->latest_log, 'finished_at')) }} +
Finished + {{ \Carbon\Carbon::parse(data_get($backup->latest_log, 'finished_at'))->diffForHumans() }} + @endif +

Total Executions: {{ $backup->executions()->count() }} + @if ($backup->save_s3) +
S3 Storage: Enabled + @endif + @php + $successCount = $backup->executions()->where('status', 'success')->count(); + $totalCount = $backup->executions()->count(); + $successRate = $totalCount > 0 ? round(($successCount / $totalCount) * 100) : 0; + @endphp + @if ($totalCount > 0) +
Success Rate: $successRate >= 80, + 'text-yellow-600' => $successRate >= 50 && $successRate < 80, + 'text-red-600' => $successRate < 50, + ])>{{ $successRate }}% + ({{ $successCount }}/{{ $totalCount }}) + @endif + @if (data_get($backup->latest_log, 'status') === 'success') + @php + $size = data_get($backup->latest_log, 'size', 0); + $sizeFormatted = + $size > 0 ? number_format($size / 1024 / 1024, 2) . ' MB' : 'Unknown'; + @endphp +
Last Backup Size: {{ $sizeFormatted }} + @endif + @else + Last Run: Never +
Total Executions: 0 + @if ($backup->save_s3) +
S3 Storage: Enabled + @endif + @endif
@endif